opencastle 0.27.0 → 0.27.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.
Files changed (242) hide show
  1. package/bin/cli.mjs +6 -0
  2. package/dist/cli/agents.d.ts +3 -0
  3. package/dist/cli/agents.d.ts.map +1 -0
  4. package/dist/cli/agents.js +161 -0
  5. package/dist/cli/agents.js.map +1 -0
  6. package/dist/cli/baselines.d.ts +3 -0
  7. package/dist/cli/baselines.d.ts.map +1 -0
  8. package/dist/cli/baselines.js +128 -0
  9. package/dist/cli/baselines.js.map +1 -0
  10. package/dist/cli/convoy/dashboard-types.d.ts +146 -0
  11. package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
  12. package/dist/cli/convoy/dashboard-types.js +2 -0
  13. package/dist/cli/convoy/dashboard-types.js.map +1 -0
  14. package/dist/cli/convoy/engine.d.ts +67 -2
  15. package/dist/cli/convoy/engine.d.ts.map +1 -1
  16. package/dist/cli/convoy/engine.js +2036 -28
  17. package/dist/cli/convoy/engine.js.map +1 -1
  18. package/dist/cli/convoy/engine.test.js +1659 -70
  19. package/dist/cli/convoy/engine.test.js.map +1 -1
  20. package/dist/cli/convoy/event-schemas.d.ts +9 -0
  21. package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
  22. package/dist/cli/convoy/event-schemas.js +185 -0
  23. package/dist/cli/convoy/event-schemas.js.map +1 -0
  24. package/dist/cli/convoy/events.d.ts +12 -1
  25. package/dist/cli/convoy/events.d.ts.map +1 -1
  26. package/dist/cli/convoy/events.js +186 -13
  27. package/dist/cli/convoy/events.js.map +1 -1
  28. package/dist/cli/convoy/events.test.js +325 -28
  29. package/dist/cli/convoy/events.test.js.map +1 -1
  30. package/dist/cli/convoy/expertise.d.ts +16 -0
  31. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  32. package/dist/cli/convoy/expertise.js +121 -0
  33. package/dist/cli/convoy/expertise.js.map +1 -0
  34. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  35. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  36. package/dist/cli/convoy/expertise.test.js +96 -0
  37. package/dist/cli/convoy/expertise.test.js.map +1 -0
  38. package/dist/cli/convoy/export.test.js +1 -0
  39. package/dist/cli/convoy/export.test.js.map +1 -1
  40. package/dist/cli/convoy/formula.d.ts +19 -0
  41. package/dist/cli/convoy/formula.d.ts.map +1 -0
  42. package/dist/cli/convoy/formula.js +142 -0
  43. package/dist/cli/convoy/formula.js.map +1 -0
  44. package/dist/cli/convoy/formula.test.d.ts +2 -0
  45. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  46. package/dist/cli/convoy/formula.test.js +342 -0
  47. package/dist/cli/convoy/formula.test.js.map +1 -0
  48. package/dist/cli/convoy/gates.d.ts +128 -0
  49. package/dist/cli/convoy/gates.d.ts.map +1 -0
  50. package/dist/cli/convoy/gates.js +606 -0
  51. package/dist/cli/convoy/gates.js.map +1 -0
  52. package/dist/cli/convoy/gates.test.d.ts +2 -0
  53. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  54. package/dist/cli/convoy/gates.test.js +976 -0
  55. package/dist/cli/convoy/gates.test.js.map +1 -0
  56. package/dist/cli/convoy/health.d.ts +11 -0
  57. package/dist/cli/convoy/health.d.ts.map +1 -1
  58. package/dist/cli/convoy/health.js +54 -0
  59. package/dist/cli/convoy/health.js.map +1 -1
  60. package/dist/cli/convoy/health.test.js +56 -1
  61. package/dist/cli/convoy/health.test.js.map +1 -1
  62. package/dist/cli/convoy/issues.d.ts +8 -0
  63. package/dist/cli/convoy/issues.d.ts.map +1 -0
  64. package/dist/cli/convoy/issues.js +98 -0
  65. package/dist/cli/convoy/issues.js.map +1 -0
  66. package/dist/cli/convoy/issues.test.d.ts +2 -0
  67. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  68. package/dist/cli/convoy/issues.test.js +107 -0
  69. package/dist/cli/convoy/issues.test.js.map +1 -0
  70. package/dist/cli/convoy/knowledge.d.ts +5 -0
  71. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  72. package/dist/cli/convoy/knowledge.js +116 -0
  73. package/dist/cli/convoy/knowledge.js.map +1 -0
  74. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  75. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  76. package/dist/cli/convoy/knowledge.test.js +87 -0
  77. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  78. package/dist/cli/convoy/lessons.d.ts +17 -0
  79. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  80. package/dist/cli/convoy/lessons.js +149 -0
  81. package/dist/cli/convoy/lessons.js.map +1 -0
  82. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  83. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  84. package/dist/cli/convoy/lessons.test.js +135 -0
  85. package/dist/cli/convoy/lessons.test.js.map +1 -0
  86. package/dist/cli/convoy/lock.d.ts +13 -0
  87. package/dist/cli/convoy/lock.d.ts.map +1 -0
  88. package/dist/cli/convoy/lock.js +88 -0
  89. package/dist/cli/convoy/lock.js.map +1 -0
  90. package/dist/cli/convoy/lock.test.d.ts +2 -0
  91. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  92. package/dist/cli/convoy/lock.test.js +136 -0
  93. package/dist/cli/convoy/lock.test.js.map +1 -0
  94. package/dist/cli/convoy/log-merge.test.d.ts +2 -0
  95. package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
  96. package/dist/cli/convoy/log-merge.test.js +147 -0
  97. package/dist/cli/convoy/log-merge.test.js.map +1 -0
  98. package/dist/cli/convoy/merge.d.ts +4 -0
  99. package/dist/cli/convoy/merge.d.ts.map +1 -1
  100. package/dist/cli/convoy/merge.js +18 -1
  101. package/dist/cli/convoy/merge.js.map +1 -1
  102. package/dist/cli/convoy/merge.test.js +6 -7
  103. package/dist/cli/convoy/merge.test.js.map +1 -1
  104. package/dist/cli/convoy/partition.d.ts +51 -0
  105. package/dist/cli/convoy/partition.d.ts.map +1 -0
  106. package/dist/cli/convoy/partition.js +186 -0
  107. package/dist/cli/convoy/partition.js.map +1 -0
  108. package/dist/cli/convoy/partition.test.d.ts +2 -0
  109. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  110. package/dist/cli/convoy/partition.test.js +315 -0
  111. package/dist/cli/convoy/partition.test.js.map +1 -0
  112. package/dist/cli/convoy/pipeline.test.js +6 -0
  113. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  114. package/dist/cli/convoy/store.d.ts +99 -7
  115. package/dist/cli/convoy/store.d.ts.map +1 -1
  116. package/dist/cli/convoy/store.js +764 -31
  117. package/dist/cli/convoy/store.js.map +1 -1
  118. package/dist/cli/convoy/store.test.js +1810 -18
  119. package/dist/cli/convoy/store.test.js.map +1 -1
  120. package/dist/cli/convoy/types.d.ts +427 -5
  121. package/dist/cli/convoy/types.d.ts.map +1 -1
  122. package/dist/cli/convoy/types.js +42 -1
  123. package/dist/cli/convoy/types.js.map +1 -1
  124. package/dist/cli/log.d.ts +11 -0
  125. package/dist/cli/log.d.ts.map +1 -1
  126. package/dist/cli/log.js +114 -2
  127. package/dist/cli/log.js.map +1 -1
  128. package/dist/cli/run/adapters/claude.d.ts +2 -0
  129. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  130. package/dist/cli/run/adapters/claude.js +89 -49
  131. package/dist/cli/run/adapters/claude.js.map +1 -1
  132. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  133. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  134. package/dist/cli/run/adapters/claude.test.js +205 -0
  135. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  137. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  138. package/dist/cli/run/adapters/copilot.js +84 -46
  139. package/dist/cli/run/adapters/copilot.js.map +1 -1
  140. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  141. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  142. package/dist/cli/run/adapters/copilot.test.js +195 -0
  143. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  145. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  146. package/dist/cli/run/adapters/cursor.js +83 -47
  147. package/dist/cli/run/adapters/cursor.js.map +1 -1
  148. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  149. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  150. package/dist/cli/run/adapters/cursor.test.js +129 -0
  151. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  153. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  154. package/dist/cli/run/adapters/opencode.js +81 -47
  155. package/dist/cli/run/adapters/opencode.js.map +1 -1
  156. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  157. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  158. package/dist/cli/run/adapters/opencode.test.js +119 -0
  159. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  160. package/dist/cli/run/executor.js +1 -1
  161. package/dist/cli/run/executor.js.map +1 -1
  162. package/dist/cli/run/schema.d.ts.map +1 -1
  163. package/dist/cli/run/schema.js +245 -4
  164. package/dist/cli/run/schema.js.map +1 -1
  165. package/dist/cli/run/schema.test.js +669 -0
  166. package/dist/cli/run/schema.test.js.map +1 -1
  167. package/dist/cli/run.d.ts.map +1 -1
  168. package/dist/cli/run.js +362 -22
  169. package/dist/cli/run.js.map +1 -1
  170. package/dist/cli/types.d.ts +85 -2
  171. package/dist/cli/types.d.ts.map +1 -1
  172. package/dist/cli/types.js.map +1 -1
  173. package/dist/cli/watch.d.ts +15 -0
  174. package/dist/cli/watch.d.ts.map +1 -0
  175. package/dist/cli/watch.js +279 -0
  176. package/dist/cli/watch.js.map +1 -0
  177. package/package.json +5 -1
  178. package/src/cli/agents.ts +177 -0
  179. package/src/cli/baselines.ts +143 -0
  180. package/src/cli/convoy/TELEMETRY.md +203 -0
  181. package/src/cli/convoy/dashboard-types.ts +141 -0
  182. package/src/cli/convoy/engine.test.ts +1937 -70
  183. package/src/cli/convoy/engine.ts +2350 -40
  184. package/src/cli/convoy/event-schemas.ts +195 -0
  185. package/src/cli/convoy/events.test.ts +384 -39
  186. package/src/cli/convoy/events.ts +202 -16
  187. package/src/cli/convoy/expertise.test.ts +128 -0
  188. package/src/cli/convoy/expertise.ts +163 -0
  189. package/src/cli/convoy/export.test.ts +1 -0
  190. package/src/cli/convoy/formula.test.ts +405 -0
  191. package/src/cli/convoy/formula.ts +174 -0
  192. package/src/cli/convoy/gates.test.ts +1169 -0
  193. package/src/cli/convoy/gates.ts +774 -0
  194. package/src/cli/convoy/health.test.ts +64 -2
  195. package/src/cli/convoy/health.ts +80 -2
  196. package/src/cli/convoy/issues.test.ts +143 -0
  197. package/src/cli/convoy/issues.ts +136 -0
  198. package/src/cli/convoy/knowledge.test.ts +101 -0
  199. package/src/cli/convoy/knowledge.ts +132 -0
  200. package/src/cli/convoy/lessons.test.ts +188 -0
  201. package/src/cli/convoy/lessons.ts +164 -0
  202. package/src/cli/convoy/lock.test.ts +181 -0
  203. package/src/cli/convoy/lock.ts +103 -0
  204. package/src/cli/convoy/log-merge.test.ts +179 -0
  205. package/src/cli/convoy/merge.test.ts +6 -7
  206. package/src/cli/convoy/merge.ts +19 -1
  207. package/src/cli/convoy/partition.test.ts +423 -0
  208. package/src/cli/convoy/partition.ts +232 -0
  209. package/src/cli/convoy/pipeline.test.ts +6 -0
  210. package/src/cli/convoy/store.test.ts +2041 -20
  211. package/src/cli/convoy/store.ts +945 -46
  212. package/src/cli/convoy/types.ts +278 -4
  213. package/src/cli/log.ts +120 -2
  214. package/src/cli/run/adapters/claude.test.ts +234 -0
  215. package/src/cli/run/adapters/claude.ts +45 -5
  216. package/src/cli/run/adapters/copilot.test.ts +224 -0
  217. package/src/cli/run/adapters/copilot.ts +34 -4
  218. package/src/cli/run/adapters/cursor.test.ts +144 -0
  219. package/src/cli/run/adapters/cursor.ts +33 -2
  220. package/src/cli/run/adapters/opencode.test.ts +135 -0
  221. package/src/cli/run/adapters/opencode.ts +30 -2
  222. package/src/cli/run/executor.ts +1 -1
  223. package/src/cli/run/schema.test.ts +758 -0
  224. package/src/cli/run/schema.ts +300 -25
  225. package/src/cli/run.ts +341 -21
  226. package/src/cli/types.ts +86 -1
  227. package/src/cli/watch.ts +298 -0
  228. package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
  229. package/src/dashboard/dist/data/.gitkeep +0 -0
  230. package/src/dashboard/dist/data/convoy-list.json +1 -0
  231. package/src/dashboard/dist/data/overall-stats.json +24 -0
  232. package/src/dashboard/dist/index.html +701 -3
  233. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  234. package/src/dashboard/public/data/.gitkeep +0 -0
  235. package/src/dashboard/public/data/convoy-list.json +1 -0
  236. package/src/dashboard/public/data/overall-stats.json +24 -0
  237. package/src/dashboard/scripts/etl.test.ts +210 -0
  238. package/src/dashboard/scripts/etl.ts +108 -0
  239. package/src/dashboard/scripts/integration-test.ts +504 -0
  240. package/src/dashboard/src/pages/index.astro +854 -15
  241. package/src/dashboard/src/styles/dashboard.css +557 -1
  242. package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
@@ -5,8 +5,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
5
5
  import { createConvoyStore } from './store.js'
6
6
  import type { ConvoyStore } from './store.js'
7
7
  import type { ConvoyEventEmitter } from './events.js'
8
- import { createHealthMonitor } from './health.js'
8
+ import { createHealthMonitor, detectDrift } from './health.js'
9
9
  import type { HealthMonitorOptions } from './health.js'
10
+ import type { AgentAdapter } from '../types.js'
10
11
 
11
12
  // ── fixtures ──────────────────────────────────────────────────────────────────
12
13
 
@@ -33,6 +34,7 @@ beforeEach(() => {
33
34
  emit(type, data, ids) {
34
35
  emittedEvents.push({ type, data, ids })
35
36
  },
37
+ close() {},
36
38
  }
37
39
  store.insertConvoy({
38
40
  id: CONVOY_ID,
@@ -71,8 +73,9 @@ function makeTask(
71
73
  max_retries: 1,
72
74
  files: null,
73
75
  depends_on: null,
76
+ gates: null as string | null,
74
77
  ...overrides,
75
- }
78
+ } as Parameters<ConvoyStore['insertTask']>[0]
76
79
  }
77
80
 
78
81
  function makeWorker(
@@ -455,3 +458,62 @@ describe('event emission', () => {
455
458
  expect(ids?.worker_id).toBe('worker-1')
456
459
  })
457
460
  })
461
+
462
+ // ── detectDrift ───────────────────────────────────────────────────────────────
463
+
464
+ describe('detectDrift', () => {
465
+ function makeTaskRecord(adapterName: string | null = null) {
466
+ store.insertTask(makeTask({ id: 'drift-task', adapter: adapterName }))
467
+ return store.getTask('drift-task', CONVOY_ID)!
468
+ }
469
+
470
+ function makeAgentAdapter(name: string, output: string): AgentAdapter {
471
+ return {
472
+ name,
473
+ isAvailable: vi.fn().mockResolvedValue(true),
474
+ execute: vi.fn().mockResolvedValue({ success: true, output, exitCode: 0 }),
475
+ } as unknown as AgentAdapter
476
+ }
477
+
478
+ it('non-streaming adapter returns score 1.0, drifted=false, and emits a warning', async () => {
479
+ const taskRecord = makeTaskRecord(null)
480
+ const adapter = makeAgentAdapter('vscode', 'ok')
481
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
482
+ try {
483
+ const result = await detectDrift(taskRecord, adapter)
484
+ expect(result.score).toBe(1.0)
485
+ expect(result.drifted).toBe(false)
486
+ expect(result.explanation).toContain('non-streaming adapter')
487
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('drift detection skipped'))
488
+ } finally {
489
+ stderrSpy.mockRestore()
490
+ }
491
+ })
492
+
493
+ it('streaming adapter with high confidence score returns drifted=false', async () => {
494
+ const taskRecord = makeTaskRecord('copilot')
495
+ const adapter = makeAgentAdapter('copilot', '{"score": 0.95, "explanation": "very confident"}')
496
+ const result = await detectDrift(taskRecord, adapter)
497
+ expect(result.score).toBe(0.95)
498
+ expect(result.drifted).toBe(false)
499
+ expect(result.explanation).toBe('very confident')
500
+ })
501
+
502
+ it('streaming adapter with low confidence score returns drifted=true', async () => {
503
+ const taskRecord = makeTaskRecord('copilot')
504
+ const adapter = makeAgentAdapter('copilot', '{"score": 0.4, "explanation": "not confident"}')
505
+ const result = await detectDrift(taskRecord, adapter)
506
+ expect(result.score).toBe(0.4)
507
+ expect(result.drifted).toBe(true)
508
+ expect(result.threshold).toBe(0.8)
509
+ })
510
+
511
+ it('parse failure returns score 0.5 and drifted=true (below default threshold)', async () => {
512
+ const taskRecord = makeTaskRecord('copilot')
513
+ const adapter = makeAgentAdapter('copilot', 'I cannot rate myself')
514
+ const result = await detectDrift(taskRecord, adapter)
515
+ expect(result.score).toBe(0.5)
516
+ expect(result.drifted).toBe(true)
517
+ expect(result.explanation).toContain('Could not parse')
518
+ })
519
+ })
@@ -1,5 +1,7 @@
1
1
  import type { ConvoyStore } from './store.js'
2
2
  import type { ConvoyEventEmitter } from './events.js'
3
+ import type { TaskRecord } from './types.js'
4
+ import type { AgentAdapter } from '../types.js'
3
5
 
4
6
  export interface HealthMonitorOptions {
5
7
  store: ConvoyStore
@@ -22,8 +24,7 @@ export interface HealthMonitor {
22
24
  check(): void
23
25
  }
24
26
 
25
- export function createHealthMonitor(options: HealthMonitorOptions): HealthMonitor {
26
- const {
27
+ export function createHealthMonitor(options: HealthMonitorOptions): HealthMonitor { const {
27
28
  store,
28
29
  events,
29
30
  convoyId,
@@ -109,3 +110,80 @@ export function createHealthMonitor(options: HealthMonitorOptions): HealthMonito
109
110
  check,
110
111
  }
111
112
  }
113
+
114
+ // ── Drift detection ───────────────────────────────────────────────────────────
115
+
116
+ export interface DriftCheckResult {
117
+ score: number
118
+ explanation: string
119
+ drifted: boolean
120
+ threshold: number
121
+ }
122
+
123
+ export async function detectDrift(
124
+ taskRecord: TaskRecord,
125
+ adapter: AgentAdapter,
126
+ options?: { threshold?: number },
127
+ ): Promise<DriftCheckResult> {
128
+ // Streaming adapters: copilot (vscode adapter), cursor
129
+ const streamingAdapters = ['copilot', 'cursor']
130
+ const adapterName = taskRecord.adapter ?? adapter.name
131
+
132
+ if (!streamingAdapters.includes(adapterName)) {
133
+ process.stderr.write(
134
+ `Warning: drift detection skipped for non-streaming adapter "${adapterName}"\n`,
135
+ )
136
+ return {
137
+ score: 1.0,
138
+ explanation: 'Drift detection skipped: non-streaming adapter',
139
+ drifted: false,
140
+ threshold: options?.threshold ?? 0.8,
141
+ }
142
+ }
143
+
144
+ const threshold = options?.threshold ?? 0.8
145
+
146
+ const confidencePrompt = `Review the work you just completed for task "${taskRecord.id}". Rate your confidence that the implementation is correct and complete on a scale of 0.0 to 1.0. Respond with ONLY a JSON object: {"score": <number>, "explanation": "<brief explanation>"}`
147
+
148
+ const confidenceTask = {
149
+ id: `drift-check-${taskRecord.id}`,
150
+ prompt: confidencePrompt,
151
+ agent: taskRecord.agent,
152
+ timeout: '2m',
153
+ depends_on: [] as string[],
154
+ files: [] as string[],
155
+ description: 'Drift confidence check',
156
+ max_retries: 0,
157
+ }
158
+
159
+ try {
160
+ const result = await adapter.execute(confidenceTask, { verbose: false })
161
+
162
+ const jsonMatch = result.output.match(/\{[^}]*"score"\s*:\s*([\d.]+)[^}]*"explanation"\s*:\s*"([^"]*)"[^}]*\}/)
163
+ if (jsonMatch) {
164
+ const score = Math.max(0, Math.min(1, parseFloat(jsonMatch[1])))
165
+ const explanation = jsonMatch[2]
166
+ return { score, explanation, drifted: score < threshold, threshold }
167
+ }
168
+
169
+ const numberMatch = result.output.match(/(0\.\d+|1\.0|1)/)
170
+ if (numberMatch) {
171
+ const score = Math.max(0, Math.min(1, parseFloat(numberMatch[1])))
172
+ return { score, explanation: 'Parsed from raw output', drifted: score < threshold, threshold }
173
+ }
174
+
175
+ return {
176
+ score: 0.5,
177
+ explanation: 'Could not parse confidence score from adapter response',
178
+ drifted: 0.5 < threshold,
179
+ threshold,
180
+ }
181
+ } catch (err) {
182
+ return {
183
+ score: 0.5,
184
+ explanation: `Confidence check failed: ${(err as Error).message}`,
185
+ drifted: 0.5 < threshold,
186
+ threshold,
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,143 @@
1
+ import { mkdtempSync, rmSync, realpathSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
+ import { injectDiscoveredIssuesInstruction, checkDiscoveredIssues, consolidateIssues } from './issues.js'
6
+
7
+ vi.mock('./gates.js', () => ({
8
+ scanForSecrets: vi.fn(() => ({ clean: true, findings: [] })),
9
+ }))
10
+
11
+ const DISCOVERED_REL = 'DISCOVERED-ISSUES.md'
12
+ const KNOWN_REL = 'KNOWN-ISSUES.md'
13
+
14
+ const DISCOVERED_HEADER = '# Discovered Issues\n\n'
15
+ const KNOWN_HEADER = '# Known Issues\n\n'
16
+
17
+ function makeBase(): string {
18
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'issues-test-')))
19
+ return dir
20
+ }
21
+
22
+ function makeEvents() {
23
+ const emitted: Array<{ type: string; data?: unknown }> = []
24
+ return {
25
+ emit: vi.fn((type: string, data?: unknown) => { emitted.push({ type, data }) }),
26
+ emitted,
27
+ }
28
+ }
29
+
30
+ let tmpDir: string
31
+
32
+ beforeEach(() => {
33
+ tmpDir = makeBase()
34
+ vi.clearAllMocks()
35
+ })
36
+
37
+ afterEach(() => {
38
+ rmSync(tmpDir, { recursive: true, force: true })
39
+ })
40
+
41
+ describe('injectDiscoveredIssuesInstruction', () => {
42
+ it('prepends the instruction to the prompt', () => {
43
+ const result = injectDiscoveredIssuesInstruction('Do the task.')
44
+ expect(result).toContain('Do the task.')
45
+ expect(result.indexOf('IMPORTANT')).toBeLessThan(result.indexOf('Do the task.'))
46
+ })
47
+
48
+ it('includes the ISSUE format in the instruction', () => {
49
+ const result = injectDiscoveredIssuesInstruction('proceed')
50
+ expect(result).toContain('### ISSUE:')
51
+ })
52
+ })
53
+
54
+ describe('checkDiscoveredIssues', () => {
55
+ it('returns 0 when file does not exist', () => {
56
+ const events = makeEvents()
57
+ const count = checkDiscoveredIssues('task-1', events as any, 'convoy-1', tmpDir)
58
+ expect(count).toBe(0)
59
+ expect(events.emit).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('returns 0 when file has no ISSUE entries', () => {
63
+ writeFileSync(join(tmpDir, DISCOVERED_REL), DISCOVERED_HEADER)
64
+ const events = makeEvents()
65
+ const count = checkDiscoveredIssues('task-1', events as any, 'convoy-1', tmpDir)
66
+ expect(count).toBe(0)
67
+ })
68
+
69
+ it('emits discovered_issue event for each entry', () => {
70
+ writeFileSync(
71
+ join(tmpDir, DISCOVERED_REL),
72
+ DISCOVERED_HEADER +
73
+ '### ISSUE: Null pointer crash\n- **File:** src/app.ts\n- **Description:** Crashes when null.\n- **Severity:** high\n\n---\n',
74
+ )
75
+ const events = makeEvents()
76
+ const count = checkDiscoveredIssues('task-1', events as any, 'convoy-1', tmpDir)
77
+ expect(count).toBe(1)
78
+ expect(events.emit).toHaveBeenCalledWith(
79
+ 'discovered_issue',
80
+ expect.objectContaining({ title: 'Null pointer crash' }),
81
+ expect.objectContaining({ task_id: 'task-1', convoy_id: 'convoy-1' }),
82
+ )
83
+ })
84
+
85
+ it('parses multiple entries', () => {
86
+ writeFileSync(
87
+ join(tmpDir, DISCOVERED_REL),
88
+ DISCOVERED_HEADER +
89
+ '### ISSUE: Issue one\n- **File:** a.ts\n- **Description:** Desc one.\n\n---\n' +
90
+ '### ISSUE: Issue two\n- **File:** b.ts\n- **Description:** Desc two.\n\n---\n',
91
+ )
92
+ const events = makeEvents()
93
+ const count = checkDiscoveredIssues('task-x', events as any, 'convoy-y', tmpDir)
94
+ expect(count).toBe(2)
95
+ expect(events.emit).toHaveBeenCalledTimes(2)
96
+ })
97
+ })
98
+
99
+ describe('consolidateIssues', () => {
100
+ it('returns zero counts when discovered file does not exist', () => {
101
+ const result = consolidateIssues(tmpDir)
102
+ expect(result).toEqual({ moved: 0, skipped: 0 })
103
+ })
104
+
105
+ it('moves entries from discovered to known', () => {
106
+ writeFileSync(
107
+ join(tmpDir, DISCOVERED_REL),
108
+ DISCOVERED_HEADER +
109
+ '### ISSUE: New bug\n- **File:** src/x.ts\n- **Description:** A bug.\n\n---\n',
110
+ )
111
+ const result = consolidateIssues(tmpDir)
112
+ expect(result.moved).toBe(1)
113
+ const known = readFileSync(join(tmpDir, KNOWN_REL), 'utf8')
114
+ expect(known).toContain('New bug')
115
+ })
116
+
117
+ it('deduplicates by title and file', () => {
118
+ const existingEntry = '### ISSUE: Known bug\n- **File:** src/x.ts\n- **Description:** Already known.\n\n---\n'
119
+ writeFileSync(join(tmpDir, KNOWN_REL), KNOWN_HEADER + existingEntry)
120
+ writeFileSync(
121
+ join(tmpDir, DISCOVERED_REL),
122
+ DISCOVERED_HEADER +
123
+ '### ISSUE: Known bug\n- **File:** src/x.ts\n- **Description:** Duplicate.\n\n---\n',
124
+ )
125
+ const result = consolidateIssues(tmpDir)
126
+ expect(result.skipped).toBe(1)
127
+ expect(result.moved).toBe(0)
128
+ const known = readFileSync(join(tmpDir, KNOWN_REL), 'utf8')
129
+ const occurrences = (known.match(/### ISSUE: Known bug/g) || []).length
130
+ expect(occurrences).toBe(1)
131
+ })
132
+
133
+ it('clears discovered file after consolidation', () => {
134
+ writeFileSync(
135
+ join(tmpDir, DISCOVERED_REL),
136
+ DISCOVERED_HEADER +
137
+ '### ISSUE: Temp issue\n- **File:** src/y.ts\n- **Description:** Temp.\n\n---\n',
138
+ )
139
+ consolidateIssues(tmpDir)
140
+ const discovered = readFileSync(join(tmpDir, DISCOVERED_REL), 'utf8')
141
+ expect(discovered).not.toContain('### ISSUE:')
142
+ })
143
+ })
@@ -0,0 +1,136 @@
1
+ import { existsSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { scanForSecrets } from './gates.js'
4
+ import type { ConvoyEventEmitter } from './events.js'
5
+
6
+ const DISCOVERED_PATH = 'DISCOVERED-ISSUES.md'
7
+ const KNOWN_PATH = 'KNOWN-ISSUES.md'
8
+
9
+ const INJECT_INSTRUCTION =
10
+ 'IMPORTANT: After completing your task, if you notice any pre-existing bugs or issues ' +
11
+ 'unrelated to your task, append them to DISCOVERED-ISSUES.md in the format:\n\n' +
12
+ '### ISSUE: [title]\n' +
13
+ '- **File:** [filepath]\n' +
14
+ '- **Description:** [description]\n' +
15
+ '- **Severity:** low|medium|high\n\n' +
16
+ '---\n\n' +
17
+ 'Now proceed with your original task:\n\n'
18
+
19
+ export function injectDiscoveredIssuesInstruction(prompt: string): string {
20
+ return INJECT_INSTRUCTION + prompt
21
+ }
22
+
23
+ interface DiscoveredIssue {
24
+ title: string
25
+ file: string
26
+ description: string
27
+ severity: string
28
+ }
29
+
30
+ function parseIssueEntries(content: string): DiscoveredIssue[] {
31
+ const issues: DiscoveredIssue[] = []
32
+ const parts = content.split(/(?=### ISSUE:)/)
33
+ for (const part of parts) {
34
+ if (!part.trim().startsWith('### ISSUE:')) continue
35
+ const titleMatch = part.match(/### ISSUE:\s*(.+)/)
36
+ const fileMatch = part.match(/\*\*File:\*\*\s*(.+)/)
37
+ const descMatch = part.match(/\*\*Description:\*\*\s*(.+)/)
38
+ const sevMatch = part.match(/\*\*Severity:\*\*\s*(.+)/)
39
+ if (titleMatch) {
40
+ issues.push({
41
+ title: titleMatch[1].trim(),
42
+ file: fileMatch ? fileMatch[1].trim() : '',
43
+ description: descMatch ? descMatch[1].trim() : '',
44
+ severity: sevMatch ? sevMatch[1].trim() : 'low',
45
+ })
46
+ }
47
+ }
48
+ return issues
49
+ }
50
+
51
+ export function checkDiscoveredIssues(
52
+ taskId: string,
53
+ events: ConvoyEventEmitter,
54
+ convoyId: string,
55
+ basePath?: string,
56
+ ): number {
57
+ const base = basePath ?? process.cwd()
58
+ const filePath = join(base, DISCOVERED_PATH)
59
+ if (!existsSync(filePath)) return 0
60
+
61
+ const content = readFileSync(filePath, 'utf8')
62
+ const issues = parseIssueEntries(content)
63
+
64
+ for (const issue of issues) {
65
+ events.emit(
66
+ 'discovered_issue',
67
+ {
68
+ task_id: taskId,
69
+ title: issue.title,
70
+ file: issue.file,
71
+ description: issue.description,
72
+ severity: issue.severity,
73
+ },
74
+ { convoy_id: convoyId, task_id: taskId },
75
+ )
76
+ }
77
+
78
+ return issues.length
79
+ }
80
+
81
+ export function consolidateIssues(basePath?: string): { moved: number; skipped: number } {
82
+ const base = basePath ?? process.cwd()
83
+ const discoveredPath = join(base, DISCOVERED_PATH)
84
+ const knownPath = join(base, KNOWN_PATH)
85
+
86
+ if (!existsSync(discoveredPath)) return { moved: 0, skipped: 0 }
87
+
88
+ const discoveredContent = readFileSync(discoveredPath, 'utf8')
89
+ const discovered = parseIssueEntries(discoveredContent)
90
+ if (discovered.length === 0) return { moved: 0, skipped: 0 }
91
+
92
+ const knownContent = existsSync(knownPath) ? readFileSync(knownPath, 'utf8') : ''
93
+ let moved = 0
94
+ let skipped = 0
95
+ const newEntries: string[] = []
96
+
97
+ for (const issue of discovered) {
98
+ const knownLower = knownContent.toLowerCase()
99
+ const alreadyKnown =
100
+ knownLower.includes(issue.title.toLowerCase()) &&
101
+ issue.file !== '' &&
102
+ knownLower.includes(issue.file.toLowerCase())
103
+
104
+ if (alreadyKnown) {
105
+ skipped++
106
+ continue
107
+ }
108
+
109
+ const entry =
110
+ '\n### ' + issue.title + '\n' +
111
+ '- **File:** ' + issue.file + '\n' +
112
+ '- **Description:** ' + issue.description + '\n' +
113
+ '- **Severity:** ' + issue.severity + '\n\n---\n'
114
+
115
+ const scan = scanForSecrets(entry, 'known-issues')
116
+ if (!scan.clean) continue
117
+
118
+ newEntries.push(entry)
119
+ moved++
120
+ }
121
+
122
+ if (newEntries.length > 0) {
123
+ if (!existsSync(knownPath)) {
124
+ writeFileSync(knownPath, '# Known Issues\n\nTracked pre-existing bugs and issues.\n', 'utf8')
125
+ }
126
+ appendFileSync(knownPath, newEntries.join(''), 'utf8')
127
+ }
128
+
129
+ writeFileSync(
130
+ discoveredPath,
131
+ '# Discovered Issues\n\nIssues discovered by agents during task execution.\n',
132
+ 'utf8',
133
+ )
134
+
135
+ return { moved, skipped }
136
+ }
@@ -0,0 +1,101 @@
1
+ import { mkdtempSync, rmSync, realpathSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
+ import { buildKnowledgeGraph } from './knowledge.js'
6
+
7
+ vi.mock('./gates.js', () => ({
8
+ scanForSecrets: vi.fn(() => ({ clean: true, findings: [] })),
9
+ }))
10
+
11
+ const KG_REL = '.opencastle/KNOWLEDGE-GRAPH.md'
12
+
13
+ function makeBase(): string {
14
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'knowledge-test-')))
15
+ mkdirSync(join(dir, '.opencastle'), { recursive: true })
16
+ return dir
17
+ }
18
+
19
+ let tmpDir: string
20
+
21
+ beforeEach(() => {
22
+ tmpDir = makeBase()
23
+ vi.clearAllMocks()
24
+ })
25
+
26
+ afterEach(() => {
27
+ rmSync(tmpDir, { recursive: true, force: true })
28
+ })
29
+
30
+ const SIMPLE_DIFF = `diff --git a/src/app.ts b/src/app.ts
31
+ index abc..def 100644
32
+ --- a/src/app.ts
33
+ +++ b/src/app.ts
34
+ @@ -1,2 +1,3 @@
35
+ +import { helper } from './utils.js'
36
+ const x = 1
37
+ `
38
+
39
+ const TEST_FILE_DIFF = `diff --git a/src/app.test.ts b/src/app.test.ts
40
+ index abc..def 100644
41
+ --- a/src/app.test.ts
42
+ +++ b/src/app.test.ts
43
+ @@ -1,2 +1,3 @@
44
+ +import { helper } from './utils.js'
45
+ const x = 1
46
+ `
47
+
48
+ const NON_TS_DIFF = `diff --git a/README.md b/README.md
49
+ index abc..def 100644
50
+ --- a/README.md
51
+ +++ b/README.md
52
+ @@ -1,2 +1,3 @@
53
+ +Some content about importing things
54
+ existing line
55
+ `
56
+
57
+ describe('buildKnowledgeGraph', () => {
58
+ it('creates file with table header when file does not exist', () => {
59
+ buildKnowledgeGraph(SIMPLE_DIFF, 'convoy-1', tmpDir)
60
+ const content = readFileSync(join(tmpDir, KG_REL), 'utf8')
61
+ expect(content).toContain('| source |')
62
+ expect(content).toContain('| target |')
63
+ })
64
+
65
+ it('extracts import relationships from diff', () => {
66
+ buildKnowledgeGraph(SIMPLE_DIFF, 'convoy-1', tmpDir)
67
+ const content = readFileSync(join(tmpDir, KG_REL), 'utf8')
68
+ expect(content).toContain('src/app.ts')
69
+ expect(content).toContain('utils.js')
70
+ })
71
+
72
+ it('skips test files', () => {
73
+ buildKnowledgeGraph(TEST_FILE_DIFF, 'convoy-1', tmpDir)
74
+ // File should not be created or should be empty (only header)
75
+ if (require('node:fs').existsSync(join(tmpDir, KG_REL))) {
76
+ const content = readFileSync(join(tmpDir, KG_REL), 'utf8')
77
+ // Should only have header, no data rows from test files
78
+ const lines = content.split('\n').filter((l: string) => l.startsWith('| src/app.test.ts'))
79
+ expect(lines).toHaveLength(0)
80
+ }
81
+ })
82
+
83
+ it('skips non-ts/js files', () => {
84
+ buildKnowledgeGraph(NON_TS_DIFF, 'convoy-1', tmpDir)
85
+ expect(require('node:fs').existsSync(join(tmpDir, KG_REL))).toBe(false)
86
+ })
87
+
88
+ it('deduplicates existing rows', () => {
89
+ buildKnowledgeGraph(SIMPLE_DIFF, 'convoy-1', tmpDir)
90
+ buildKnowledgeGraph(SIMPLE_DIFF, 'convoy-2', tmpDir)
91
+ const content = readFileSync(join(tmpDir, KG_REL), 'utf8')
92
+ const rows = content.split('\n').filter((l: string) => l.includes('src/app.ts') && l.includes('utils.js'))
93
+ expect(rows).toHaveLength(1)
94
+ })
95
+
96
+ it('includes convoy_id in the row', () => {
97
+ buildKnowledgeGraph(SIMPLE_DIFF, 'convoy-abc123', tmpDir)
98
+ const content = readFileSync(join(tmpDir, KG_REL), 'utf8')
99
+ expect(content).toContain('convoy-abc123')
100
+ })
101
+ })