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