@renseiai/agentfactory-cli 0.8.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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/dist/src/agent.d.ts +20 -0
  4. package/dist/src/agent.d.ts.map +1 -0
  5. package/dist/src/agent.js +109 -0
  6. package/dist/src/analyze-logs.d.ts +26 -0
  7. package/dist/src/analyze-logs.d.ts.map +1 -0
  8. package/dist/src/analyze-logs.js +152 -0
  9. package/dist/src/cleanup.d.ts +17 -0
  10. package/dist/src/cleanup.d.ts.map +1 -0
  11. package/dist/src/cleanup.js +111 -0
  12. package/dist/src/governor.d.ts +26 -0
  13. package/dist/src/governor.d.ts.map +1 -0
  14. package/dist/src/governor.js +305 -0
  15. package/dist/src/index.d.ts +10 -0
  16. package/dist/src/index.d.ts.map +1 -0
  17. package/dist/src/index.js +76 -0
  18. package/dist/src/lib/agent-runner.d.ts +28 -0
  19. package/dist/src/lib/agent-runner.d.ts.map +1 -0
  20. package/dist/src/lib/agent-runner.js +272 -0
  21. package/dist/src/lib/analyze-logs-runner.d.ts +47 -0
  22. package/dist/src/lib/analyze-logs-runner.d.ts.map +1 -0
  23. package/dist/src/lib/analyze-logs-runner.js +216 -0
  24. package/dist/src/lib/auto-updater.d.ts +40 -0
  25. package/dist/src/lib/auto-updater.d.ts.map +1 -0
  26. package/dist/src/lib/auto-updater.js +109 -0
  27. package/dist/src/lib/cleanup-runner.d.ts +29 -0
  28. package/dist/src/lib/cleanup-runner.d.ts.map +1 -0
  29. package/dist/src/lib/cleanup-runner.js +295 -0
  30. package/dist/src/lib/governor-dependencies.d.ts +23 -0
  31. package/dist/src/lib/governor-dependencies.d.ts.map +1 -0
  32. package/dist/src/lib/governor-dependencies.js +361 -0
  33. package/dist/src/lib/governor-logger.d.ts +30 -0
  34. package/dist/src/lib/governor-logger.d.ts.map +1 -0
  35. package/dist/src/lib/governor-logger.js +210 -0
  36. package/dist/src/lib/governor-runner.d.ts +103 -0
  37. package/dist/src/lib/governor-runner.d.ts.map +1 -0
  38. package/dist/src/lib/governor-runner.js +210 -0
  39. package/dist/src/lib/linear-runner.d.ts +8 -0
  40. package/dist/src/lib/linear-runner.d.ts.map +1 -0
  41. package/dist/src/lib/linear-runner.js +7 -0
  42. package/dist/src/lib/orchestrator-runner.d.ts +51 -0
  43. package/dist/src/lib/orchestrator-runner.d.ts.map +1 -0
  44. package/dist/src/lib/orchestrator-runner.js +151 -0
  45. package/dist/src/lib/queue-admin-runner.d.ts +30 -0
  46. package/dist/src/lib/queue-admin-runner.d.ts.map +1 -0
  47. package/dist/src/lib/queue-admin-runner.js +378 -0
  48. package/dist/src/lib/sync-routes-runner.d.ts +28 -0
  49. package/dist/src/lib/sync-routes-runner.d.ts.map +1 -0
  50. package/dist/src/lib/sync-routes-runner.js +110 -0
  51. package/dist/src/lib/version.d.ts +35 -0
  52. package/dist/src/lib/version.d.ts.map +1 -0
  53. package/dist/src/lib/version.js +168 -0
  54. package/dist/src/lib/worker-fleet-runner.d.ts +32 -0
  55. package/dist/src/lib/worker-fleet-runner.d.ts.map +1 -0
  56. package/dist/src/lib/worker-fleet-runner.js +256 -0
  57. package/dist/src/lib/worker-runner.d.ts +33 -0
  58. package/dist/src/lib/worker-runner.d.ts.map +1 -0
  59. package/dist/src/lib/worker-runner.js +781 -0
  60. package/dist/src/linear.d.ts +37 -0
  61. package/dist/src/linear.d.ts.map +1 -0
  62. package/dist/src/linear.js +118 -0
  63. package/dist/src/orchestrator.d.ts +21 -0
  64. package/dist/src/orchestrator.d.ts.map +1 -0
  65. package/dist/src/orchestrator.js +190 -0
  66. package/dist/src/queue-admin.d.ts +25 -0
  67. package/dist/src/queue-admin.d.ts.map +1 -0
  68. package/dist/src/queue-admin.js +96 -0
  69. package/dist/src/sync-routes.d.ts +17 -0
  70. package/dist/src/sync-routes.d.ts.map +1 -0
  71. package/dist/src/sync-routes.js +100 -0
  72. package/dist/src/worker-fleet.d.ts +25 -0
  73. package/dist/src/worker-fleet.d.ts.map +1 -0
  74. package/dist/src/worker-fleet.js +140 -0
  75. package/dist/src/worker.d.ts +26 -0
  76. package/dist/src/worker.d.ts.map +1 -0
  77. package/dist/src/worker.js +135 -0
  78. package/package.json +175 -0
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Real Governor Dependencies
3
+ *
4
+ * Maps each GovernorDependencies callback to its real implementation
5
+ * using the Linear SDK (via LinearAgentClient) and Redis storage
6
+ * (from @renseiai/agentfactory-server).
7
+ */
8
+ import { isHeld as checkIsHeld, getOverridePriority as checkOverridePriority, } from '@renseiai/agentfactory';
9
+ import { getSessionStateByIssue, didJustFailQA, getWorkflowState, getTotalSessionCount, RedisProcessingStateStorage, storeSessionState, dispatchWork as issueLockDispatchWork, } from '@renseiai/agentfactory-server';
10
+ // ---------------------------------------------------------------------------
11
+ // Logging
12
+ // ---------------------------------------------------------------------------
13
+ const log = {
14
+ info: (msg, data) => console.log(`[governor-deps] ${msg}`, data ? JSON.stringify(data) : ''),
15
+ warn: (msg, data) => console.warn(`[governor-deps] ${msg}`, data ? JSON.stringify(data) : ''),
16
+ error: (msg, data) => console.error(`[governor-deps] ${msg}`, data ? JSON.stringify(data) : ''),
17
+ };
18
+ // ---------------------------------------------------------------------------
19
+ // Action-to-WorkType mapping
20
+ // ---------------------------------------------------------------------------
21
+ function actionToWorkType(action) {
22
+ switch (action) {
23
+ case 'trigger-research':
24
+ return 'research';
25
+ case 'trigger-backlog-creation':
26
+ return 'backlog-creation';
27
+ case 'trigger-development':
28
+ return 'development';
29
+ case 'trigger-qa':
30
+ return 'qa';
31
+ case 'trigger-acceptance':
32
+ return 'acceptance';
33
+ case 'trigger-refinement':
34
+ return 'refinement';
35
+ case 'decompose':
36
+ return 'coordination';
37
+ case 'escalate-human':
38
+ return 'escalation';
39
+ default:
40
+ return 'development';
41
+ }
42
+ }
43
+ /**
44
+ * Create real GovernorDependencies backed by the Linear SDK and Redis.
45
+ *
46
+ * Each callback wraps its implementation in a try/catch so that a single
47
+ * failing dependency does not crash the entire governor scan loop.
48
+ */
49
+ export function createRealDependencies(config) {
50
+ const processingState = new RedisProcessingStateStorage();
51
+ // Caches populated by listIssues() single GraphQL query.
52
+ // parentIssueIds: issues with children (isParent = true)
53
+ // scannedIssueIds: all issues returned by the last scan (isParent known definitively)
54
+ // Only issues NOT in scannedIssueIds need an API fallback (e.g., webhook-driven).
55
+ const parentIssueIds = new Set();
56
+ const scannedIssueIds = new Set();
57
+ return {
58
+ // -----------------------------------------------------------------------
59
+ // 1. listIssues -- scan Linear project using single GraphQL query
60
+ // -----------------------------------------------------------------------
61
+ listIssues: async (project) => {
62
+ try {
63
+ const rawIssues = await config.linearClient.listProjectIssues(project);
64
+ // Cache issue IDs for isParentIssue() lookups — avoids per-issue API calls
65
+ parentIssueIds.clear();
66
+ scannedIssueIds.clear();
67
+ for (const issue of rawIssues) {
68
+ scannedIssueIds.add(issue.id);
69
+ if (issue.childCount > 0) {
70
+ parentIssueIds.add(issue.id);
71
+ }
72
+ }
73
+ return rawIssues.map((issue) => ({
74
+ id: issue.id,
75
+ identifier: issue.identifier,
76
+ title: issue.title,
77
+ description: issue.description,
78
+ status: issue.status,
79
+ labels: issue.labels,
80
+ createdAt: issue.createdAt,
81
+ parentId: issue.parentId,
82
+ project: issue.project,
83
+ }));
84
+ }
85
+ catch (err) {
86
+ log.error('listIssues failed', {
87
+ project,
88
+ error: err instanceof Error ? err.message : String(err),
89
+ });
90
+ return [];
91
+ }
92
+ },
93
+ // -----------------------------------------------------------------------
94
+ // 2. hasActiveSession -- check Redis session storage
95
+ // -----------------------------------------------------------------------
96
+ hasActiveSession: async (issueId) => {
97
+ try {
98
+ const session = await getSessionStateByIssue(issueId);
99
+ if (!session)
100
+ return false;
101
+ const activeStatuses = ['running', 'claimed', 'pending', 'finalizing'];
102
+ return activeStatuses.includes(session.status);
103
+ }
104
+ catch (err) {
105
+ log.error('hasActiveSession failed', {
106
+ issueId,
107
+ error: err instanceof Error ? err.message : String(err),
108
+ });
109
+ return false;
110
+ }
111
+ },
112
+ // -----------------------------------------------------------------------
113
+ // 3. isWithinCooldown -- check if QA just failed for this issue
114
+ // -----------------------------------------------------------------------
115
+ isWithinCooldown: async (issueId) => {
116
+ try {
117
+ return await didJustFailQA(issueId);
118
+ }
119
+ catch (err) {
120
+ log.error('isWithinCooldown failed', {
121
+ issueId,
122
+ error: err instanceof Error ? err.message : String(err),
123
+ });
124
+ return false;
125
+ }
126
+ },
127
+ // -----------------------------------------------------------------------
128
+ // 4. isParentIssue -- check cache first, fall back to API
129
+ // -----------------------------------------------------------------------
130
+ isParentIssue: async (issueId) => {
131
+ // Check cached parent IDs from listIssues (populated by single GraphQL query)
132
+ if (parentIssueIds.has(issueId))
133
+ return true;
134
+ // If the issue was in the scan, we know definitively it's not a parent
135
+ if (scannedIssueIds.has(issueId))
136
+ return false;
137
+ // Fall back to API only for issues not in the last scan (e.g., webhook-driven)
138
+ try {
139
+ return await config.linearClient.isParentIssue(issueId);
140
+ }
141
+ catch (err) {
142
+ log.error('isParentIssue failed', {
143
+ issueId,
144
+ error: err instanceof Error ? err.message : String(err),
145
+ });
146
+ return false;
147
+ }
148
+ },
149
+ // -----------------------------------------------------------------------
150
+ // 5. isHeld -- check touchpoint override storage
151
+ // -----------------------------------------------------------------------
152
+ isHeld: async (issueId) => {
153
+ try {
154
+ return await checkIsHeld(issueId);
155
+ }
156
+ catch (err) {
157
+ log.error('isHeld failed', {
158
+ issueId,
159
+ error: err instanceof Error ? err.message : String(err),
160
+ });
161
+ return false;
162
+ }
163
+ },
164
+ // -----------------------------------------------------------------------
165
+ // 6. getOverridePriority -- check touchpoint override storage
166
+ // -----------------------------------------------------------------------
167
+ getOverridePriority: async (issueId) => {
168
+ try {
169
+ return await checkOverridePriority(issueId);
170
+ }
171
+ catch (err) {
172
+ log.error('getOverridePriority failed', {
173
+ issueId,
174
+ error: err instanceof Error ? err.message : String(err),
175
+ });
176
+ return null;
177
+ }
178
+ },
179
+ // -----------------------------------------------------------------------
180
+ // 7. getWorkflowStrategy -- check Redis workflow state
181
+ // -----------------------------------------------------------------------
182
+ getWorkflowStrategy: async (issueId) => {
183
+ try {
184
+ const workflowState = await getWorkflowState(issueId);
185
+ return workflowState?.strategy;
186
+ }
187
+ catch (err) {
188
+ log.error('getWorkflowStrategy failed', {
189
+ issueId,
190
+ error: err instanceof Error ? err.message : String(err),
191
+ });
192
+ return undefined;
193
+ }
194
+ },
195
+ // -----------------------------------------------------------------------
196
+ // 8. isResearchCompleted -- check Redis processing state
197
+ // -----------------------------------------------------------------------
198
+ isResearchCompleted: async (issueId) => {
199
+ try {
200
+ return await processingState.isPhaseCompleted(issueId, 'research');
201
+ }
202
+ catch (err) {
203
+ log.error('isResearchCompleted failed', {
204
+ issueId,
205
+ error: err instanceof Error ? err.message : String(err),
206
+ });
207
+ return false;
208
+ }
209
+ },
210
+ // -----------------------------------------------------------------------
211
+ // 9. isBacklogCreationCompleted -- check Redis processing state
212
+ // -----------------------------------------------------------------------
213
+ isBacklogCreationCompleted: async (issueId) => {
214
+ try {
215
+ return await processingState.isPhaseCompleted(issueId, 'backlog-creation');
216
+ }
217
+ catch (err) {
218
+ log.error('isBacklogCreationCompleted failed', {
219
+ issueId,
220
+ error: err instanceof Error ? err.message : String(err),
221
+ });
222
+ return false;
223
+ }
224
+ },
225
+ // -----------------------------------------------------------------------
226
+ // 10. getCompletedSessionCount -- count completed sessions for circuit breaker
227
+ // -----------------------------------------------------------------------
228
+ getCompletedSessionCount: async (issueId) => {
229
+ try {
230
+ return await getTotalSessionCount(issueId);
231
+ }
232
+ catch (err) {
233
+ log.error('getCompletedSessionCount failed', {
234
+ issueId,
235
+ error: err instanceof Error ? err.message : String(err),
236
+ });
237
+ return 0;
238
+ }
239
+ },
240
+ // -----------------------------------------------------------------------
241
+ // 11. dispatchWork -- create Linear session and queue work
242
+ // Accepts GovernorIssue directly (already resolved in the scan),
243
+ // eliminating 2 redundant API calls per dispatch.
244
+ // -----------------------------------------------------------------------
245
+ dispatchWork: async (issue, action) => {
246
+ const issueId = issue.id;
247
+ const issueIdentifier = issue.identifier;
248
+ const projectName = issue.project;
249
+ try {
250
+ let workType = actionToWorkType(action);
251
+ // Parent issues use coordination variants for development, QA, acceptance, and refinement
252
+ if (parentIssueIds.has(issueId)) {
253
+ if (workType === 'development')
254
+ workType = 'coordination';
255
+ else if (workType === 'qa')
256
+ workType = 'qa-coordination';
257
+ else if (workType === 'acceptance')
258
+ workType = 'acceptance-coordination';
259
+ else if (workType === 'refinement')
260
+ workType = 'refinement-coordination';
261
+ }
262
+ log.info('Dispatching work', { issueId, issueIdentifier, action, workType });
263
+ // Create a Linear Agent Session on the issue so the UI shows activity
264
+ // Resolve OAuth client fresh each dispatch (handles token refresh/expiry)
265
+ const oauthClient = await config.resolveOAuthClient?.();
266
+ const sessionClient = oauthClient ?? config.linearClient;
267
+ let sessionId;
268
+ try {
269
+ const sessionResult = await sessionClient.createAgentSessionOnIssue({
270
+ issueId,
271
+ });
272
+ sessionId = sessionResult.sessionId;
273
+ }
274
+ catch (err) {
275
+ log.warn('Could not create agent session, will queue without sessionId', {
276
+ issueId,
277
+ error: err instanceof Error ? err.message : String(err),
278
+ });
279
+ }
280
+ const finalSessionId = sessionId ?? `governor-${issueId}-${Date.now()}`;
281
+ const now = Date.now();
282
+ // Fetch workflow state for retry context injection
283
+ const workflowState = await getWorkflowState(issueId);
284
+ const workflowContext = workflowState?.cycleCount
285
+ ? {
286
+ cycleCount: workflowState.cycleCount,
287
+ strategy: workflowState.strategy,
288
+ failureSummary: workflowState.failureSummary,
289
+ }
290
+ : undefined;
291
+ // Generate prompt for the work type (with retry context if available)
292
+ const prompt = config.generatePrompt?.(issueIdentifier, workType, undefined, workflowContext);
293
+ // Register a pending session FIRST so hasActiveSession() returns true
294
+ // immediately, preventing re-dispatch on subsequent poll sweeps.
295
+ await storeSessionState(finalSessionId, {
296
+ issueId,
297
+ issueIdentifier,
298
+ providerSessionId: null,
299
+ worktreePath: '',
300
+ status: 'pending',
301
+ workerId: null,
302
+ queuedAt: now,
303
+ priority: 3,
304
+ workType: workType,
305
+ projectName,
306
+ organizationId: config.organizationId,
307
+ promptContext: prompt,
308
+ });
309
+ // Queue the work item for a worker to pick up
310
+ const queuedWork = {
311
+ sessionId: finalSessionId,
312
+ issueId,
313
+ issueIdentifier,
314
+ priority: 3,
315
+ queuedAt: now,
316
+ workType: workType,
317
+ projectName,
318
+ prompt,
319
+ };
320
+ // Use issue-lock dispatch instead of raw queueWork().
321
+ // If the issue is already locked (another session is in-flight),
322
+ // work is parked and promoted when the lock is released.
323
+ const result = await issueLockDispatchWork(queuedWork);
324
+ if (!result.dispatched && !result.parked) {
325
+ log.warn('Failed to dispatch or park work', {
326
+ issueId,
327
+ action,
328
+ });
329
+ }
330
+ else if (result.parked) {
331
+ log.info('Work parked (issue already locked)', {
332
+ issueId,
333
+ issueIdentifier,
334
+ action,
335
+ workType,
336
+ replaced: result.replaced,
337
+ sessionId: finalSessionId,
338
+ });
339
+ }
340
+ else {
341
+ log.info('Work queued successfully', {
342
+ issueId,
343
+ issueIdentifier,
344
+ action,
345
+ workType,
346
+ projectName,
347
+ sessionId: finalSessionId,
348
+ });
349
+ }
350
+ }
351
+ catch (err) {
352
+ log.error('dispatchWork failed', {
353
+ issueId,
354
+ action,
355
+ error: err instanceof Error ? err.message : String(err),
356
+ });
357
+ throw err; // Re-throw so the governor can record the error
358
+ }
359
+ },
360
+ };
361
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Governor Logger — Colorized, structured display for the governor CLI.
3
+ *
4
+ * Uses ANSI escape codes directly (matching the core Logger pattern)
5
+ * to avoid external dependencies like chalk.
6
+ */
7
+ import type { ScanResult } from '@renseiai/agentfactory';
8
+ import type { LinearApiQuota } from '@renseiai/agentfactory-linear';
9
+ export interface StartupBannerConfig {
10
+ version: string;
11
+ projects: string[];
12
+ scanIntervalMs: number;
13
+ maxConcurrentDispatches: number;
14
+ mode: string;
15
+ once: boolean;
16
+ features: {
17
+ autoResearch: boolean;
18
+ autoBacklogCreation: boolean;
19
+ autoDevelopment: boolean;
20
+ autoQA: boolean;
21
+ autoAcceptance: boolean;
22
+ };
23
+ redisConnected: boolean;
24
+ oauthResolved: boolean;
25
+ }
26
+ export declare function printStartupBanner(config: StartupBannerConfig): void;
27
+ export declare function printScanSummary(results: ScanResult[], durationMs: number, quota?: LinearApiQuota, apiCalls?: number): void;
28
+ export declare function printQuotaBar(quota: LinearApiQuota, apiCalls?: number): void;
29
+ export declare function printCircuitBreakerWarning(status: string): void;
30
+ //# sourceMappingURL=governor-logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"governor-logger.d.ts","sourceRoot":"","sources":["../../../src/lib/governor-logger.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAA;AAkCnE,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,EAAE,MAAM,CAAA;IACtB,uBAAuB,EAAE,MAAM,CAAA;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,OAAO,CAAA;IACb,QAAQ,EAAE;QACR,YAAY,EAAE,OAAO,CAAA;QACrB,mBAAmB,EAAE,OAAO,CAAA;QAC5B,eAAe,EAAE,OAAO,CAAA;QACxB,MAAM,EAAE,OAAO,CAAA;QACf,cAAc,EAAE,OAAO,CAAA;KACxB,CAAA;IACD,cAAc,EAAE,OAAO,CAAA;IACvB,aAAa,EAAE,OAAO,CAAA;CACvB;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,mBAAmB,GAAG,IAAI,CAqFpE;AAMD,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,UAAU,EAAE,EACrB,UAAU,EAAE,MAAM,EAClB,KAAK,CAAC,EAAE,cAAc,EACtB,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CA6CN;AAMD,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAuC5E;AAMD,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAa/D"}
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Governor Logger — Colorized, structured display for the governor CLI.
3
+ *
4
+ * Uses ANSI escape codes directly (matching the core Logger pattern)
5
+ * to avoid external dependencies like chalk.
6
+ */
7
+ // ---------------------------------------------------------------------------
8
+ // ANSI colors (matching packages/core/src/logger.ts)
9
+ // ---------------------------------------------------------------------------
10
+ const c = {
11
+ reset: '\x1b[0m',
12
+ bold: '\x1b[1m',
13
+ dim: '\x1b[2m',
14
+ red: '\x1b[31m',
15
+ green: '\x1b[32m',
16
+ yellow: '\x1b[33m',
17
+ blue: '\x1b[34m',
18
+ magenta: '\x1b[35m',
19
+ cyan: '\x1b[36m',
20
+ white: '\x1b[37m',
21
+ brightBlack: '\x1b[90m',
22
+ brightGreen: '\x1b[92m',
23
+ brightYellow: '\x1b[93m',
24
+ brightRed: '\x1b[91m',
25
+ brightCyan: '\x1b[96m',
26
+ };
27
+ function color(text, ...codes) {
28
+ return `${codes.join('')}${text}${c.reset}`;
29
+ }
30
+ export function printStartupBanner(config) {
31
+ const w = 56; // inner width
32
+ const top = `${c.cyan}┌${'─'.repeat(w)}┐${c.reset}`;
33
+ const bottom = `${c.cyan}└${'─'.repeat(w)}┘${c.reset}`;
34
+ const sep = `${c.cyan}├${'─'.repeat(w)}┤${c.reset}`;
35
+ const pad = (text, rawLen) => {
36
+ const padding = w - 2 - rawLen;
37
+ return `${c.cyan}│${c.reset} ${text}${' '.repeat(Math.max(0, padding))} ${c.cyan}│${c.reset}`;
38
+ };
39
+ const title = 'AgentFactory Governor';
40
+ const titleLen = title.length + config.version.length + 3;
41
+ const titleLine = `${c.bold}${c.white}${title}${c.reset} ${c.dim}v${config.version}${c.reset}`;
42
+ const lines = [
43
+ top,
44
+ pad(titleLine, titleLen),
45
+ sep,
46
+ ];
47
+ // Projects
48
+ const projList = config.projects.join(', ');
49
+ lines.push(pad(`${color('Projects:', c.bold)} ${projList}`, 10 + projList.length));
50
+ // Scan interval
51
+ const intervalSec = `${config.scanIntervalMs / 1000}s`;
52
+ lines.push(pad(`${color('Interval:', c.bold)} ${intervalSec}`, 10 + intervalSec.length));
53
+ // Max dispatches
54
+ const maxStr = String(config.maxConcurrentDispatches);
55
+ lines.push(pad(`${color('Max dispatch:', c.bold)} ${maxStr}/scan`, 14 + maxStr.length + 5));
56
+ // Mode
57
+ const modeStr = config.once ? 'single scan' : config.mode;
58
+ lines.push(pad(`${color('Mode:', c.bold)} ${modeStr}`, 6 + modeStr.length));
59
+ lines.push(sep);
60
+ // Feature flags
61
+ const features = config.features;
62
+ const featureEntries = [
63
+ ['Research', features.autoResearch],
64
+ ['Backlog Creation', features.autoBacklogCreation],
65
+ ['Development', features.autoDevelopment],
66
+ ['QA', features.autoQA],
67
+ ['Acceptance', features.autoAcceptance],
68
+ ];
69
+ const enabledList = featureEntries
70
+ .filter(([, v]) => v)
71
+ .map(([k]) => k);
72
+ const disabledList = featureEntries
73
+ .filter(([, v]) => !v)
74
+ .map(([k]) => k);
75
+ if (enabledList.length > 0) {
76
+ const text = enabledList.join(', ');
77
+ lines.push(pad(`${color('Enabled:', c.green, c.bold)} ${text}`, 9 + text.length));
78
+ }
79
+ if (disabledList.length > 0) {
80
+ const text = disabledList.join(', ');
81
+ lines.push(pad(`${color('Disabled:', c.dim)} ${text}`, 10 + text.length));
82
+ }
83
+ lines.push(sep);
84
+ // Integration status
85
+ const redisStatus = config.redisConnected
86
+ ? color('connected', c.green)
87
+ : color('not configured', c.yellow);
88
+ const redisRawLen = config.redisConnected ? 16 : 22;
89
+ lines.push(pad(`${color('Redis:', c.bold)} ${redisStatus}`, 7 + (redisRawLen - 7)));
90
+ const oauthStatus = config.oauthResolved
91
+ ? color('resolved', c.green)
92
+ : color('personal API key', c.yellow);
93
+ const oauthRawLen = config.oauthResolved ? 15 : 24;
94
+ lines.push(pad(`${color('OAuth:', c.bold)} ${oauthStatus}`, 7 + (oauthRawLen - 7)));
95
+ lines.push(bottom);
96
+ console.log(lines.join('\n'));
97
+ console.log();
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Scan Summary
101
+ // ---------------------------------------------------------------------------
102
+ export function printScanSummary(results, durationMs, quota, apiCalls) {
103
+ const ts = formatTime();
104
+ for (const result of results) {
105
+ const { project, scannedIssues, actionsDispatched, skippedReasons, errors } = result;
106
+ const projectTag = color(`[${project}]`, c.cyan, c.bold);
107
+ // Main summary line
108
+ const dispatched = actionsDispatched > 0
109
+ ? color(String(actionsDispatched), c.green, c.bold)
110
+ : color('0', c.dim);
111
+ const scanned = color(String(scannedIssues), c.white);
112
+ const skipped = skippedReasons.size > 0
113
+ ? color(String(skippedReasons.size), c.dim)
114
+ : '0';
115
+ console.log(`${color(ts, c.dim)} ${projectTag} ${scanned} scanned, ${dispatched} dispatched, ${skipped} skipped`);
116
+ // Errors
117
+ if (errors.length > 0) {
118
+ for (const err of errors) {
119
+ console.log(`${color(ts, c.dim)} ${projectTag} ${color('ERR', c.red)} ${err.issueId}: ${err.error}`);
120
+ }
121
+ }
122
+ }
123
+ // Duration
124
+ const durStr = durationMs < 1000
125
+ ? `${durationMs}ms`
126
+ : `${(durationMs / 1000).toFixed(1)}s`;
127
+ console.log(`${color(ts, c.dim)} ${color('Scan completed', c.dim)} in ${color(durStr, c.white)}`);
128
+ // Quota bar
129
+ if (quota) {
130
+ printQuotaBar(quota, apiCalls);
131
+ }
132
+ console.log();
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Quota Bar
136
+ // ---------------------------------------------------------------------------
137
+ export function printQuotaBar(quota, apiCalls) {
138
+ const barWidth = 20;
139
+ // Request quota bar
140
+ if (quota.requestsRemaining != null && quota.requestsLimit != null) {
141
+ const used = quota.requestsLimit - quota.requestsRemaining;
142
+ const pct = quota.requestsLimit > 0 ? used / quota.requestsLimit : 0;
143
+ const barColor = getQuotaColor(1 - pct);
144
+ const bar = renderBar(pct, barWidth, barColor);
145
+ const usedStr = used.toLocaleString();
146
+ const limitStr = quota.requestsLimit.toLocaleString();
147
+ const pctStr = `(${Math.round(pct * 100)}%)`;
148
+ let line = ` Linear API ${bar} ${usedStr}/${limitStr} req ${color(pctStr, c.dim)}`;
149
+ if (apiCalls != null) {
150
+ line += ` ${color('│', c.dim)} ${apiCalls} calls this scan`;
151
+ }
152
+ console.log(line);
153
+ }
154
+ // Complexity quota bar
155
+ if (quota.complexityRemaining != null && quota.complexityLimit != null) {
156
+ const used = quota.complexityLimit - quota.complexityRemaining;
157
+ const pct = quota.complexityLimit > 0 ? used / quota.complexityLimit : 0;
158
+ const barColor = getQuotaColor(1 - pct);
159
+ const bar = renderBar(pct, barWidth, barColor);
160
+ const usedStr = formatCompact(used);
161
+ const limitStr = formatCompact(quota.complexityLimit);
162
+ const pctStr = `(${Math.round(pct * 100)}%)`;
163
+ let line = ` ${bar} ${usedStr}/${limitStr} cmplx ${color(pctStr, c.dim)}`;
164
+ if (quota.resetSeconds != null) {
165
+ const resetMin = Math.ceil(quota.resetSeconds / 60);
166
+ line += ` ${color('│', c.dim)} resets in ${resetMin}m`;
167
+ }
168
+ console.log(line);
169
+ }
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Circuit Breaker Warning
173
+ // ---------------------------------------------------------------------------
174
+ export function printCircuitBreakerWarning(status) {
175
+ if (status === 'closed')
176
+ return;
177
+ const ts = formatTime();
178
+ if (status === 'open') {
179
+ console.log(`${color(ts, c.dim)} ${color('CIRCUIT BREAKER OPEN', c.brightRed, c.bold)} — API calls blocked, waiting for reset`);
180
+ }
181
+ else if (status === 'half-open') {
182
+ console.log(`${color(ts, c.dim)} ${color('CIRCUIT BREAKER HALF-OPEN', c.brightYellow, c.bold)} — probing with single request`);
183
+ }
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // Helpers
187
+ // ---------------------------------------------------------------------------
188
+ function formatTime() {
189
+ const now = new Date();
190
+ return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`;
191
+ }
192
+ function getQuotaColor(remainingPct) {
193
+ if (remainingPct < 0.10)
194
+ return c.brightRed;
195
+ if (remainingPct < 0.20)
196
+ return c.brightYellow;
197
+ return c.brightGreen;
198
+ }
199
+ function renderBar(pct, width, barColor) {
200
+ const filled = Math.round(pct * width);
201
+ const empty = width - filled;
202
+ return `[${barColor}${'█'.repeat(filled)}${c.dim}${'░'.repeat(empty)}${c.reset}]`;
203
+ }
204
+ function formatCompact(n) {
205
+ if (n >= 1_000_000)
206
+ return `${(n / 1_000_000).toFixed(1)}M`;
207
+ if (n >= 1_000)
208
+ return `${(n / 1_000).toFixed(0)}K`;
209
+ return String(n);
210
+ }