kanbango 3.8.0 → 5.1.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/AGENTS.md +23 -18
- package/API.md +58 -170
- package/CHANGELOG.md +39 -1
- package/LLM_AGENTS.md +21 -502
- package/README.md +18 -16
- package/agent-playbook.js +18 -57
- package/bin/kanban.js +344 -304
- package/config-wizard.js +4 -4
- package/index.html +59 -2
- package/kanban.js +503 -283
- package/mcp-server.js +259 -201
- package/package.json +1 -1
- package/plan.js +19 -24
- package/plugins/tui-kanban-controller.js +117 -0
- package/plugins/tui-kanban.tsx +314 -0
- package/workflow.js +15 -14
- package/.ai/lessons.jsonl +0 -14
- package/.ai/retro/close.json +0 -1
- package/.ai/retro/last-run.json +0 -1
- package/.ait-quality.yml +0 -26
- package/bin/kanban-cmd.js +0 -40
- package/kan2.md +0 -76
- package/kanbango.md +0 -48
- package/planv2.md +0 -317
- package/tests/fixtures/fake-opencode.js +0 -69
- package/tests/index.js +0 -19
- package/tests/kanban-cli.js +0 -118
- package/tests/kanban.js +0 -104
- package/tests/run.js +0 -38
package/bin/kanban.js
CHANGED
|
@@ -11,6 +11,8 @@ const crypto = require('crypto');
|
|
|
11
11
|
|
|
12
12
|
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
13
13
|
const AGENT_MANIFEST = '.kanbango-agents.json';
|
|
14
|
+
const PLUGIN_MANIFEST = '.kanbango-plugins.json';
|
|
15
|
+
const OPENCODE_PLUGIN_FILES = ['tui-kanban.tsx', 'tui-kanban-controller.js'];
|
|
14
16
|
|
|
15
17
|
function shortId(taskId) {
|
|
16
18
|
const match = taskId.match(/^(?:[A-Z]+-)?(\d+)/);
|
|
@@ -90,7 +92,7 @@ function sendError(res, error) {
|
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
async function cliInit() {
|
|
95
|
+
async function cliInit(options = {}) {
|
|
94
96
|
await kanban.ensureBacklogDir();
|
|
95
97
|
const readme = path.join(BACKLOG, 'README.md');
|
|
96
98
|
|
|
@@ -112,6 +114,11 @@ async function cliInit() {
|
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
console.log(`✓ Backlog w: ${BACKLOG}`);
|
|
117
|
+
if (options.opencode) {
|
|
118
|
+
const cwd = process.cwd();
|
|
119
|
+
await copyOpenCodeAgents(cwd, options.force);
|
|
120
|
+
await copyOpenCodePlugins(cwd, options.force);
|
|
121
|
+
}
|
|
115
122
|
await configWizard.maybeCreateConfigOnInit({ cwd: process.cwd() });
|
|
116
123
|
}
|
|
117
124
|
|
|
@@ -137,21 +144,26 @@ function sha256Hex(content) {
|
|
|
137
144
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
138
145
|
}
|
|
139
146
|
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
function emptyCopyStats() {
|
|
148
|
+
return { written: 0, updated: 0, unchanged: 0, skipped: 0, conflict: 0 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hashesFromManifest(parsed, hashesKey) {
|
|
152
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
153
|
+
const nested = parsed[hashesKey];
|
|
154
|
+
const hashes = nested && typeof nested === 'object' && !Array.isArray(nested) ? nested : parsed;
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const [name, value] of Object.entries(hashes)) {
|
|
157
|
+
if (typeof value === 'string' && value) out[name] = value;
|
|
158
|
+
else if (value && typeof value.hash === 'string' && value.hash) out[name] = value.hash;
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function readTrackedManifest(destDir, manifestName, hashesKey) {
|
|
164
|
+
const manifestPath = path.join(destDir, manifestName);
|
|
142
165
|
try {
|
|
143
|
-
|
|
144
|
-
const parsed = JSON.parse(raw);
|
|
145
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
146
|
-
const agents = parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents)
|
|
147
|
-
? parsed.agents
|
|
148
|
-
: parsed;
|
|
149
|
-
const out = {};
|
|
150
|
-
for (const [name, value] of Object.entries(agents)) {
|
|
151
|
-
if (typeof value === 'string' && value) out[name] = value;
|
|
152
|
-
else if (value && typeof value.hash === 'string' && value.hash) out[name] = value.hash;
|
|
153
|
-
}
|
|
154
|
-
return out;
|
|
166
|
+
return hashesFromManifest(JSON.parse(await fs.promises.readFile(manifestPath, 'utf-8')), hashesKey);
|
|
155
167
|
} catch (error) {
|
|
156
168
|
if (error.code === 'ENOENT') return {};
|
|
157
169
|
if (error instanceof SyntaxError) return {};
|
|
@@ -159,16 +171,79 @@ async function readAgentManifest(destDir) {
|
|
|
159
171
|
}
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
async function
|
|
163
|
-
const manifestPath = path.join(destDir,
|
|
174
|
+
async function writeTrackedManifest(destDir, manifestName, hashesKey, hashes) {
|
|
175
|
+
const manifestPath = path.join(destDir, manifestName);
|
|
164
176
|
const payload = {
|
|
165
177
|
version: 1,
|
|
166
178
|
updated: new Date().toISOString(),
|
|
167
|
-
|
|
179
|
+
[hashesKey]: hashes
|
|
168
180
|
};
|
|
169
181
|
await fs.promises.writeFile(manifestPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
170
182
|
}
|
|
171
183
|
|
|
184
|
+
function logTrackedSkip(logPrefix, name, recorded, destHash) {
|
|
185
|
+
if (recorded && destHash !== recorded) {
|
|
186
|
+
console.log(`• Konflikt ${logPrefix}/${name} (lokalna edycja; użyj --force aby nadpisać)`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
console.log(`• Pominięto ${logPrefix}/${name} (lokalna kopia różni się od paczki; użyj --force)`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function syncTrackedFile({ srcPath, destPath, name, force, logPrefix, recorded }) {
|
|
193
|
+
const srcContent = await fs.promises.readFile(srcPath, 'utf-8');
|
|
194
|
+
const srcHash = sha256Hex(srcContent);
|
|
195
|
+
if (!fs.existsSync(destPath)) {
|
|
196
|
+
await fs.promises.writeFile(destPath, srcContent, 'utf-8');
|
|
197
|
+
console.log(`✓ Skopiowano ${logPrefix}/${name}`);
|
|
198
|
+
return { kind: 'written', hash: srcHash };
|
|
199
|
+
}
|
|
200
|
+
const destHash = sha256Hex(await fs.promises.readFile(destPath, 'utf-8'));
|
|
201
|
+
if (destHash === srcHash) {
|
|
202
|
+
console.log(`• Bez zmian ${logPrefix}/${name}`);
|
|
203
|
+
return { kind: 'unchanged', hash: srcHash };
|
|
204
|
+
}
|
|
205
|
+
if (force || (recorded && destHash === recorded)) {
|
|
206
|
+
const reason = force ? '(--force)' : '(nowa wersja w paczce)';
|
|
207
|
+
await fs.promises.writeFile(destPath, srcContent, 'utf-8');
|
|
208
|
+
console.log(`✓ Zaktualizowano ${logPrefix}/${name} ${reason}`);
|
|
209
|
+
return { kind: 'updated', hash: srcHash };
|
|
210
|
+
}
|
|
211
|
+
logTrackedSkip(logPrefix, name, recorded, destHash);
|
|
212
|
+
return { kind: 'conflict', hash: null };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function copyTrackedFiles(options) {
|
|
216
|
+
const { srcDir, destDir, names, force, logPrefix, manifestName, hashesKey } = options;
|
|
217
|
+
const stats = emptyCopyStats();
|
|
218
|
+
if (!names || names.length === 0) return stats;
|
|
219
|
+
|
|
220
|
+
await fs.promises.mkdir(destDir, { recursive: true });
|
|
221
|
+
const prevHashes = await readTrackedManifest(destDir, manifestName, hashesKey);
|
|
222
|
+
const nextHashes = { ...prevHashes };
|
|
223
|
+
|
|
224
|
+
for (const name of names) {
|
|
225
|
+
const result = await syncTrackedFile({
|
|
226
|
+
srcPath: path.join(srcDir, name),
|
|
227
|
+
destPath: path.join(destDir, name),
|
|
228
|
+
name,
|
|
229
|
+
force,
|
|
230
|
+
logPrefix,
|
|
231
|
+
recorded: prevHashes[name] || null
|
|
232
|
+
});
|
|
233
|
+
if (result.hash) nextHashes[name] = result.hash;
|
|
234
|
+
if (result.kind === 'written') stats.written += 1;
|
|
235
|
+
else if (result.kind === 'updated') stats.updated += 1;
|
|
236
|
+
else if (result.kind === 'unchanged') stats.unchanged += 1;
|
|
237
|
+
else {
|
|
238
|
+
stats.conflict += 1;
|
|
239
|
+
stats.skipped += 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await writeTrackedManifest(destDir, manifestName, hashesKey, nextHashes);
|
|
244
|
+
return stats;
|
|
245
|
+
}
|
|
246
|
+
|
|
172
247
|
async function copyOpenCodeAgents(cwd, force) {
|
|
173
248
|
const srcDir = path.join(__dirname, '..', 'agents');
|
|
174
249
|
const destDir = path.join(cwd, '.opencode', 'agent');
|
|
@@ -176,84 +251,37 @@ async function copyOpenCodeAgents(cwd, force) {
|
|
|
176
251
|
try {
|
|
177
252
|
names = (await fs.promises.readdir(srcDir)).filter((f) => f.endsWith('.md'));
|
|
178
253
|
} catch (error) {
|
|
179
|
-
if (error.code === 'ENOENT') return
|
|
254
|
+
if (error.code === 'ENOENT') return emptyCopyStats();
|
|
180
255
|
throw error;
|
|
181
256
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const srcPath = path.join(srcDir, name);
|
|
193
|
-
const destPath = path.join(destDir, name);
|
|
194
|
-
const srcContent = await fs.promises.readFile(srcPath, 'utf-8');
|
|
195
|
-
const srcHash = sha256Hex(srcContent);
|
|
196
|
-
const destExists = fs.existsSync(destPath);
|
|
197
|
-
|
|
198
|
-
if (!destExists) {
|
|
199
|
-
await fs.promises.writeFile(destPath, srcContent, 'utf-8');
|
|
200
|
-
nextHashes[name] = srcHash;
|
|
201
|
-
written += 1;
|
|
202
|
-
console.log(`✓ Skopiowano .opencode/agent/${name}`);
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const destContent = await fs.promises.readFile(destPath, 'utf-8');
|
|
207
|
-
const destHash = sha256Hex(destContent);
|
|
208
|
-
const recorded = prevHashes[name] || null;
|
|
209
|
-
|
|
210
|
-
if (force) {
|
|
211
|
-
if (destHash === srcHash) {
|
|
212
|
-
nextHashes[name] = srcHash;
|
|
213
|
-
unchanged += 1;
|
|
214
|
-
console.log(`• Bez zmian .opencode/agent/${name}`);
|
|
215
|
-
} else {
|
|
216
|
-
await fs.promises.writeFile(destPath, srcContent, 'utf-8');
|
|
217
|
-
nextHashes[name] = srcHash;
|
|
218
|
-
updated += 1;
|
|
219
|
-
console.log(`✓ Zaktualizowano .opencode/agent/${name} (--force)`);
|
|
220
|
-
}
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Local copy already matches package source.
|
|
225
|
-
if (destHash === srcHash) {
|
|
226
|
-
nextHashes[name] = srcHash;
|
|
227
|
-
unchanged += 1;
|
|
228
|
-
console.log(`• Bez zmian .opencode/agent/${name}`);
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Clean packaged copy from an older version → safe auto-update.
|
|
233
|
-
if (recorded && destHash === recorded && recorded !== srcHash) {
|
|
234
|
-
await fs.promises.writeFile(destPath, srcContent, 'utf-8');
|
|
235
|
-
nextHashes[name] = srcHash;
|
|
236
|
-
updated += 1;
|
|
237
|
-
console.log(`✓ Zaktualizowano .opencode/agent/${name} (nowa wersja w paczce)`);
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
257
|
+
return copyTrackedFiles({
|
|
258
|
+
srcDir,
|
|
259
|
+
destDir,
|
|
260
|
+
names,
|
|
261
|
+
force,
|
|
262
|
+
logPrefix: '.opencode/agent',
|
|
263
|
+
manifestName: AGENT_MANIFEST,
|
|
264
|
+
hashesKey: 'agents'
|
|
265
|
+
});
|
|
266
|
+
}
|
|
240
267
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
);
|
|
248
|
-
} else {
|
|
249
|
-
console.log(
|
|
250
|
-
`• Pominięto .opencode/agent/${name} (lokalna kopia różni się od paczki; użyj --force)`
|
|
251
|
-
);
|
|
252
|
-
}
|
|
268
|
+
async function copyOpenCodePlugins(cwd, force) {
|
|
269
|
+
const srcDir = path.join(__dirname, '..', 'plugins');
|
|
270
|
+
const destDir = path.join(cwd, '.opencode', 'plugins');
|
|
271
|
+
const names = [];
|
|
272
|
+
for (const name of OPENCODE_PLUGIN_FILES) {
|
|
273
|
+
if (fs.existsSync(path.join(srcDir, name))) names.push(name);
|
|
253
274
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
275
|
+
if (names.length === 0) return emptyCopyStats();
|
|
276
|
+
return copyTrackedFiles({
|
|
277
|
+
srcDir,
|
|
278
|
+
destDir,
|
|
279
|
+
names,
|
|
280
|
+
force,
|
|
281
|
+
logPrefix: '.opencode/plugins',
|
|
282
|
+
manifestName: PLUGIN_MANIFEST,
|
|
283
|
+
hashesKey: 'plugins'
|
|
284
|
+
});
|
|
257
285
|
}
|
|
258
286
|
|
|
259
287
|
async function cliMcpInit(options) {
|
|
@@ -288,9 +316,10 @@ async function cliMcpInit(options) {
|
|
|
288
316
|
}
|
|
289
317
|
}
|
|
290
318
|
|
|
291
|
-
// OpenCode agents ship with the package; Claude path does not get them.
|
|
319
|
+
// OpenCode agents and TUI plugin ship with the package; Claude path does not get them.
|
|
292
320
|
if (!onlyClaude) {
|
|
293
321
|
await copyOpenCodeAgents(cwd, force);
|
|
322
|
+
await copyOpenCodePlugins(cwd, force);
|
|
294
323
|
}
|
|
295
324
|
|
|
296
325
|
if (!force) {
|
|
@@ -325,7 +354,7 @@ async function cliList(colFilter, epicFilter, asJson, listOptions = {}) {
|
|
|
325
354
|
if (epicFilter) {
|
|
326
355
|
tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, epicFilter));
|
|
327
356
|
} else {
|
|
328
|
-
tasks = kanban.filterTasksForList(tasks, epics, listOptions);
|
|
357
|
+
tasks = kanban.filterTasksForList(tasks, epics, { ...listOptions, col: colFilter });
|
|
329
358
|
}
|
|
330
359
|
|
|
331
360
|
if (asJson) {
|
|
@@ -549,227 +578,232 @@ function logGuiPortCleanupError(err) {
|
|
|
549
578
|
}
|
|
550
579
|
}
|
|
551
580
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
581
|
+
function taskCreateExtra(body) {
|
|
582
|
+
return {
|
|
583
|
+
description: body.description,
|
|
584
|
+
specs: body.specs,
|
|
585
|
+
in_scope: body.in_scope,
|
|
586
|
+
out_of_scope: body.out_of_scope,
|
|
587
|
+
acceptance_criteria: body.acceptance_criteria,
|
|
588
|
+
test_cases: body.test_cases,
|
|
589
|
+
subtasks: body.subtasks,
|
|
590
|
+
notes: body.notes,
|
|
591
|
+
adr: body.adr,
|
|
592
|
+
evidence: body.evidence,
|
|
593
|
+
depends_on: body.depends_on,
|
|
594
|
+
files: body.files
|
|
595
|
+
};
|
|
596
|
+
}
|
|
556
597
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
598
|
+
function taskCreateRef(body) {
|
|
599
|
+
return body.epic_id || body.epic || body.epic_group || '—';
|
|
600
|
+
}
|
|
560
601
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
602
|
+
function taskPatchFromBody(body) {
|
|
603
|
+
const patch = body.patch ? { ...body.patch } : {};
|
|
604
|
+
const keys = [
|
|
605
|
+
'title', 'description', 'specs', 'in_scope', 'out_of_scope',
|
|
606
|
+
'acceptance_criteria', 'test_cases', 'subtasks', 'notes', 'adr', 'evidence'
|
|
607
|
+
];
|
|
608
|
+
for (const key of keys) {
|
|
609
|
+
if (body[key] !== undefined) patch[key] = body[key];
|
|
610
|
+
}
|
|
611
|
+
if (body.epic_id !== undefined) patch.epic_id = body.epic_id;
|
|
612
|
+
else if (body.epic !== undefined) patch.epic = body.epic;
|
|
613
|
+
else if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
|
|
614
|
+
return patch;
|
|
615
|
+
}
|
|
567
616
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
617
|
+
async function handleGuiGet(req, res, url, requestPath, html) {
|
|
618
|
+
if (requestPath === '/' || requestPath === '/index.html') {
|
|
619
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
620
|
+
res.end(html);
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
if (sendVendorFile(res, requestPath)) return true;
|
|
571
624
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
625
|
+
if (requestPath === '/api/config') {
|
|
626
|
+
const workflow = require('../workflow.js');
|
|
627
|
+
const config = await workflow.ensureBoardConfig();
|
|
628
|
+
sendJson(res, 200, workflow.shapePublicConfig(config));
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
578
631
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
632
|
+
if (requestPath === '/api/board') {
|
|
633
|
+
await require('../workflow.js').ensureBoardConfig();
|
|
634
|
+
await kanban.migrateEpicGroups();
|
|
635
|
+
const includeArchived = url.searchParams.get('include_archived') === 'true';
|
|
636
|
+
const epics = await kanban.listEpicEntities();
|
|
637
|
+
const allBoardTasks = await kanban.allTasks();
|
|
638
|
+
const tasks = kanban.filterTasksForList(allBoardTasks, epics, {
|
|
639
|
+
live_only: false,
|
|
640
|
+
include_archived: includeArchived
|
|
641
|
+
});
|
|
642
|
+
const boardFields = ['id'].concat(kanban.VIEW_FIELDS.full);
|
|
643
|
+
sendJson(res, 200, tasks.map((task) => kanban.shapeTask(task, {
|
|
644
|
+
fields: boardFields,
|
|
645
|
+
allTasks: allBoardTasks
|
|
646
|
+
})));
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
592
649
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const includeArchived = url.searchParams.get('include_archived') === 'true';
|
|
600
|
-
const includeDone = url.searchParams.get('include_done') === 'true';
|
|
601
|
-
const status = url.searchParams.get('status') || undefined;
|
|
602
|
-
// GUI default: all non-archived (live + done). Agents use MCP live-only.
|
|
603
|
-
const liveOnly = url.searchParams.get('live_only') === 'true';
|
|
604
|
-
const filtered = kanban.filterShapedEpics(shaped, liveOnly
|
|
605
|
-
? { include_archived: includeArchived, include_done: includeDone, status }
|
|
606
|
-
: { live_only: false, include_archived: includeArchived, status });
|
|
607
|
-
sendJson(res, 200, filtered);
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
650
|
+
if (requestPath === '/api/context') {
|
|
651
|
+
await require('../workflow.js').ensureBoardConfig();
|
|
652
|
+
await kanban.migrateEpicGroups();
|
|
653
|
+
sendJson(res, 200, await kanban.getContextPayload());
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
610
656
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
const task = await kanban.doCreate(
|
|
628
|
-
body.title || '',
|
|
629
|
-
body.column || 'planned',
|
|
630
|
-
body.epic_id || body.epic || body.epic_group || '—',
|
|
631
|
-
{
|
|
632
|
-
description: body.description,
|
|
633
|
-
specs: body.specs,
|
|
634
|
-
in_scope: body.in_scope,
|
|
635
|
-
out_of_scope: body.out_of_scope,
|
|
636
|
-
acceptance_criteria: body.acceptance_criteria,
|
|
637
|
-
test_cases: body.test_cases,
|
|
638
|
-
subtasks: body.subtasks,
|
|
639
|
-
notes: body.notes,
|
|
640
|
-
adr: body.adr,
|
|
641
|
-
evidence: body.evidence
|
|
642
|
-
}
|
|
643
|
-
);
|
|
644
|
-
sendJson(res, 201, task);
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
657
|
+
if (requestPath === '/api/epics') {
|
|
658
|
+
await require('../workflow.js').ensureBoardConfig();
|
|
659
|
+
await kanban.migrateEpicGroups();
|
|
660
|
+
const tasks = await kanban.allTasks();
|
|
661
|
+
const epics = await kanban.listEpicEntities();
|
|
662
|
+
const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'full' }));
|
|
663
|
+
const includeArchived = url.searchParams.get('include_archived') === 'true';
|
|
664
|
+
const includeDone = url.searchParams.get('include_done') === 'true';
|
|
665
|
+
const status = url.searchParams.get('status') || undefined;
|
|
666
|
+
const liveOnly = url.searchParams.get('live_only') === 'true';
|
|
667
|
+
const filtered = kanban.filterShapedEpics(shaped, liveOnly
|
|
668
|
+
? { include_archived: includeArchived, include_done: includeDone, status }
|
|
669
|
+
: { live_only: false, include_archived: includeArchived, status });
|
|
670
|
+
sendJson(res, 200, filtered);
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
647
673
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const body = await readBody(req);
|
|
651
|
-
const task = await kanban.doCreate(
|
|
652
|
-
body.title || '',
|
|
653
|
-
body.column || 'planned',
|
|
654
|
-
body.epic_id || body.epic || body.epic_group || '—',
|
|
655
|
-
{
|
|
656
|
-
description: body.description,
|
|
657
|
-
specs: body.specs,
|
|
658
|
-
in_scope: body.in_scope,
|
|
659
|
-
out_of_scope: body.out_of_scope,
|
|
660
|
-
acceptance_criteria: body.acceptance_criteria,
|
|
661
|
-
test_cases: body.test_cases,
|
|
662
|
-
subtasks: body.subtasks,
|
|
663
|
-
notes: body.notes,
|
|
664
|
-
adr: body.adr,
|
|
665
|
-
evidence: body.evidence
|
|
666
|
-
}
|
|
667
|
-
);
|
|
668
|
-
sendJson(res, 201, task);
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
671
676
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
const taskDeleteMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
|
|
681
|
-
if (taskDeleteMatch) {
|
|
682
|
-
const result = await kanban.deleteTask(taskDeleteMatch[1]);
|
|
683
|
-
sendJson(res, 200, result);
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
677
|
+
async function createTaskFromBody(body) {
|
|
678
|
+
return kanban.doCreate(
|
|
679
|
+
body.title || '',
|
|
680
|
+
body.column || 'planned',
|
|
681
|
+
taskCreateRef(body),
|
|
682
|
+
taskCreateExtra(body)
|
|
683
|
+
);
|
|
684
|
+
}
|
|
687
685
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
686
|
+
async function handleGuiPost(req, res, requestPath) {
|
|
687
|
+
if (requestPath === '/api/epics') {
|
|
688
|
+
await require('../workflow.js').ensureBoardConfig();
|
|
689
|
+
const body = await readBody(req);
|
|
690
|
+
if (body.column === undefined && body.as_task !== true) {
|
|
691
|
+
const epic = await kanban.doCreateEpic(body.title || '', {
|
|
692
|
+
description: body.description,
|
|
693
|
+
goals: body.goals,
|
|
694
|
+
in_scope: body.in_scope,
|
|
695
|
+
out_of_scope: body.out_of_scope,
|
|
696
|
+
notes: body.notes
|
|
697
|
+
});
|
|
698
|
+
sendJson(res, 201, kanban.shapeEpic(epic, [], { view: 'full' }));
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
sendJson(res, 201, await createTaskFromBody(body));
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
698
704
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const patch = body.patch ? { ...body.patch } : {};
|
|
750
|
-
|
|
751
|
-
if (body.title !== undefined) patch.title = body.title;
|
|
752
|
-
if (body.description !== undefined) patch.description = body.description;
|
|
753
|
-
if (body.specs !== undefined) patch.specs = body.specs;
|
|
754
|
-
if (body.in_scope !== undefined) patch.in_scope = body.in_scope;
|
|
755
|
-
if (body.out_of_scope !== undefined) patch.out_of_scope = body.out_of_scope;
|
|
756
|
-
if (body.acceptance_criteria !== undefined) patch.acceptance_criteria = body.acceptance_criteria;
|
|
757
|
-
if (body.test_cases !== undefined) patch.test_cases = body.test_cases;
|
|
758
|
-
if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
|
|
759
|
-
if (body.notes !== undefined) patch.notes = body.notes;
|
|
760
|
-
if (body.adr !== undefined) patch.adr = body.adr;
|
|
761
|
-
if (body.evidence !== undefined) patch.evidence = body.evidence;
|
|
762
|
-
if (body.epic_id !== undefined) patch.epic_id = body.epic_id;
|
|
763
|
-
else if (body.epic !== undefined) patch.epic = body.epic;
|
|
764
|
-
else if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
|
|
765
|
-
|
|
766
|
-
const task = await kanban.updateTask(taskId, patch);
|
|
767
|
-
sendJson(res, 200, task);
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
705
|
+
if (requestPath === '/api/tasks') {
|
|
706
|
+
await require('../workflow.js').ensureBoardConfig();
|
|
707
|
+
sendJson(res, 201, await createTaskFromBody(await readBody(req)));
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const epicArchiveMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)\/(archive|unarchive)$/);
|
|
712
|
+
if (epicArchiveMatch) {
|
|
713
|
+
const epic = epicArchiveMatch[2] === 'archive'
|
|
714
|
+
? await kanban.archiveEpic(epicArchiveMatch[1])
|
|
715
|
+
: await kanban.unarchiveEpic(epicArchiveMatch[1]);
|
|
716
|
+
sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function handleGuiDelete(res, requestPath) {
|
|
724
|
+
const epicDeleteMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
|
|
725
|
+
if (epicDeleteMatch) {
|
|
726
|
+
sendJson(res, 200, await kanban.deleteEpic(epicDeleteMatch[1]));
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
const taskDeleteMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
|
|
730
|
+
if (taskDeleteMatch) {
|
|
731
|
+
sendJson(res, 200, await kanban.deleteTask(taskDeleteMatch[1]));
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function handleGuiPatch(req, res, requestPath) {
|
|
738
|
+
const epicUpdateMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
|
|
739
|
+
if (epicUpdateMatch) {
|
|
740
|
+
const body = await readBody(req);
|
|
741
|
+
const patch = body.patch ? { ...body.patch } : { ...body };
|
|
742
|
+
delete patch.patch;
|
|
743
|
+
const epic = await kanban.updateEpicEntity(epicUpdateMatch[1], patch);
|
|
744
|
+
sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const moveMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/move$/);
|
|
749
|
+
if (moveMatch) {
|
|
750
|
+
await require('../workflow.js').ensureBoardConfig();
|
|
751
|
+
const body = await readBody(req);
|
|
752
|
+
sendJson(res, 200, await kanban.updateTask(moveMatch[1], { column: body.column || '' }));
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
771
755
|
|
|
772
|
-
|
|
756
|
+
const toggleMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/tasks\/(\d+)$/);
|
|
757
|
+
if (toggleMatch) {
|
|
758
|
+
const taskId = toggleMatch[1];
|
|
759
|
+
const idx = parseInt(toggleMatch[2], 10);
|
|
760
|
+
const current = await kanban.getTask(taskId);
|
|
761
|
+
if (idx < 0 || idx >= current.subtasks.length) {
|
|
762
|
+
throw kanban.createKanbanError(
|
|
763
|
+
'INVALID_SUBTASK_INDEX',
|
|
764
|
+
`Subtask index ${idx} is not valid for task ${taskId}`,
|
|
765
|
+
'Read the task first and use an index between 0 and subtasks.length - 1',
|
|
766
|
+
{ task_id: taskId, idx, total_subtasks: current.subtasks.length },
|
|
767
|
+
false,
|
|
768
|
+
400
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const subtasks = current.subtasks.map((subtask, subtaskIdx) => ({
|
|
772
|
+
...subtask,
|
|
773
|
+
done: subtaskIdx === idx ? !subtask.done : subtask.done
|
|
774
|
+
}));
|
|
775
|
+
sendJson(res, 200, await kanban.updateTask(taskId, { subtasks }));
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const updateMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
|
|
780
|
+
if (updateMatch) {
|
|
781
|
+
const body = await readBody(req);
|
|
782
|
+
sendJson(res, 200, await kanban.updateTask(updateMatch[1], taskPatchFromBody(body)));
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function handleGuiRequest(req, res, url, requestPath, html) {
|
|
790
|
+
if (req.method === 'GET' && await handleGuiGet(req, res, url, requestPath, html)) return;
|
|
791
|
+
if (req.method === 'POST' && await handleGuiPost(req, res, requestPath)) return;
|
|
792
|
+
if (req.method === 'DELETE' && await handleGuiDelete(res, requestPath)) return;
|
|
793
|
+
if (req.method === 'PATCH' && await handleGuiPatch(req, res, requestPath)) return;
|
|
794
|
+
sendJson(res, 404, { error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function serveWeb(port) {
|
|
798
|
+
const htmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
|
|
799
|
+
const project = guiRegistry.projectLabel();
|
|
800
|
+
const html = injectProjectIntoHtml(htmlTemplate, project);
|
|
801
|
+
|
|
802
|
+
const server = http.createServer(async (req, res) => {
|
|
803
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
804
|
+
const requestPath = decodeURIComponent(url.pathname);
|
|
805
|
+
try {
|
|
806
|
+
await handleGuiRequest(req, res, url, requestPath, html);
|
|
773
807
|
} catch (error) {
|
|
774
808
|
sendError(res, error);
|
|
775
809
|
}
|
|
@@ -915,7 +949,13 @@ async function main() {
|
|
|
915
949
|
const port = guiRegistry.resolvePreferredGuiPort(args[1]);
|
|
916
950
|
await serveWeb(port);
|
|
917
951
|
} else if (cmd === 'init') {
|
|
918
|
-
|
|
952
|
+
let initOpenCode = false;
|
|
953
|
+
let initForce = false;
|
|
954
|
+
for (let i = 1; i < args.length; i++) {
|
|
955
|
+
if (args[i] === '--opencode') initOpenCode = true;
|
|
956
|
+
else if (args[i] === '--force') initForce = true;
|
|
957
|
+
}
|
|
958
|
+
await cliInit({ opencode: initOpenCode, force: initForce });
|
|
919
959
|
} else if (cmd === 'config') {
|
|
920
960
|
await cliConfig();
|
|
921
961
|
} else if (cmd === 'mcp-init') {
|