kanbango 3.5.0 → 3.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai/lessons.jsonl +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/CHANGELOG.md +39 -0
- package/agent-playbook.js +8 -1
- package/agents/qa-e2e-tester.md +315 -0
- package/agents/qa-tester.md +182 -0
- package/agents/temida.md +81 -0
- package/bin/kanban.js +140 -2
- package/index.html +66 -5
- package/index.js +2 -0
- package/kanban.js +155 -19
- package/mcp-server.js +4 -1
- package/package.json +1 -1
- package/plan.js +5 -2
- package/tests/fixtures/fake-opencode.js +69 -0
- package/tests/index.js +19 -0
- package/tests/kanban-cli.js +118 -0
- package/tests/kanban.js +104 -0
- package/tests/run.js +4 -0
- package/workflow.js +460 -0
package/bin/kanban.js
CHANGED
|
@@ -6,8 +6,10 @@ const guiRegistry = require('../gui-registry.js');
|
|
|
6
6
|
const http = require('http');
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const crypto = require('crypto');
|
|
9
10
|
|
|
10
11
|
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
12
|
+
const AGENT_MANIFEST = '.kanbango-agents.json';
|
|
11
13
|
|
|
12
14
|
function shortId(taskId) {
|
|
13
15
|
const match = taskId.match(/^(?:[A-Z]+-)?(\d+)/);
|
|
@@ -99,6 +101,8 @@ async function cliInit() {
|
|
|
99
101
|
+ '- `active/` — w trakcie (max 1-2)\n'
|
|
100
102
|
+ '- `planned/` — zaplanowane\n'
|
|
101
103
|
+ '- `icebox/` — zamrozone / nice-to-have\n'
|
|
104
|
+
+ '- `testing/` — bramka QA (async agent)\n'
|
|
105
|
+
+ '- `review/` — bramka review / Temida (async agent)\n'
|
|
102
106
|
+ '- `done/` — ukonczone\n'
|
|
103
107
|
+ '- `epics/` — first-class epic containers (context for initiatives)\n',
|
|
104
108
|
'utf-8'
|
|
@@ -108,6 +112,129 @@ async function cliInit() {
|
|
|
108
112
|
console.log(`✓ Backlog w: ${BACKLOG}`);
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
function sha256Hex(content) {
|
|
116
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readAgentManifest(destDir) {
|
|
120
|
+
const manifestPath = path.join(destDir, AGENT_MANIFEST);
|
|
121
|
+
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;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error.code === 'ENOENT') return {};
|
|
136
|
+
if (error instanceof SyntaxError) return {};
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function writeAgentManifest(destDir, hashes) {
|
|
142
|
+
const manifestPath = path.join(destDir, AGENT_MANIFEST);
|
|
143
|
+
const payload = {
|
|
144
|
+
version: 1,
|
|
145
|
+
updated: new Date().toISOString(),
|
|
146
|
+
agents: hashes
|
|
147
|
+
};
|
|
148
|
+
await fs.promises.writeFile(manifestPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function copyOpenCodeAgents(cwd, force) {
|
|
152
|
+
const srcDir = path.join(__dirname, '..', 'agents');
|
|
153
|
+
const destDir = path.join(cwd, '.opencode', 'agent');
|
|
154
|
+
let names;
|
|
155
|
+
try {
|
|
156
|
+
names = (await fs.promises.readdir(srcDir)).filter((f) => f.endsWith('.md'));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error.code === 'ENOENT') return { written: 0, updated: 0, unchanged: 0, skipped: 0, conflict: 0 };
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
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
|
+
}
|
|
219
|
+
|
|
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
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await writeAgentManifest(destDir, nextHashes);
|
|
235
|
+
return { written, updated, unchanged, skipped, conflict };
|
|
236
|
+
}
|
|
237
|
+
|
|
111
238
|
async function cliMcpInit(options) {
|
|
112
239
|
const useNpx = options.useNpx;
|
|
113
240
|
const force = options.force;
|
|
@@ -140,6 +267,11 @@ async function cliMcpInit(options) {
|
|
|
140
267
|
}
|
|
141
268
|
}
|
|
142
269
|
|
|
270
|
+
// OpenCode agents ship with the package; Claude path does not get them.
|
|
271
|
+
if (!onlyClaude) {
|
|
272
|
+
await copyOpenCodeAgents(cwd, force);
|
|
273
|
+
}
|
|
274
|
+
|
|
143
275
|
if (!force) {
|
|
144
276
|
console.log(' (użyj --force, aby nadpisać istniejące pliki)');
|
|
145
277
|
}
|
|
@@ -453,7 +585,9 @@ async function serveWeb(port) {
|
|
|
453
585
|
acceptance_criteria: body.acceptance_criteria,
|
|
454
586
|
test_cases: body.test_cases,
|
|
455
587
|
subtasks: body.subtasks,
|
|
456
|
-
notes: body.notes
|
|
588
|
+
notes: body.notes,
|
|
589
|
+
adr: body.adr,
|
|
590
|
+
evidence: body.evidence
|
|
457
591
|
}
|
|
458
592
|
);
|
|
459
593
|
sendJson(res, 201, task);
|
|
@@ -474,7 +608,9 @@ async function serveWeb(port) {
|
|
|
474
608
|
acceptance_criteria: body.acceptance_criteria,
|
|
475
609
|
test_cases: body.test_cases,
|
|
476
610
|
subtasks: body.subtasks,
|
|
477
|
-
notes: body.notes
|
|
611
|
+
notes: body.notes,
|
|
612
|
+
adr: body.adr,
|
|
613
|
+
evidence: body.evidence
|
|
478
614
|
}
|
|
479
615
|
);
|
|
480
616
|
sendJson(res, 201, task);
|
|
@@ -568,6 +704,8 @@ async function serveWeb(port) {
|
|
|
568
704
|
if (body.test_cases !== undefined) patch.test_cases = body.test_cases;
|
|
569
705
|
if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
|
|
570
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;
|
|
571
709
|
if (body.epic_id !== undefined) patch.epic_id = body.epic_id;
|
|
572
710
|
else if (body.epic !== undefined) patch.epic = body.epic;
|
|
573
711
|
else if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
|
package/index.html
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
--active: #e85d04;
|
|
12
12
|
--planned: #2563eb;
|
|
13
13
|
--icebox: #64748b;
|
|
14
|
+
--testing: #ca8a04;
|
|
15
|
+
--review: #7c3aed;
|
|
14
16
|
--done: #16a34a;
|
|
15
17
|
--bg: #e8eaef;
|
|
16
18
|
--card: #ffffff;
|
|
@@ -285,6 +287,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
285
287
|
}
|
|
286
288
|
.col-head.icebox { color: var(--icebox); }
|
|
287
289
|
.col-head.planned { color: var(--planned); }
|
|
290
|
+
.col-head.testing { color: var(--testing); }
|
|
291
|
+
.col-head.review { color: var(--review); }
|
|
288
292
|
.col-head.done { color: var(--done); }
|
|
289
293
|
.ch-count {
|
|
290
294
|
background: rgba(0,0,0,0.07);
|
|
@@ -327,6 +331,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
327
331
|
box-shadow: 0 2px 6px rgba(232,93,4,0.10);
|
|
328
332
|
}
|
|
329
333
|
.card.col-active.selected { box-shadow: 0 0 0 2px var(--active); }
|
|
334
|
+
.card.col-testing { border-left-color: var(--testing); }
|
|
335
|
+
.card.col-review { border-left-color: var(--review); }
|
|
330
336
|
.card.col-done { border-left-color: var(--done); opacity: 0.7; }
|
|
331
337
|
.card.col-icebox {
|
|
332
338
|
background: #edf0f7;
|
|
@@ -353,8 +359,24 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
353
359
|
.prog-fill { height: 100%; border-radius: 2px; }
|
|
354
360
|
.prog-fill.planned { background: var(--planned); }
|
|
355
361
|
.prog-fill.active { background: var(--active); }
|
|
362
|
+
.prog-fill.testing { background: var(--testing); }
|
|
363
|
+
.prog-fill.review { background: var(--review); }
|
|
356
364
|
.prog-fill.done { background: var(--done); }
|
|
357
365
|
.prog-fill.icebox { background: var(--icebox); }
|
|
366
|
+
.wf-badge {
|
|
367
|
+
font-size: 9px;
|
|
368
|
+
font-weight: 700;
|
|
369
|
+
text-transform: uppercase;
|
|
370
|
+
letter-spacing: 0.4px;
|
|
371
|
+
padding: 1px 5px;
|
|
372
|
+
border-radius: 4px;
|
|
373
|
+
margin-left: auto;
|
|
374
|
+
}
|
|
375
|
+
.wf-badge.running { background: #fef3c7; color: #92400e; }
|
|
376
|
+
.wf-badge.pass { background: #dcfce7; color: #166534; }
|
|
377
|
+
.wf-badge.fail { background: #fee2e2; color: #991b1b; }
|
|
378
|
+
.wf-badge.blocked { background: #e2e8f0; color: #475569; }
|
|
379
|
+
.wf-badge.idle { background: #f1f5f9; color: #64748b; }
|
|
358
380
|
.prog-txt { font-size: 10px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
359
381
|
|
|
360
382
|
.card-actions {
|
|
@@ -783,6 +805,14 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
783
805
|
<div class="col-head planned">Planned <span class="ch-count" id="cnt-planned">0</span></div>
|
|
784
806
|
<div class="col-body" id="col-planned"></div>
|
|
785
807
|
</div>
|
|
808
|
+
<div class="col" data-col="testing">
|
|
809
|
+
<div class="col-head testing">Testing <span class="ch-count" id="cnt-testing">0</span></div>
|
|
810
|
+
<div class="col-body" id="col-testing"></div>
|
|
811
|
+
</div>
|
|
812
|
+
<div class="col" data-col="review">
|
|
813
|
+
<div class="col-head review">Review <span class="ch-count" id="cnt-review">0</span></div>
|
|
814
|
+
<div class="col-body" id="col-review"></div>
|
|
815
|
+
</div>
|
|
786
816
|
<div class="col" data-col="done">
|
|
787
817
|
<div class="col-head done">Done <span class="ch-count" id="cnt-done">0</span></div>
|
|
788
818
|
<div class="col-body" id="col-done"></div>
|
|
@@ -804,10 +834,12 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
804
834
|
const BOARD_COLS = [
|
|
805
835
|
{ id: "icebox", label: "Icebox" },
|
|
806
836
|
{ id: "planned", label: "Planned" },
|
|
837
|
+
{ id: "testing", label: "Testing" },
|
|
838
|
+
{ id: "review", label: "Review" },
|
|
807
839
|
{ id: "done", label: "Done" }
|
|
808
840
|
];
|
|
809
|
-
// Move order: icebox ↔ planned ↔ active(NOW) ↔ done
|
|
810
|
-
const MOVE_ORDER = ["icebox", "planned", "active", "done"];
|
|
841
|
+
// Move order: icebox ↔ planned ↔ active(NOW) ↔ testing ↔ review ↔ done
|
|
842
|
+
const MOVE_ORDER = ["icebox", "planned", "active", "testing", "review", "done"];
|
|
811
843
|
|
|
812
844
|
let allTasks = [];
|
|
813
845
|
let allEpicEntities = [];
|
|
@@ -1002,11 +1034,23 @@ function currentEpicRef() {
|
|
|
1002
1034
|
return "";
|
|
1003
1035
|
}
|
|
1004
1036
|
|
|
1037
|
+
const COLUMN_TRANSITIONS = {
|
|
1038
|
+
icebox: ["planned"],
|
|
1039
|
+
planned: ["active", "icebox", "testing"],
|
|
1040
|
+
active: ["planned", "testing", "icebox"],
|
|
1041
|
+
testing: ["active", "review"],
|
|
1042
|
+
review: ["active", "done"],
|
|
1043
|
+
done: ["active"]
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1005
1046
|
function getNeighborCols(colId) {
|
|
1047
|
+
const allowed = COLUMN_TRANSITIONS[colId] || [];
|
|
1006
1048
|
const idx = MOVE_ORDER.indexOf(colId);
|
|
1049
|
+
const behind = idx > 0 ? MOVE_ORDER.slice(0, idx).reverse() : [];
|
|
1050
|
+
const ahead = idx >= 0 ? MOVE_ORDER.slice(idx + 1) : [];
|
|
1007
1051
|
return {
|
|
1008
|
-
prev:
|
|
1009
|
-
next:
|
|
1052
|
+
prev: behind.find((col) => allowed.includes(col)) || null,
|
|
1053
|
+
next: ahead.find((col) => allowed.includes(col)) || null
|
|
1010
1054
|
};
|
|
1011
1055
|
}
|
|
1012
1056
|
|
|
@@ -1035,6 +1079,12 @@ function renderCard(task, colId) {
|
|
|
1035
1079
|
const idBadge = el("span", "card-id");
|
|
1036
1080
|
idBadge.textContent = shortId;
|
|
1037
1081
|
top.appendChild(idBadge);
|
|
1082
|
+
const wfStatus = task.workflow && task.workflow.status;
|
|
1083
|
+
if (wfStatus && (colId === "testing" || colId === "review" || wfStatus === "running")) {
|
|
1084
|
+
const badge = el("span", `wf-badge ${wfStatus}`);
|
|
1085
|
+
badge.textContent = wfStatus;
|
|
1086
|
+
top.appendChild(badge);
|
|
1087
|
+
}
|
|
1038
1088
|
card.appendChild(top);
|
|
1039
1089
|
|
|
1040
1090
|
const titleEl = el("div", "card-title");
|
|
@@ -1267,8 +1317,19 @@ function renderTaskPaperView(paper, task) {
|
|
|
1267
1317
|
cmd.textContent = ev.test_command || "(no command)";
|
|
1268
1318
|
card.appendChild(cmd);
|
|
1269
1319
|
const meta = el("div", "ev-meta");
|
|
1270
|
-
|
|
1320
|
+
const bits = [];
|
|
1321
|
+
if (ev.stage) bits.push(ev.stage);
|
|
1322
|
+
if (ev.agent) bits.push(ev.agent);
|
|
1323
|
+
if (ev.verdict) bits.push(ev.verdict);
|
|
1324
|
+
bits.push(`exit ${ev.exit_code == null ? "?" : ev.exit_code}`);
|
|
1325
|
+
if (ev.created) bits.push(ev.created);
|
|
1326
|
+
meta.textContent = bits.join(" · ");
|
|
1271
1327
|
card.appendChild(meta);
|
|
1328
|
+
if (ev.summary) {
|
|
1329
|
+
const sum = el("div", "ev-diff");
|
|
1330
|
+
sum.textContent = ev.summary;
|
|
1331
|
+
card.appendChild(sum);
|
|
1332
|
+
}
|
|
1272
1333
|
if (ev.diff) {
|
|
1273
1334
|
const diff = el("div", "ev-diff");
|
|
1274
1335
|
diff.textContent = ev.diff;
|
package/index.js
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
const kanban = require('./kanban.js');
|
|
12
12
|
const plan = require('./plan.js');
|
|
13
|
+
const workflow = require('./workflow.js');
|
|
13
14
|
const guiRegistry = require('./gui-registry.js');
|
|
14
15
|
const playbook = require('./agent-playbook.js');
|
|
15
16
|
|
|
16
17
|
module.exports = {
|
|
17
18
|
kanban,
|
|
18
19
|
plan,
|
|
20
|
+
workflow,
|
|
19
21
|
guiRegistry,
|
|
20
22
|
playbook,
|
|
21
23
|
};
|
package/kanban.js
CHANGED
|
@@ -3,13 +3,27 @@ const path = require('path');
|
|
|
3
3
|
|
|
4
4
|
const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
5
5
|
const EPICS_DIR = path.join(BACKLOG, 'epics');
|
|
6
|
-
const COLS = ['active', 'planned', 'icebox', 'done'];
|
|
6
|
+
const COLS = ['active', 'planned', 'icebox', 'testing', 'review', 'done'];
|
|
7
7
|
const STATUS_MAP = {
|
|
8
8
|
active: 'in_progress',
|
|
9
9
|
planned: 'planned',
|
|
10
10
|
icebox: 'icebox',
|
|
11
|
+
testing: 'testing',
|
|
12
|
+
review: 'review',
|
|
11
13
|
done: 'done'
|
|
12
14
|
};
|
|
15
|
+
const WORKFLOW_STAGES = ['testing', 'review'];
|
|
16
|
+
const WORKFLOW_STATUSES = ['idle', 'running', 'pass', 'fail', 'blocked'];
|
|
17
|
+
const EVIDENCE_VERDICTS = ['pass', 'fail', 'blocked', ''];
|
|
18
|
+
// Agent/human move contract. Same column is always a no-op.
|
|
19
|
+
const COLUMN_TRANSITIONS = {
|
|
20
|
+
icebox: ['planned'],
|
|
21
|
+
planned: ['active', 'icebox', 'testing'],
|
|
22
|
+
active: ['planned', 'testing', 'icebox'],
|
|
23
|
+
testing: ['active', 'review'],
|
|
24
|
+
review: ['active', 'done'],
|
|
25
|
+
done: ['active']
|
|
26
|
+
};
|
|
13
27
|
const VIEW_FIELDS = {
|
|
14
28
|
summary: ['task_number', 'title', 'column', 'epic_id', 'epic_group', 'created', 'progress'],
|
|
15
29
|
planning: [
|
|
@@ -44,7 +58,10 @@ const VIEW_FIELDS = {
|
|
|
44
58
|
'acceptance_criteria',
|
|
45
59
|
'test_cases',
|
|
46
60
|
'subtasks',
|
|
47
|
-
'adr'
|
|
61
|
+
'adr',
|
|
62
|
+
'evidence',
|
|
63
|
+
'plan',
|
|
64
|
+
'workflow'
|
|
48
65
|
],
|
|
49
66
|
full: [
|
|
50
67
|
'task_number',
|
|
@@ -63,7 +80,10 @@ const VIEW_FIELDS = {
|
|
|
63
80
|
'test_cases',
|
|
64
81
|
'subtasks',
|
|
65
82
|
'adr',
|
|
66
|
-
'notes'
|
|
83
|
+
'notes',
|
|
84
|
+
'evidence',
|
|
85
|
+
'plan',
|
|
86
|
+
'workflow'
|
|
67
87
|
]
|
|
68
88
|
};
|
|
69
89
|
|
|
@@ -261,14 +281,39 @@ function normalizeSubtasks(value) {
|
|
|
261
281
|
|
|
262
282
|
function normalizeEvidence(value) {
|
|
263
283
|
if (!Array.isArray(value)) return [];
|
|
264
|
-
return value.map((item) =>
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
284
|
+
return value.map((item) => {
|
|
285
|
+
const stage = normalizeString(item && item.stage);
|
|
286
|
+
const verdict = normalizeString(item && item.verdict);
|
|
287
|
+
return {
|
|
288
|
+
diff: normalizeString(item && item.diff),
|
|
289
|
+
test_command: normalizeString(item && item.test_command),
|
|
290
|
+
stdout: normalizeString(item && item.stdout),
|
|
291
|
+
stderr: normalizeString(item && item.stderr),
|
|
292
|
+
exit_code: Number.isInteger(item && item.exit_code) ? item.exit_code : null,
|
|
293
|
+
created: normalizeString(item && item.created) || todayIso(),
|
|
294
|
+
stage: WORKFLOW_STAGES.includes(stage) || stage === 'plan' ? stage : '',
|
|
295
|
+
agent: normalizeString(item && item.agent),
|
|
296
|
+
verdict: EVIDENCE_VERDICTS.includes(verdict) ? verdict : '',
|
|
297
|
+
summary: normalizeString(item && item.summary)
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeWorkflow(value) {
|
|
303
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
304
|
+
const stage = normalizeString(value.stage);
|
|
305
|
+
const status = normalizeString(value.status);
|
|
306
|
+
const agent = normalizeString(value.agent);
|
|
307
|
+
const runId = normalizeString(value.run_id);
|
|
308
|
+
if (!stage && !status && !agent && !runId) return null;
|
|
309
|
+
return {
|
|
310
|
+
stage: WORKFLOW_STAGES.includes(stage) ? stage : null,
|
|
311
|
+
status: WORKFLOW_STATUSES.includes(status) ? status : 'idle',
|
|
312
|
+
agent,
|
|
313
|
+
run_id: runId,
|
|
314
|
+
started_at: normalizeString(value.started_at) || undefined,
|
|
315
|
+
finished_at: normalizeString(value.finished_at) || undefined
|
|
316
|
+
};
|
|
272
317
|
}
|
|
273
318
|
|
|
274
319
|
function normalizeAdr(value) {
|
|
@@ -355,6 +400,7 @@ function normalizeTask(task) {
|
|
|
355
400
|
notes: normalizeString(task.notes),
|
|
356
401
|
plan: normalizePlan(task.plan),
|
|
357
402
|
evidence: normalizeEvidence(task.evidence),
|
|
403
|
+
workflow: normalizeWorkflow(task.workflow),
|
|
358
404
|
task_number: extractTaskNumber(id)
|
|
359
405
|
};
|
|
360
406
|
|
|
@@ -381,6 +427,7 @@ function serializeTask(task) {
|
|
|
381
427
|
notes: normalized.notes,
|
|
382
428
|
plan: normalized.plan,
|
|
383
429
|
evidence: normalized.evidence,
|
|
430
|
+
workflow: normalized.workflow,
|
|
384
431
|
task_number: normalized.task_number
|
|
385
432
|
};
|
|
386
433
|
}
|
|
@@ -418,7 +465,9 @@ function serializeEpic(epic) {
|
|
|
418
465
|
function deriveEpicStatus(tasks, epic) {
|
|
419
466
|
if (epic && epic.archived) return 'archived';
|
|
420
467
|
if (!tasks || tasks.length === 0) return 'empty';
|
|
421
|
-
if (tasks.some((task) => task.column === 'active'))
|
|
468
|
+
if (tasks.some((task) => WORKFLOW_STAGES.includes(task.column) || task.column === 'active')) {
|
|
469
|
+
return 'active';
|
|
470
|
+
}
|
|
422
471
|
if (tasks.every((task) => task.column === 'done')) return 'done';
|
|
423
472
|
return 'planned';
|
|
424
473
|
}
|
|
@@ -436,13 +485,21 @@ function getEpicProgress(tasks) {
|
|
|
436
485
|
tasks_done: 0,
|
|
437
486
|
tasks_active: 0,
|
|
438
487
|
tasks_planned: 0,
|
|
439
|
-
tasks_icebox: 0
|
|
488
|
+
tasks_icebox: 0,
|
|
489
|
+
tasks_testing: 0,
|
|
490
|
+
tasks_review: 0
|
|
491
|
+
};
|
|
492
|
+
const keyByCol = {
|
|
493
|
+
done: 'tasks_done',
|
|
494
|
+
active: 'tasks_active',
|
|
495
|
+
planned: 'tasks_planned',
|
|
496
|
+
icebox: 'tasks_icebox',
|
|
497
|
+
testing: 'tasks_testing',
|
|
498
|
+
review: 'tasks_review'
|
|
440
499
|
};
|
|
441
500
|
for (const task of tasks) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
else if (task.column === 'planned') progress.tasks_planned += 1;
|
|
445
|
-
else if (task.column === 'icebox') progress.tasks_icebox += 1;
|
|
501
|
+
const key = keyByCol[task.column];
|
|
502
|
+
if (key) progress[key] += 1;
|
|
446
503
|
}
|
|
447
504
|
return progress;
|
|
448
505
|
}
|
|
@@ -1360,6 +1417,32 @@ function validateColumn(column, fieldName = 'column') {
|
|
|
1360
1417
|
}
|
|
1361
1418
|
}
|
|
1362
1419
|
|
|
1420
|
+
function allowedColumnsFrom(fromColumn) {
|
|
1421
|
+
return COLUMN_TRANSITIONS[fromColumn] ? COLUMN_TRANSITIONS[fromColumn].slice() : [];
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function validateTransition(fromColumn, toColumn, taskId) {
|
|
1425
|
+
if (fromColumn === toColumn) return;
|
|
1426
|
+
validateColumn(toColumn);
|
|
1427
|
+
const allowed = allowedColumnsFrom(fromColumn);
|
|
1428
|
+
if (allowed.includes(toColumn)) return;
|
|
1429
|
+
throw createKanbanError(
|
|
1430
|
+
'INVALID_TRANSITION',
|
|
1431
|
+
taskId
|
|
1432
|
+
? `Cannot move task ${taskId} from ${fromColumn} to ${toColumn}`
|
|
1433
|
+
: `Cannot move from ${fromColumn} to ${toColumn}`,
|
|
1434
|
+
`From ${fromColumn} you can move only to: ${allowed.join(', ') || '(none)'}`,
|
|
1435
|
+
{
|
|
1436
|
+
task_id: taskId || undefined,
|
|
1437
|
+
from: fromColumn,
|
|
1438
|
+
to: toColumn,
|
|
1439
|
+
allowed_columns: allowed
|
|
1440
|
+
},
|
|
1441
|
+
false,
|
|
1442
|
+
400
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1363
1446
|
function validatePatch(patch) {
|
|
1364
1447
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
1365
1448
|
throw createKanbanError(
|
|
@@ -1413,6 +1496,7 @@ async function doCreate(title, column = 'planned', epicRef = '—', extra = {})
|
|
|
1413
1496
|
subtasks: extra.subtasks,
|
|
1414
1497
|
notes: extra.notes,
|
|
1415
1498
|
plan: extra.plan,
|
|
1499
|
+
adr: extra.adr,
|
|
1416
1500
|
evidence: extra.evidence
|
|
1417
1501
|
});
|
|
1418
1502
|
try {
|
|
@@ -1451,6 +1535,9 @@ async function updateTaskRecord(taskId, patch) {
|
|
|
1451
1535
|
|
|
1452
1536
|
if (patch.column !== undefined) {
|
|
1453
1537
|
validateColumn(patch.column);
|
|
1538
|
+
if (patch.column !== current.column) {
|
|
1539
|
+
validateTransition(current.column, patch.column, current.id);
|
|
1540
|
+
}
|
|
1454
1541
|
next.column = patch.column;
|
|
1455
1542
|
}
|
|
1456
1543
|
if (patch.title !== undefined) {
|
|
@@ -1566,6 +1653,22 @@ async function updateTaskRecord(taskId, patch) {
|
|
|
1566
1653
|
}
|
|
1567
1654
|
if (patch.notes !== undefined) next.notes = normalizeString(patch.notes);
|
|
1568
1655
|
if (patch.plan !== undefined) next.plan = patch.plan;
|
|
1656
|
+
if (patch.workflow !== undefined) {
|
|
1657
|
+
next.workflow = patch.workflow === null ? null : normalizeWorkflow(patch.workflow);
|
|
1658
|
+
}
|
|
1659
|
+
if (patch.appendEvidence !== undefined) {
|
|
1660
|
+
if (!patch.appendEvidence || typeof patch.appendEvidence !== 'object' || Array.isArray(patch.appendEvidence)) {
|
|
1661
|
+
throw createKanbanError(
|
|
1662
|
+
'VALIDATION_ERROR',
|
|
1663
|
+
'appendEvidence must be an evidence object',
|
|
1664
|
+
'Send a single evidence entry to append',
|
|
1665
|
+
{ field: 'appendEvidence' },
|
|
1666
|
+
false,
|
|
1667
|
+
400
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
next.evidence = [...normalizeEvidence(next.evidence), ...normalizeEvidence([patch.appendEvidence])];
|
|
1671
|
+
}
|
|
1569
1672
|
if (patch.evidence !== undefined) {
|
|
1570
1673
|
if (!Array.isArray(patch.evidence)) {
|
|
1571
1674
|
throw createKanbanError(
|
|
@@ -1583,9 +1686,36 @@ async function updateTaskRecord(taskId, patch) {
|
|
|
1583
1686
|
return writeTask(next, previousFilePath);
|
|
1584
1687
|
}
|
|
1585
1688
|
|
|
1689
|
+
function scheduleWorkflowEnqueue(previousColumn, updated) {
|
|
1690
|
+
if (!updated || !WORKFLOW_STAGES.includes(updated.column)) return;
|
|
1691
|
+
if (previousColumn === updated.column) return;
|
|
1692
|
+
// Lazy require avoids circular load: workflow.js requires kanban.js.
|
|
1693
|
+
setImmediate(() => {
|
|
1694
|
+
try {
|
|
1695
|
+
const workflow = require('./workflow.js');
|
|
1696
|
+
Promise.resolve(workflow.maybeEnqueueOnColumnEnter(updated, previousColumn)).catch((err) => {
|
|
1697
|
+
console.error('workflow enqueue failed:', err && err.message ? err.message : err);
|
|
1698
|
+
});
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
console.error('workflow load failed:', err && err.message ? err.message : err);
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1586
1705
|
async function updateTask(taskId, patch) {
|
|
1587
1706
|
validatePatch(patch);
|
|
1588
|
-
|
|
1707
|
+
let previousColumn = null;
|
|
1708
|
+
const updated = await withBoardLock(async () => {
|
|
1709
|
+
const resolvedId = await resolveTaskId(taskId);
|
|
1710
|
+
const previousFilePath = await findFile(resolvedId);
|
|
1711
|
+
if (previousFilePath) {
|
|
1712
|
+
const current = await parseEpic(previousFilePath, path.basename(path.dirname(previousFilePath)));
|
|
1713
|
+
previousColumn = current.column;
|
|
1714
|
+
}
|
|
1715
|
+
return updateTaskRecord(taskId, patch);
|
|
1716
|
+
});
|
|
1717
|
+
scheduleWorkflowEnqueue(previousColumn, updated);
|
|
1718
|
+
return updated;
|
|
1589
1719
|
}
|
|
1590
1720
|
|
|
1591
1721
|
async function doMove(epicId, target) {
|
|
@@ -1593,7 +1723,7 @@ async function doMove(epicId, target) {
|
|
|
1593
1723
|
await updateTask(epicId, { column: target });
|
|
1594
1724
|
return true;
|
|
1595
1725
|
} catch (error) {
|
|
1596
|
-
if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN') {
|
|
1726
|
+
if (error.code === 'TASK_NOT_FOUND' || error.code === 'INVALID_COLUMN' || error.code === 'INVALID_TRANSITION') {
|
|
1597
1727
|
return false;
|
|
1598
1728
|
}
|
|
1599
1729
|
throw error;
|
|
@@ -1683,8 +1813,14 @@ module.exports = {
|
|
|
1683
1813
|
resolveTaskId,
|
|
1684
1814
|
COLS,
|
|
1685
1815
|
STATUS_MAP,
|
|
1816
|
+
WORKFLOW_STAGES,
|
|
1817
|
+
COLUMN_TRANSITIONS,
|
|
1818
|
+
allowedColumnsFrom,
|
|
1819
|
+
validateTransition,
|
|
1686
1820
|
VIEW_FIELDS,
|
|
1687
1821
|
EPIC_VIEW_FIELDS,
|
|
1822
|
+
normalizeEvidence,
|
|
1823
|
+
normalizeWorkflow,
|
|
1688
1824
|
LIVE_EPIC_STATUSES,
|
|
1689
1825
|
RECOMMENDED_CREATE_FIELDS,
|
|
1690
1826
|
RECOMMENDED_EPIC_CREATE_FIELDS,
|
package/mcp-server.js
CHANGED
|
@@ -502,7 +502,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
502
502
|
column: {
|
|
503
503
|
type: 'string',
|
|
504
504
|
enum: COLS,
|
|
505
|
-
description:
|
|
505
|
+
description:
|
|
506
|
+
'Target column for move (not col). Must be a legal transition from the current column. '
|
|
507
|
+
+ 'icebox→planned; planned→active|icebox|testing; active→planned|testing|icebox; '
|
|
508
|
+
+ 'testing→active|review; review→active|done; done→active. Illegal → INVALID_TRANSITION + allowed_columns.'
|
|
506
509
|
},
|
|
507
510
|
patch: {
|
|
508
511
|
type: 'object',
|
package/package.json
CHANGED
package/plan.js
CHANGED
|
@@ -147,8 +147,11 @@ async function done(payload = {}) {
|
|
|
147
147
|
throw planError('PLAN_INCOMPLETE', 'Plan has incomplete subtasks',
|
|
148
148
|
'Advance every plan step before marking the workflow done', { incomplete });
|
|
149
149
|
}
|
|
150
|
-
const updated = await kanban.updateTask(task.id, {
|
|
151
|
-
|
|
150
|
+
const updated = await kanban.updateTask(task.id, {
|
|
151
|
+
column: 'testing',
|
|
152
|
+
plan: { ...(task.plan || {}), status: 'done' }
|
|
153
|
+
});
|
|
154
|
+
return result(updated, { status: 'done', column: updated.column });
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
async function status(taskId) {
|