@stilero/bankan 1.1.3 → 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.3",
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;
@@ -42,8 +44,10 @@ function stripAnsi(text) {
42
44
  );
43
45
  }
44
46
 
47
+ const isWindows = process.platform === 'win32';
48
+
45
49
  function escapePrompt(text) {
46
- if (process.platform === 'win32') {
50
+ if (isWindows) {
47
51
  // PowerShell: escape single quotes by doubling them
48
52
  return text.replace(/'/g, "''");
49
53
  }
@@ -51,9 +55,6 @@ function escapePrompt(text) {
51
55
  return text.replace(/'/g, "'\\''");
52
56
  }
53
57
 
54
- // TODO: buildCodexExecCommand emits bash syntax (mktemp, $?, printf).
55
- // On Windows the agent shell is PowerShell, so codex with captureLastMessage
56
- // will not work until this function gets a win32 branch.
57
58
  function buildCodexExecCommand(prompt, { captureLastMessage = false, sandbox = 'read-only', model = '' } = {}) {
58
59
  const escapedPrompt = escapePrompt(prompt);
59
60
  const modelFlag = model ? `-m ${model} ` : '';
@@ -64,13 +65,17 @@ function buildCodexExecCommand(prompt, { captureLastMessage = false, sandbox = '
64
65
  return `tmpfile=$(mktemp); codex exec ${modelFlag}--sandbox ${sandbox} -o "$tmpfile" '${escapedPrompt}'; status=$?; printf '\\n=== CODEX_LAST_MESSAGE_FILE:%s ===\\n' "$tmpfile"; exit $status`;
65
66
  }
66
67
 
68
+ // On Windows the agent shell is PowerShell, so the bash-syntax
69
+ // captureLastMessage path cannot work — the structured-capture and
70
+ // terminal-buffer fallbacks in extractStructuredStageText still apply.
67
71
  export function buildAgentCommand(cliTool, prompt, mode = 'interactive', model = '') {
68
72
  if (cliTool === 'codex') {
73
+ const capture = !isWindows;
69
74
  if (mode === 'plan' || mode === 'review') {
70
- return buildCodexExecCommand(prompt, { captureLastMessage: true, sandbox: 'read-only', model });
75
+ return buildCodexExecCommand(prompt, { captureLastMessage: capture, sandbox: 'read-only', model });
71
76
  }
72
77
  if (mode === 'interactive') {
73
- return buildCodexExecCommand(prompt, { captureLastMessage: true, sandbox: 'danger-full-access', model });
78
+ return buildCodexExecCommand(prompt, { captureLastMessage: capture, sandbox: 'danger-full-access', model });
74
79
  }
75
80
  return buildCodexExecCommand(prompt, { captureLastMessage: false, sandbox: 'read-only', model });
76
81
  }
@@ -786,7 +791,7 @@ async function startPlanning(task) {
786
791
  review: null,
787
792
  reviewFeedback: null,
788
793
  reviewCycleCount: 0,
789
- maxReviewCycles: resolveTaskMaxReviewCycles(task, DEFAULT_MAX_REVIEW_CYCLES),
794
+ maxReviewCycles: resolveTaskMaxReviewCycles(task, getMaxReviewCycles()),
790
795
  blockedReason: null,
791
796
  assignedTo: null,
792
797
  });
@@ -867,7 +872,7 @@ function onPlanComplete(agentId, taskId) {
867
872
  review: null,
868
873
  reviewFeedback: null,
869
874
  reviewCycleCount: 0,
870
- maxReviewCycles: resolveTaskMaxReviewCycles(task, DEFAULT_MAX_REVIEW_CYCLES),
875
+ maxReviewCycles: resolveTaskMaxReviewCycles(task, getMaxReviewCycles()),
871
876
  blockedReason: null,
872
877
  assignedTo: null,
873
878
  });
@@ -1048,7 +1053,7 @@ async function onReviewComplete(agentId, taskId) {
1048
1053
 
1049
1054
  const task = store.getTask(taskId);
1050
1055
  const nextReviewCycleCount = (task?.reviewCycleCount || 0) + 1;
1051
- const maxReviewCycles = Math.max(1, task?.maxReviewCycles || DEFAULT_MAX_REVIEW_CYCLES);
1056
+ const maxReviewCycles = Math.max(1, task?.maxReviewCycles || getMaxReviewCycles());
1052
1057
 
1053
1058
  if (nextReviewCycleCount >= maxReviewCycles) {
1054
1059
  store.updateTask(taskId, {
@@ -1168,7 +1173,7 @@ async function abortTask(taskId) {
1168
1173
  reviewFeedback: null,
1169
1174
  previousStatus: null,
1170
1175
  reviewCycleCount: 0,
1171
- maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
1176
+ maxReviewCycles: getMaxReviewCycles(),
1172
1177
  });
1173
1178
 
1174
1179
  bus.emit('task:aborted', { taskId });
@@ -1200,7 +1205,7 @@ async function resetTask(taskId) {
1200
1205
  planFeedback: null,
1201
1206
  previousStatus: null,
1202
1207
  reviewCycleCount: 0,
1203
- maxReviewCycles: DEFAULT_MAX_REVIEW_CYCLES,
1208
+ maxReviewCycles: getMaxReviewCycles(),
1204
1209
  sessionHistory: [],
1205
1210
  progress: 0,
1206
1211
  totalTokens: 0,
@@ -1214,7 +1219,7 @@ async function resetTask(taskId) {
1214
1219
 
1215
1220
  async function deleteTask(taskId) {
1216
1221
  const task = store.getTask(taskId);
1217
- if (!task || task.status !== 'done') return false;
1222
+ if (!task || !['done', 'aborted'].includes(task.status)) return false;
1218
1223
 
1219
1224
  if (task.workspacePath) {
1220
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) {