@stilero/bankan 1.1.4 → 1.2.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/client/dist/assets/index-B1C_mPSX.js +50 -0
- package/client/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/src/config.js +9 -0
- package/server/src/config.test.js +50 -0
- package/server/src/index.js +2 -2
- package/server/src/orchestrator.delete.test.js +95 -0
- package/server/src/orchestrator.js +9 -7
- package/server/src/store.js +9 -5
- package/client/dist/assets/index-DBbtVfOb.js +0 -50
package/client/dist/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@600;700;800&display=swap" rel="stylesheet" />
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-B1C_mPSX.js"></script>
|
|
11
11
|
<link rel="stylesheet" crossorigin href="/assets/index-BZkAflU1.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stilero/bankan",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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/server/src/config.js
CHANGED
|
@@ -136,6 +136,7 @@ export function getDefaults() {
|
|
|
136
136
|
implementors: { max: 8, cli: getLegacyImplementorCli(), model: '' },
|
|
137
137
|
reviewers: { max: 4, cli: 'claude', model: '' },
|
|
138
138
|
},
|
|
139
|
+
maxReviewCycles: 3,
|
|
139
140
|
prompts: { ...DEFAULT_PROMPTS },
|
|
140
141
|
};
|
|
141
142
|
}
|
|
@@ -170,6 +171,10 @@ function normalizeSettingsShape(data) {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
if (typeof data.maxReviewCycles !== 'number' || data.maxReviewCycles < 1) {
|
|
175
|
+
data.maxReviewCycles = defaults.maxReviewCycles;
|
|
176
|
+
}
|
|
177
|
+
|
|
173
178
|
data.prompts = {
|
|
174
179
|
...defaults.prompts,
|
|
175
180
|
...(data.prompts || {}),
|
|
@@ -239,6 +244,10 @@ export function validateSettings(settings) {
|
|
|
239
244
|
}
|
|
240
245
|
}
|
|
241
246
|
|
|
247
|
+
if (typeof settings.maxReviewCycles !== 'number' || settings.maxReviewCycles < 1 || settings.maxReviewCycles > 20) {
|
|
248
|
+
errors.push('maxReviewCycles must be a number between 1 and 20');
|
|
249
|
+
}
|
|
250
|
+
|
|
242
251
|
if (!settings.prompts || typeof settings.prompts !== 'object') {
|
|
243
252
|
errors.push('prompts configuration is required');
|
|
244
253
|
} else {
|
|
@@ -76,6 +76,53 @@ describe('config settings lifecycle', () => {
|
|
|
76
76
|
expect(errors).toContain('prompts.review must be a string');
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test('getDefaults includes maxReviewCycles and normalizeSettingsShape corrects invalid values', async () => {
|
|
80
|
+
harness = createRuntimeHarness();
|
|
81
|
+
const configModule = await harness.importModule('./src/config.js');
|
|
82
|
+
|
|
83
|
+
const defaults = configModule.getDefaults();
|
|
84
|
+
expect(defaults.maxReviewCycles).toBe(3);
|
|
85
|
+
|
|
86
|
+
configModule.saveSettings({
|
|
87
|
+
...defaults,
|
|
88
|
+
maxReviewCycles: -5,
|
|
89
|
+
});
|
|
90
|
+
const loaded = configModule.loadSettings();
|
|
91
|
+
expect(loaded.maxReviewCycles).toBe(3);
|
|
92
|
+
|
|
93
|
+
configModule.saveSettings({
|
|
94
|
+
...defaults,
|
|
95
|
+
maxReviewCycles: 'bad',
|
|
96
|
+
});
|
|
97
|
+
expect(configModule.loadSettings().maxReviewCycles).toBe(3);
|
|
98
|
+
|
|
99
|
+
configModule.saveSettings({
|
|
100
|
+
...defaults,
|
|
101
|
+
maxReviewCycles: 10,
|
|
102
|
+
});
|
|
103
|
+
expect(configModule.loadSettings().maxReviewCycles).toBe(10);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('validateSettings rejects out-of-range maxReviewCycles', async () => {
|
|
107
|
+
harness = createRuntimeHarness();
|
|
108
|
+
const { validateSettings, getDefaults } = await harness.importModule('./src/config.js');
|
|
109
|
+
const base = {
|
|
110
|
+
...getDefaults(),
|
|
111
|
+
repos: ['/repo'],
|
|
112
|
+
defaultRepoPath: '/repo',
|
|
113
|
+
workspaceRoot: '/tmp/ws',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
expect(validateSettings({ ...base, maxReviewCycles: 0 }))
|
|
117
|
+
.toContain('maxReviewCycles must be a number between 1 and 20');
|
|
118
|
+
expect(validateSettings({ ...base, maxReviewCycles: 21 }))
|
|
119
|
+
.toContain('maxReviewCycles must be a number between 1 and 20');
|
|
120
|
+
expect(validateSettings({ ...base, maxReviewCycles: 'bad' }))
|
|
121
|
+
.toContain('maxReviewCycles must be a number between 1 and 20');
|
|
122
|
+
expect(validateSettings({ ...base, maxReviewCycles: 5 }))
|
|
123
|
+
.not.toContain('maxReviewCycles must be a number between 1 and 20');
|
|
124
|
+
});
|
|
125
|
+
|
|
79
126
|
test('reads env defaults for repos, port, and legacy implementor cli', async () => {
|
|
80
127
|
harness = createRuntimeHarness();
|
|
81
128
|
const { writeFileSync } = await import('node:fs');
|
|
@@ -135,6 +182,7 @@ describe('config settings lifecycle', () => {
|
|
|
135
182
|
repos: ['/repo'],
|
|
136
183
|
defaultRepoPath: '/repo',
|
|
137
184
|
workspaceRoot: '/tmp/ws',
|
|
185
|
+
maxReviewCycles: 3,
|
|
138
186
|
agents: {
|
|
139
187
|
planners: { max: 1, cli: 'claude', model: 'claude-haiku-4-5' },
|
|
140
188
|
implementors: { max: 1, cli: 'claude', model: 'claude-opus-4-6' },
|
|
@@ -158,6 +206,7 @@ describe('config settings lifecycle', () => {
|
|
|
158
206
|
repos: ['/repo'],
|
|
159
207
|
defaultRepoPath: '/repo',
|
|
160
208
|
workspaceRoot: '/tmp/ws',
|
|
209
|
+
maxReviewCycles: 3,
|
|
161
210
|
agents: {
|
|
162
211
|
planners: { max: 1, cli: 'codex', model: 'claude-haiku-4-5' },
|
|
163
212
|
implementors: { max: 1, cli: 'claude', model: 'gpt-5.4' },
|
|
@@ -179,6 +228,7 @@ describe('config settings lifecycle', () => {
|
|
|
179
228
|
repos: ['/repo'],
|
|
180
229
|
defaultRepoPath: '/repo',
|
|
181
230
|
workspaceRoot: '/tmp/ws',
|
|
231
|
+
maxReviewCycles: 3,
|
|
182
232
|
agents: {
|
|
183
233
|
planners: { max: 1, cli: 'codex', model: 'gpt-5.3-codex' },
|
|
184
234
|
implementors: { max: 1, cli: 'codex', model: 'gpt-5.4' },
|
package/server/src/index.js
CHANGED
|
@@ -175,7 +175,7 @@ app.patch('/api/tasks/:id/extend-max-review-blocker', (req, res) => {
|
|
|
175
175
|
app.delete('/api/tasks/:id', async (req, res) => {
|
|
176
176
|
const task = store.getTask(req.params.id);
|
|
177
177
|
if (!task) return res.status(404).json({ error: 'Task not found' });
|
|
178
|
-
if (task.status
|
|
178
|
+
if (!['done', 'aborted'].includes(task.status)) return res.status(400).json({ error: 'Only completed or aborted tasks can be deleted' });
|
|
179
179
|
await orchestrator.deleteTask(task.id);
|
|
180
180
|
broadcast('TASK_DELETED', { taskId: task.id });
|
|
181
181
|
res.json({ ok: true });
|
|
@@ -585,7 +585,7 @@ wss.on('connection', (ws) => {
|
|
|
585
585
|
case 'DELETE_TASK': {
|
|
586
586
|
const { taskId } = msg.payload || {};
|
|
587
587
|
const task = store.getTask(taskId);
|
|
588
|
-
if (task?.status === 'done') {
|
|
588
|
+
if (task?.status === 'done' || task?.status === 'aborted') {
|
|
589
589
|
orchestrator.deleteTask(taskId);
|
|
590
590
|
broadcast('TASK_DELETED', { taskId });
|
|
591
591
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const getTask = vi.fn();
|
|
4
|
+
const deleteTaskStore = vi.fn();
|
|
5
|
+
const removePlan = vi.fn();
|
|
6
|
+
const appendLog = vi.fn();
|
|
7
|
+
|
|
8
|
+
vi.mock('./store.js', () => ({
|
|
9
|
+
default: {
|
|
10
|
+
getTask,
|
|
11
|
+
deleteTask: deleteTaskStore,
|
|
12
|
+
removePlan,
|
|
13
|
+
appendLog,
|
|
14
|
+
updateTask: vi.fn(),
|
|
15
|
+
restartRecovery: vi.fn(),
|
|
16
|
+
getAllTasks: vi.fn(() => []),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('./events.js', () => ({
|
|
21
|
+
default: {
|
|
22
|
+
emit: vi.fn(),
|
|
23
|
+
on: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('./agents.js', () => ({
|
|
28
|
+
default: {
|
|
29
|
+
get: vi.fn(),
|
|
30
|
+
getAvailablePlanner: vi.fn(),
|
|
31
|
+
getAvailableImplementor: vi.fn(),
|
|
32
|
+
getAvailableReviewer: vi.fn(),
|
|
33
|
+
getAllStatus: vi.fn(() => []),
|
|
34
|
+
agents: new Map(),
|
|
35
|
+
reconfigure: vi.fn(),
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock('./config.js', () => ({
|
|
40
|
+
loadSettings: vi.fn(() => ({
|
|
41
|
+
agents: {
|
|
42
|
+
planners: { max: 1 },
|
|
43
|
+
implementors: { max: 1 },
|
|
44
|
+
reviewers: { max: 1 },
|
|
45
|
+
},
|
|
46
|
+
})),
|
|
47
|
+
getWorkspacesDir: vi.fn(() => '/tmp/workspaces'),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('simple-git', () => ({
|
|
51
|
+
simpleGit: vi.fn(() => ({})),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const orchestrator = (await import('./orchestrator.js')).default;
|
|
55
|
+
const deleteTask = orchestrator.deleteTask;
|
|
56
|
+
|
|
57
|
+
describe('deleteTask', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
getTask.mockReset();
|
|
60
|
+
deleteTaskStore.mockReset();
|
|
61
|
+
removePlan.mockReset();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('succeeds for a task with status done', async () => {
|
|
65
|
+
getTask.mockReturnValue({ id: 'T-1', status: 'done', workspacePath: null });
|
|
66
|
+
const result = await deleteTask('T-1');
|
|
67
|
+
expect(result).toBe(true);
|
|
68
|
+
expect(removePlan).toHaveBeenCalledWith('T-1');
|
|
69
|
+
expect(deleteTaskStore).toHaveBeenCalledWith('T-1');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('succeeds for a task with status aborted', async () => {
|
|
73
|
+
getTask.mockReturnValue({ id: 'T-2', status: 'aborted', workspacePath: null });
|
|
74
|
+
const result = await deleteTask('T-2');
|
|
75
|
+
expect(result).toBe(true);
|
|
76
|
+
expect(removePlan).toHaveBeenCalledWith('T-2');
|
|
77
|
+
expect(deleteTaskStore).toHaveBeenCalledWith('T-2');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('rejects tasks in non-terminal statuses', async () => {
|
|
81
|
+
for (const status of ['backlog', 'planning', 'implementing', 'review', 'blocked']) {
|
|
82
|
+
getTask.mockReturnValue({ id: 'T-3', status, workspacePath: null });
|
|
83
|
+
const result = await deleteTask('T-3');
|
|
84
|
+
expect(result).toBe(false);
|
|
85
|
+
expect(deleteTaskStore).not.toHaveBeenCalled();
|
|
86
|
+
deleteTaskStore.mockReset();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('returns false when task does not exist', async () => {
|
|
91
|
+
getTask.mockReturnValue(undefined);
|
|
92
|
+
const result = await deleteTask('T-999');
|
|
93
|
+
expect(result).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -24,7 +24,9 @@ const PLANNER_TIMEOUT = 5 * 60 * 1000;
|
|
|
24
24
|
const IMPLEMENTOR_TIMEOUT = 60 * 60 * 1000;
|
|
25
25
|
const REVIEWER_TIMEOUT = 30 * 60 * 1000;
|
|
26
26
|
const STUCK_TIMEOUT = 10 * 60 * 1000;
|
|
27
|
-
|
|
27
|
+
function getMaxReviewCycles() {
|
|
28
|
+
return loadSettings().maxReviewCycles || 3;
|
|
29
|
+
}
|
|
28
30
|
|
|
29
31
|
let pollTimer = null;
|
|
30
32
|
let signalTimer = null;
|
|
@@ -789,7 +791,7 @@ async function startPlanning(task) {
|
|
|
789
791
|
review: null,
|
|
790
792
|
reviewFeedback: null,
|
|
791
793
|
reviewCycleCount: 0,
|
|
792
|
-
maxReviewCycles: resolveTaskMaxReviewCycles(task,
|
|
794
|
+
maxReviewCycles: resolveTaskMaxReviewCycles(task, getMaxReviewCycles()),
|
|
793
795
|
blockedReason: null,
|
|
794
796
|
assignedTo: null,
|
|
795
797
|
});
|
|
@@ -870,7 +872,7 @@ function onPlanComplete(agentId, taskId) {
|
|
|
870
872
|
review: null,
|
|
871
873
|
reviewFeedback: null,
|
|
872
874
|
reviewCycleCount: 0,
|
|
873
|
-
maxReviewCycles: resolveTaskMaxReviewCycles(task,
|
|
875
|
+
maxReviewCycles: resolveTaskMaxReviewCycles(task, getMaxReviewCycles()),
|
|
874
876
|
blockedReason: null,
|
|
875
877
|
assignedTo: null,
|
|
876
878
|
});
|
|
@@ -1051,7 +1053,7 @@ async function onReviewComplete(agentId, taskId) {
|
|
|
1051
1053
|
|
|
1052
1054
|
const task = store.getTask(taskId);
|
|
1053
1055
|
const nextReviewCycleCount = (task?.reviewCycleCount || 0) + 1;
|
|
1054
|
-
const maxReviewCycles = Math.max(1, task?.maxReviewCycles ||
|
|
1056
|
+
const maxReviewCycles = Math.max(1, task?.maxReviewCycles || getMaxReviewCycles());
|
|
1055
1057
|
|
|
1056
1058
|
if (nextReviewCycleCount >= maxReviewCycles) {
|
|
1057
1059
|
store.updateTask(taskId, {
|
|
@@ -1171,7 +1173,7 @@ async function abortTask(taskId) {
|
|
|
1171
1173
|
reviewFeedback: null,
|
|
1172
1174
|
previousStatus: null,
|
|
1173
1175
|
reviewCycleCount: 0,
|
|
1174
|
-
maxReviewCycles:
|
|
1176
|
+
maxReviewCycles: getMaxReviewCycles(),
|
|
1175
1177
|
});
|
|
1176
1178
|
|
|
1177
1179
|
bus.emit('task:aborted', { taskId });
|
|
@@ -1203,7 +1205,7 @@ async function resetTask(taskId) {
|
|
|
1203
1205
|
planFeedback: null,
|
|
1204
1206
|
previousStatus: null,
|
|
1205
1207
|
reviewCycleCount: 0,
|
|
1206
|
-
maxReviewCycles:
|
|
1208
|
+
maxReviewCycles: getMaxReviewCycles(),
|
|
1207
1209
|
sessionHistory: [],
|
|
1208
1210
|
progress: 0,
|
|
1209
1211
|
totalTokens: 0,
|
|
@@ -1217,7 +1219,7 @@ async function resetTask(taskId) {
|
|
|
1217
1219
|
|
|
1218
1220
|
async function deleteTask(taskId) {
|
|
1219
1221
|
const task = store.getTask(taskId);
|
|
1220
|
-
if (!task || task.status
|
|
1222
|
+
if (!task || !['done', 'aborted'].includes(task.status)) return false;
|
|
1221
1223
|
|
|
1222
1224
|
if (task.workspacePath) {
|
|
1223
1225
|
await cleanupWorkspace(task);
|
package/server/src/store.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { v4 as uuidv4 } from 'uuid';
|
|
4
4
|
import bus from './events.js';
|
|
5
|
-
import { getRuntimeStatePaths } from './config.js';
|
|
5
|
+
import { getRuntimeStatePaths, loadSettings } from './config.js';
|
|
6
6
|
|
|
7
7
|
const runtimePaths = getRuntimeStatePaths();
|
|
8
8
|
const DATA_DIR = runtimePaths.dataDir;
|
|
@@ -42,6 +42,10 @@ function isLegacyPlannerPathBlocker(task) {
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function getDefaultMaxReviewCycles() {
|
|
46
|
+
return loadSettings().maxReviewCycles || 3;
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
class TaskStore {
|
|
46
50
|
constructor() {
|
|
47
51
|
this.tasks = [];
|
|
@@ -61,7 +65,7 @@ class TaskStore {
|
|
|
61
65
|
this.tasks = this.tasks.map(task => {
|
|
62
66
|
const normalized = {
|
|
63
67
|
reviewCycleCount: 0,
|
|
64
|
-
maxReviewCycles:
|
|
68
|
+
maxReviewCycles: getDefaultMaxReviewCycles(),
|
|
65
69
|
lastActiveStage: statusToStage(task.status) || 'backlog',
|
|
66
70
|
previousStatus: null,
|
|
67
71
|
totalTokens: 0,
|
|
@@ -82,7 +86,7 @@ class TaskStore {
|
|
|
82
86
|
normalized.reviewCycleCount = 0;
|
|
83
87
|
}
|
|
84
88
|
if (typeof normalized.maxReviewCycles !== 'number' || normalized.maxReviewCycles < 1) {
|
|
85
|
-
normalized.maxReviewCycles =
|
|
89
|
+
normalized.maxReviewCycles = getDefaultMaxReviewCycles();
|
|
86
90
|
}
|
|
87
91
|
if (typeof normalized.totalTokens !== 'number' || normalized.totalTokens < 0) {
|
|
88
92
|
normalized.totalTokens = 0;
|
|
@@ -134,7 +138,7 @@ class TaskStore {
|
|
|
134
138
|
blockedReason: null,
|
|
135
139
|
workspacePath: null,
|
|
136
140
|
reviewCycleCount: 0,
|
|
137
|
-
maxReviewCycles:
|
|
141
|
+
maxReviewCycles: getDefaultMaxReviewCycles(),
|
|
138
142
|
lastActiveStage: 'backlog',
|
|
139
143
|
previousStatus: null,
|
|
140
144
|
totalTokens: 0,
|
|
@@ -251,7 +255,7 @@ class TaskStore {
|
|
|
251
255
|
changed = true;
|
|
252
256
|
}
|
|
253
257
|
if (typeof task.maxReviewCycles !== 'number' || task.maxReviewCycles < 1) {
|
|
254
|
-
task.maxReviewCycles =
|
|
258
|
+
task.maxReviewCycles = getDefaultMaxReviewCycles();
|
|
255
259
|
changed = true;
|
|
256
260
|
}
|
|
257
261
|
if (typeof task.totalTokens !== 'number' || task.totalTokens < 0) {
|