kanbango 3.3.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai/lessons.jsonl +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +25 -0
- package/CHANGELOG.md +20 -5
- package/README.md +2 -2
- package/agent-playbook.js +6 -7
- package/bin/kanban.js +49 -14
- package/fenced-text.js +62 -0
- package/index.html +247 -18
- package/kanban.js +2 -81
- package/mcp-server.js +31 -135
- package/package.json +3 -2
- package/tests/run.js +2 -1
- package/.opencode/plans/action-error-recipe.md +0 -121
package/.ai/lessons.jsonl
CHANGED
|
@@ -5,3 +5,4 @@
|
|
|
5
5
|
{"id":"260731-o6g","ts":"2026-07-31","scope":"proj","tags":["kanban","epics","mcp"],"rule":"DO Default list/list_epics hide done+archived; hard-delete cascades epic children; archive is reversible flag","when":"kanban cleanup context","sev":2,"hits":1}
|
|
6
6
|
{"id":"260803-b27","ts":"2026-08-03","scope":"proj","tags":["tests","race","fs"],"rule":"DO Serialize board file mutations with an in-process lock and atomic write (temp+rename/link); race tests should assert…","when":"kanban concurrent IO","sev":2,"hits":1}
|
|
7
7
|
{"id":"260813-9t0","ts":"2026-08-13","scope":"proj","tags":["mcp","errors","dx"],"rule":"DO MCP missing-field errors: put Valid list + Example JSON + sent keys in message so the agent can retry without guessi…","when":"MCP tool validation / agent-facing erro…","sev":2,"hits":1}
|
|
8
|
+
{"id":"260819-ztl","ts":"2026-08-19","scope":"proj","tags":["config","kanban","validation"],"rule":"DO Field-policy config: skip only ENOENT; invalid JSON/shape must throw CONFIG_INVALID, never silently fall back to def…","when":"config load / required fields","sev":2,"hits":1}
|
package/.ai/retro/last-run.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"ts":"2026-08-
|
|
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,14 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [3.
|
|
10
|
+
## [3.4.1] - 2026-08-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- CLI `kanban mcp` / `npx kanbango mcp` starts the MCP server (was `Unknown command: mcp` → client `-32000 Connection closed`)
|
|
14
|
+
|
|
15
|
+
## [3.4.0] - 2026-08-20
|
|
11
16
|
|
|
12
17
|
### Added
|
|
13
|
-
-
|
|
14
|
-
-
|
|
18
|
+
- GUI renders fenced ` ```mermaid ` blocks in task Details and epic info panels (flowchart, sequence, gantt, pie, …)
|
|
19
|
+
- Local Mermaid vendor served from `node_modules` (`/vendor/mermaid.min.js`); dual-export fence parser `fenced-text.js`
|
|
20
|
+
- Soft-fail per diagram: broken Mermaid shows source and does not break the board
|
|
21
|
+
- Dependency `mermaid`; tracked `package-lock.json`
|
|
22
|
+
- Tests: `tests/fenced-text.test.js`, `tests/kanban-mermaid-vendor.test.js`
|
|
23
|
+
- In-process lock + atomic writes for create/update/delete (race-safe board mutations)
|
|
24
|
+
- `.ait-quality.yml` project quality policy (CLI `console.log` treated as UX output)
|
|
15
25
|
|
|
16
|
-
###
|
|
17
|
-
-
|
|
26
|
+
### Changed
|
|
27
|
+
- Edit mode still shows raw mermaid fences (no live preview)
|
|
28
|
+
|
|
29
|
+
## [3.3.0] - 2026-08-19
|
|
30
|
+
|
|
31
|
+
### Note
|
|
32
|
+
- Version 3.3.0 was published on npm; this repo release for Mermaid GUI is **3.4.0**.
|
|
18
33
|
|
|
19
34
|
## [3.2.0] - 2026-07-31
|
|
20
35
|
|
package/README.md
CHANGED
|
@@ -152,7 +152,7 @@ Once connected, your agent gets access to these tools:
|
|
|
152
152
|
| Tool | What it does |
|
|
153
153
|
|------|-------------|
|
|
154
154
|
| `kanban_read` | List/show tasks (`view=summary` by default — cheap) |
|
|
155
|
-
| `kanban_manage` | Create, move, update,
|
|
155
|
+
| `kanban_manage` | Create, move, update, plan_* workflow |
|
|
156
156
|
| `kanban_gui` | Start / status / stop (stop only kills GUI this MCP started) |
|
|
157
157
|
|
|
158
158
|
**Token tip for agents:** rules ship inside MCP tool descriptions (`agent-playbook.js`). Optional: `kanban_read` → `operation: "help"`. Human setup notes: [LLM_AGENTS.md](./LLM_AGENTS.md).
|
|
@@ -169,8 +169,8 @@ Your agent stays in sync with your real board — every change is persisted as J
|
|
|
169
169
|
| `kanban show <ID>` | Show task details |
|
|
170
170
|
| `kanban add <TITLE>` | Add a new task |
|
|
171
171
|
| `kanban move <ID> <COL>` | Move task |
|
|
172
|
+
| `kanban mcp` | Start MCP server (stdio) |
|
|
172
173
|
| `kanban mcp-init` | Generate MCP config files |
|
|
173
|
-
| `kanban comment <ID> <text> [--author NAME]` | Append a comment to a task |
|
|
174
174
|
| `kanban plan <action> --json '{...}'` | Accepted-plan workflow (create/advance/evidence/done/status) |
|
|
175
175
|
|
|
176
176
|
## Web GUI
|
package/agent-playbook.js
CHANGED
|
@@ -10,10 +10,10 @@ const DROP_IN_RULE = [
|
|
|
10
10
|
'- list: col filter, view=summary; keep task_ids; no full-board re-list after writes',
|
|
11
11
|
'- list_epics default = live only (hides done + archived); include_archived / status to widen',
|
|
12
12
|
'- list tasks default hides tasks under done/archived epics; show/show_epic by id always works',
|
|
13
|
-
'- show: view=execution while coding
|
|
13
|
+
'- show: view=execution while coding; full only if needed',
|
|
14
14
|
'- epic_create with description/goals; create tasks with epic=E001 (prefer id)',
|
|
15
15
|
'- create once with description,specs,in_scope,out_of_scope,acceptance_criteria',
|
|
16
|
-
'- move/update/delete: return=none; subtasks=full array replace
|
|
16
|
+
'- move/update/delete: return=none; subtasks=full array replace',
|
|
17
17
|
'- cleanup: delete (task), epic_delete (cascade tasks), epic_archive / epic_unarchive',
|
|
18
18
|
'- non-trivial: plan_create → plan_advance → plan_evidence (real tests, truncated logs) → plan_done',
|
|
19
19
|
'- gui: status before start; stop only owned; external_running = do not kill'
|
|
@@ -23,7 +23,7 @@ const TOOL_DESCRIPTIONS = {
|
|
|
23
23
|
kanban_read: [
|
|
24
24
|
'Read board. TOKEN RULES: list defaults to view=summary (id/title/col/progress only).',
|
|
25
25
|
'Hierarchy: epic (container/context) → task (work) → subtasks (steps).',
|
|
26
|
-
'Always pass col when possible. Prefer show+view=execution over full
|
|
26
|
+
'Always pass col when possible. Prefer show+view=execution over full.',
|
|
27
27
|
'list_epics default live only (empty|planned|active; hides done+archived).',
|
|
28
28
|
'Pass include_archived=true or status=done|archived to see closed initiatives.',
|
|
29
29
|
'list hides tasks under done/archived epics unless include_archived/include_done; show/show_epic by id always works.',
|
|
@@ -36,13 +36,13 @@ const TOOL_DESCRIPTIONS = {
|
|
|
36
36
|
'Write board / plan. TOKEN RULES: one create with all planning fields beats many updates;',
|
|
37
37
|
'after write use return=none (or summary). Do not dump full task unless needed.',
|
|
38
38
|
'Actions: create|move|update|delete (daily); epic_create|epic_update|epic_archive|epic_unarchive|epic_delete;',
|
|
39
|
-
'
|
|
39
|
+
'plan_create→plan_advance→plan_evidence→plan_done (non-trivial only).',
|
|
40
40
|
'epic_create: title + description/goals/in_scope/out_of_scope. Link tasks via epic=E001.',
|
|
41
41
|
'delete: task_id (hard remove). epic_delete: epic_id, always cascades child tasks.',
|
|
42
42
|
'epic_archive/epic_unarchive: epic_id — hide/restore initiative without deleting history.',
|
|
43
43
|
'create/plan_create: title required; also send description,specs,in_scope,out_of_scope,acceptance_criteria',
|
|
44
44
|
'(missing → warnings, not failure). move: task_id+column. update: task_id + fields or subtasks[] full list',
|
|
45
|
-
'(no toggle).
|
|
45
|
+
'(no toggle). plan_evidence needs real test run: diff,test_command,stdout,stderr,exit_code — truncate logs.',
|
|
46
46
|
'Example create: {"action":"create","title":"Ship image","epic":"E001","description":"...","specs":"...",',
|
|
47
47
|
'"in_scope":["CLI"],"out_of_scope":["GUI"],"acceptance_criteria":["npm test passes"],"col":"planned"}'
|
|
48
48
|
].join(' '),
|
|
@@ -63,8 +63,7 @@ const MUST_CONTAIN = [
|
|
|
63
63
|
'subtasks',
|
|
64
64
|
'epic_create',
|
|
65
65
|
'epic_archive',
|
|
66
|
-
'epic_delete'
|
|
67
|
-
'comment_add'
|
|
66
|
+
'epic_delete'
|
|
68
67
|
];
|
|
69
68
|
|
|
70
69
|
function playbookHelpPayload() {
|
package/bin/kanban.js
CHANGED
|
@@ -336,6 +336,46 @@ function injectProjectIntoHtml(html, project) {
|
|
|
336
336
|
.replace(/<h1>Donna Kanban<\/h1>/, `<h1>${safe}</h1>`);
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/** Allowlisted GUI vendor files only (no open node_modules). */
|
|
340
|
+
const VENDOR_FILES = {
|
|
341
|
+
'/vendor/fenced-text.js': {
|
|
342
|
+
filePath: path.join(__dirname, '..', 'fenced-text.js'),
|
|
343
|
+
contentType: 'application/javascript; charset=utf-8'
|
|
344
|
+
},
|
|
345
|
+
'/vendor/mermaid.min.js': {
|
|
346
|
+
filePath: path.join(__dirname, '..', 'node_modules', 'mermaid', 'dist', 'mermaid.min.js'),
|
|
347
|
+
contentType: 'application/javascript; charset=utf-8'
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
function sendVendorFile(res, requestPath) {
|
|
352
|
+
const entry = VENDOR_FILES[requestPath];
|
|
353
|
+
if (!entry) return false;
|
|
354
|
+
if (!fs.existsSync(entry.filePath)) {
|
|
355
|
+
sendJson(res, 404, {
|
|
356
|
+
error: {
|
|
357
|
+
code: 'VENDOR_MISSING',
|
|
358
|
+
message: `Vendor file missing: ${requestPath}`,
|
|
359
|
+
hint: 'Run npm install in the kanbango package root'
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
res.writeHead(200, {
|
|
365
|
+
'Content-Type': entry.contentType,
|
|
366
|
+
'Cache-Control': 'public, max-age=3600'
|
|
367
|
+
});
|
|
368
|
+
fs.createReadStream(entry.filePath).pipe(res);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function logGuiPortCleanupError(err) {
|
|
373
|
+
// Port file may already be gone on shutdown — do not block exit.
|
|
374
|
+
if (err && err.code !== 'ENOENT') {
|
|
375
|
+
console.error('gui port cleanup:', err.message || err);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
339
379
|
async function serveWeb(port) {
|
|
340
380
|
const htmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
|
|
341
381
|
const project = guiRegistry.projectLabel();
|
|
@@ -352,6 +392,10 @@ async function serveWeb(port) {
|
|
|
352
392
|
return;
|
|
353
393
|
}
|
|
354
394
|
|
|
395
|
+
if (req.method === 'GET' && sendVendorFile(res, requestPath)) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
355
399
|
if (requestPath === '/api/board') {
|
|
356
400
|
await kanban.migrateEpicGroups();
|
|
357
401
|
const includeArchived = url.searchParams.get('include_archived') === 'true';
|
|
@@ -579,8 +623,8 @@ async function serveWeb(port) {
|
|
|
579
623
|
async function cleanupGuiPortFile() {
|
|
580
624
|
try {
|
|
581
625
|
await guiRegistry.clearGuiPortFile({ pid: process.pid });
|
|
582
|
-
} catch {
|
|
583
|
-
|
|
626
|
+
} catch (err) {
|
|
627
|
+
logGuiPortCleanupError(err);
|
|
584
628
|
}
|
|
585
629
|
}
|
|
586
630
|
|
|
@@ -705,6 +749,9 @@ async function main() {
|
|
|
705
749
|
}
|
|
706
750
|
|
|
707
751
|
await cliMcpInit({ useNpx, onlyClaude, onlyOpenCode, force });
|
|
752
|
+
} else if (cmd === 'mcp') {
|
|
753
|
+
const mcpServer = require('../mcp-server.js');
|
|
754
|
+
await mcpServer.main();
|
|
708
755
|
} else if (cmd === 'migrate') {
|
|
709
756
|
const dryRun = args.includes('--dry-run');
|
|
710
757
|
await cliMigrate(dryRun);
|
|
@@ -789,18 +836,6 @@ async function main() {
|
|
|
789
836
|
await cliAdd(args[1], column, epicGroup);
|
|
790
837
|
} else if (cmd === 'plan' && args[1] && args[2] === '--json') {
|
|
791
838
|
await cliPlan(args[1], parseJsonPayload(args[3]));
|
|
792
|
-
} else if (cmd === 'comment' && args[1] && args[2]) {
|
|
793
|
-
let author = 'user';
|
|
794
|
-
for (let i = 3; i < args.length; i++) {
|
|
795
|
-
if (args[i] === '--author' && args[i + 1]) author = args[++i];
|
|
796
|
-
}
|
|
797
|
-
try {
|
|
798
|
-
const added = await kanban.addComment(args[1], args[2], author);
|
|
799
|
-
console.log(JSON.stringify({ ok: true, task_id: added.task_id, comment_id: added.comment.id, count: added.comments.length }));
|
|
800
|
-
} catch (error) {
|
|
801
|
-
console.error(`✗ ${error.message}`);
|
|
802
|
-
process.exit(1);
|
|
803
|
-
}
|
|
804
839
|
} else {
|
|
805
840
|
console.error('Unknown command:', cmd);
|
|
806
841
|
process.exit(1);
|
package/fenced-text.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split plain text into text / mermaid fence segments.
|
|
3
|
+
* Dual export: CommonJS (Node tests) + browser global FencedText.
|
|
4
|
+
*/
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
if (typeof module === 'object' && module.exports) {
|
|
7
|
+
module.exports = factory();
|
|
8
|
+
} else {
|
|
9
|
+
root.FencedText = factory();
|
|
10
|
+
}
|
|
11
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// ```mermaid ... ``` (language case-insensitive). CRLF ok. Other fences stay plain text.
|
|
15
|
+
var FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)^```[ \t]*\r?$/gm;
|
|
16
|
+
|
|
17
|
+
function parseFencedText(text) {
|
|
18
|
+
var source = text == null ? '' : String(text);
|
|
19
|
+
if (!source) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
var segments = [];
|
|
24
|
+
var lastIndex = 0;
|
|
25
|
+
var match;
|
|
26
|
+
FENCE_RE.lastIndex = 0;
|
|
27
|
+
|
|
28
|
+
while ((match = FENCE_RE.exec(source)) !== null) {
|
|
29
|
+
if (match.index > lastIndex) {
|
|
30
|
+
segments.push({ type: 'text', value: source.slice(lastIndex, match.index) });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var lang = String(match[1] || '').trim().toLowerCase();
|
|
34
|
+
// Strip optional info string after language (e.g. mermaid title="x")
|
|
35
|
+
if (lang.indexOf(' ') !== -1) {
|
|
36
|
+
lang = lang.split(/\s+/)[0];
|
|
37
|
+
}
|
|
38
|
+
var body = match[2];
|
|
39
|
+
if (lang === 'mermaid') {
|
|
40
|
+
segments.push({ type: 'mermaid', value: body.replace(/\s+$/, '') });
|
|
41
|
+
} else {
|
|
42
|
+
segments.push({ type: 'text', value: match[0] });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
lastIndex = match.index + match[0].length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (lastIndex < source.length) {
|
|
49
|
+
segments.push({ type: 'text', value: source.slice(lastIndex) });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (segments.length === 0) {
|
|
53
|
+
segments.push({ type: 'text', value: source });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return segments;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
parseFencedText: parseFencedText
|
|
61
|
+
};
|
|
62
|
+
});
|
package/index.html
CHANGED
|
@@ -614,6 +614,45 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
614
614
|
color: var(--text);
|
|
615
615
|
white-space: pre-wrap;
|
|
616
616
|
}
|
|
617
|
+
.detail-text .md-text {
|
|
618
|
+
white-space: pre-wrap;
|
|
619
|
+
}
|
|
620
|
+
.md-mermaid {
|
|
621
|
+
margin: 8px 0;
|
|
622
|
+
padding: 10px;
|
|
623
|
+
background: #f8fafc;
|
|
624
|
+
border: 1px solid var(--border);
|
|
625
|
+
border-radius: var(--radius);
|
|
626
|
+
overflow-x: auto;
|
|
627
|
+
text-align: center;
|
|
628
|
+
}
|
|
629
|
+
.md-mermaid svg { max-width: 100%; height: auto; }
|
|
630
|
+
.md-mermaid-error {
|
|
631
|
+
margin: 8px 0;
|
|
632
|
+
padding: 8px 10px;
|
|
633
|
+
background: #fef2f2;
|
|
634
|
+
border: 1px solid #fecaca;
|
|
635
|
+
border-radius: var(--radius);
|
|
636
|
+
text-align: left;
|
|
637
|
+
}
|
|
638
|
+
.md-mermaid-error .md-err-label {
|
|
639
|
+
font-size: 10px;
|
|
640
|
+
font-weight: 700;
|
|
641
|
+
text-transform: uppercase;
|
|
642
|
+
letter-spacing: 0.5px;
|
|
643
|
+
color: #b91c1c;
|
|
644
|
+
margin-bottom: 6px;
|
|
645
|
+
}
|
|
646
|
+
.md-mermaid-error pre {
|
|
647
|
+
margin: 0;
|
|
648
|
+
font-size: 11px;
|
|
649
|
+
line-height: 1.4;
|
|
650
|
+
white-space: pre-wrap;
|
|
651
|
+
word-break: break-word;
|
|
652
|
+
color: var(--text);
|
|
653
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
654
|
+
}
|
|
655
|
+
.sl-epic-text .md-mermaid { margin: 6px 0 0; }
|
|
617
656
|
.detail-list {
|
|
618
657
|
margin: 0;
|
|
619
658
|
padding-left: 18px;
|
|
@@ -777,6 +816,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
777
816
|
|
|
778
817
|
<div class="toast" id="toast"></div>
|
|
779
818
|
|
|
819
|
+
<script src="/vendor/fenced-text.js"></script>
|
|
820
|
+
<script src="/vendor/mermaid.min.js"></script>
|
|
780
821
|
<script>
|
|
781
822
|
const COL_DEFS = [
|
|
782
823
|
{ id: "icebox", label: "🧊 Icebox", icon: "🧊" },
|
|
@@ -877,6 +918,7 @@ function render() {
|
|
|
877
918
|
const sl = document.getElementById("swimlanes");
|
|
878
919
|
sl.innerHTML = "";
|
|
879
920
|
groupTasks(allTasks).forEach(g => sl.appendChild(renderSwimlane(g)));
|
|
921
|
+
queueMermaidRun();
|
|
880
922
|
}
|
|
881
923
|
|
|
882
924
|
function groupTasks(tasks) {
|
|
@@ -924,11 +966,14 @@ function renderEpicDetailView(entity) {
|
|
|
924
966
|
|
|
925
967
|
const hero = el("div", "sl-epic-hero");
|
|
926
968
|
const goals = el("div", "sl-epic-goals");
|
|
927
|
-
goals.
|
|
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
|
@@ -41,8 +41,7 @@ const VIEW_FIELDS = {
|
|
|
41
41
|
'out_of_scope',
|
|
42
42
|
'acceptance_criteria',
|
|
43
43
|
'test_cases',
|
|
44
|
-
'subtasks'
|
|
45
|
-
'comments'
|
|
44
|
+
'subtasks'
|
|
46
45
|
],
|
|
47
46
|
full: [
|
|
48
47
|
'task_number',
|
|
@@ -59,8 +58,7 @@ const VIEW_FIELDS = {
|
|
|
59
58
|
'acceptance_criteria',
|
|
60
59
|
'test_cases',
|
|
61
60
|
'subtasks',
|
|
62
|
-
'notes'
|
|
63
|
-
'comments'
|
|
61
|
+
'notes'
|
|
64
62
|
]
|
|
65
63
|
};
|
|
66
64
|
|
|
@@ -255,28 +253,6 @@ function normalizeSubtasks(value) {
|
|
|
255
253
|
})).filter((subtask) => subtask.text);
|
|
256
254
|
}
|
|
257
255
|
|
|
258
|
-
function nowIso() {
|
|
259
|
-
return new Date().toISOString();
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function nextCommentId(comments) {
|
|
263
|
-
const max = comments.reduce((highest, comment) => {
|
|
264
|
-
const match = String(comment && comment.id || '').match(/^c-(\d+)$/i);
|
|
265
|
-
return match ? Math.max(highest, parseInt(match[1], 10)) : highest;
|
|
266
|
-
}, 0);
|
|
267
|
-
return `c-${max + 1}`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function normalizeComments(value) {
|
|
271
|
-
if (!Array.isArray(value)) return [];
|
|
272
|
-
return value.map((item, idx) => ({
|
|
273
|
-
id: normalizeString(item && item.id, `c-${idx + 1}`),
|
|
274
|
-
created: normalizeString(item && item.created) || nowIso(),
|
|
275
|
-
author: normalizeString(item && item.author, 'user') || 'user',
|
|
276
|
-
text: normalizeString(item && item.text)
|
|
277
|
-
})).filter((item) => item.text);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
256
|
function normalizeEvidence(value) {
|
|
281
257
|
if (!Array.isArray(value)) return [];
|
|
282
258
|
return value.map((item) => ({
|
|
@@ -316,7 +292,6 @@ function normalizeTask(task) {
|
|
|
316
292
|
test_cases: normalizeStringArray(task.test_cases),
|
|
317
293
|
subtasks: normalizeSubtasks(task.subtasks),
|
|
318
294
|
notes: normalizeString(task.notes),
|
|
319
|
-
comments: normalizeComments(task.comments),
|
|
320
295
|
plan: normalizePlan(task.plan),
|
|
321
296
|
evidence: normalizeEvidence(task.evidence),
|
|
322
297
|
task_number: extractTaskNumber(id)
|
|
@@ -342,7 +317,6 @@ function serializeTask(task) {
|
|
|
342
317
|
test_cases: normalized.test_cases,
|
|
343
318
|
subtasks: normalized.subtasks,
|
|
344
319
|
notes: normalized.notes,
|
|
345
|
-
comments: normalized.comments,
|
|
346
320
|
plan: normalized.plan,
|
|
347
321
|
evidence: normalized.evidence,
|
|
348
322
|
task_number: normalized.task_number
|
|
@@ -1341,7 +1315,6 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
|
|
|
1341
1315
|
test_cases: extra.test_cases,
|
|
1342
1316
|
subtasks: extra.subtasks,
|
|
1343
1317
|
notes: extra.notes,
|
|
1344
|
-
comments: extra.comments,
|
|
1345
1318
|
plan: extra.plan,
|
|
1346
1319
|
evidence: extra.evidence
|
|
1347
1320
|
});
|
|
@@ -1488,19 +1461,6 @@ async function updateTaskRecord(taskId, patch) {
|
|
|
1488
1461
|
next.subtasks = patch.subtasks;
|
|
1489
1462
|
}
|
|
1490
1463
|
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
1491
|
-
if (patch.comments !== undefined) {
|
|
1492
|
-
if (!Array.isArray(patch.comments)) {
|
|
1493
|
-
throw createKanbanError(
|
|
1494
|
-
'VALIDATION_ERROR',
|
|
1495
|
-
'comments must be an array',
|
|
1496
|
-
'Send comments as an array of comment objects',
|
|
1497
|
-
{ field: 'comments' },
|
|
1498
|
-
false,
|
|
1499
|
-
400
|
|
1500
|
-
);
|
|
1501
|
-
}
|
|
1502
|
-
next.comments = patch.comments;
|
|
1503
|
-
}
|
|
1504
1464
|
if (patch.plan !== undefined) next.plan = patch.plan;
|
|
1505
1465
|
if (patch.evidence !== undefined) {
|
|
1506
1466
|
if (!Array.isArray(patch.evidence)) {
|
|
@@ -1524,44 +1484,6 @@ async function updateTask(taskId, patch) {
|
|
|
1524
1484
|
return withBoardLock(() => updateTaskRecord(taskId, patch));
|
|
1525
1485
|
}
|
|
1526
1486
|
|
|
1527
|
-
async function addComment(taskId, text, author = 'user') {
|
|
1528
|
-
const body = normalizeString(text);
|
|
1529
|
-
if (!body) {
|
|
1530
|
-
throw createKanbanError(
|
|
1531
|
-
'MISSING_REQUIRED_FIELD',
|
|
1532
|
-
'text is required',
|
|
1533
|
-
'Provide a non-empty comment text',
|
|
1534
|
-
{ field: 'text' },
|
|
1535
|
-
false,
|
|
1536
|
-
400
|
|
1537
|
-
);
|
|
1538
|
-
}
|
|
1539
|
-
return withBoardLock(async () => {
|
|
1540
|
-
const resolvedId = await resolveTaskId(taskId);
|
|
1541
|
-
const previousFilePath = await findFile(resolvedId);
|
|
1542
|
-
if (!previousFilePath) {
|
|
1543
|
-
throw createKanbanError(
|
|
1544
|
-
'TASK_NOT_FOUND',
|
|
1545
|
-
`Task ${taskId} was not found`,
|
|
1546
|
-
'Call kanban_read with operation=list to discover valid task ids',
|
|
1547
|
-
{ task_id: taskId },
|
|
1548
|
-
false,
|
|
1549
|
-
404
|
|
1550
|
-
);
|
|
1551
|
-
}
|
|
1552
|
-
const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
|
|
1553
|
-
const comments = normalizeComments(current.comments);
|
|
1554
|
-
const comment = {
|
|
1555
|
-
id: nextCommentId(comments),
|
|
1556
|
-
created: nowIso(),
|
|
1557
|
-
author: normalizeString(author, 'user') || 'user',
|
|
1558
|
-
text: body
|
|
1559
|
-
};
|
|
1560
|
-
const saved = await writeTask({ ...current, comments: [...comments, comment] }, previousFilePath);
|
|
1561
|
-
return { comment, comments: saved.comments, task_id: saved.id };
|
|
1562
|
-
});
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
1487
|
async function doMove(epicId, target) {
|
|
1566
1488
|
try {
|
|
1567
1489
|
await updateTask(epicId, { column: target });
|
|
@@ -1627,7 +1549,6 @@ module.exports = {
|
|
|
1627
1549
|
shapeTask,
|
|
1628
1550
|
shapeEpic,
|
|
1629
1551
|
updateTask,
|
|
1630
|
-
addComment,
|
|
1631
1552
|
migrateAll,
|
|
1632
1553
|
migrateEpicGroups,
|
|
1633
1554
|
doMove,
|
package/mcp-server.js
CHANGED
|
@@ -76,101 +76,6 @@ function invalidRequest(message, hint, details) {
|
|
|
76
76
|
return kanban.createKanbanError('VALIDATION_ERROR', message, hint, details, false, 400);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const MANAGE_ACTIONS = [
|
|
80
|
-
'create',
|
|
81
|
-
'move',
|
|
82
|
-
'update',
|
|
83
|
-
'delete',
|
|
84
|
-
'epic_create',
|
|
85
|
-
'epic_update',
|
|
86
|
-
'epic_archive',
|
|
87
|
-
'epic_unarchive',
|
|
88
|
-
'epic_delete',
|
|
89
|
-
'plan_create',
|
|
90
|
-
'plan_advance',
|
|
91
|
-
'plan_evidence',
|
|
92
|
-
'plan_done',
|
|
93
|
-
'plan_status',
|
|
94
|
-
'comment_add'
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
const GUI_ACTIONS = ['start', 'stop', 'status'];
|
|
98
|
-
|
|
99
|
-
const ACTION_EXAMPLES = {
|
|
100
|
-
kanban_manage: '{"action":"create","title":"Ship image","col":"planned"}',
|
|
101
|
-
kanban_gui: '{"action":"status"}'
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
function receivedKeys(args) {
|
|
105
|
-
return Object.keys(args && typeof args === 'object' ? args : {});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function actionRecipeMessage(toolName, allowed, opts = {}) {
|
|
109
|
-
const keys = opts.received_keys || [];
|
|
110
|
-
const example = ACTION_EXAMPLES[toolName] || `{"action":"${allowed[0]}"}`;
|
|
111
|
-
const lines = [];
|
|
112
|
-
|
|
113
|
-
if (opts.kind === 'unknown') {
|
|
114
|
-
lines.push(`Unknown action "${opts.action}" on ${toolName}.`);
|
|
115
|
-
} else {
|
|
116
|
-
lines.push(`Missing required top-level field "action" on ${toolName}.`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
lines.push('Pass action next to other args (not nested under params).');
|
|
120
|
-
lines.push(`Valid: ${allowed.join(', ')}.`);
|
|
121
|
-
lines.push(`Example: ${example}`);
|
|
122
|
-
lines.push(keys.length > 0 ? `You sent keys: ${keys.join(', ')}` : 'You sent keys: (none)');
|
|
123
|
-
return lines.join(' ');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function actionRecipeHint() {
|
|
127
|
-
return [
|
|
128
|
-
'Retry the same tool with top-level action set to one Valid value.',
|
|
129
|
-
'Common mistake: omitting action, or putting it under params (jira/gitlab style) — kanban uses top-level action.'
|
|
130
|
-
].join(' ');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function requireToolAction(args, allowed, toolName) {
|
|
134
|
-
const action = args && args.action;
|
|
135
|
-
const keys = receivedKeys(args);
|
|
136
|
-
if (action === undefined || action === null || action === '') {
|
|
137
|
-
throw kanban.createKanbanError(
|
|
138
|
-
'MISSING_REQUIRED_FIELD',
|
|
139
|
-
actionRecipeMessage(toolName, allowed, {
|
|
140
|
-
kind: 'missing',
|
|
141
|
-
received_keys: keys,
|
|
142
|
-
action: action === undefined ? null : action
|
|
143
|
-
}),
|
|
144
|
-
actionRecipeHint(),
|
|
145
|
-
{
|
|
146
|
-
field: 'action',
|
|
147
|
-
tool: toolName,
|
|
148
|
-
received_keys: keys,
|
|
149
|
-
allowed_actions: allowed,
|
|
150
|
-
action: action === undefined ? null : action
|
|
151
|
-
},
|
|
152
|
-
false,
|
|
153
|
-
400
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
return action;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function unknownToolAction(action, args, allowed, toolName) {
|
|
160
|
-
const keys = receivedKeys(args);
|
|
161
|
-
return invalidRequest(
|
|
162
|
-
actionRecipeMessage(toolName, allowed, { kind: 'unknown', action, received_keys: keys }),
|
|
163
|
-
actionRecipeHint(),
|
|
164
|
-
{
|
|
165
|
-
field: 'action',
|
|
166
|
-
tool: toolName,
|
|
167
|
-
action,
|
|
168
|
-
received_keys: keys,
|
|
169
|
-
allowed_actions: allowed
|
|
170
|
-
}
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
79
|
function serializeResult(result) {
|
|
175
80
|
if (typeof result === 'string') return result;
|
|
176
81
|
|
|
@@ -465,8 +370,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
465
370
|
properties: {
|
|
466
371
|
action: {
|
|
467
372
|
type: 'string',
|
|
468
|
-
enum:
|
|
469
|
-
|
|
373
|
+
enum: [
|
|
374
|
+
'create',
|
|
375
|
+
'move',
|
|
376
|
+
'update',
|
|
377
|
+
'delete',
|
|
378
|
+
'epic_create',
|
|
379
|
+
'epic_update',
|
|
380
|
+
'epic_archive',
|
|
381
|
+
'epic_unarchive',
|
|
382
|
+
'epic_delete',
|
|
383
|
+
'plan_create',
|
|
384
|
+
'plan_advance',
|
|
385
|
+
'plan_evidence',
|
|
386
|
+
'plan_done',
|
|
387
|
+
'plan_status'
|
|
388
|
+
],
|
|
389
|
+
description: 'create|move|update|delete daily; epic_create|epic_update|epic_archive|epic_unarchive|epic_delete; plan_* multi-step'
|
|
470
390
|
},
|
|
471
391
|
title: {
|
|
472
392
|
type: 'string',
|
|
@@ -536,17 +456,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
536
456
|
type: 'string',
|
|
537
457
|
description: 'Freeform notes'
|
|
538
458
|
},
|
|
539
|
-
text: {
|
|
540
|
-
type: 'string',
|
|
541
|
-
description: 'Required for comment_add. Non-empty comment body.'
|
|
542
|
-
},
|
|
543
|
-
author: {
|
|
544
|
-
type: 'string',
|
|
545
|
-
description: 'Optional comment author (default user). e.g. kocur-reviewer, worker'
|
|
546
|
-
},
|
|
547
459
|
task_id: {
|
|
548
460
|
type: 'string',
|
|
549
|
-
description: "Required for move/update/delete/
|
|
461
|
+
description: "Required for move/update/delete/plan_* except plan_create. '014' or '14'."
|
|
550
462
|
},
|
|
551
463
|
column: {
|
|
552
464
|
type: 'string',
|
|
@@ -608,7 +520,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
608
520
|
properties: {
|
|
609
521
|
action: {
|
|
610
522
|
type: 'string',
|
|
611
|
-
enum:
|
|
523
|
+
enum: ['start', 'stop', 'status'],
|
|
612
524
|
description: 'start | stop (owned only) | status'
|
|
613
525
|
},
|
|
614
526
|
port: {
|
|
@@ -714,7 +626,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
714
626
|
}
|
|
715
627
|
|
|
716
628
|
case 'kanban_manage': {
|
|
717
|
-
const action =
|
|
629
|
+
const action = args.action;
|
|
718
630
|
const returnShape = normalizeReturnShape(args.return);
|
|
719
631
|
|
|
720
632
|
switch (action) {
|
|
@@ -912,38 +824,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
912
824
|
result = await plan.status(args.task_id);
|
|
913
825
|
break;
|
|
914
826
|
|
|
915
|
-
case 'comment_add': {
|
|
916
|
-
if (!args.task_id) {
|
|
917
|
-
throw invalidRequest(
|
|
918
|
-
"task_id is required for 'comment_add'",
|
|
919
|
-
'Provide a task ID',
|
|
920
|
-
{ action }
|
|
921
|
-
);
|
|
922
|
-
}
|
|
923
|
-
if (!args.text) {
|
|
924
|
-
throw invalidRequest(
|
|
925
|
-
"text is required for 'comment_add'",
|
|
926
|
-
'Provide a non-empty comment body',
|
|
927
|
-
{ action }
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
const added = await kanban.addComment(args.task_id, args.text, args.author);
|
|
931
|
-
result = returnShape === 'none'
|
|
932
|
-
? { ok: true, comment_id: added.comment.id }
|
|
933
|
-
: returnShape === 'full'
|
|
934
|
-
? added
|
|
935
|
-
: { ok: true, task_id: added.task_id, comment_id: added.comment.id, count: added.comments.length };
|
|
936
|
-
break;
|
|
937
|
-
}
|
|
938
|
-
|
|
939
827
|
default:
|
|
940
|
-
throw
|
|
828
|
+
throw invalidRequest(
|
|
829
|
+
`Unknown action: ${action}`,
|
|
830
|
+
'Use create, move, update, delete, epic_create, epic_update, epic_archive, epic_unarchive, epic_delete, plan_create, plan_advance, plan_evidence, plan_done, or plan_status',
|
|
831
|
+
{ action }
|
|
832
|
+
);
|
|
941
833
|
}
|
|
942
834
|
break;
|
|
943
835
|
}
|
|
944
836
|
|
|
945
837
|
case 'kanban_gui': {
|
|
946
|
-
const action =
|
|
838
|
+
const action = args.action;
|
|
947
839
|
|
|
948
840
|
switch (action) {
|
|
949
841
|
case 'start': {
|
|
@@ -959,7 +851,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
959
851
|
break;
|
|
960
852
|
}
|
|
961
853
|
default:
|
|
962
|
-
throw
|
|
854
|
+
throw invalidRequest(
|
|
855
|
+
`Unknown action: ${action}`,
|
|
856
|
+
'Use one of: start, stop, status',
|
|
857
|
+
{ action }
|
|
858
|
+
);
|
|
963
859
|
}
|
|
964
860
|
break;
|
|
965
861
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kanbango",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
4
4
|
"description": "JSON-first local Kanban board with web GUI, CLI, and MCP server",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"node": ">=16.0.0"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@modelcontextprotocol/sdk": "^1.0.4"
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
40
|
+
"mermaid": "^11.17.0"
|
|
40
41
|
}
|
|
41
42
|
}
|
package/tests/run.js
CHANGED
|
@@ -15,10 +15,11 @@ function runNode(scriptPath, args, label) {
|
|
|
15
15
|
|
|
16
16
|
runNode(path.join('bin', 'kanban.js'), ['list', '--json'], 'CLI list');
|
|
17
17
|
runNode(path.join('tests', 'update-tasks.test.js'), [], 'Update tasks test');
|
|
18
|
-
runNode(path.join('tests', 'comments.test.js'), [], 'Comments test');
|
|
19
18
|
runNode(path.join('tests', 'read-views.test.js'), [], 'Read views test');
|
|
20
19
|
runNode(path.join('tests', 'mcp-server.test.js'), [], 'MCP server test');
|
|
21
20
|
runNode(path.join('tests', 'gui-port.test.js'), [], 'GUI port test');
|
|
21
|
+
runNode(path.join('tests', 'fenced-text.test.js'), [], 'Fenced text test');
|
|
22
|
+
runNode(path.join('tests', 'kanban-mermaid-vendor.test.js'), [], 'Kanban mermaid vendor route test');
|
|
22
23
|
runNode(path.join('tests', 'plan-workflow.test.js'), [], 'Plan workflow test');
|
|
23
24
|
runNode(path.join('tests', 'agent-playbook.test.js'), [], 'Agent playbook test');
|
|
24
25
|
runNode(path.join('tests', 'epics.test.js'), [], 'Epics test');
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
# Action error recipe (implement)
|
|
2
|
-
|
|
3
|
-
Status: approved (user said go). Plan mode blocked code edits — switch to **build** and apply.
|
|
4
|
-
|
|
5
|
-
## Goal
|
|
6
|
-
Missing/empty/unknown `action` on `kanban_manage` / `kanban_gui`: **message** is a full retry recipe (Valid + Example JSON + sent keys).
|
|
7
|
-
|
|
8
|
-
## Files
|
|
9
|
-
1. `mcp-server.js` — replace `requireToolAction`; add helpers; unknown-action defaults
|
|
10
|
-
2. `tests/mcp-server.test.js` — assert recipe content
|
|
11
|
-
3. `CHANGELOG.md` — one-line unreleased note
|
|
12
|
-
|
|
13
|
-
## mcp-server.js
|
|
14
|
-
|
|
15
|
-
Replace current `requireToolAction` block (after `GUI_ACTIONS`) with:
|
|
16
|
-
|
|
17
|
-
```js
|
|
18
|
-
const ACTION_EXAMPLES = {
|
|
19
|
-
kanban_manage: '{"action":"create","title":"Ship image","col":"planned"}',
|
|
20
|
-
kanban_gui: '{"action":"status"}'
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
function receivedKeys(args) {
|
|
24
|
-
return Object.keys(args && typeof args === 'object' ? args : {});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function actionRecipeMessage(toolName, allowed, opts = {}) {
|
|
28
|
-
const keys = opts.received_keys || [];
|
|
29
|
-
const example = ACTION_EXAMPLES[toolName] || `{"action":"${allowed[0]}"}`;
|
|
30
|
-
const lines = [];
|
|
31
|
-
if (opts.kind === 'unknown') {
|
|
32
|
-
lines.push(`Unknown action "${opts.action}" on ${toolName}.`);
|
|
33
|
-
} else {
|
|
34
|
-
lines.push(`Missing required top-level field "action" on ${toolName}.`);
|
|
35
|
-
}
|
|
36
|
-
lines.push('Pass action next to other args (not nested under params).');
|
|
37
|
-
lines.push(`Valid: ${allowed.join(', ')}.`);
|
|
38
|
-
lines.push(`Example: ${example}`);
|
|
39
|
-
lines.push(keys.length > 0 ? `You sent keys: ${keys.join(', ')}` : 'You sent keys: (none)');
|
|
40
|
-
return lines.join(' ');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function actionRecipeHint() {
|
|
44
|
-
return [
|
|
45
|
-
'Retry the same tool with top-level action set to one Valid value.',
|
|
46
|
-
'Common mistake: omitting action, or putting it under params (jira/gitlab style) — kanban uses top-level action.'
|
|
47
|
-
].join(' ');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function requireToolAction(args, allowed, toolName) {
|
|
51
|
-
const action = args && args.action;
|
|
52
|
-
const keys = receivedKeys(args);
|
|
53
|
-
if (action === undefined || action === null || action === '') {
|
|
54
|
-
throw kanban.createKanbanError(
|
|
55
|
-
'MISSING_REQUIRED_FIELD',
|
|
56
|
-
actionRecipeMessage(toolName, allowed, {
|
|
57
|
-
kind: 'missing',
|
|
58
|
-
received_keys: keys,
|
|
59
|
-
action: action === undefined ? null : action
|
|
60
|
-
}),
|
|
61
|
-
actionRecipeHint(),
|
|
62
|
-
{
|
|
63
|
-
field: 'action',
|
|
64
|
-
tool: toolName,
|
|
65
|
-
received_keys: keys,
|
|
66
|
-
allowed_actions: allowed,
|
|
67
|
-
action: action === undefined ? null : action
|
|
68
|
-
},
|
|
69
|
-
false,
|
|
70
|
-
400
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
return action;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function unknownToolAction(action, args, allowed, toolName) {
|
|
77
|
-
const keys = receivedKeys(args);
|
|
78
|
-
return invalidRequest(
|
|
79
|
-
actionRecipeMessage(toolName, allowed, { kind: 'unknown', action, received_keys: keys }),
|
|
80
|
-
actionRecipeHint(),
|
|
81
|
-
{
|
|
82
|
-
field: 'action',
|
|
83
|
-
tool: toolName,
|
|
84
|
-
action,
|
|
85
|
-
received_keys: keys,
|
|
86
|
-
allowed_actions: allowed
|
|
87
|
-
}
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
Manage default: `throw unknownToolAction(action, args, MANAGE_ACTIONS, 'kanban_manage');`
|
|
93
|
-
GUI default: `throw unknownToolAction(action, args, GUI_ACTIONS, 'kanban_gui');`
|
|
94
|
-
|
|
95
|
-
## tests/mcp-server.test.js
|
|
96
|
-
|
|
97
|
-
Assert on missing/empty/unknown/gui:
|
|
98
|
-
- message includes `Missing required top-level field "action"` (or `Unknown action "nope"`)
|
|
99
|
-
- `Valid:` + key actions + `Example:` + `"action":`
|
|
100
|
-
- sent keys line
|
|
101
|
-
- `details.allowed_actions`
|
|
102
|
-
- no `undefined` in message
|
|
103
|
-
- codes unchanged: MISSING_REQUIRED_FIELD / VALIDATION_ERROR
|
|
104
|
-
|
|
105
|
-
## CHANGELOG
|
|
106
|
-
|
|
107
|
-
```md
|
|
108
|
-
### Fixed
|
|
109
|
-
- MCP `kanban_manage` / `kanban_gui`: missing/empty/unknown `action` errors put a full retry recipe in `message` (Valid list + Example JSON + sent keys); missing/empty use `MISSING_REQUIRED_FIELD` instead of `Unknown action: undefined`
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
## Verify
|
|
113
|
-
```bash
|
|
114
|
-
node tests/mcp-server.test.js
|
|
115
|
-
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kanban_manage","arguments":{"title":"x"}}}' | node mcp-server.js
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
## Acceptance
|
|
119
|
-
- [ ] Missing action message is copy-paste retry recipe
|
|
120
|
-
- [ ] Unknown action same clarity
|
|
121
|
-
- [ ] Tests green
|