kanbango 3.5.0 → 3.8.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/.ai/lessons.jsonl +6 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +1 -0
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +77 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +34 -3
- package/agent-playbook.js +31 -7
- 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 +200 -7
- package/config-wizard.js +441 -0
- package/index.html +115 -23
- package/index.js +2 -0
- package/kanban.js +578 -33
- package/mcp-server.js +89 -21
- package/package.json +1 -1
- package/plan.js +34 -27
- 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 +9 -0
- package/workflow.js +806 -0
package/mcp-server.js
CHANGED
|
@@ -11,13 +11,25 @@ const plan = require('./plan.js');
|
|
|
11
11
|
const guiRegistry = require('./gui-registry.js');
|
|
12
12
|
const playbook = require('./agent-playbook.js');
|
|
13
13
|
|
|
14
|
-
const COLS = kanban.COLS;
|
|
15
14
|
const READ_VIEWS = Object.keys(kanban.VIEW_FIELDS);
|
|
16
15
|
const GUI_READY_TIMEOUT_MS = 8000;
|
|
17
16
|
const GUI_READY_POLL_MS = 50;
|
|
18
17
|
let guiProcess = null;
|
|
19
18
|
let guiPort = null;
|
|
20
19
|
|
|
20
|
+
function activeCols() {
|
|
21
|
+
return kanban.COLS.slice();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function transitionHint() {
|
|
25
|
+
const map = kanban.COLUMN_TRANSITIONS || {};
|
|
26
|
+
const parts = Object.keys(map).map((from) => {
|
|
27
|
+
const to = (map[from] || []).join('|');
|
|
28
|
+
return `${from}→${to || '(none)'}`;
|
|
29
|
+
});
|
|
30
|
+
return parts.join('; ') || 'see backlog/kanbango.json columns';
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
function normalizePort(value) {
|
|
22
34
|
return guiRegistry.normalizeGuiPort(value);
|
|
23
35
|
}
|
|
@@ -151,12 +163,23 @@ async function buildEpicLookup() {
|
|
|
151
163
|
}
|
|
152
164
|
|
|
153
165
|
async function formatTaskResult(task, returnShape) {
|
|
154
|
-
if (returnShape === 'none')
|
|
166
|
+
if (returnShape === 'none') {
|
|
167
|
+
if (Array.isArray(task.unblocked_tasks) && task.unblocked_tasks.length > 0) {
|
|
168
|
+
return { ok: true, unblocked_tasks: task.unblocked_tasks };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true };
|
|
171
|
+
}
|
|
155
172
|
const epicLookup = await buildEpicLookup();
|
|
156
|
-
|
|
173
|
+
const allTasks = await kanban.allTasks();
|
|
174
|
+
const shaped = kanban.shapeTask(task, {
|
|
157
175
|
view: returnShape === 'full' ? 'full' : 'summary',
|
|
158
|
-
epicLookup
|
|
176
|
+
epicLookup,
|
|
177
|
+
allTasks
|
|
159
178
|
});
|
|
179
|
+
if (Array.isArray(task.unblocked_tasks)) {
|
|
180
|
+
shaped.unblocked_tasks = task.unblocked_tasks;
|
|
181
|
+
}
|
|
182
|
+
return shaped;
|
|
160
183
|
}
|
|
161
184
|
|
|
162
185
|
function guiIdentity(extra = {}) {
|
|
@@ -317,6 +340,8 @@ const server = new Server(
|
|
|
317
340
|
);
|
|
318
341
|
|
|
319
342
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
343
|
+
await require('./workflow.js').ensureBoardConfig();
|
|
344
|
+
const COLS = activeCols();
|
|
320
345
|
return {
|
|
321
346
|
tools: [
|
|
322
347
|
{
|
|
@@ -327,8 +352,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
327
352
|
properties: {
|
|
328
353
|
operation: {
|
|
329
354
|
type: 'string',
|
|
330
|
-
enum: ['list', 'show', 'list_epics', 'show_epic', 'help'],
|
|
331
|
-
description: 'list/show=tasks; list_epics/show_epic=initiative containers; help=token playbook',
|
|
355
|
+
enum: ['list', 'show', 'list_epics', 'show_epic', 'context', 'help'],
|
|
356
|
+
description: 'list/show=tasks; context=compact next action; list_epics/show_epic=initiative containers; help=token playbook',
|
|
332
357
|
default: 'list'
|
|
333
358
|
},
|
|
334
359
|
task_id: {
|
|
@@ -452,6 +477,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
452
477
|
description: 'Verification scenarios',
|
|
453
478
|
items: { type: 'string' }
|
|
454
479
|
},
|
|
480
|
+
depends_on: {
|
|
481
|
+
type: 'array',
|
|
482
|
+
description: 'Task ids that must be done before this task can enter active/gates',
|
|
483
|
+
items: { type: 'string' }
|
|
484
|
+
},
|
|
485
|
+
files: {
|
|
486
|
+
type: 'array',
|
|
487
|
+
description: 'Touched file paths for this task (create/update/plan_evidence)',
|
|
488
|
+
items: { type: 'string' }
|
|
489
|
+
},
|
|
455
490
|
subtasks: {
|
|
456
491
|
type: 'array',
|
|
457
492
|
description: 'Full subtask list replace on update (send complete array, not a single toggle)',
|
|
@@ -502,7 +537,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
502
537
|
column: {
|
|
503
538
|
type: 'string',
|
|
504
539
|
enum: COLS,
|
|
505
|
-
description:
|
|
540
|
+
description:
|
|
541
|
+
'Target column for move (not col). Must be a legal transition from the current column. '
|
|
542
|
+
+ `Allowed (from project config): ${transitionHint()}. `
|
|
543
|
+
+ 'Illegal → INVALID_TRANSITION + allowed_columns.'
|
|
506
544
|
},
|
|
507
545
|
patch: {
|
|
508
546
|
type: 'object',
|
|
@@ -520,31 +558,35 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
520
558
|
steps: {
|
|
521
559
|
type: 'array',
|
|
522
560
|
items: { type: 'string' },
|
|
523
|
-
description: 'plan_create:
|
|
561
|
+
description: 'create/plan_create: checklist texts → subtasks (create uses when subtasks omitted; plan_create uses as-is, no forced TDD steps)'
|
|
524
562
|
},
|
|
525
563
|
project_root: {
|
|
526
564
|
type: 'string',
|
|
527
|
-
description: 'plan_create: root for test-runner detect (default cwd)'
|
|
565
|
+
description: 'plan_create: root for optional test-runner detect (default cwd); missing runner → null, not error'
|
|
528
566
|
},
|
|
529
567
|
diff: {
|
|
530
568
|
type: 'string',
|
|
531
|
-
description: 'plan_evidence: short diff or
|
|
569
|
+
description: 'plan_evidence: short diff or note (one of diff/summary/test_command required)'
|
|
570
|
+
},
|
|
571
|
+
summary: {
|
|
572
|
+
type: 'string',
|
|
573
|
+
description: 'plan_evidence: short implementation summary (alternative to diff)'
|
|
532
574
|
},
|
|
533
575
|
test_command: {
|
|
534
576
|
type: 'string',
|
|
535
|
-
description: 'plan_evidence: exact command run'
|
|
577
|
+
description: 'plan_evidence: optional exact command run'
|
|
536
578
|
},
|
|
537
579
|
stdout: {
|
|
538
580
|
type: 'string',
|
|
539
|
-
description: 'plan_evidence: test stdout (truncate to last ~2KB if huge)'
|
|
581
|
+
description: 'plan_evidence: optional test stdout (truncate to last ~2KB if huge)'
|
|
540
582
|
},
|
|
541
583
|
stderr: {
|
|
542
584
|
type: 'string',
|
|
543
|
-
description: 'plan_evidence: test stderr (truncate if huge)'
|
|
585
|
+
description: 'plan_evidence: optional test stderr (truncate if huge)'
|
|
544
586
|
},
|
|
545
587
|
exit_code: {
|
|
546
588
|
type: 'integer',
|
|
547
|
-
description: 'plan_evidence: process exit code'
|
|
589
|
+
description: 'plan_evidence: optional process exit code'
|
|
548
590
|
}
|
|
549
591
|
},
|
|
550
592
|
required: ['action'],
|
|
@@ -590,12 +632,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
590
632
|
break;
|
|
591
633
|
}
|
|
592
634
|
|
|
635
|
+
await require('./workflow.js').ensureBoardConfig();
|
|
636
|
+
|
|
637
|
+
if (operation === 'context') {
|
|
638
|
+
result = await kanban.getContextPayload({
|
|
639
|
+
epic: args.epic,
|
|
640
|
+
epic_id: args.epic_id
|
|
641
|
+
});
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
|
|
593
645
|
const readOptions = normalizeReadOptions(args, 'summary');
|
|
594
646
|
|
|
595
647
|
if (operation === 'list') {
|
|
596
648
|
await kanban.migrateEpicGroups();
|
|
597
649
|
const epics = await kanban.listEpicEntities();
|
|
598
|
-
|
|
650
|
+
const allBoardTasks = await kanban.allTasks();
|
|
651
|
+
let tasks = allBoardTasks;
|
|
599
652
|
if (args.col) {
|
|
600
653
|
tasks = tasks.filter((task) => task.column === args.col);
|
|
601
654
|
}
|
|
@@ -610,8 +663,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
610
663
|
});
|
|
611
664
|
}
|
|
612
665
|
|
|
613
|
-
|
|
614
|
-
result = tasks.map((task) => kanban.shapeTask(task, readOptions));
|
|
666
|
+
result = tasks.map((task) => kanban.shapeTask(task, { ...readOptions, allTasks: allBoardTasks }));
|
|
615
667
|
} else if (operation === 'show') {
|
|
616
668
|
if (!args.task_id) {
|
|
617
669
|
throw invalidRequest(
|
|
@@ -623,9 +675,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
623
675
|
|
|
624
676
|
await kanban.migrateEpicGroups();
|
|
625
677
|
const epicLookup = await buildEpicLookup();
|
|
678
|
+
const allTasks = await kanban.allTasks();
|
|
626
679
|
result = kanban.shapeTask(await kanban.getTask(args.task_id), {
|
|
627
680
|
...readOptions,
|
|
628
|
-
epicLookup
|
|
681
|
+
epicLookup,
|
|
682
|
+
allTasks
|
|
629
683
|
});
|
|
630
684
|
} else if (operation === 'list_epics') {
|
|
631
685
|
await kanban.migrateEpicGroups();
|
|
@@ -663,7 +717,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
663
717
|
} else {
|
|
664
718
|
throw invalidRequest(
|
|
665
719
|
`Unknown operation: ${operation}`,
|
|
666
|
-
'Use one of: list, show, list_epics, show_epic, help',
|
|
720
|
+
'Use one of: list, show, list_epics, show_epic, context, help',
|
|
667
721
|
{ operation }
|
|
668
722
|
);
|
|
669
723
|
}
|
|
@@ -673,9 +727,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
673
727
|
case 'kanban_manage': {
|
|
674
728
|
const action = args.action;
|
|
675
729
|
const returnShape = normalizeReturnShape(args.return);
|
|
730
|
+
await require('./workflow.js').ensureBoardConfig();
|
|
676
731
|
|
|
677
732
|
switch (action) {
|
|
678
733
|
case 'create': {
|
|
734
|
+
let subtasks = args.subtasks;
|
|
735
|
+
if (subtasks === undefined && Array.isArray(args.steps)) {
|
|
736
|
+
subtasks = args.steps.filter(Boolean).map(String).map((text, index) => ({
|
|
737
|
+
id: `st-${index + 1}`,
|
|
738
|
+
text,
|
|
739
|
+
done: false,
|
|
740
|
+
description: ''
|
|
741
|
+
}));
|
|
742
|
+
}
|
|
679
743
|
const createPayload = {
|
|
680
744
|
description: args.description,
|
|
681
745
|
specs: args.specs,
|
|
@@ -683,8 +747,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
683
747
|
out_of_scope: args.out_of_scope,
|
|
684
748
|
acceptance_criteria: args.acceptance_criteria,
|
|
685
749
|
test_cases: args.test_cases,
|
|
686
|
-
subtasks
|
|
687
|
-
notes: args.notes
|
|
750
|
+
subtasks,
|
|
751
|
+
notes: args.notes,
|
|
752
|
+
depends_on: args.depends_on,
|
|
753
|
+
files: args.files
|
|
688
754
|
};
|
|
689
755
|
const epicRef = args.epic_id || args.epic || '—';
|
|
690
756
|
const created = await kanban.doCreate(args.title, args.col || 'planned', epicRef, createPayload);
|
|
@@ -839,6 +905,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
839
905
|
if (args.test_cases !== undefined) patch.test_cases = args.test_cases;
|
|
840
906
|
if (args.subtasks !== undefined) patch.subtasks = args.subtasks;
|
|
841
907
|
if (args.notes !== undefined) patch.notes = args.notes;
|
|
908
|
+
if (args.depends_on !== undefined) patch.depends_on = args.depends_on;
|
|
909
|
+
if (args.files !== undefined) patch.files = args.files;
|
|
842
910
|
if (args.adr !== undefined) patch.adr = args.adr;
|
|
843
911
|
if (args.epic_id !== undefined) patch.epic_id = args.epic_id;
|
|
844
912
|
else if (args.epic !== undefined) patch.epic = args.epic;
|
package/package.json
CHANGED
package/plan.js
CHANGED
|
@@ -55,17 +55,13 @@ async function detectTestRunner(projectRoot = process.cwd()) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
'Add a supported project manifest or set OPENCODE_TEST_COMMAND', { project_root: projectRoot });
|
|
58
|
+
return null;
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
function planSubtasks(implementationSteps) {
|
|
63
|
-
const steps =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
...implementationSteps,
|
|
67
|
-
'Run tests and confirm green'
|
|
68
|
-
];
|
|
62
|
+
const steps = Array.isArray(implementationSteps)
|
|
63
|
+
? implementationSteps.filter(Boolean).map(String)
|
|
64
|
+
: [];
|
|
69
65
|
return steps.map((text, index) => ({ id: `st-${index + 1}`, text, done: false, description: '' }));
|
|
70
66
|
}
|
|
71
67
|
|
|
@@ -93,6 +89,8 @@ async function create(payload = {}) {
|
|
|
93
89
|
test_cases: payload.test_cases,
|
|
94
90
|
subtasks: planSubtasks(implementationSteps),
|
|
95
91
|
notes: payload.notes,
|
|
92
|
+
depends_on: payload.depends_on,
|
|
93
|
+
files: payload.files,
|
|
96
94
|
plan: { runner, status: 'active' },
|
|
97
95
|
evidence: []
|
|
98
96
|
});
|
|
@@ -116,28 +114,30 @@ async function advance(payload = {}) {
|
|
|
116
114
|
}
|
|
117
115
|
|
|
118
116
|
async function evidence(payload = {}) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
const diff = payload.diff !== undefined ? String(payload.diff) : '';
|
|
118
|
+
const summary = payload.summary !== undefined ? String(payload.summary) : '';
|
|
119
|
+
const testCommand = payload.test_command !== undefined ? String(payload.test_command) : '';
|
|
120
|
+
if (!diff && !summary && !testCommand) {
|
|
121
|
+
throw planError('MISSING_REQUIRED_FIELD', 'diff, summary, or test_command is required',
|
|
122
|
+
'Provide at least one of diff, summary, or test_command', { field: 'diff' });
|
|
125
123
|
}
|
|
126
|
-
if (!Number.isInteger(payload.exit_code)) {
|
|
124
|
+
if (payload.exit_code !== undefined && !Number.isInteger(payload.exit_code)) {
|
|
127
125
|
throw planError('VALIDATION_ERROR', 'exit_code must be an integer',
|
|
128
|
-
'Use the process exit code from the test command', { field: 'exit_code' });
|
|
126
|
+
'Use the process exit code from the test command, or omit exit_code', { field: 'exit_code' });
|
|
129
127
|
}
|
|
130
128
|
const task = await kanban.getTask(payload.task_id);
|
|
131
|
-
const
|
|
132
|
-
diff
|
|
133
|
-
test_command:
|
|
134
|
-
stdout: String(payload.stdout),
|
|
135
|
-
stderr: String(payload.stderr),
|
|
136
|
-
exit_code: payload.exit_code,
|
|
129
|
+
const patch = { evidence: [...task.evidence, {
|
|
130
|
+
diff,
|
|
131
|
+
test_command: testCommand,
|
|
132
|
+
stdout: payload.stdout !== undefined ? String(payload.stdout) : '',
|
|
133
|
+
stderr: payload.stderr !== undefined ? String(payload.stderr) : '',
|
|
134
|
+
exit_code: Number.isInteger(payload.exit_code) ? payload.exit_code : null,
|
|
135
|
+
summary,
|
|
137
136
|
created: new Date().toISOString()
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
}] };
|
|
138
|
+
if (payload.files !== undefined) patch.files = payload.files;
|
|
139
|
+
const updated = await kanban.updateTask(task.id, patch);
|
|
140
|
+
return result(updated, { evidence: patch.evidence[patch.evidence.length - 1] });
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
async function done(payload = {}) {
|
|
@@ -147,8 +147,15 @@ 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
|
|
151
|
-
|
|
150
|
+
const workflow = require('./workflow.js');
|
|
151
|
+
const targetColumn = await workflow.planDoneColumn();
|
|
152
|
+
const updated = await kanban.updateTask(task.id, {
|
|
153
|
+
column: targetColumn,
|
|
154
|
+
plan: { ...(task.plan || {}), status: 'done' }
|
|
155
|
+
});
|
|
156
|
+
const extra = { status: 'done', column: updated.column };
|
|
157
|
+
if (Array.isArray(updated.unblocked_tasks)) extra.unblocked_tasks = updated.unblocked_tasks;
|
|
158
|
+
return result(updated, extra);
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
async function status(taskId) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fake opencode runner for workflow-gates tests.
|
|
4
|
+
* Env:
|
|
5
|
+
* FAKE_OPENCODE_MODE = pass|fail|blocked|gate_pass|gate_fail|gate_missing|hang|exit1
|
|
6
|
+
* FAKE_OPENCODE_REVIEW_MODE = gate_pass|gate_fail|gate_missing (optional; overrides when --agent is review)
|
|
7
|
+
* FAKE_OPENCODE_ARGV_FILE = path to write JSON argv
|
|
8
|
+
* FAKE_OPENCODE_HANG_MS = hang duration (default 60000)
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const agentIdx = args.indexOf('--agent');
|
|
15
|
+
const agent = agentIdx >= 0 ? args[agentIdx + 1] : '';
|
|
16
|
+
|
|
17
|
+
let mode = String(process.env.FAKE_OPENCODE_MODE || 'pass').trim();
|
|
18
|
+
const reviewMode = String(process.env.FAKE_OPENCODE_REVIEW_MODE || '').trim();
|
|
19
|
+
// Review agents: prefer review mode, else map testing modes to gate_* defaults.
|
|
20
|
+
if (agent === 'temida' || agent === 'review' || /temida|review/i.test(agent)) {
|
|
21
|
+
if (reviewMode) {
|
|
22
|
+
mode = reviewMode;
|
|
23
|
+
} else if (mode === 'pass') {
|
|
24
|
+
mode = 'gate_pass';
|
|
25
|
+
} else if (mode === 'fail') {
|
|
26
|
+
mode = 'gate_fail';
|
|
27
|
+
} else if (mode === 'blocked' || mode === 'gate_missing') {
|
|
28
|
+
mode = mode === 'blocked' ? 'gate_missing' : mode;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const argvFile = process.env.FAKE_OPENCODE_ARGV_FILE;
|
|
33
|
+
if (argvFile) {
|
|
34
|
+
try {
|
|
35
|
+
fs.mkdirSync(path.dirname(argvFile), { recursive: true });
|
|
36
|
+
fs.writeFileSync(argvFile, JSON.stringify(args, null, 2) + '\n', 'utf-8');
|
|
37
|
+
} catch (err) {
|
|
38
|
+
process.stderr.write(`argv write failed: ${err.message}\n`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (mode === 'hang') {
|
|
43
|
+
const ms = Number(process.env.FAKE_OPENCODE_HANG_MS || 60000);
|
|
44
|
+
setTimeout(() => process.exit(0), ms);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const outputs = {
|
|
49
|
+
pass: { out: 'Tests green\nPASS\n', code: 0 },
|
|
50
|
+
fail: { out: 'Tests red\nFAIL\n', code: 1 },
|
|
51
|
+
blocked: { out: 'Missing env\nBLOCKED\n', code: 0 },
|
|
52
|
+
gate_pass: {
|
|
53
|
+
out: 'Nagroda: solidny diff.\nKara kosmetyczna: naming.\nWedka: ok.\nGATE: PASS\n',
|
|
54
|
+
code: 0
|
|
55
|
+
},
|
|
56
|
+
gate_fail: {
|
|
57
|
+
out: 'Kara: brak testow produkcji.\nWedka: dodaj testy.\nGATE: FAIL\n',
|
|
58
|
+
code: 0
|
|
59
|
+
},
|
|
60
|
+
gate_missing: {
|
|
61
|
+
out: 'Wyrok bez markera bramki.\nTylko narracja.\n',
|
|
62
|
+
code: 0
|
|
63
|
+
},
|
|
64
|
+
exit1: { out: 'crashed without verdict\n', code: 1 }
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const picked = outputs[mode] || outputs.pass;
|
|
68
|
+
process.stdout.write(picked.out);
|
|
69
|
+
process.exit(picked.code);
|
package/tests/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
function run() {
|
|
5
|
+
const pkg = require(path.join(__dirname, '..', 'index.js'));
|
|
6
|
+
assert.ok(pkg.kanban, 'index exports kanban');
|
|
7
|
+
assert.ok(pkg.plan, 'index exports plan');
|
|
8
|
+
assert.ok(pkg.workflow, 'index exports workflow');
|
|
9
|
+
assert.ok(pkg.guiRegistry, 'index exports guiRegistry');
|
|
10
|
+
assert.ok(pkg.playbook, 'index exports playbook');
|
|
11
|
+
assert.ok(pkg.kanban.COLS.includes('testing'));
|
|
12
|
+
assert.ok(pkg.kanban.COLS.includes('review'));
|
|
13
|
+
assert.strictEqual(typeof pkg.workflow.loadConfig, 'function');
|
|
14
|
+
assert.strictEqual(typeof pkg.workflow.maybeEnqueueOnColumnEnter, 'function');
|
|
15
|
+
assert.strictEqual(typeof pkg.plan.done, 'function');
|
|
16
|
+
console.log('✓ index.test.js passed');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
run();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const PKG = path.join(__dirname, '..');
|
|
9
|
+
const CLI = path.join(PKG, 'bin', 'kanban.js');
|
|
10
|
+
const SRC_QA = path.join(PKG, 'agents', 'qa-tester.md');
|
|
11
|
+
|
|
12
|
+
function runCli(cwd, args) {
|
|
13
|
+
return spawnSync(process.execPath, [CLI, ...args], {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: 'utf-8'
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sha256(content) {
|
|
20
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function run() {
|
|
24
|
+
// init creates testing/review dirs + README
|
|
25
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'kanbango-bin-'));
|
|
26
|
+
const init = runCli(root, ['init']);
|
|
27
|
+
assert.strictEqual(init.status, 0, init.stderr);
|
|
28
|
+
for (const col of ['testing', 'review', 'active', 'planned', 'icebox', 'done']) {
|
|
29
|
+
const st = await fs.stat(path.join(root, 'backlog', col));
|
|
30
|
+
assert.ok(st.isDirectory(), col);
|
|
31
|
+
}
|
|
32
|
+
const readme = await fs.readFile(path.join(root, 'backlog', 'README.md'), 'utf-8');
|
|
33
|
+
assert.ok(readme.includes('testing/'));
|
|
34
|
+
assert.ok(readme.includes('review/'));
|
|
35
|
+
|
|
36
|
+
// mcp-init --opencode copies agents + writes manifest
|
|
37
|
+
const ocRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kanbango-bin-oc-'));
|
|
38
|
+
const oc = runCli(ocRoot, ['mcp-init', '--opencode']);
|
|
39
|
+
assert.strictEqual(oc.status, 0, oc.stderr);
|
|
40
|
+
const agentDir = path.join(ocRoot, '.opencode', 'agent');
|
|
41
|
+
const agents = await fs.readdir(agentDir);
|
|
42
|
+
assert.ok(agents.includes('qa-tester.md'));
|
|
43
|
+
assert.ok(agents.includes('temida.md'));
|
|
44
|
+
assert.ok(agents.includes('.kanbango-agents.json'));
|
|
45
|
+
const temida = await fs.readFile(path.join(agentDir, 'temida.md'), 'utf-8');
|
|
46
|
+
assert.ok(temida.includes('GATE: PASS'));
|
|
47
|
+
|
|
48
|
+
const srcQa = await fs.readFile(SRC_QA, 'utf-8');
|
|
49
|
+
const srcHash = sha256(srcQa);
|
|
50
|
+
const manifest1 = JSON.parse(await fs.readFile(path.join(agentDir, '.kanbango-agents.json'), 'utf-8'));
|
|
51
|
+
assert.strictEqual(manifest1.agents['qa-tester.md'], srcHash);
|
|
52
|
+
|
|
53
|
+
// second run, identical source → unchanged
|
|
54
|
+
const againSame = runCli(ocRoot, ['mcp-init', '--opencode']);
|
|
55
|
+
assert.strictEqual(againSame.status, 0);
|
|
56
|
+
assert.ok(againSame.stdout.includes('Bez zmian') || againSame.stdout.includes('unchanged')
|
|
57
|
+
|| againSame.stdout.includes('Pominięto') === false);
|
|
58
|
+
assert.ok(againSame.stdout.includes('Bez zmian .opencode/agent/qa-tester.md'));
|
|
59
|
+
assert.strictEqual(await fs.readFile(path.join(agentDir, 'qa-tester.md'), 'utf-8'), srcQa);
|
|
60
|
+
|
|
61
|
+
// local edit while manifest matches previous package hash → conflict, keep local
|
|
62
|
+
await fs.writeFile(path.join(agentDir, 'qa-tester.md'), 'KEEP_LOCAL_EDIT', 'utf-8');
|
|
63
|
+
const conflictRun = runCli(ocRoot, ['mcp-init', '--opencode']);
|
|
64
|
+
assert.strictEqual(conflictRun.status, 0);
|
|
65
|
+
assert.ok(
|
|
66
|
+
conflictRun.stdout.includes('Konflikt') || conflictRun.stdout.includes('Pominięto'),
|
|
67
|
+
`expected conflict/skip message, got: ${conflictRun.stdout}`
|
|
68
|
+
);
|
|
69
|
+
assert.strictEqual(
|
|
70
|
+
await fs.readFile(path.join(agentDir, 'qa-tester.md'), 'utf-8'),
|
|
71
|
+
'KEEP_LOCAL_EDIT'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// clean older package version: dest matches recorded hash, source "new" → auto update
|
|
75
|
+
// Simulate by writing dest = old body, manifest hash = old hash, then force-path via
|
|
76
|
+
// restoring package content is fixed; instead write dest to old body with matching manifest.
|
|
77
|
+
const oldBody = 'OLD_PACKAGE_VERSION\n';
|
|
78
|
+
const oldHash = sha256(oldBody);
|
|
79
|
+
await fs.writeFile(path.join(agentDir, 'qa-tester.md'), oldBody, 'utf-8');
|
|
80
|
+
const man = JSON.parse(await fs.readFile(path.join(agentDir, '.kanbango-agents.json'), 'utf-8'));
|
|
81
|
+
man.agents['qa-tester.md'] = oldHash;
|
|
82
|
+
await fs.writeFile(
|
|
83
|
+
path.join(agentDir, '.kanbango-agents.json'),
|
|
84
|
+
JSON.stringify(man, null, 2) + '\n',
|
|
85
|
+
'utf-8'
|
|
86
|
+
);
|
|
87
|
+
const autoUpdate = runCli(ocRoot, ['mcp-init', '--opencode']);
|
|
88
|
+
assert.strictEqual(autoUpdate.status, 0, autoUpdate.stderr);
|
|
89
|
+
assert.ok(
|
|
90
|
+
autoUpdate.stdout.includes('Zaktualizowano .opencode/agent/qa-tester.md'),
|
|
91
|
+
autoUpdate.stdout
|
|
92
|
+
);
|
|
93
|
+
assert.strictEqual(await fs.readFile(path.join(agentDir, 'qa-tester.md'), 'utf-8'), srcQa);
|
|
94
|
+
const manAfter = JSON.parse(await fs.readFile(path.join(agentDir, '.kanbango-agents.json'), 'utf-8'));
|
|
95
|
+
assert.strictEqual(manAfter.agents['qa-tester.md'], srcHash);
|
|
96
|
+
|
|
97
|
+
// --force overwrites local edit
|
|
98
|
+
await fs.writeFile(path.join(agentDir, 'qa-tester.md'), 'FORCE_ME', 'utf-8');
|
|
99
|
+
const forceRun = runCli(ocRoot, ['mcp-init', '--opencode', '--force']);
|
|
100
|
+
assert.strictEqual(forceRun.status, 0);
|
|
101
|
+
assert.strictEqual(await fs.readFile(path.join(agentDir, 'qa-tester.md'), 'utf-8'), srcQa);
|
|
102
|
+
|
|
103
|
+
// --claude does not create .opencode/agent
|
|
104
|
+
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kanbango-bin-cl-'));
|
|
105
|
+
const cl = runCli(claudeRoot, ['mcp-init', '--claude']);
|
|
106
|
+
assert.strictEqual(cl.status, 0, cl.stderr);
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
() => fs.access(path.join(claudeRoot, '.opencode', 'agent')),
|
|
109
|
+
(err) => err.code === 'ENOENT'
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
console.log('✓ bin-kanban.test.js passed');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
run().catch((err) => {
|
|
116
|
+
console.error(err);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
package/tests/kanban.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs').promises;
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
async function run() {
|
|
7
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kanbango-kanban-'));
|
|
8
|
+
process.chdir(tempRoot);
|
|
9
|
+
const kanban = require(path.join(__dirname, '..', 'kanban.js'));
|
|
10
|
+
|
|
11
|
+
assert.ok(kanban.COLS.includes('testing'));
|
|
12
|
+
assert.ok(kanban.COLS.includes('review'));
|
|
13
|
+
assert.strictEqual(kanban.STATUS_MAP.testing, 'testing');
|
|
14
|
+
assert.strictEqual(kanban.STATUS_MAP.review, 'review');
|
|
15
|
+
|
|
16
|
+
await kanban.ensureBacklogDir();
|
|
17
|
+
for (const col of ['testing', 'review']) {
|
|
18
|
+
const st = await fs.stat(path.join(tempRoot, 'backlog', col));
|
|
19
|
+
assert.ok(st.isDirectory());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ev = kanban.normalizeEvidence([{
|
|
23
|
+
diff: 'old',
|
|
24
|
+
test_command: 't',
|
|
25
|
+
stdout: '',
|
|
26
|
+
stderr: '',
|
|
27
|
+
exit_code: 0,
|
|
28
|
+
stage: 'testing',
|
|
29
|
+
agent: 'qa-tester',
|
|
30
|
+
verdict: 'pass',
|
|
31
|
+
summary: 'ok'
|
|
32
|
+
}]);
|
|
33
|
+
assert.strictEqual(ev[0].stage, 'testing');
|
|
34
|
+
assert.strictEqual(ev[0].verdict, 'pass');
|
|
35
|
+
|
|
36
|
+
const wf = kanban.normalizeWorkflow({
|
|
37
|
+
stage: 'review',
|
|
38
|
+
status: 'running',
|
|
39
|
+
agent: 'temida',
|
|
40
|
+
run_id: 'run-x'
|
|
41
|
+
});
|
|
42
|
+
assert.strictEqual(wf.stage, 'review');
|
|
43
|
+
assert.strictEqual(wf.status, 'running');
|
|
44
|
+
|
|
45
|
+
const task = await kanban.doCreate('Kanban unit', 'active', '—', {});
|
|
46
|
+
const moved = await kanban.updateTask(task.id, { column: 'testing' });
|
|
47
|
+
assert.strictEqual(moved.column, 'testing');
|
|
48
|
+
|
|
49
|
+
await assert.rejects(
|
|
50
|
+
() => kanban.updateTask(task.id, { column: 'done' }),
|
|
51
|
+
(err) => {
|
|
52
|
+
assert.strictEqual(err.code, 'INVALID_TRANSITION');
|
|
53
|
+
assert.deepStrictEqual(err.details.allowed_columns, ['active', 'review']);
|
|
54
|
+
assert.strictEqual(err.details.from, 'testing');
|
|
55
|
+
assert.strictEqual(err.details.to, 'done');
|
|
56
|
+
assert.ok(err.hint.includes('active, review'));
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
assert.deepStrictEqual(kanban.allowedColumnsFrom('active'), ['planned', 'testing', 'icebox']);
|
|
61
|
+
assert.deepStrictEqual(kanban.allowedColumnsFrom('review'), ['active', 'done']);
|
|
62
|
+
kanban.validateTransition('active', 'testing', '001');
|
|
63
|
+
kanban.validateTransition('testing', 'testing', '001');
|
|
64
|
+
|
|
65
|
+
const appended = await kanban.updateTask(task.id, {
|
|
66
|
+
appendEvidence: {
|
|
67
|
+
diff: '',
|
|
68
|
+
test_command: 'fake',
|
|
69
|
+
stdout: 'PASS',
|
|
70
|
+
stderr: '',
|
|
71
|
+
exit_code: 0,
|
|
72
|
+
stage: 'testing',
|
|
73
|
+
agent: 'qa-tester',
|
|
74
|
+
verdict: 'pass',
|
|
75
|
+
summary: 'PASS'
|
|
76
|
+
},
|
|
77
|
+
workflow: {
|
|
78
|
+
stage: 'testing',
|
|
79
|
+
status: 'pass',
|
|
80
|
+
agent: 'qa-tester',
|
|
81
|
+
run_id: 'run-1'
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
assert.ok(appended.evidence.some((e) => e.verdict === 'pass'));
|
|
85
|
+
assert.strictEqual(appended.workflow.status, 'pass');
|
|
86
|
+
|
|
87
|
+
assert.strictEqual(
|
|
88
|
+
kanban.deriveEpicStatus([{ column: 'testing' }], {}),
|
|
89
|
+
'active'
|
|
90
|
+
);
|
|
91
|
+
const progress = kanban.getEpicProgress([
|
|
92
|
+
{ column: 'testing' },
|
|
93
|
+
{ column: 'review' }
|
|
94
|
+
]);
|
|
95
|
+
assert.strictEqual(progress.tasks_testing, 1);
|
|
96
|
+
assert.strictEqual(progress.tasks_review, 1);
|
|
97
|
+
|
|
98
|
+
console.log('✓ kanban.test.js passed');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
run().catch((err) => {
|
|
102
|
+
console.error(err);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
});
|