@stilero/bankan 1.0.21 → 1.1.2

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.
@@ -13,7 +13,7 @@ function statusToStage(status) {
13
13
  if (['workspace_setup', 'planning', 'awaiting_approval'].includes(status)) return 'planning';
14
14
  if (['queued', 'implementing'].includes(status)) return 'implementation';
15
15
  if (status === 'review') return 'review';
16
- if (status === 'done') return 'done';
16
+ if (status === 'done' || status === 'awaiting_manual_pr') return 'done';
17
17
  if (['backlog', 'aborted'].includes(status)) return 'backlog';
18
18
  return null;
19
19
  }
@@ -61,6 +61,7 @@ class TaskStore {
61
61
  this.tasks = this.tasks.map(task => {
62
62
  const normalized = {
63
63
  reviewCycleCount: 0,
64
+ maxReviewCycles: 3,
64
65
  lastActiveStage: statusToStage(task.status) || 'backlog',
65
66
  previousStatus: null,
66
67
  totalTokens: 0,
@@ -80,6 +81,9 @@ class TaskStore {
80
81
  if (typeof normalized.reviewCycleCount !== 'number' || normalized.reviewCycleCount < 0) {
81
82
  normalized.reviewCycleCount = 0;
82
83
  }
84
+ if (typeof normalized.maxReviewCycles !== 'number' || normalized.maxReviewCycles < 1) {
85
+ normalized.maxReviewCycles = 3;
86
+ }
83
87
  if (typeof normalized.totalTokens !== 'number' || normalized.totalTokens < 0) {
84
88
  normalized.totalTokens = 0;
85
89
  }
@@ -130,6 +134,7 @@ class TaskStore {
130
134
  blockedReason: null,
131
135
  workspacePath: null,
132
136
  reviewCycleCount: 0,
137
+ maxReviewCycles: 3,
133
138
  lastActiveStage: 'backlog',
134
139
  previousStatus: null,
135
140
  totalTokens: 0,
@@ -245,6 +250,10 @@ class TaskStore {
245
250
  task.reviewCycleCount = 0;
246
251
  changed = true;
247
252
  }
253
+ if (typeof task.maxReviewCycles !== 'number' || task.maxReviewCycles < 1) {
254
+ task.maxReviewCycles = 3;
255
+ changed = true;
256
+ }
248
257
  if (typeof task.totalTokens !== 'number' || task.totalTokens < 0) {
249
258
  task.totalTokens = 0;
250
259
  changed = true;
@@ -26,6 +26,7 @@ describe('TaskStore persistence and recovery', () => {
26
26
  expect(task.status).toBe('backlog');
27
27
  expect(task.lastActiveStage).toBe('backlog');
28
28
  expect(task.log.at(-1).message).toBe('Task created');
29
+ expect(task.maxReviewCycles).toBe(3);
29
30
 
30
31
  store.updateTask(task.id, { status: 'planning', assignedTo: 'plan-1' });
31
32
  store.appendLog(task.id, 'Planner started');
@@ -125,6 +126,7 @@ describe('TaskStore persistence and recovery', () => {
125
126
  status: 'awaiting_human_review',
126
127
  repoPath: '/repo',
127
128
  reviewCycleCount: -2,
129
+ maxReviewCycles: -1,
128
130
  totalTokens: -10,
129
131
  startedAt: 42,
130
132
  completedAt: 24,
@@ -143,6 +145,7 @@ describe('TaskStore persistence and recovery', () => {
143
145
  expect(task.assignedTo).toBeNull();
144
146
  expect(task.workspacePath).toBeNull();
145
147
  expect(task.reviewCycleCount).toBe(0);
148
+ expect(task.maxReviewCycles).toBe(3);
146
149
  expect(task.totalTokens).toBe(0);
147
150
  expect(task.startedAt).toBeNull();
148
151
  expect(task.completedAt).toBeNull();
@@ -170,6 +173,7 @@ describe('TaskStore persistence and recovery', () => {
170
173
  blockedReason: 'Invalid planner working directory: git@github.com:stilero/bankan.git',
171
174
  workspacePath: null,
172
175
  reviewCycleCount: -1,
176
+ maxReviewCycles: -1,
173
177
  totalTokens: -1,
174
178
  lastActiveStage: null,
175
179
  });
@@ -181,6 +185,7 @@ describe('TaskStore persistence and recovery', () => {
181
185
  expect(recovered.previousStatus).toBeNull();
182
186
  expect(recovered.lastActiveStage).toBe('backlog');
183
187
  expect(recovered.reviewCycleCount).toBe(0);
188
+ expect(recovered.maxReviewCycles).toBe(3);
184
189
  expect(recovered.totalTokens).toBe(0);
185
190
  });
186
191
  });
@@ -42,6 +42,40 @@ export function reviewShouldPass(reviewResult) {
42
42
  return reviewResult.verdict === 'PASS' || !reviewResult.hasCriticalIssues;
43
43
  }
44
44
 
45
+ export function isMaxReviewCyclesBlocker(blockedReason) {
46
+ if (typeof blockedReason !== 'string') return false;
47
+ return /^Reached maximum review cycles(?: \(\d+\))?/i.test(blockedReason.trim());
48
+ }
49
+
50
+ export function resolveTaskMaxReviewCycles(task, fallback = 3) {
51
+ const configuredValue = task?.maxReviewCycles;
52
+ if (typeof configuredValue === 'number' && configuredValue >= 1) {
53
+ return configuredValue;
54
+ }
55
+ return fallback;
56
+ }
57
+
58
+ export function buildMaxReviewBlockerApprovalUpdate(task, now = new Date().toISOString()) {
59
+ if (task?.status !== 'blocked' || !isMaxReviewCyclesBlocker(task?.blockedReason)) return null;
60
+ return {
61
+ status: 'done',
62
+ blockedReason: null,
63
+ assignedTo: null,
64
+ workspacePath: null,
65
+ completedAt: task.completedAt || now,
66
+ };
67
+ }
68
+
69
+ export function buildMaxReviewBlockerExtensionUpdate(task) {
70
+ if (task?.status !== 'blocked' || !isMaxReviewCyclesBlocker(task?.blockedReason)) return null;
71
+ return {
72
+ status: 'queued',
73
+ blockedReason: null,
74
+ assignedTo: null,
75
+ maxReviewCycles: resolveTaskMaxReviewCycles(task) + 1,
76
+ };
77
+ }
78
+
45
79
  export function isReviewResultPlaceholder(reviewText, reviewResult = parseReviewResult(reviewText)) {
46
80
  if (typeof reviewText !== 'string' || !reviewText.trim()) return true;
47
81
 
@@ -123,7 +157,7 @@ export function stageToRetryStatus(task, { planningDisabled = false, liveAgent =
123
157
  if (stage === 'review') return 'review';
124
158
  }
125
159
 
126
- if ((task.blockedReason || '').includes('maximum review cycles')) {
160
+ if (isMaxReviewCyclesBlocker(task.blockedReason || '')) {
127
161
  return 'queued';
128
162
  }
129
163
  if (task.lastActiveStage === 'review') {
@@ -1,12 +1,16 @@
1
1
  import { describe, expect, test } from 'vitest';
2
2
 
3
3
  import {
4
- getLiveTaskAgent,
4
+ buildMaxReviewBlockerApprovalUpdate,
5
+ buildMaxReviewBlockerExtensionUpdate,
5
6
  getAgentStage,
7
+ getLiveTaskAgent,
6
8
  isImplementationPlaceholder,
7
- isReviewResultPlaceholder,
9
+ isMaxReviewCyclesBlocker,
8
10
  isPlanPlaceholder,
11
+ isReviewResultPlaceholder,
9
12
  parseReviewResult,
13
+ resolveTaskMaxReviewCycles,
10
14
  reviewShouldPass,
11
15
  stageToRetryStatus,
12
16
  } from './workflow.js';
@@ -270,6 +274,51 @@ describe('retry status resolution', () => {
270
274
  }, { planningDisabled: false })).toBe('queued');
271
275
  });
272
276
 
277
+ test('maximum review cycle blockers are detected from the canonical message', () => {
278
+ expect(isMaxReviewCyclesBlocker('Reached maximum review cycles (3). Human input required.')).toBe(true);
279
+ expect(isMaxReviewCyclesBlocker('Reached maximum review cycles (3) Human input required.')).toBe(true);
280
+ expect(isMaxReviewCyclesBlocker('Invalid workspace path for review: /tmp/workspace')).toBe(false);
281
+ });
282
+
283
+ test('builds approval and extension updates for max review blockers', () => {
284
+ const task = {
285
+ status: 'blocked',
286
+ blockedReason: 'Reached maximum review cycles (3). Human input required.',
287
+ maxReviewCycles: 3,
288
+ };
289
+
290
+ const approved = buildMaxReviewBlockerApprovalUpdate(task);
291
+ const extended = buildMaxReviewBlockerExtensionUpdate(task);
292
+
293
+ expect(approved).toMatchObject({
294
+ status: 'done',
295
+ blockedReason: null,
296
+ assignedTo: null,
297
+ workspacePath: null,
298
+ });
299
+ expect(typeof approved.completedAt).toBe('string');
300
+ expect(extended).toEqual({
301
+ status: 'queued',
302
+ blockedReason: null,
303
+ assignedTo: null,
304
+ maxReviewCycles: 4,
305
+ });
306
+ expect(buildMaxReviewBlockerApprovalUpdate({
307
+ status: 'blocked',
308
+ blockedReason: 'Different blocker',
309
+ })).toBeNull();
310
+ expect(buildMaxReviewBlockerExtensionUpdate({
311
+ status: 'review',
312
+ blockedReason: 'Reached maximum review cycles (3). Human input required.',
313
+ })).toBeNull();
314
+ });
315
+
316
+ test('preserves an existing per-task review cap and falls back only when missing or invalid', () => {
317
+ expect(resolveTaskMaxReviewCycles({ maxReviewCycles: 7 }, 3)).toBe(7);
318
+ expect(resolveTaskMaxReviewCycles({ maxReviewCycles: 0 }, 3)).toBe(3);
319
+ expect(resolveTaskMaxReviewCycles({}, 3)).toBe(3);
320
+ });
321
+
273
322
  test('live planner and reviewer agents preserve their active stage', () => {
274
323
  expect(stageToRetryStatus({
275
324
  lastActiveStage: 'implementation',