kanbango 3.6.2 → 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/bin/kanban.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const kanban = require('../kanban.js');
4
4
  const plan = require('../plan.js');
5
5
  const guiRegistry = require('../gui-registry.js');
6
+ const configWizard = require('../config-wizard.js');
6
7
  const http = require('http');
7
8
  const fs = require('fs');
8
9
  const path = require('path');
@@ -10,6 +11,8 @@ const crypto = require('crypto');
10
11
 
11
12
  const BACKLOG = path.join(process.cwd(), 'backlog');
12
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'];
13
16
 
14
17
  function shortId(taskId) {
15
18
  const match = taskId.match(/^(?:[A-Z]+-)?(\d+)/);
@@ -89,7 +92,7 @@ function sendError(res, error) {
89
92
  });
90
93
  }
91
94
 
92
- async function cliInit() {
95
+ async function cliInit(options = {}) {
93
96
  await kanban.ensureBacklogDir();
94
97
  const readme = path.join(BACKLOG, 'README.md');
95
98
 
@@ -101,36 +104,66 @@ async function cliInit() {
101
104
  + '- `active/` — w trakcie (max 1-2)\n'
102
105
  + '- `planned/` — zaplanowane\n'
103
106
  + '- `icebox/` — zamrozone / nice-to-have\n'
104
- + '- `testing/` — bramka QA (async agent)\n'
105
- + '- `review/` — bramka review / Temida (async agent)\n'
107
+ + '- `testing/` — bramka QA (async agent; optional via kanbango.json)\n'
108
+ + '- `review/` — bramka review / Temida (optional via kanbango.json)\n'
106
109
  + '- `done/` — ukonczone\n'
107
- + '- `epics/` — first-class epic containers (context for initiatives)\n',
110
+ + '- `epics/` — first-class epic containers (context for initiatives)\n'
111
+ + '- `kanbango.json` — optional project config (columns + workflow agents)\n',
108
112
  'utf-8'
109
113
  );
110
114
  }
111
115
 
112
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
+ }
122
+ await configWizard.maybeCreateConfigOnInit({ cwd: process.cwd() });
123
+ }
124
+
125
+ function exitConfigError(error) {
126
+ console.error(`✗ ${error.message}`);
127
+ if (error.hint) console.error(` ${error.hint}`);
128
+ process.exit(1);
129
+ }
130
+
131
+ async function cliConfig() {
132
+ try {
133
+ await kanban.ensureBacklogDir();
134
+ await configWizard.runConfigCommand({ cwd: process.cwd() });
135
+ } catch (error) {
136
+ if (error && (error.code === 'NOT_TTY' || error.code === 'CONFIG_INVALID')) {
137
+ exitConfigError(error);
138
+ }
139
+ throw error;
140
+ }
113
141
  }
114
142
 
115
143
  function sha256Hex(content) {
116
144
  return crypto.createHash('sha256').update(content).digest('hex');
117
145
  }
118
146
 
119
- async function readAgentManifest(destDir) {
120
- const manifestPath = path.join(destDir, AGENT_MANIFEST);
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);
121
165
  try {
122
- const raw = await fs.promises.readFile(manifestPath, 'utf-8');
123
- const parsed = JSON.parse(raw);
124
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
125
- const agents = parsed.agents && typeof parsed.agents === 'object' && !Array.isArray(parsed.agents)
126
- ? parsed.agents
127
- : parsed;
128
- const out = {};
129
- for (const [name, value] of Object.entries(agents)) {
130
- if (typeof value === 'string' && value) out[name] = value;
131
- else if (value && typeof value.hash === 'string' && value.hash) out[name] = value.hash;
132
- }
133
- return out;
166
+ return hashesFromManifest(JSON.parse(await fs.promises.readFile(manifestPath, 'utf-8')), hashesKey);
134
167
  } catch (error) {
135
168
  if (error.code === 'ENOENT') return {};
136
169
  if (error instanceof SyntaxError) return {};
@@ -138,16 +171,79 @@ async function readAgentManifest(destDir) {
138
171
  }
139
172
  }
140
173
 
141
- async function writeAgentManifest(destDir, hashes) {
142
- const manifestPath = path.join(destDir, AGENT_MANIFEST);
174
+ async function writeTrackedManifest(destDir, manifestName, hashesKey, hashes) {
175
+ const manifestPath = path.join(destDir, manifestName);
143
176
  const payload = {
144
177
  version: 1,
145
178
  updated: new Date().toISOString(),
146
- agents: hashes
179
+ [hashesKey]: hashes
147
180
  };
148
181
  await fs.promises.writeFile(manifestPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
149
182
  }
150
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
+
151
247
  async function copyOpenCodeAgents(cwd, force) {
152
248
  const srcDir = path.join(__dirname, '..', 'agents');
153
249
  const destDir = path.join(cwd, '.opencode', 'agent');
@@ -155,84 +251,37 @@ async function copyOpenCodeAgents(cwd, force) {
155
251
  try {
156
252
  names = (await fs.promises.readdir(srcDir)).filter((f) => f.endsWith('.md'));
157
253
  } catch (error) {
158
- if (error.code === 'ENOENT') return { written: 0, updated: 0, unchanged: 0, skipped: 0, conflict: 0 };
254
+ if (error.code === 'ENOENT') return emptyCopyStats();
159
255
  throw error;
160
256
  }
161
- await fs.promises.mkdir(destDir, { recursive: true });
162
- const prevHashes = await readAgentManifest(destDir);
163
- const nextHashes = { ...prevHashes };
164
- let written = 0;
165
- let updated = 0;
166
- let unchanged = 0;
167
- let skipped = 0;
168
- let conflict = 0;
169
-
170
- for (const name of names) {
171
- const srcPath = path.join(srcDir, name);
172
- const destPath = path.join(destDir, name);
173
- const srcContent = await fs.promises.readFile(srcPath, 'utf-8');
174
- const srcHash = sha256Hex(srcContent);
175
- const destExists = fs.existsSync(destPath);
176
-
177
- if (!destExists) {
178
- await fs.promises.writeFile(destPath, srcContent, 'utf-8');
179
- nextHashes[name] = srcHash;
180
- written += 1;
181
- console.log(`✓ Skopiowano .opencode/agent/${name}`);
182
- continue;
183
- }
184
-
185
- const destContent = await fs.promises.readFile(destPath, 'utf-8');
186
- const destHash = sha256Hex(destContent);
187
- const recorded = prevHashes[name] || null;
188
-
189
- if (force) {
190
- if (destHash === srcHash) {
191
- nextHashes[name] = srcHash;
192
- unchanged += 1;
193
- console.log(`• Bez zmian .opencode/agent/${name}`);
194
- } else {
195
- await fs.promises.writeFile(destPath, srcContent, 'utf-8');
196
- nextHashes[name] = srcHash;
197
- updated += 1;
198
- console.log(`✓ Zaktualizowano .opencode/agent/${name} (--force)`);
199
- }
200
- continue;
201
- }
202
-
203
- // Local copy already matches package source.
204
- if (destHash === srcHash) {
205
- nextHashes[name] = srcHash;
206
- unchanged += 1;
207
- console.log(`• Bez zmian .opencode/agent/${name}`);
208
- continue;
209
- }
210
-
211
- // Clean packaged copy from an older version → safe auto-update.
212
- if (recorded && destHash === recorded && recorded !== srcHash) {
213
- await fs.promises.writeFile(destPath, srcContent, 'utf-8');
214
- nextHashes[name] = srcHash;
215
- updated += 1;
216
- console.log(`✓ Zaktualizowano .opencode/agent/${name} (nowa wersja w paczce)`);
217
- continue;
218
- }
257
+ return copyTrackedFiles({
258
+ srcDir,
259
+ destDir,
260
+ names,
261
+ force,
262
+ logPrefix: '.opencode/agent',
263
+ manifestName: AGENT_MANIFEST,
264
+ hashesKey: 'agents'
265
+ });
266
+ }
219
267
 
220
- // Local edit (or legacy copy without matching manifest) → do not overwrite.
221
- conflict += 1;
222
- skipped += 1;
223
- if (recorded && destHash !== recorded) {
224
- console.log(
225
- `• Konflikt .opencode/agent/${name} (lokalna edycja; użyj --force aby nadpisać)`
226
- );
227
- } else {
228
- console.log(
229
- `• Pominięto .opencode/agent/${name} (lokalna kopia różni się od paczki; użyj --force)`
230
- );
231
- }
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);
232
274
  }
233
-
234
- await writeAgentManifest(destDir, nextHashes);
235
- return { written, updated, unchanged, skipped, conflict };
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
+ });
236
285
  }
237
286
 
238
287
  async function cliMcpInit(options) {
@@ -267,17 +316,34 @@ async function cliMcpInit(options) {
267
316
  }
268
317
  }
269
318
 
270
- // 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.
271
320
  if (!onlyClaude) {
272
321
  await copyOpenCodeAgents(cwd, force);
322
+ await copyOpenCodePlugins(cwd, force);
273
323
  }
274
324
 
275
325
  if (!force) {
276
326
  console.log(' (użyj --force, aby nadpisać istniejące pliki)');
277
327
  }
328
+
329
+ await kanban.ensureBacklogDir();
330
+ await configWizard.maybeCreateConfigOnInit({ cwd });
331
+ }
332
+
333
+ async function ensureProjectConfig() {
334
+ try {
335
+ await require('../workflow.js').ensureBoardConfig();
336
+ } catch (error) {
337
+ if (error && error.code === 'CONFIG_INVALID') {
338
+ console.error(`✗ ${error.message}`);
339
+ process.exit(1);
340
+ }
341
+ throw error;
342
+ }
278
343
  }
279
344
 
280
345
  async function cliList(colFilter, epicFilter, asJson, listOptions = {}) {
346
+ await ensureProjectConfig();
281
347
  await kanban.migrateEpicGroups();
282
348
  const epics = await kanban.listEpicEntities();
283
349
  let tasks = await kanban.allTasks();
@@ -288,7 +354,7 @@ async function cliList(colFilter, epicFilter, asJson, listOptions = {}) {
288
354
  if (epicFilter) {
289
355
  tasks = tasks.filter((task) => kanban.taskMatchesEpicFilter(task, epicFilter));
290
356
  } else {
291
- tasks = kanban.filterTasksForList(tasks, epics, listOptions);
357
+ tasks = kanban.filterTasksForList(tasks, epics, { ...listOptions, col: colFilter });
292
358
  }
293
359
 
294
360
  if (asJson) {
@@ -316,6 +382,7 @@ async function cliList(colFilter, epicFilter, asJson, listOptions = {}) {
316
382
 
317
383
  async function cliShow(taskId) {
318
384
  try {
385
+ await ensureProjectConfig();
319
386
  const task = await kanban.getTask(taskId);
320
387
  console.log(`ID: ${shortId(task.id)}`);
321
388
  console.log(`Plik: ${taskFilePath(task)}`);
@@ -343,6 +410,7 @@ async function cliShow(taskId) {
343
410
  }
344
411
 
345
412
  async function cliEpicList(asJson, listOptions = {}) {
413
+ await ensureProjectConfig();
346
414
  await kanban.migrateEpicGroups();
347
415
  const tasks = await kanban.allTasks();
348
416
  const epics = await kanban.listEpicEntities();
@@ -437,17 +505,19 @@ async function cliDelete(taskId) {
437
505
  }
438
506
 
439
507
  async function cliMove(taskId, column) {
440
- const success = await kanban.doMove(taskId, column);
441
- if (success) {
508
+ try {
509
+ await ensureProjectConfig();
510
+ await kanban.updateTask(taskId, { column });
442
511
  console.log(`✓ ${shortId(taskId)} → ${column}`);
443
- } else {
444
- console.error(`✗ Nie znaleziono: ${taskId}`);
512
+ } catch (error) {
513
+ console.error(`✗ ${error.message}`);
445
514
  process.exit(1);
446
515
  }
447
516
  }
448
517
 
449
518
  async function cliAdd(title, column, epicGroup) {
450
519
  try {
520
+ await ensureProjectConfig();
451
521
  const task = await kanban.doCreate(title, column, epicGroup);
452
522
  console.log(`✓ Utworzono ${shortId(task.id)} w ${column} [${task.epic_group}]`);
453
523
  console.log(` Plik: ${taskFilePath(task)}`);
@@ -508,215 +578,232 @@ function logGuiPortCleanupError(err) {
508
578
  }
509
579
  }
510
580
 
511
- async function serveWeb(port) {
512
- const htmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
513
- const project = guiRegistry.projectLabel();
514
- const html = injectProjectIntoHtml(htmlTemplate, project);
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
+ }
515
597
 
516
- const server = http.createServer(async (req, res) => {
517
- const url = new URL(req.url, `http://localhost:${port}`);
518
- const requestPath = decodeURIComponent(url.pathname);
598
+ function taskCreateRef(body) {
599
+ return body.epic_id || body.epic || body.epic_group || '—';
600
+ }
519
601
 
520
- try {
521
- if (requestPath === '/' || requestPath === '/index.html') {
522
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
523
- res.end(html);
524
- return;
525
- }
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
+ }
526
616
 
527
- if (req.method === 'GET' && sendVendorFile(res, requestPath)) {
528
- return;
529
- }
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;
530
624
 
531
- if (requestPath === '/api/board') {
532
- await kanban.migrateEpicGroups();
533
- const includeArchived = url.searchParams.get('include_archived') === 'true';
534
- const epics = await kanban.listEpicEntities();
535
- // GUI: show done-epic tasks; only hide archived unless toggled
536
- const tasks = kanban.filterTasksForList(await kanban.allTasks(), epics, {
537
- live_only: false,
538
- include_archived: includeArchived
539
- });
540
- sendJson(res, 200, tasks);
541
- return;
542
- }
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
+ }
543
631
 
544
- if (requestPath === '/api/epics' && req.method === 'GET') {
545
- await kanban.migrateEpicGroups();
546
- const tasks = await kanban.allTasks();
547
- const epics = await kanban.listEpicEntities();
548
- const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'full' }));
549
- const includeArchived = url.searchParams.get('include_archived') === 'true';
550
- const includeDone = url.searchParams.get('include_done') === 'true';
551
- const status = url.searchParams.get('status') || undefined;
552
- // GUI default: all non-archived (live + done). Agents use MCP live-only.
553
- const liveOnly = url.searchParams.get('live_only') === 'true';
554
- const filtered = kanban.filterShapedEpics(shaped, liveOnly
555
- ? { include_archived: includeArchived, include_done: includeDone, status }
556
- : { live_only: false, include_archived: includeArchived, status });
557
- sendJson(res, 200, filtered);
558
- return;
559
- }
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
+ }
560
649
 
561
- if (requestPath === '/api/epics' && req.method === 'POST') {
562
- const body = await readBody(req);
563
- // Real epic container when no task column is provided.
564
- if (body.column === undefined && body.as_task !== true) {
565
- const epic = await kanban.doCreateEpic(body.title || '', {
566
- description: body.description,
567
- goals: body.goals,
568
- in_scope: body.in_scope,
569
- out_of_scope: body.out_of_scope,
570
- notes: body.notes
571
- });
572
- sendJson(res, 201, kanban.shapeEpic(epic, [], { view: 'full' }));
573
- return;
574
- }
575
-
576
- const task = await kanban.doCreate(
577
- body.title || '',
578
- body.column || 'planned',
579
- body.epic_id || body.epic || body.epic_group || '—',
580
- {
581
- description: body.description,
582
- specs: body.specs,
583
- in_scope: body.in_scope,
584
- out_of_scope: body.out_of_scope,
585
- acceptance_criteria: body.acceptance_criteria,
586
- test_cases: body.test_cases,
587
- subtasks: body.subtasks,
588
- notes: body.notes,
589
- adr: body.adr,
590
- evidence: body.evidence
591
- }
592
- );
593
- sendJson(res, 201, task);
594
- return;
595
- }
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
+ }
596
656
 
597
- if (requestPath === '/api/tasks' && req.method === 'POST') {
598
- const body = await readBody(req);
599
- const task = await kanban.doCreate(
600
- body.title || '',
601
- body.column || 'planned',
602
- body.epic_id || body.epic || body.epic_group || '',
603
- {
604
- description: body.description,
605
- specs: body.specs,
606
- in_scope: body.in_scope,
607
- out_of_scope: body.out_of_scope,
608
- acceptance_criteria: body.acceptance_criteria,
609
- test_cases: body.test_cases,
610
- subtasks: body.subtasks,
611
- notes: body.notes,
612
- adr: body.adr,
613
- evidence: body.evidence
614
- }
615
- );
616
- sendJson(res, 201, task);
617
- return;
618
- }
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
+ }
619
673
 
620
- if (req.method === 'DELETE') {
621
- const epicDeleteMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
622
- if (epicDeleteMatch) {
623
- const result = await kanban.deleteEpic(epicDeleteMatch[1]);
624
- sendJson(res, 200, result);
625
- return;
626
- }
627
-
628
- const taskDeleteMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
629
- if (taskDeleteMatch) {
630
- const result = await kanban.deleteTask(taskDeleteMatch[1]);
631
- sendJson(res, 200, result);
632
- return;
633
- }
634
- }
674
+ return false;
675
+ }
635
676
 
636
- if (req.method === 'POST') {
637
- const epicArchiveMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)\/(archive|unarchive)$/);
638
- if (epicArchiveMatch) {
639
- const epic = epicArchiveMatch[2] === 'archive'
640
- ? await kanban.archiveEpic(epicArchiveMatch[1])
641
- : await kanban.unarchiveEpic(epicArchiveMatch[1]);
642
- sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
643
- return;
644
- }
645
- }
677
+ async function createTaskFromBody(body) {
678
+ return kanban.doCreate(
679
+ body.title || '',
680
+ body.column || 'planned',
681
+ taskCreateRef(body),
682
+ taskCreateExtra(body)
683
+ );
684
+ }
646
685
 
647
- if (req.method === 'PATCH') {
648
- const epicUpdateMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
649
- if (epicUpdateMatch) {
650
- const body = await readBody(req);
651
- const patch = body.patch ? { ...body.patch } : { ...body };
652
- delete patch.patch;
653
- const epic = await kanban.updateEpicEntity(epicUpdateMatch[1], patch);
654
- sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
655
- return;
656
- }
657
-
658
- const moveMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/move$/);
659
- if (moveMatch) {
660
- const taskId = moveMatch[1];
661
- const body = await readBody(req);
662
- const task = await kanban.updateTask(taskId, { column: body.column || '' });
663
- sendJson(res, 200, task);
664
- return;
665
- }
666
-
667
- const toggleMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/tasks\/(\d+)$/);
668
- if (toggleMatch) {
669
- const taskId = toggleMatch[1];
670
- const idx = parseInt(toggleMatch[2], 10);
671
- const current = await kanban.getTask(taskId);
672
- if (idx < 0 || idx >= current.subtasks.length) {
673
- throw kanban.createKanbanError(
674
- 'INVALID_SUBTASK_INDEX',
675
- `Subtask index ${idx} is not valid for task ${taskId}`,
676
- 'Read the task first and use an index between 0 and subtasks.length - 1',
677
- { task_id: taskId, idx, total_subtasks: current.subtasks.length },
678
- false,
679
- 400
680
- );
681
- }
682
-
683
- const subtasks = current.subtasks.map((subtask, subtaskIdx) => ({
684
- ...subtask,
685
- done: subtaskIdx === idx ? !subtask.done : subtask.done
686
- }));
687
- const task = await kanban.updateTask(taskId, { subtasks });
688
- sendJson(res, 200, task);
689
- return;
690
- }
691
-
692
- const updateMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
693
- if (updateMatch) {
694
- const taskId = updateMatch[1];
695
- const body = await readBody(req);
696
- const patch = body.patch ? { ...body.patch } : {};
697
-
698
- if (body.title !== undefined) patch.title = body.title;
699
- if (body.description !== undefined) patch.description = body.description;
700
- if (body.specs !== undefined) patch.specs = body.specs;
701
- if (body.in_scope !== undefined) patch.in_scope = body.in_scope;
702
- if (body.out_of_scope !== undefined) patch.out_of_scope = body.out_of_scope;
703
- if (body.acceptance_criteria !== undefined) patch.acceptance_criteria = body.acceptance_criteria;
704
- if (body.test_cases !== undefined) patch.test_cases = body.test_cases;
705
- if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
706
- if (body.notes !== undefined) patch.notes = body.notes;
707
- if (body.adr !== undefined) patch.adr = body.adr;
708
- if (body.evidence !== undefined) patch.evidence = body.evidence;
709
- if (body.epic_id !== undefined) patch.epic_id = body.epic_id;
710
- else if (body.epic !== undefined) patch.epic = body.epic;
711
- else if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
712
-
713
- const task = await kanban.updateTask(taskId, patch);
714
- sendJson(res, 200, task);
715
- return;
716
- }
717
- }
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
+ }
718
704
 
719
- sendJson(res, 404, { error: { code: 'NOT_FOUND', message: 'Route not found' } });
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
+ }
755
+
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);
720
807
  } catch (error) {
721
808
  sendError(res, error);
722
809
  }
@@ -862,7 +949,15 @@ async function main() {
862
949
  const port = guiRegistry.resolvePreferredGuiPort(args[1]);
863
950
  await serveWeb(port);
864
951
  } else if (cmd === 'init') {
865
- await cliInit();
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 });
959
+ } else if (cmd === 'config') {
960
+ await cliConfig();
866
961
  } else if (cmd === 'mcp-init') {
867
962
  let useNpx = false;
868
963
  let onlyClaude = false;