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/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
- async function readAgentManifest(destDir) {
141
- 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);
142
165
  try {
143
- const raw = await fs.promises.readFile(manifestPath, 'utf-8');
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 writeAgentManifest(destDir, hashes) {
163
- const manifestPath = path.join(destDir, AGENT_MANIFEST);
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
- agents: hashes
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 { written: 0, updated: 0, unchanged: 0, skipped: 0, conflict: 0 };
254
+ if (error.code === 'ENOENT') return emptyCopyStats();
180
255
  throw error;
181
256
  }
182
- await fs.promises.mkdir(destDir, { recursive: true });
183
- const prevHashes = await readAgentManifest(destDir);
184
- const nextHashes = { ...prevHashes };
185
- let written = 0;
186
- let updated = 0;
187
- let unchanged = 0;
188
- let skipped = 0;
189
- let conflict = 0;
190
-
191
- for (const name of names) {
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
- // Local edit (or legacy copy without matching manifest) → do not overwrite.
242
- conflict += 1;
243
- skipped += 1;
244
- if (recorded && destHash !== recorded) {
245
- console.log(
246
- `• Konflikt .opencode/agent/${name} (lokalna edycja; użyj --force aby nadpisać)`
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
- await writeAgentManifest(destDir, nextHashes);
256
- 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
+ });
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
- async function serveWeb(port) {
553
- const htmlTemplate = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
554
- const project = guiRegistry.projectLabel();
555
- 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
+ }
556
597
 
557
- const server = http.createServer(async (req, res) => {
558
- const url = new URL(req.url, `http://localhost:${port}`);
559
- const requestPath = decodeURIComponent(url.pathname);
598
+ function taskCreateRef(body) {
599
+ return body.epic_id || body.epic || body.epic_group || '—';
600
+ }
560
601
 
561
- try {
562
- if (requestPath === '/' || requestPath === '/index.html') {
563
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
564
- res.end(html);
565
- return;
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
- if (req.method === 'GET' && sendVendorFile(res, requestPath)) {
569
- return;
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
- if (requestPath === '/api/config' && req.method === 'GET') {
573
- const workflow = require('../workflow.js');
574
- const config = await workflow.ensureBoardConfig();
575
- sendJson(res, 200, workflow.shapePublicConfig(config));
576
- return;
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
- if (requestPath === '/api/board') {
580
- await require('../workflow.js').ensureBoardConfig();
581
- await kanban.migrateEpicGroups();
582
- const includeArchived = url.searchParams.get('include_archived') === 'true';
583
- const epics = await kanban.listEpicEntities();
584
- // GUI: show done-epic tasks; only hide archived unless toggled
585
- const tasks = kanban.filterTasksForList(await kanban.allTasks(), epics, {
586
- live_only: false,
587
- include_archived: includeArchived
588
- });
589
- sendJson(res, 200, tasks);
590
- return;
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
- if (requestPath === '/api/epics' && req.method === 'GET') {
594
- await require('../workflow.js').ensureBoardConfig();
595
- await kanban.migrateEpicGroups();
596
- const tasks = await kanban.allTasks();
597
- const epics = await kanban.listEpicEntities();
598
- const shaped = epics.map((epic) => kanban.shapeEpic(epic, tasks, { view: 'full' }));
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
- if (requestPath === '/api/epics' && req.method === 'POST') {
612
- await require('../workflow.js').ensureBoardConfig();
613
- const body = await readBody(req);
614
- // Real epic container when no task column is provided.
615
- if (body.column === undefined && body.as_task !== true) {
616
- const epic = await kanban.doCreateEpic(body.title || '', {
617
- description: body.description,
618
- goals: body.goals,
619
- in_scope: body.in_scope,
620
- out_of_scope: body.out_of_scope,
621
- notes: body.notes
622
- });
623
- sendJson(res, 201, kanban.shapeEpic(epic, [], { view: 'full' }));
624
- return;
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
- if (requestPath === '/api/tasks' && req.method === 'POST') {
649
- await require('../workflow.js').ensureBoardConfig();
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
- if (req.method === 'DELETE') {
673
- const epicDeleteMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
674
- if (epicDeleteMatch) {
675
- const result = await kanban.deleteEpic(epicDeleteMatch[1]);
676
- sendJson(res, 200, result);
677
- return;
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
- if (req.method === 'POST') {
689
- const epicArchiveMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)\/(archive|unarchive)$/);
690
- if (epicArchiveMatch) {
691
- const epic = epicArchiveMatch[2] === 'archive'
692
- ? await kanban.archiveEpic(epicArchiveMatch[1])
693
- : await kanban.unarchiveEpic(epicArchiveMatch[1]);
694
- sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
695
- return;
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
- if (req.method === 'PATCH') {
700
- const epicUpdateMatch = requestPath.match(/^\/api\/epic-entities\/([^/]+)$/);
701
- if (epicUpdateMatch) {
702
- const body = await readBody(req);
703
- const patch = body.patch ? { ...body.patch } : { ...body };
704
- delete patch.patch;
705
- const epic = await kanban.updateEpicEntity(epicUpdateMatch[1], patch);
706
- sendJson(res, 200, kanban.shapeEpic(epic, await kanban.allTasks(), { view: 'full' }));
707
- return;
708
- }
709
-
710
- const moveMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/move$/);
711
- if (moveMatch) {
712
- await require('../workflow.js').ensureBoardConfig();
713
- const taskId = moveMatch[1];
714
- const body = await readBody(req);
715
- const task = await kanban.updateTask(taskId, { column: body.column || '' });
716
- sendJson(res, 200, task);
717
- return;
718
- }
719
-
720
- const toggleMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)\/tasks\/(\d+)$/);
721
- if (toggleMatch) {
722
- const taskId = toggleMatch[1];
723
- const idx = parseInt(toggleMatch[2], 10);
724
- const current = await kanban.getTask(taskId);
725
- if (idx < 0 || idx >= current.subtasks.length) {
726
- throw kanban.createKanbanError(
727
- 'INVALID_SUBTASK_INDEX',
728
- `Subtask index ${idx} is not valid for task ${taskId}`,
729
- 'Read the task first and use an index between 0 and subtasks.length - 1',
730
- { task_id: taskId, idx, total_subtasks: current.subtasks.length },
731
- false,
732
- 400
733
- );
734
- }
735
-
736
- const subtasks = current.subtasks.map((subtask, subtaskIdx) => ({
737
- ...subtask,
738
- done: subtaskIdx === idx ? !subtask.done : subtask.done
739
- }));
740
- const task = await kanban.updateTask(taskId, { subtasks });
741
- sendJson(res, 200, task);
742
- return;
743
- }
744
-
745
- const updateMatch = requestPath.match(/^\/api\/(?:epics|tasks)\/([^/]+)$/);
746
- if (updateMatch) {
747
- const taskId = updateMatch[1];
748
- const body = await readBody(req);
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
- sendJson(res, 404, { error: { code: 'NOT_FOUND', message: 'Route not found' } });
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
- 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 });
919
959
  } else if (cmd === 'config') {
920
960
  await cliConfig();
921
961
  } else if (cmd === 'mcp-init') {