@stilero/bankan 1.0.6 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stilero/bankan",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
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",
@@ -11,6 +11,7 @@ import config, { loadSettings, saveSettings, validateSettings, getWorkspacesDir,
11
11
  import store from './store.js';
12
12
  import agentManager from './agents.js';
13
13
  import bus from './events.js';
14
+ import { getLiveTaskAgent, stageToRetryStatus } from './workflow.js';
14
15
 
15
16
  const app = express();
16
17
  app.use(cors());
@@ -46,28 +47,11 @@ function stageToResumeStatus(task) {
46
47
  return planningDisabled ? 'queued' : 'backlog';
47
48
  }
48
49
 
49
- function stageToRetryStatus(task) {
50
+ function resolveRetryStatus(task) {
50
51
  const settings = loadSettings();
51
52
  const planningDisabled = settings.agents?.planners?.max === 0;
52
- if (task.assignedTo) {
53
- if (task.assignedTo.startsWith('plan-')) return 'planning';
54
- if (task.assignedTo.startsWith('imp-')) return 'implementing';
55
- if (task.assignedTo.startsWith('rev-')) return 'review';
56
- }
57
-
58
- if ((task.blockedReason || '').includes('maximum review cycles')) {
59
- return 'queued';
60
- }
61
- if (task.lastActiveStage === 'review') {
62
- return 'review';
63
- }
64
- if (task.lastActiveStage === 'implementation') {
65
- return 'queued';
66
- }
67
- if (task.lastActiveStage === 'planning') {
68
- return task.plan ? 'awaiting_approval' : (planningDisabled ? 'queued' : 'backlog');
69
- }
70
- return planningDisabled ? 'queued' : 'backlog';
53
+ const liveAgent = getLiveTaskAgent(task, agentManager);
54
+ return stageToRetryStatus(task, { planningDisabled, liveAgent });
71
55
  }
72
56
 
73
57
  // REST API
@@ -545,10 +529,10 @@ wss.on('connection', (ws) => {
545
529
  const { taskId } = msg.payload || {};
546
530
  const task = store.getTask(taskId);
547
531
  if (task && task.status === 'blocked') {
548
- const retryStatus = stageToRetryStatus(task);
549
- const agent = task.assignedTo ? agentManager.get(task.assignedTo) : null;
532
+ const retryStatus = resolveRetryStatus(task);
533
+ const agent = getLiveTaskAgent(task, agentManager);
550
534
 
551
- if (agent && agent.process) {
535
+ if (agent) {
552
536
  agent.status = 'active';
553
537
  bus.emit('agent:updated', agent.getStatus());
554
538
  }
@@ -556,7 +540,7 @@ wss.on('connection', (ws) => {
556
540
  store.updateTask(taskId, {
557
541
  status: retryStatus,
558
542
  blockedReason: null,
559
- assignedTo: agent?.process ? task.assignedTo : null,
543
+ assignedTo: agent ? task.assignedTo : null,
560
544
  });
561
545
  broadcast('TASK_RETRIED', { taskId, retryStatus });
562
546
  }
@@ -7,6 +7,7 @@ import config, { loadSettings, getWorkspacesDir } from './config.js';
7
7
  import store from './store.js';
8
8
  import agentManager from './agents.js';
9
9
  import bus from './events.js';
10
+ import { parseReviewResult, reviewShouldPass } from './workflow.js';
10
11
 
11
12
  const POLL_INTERVAL = 4000;
12
13
  const SIGNAL_CHECK_INTERVAL = 2500;
@@ -719,20 +720,21 @@ async function onReviewComplete(agentId, taskId) {
719
720
  const sourceText = captured || bufStr;
720
721
  const reviewText = getLastStructuredBlock(sourceText, '=== REVIEW START ===', '=== REVIEW END ===');
721
722
  if (!reviewText) return;
722
- const verdictMatch = reviewText.match(/VERDICT:\s*(PASS|FAIL)/i);
723
- const verdict = verdictMatch ? verdictMatch[1].toUpperCase() : 'FAIL';
723
+ const reviewResult = parseReviewResult(reviewText);
724
+ const shouldPass = reviewShouldPass(reviewResult);
724
725
 
725
726
  store.updateTask(taskId, { review: reviewText });
726
727
  reviewer.kill();
727
728
  if (reviewer.draining) agentManager.removeAgent(agentId);
728
729
 
729
- if (verdict === 'PASS') {
730
+ if (shouldPass) {
731
+ if (reviewResult.verdict !== 'PASS') {
732
+ store.appendLog(taskId, 'Reviewer returned FAIL without critical issues; normalized to PASS.');
733
+ }
730
734
  bus.emit('review:passed', { taskId });
731
735
  await createPR(taskId);
732
736
  } else {
733
- // Extract critical issues
734
- const issuesMatch = reviewText.match(/CRITICAL_ISSUES:\s*([\s\S]*?)(?=MINOR_ISSUES:|SUMMARY:|=== REVIEW END ===)/i);
735
- const criticalIssues = issuesMatch ? issuesMatch[1].trim() : 'Critical issues found';
737
+ const criticalIssues = reviewResult.criticalIssues.join('\n');
736
738
 
737
739
  const task = store.getTask(taskId);
738
740
  const nextReviewCycleCount = (task?.reviewCycleCount || 0) + 1;
@@ -0,0 +1,71 @@
1
+ function parseBulletList(sectionText) {
2
+ return (sectionText || '')
3
+ .split('\n')
4
+ .map(line => line.trim())
5
+ .filter(line => line.startsWith('- '))
6
+ .map(line => line.slice(2).trim())
7
+ .filter(Boolean);
8
+ }
9
+
10
+ function extractSection(text, label, nextLabels = []) {
11
+ if (typeof text !== 'string' || !text) return '';
12
+ const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ const nextPattern = nextLabels.length > 0
14
+ ? `(?=${nextLabels.map(item => item.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`
15
+ : '$';
16
+ const regex = new RegExp(`${escapedLabel}\\s*([\\s\\S]*?)${nextPattern}`, 'i');
17
+ const match = text.match(regex);
18
+ return match ? match[1].trim() : '';
19
+ }
20
+
21
+ export function parseReviewResult(reviewText) {
22
+ const verdictMatch = reviewText.match(/VERDICT:\s*(PASS|FAIL)/i);
23
+ const verdict = verdictMatch ? verdictMatch[1].toUpperCase() : 'FAIL';
24
+ const criticalIssues = parseBulletList(
25
+ extractSection(reviewText, 'CRITICAL_ISSUES:', ['MINOR_ISSUES:', 'SUMMARY:', '=== REVIEW END ==='])
26
+ ).filter(item => item.toLowerCase() !== 'none');
27
+ const minorIssues = parseBulletList(
28
+ extractSection(reviewText, 'MINOR_ISSUES:', ['SUMMARY:', '=== REVIEW END ==='])
29
+ ).filter(item => item.toLowerCase() !== 'none');
30
+
31
+ return {
32
+ verdict,
33
+ criticalIssues,
34
+ minorIssues,
35
+ hasCriticalIssues: criticalIssues.length > 0,
36
+ };
37
+ }
38
+
39
+ export function reviewShouldPass(reviewResult) {
40
+ return reviewResult.verdict === 'PASS' || !reviewResult.hasCriticalIssues;
41
+ }
42
+
43
+ export function getLiveTaskAgent(task, agentManager) {
44
+ if (!task?.assignedTo) return null;
45
+ const agent = agentManager.get(task.assignedTo);
46
+ if (!agent?.process) return null;
47
+ if (agent.currentTask !== task.id) return null;
48
+ return agent;
49
+ }
50
+
51
+ export function stageToRetryStatus(task, { planningDisabled = false, liveAgent = null } = {}) {
52
+ if (liveAgent) {
53
+ if (liveAgent.id.startsWith('plan-')) return 'planning';
54
+ if (liveAgent.id.startsWith('imp-')) return 'implementing';
55
+ if (liveAgent.id.startsWith('rev-')) return 'review';
56
+ }
57
+
58
+ if ((task.blockedReason || '').includes('maximum review cycles')) {
59
+ return 'queued';
60
+ }
61
+ if (task.lastActiveStage === 'review') {
62
+ return 'review';
63
+ }
64
+ if (task.lastActiveStage === 'implementation') {
65
+ return 'queued';
66
+ }
67
+ if (task.lastActiveStage === 'planning') {
68
+ return task.plan ? 'awaiting_approval' : (planningDisabled ? 'queued' : 'backlog');
69
+ }
70
+ return planningDisabled ? 'queued' : 'backlog';
71
+ }
@@ -0,0 +1,93 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ getLiveTaskAgent,
6
+ parseReviewResult,
7
+ reviewShouldPass,
8
+ stageToRetryStatus,
9
+ } from './workflow.js';
10
+
11
+ test('minor-only review output is normalized to pass', () => {
12
+ const reviewText = `=== REVIEW START ===
13
+ VERDICT: FAIL
14
+ CRITICAL_ISSUES:
15
+ - none
16
+ MINOR_ISSUES:
17
+ - server/src/index.js: stale status label in toast copy
18
+ SUMMARY: Only minor issues were found.
19
+ === REVIEW END ===`;
20
+
21
+ const result = parseReviewResult(reviewText);
22
+
23
+ assert.equal(result.verdict, 'FAIL');
24
+ assert.deepEqual(result.criticalIssues, []);
25
+ assert.equal(result.minorIssues.length, 1);
26
+ assert.equal(reviewShouldPass(result), true);
27
+ });
28
+
29
+ test('review with critical issues still fails', () => {
30
+ const reviewText = `=== REVIEW START ===
31
+ VERDICT: FAIL
32
+ CRITICAL_ISSUES:
33
+ - server/src/orchestrator.js: review failures can loop indefinitely
34
+ MINOR_ISSUES:
35
+ - none
36
+ SUMMARY: A must-fix issue remains.
37
+ === REVIEW END ===`;
38
+
39
+ const result = parseReviewResult(reviewText);
40
+
41
+ assert.equal(result.hasCriticalIssues, true);
42
+ assert.equal(reviewShouldPass(result), false);
43
+ });
44
+
45
+ test('retry ignores stale assigned agent process from another task', () => {
46
+ const task = {
47
+ id: 'T-123',
48
+ assignedTo: 'imp-1',
49
+ blockedReason: 'Agent is awaiting user input',
50
+ lastActiveStage: 'implementation',
51
+ };
52
+ const agentManager = {
53
+ get(id) {
54
+ assert.equal(id, 'imp-1');
55
+ return {
56
+ id: 'imp-1',
57
+ process: { pid: 1234 },
58
+ currentTask: 'T-999',
59
+ };
60
+ },
61
+ };
62
+
63
+ const liveAgent = getLiveTaskAgent(task, agentManager);
64
+ const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
65
+
66
+ assert.equal(liveAgent, null);
67
+ assert.equal(retryStatus, 'queued');
68
+ });
69
+
70
+ test('retry reuses live agent only when it still owns the task', () => {
71
+ const task = {
72
+ id: 'T-123',
73
+ assignedTo: 'imp-1',
74
+ blockedReason: 'Agent is awaiting user input',
75
+ lastActiveStage: 'implementation',
76
+ };
77
+ const liveAgentDef = {
78
+ id: 'imp-1',
79
+ process: { pid: 1234 },
80
+ currentTask: 'T-123',
81
+ };
82
+ const agentManager = {
83
+ get() {
84
+ return liveAgentDef;
85
+ },
86
+ };
87
+
88
+ const liveAgent = getLiveTaskAgent(task, agentManager);
89
+ const retryStatus = stageToRetryStatus(task, { liveAgent, planningDisabled: false });
90
+
91
+ assert.equal(liveAgent, liveAgentDef);
92
+ assert.equal(retryStatus, 'implementing');
93
+ });