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
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { scanForSecrets } from './gates.js'
4
+
5
+ const KNOWLEDGE_PATH = '.opencastle/KNOWLEDGE-GRAPH.md'
6
+ const TABLE_HEADER =
7
+ '| source | target | relationship | date | convoy_id |\n' +
8
+ '|--------|--------|--------------|------|-----------|\n'
9
+
10
+ function extractChangedFiles(diffOutput: string): string[] {
11
+ const files: string[] = []
12
+ const re = /^diff --git a\/.+ b\/(.+)$/gm
13
+ let m: RegExpExecArray | null
14
+ while ((m = re.exec(diffOutput)) !== null) {
15
+ const fileName = m[1]
16
+ if (isTargetFile(fileName)) files.push(fileName)
17
+ }
18
+ return files
19
+ }
20
+
21
+ function isTargetFile(fileName: string): boolean {
22
+ if (!/\.(ts|js)$/.test(fileName)) return false
23
+ if (/\.(test|spec)\.(ts|js)$/.test(fileName)) return false
24
+ return true
25
+ }
26
+
27
+ function extractFileDiff(diffOutput: string, fileName: string): string {
28
+ const escaped = fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
29
+ const re = new RegExp(
30
+ 'diff --git a/' + escaped + ' b/' + escaped + '([\\s\\S]*?)(?=diff --git |$)',
31
+ )
32
+ const m = diffOutput.match(re)
33
+ return m ? m[0] : ''
34
+ }
35
+
36
+ function extractImports(diffContent: string): Array<{ source: string; target: string }> {
37
+ const imports: Array<{ source: string; target: string }> = []
38
+ const headerMatch = diffContent.match(/^diff --git a\/(.+) b\//)
39
+ if (!headerMatch) return []
40
+ const sourceFile = headerMatch[1]
41
+
42
+ for (const line of diffContent.split('\n')) {
43
+ if (!line.startsWith('+') || line.startsWith('+++')) continue
44
+ const content = line.slice(1)
45
+
46
+ const esmMatch = content.match(/import\s+.*\s+from\s+['"](\.[^'"]+)['"]/)
47
+ if (esmMatch) {
48
+ imports.push({ source: sourceFile, target: esmMatch[1] })
49
+ continue
50
+ }
51
+ const reqMatch = content.match(/require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/)
52
+ if (reqMatch) {
53
+ imports.push({ source: sourceFile, target: reqMatch[1] })
54
+ }
55
+ }
56
+
57
+ return imports
58
+ }
59
+
60
+ function parseExistingRowKeys(content: string): Set<string> {
61
+ const keys = new Set<string>()
62
+ for (const line of content.split('\n')) {
63
+ const parts = line.split('|').map(p => p.trim()).filter(Boolean)
64
+ if (parts.length >= 2 && parts[0] !== 'source') {
65
+ keys.add(parts[0] + '|' + parts[1])
66
+ }
67
+ }
68
+ return keys
69
+ }
70
+
71
+ export function buildKnowledgeGraph(
72
+ diffOutput: string,
73
+ convoyId: string,
74
+ basePath?: string,
75
+ ): { added: number; skipped: number } {
76
+ const base = basePath ?? process.cwd()
77
+ const filePath = join(base, KNOWLEDGE_PATH)
78
+ const date = new Date().toISOString().slice(0, 10)
79
+
80
+ const changedFiles = extractChangedFiles(diffOutput)
81
+ const allImports: Array<{ source: string; target: string }> = []
82
+ for (const file of changedFiles) {
83
+ allImports.push(...extractImports(extractFileDiff(diffOutput, file)))
84
+ }
85
+
86
+ if (allImports.length === 0) return { added: 0, skipped: 0 }
87
+
88
+ const existingContent = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
89
+ const existingKeys = parseExistingRowKeys(existingContent)
90
+
91
+ const newRows: string[] = []
92
+ let skipped = 0
93
+
94
+ for (const { source, target } of allImports) {
95
+ const key = source + '|' + target
96
+ if (existingKeys.has(key)) {
97
+ skipped++
98
+ continue
99
+ }
100
+ const row = '| ' + source + ' | ' + target + ' | imports | ' + date + ' | ' + convoyId + ' |'
101
+ const scan = scanForSecrets(row, 'knowledge-graph')
102
+ if (!scan.clean) {
103
+ skipped++
104
+ continue
105
+ }
106
+ newRows.push(row)
107
+ existingKeys.add(key)
108
+ }
109
+
110
+ if (newRows.length === 0) return { added: 0, skipped }
111
+
112
+ let fileContent: string
113
+ if (!existsSync(filePath)) {
114
+ fileContent =
115
+ '# Knowledge Graph\n\nFile dependency relationships discovered during convoy runs.\n\n' +
116
+ TABLE_HEADER +
117
+ newRows.join('\n') +
118
+ '\n'
119
+ } else {
120
+ const hasHeader = existingContent.includes('| source | target |')
121
+ fileContent =
122
+ (hasHeader
123
+ ? existingContent.trimEnd()
124
+ : existingContent.trimEnd() + '\n\n' + TABLE_HEADER.trimEnd()) +
125
+ '\n' +
126
+ newRows.join('\n') +
127
+ '\n'
128
+ }
129
+
130
+ writeFileSync(filePath, fileContent, 'utf8')
131
+ return { added: newRows.length, skipped }
132
+ }
@@ -0,0 +1,188 @@
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 { readLessons, captureLessons, consolidateLessons } from './lessons.js'
6
+
7
+ vi.mock('./gates.js', () => ({
8
+ scanForSecrets: vi.fn(() => ({ clean: true, findings: [] })),
9
+ }))
10
+
11
+ const LESSONS_REL = '.opencastle/LESSONS-LEARNED.md'
12
+
13
+ function makeBase(): string {
14
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'lessons-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
+ describe('readLessons', () => {
31
+ it('returns empty array when file does not exist', () => {
32
+ const result = readLessons('developer', [], tmpDir)
33
+ expect(result).toEqual([])
34
+ })
35
+
36
+ it('returns empty array when file has no LES entries', () => {
37
+ writeFileSync(join(tmpDir, LESSONS_REL), '# Lessons Learned\n\nNo entries yet.\n')
38
+ const result = readLessons('developer', [], tmpDir)
39
+ expect(result).toEqual([])
40
+ })
41
+
42
+ it('filters by agent name', () => {
43
+ writeFileSync(
44
+ join(tmpDir, LESSONS_REL),
45
+ '# Lessons\n\n' +
46
+ '### LES-001: Dev tip\n| **Category** | `general` |\n| **Added** | 2026-01-01 |\n| **Agent** | developer |\nText about developer agent.\n\n' +
47
+ '### LES-002: Other tip\n| **Category** | `git` |\n| **Added** | 2026-01-02 |\n| **Agent** | reviewer |\nText about reviewer agent.\n',
48
+ )
49
+ const result = readLessons('developer', [], tmpDir)
50
+ expect(result).toHaveLength(1)
51
+ expect(result[0]).toContain('Dev tip')
52
+ })
53
+
54
+ it('filters by file paths', () => {
55
+ writeFileSync(
56
+ join(tmpDir, LESSONS_REL),
57
+ '# Lessons\n\n' +
58
+ '### LES-001: File tip\n| **Category** | `general` |\n| **Added** | 2026-01-01 |\nText about src/cli/engine.ts file.\n\n' +
59
+ '### LES-002: Unrelated\n| **Category** | `git` |\n| **Added** | 2026-01-02 |\nSome other content.\n',
60
+ )
61
+ const result = readLessons('unknown', ['src/cli/engine.ts'], tmpDir)
62
+ expect(result).toHaveLength(1)
63
+ expect(result[0]).toContain('File tip')
64
+ })
65
+
66
+ it('returns maximum 5 entries', () => {
67
+ let content = '# Lessons\n\n'
68
+ for (let i = 1; i <= 8; i++) {
69
+ content += `### LES-00${i}: Tip ${i}\n| **Category** | \`general\` |\n| **Added** | 2026-01-0${i} |\nText about developer agent.\n\n`
70
+ }
71
+ writeFileSync(join(tmpDir, LESSONS_REL), content)
72
+ const result = readLessons('developer', [], tmpDir)
73
+ expect(result.length).toBeLessThanOrEqual(5)
74
+ })
75
+
76
+ it('prioritizes agent+file matches over agent-only matches', () => {
77
+ writeFileSync(
78
+ join(tmpDir, LESSONS_REL),
79
+ '# Lessons\n\n' +
80
+ '### LES-001: Agent only\n| **Category** | `general` |\n| **Added** | 2026-01-01 |\nContent about developer.\n\n' +
81
+ '### LES-002: Agent and file\n| **Category** | `general` |\n| **Added** | 2026-01-02 |\nContent about developer and src/app.ts.\n',
82
+ )
83
+ const result = readLessons('developer', ['src/app.ts'], tmpDir)
84
+ expect(result[0]).toContain('Agent and file')
85
+ })
86
+ })
87
+
88
+ describe('captureLessons', () => {
89
+ it('appends an entry with correct format', () => {
90
+ const result = captureLessons(
91
+ { title: 'Test lesson', category: 'general', agent: 'developer', problem: 'A problem', solution: 'A solution' },
92
+ tmpDir,
93
+ )
94
+ expect(result.captured).toBe(true)
95
+ const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
96
+ expect(content).toContain('### LES-001: Test lesson')
97
+ expect(content).toContain('**Problem:** A problem')
98
+ expect(content).toContain('**Correct approach:** A solution')
99
+ })
100
+
101
+ it('creates file if it does not exist', () => {
102
+ captureLessons(
103
+ { title: 'First', category: 'general', agent: 'x', problem: 'p', solution: 's' },
104
+ tmpDir,
105
+ )
106
+ expect(readFileSync(join(tmpDir, LESSONS_REL), 'utf8')).toContain('### LES-001: First')
107
+ })
108
+
109
+ it('auto-increments LES number', () => {
110
+ writeFileSync(join(tmpDir, LESSONS_REL), '# Lessons\n\n### LES-007: Existing\n')
111
+ captureLessons(
112
+ { title: 'New', category: 'general', agent: 'x', problem: 'p', solution: 's' },
113
+ tmpDir,
114
+ )
115
+ const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
116
+ expect(content).toContain('### LES-008: New')
117
+ })
118
+
119
+ it('includes files note when files provided', () => {
120
+ captureLessons(
121
+ { title: 'With files', category: 'general', agent: 'x', problem: 'p', solution: 's', files: ['foo.ts', 'bar.ts'] },
122
+ tmpDir,
123
+ )
124
+ const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
125
+ expect(content).toContain('foo.ts')
126
+ expect(content).toContain('bar.ts')
127
+ })
128
+
129
+ it('rejects content with secrets', async () => {
130
+ const { scanForSecrets } = await import('./gates.js')
131
+ vi.mocked(scanForSecrets).mockReturnValueOnce({
132
+ clean: false,
133
+ findings: [{ pattern: 'Generic Secret', file: '', line: 1, snippet: 'x' }],
134
+ })
135
+ const result = captureLessons(
136
+ { title: 'Bad', category: 'general', agent: 'x', problem: 'p', solution: 's' },
137
+ tmpDir,
138
+ )
139
+ expect(result.captured).toBe(false)
140
+ expect(result.reason).toBe('secrets_detected')
141
+ })
142
+ })
143
+
144
+ describe('consolidateLessons', () => {
145
+ it('returns zeros when file does not exist', () => {
146
+ const result = consolidateLessons(tmpDir)
147
+ expect(result).toEqual({ merged: 0, remaining: 0 })
148
+ })
149
+
150
+ it('merges duplicate entries with same category and similar title', () => {
151
+ writeFileSync(
152
+ join(tmpDir, LESSONS_REL),
153
+ '# Lessons\n\n' +
154
+ '### LES-001: Git push rejected error\n| **Category** | `git` |\n| **Added** | 2026-01-01 |\nOld content.\n\n' +
155
+ '### LES-002: Git push rejected error fix\n| **Category** | `git` |\n| **Added** | 2026-01-05 |\nNewer content.\n',
156
+ )
157
+ const result = consolidateLessons(tmpDir)
158
+ expect(result.merged).toBe(1)
159
+ expect(result.remaining).toBe(1)
160
+ const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
161
+ expect(content).toContain('2026-01-05')
162
+ })
163
+
164
+ it('keeps the most recent entry when merging', () => {
165
+ writeFileSync(
166
+ join(tmpDir, LESSONS_REL),
167
+ '# Lessons\n\n' +
168
+ '### LES-001: Same title same\n| **Category** | `general` |\n| **Added** | 2026-01-01 |\nOld.\n\n' +
169
+ '### LES-002: Same title same\n| **Category** | `general` |\n| **Added** | 2026-03-01 |\nNew.\n',
170
+ )
171
+ consolidateLessons(tmpDir)
172
+ const content = readFileSync(join(tmpDir, LESSONS_REL), 'utf8')
173
+ expect(content).toContain('New.')
174
+ expect(content).not.toContain('Old.')
175
+ })
176
+
177
+ it('does not merge entries with different categories', () => {
178
+ writeFileSync(
179
+ join(tmpDir, LESSONS_REL),
180
+ '# Lessons\n\n' +
181
+ '### LES-001: Same title same\n| **Category** | `git` |\n| **Added** | 2026-01-01 |\nGit.\n\n' +
182
+ '### LES-002: Same title same\n| **Category** | `general` |\n| **Added** | 2026-01-02 |\nGeneral.\n',
183
+ )
184
+ const result = consolidateLessons(tmpDir)
185
+ expect(result.remaining).toBe(2)
186
+ expect(result.merged).toBe(0)
187
+ })
188
+ })
@@ -0,0 +1,164 @@
1
+ import { existsSync, readFileSync, appendFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { scanForSecrets } from './gates.js'
4
+
5
+ const LESSONS_PATH = '.opencastle/LESSONS-LEARNED.md'
6
+
7
+ function parseLessonEntries(content: string): string[] {
8
+ const parts = content.split(/(?=### LES-\d+:)/)
9
+ return parts.filter(p => p.trim().startsWith('### LES-'))
10
+ }
11
+
12
+ function getNextLessonNumber(entries: string[]): number {
13
+ let max = 0
14
+ for (const entry of entries) {
15
+ const m = entry.match(/### LES-(\d+):/)
16
+ if (m) {
17
+ const n = parseInt(m[1], 10)
18
+ if (n > max) max = n
19
+ }
20
+ }
21
+ return max + 1
22
+ }
23
+
24
+ export function readLessons(agentName: string, filePaths: string[], basePath?: string): string[] {
25
+ const base = basePath ?? process.cwd()
26
+ const filePath = join(base, LESSONS_PATH)
27
+ if (!existsSync(filePath)) return []
28
+ const content = readFileSync(filePath, 'utf8')
29
+ const entries = parseLessonEntries(content)
30
+ if (entries.length === 0) return []
31
+ const agentLower = agentName.toLowerCase()
32
+ const scored: Array<{ entry: string; score: number }> = []
33
+ for (const entry of entries) {
34
+ const entryLower = entry.toLowerCase()
35
+ const matchesAgent = entryLower.includes(agentLower)
36
+ const matchesFiles =
37
+ filePaths.length > 0 && filePaths.some(fp => entryLower.includes(fp.toLowerCase()))
38
+ if (matchesAgent && matchesFiles) {
39
+ scored.push({ entry, score: 2 })
40
+ } else if (matchesAgent) {
41
+ scored.push({ entry, score: 1 })
42
+ } else if (matchesFiles) {
43
+ scored.push({ entry, score: 0.5 })
44
+ }
45
+ }
46
+ scored.sort((a, b) => b.score - a.score)
47
+ return scored.slice(0, 5).map(s => s.entry.trim())
48
+ }
49
+
50
+ export function captureLessons(
51
+ lesson: {
52
+ title: string
53
+ category: string
54
+ agent: string
55
+ problem: string
56
+ solution: string
57
+ files?: string[]
58
+ },
59
+ basePath?: string,
60
+ ): { captured: boolean; reason?: string } {
61
+ const base = basePath ?? process.cwd()
62
+ const filePath = join(base, LESSONS_PATH)
63
+ const existingContent = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
64
+ const entries = parseLessonEntries(existingContent)
65
+ const nextNum = getNextLessonNumber(entries)
66
+ const lesNum = String(nextNum).padStart(3, '0')
67
+ const date = new Date().toISOString().slice(0, 10)
68
+ const filesNote =
69
+ lesson.files && lesson.files.length > 0
70
+ ? `\n**Files:** ${lesson.files.join(', ')}`
71
+ : ''
72
+ const entry =
73
+ `\n### LES-${lesNum}: ${lesson.title}\n\n` +
74
+ '| Field | Value |\n|-------|-------|\n' +
75
+ `| **Category** | \`${lesson.category}\` |\n` +
76
+ `| **Added** | ${date} |\n` +
77
+ `| **Agent** | ${lesson.agent} |\n` +
78
+ '| **Severity** | `medium` |\n\n' +
79
+ `**Problem:** ${lesson.problem}\n\n` +
80
+ `**Correct approach:** ${lesson.solution}${filesNote}\n`
81
+ const scanResult = scanForSecrets(entry, 'lessons')
82
+ if (!scanResult.clean) {
83
+ return { captured: false, reason: 'secrets_detected' }
84
+ }
85
+ if (!existsSync(filePath)) {
86
+ writeFileSync(
87
+ filePath,
88
+ '# Lessons Learned\n\nStructured log of pitfalls and correct approaches.\n\n## Lessons\n',
89
+ 'utf8',
90
+ )
91
+ }
92
+ appendFileSync(filePath, entry, 'utf8')
93
+ return { captured: true }
94
+ }
95
+
96
+ function extractDate(entry: string): string {
97
+ const m = entry.match(/\*\*Added\*\*\s*\|\s*(\d{4}-\d{2}-\d{2})/)
98
+ return m ? m[1] : '0000-00-00'
99
+ }
100
+
101
+ function extractCategory(entry: string): string {
102
+ const m = entry.match(/\*\*Category\*\*\s*\|\s*`([^`]+)`/)
103
+ return m ? m[1] : ''
104
+ }
105
+
106
+ function extractTitle(entry: string): string {
107
+ const m = entry.match(/### LES-\d+:\s*(.+)/)
108
+ return m ? m[1].trim() : ''
109
+ }
110
+
111
+ function normalizeTitle(title: string): string {
112
+ return title.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim()
113
+ }
114
+
115
+ function wordOverlap(a: string, b: string): number {
116
+ const wordsA = new Set(a.split(/\s+/).filter(Boolean))
117
+ const wordsB = new Set(b.split(/\s+/).filter(Boolean))
118
+ if (wordsA.size === 0 || wordsB.size === 0) return 0
119
+ let overlap = 0
120
+ for (const w of wordsA) {
121
+ if (wordsB.has(w)) overlap++
122
+ }
123
+ return overlap / Math.max(wordsA.size, wordsB.size)
124
+ }
125
+
126
+ export function consolidateLessons(basePath?: string): { merged: number; remaining: number } {
127
+ const base = basePath ?? process.cwd()
128
+ const filePath = join(base, LESSONS_PATH)
129
+ if (!existsSync(filePath)) return { merged: 0, remaining: 0 }
130
+ const content = readFileSync(filePath, 'utf8')
131
+ const entries = parseLessonEntries(content)
132
+ if (entries.length === 0) return { merged: 0, remaining: 0 }
133
+ const firstLessonIdx = content.indexOf('### LES-')
134
+ const header = firstLessonIdx > 0 ? content.slice(0, firstLessonIdx) : ''
135
+ const kept: string[] = []
136
+ let mergedCount = 0
137
+ const processed = new Set<number>()
138
+ for (let i = 0; i < entries.length; i++) {
139
+ if (processed.has(i)) continue
140
+ const catI = extractCategory(entries[i])
141
+ const titleI = normalizeTitle(extractTitle(entries[i]))
142
+ let bestIdx = i
143
+ let bestDate = extractDate(entries[i])
144
+ for (let j = i + 1; j < entries.length; j++) {
145
+ if (processed.has(j)) continue
146
+ const catJ = extractCategory(entries[j])
147
+ if (catI !== catJ) continue
148
+ const titleJ = normalizeTitle(extractTitle(entries[j]))
149
+ if (wordOverlap(titleI, titleJ) >= 0.8) {
150
+ const dateJ = extractDate(entries[j])
151
+ if (dateJ > bestDate) {
152
+ bestDate = dateJ
153
+ bestIdx = j
154
+ }
155
+ processed.add(j)
156
+ mergedCount++
157
+ }
158
+ }
159
+ processed.add(i)
160
+ kept.push(entries[bestIdx])
161
+ }
162
+ writeFileSync(filePath, header + kept.join('\n'), 'utf8')
163
+ return { merged: mergedCount, remaining: kept.length }
164
+ }
@@ -0,0 +1,181 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { realpathSync } from 'node:fs'
3
+ import { tmpdir, hostname } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { DatabaseSync } from 'node:sqlite'
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
7
+ import {
8
+ acquireEngineLock,
9
+ EngineAlreadyRunningError,
10
+ isLockStale,
11
+ releaseEngineLock,
12
+ } from './lock.js'
13
+
14
+ const LOCK_TABLE_SQL = `
15
+ CREATE TABLE IF NOT EXISTS engine_lock (
16
+ id INTEGER PRIMARY KEY CHECK (id = 1),
17
+ pid INTEGER NOT NULL,
18
+ hostname TEXT NOT NULL,
19
+ started_at TEXT NOT NULL,
20
+ last_heartbeat TEXT NOT NULL
21
+ )
22
+ `
23
+
24
+ let tmpDir: string
25
+ let dbPath: string
26
+ let db: DatabaseSync
27
+
28
+ beforeEach(() => {
29
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'lock-test-')))
30
+ dbPath = join(tmpDir, 'test.db')
31
+ db = new DatabaseSync(dbPath)
32
+ db.exec('PRAGMA journal_mode = WAL')
33
+ db.exec(LOCK_TABLE_SQL)
34
+ })
35
+
36
+ afterEach(() => {
37
+ try {
38
+ db.close()
39
+ } catch {
40
+ // already closed
41
+ }
42
+ rmSync(tmpDir, { recursive: true, force: true })
43
+ })
44
+
45
+ describe('engine lock', () => {
46
+ it('takes over a stale lock when heartbeat is expired and PID is dead', () => {
47
+ const staleTime = new Date(Date.now() - 60_000).toISOString()
48
+ const deadPid = 999999
49
+
50
+ // Verify the PID is actually dead on this machine
51
+ expect(() => process.kill(deadPid, 0)).toThrow()
52
+
53
+ db.prepare(
54
+ 'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
55
+ ).run(deadPid, hostname(), staleTime, staleTime)
56
+
57
+ const lock = acquireEngineLock(db, dbPath)
58
+ const row = db
59
+ .prepare('SELECT pid FROM engine_lock WHERE id = 1')
60
+ .get() as { pid: number }
61
+ expect(row.pid).toBe(process.pid)
62
+ lock.release()
63
+ })
64
+
65
+ it('throws EngineAlreadyRunningError when lock is held by a live process', () => {
66
+ const now = new Date().toISOString()
67
+ db.prepare(
68
+ 'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
69
+ ).run(process.pid, hostname(), now, now)
70
+
71
+ expect(() => acquireEngineLock(db, dbPath)).toThrow(EngineAlreadyRunningError)
72
+ })
73
+
74
+ it('takes over when hostname differs (treated as stale regardless of PID)', () => {
75
+ const staleTime = new Date(Date.now() - 60_000).toISOString()
76
+ db.prepare(
77
+ 'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
78
+ ).run(process.pid, 'other-host.example.com', staleTime, staleTime)
79
+
80
+ const lock = acquireEngineLock(db, dbPath)
81
+ const row = db
82
+ .prepare('SELECT hostname FROM engine_lock WHERE id = 1')
83
+ .get() as { hostname: string }
84
+ expect(row.hostname).toBe(hostname())
85
+ lock.release()
86
+ })
87
+
88
+ it('release deletes the lock row', () => {
89
+ const lock = acquireEngineLock(db, dbPath)
90
+ lock.release()
91
+ const row = db.prepare('SELECT * FROM engine_lock WHERE id = 1').get()
92
+ expect(row).toBeUndefined()
93
+ })
94
+
95
+ it('startHeartbeat updates last_heartbeat after 10 seconds', () => {
96
+ vi.useFakeTimers()
97
+ try {
98
+ const lock = acquireEngineLock(db, dbPath)
99
+ const before = (
100
+ db
101
+ .prepare('SELECT last_heartbeat FROM engine_lock WHERE id = 1')
102
+ .get() as { last_heartbeat: string }
103
+ ).last_heartbeat
104
+
105
+ lock.startHeartbeat()
106
+ vi.advanceTimersByTime(10_000)
107
+
108
+ const after = (
109
+ db
110
+ .prepare('SELECT last_heartbeat FROM engine_lock WHERE id = 1')
111
+ .get() as { last_heartbeat: string }
112
+ ).last_heartbeat
113
+
114
+ expect(after).not.toBe(before)
115
+ lock.release()
116
+ } finally {
117
+ vi.useRealTimers()
118
+ }
119
+ })
120
+
121
+ it('throws EngineAlreadyRunningError when SQLITE_BUSY from a concurrent write lock', () => {
122
+ // Hold a BEGIN IMMEDIATE transaction on a second connection so the first
123
+ // connection's BEGIN IMMEDIATE will return SQLITE_BUSY.
124
+ const db2 = new DatabaseSync(dbPath)
125
+ db2.exec('PRAGMA journal_mode = WAL')
126
+ db2.exec(LOCK_TABLE_SQL)
127
+ db2.exec('BEGIN IMMEDIATE')
128
+
129
+ try {
130
+ expect(() => acquireEngineLock(db, dbPath)).toThrow(EngineAlreadyRunningError)
131
+ } finally {
132
+ db2.exec('ROLLBACK')
133
+ db2.close()
134
+ }
135
+ })
136
+
137
+ it('takes over lock from different hostname when heartbeat expired', () => {
138
+ const staleTime = new Date(Date.now() - 60_000).toISOString()
139
+ db.prepare(
140
+ 'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
141
+ ).run(process.pid, 'ci-runner-42.example.com', staleTime, staleTime)
142
+
143
+ const lock = acquireEngineLock(db, dbPath)
144
+ const row = db
145
+ .prepare('SELECT hostname, pid FROM engine_lock WHERE id = 1')
146
+ .get() as { hostname: string; pid: number }
147
+ expect(row.hostname).toBe(hostname())
148
+ expect(row.pid).toBe(process.pid)
149
+ lock.release()
150
+ })
151
+
152
+ it('does NOT take over lock from different hostname when heartbeat is fresh', () => {
153
+ const freshTime = new Date().toISOString()
154
+ db.prepare(
155
+ 'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
156
+ ).run(12345, 'other-host.example.com', freshTime, freshTime)
157
+
158
+ expect(() => acquireEngineLock(db, dbPath)).toThrow(EngineAlreadyRunningError)
159
+ })
160
+
161
+ it('isLockStale returns true when no lock exists', () => {
162
+ expect(isLockStale(db)).toBe(true)
163
+ })
164
+
165
+ it('isLockStale returns false for fresh lock on same host', () => {
166
+ const now = new Date().toISOString()
167
+ db.prepare(
168
+ 'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
169
+ ).run(process.pid, hostname(), now, now)
170
+ expect(isLockStale(db)).toBe(false)
171
+ })
172
+
173
+ it('isLockStale returns true for expired lock with dead PID on same host', () => {
174
+ const staleTime = new Date(Date.now() - 60_000).toISOString()
175
+ const deadPid = 999999
176
+ db.prepare(
177
+ 'INSERT INTO engine_lock (id, pid, hostname, started_at, last_heartbeat) VALUES (1, ?, ?, ?, ?)',
178
+ ).run(deadPid, hostname(), staleTime, staleTime)
179
+ expect(isLockStale(db)).toBe(true)
180
+ })
181
+ })