@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.
@@ -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-DBbtVfOb.js"></script>
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.1.4",
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",
@@ -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' },
@@ -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 !== 'done') return res.status(400).json({ error: 'Only completed tasks can be deleted' });
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
- const DEFAULT_MAX_REVIEW_CYCLES = 3;
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, DEFAULT_MAX_REVIEW_CYCLES),
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, DEFAULT_MAX_REVIEW_CYCLES),
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 || DEFAULT_MAX_REVIEW_CYCLES);
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: DEFAULT_MAX_REVIEW_CYCLES,
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: DEFAULT_MAX_REVIEW_CYCLES,
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 !== 'done') return false;
1222
+ if (!task || !['done', 'aborted'].includes(task.status)) return false;
1221
1223
 
1222
1224
  if (task.workspacePath) {
1223
1225
  await cleanupWorkspace(task);
@@ -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: 3,
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 = 3;
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: 3,
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 = 3;
258
+ task.maxReviewCycles = getDefaultMaxReviewCycles();
255
259
  changed = true;
256
260
  }
257
261
  if (typeof task.totalTokens !== 'number' || task.totalTokens < 0) {