@stilero/bankan 1.0.5 → 1.0.7
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/README.md +8 -6
- package/package.json +1 -1
- package/scripts/setup.js +4 -14
- package/server/src/config.js +6 -3
- package/server/src/index.js +8 -24
- package/server/src/orchestrator.js +8 -6
- package/server/src/workflow.js +71 -0
- package/server/src/workflow.test.js +93 -0
package/README.md
CHANGED
|
@@ -93,15 +93,17 @@ bankan
|
|
|
93
93
|
|
|
94
94
|
2. Complete the setup wizard
|
|
95
95
|
|
|
96
|
-
3.
|
|
96
|
+
3. Configure agent CLIs in `Settings -> Implementation` and `Settings -> Review` as needed
|
|
97
97
|
|
|
98
|
-
4.
|
|
98
|
+
4. Add one or more repositories in `Settings -> General -> Repositories`
|
|
99
99
|
|
|
100
|
-
5.
|
|
100
|
+
5. Create a task in the dashboard
|
|
101
101
|
|
|
102
|
-
6.
|
|
102
|
+
6. Approve the generated plan
|
|
103
103
|
|
|
104
|
-
7.
|
|
104
|
+
7. Watch agents implement and review the change
|
|
105
|
+
|
|
106
|
+
8. Optionally create a pull request
|
|
105
107
|
|
|
106
108
|
---
|
|
107
109
|
|
|
@@ -340,7 +342,7 @@ Useful scripts:
|
|
|
340
342
|
|
|
341
343
|
- `npm run build` – build client bundle
|
|
342
344
|
- `npm run dev` – run server + Vite client
|
|
343
|
-
- `npm run setup` – interactive setup wizard for
|
|
345
|
+
- `npm run setup` – interactive setup wizard for local runtime config
|
|
344
346
|
- `npm run install:all` – install all dependencies
|
|
345
347
|
|
|
346
348
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stilero/bankan",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Run AI coding agents like a Kanban board. Plan, implement, review and ship code using parallel AI agents across your local repositories.",
|
|
6
6
|
"license": "MIT",
|
package/scripts/setup.js
CHANGED
|
@@ -134,26 +134,16 @@ async function main() {
|
|
|
134
134
|
console.log(` ${dim('Use the workspace folder in Settings to choose where task workspaces are created.')}`);
|
|
135
135
|
console.log('');
|
|
136
136
|
|
|
137
|
-
// Step 4:
|
|
138
|
-
console.log(bold('
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const imp1Answer = await ask(` IMPLEMENTOR_1_CLI ${dim(`[${imp1Default}]`)}: `);
|
|
142
|
-
config.IMPLEMENTOR_1_CLI = imp1Answer.trim() || imp1Default;
|
|
143
|
-
|
|
144
|
-
const imp2Default = existing.IMPLEMENTOR_2_CLI || 'codex';
|
|
145
|
-
const imp2Answer = await ask(` IMPLEMENTOR_2_CLI ${dim(`[${imp2Default}]`)}: `);
|
|
146
|
-
config.IMPLEMENTOR_2_CLI = imp2Answer.trim() || imp2Default;
|
|
137
|
+
// Step 4: Runtime Config
|
|
138
|
+
console.log(bold(' Runtime Configuration\n'));
|
|
139
|
+
console.log(` ${dim('Agent CLI selection is configured in the app under Settings.')}`);
|
|
140
|
+
console.log('');
|
|
147
141
|
|
|
148
142
|
config.PORT = existing.PORT || '3001';
|
|
149
143
|
|
|
150
|
-
console.log('');
|
|
151
|
-
|
|
152
144
|
// Step 5: Write .env.local
|
|
153
145
|
mkdirSync(runtimePaths.dataDir, { recursive: true });
|
|
154
146
|
const envLines = [
|
|
155
|
-
`IMPLEMENTOR_1_CLI=${config.IMPLEMENTOR_1_CLI || 'claude'}`,
|
|
156
|
-
`IMPLEMENTOR_2_CLI=${config.IMPLEMENTOR_2_CLI || 'codex'}`,
|
|
157
147
|
`PORT=${config.PORT}`,
|
|
158
148
|
];
|
|
159
149
|
writeFileSync(ENV_FILE, envLines.join('\n') + '\n');
|
package/server/src/config.js
CHANGED
|
@@ -28,8 +28,6 @@ function get(key, fallback = '') {
|
|
|
28
28
|
const config = {
|
|
29
29
|
PORT: parseInt(get('PORT', '3001'), 10),
|
|
30
30
|
REPOS: get('REPOS').split(',').map(s => s.trim()).filter(Boolean),
|
|
31
|
-
IMPLEMENTOR_1_CLI: get('IMPLEMENTOR_1_CLI', 'claude'),
|
|
32
|
-
IMPLEMENTOR_2_CLI: get('IMPLEMENTOR_2_CLI', 'codex'),
|
|
33
31
|
ROOT_DIR: runtimePaths.rootDir,
|
|
34
32
|
DATA_DIR: runtimePaths.dataDir,
|
|
35
33
|
CLIENT_DIST_DIR: runtimePaths.clientDistDir,
|
|
@@ -38,6 +36,11 @@ const config = {
|
|
|
38
36
|
PACKAGED_RUNTIME: runtimePaths.packaged,
|
|
39
37
|
};
|
|
40
38
|
|
|
39
|
+
function getLegacyImplementorCli() {
|
|
40
|
+
const legacyCli = get('IMPLEMENTOR_1_CLI', '');
|
|
41
|
+
return legacyCli === 'claude' || legacyCli === 'codex' ? legacyCli : 'claude';
|
|
42
|
+
}
|
|
43
|
+
|
|
41
44
|
const DEFAULT_PROMPTS = {
|
|
42
45
|
planning: `Plan Mode Instructions
|
|
43
46
|
|
|
@@ -105,7 +108,7 @@ export function getDefaults() {
|
|
|
105
108
|
workspaceRoot: DEFAULT_WORKSPACES_DIR,
|
|
106
109
|
agents: {
|
|
107
110
|
planners: { max: 4, cli: 'claude' },
|
|
108
|
-
implementors: { max: 8, cli:
|
|
111
|
+
implementors: { max: 8, cli: getLegacyImplementorCli() },
|
|
109
112
|
reviewers: { max: 4, cli: 'claude' },
|
|
110
113
|
},
|
|
111
114
|
prompts: { ...DEFAULT_PROMPTS },
|
package/server/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import config, { loadSettings, saveSettings, validateSettings, getWorkspacesDir,
|
|
|
11
11
|
import store from './store.js';
|
|
12
12
|
import agentManager from './agents.js';
|
|
13
13
|
import bus from './events.js';
|
|
14
|
+
import { getLiveTaskAgent, stageToRetryStatus } from './workflow.js';
|
|
14
15
|
|
|
15
16
|
const app = express();
|
|
16
17
|
app.use(cors());
|
|
@@ -46,28 +47,11 @@ function stageToResumeStatus(task) {
|
|
|
46
47
|
return planningDisabled ? 'queued' : 'backlog';
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function
|
|
50
|
+
function resolveRetryStatus(task) {
|
|
50
51
|
const settings = loadSettings();
|
|
51
52
|
const planningDisabled = settings.agents?.planners?.max === 0;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (task.assignedTo.startsWith('imp-')) return 'implementing';
|
|
55
|
-
if (task.assignedTo.startsWith('rev-')) return 'review';
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if ((task.blockedReason || '').includes('maximum review cycles')) {
|
|
59
|
-
return 'queued';
|
|
60
|
-
}
|
|
61
|
-
if (task.lastActiveStage === 'review') {
|
|
62
|
-
return 'review';
|
|
63
|
-
}
|
|
64
|
-
if (task.lastActiveStage === 'implementation') {
|
|
65
|
-
return 'queued';
|
|
66
|
-
}
|
|
67
|
-
if (task.lastActiveStage === 'planning') {
|
|
68
|
-
return task.plan ? 'awaiting_approval' : (planningDisabled ? 'queued' : 'backlog');
|
|
69
|
-
}
|
|
70
|
-
return planningDisabled ? 'queued' : 'backlog';
|
|
53
|
+
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
54
|
+
return stageToRetryStatus(task, { planningDisabled, liveAgent });
|
|
71
55
|
}
|
|
72
56
|
|
|
73
57
|
// REST API
|
|
@@ -545,10 +529,10 @@ wss.on('connection', (ws) => {
|
|
|
545
529
|
const { taskId } = msg.payload || {};
|
|
546
530
|
const task = store.getTask(taskId);
|
|
547
531
|
if (task && task.status === 'blocked') {
|
|
548
|
-
const retryStatus =
|
|
549
|
-
const agent = task
|
|
532
|
+
const retryStatus = resolveRetryStatus(task);
|
|
533
|
+
const agent = getLiveTaskAgent(task, agentManager);
|
|
550
534
|
|
|
551
|
-
if (agent
|
|
535
|
+
if (agent) {
|
|
552
536
|
agent.status = 'active';
|
|
553
537
|
bus.emit('agent:updated', agent.getStatus());
|
|
554
538
|
}
|
|
@@ -556,7 +540,7 @@ wss.on('connection', (ws) => {
|
|
|
556
540
|
store.updateTask(taskId, {
|
|
557
541
|
status: retryStatus,
|
|
558
542
|
blockedReason: null,
|
|
559
|
-
assignedTo: agent
|
|
543
|
+
assignedTo: agent ? task.assignedTo : null,
|
|
560
544
|
});
|
|
561
545
|
broadcast('TASK_RETRIED', { taskId, retryStatus });
|
|
562
546
|
}
|
|
@@ -7,6 +7,7 @@ import config, { loadSettings, getWorkspacesDir } from './config.js';
|
|
|
7
7
|
import store from './store.js';
|
|
8
8
|
import agentManager from './agents.js';
|
|
9
9
|
import bus from './events.js';
|
|
10
|
+
import { parseReviewResult, reviewShouldPass } from './workflow.js';
|
|
10
11
|
|
|
11
12
|
const POLL_INTERVAL = 4000;
|
|
12
13
|
const SIGNAL_CHECK_INTERVAL = 2500;
|
|
@@ -719,20 +720,21 @@ async function onReviewComplete(agentId, taskId) {
|
|
|
719
720
|
const sourceText = captured || bufStr;
|
|
720
721
|
const reviewText = getLastStructuredBlock(sourceText, '=== REVIEW START ===', '=== REVIEW END ===');
|
|
721
722
|
if (!reviewText) return;
|
|
722
|
-
const
|
|
723
|
-
const
|
|
723
|
+
const reviewResult = parseReviewResult(reviewText);
|
|
724
|
+
const shouldPass = reviewShouldPass(reviewResult);
|
|
724
725
|
|
|
725
726
|
store.updateTask(taskId, { review: reviewText });
|
|
726
727
|
reviewer.kill();
|
|
727
728
|
if (reviewer.draining) agentManager.removeAgent(agentId);
|
|
728
729
|
|
|
729
|
-
if (
|
|
730
|
+
if (shouldPass) {
|
|
731
|
+
if (reviewResult.verdict !== 'PASS') {
|
|
732
|
+
store.appendLog(taskId, 'Reviewer returned FAIL without critical issues; normalized to PASS.');
|
|
733
|
+
}
|
|
730
734
|
bus.emit('review:passed', { taskId });
|
|
731
735
|
await createPR(taskId);
|
|
732
736
|
} else {
|
|
733
|
-
|
|
734
|
-
const issuesMatch = reviewText.match(/CRITICAL_ISSUES:\s*([\s\S]*?)(?=MINOR_ISSUES:|SUMMARY:|=== REVIEW END ===)/i);
|
|
735
|
-
const criticalIssues = issuesMatch ? issuesMatch[1].trim() : 'Critical issues found';
|
|
737
|
+
const criticalIssues = reviewResult.criticalIssues.join('\n');
|
|
736
738
|
|
|
737
739
|
const task = store.getTask(taskId);
|
|
738
740
|
const nextReviewCycleCount = (task?.reviewCycleCount || 0) + 1;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function parseBulletList(sectionText) {
|
|
2
|
+
return (sectionText || '')
|
|
3
|
+
.split('\n')
|
|
4
|
+
.map(line => line.trim())
|
|
5
|
+
.filter(line => line.startsWith('- '))
|
|
6
|
+
.map(line => line.slice(2).trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractSection(text, label, nextLabels = []) {
|
|
11
|
+
if (typeof text !== 'string' || !text) return '';
|
|
12
|
+
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
13
|
+
const nextPattern = nextLabels.length > 0
|
|
14
|
+
? `(?=${nextLabels.map(item => item.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`
|
|
15
|
+
: '$';
|
|
16
|
+
const regex = new RegExp(`${escapedLabel}\\s*([\\s\\S]*?)${nextPattern}`, 'i');
|
|
17
|
+
const match = text.match(regex);
|
|
18
|
+
return match ? match[1].trim() : '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseReviewResult(reviewText) {
|
|
22
|
+
const verdictMatch = reviewText.match(/VERDICT:\s*(PASS|FAIL)/i);
|
|
23
|
+
const verdict = verdictMatch ? verdictMatch[1].toUpperCase() : 'FAIL';
|
|
24
|
+
const criticalIssues = parseBulletList(
|
|
25
|
+
extractSection(reviewText, 'CRITICAL_ISSUES:', ['MINOR_ISSUES:', 'SUMMARY:', '=== REVIEW END ==='])
|
|
26
|
+
).filter(item => item.toLowerCase() !== 'none');
|
|
27
|
+
const minorIssues = parseBulletList(
|
|
28
|
+
extractSection(reviewText, 'MINOR_ISSUES:', ['SUMMARY:', '=== REVIEW END ==='])
|
|
29
|
+
).filter(item => item.toLowerCase() !== 'none');
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
verdict,
|
|
33
|
+
criticalIssues,
|
|
34
|
+
minorIssues,
|
|
35
|
+
hasCriticalIssues: criticalIssues.length > 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function reviewShouldPass(reviewResult) {
|
|
40
|
+
return reviewResult.verdict === 'PASS' || !reviewResult.hasCriticalIssues;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getLiveTaskAgent(task, agentManager) {
|
|
44
|
+
if (!task?.assignedTo) return null;
|
|
45
|
+
const agent = agentManager.get(task.assignedTo);
|
|
46
|
+
if (!agent?.process) return null;
|
|
47
|
+
if (agent.currentTask !== task.id) return null;
|
|
48
|
+
return agent;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stageToRetryStatus(task, { planningDisabled = false, liveAgent = null } = {}) {
|
|
52
|
+
if (liveAgent) {
|
|
53
|
+
if (liveAgent.id.startsWith('plan-')) return 'planning';
|
|
54
|
+
if (liveAgent.id.startsWith('imp-')) return 'implementing';
|
|
55
|
+
if (liveAgent.id.startsWith('rev-')) return 'review';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ((task.blockedReason || '').includes('maximum review cycles')) {
|
|
59
|
+
return 'queued';
|
|
60
|
+
}
|
|
61
|
+
if (task.lastActiveStage === 'review') {
|
|
62
|
+
return 'review';
|
|
63
|
+
}
|
|
64
|
+
if (task.lastActiveStage === 'implementation') {
|
|
65
|
+
return 'queued';
|
|
66
|
+
}
|
|
67
|
+
if (task.lastActiveStage === 'planning') {
|
|
68
|
+
return task.plan ? 'awaiting_approval' : (planningDisabled ? 'queued' : 'backlog');
|
|
69
|
+
}
|
|
70
|
+
return planningDisabled ? 'queued' : 'backlog';
|
|
71
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getLiveTaskAgent,
|
|
6
|
+
parseReviewResult,
|
|
7
|
+
reviewShouldPass,
|
|
8
|
+
stageToRetryStatus,
|
|
9
|
+
} from './workflow.js';
|
|
10
|
+
|
|
11
|
+
test('minor-only review output is normalized to pass', () => {
|
|
12
|
+
const reviewText = `=== REVIEW START ===
|
|
13
|
+
VERDICT: FAIL
|
|
14
|
+
CRITICAL_ISSUES:
|
|
15
|
+
- none
|
|
16
|
+
MINOR_ISSUES:
|
|
17
|
+
- server/src/index.js: stale status label in toast copy
|
|
18
|
+
SUMMARY: Only minor issues were found.
|
|
19
|
+
=== REVIEW END ===`;
|
|
20
|
+
|
|
21
|
+
const result = parseReviewResult(reviewText);
|
|
22
|
+
|
|
23
|
+
assert.equal(result.verdict, 'FAIL');
|
|
24
|
+
assert.deepEqual(result.criticalIssues, []);
|
|
25
|
+
assert.equal(result.minorIssues.length, 1);
|
|
26
|
+
assert.equal(reviewShouldPass(result), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('review with critical issues still fails', () => {
|
|
30
|
+
const reviewText = `=== REVIEW START ===
|
|
31
|
+
VERDICT: FAIL
|
|
32
|
+
CRITICAL_ISSUES:
|
|
33
|
+
- server/src/orchestrator.js: review failures can loop indefinitely
|
|
34
|
+
MINOR_ISSUES:
|
|
35
|
+
- none
|
|
36
|
+
SUMMARY: A must-fix issue remains.
|
|
37
|
+
=== REVIEW END ===`;
|
|
38
|
+
|
|
39
|
+
const result = parseReviewResult(reviewText);
|
|
40
|
+
|
|
41
|
+
assert.equal(result.hasCriticalIssues, true);
|
|
42
|
+
assert.equal(reviewShouldPass(result), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('retry ignores stale assigned agent process from another task', () => {
|
|
46
|
+
const task = {
|
|
47
|
+
id: 'T-123',
|
|
48
|
+
assignedTo: 'imp-1',
|
|
49
|
+
blockedReason: 'Agent is awaiting user input',
|
|
50
|
+
lastActiveStage: 'implementation',
|
|
51
|
+
};
|
|
52
|
+
const agentManager = {
|
|
53
|
+
get(id) {
|
|
54
|
+
assert.equal(id, 'imp-1');
|
|
55
|
+
return {
|
|
56
|
+
id: 'imp-1',
|
|
57
|
+
process: { pid: 1234 },
|
|
58
|
+
currentTask: 'T-999',
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
64
|
+
const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
|
|
65
|
+
|
|
66
|
+
assert.equal(liveAgent, null);
|
|
67
|
+
assert.equal(retryStatus, 'queued');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('retry reuses live agent only when it still owns the task', () => {
|
|
71
|
+
const task = {
|
|
72
|
+
id: 'T-123',
|
|
73
|
+
assignedTo: 'imp-1',
|
|
74
|
+
blockedReason: 'Agent is awaiting user input',
|
|
75
|
+
lastActiveStage: 'implementation',
|
|
76
|
+
};
|
|
77
|
+
const liveAgentDef = {
|
|
78
|
+
id: 'imp-1',
|
|
79
|
+
process: { pid: 1234 },
|
|
80
|
+
currentTask: 'T-123',
|
|
81
|
+
};
|
|
82
|
+
const agentManager = {
|
|
83
|
+
get() {
|
|
84
|
+
return liveAgentDef;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const liveAgent = getLiveTaskAgent(task, agentManager);
|
|
89
|
+
const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
|
|
90
|
+
|
|
91
|
+
assert.equal(liveAgent, liveAgentDef);
|
|
92
|
+
assert.equal(retryStatus, 'implementing');
|
|
93
|
+
});
|