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/AGENTS.md +25 -18
- package/API.md +58 -170
- package/CHANGELOG.md +76 -0
- package/LLM_AGENTS.md +21 -502
- package/README.md +45 -12
- package/agent-playbook.js +27 -49
- package/bin/kanban.js +395 -300
- package/config-wizard.js +441 -0
- package/index.html +124 -36
- package/kanban.js +892 -263
- package/mcp-server.js +319 -196
- package/package.json +1 -1
- package/plan.js +39 -40
- package/plugins/tui-kanban-controller.js +117 -0
- package/plugins/tui-kanban.tsx +314 -0
- package/workflow.js +368 -21
- package/.ai/lessons.jsonl +0 -9
- package/.ai/retro/close.json +0 -1
- package/.ai/retro/last-run.json +0 -1
- package/.ait-quality.yml +0 -25
- 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 -33
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 (
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
const manifestPath = path.join(destDir,
|
|
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
|
-
|
|
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
|
|
254
|
+
if (error.code === 'ENOENT') return emptyCopyStats();
|
|
159
255
|
throw error;
|
|
160
256
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
441
|
-
|
|
508
|
+
try {
|
|
509
|
+
await ensureProjectConfig();
|
|
510
|
+
await kanban.updateTask(taskId, { column });
|
|
442
511
|
console.log(`✓ ${shortId(taskId)} → ${column}`);
|
|
443
|
-
}
|
|
444
|
-
console.error(`✗
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
598
|
+
function taskCreateRef(body) {
|
|
599
|
+
return body.epic_id || body.epic || body.epic_group || '—';
|
|
600
|
+
}
|
|
519
601
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|