@stilero/bankan 1.1.4 → 1.2.1

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.1",
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",
@@ -92,11 +92,38 @@ Key rules:
92
92
  - Ask for clarification only when a blocking unknown cannot be resolved from the repository context
93
93
  - Do not ask for approval in free-form prose; the human approval flow happens outside your response
94
94
  - Keep the plan specific, implementation-ready, and grounded in the current codebase`,
95
- implementation: `Follow the plan step by step
96
- - If required tools or dependencies are missing in the workspace, install them before continuing
97
- - Commit after each logical unit of work with descriptive commit messages
98
- - Run existing tests after implementation to verify nothing broke
99
- - After all work is done, make a final commit if there are any uncommitted changes`,
95
+ implementation: `Follow the plan step by step. Execute each step fully before moving on.
96
+
97
+ ## Use Subagents to Stay Focused
98
+ - Use subagents liberally to keep the main context window clean
99
+ - Offload research, exploration, file search, and parallel analysis to subagents
100
+ - Reserve the main context for implementation decisions and code changes
101
+
102
+ ## Be Autonomous
103
+ - When given a task: just do it. Don't ask for hand-holding.
104
+ - If something goes wrong, investigate logs, errors, and failing tests — then resolve them.
105
+ - Fix failing tests without being told how.
106
+ - If required tools or dependencies are missing in the workspace, install them before continuing.
107
+
108
+ ## Core Principles
109
+ - **Simplicity First**: Make every change as simple as possible. Impact minimal code.
110
+ - **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
111
+ - **Minimal Impact**: Only touch what's necessary. No side effects with new bugs.
112
+ - **Demand Elegance (Balanced)**: For non-trivial changes, pause and ask "is there a more elegant way?" Skip this for simple, obvious fixes — don't over-engineer.
113
+
114
+ ## Mandatory Test Verification
115
+ - Follow TDD when practical: create or update the failing test first, implement the change second, then rerun the suite.
116
+ - Run existing tests after implementation to verify nothing broke.
117
+ - Run the linter before finishing.
118
+
119
+ ## Definition of Done
120
+ - All plan steps are completed.
121
+ - Code compiles/runs without errors.
122
+ - Relevant tests pass (new and existing).
123
+ - Linter passes with no new violations.
124
+ - Commit after each logical unit of work with descriptive commit messages.
125
+ - Version bump if applicable, following the repository's versioning conventions.
126
+ - After all work is done, make a final commit if there are any uncommitted changes.`,
100
127
  review: `You are an expert code reviewer.
101
128
 
102
129
  Step 1 — Gather the diff
@@ -136,6 +163,7 @@ export function getDefaults() {
136
163
  implementors: { max: 8, cli: getLegacyImplementorCli(), model: '' },
137
164
  reviewers: { max: 4, cli: 'claude', model: '' },
138
165
  },
166
+ maxReviewCycles: 3,
139
167
  prompts: { ...DEFAULT_PROMPTS },
140
168
  };
141
169
  }
@@ -170,6 +198,10 @@ function normalizeSettingsShape(data) {
170
198
  }
171
199
  }
172
200
 
201
+ if (typeof data.maxReviewCycles !== 'number' || data.maxReviewCycles < 1) {
202
+ data.maxReviewCycles = defaults.maxReviewCycles;
203
+ }
204
+
173
205
  data.prompts = {
174
206
  ...defaults.prompts,
175
207
  ...(data.prompts || {}),
@@ -239,6 +271,10 @@ export function validateSettings(settings) {
239
271
  }
240
272
  }
241
273
 
274
+ if (typeof settings.maxReviewCycles !== 'number' || settings.maxReviewCycles < 1 || settings.maxReviewCycles > 20) {
275
+ errors.push('maxReviewCycles must be a number between 1 and 20');
276
+ }
277
+
242
278
  if (!settings.prompts || typeof settings.prompts !== 'object') {
243
279
  errors.push('prompts configuration is required');
244
280
  } 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) {