opencastle 0.31.7 → 0.32.1

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 (220) hide show
  1. package/README.md +4 -1
  2. package/bin/cli.mjs +15 -0
  3. package/dist/cli/agents.d.ts.map +1 -1
  4. package/dist/cli/agents.js +19 -5
  5. package/dist/cli/agents.js.map +1 -1
  6. package/dist/cli/artifacts-cli.d.ts +3 -0
  7. package/dist/cli/artifacts-cli.d.ts.map +1 -0
  8. package/dist/cli/artifacts-cli.js +36 -0
  9. package/dist/cli/artifacts-cli.js.map +1 -0
  10. package/dist/cli/baselines.d.ts.map +1 -1
  11. package/dist/cli/baselines.js +11 -0
  12. package/dist/cli/baselines.js.map +1 -1
  13. package/dist/cli/convoy/artifacts.d.ts +25 -0
  14. package/dist/cli/convoy/artifacts.d.ts.map +1 -0
  15. package/dist/cli/convoy/artifacts.js +129 -0
  16. package/dist/cli/convoy/artifacts.js.map +1 -0
  17. package/dist/cli/convoy/artifacts.test.d.ts +2 -0
  18. package/dist/cli/convoy/artifacts.test.d.ts.map +1 -0
  19. package/dist/cli/convoy/artifacts.test.js +169 -0
  20. package/dist/cli/convoy/artifacts.test.js.map +1 -0
  21. package/dist/cli/convoy/compaction.d.ts +23 -0
  22. package/dist/cli/convoy/compaction.d.ts.map +1 -0
  23. package/dist/cli/convoy/compaction.js +117 -0
  24. package/dist/cli/convoy/compaction.js.map +1 -0
  25. package/dist/cli/convoy/compaction.test.d.ts +2 -0
  26. package/dist/cli/convoy/compaction.test.d.ts.map +1 -0
  27. package/dist/cli/convoy/compaction.test.js +205 -0
  28. package/dist/cli/convoy/compaction.test.js.map +1 -0
  29. package/dist/cli/convoy/contracts.d.ts +22 -0
  30. package/dist/cli/convoy/contracts.d.ts.map +1 -0
  31. package/dist/cli/convoy/contracts.js +254 -0
  32. package/dist/cli/convoy/contracts.js.map +1 -0
  33. package/dist/cli/convoy/contracts.test.d.ts +2 -0
  34. package/dist/cli/convoy/contracts.test.d.ts.map +1 -0
  35. package/dist/cli/convoy/contracts.test.js +239 -0
  36. package/dist/cli/convoy/contracts.test.js.map +1 -0
  37. package/dist/cli/convoy/dag-analysis.d.ts +40 -0
  38. package/dist/cli/convoy/dag-analysis.d.ts.map +1 -0
  39. package/dist/cli/convoy/dag-analysis.js +282 -0
  40. package/dist/cli/convoy/dag-analysis.js.map +1 -0
  41. package/dist/cli/convoy/dag-analysis.test.d.ts +2 -0
  42. package/dist/cli/convoy/dag-analysis.test.d.ts.map +1 -0
  43. package/dist/cli/convoy/dag-analysis.test.js +289 -0
  44. package/dist/cli/convoy/dag-analysis.test.js.map +1 -0
  45. package/dist/cli/convoy/effort-scaling.d.ts +20 -0
  46. package/dist/cli/convoy/effort-scaling.d.ts.map +1 -0
  47. package/dist/cli/convoy/effort-scaling.js +82 -0
  48. package/dist/cli/convoy/effort-scaling.js.map +1 -0
  49. package/dist/cli/convoy/effort-scaling.test.d.ts +2 -0
  50. package/dist/cli/convoy/effort-scaling.test.d.ts.map +1 -0
  51. package/dist/cli/convoy/effort-scaling.test.js +120 -0
  52. package/dist/cli/convoy/effort-scaling.test.js.map +1 -0
  53. package/dist/cli/convoy/engine.d.ts.map +1 -1
  54. package/dist/cli/convoy/engine.js +280 -6
  55. package/dist/cli/convoy/engine.js.map +1 -1
  56. package/dist/cli/convoy/engine.test.js +155 -18
  57. package/dist/cli/convoy/engine.test.js.map +1 -1
  58. package/dist/cli/convoy/event-schemas.d.ts.map +1 -1
  59. package/dist/cli/convoy/event-schemas.js +55 -0
  60. package/dist/cli/convoy/event-schemas.js.map +1 -1
  61. package/dist/cli/convoy/isolation.d.ts +27 -0
  62. package/dist/cli/convoy/isolation.d.ts.map +1 -0
  63. package/dist/cli/convoy/isolation.js +120 -0
  64. package/dist/cli/convoy/isolation.js.map +1 -0
  65. package/dist/cli/convoy/isolation.test.d.ts +2 -0
  66. package/dist/cli/convoy/isolation.test.d.ts.map +1 -0
  67. package/dist/cli/convoy/isolation.test.js +105 -0
  68. package/dist/cli/convoy/isolation.test.js.map +1 -0
  69. package/dist/cli/convoy/review-stages.d.ts +9 -0
  70. package/dist/cli/convoy/review-stages.d.ts.map +1 -0
  71. package/dist/cli/convoy/review-stages.js +134 -0
  72. package/dist/cli/convoy/review-stages.js.map +1 -0
  73. package/dist/cli/convoy/review-stages.test.d.ts +2 -0
  74. package/dist/cli/convoy/review-stages.test.d.ts.map +1 -0
  75. package/dist/cli/convoy/review-stages.test.js +197 -0
  76. package/dist/cli/convoy/review-stages.test.js.map +1 -0
  77. package/dist/cli/convoy/skill-refinement.d.ts +39 -0
  78. package/dist/cli/convoy/skill-refinement.d.ts.map +1 -0
  79. package/dist/cli/convoy/skill-refinement.js +239 -0
  80. package/dist/cli/convoy/skill-refinement.js.map +1 -0
  81. package/dist/cli/convoy/skill-refinement.test.d.ts +2 -0
  82. package/dist/cli/convoy/skill-refinement.test.d.ts.map +1 -0
  83. package/dist/cli/convoy/skill-refinement.test.js +230 -0
  84. package/dist/cli/convoy/skill-refinement.test.js.map +1 -0
  85. package/dist/cli/convoy/spec-builder.d.ts +1 -0
  86. package/dist/cli/convoy/spec-builder.d.ts.map +1 -1
  87. package/dist/cli/convoy/spec-builder.js +11 -0
  88. package/dist/cli/convoy/spec-builder.js.map +1 -1
  89. package/dist/cli/convoy/spec-builder.test.js +54 -0
  90. package/dist/cli/convoy/spec-builder.test.js.map +1 -1
  91. package/dist/cli/convoy/store.d.ts +3 -2
  92. package/dist/cli/convoy/store.d.ts.map +1 -1
  93. package/dist/cli/convoy/store.js +20 -2
  94. package/dist/cli/convoy/store.js.map +1 -1
  95. package/dist/cli/convoy/store.test.js +15 -15
  96. package/dist/cli/convoy/store.test.js.map +1 -1
  97. package/dist/cli/convoy/tdd-gate.d.ts +15 -0
  98. package/dist/cli/convoy/tdd-gate.d.ts.map +1 -0
  99. package/dist/cli/convoy/tdd-gate.js +119 -0
  100. package/dist/cli/convoy/tdd-gate.js.map +1 -0
  101. package/dist/cli/convoy/tdd-gate.test.d.ts +2 -0
  102. package/dist/cli/convoy/tdd-gate.test.d.ts.map +1 -0
  103. package/dist/cli/convoy/tdd-gate.test.js +227 -0
  104. package/dist/cli/convoy/tdd-gate.test.js.map +1 -0
  105. package/dist/cli/convoy/types.d.ts +91 -0
  106. package/dist/cli/convoy/types.d.ts.map +1 -1
  107. package/dist/cli/convoy/types.js +8 -0
  108. package/dist/cli/convoy/types.js.map +1 -1
  109. package/dist/cli/insights.d.ts +3 -0
  110. package/dist/cli/insights.d.ts.map +1 -0
  111. package/dist/cli/insights.js +94 -0
  112. package/dist/cli/insights.js.map +1 -0
  113. package/dist/cli/lesson.d.ts.map +1 -1
  114. package/dist/cli/lesson.js +7 -0
  115. package/dist/cli/lesson.js.map +1 -1
  116. package/dist/cli/log.d.ts.map +1 -1
  117. package/dist/cli/log.js +7 -0
  118. package/dist/cli/log.js.map +1 -1
  119. package/dist/cli/package-config.d.ts +12 -0
  120. package/dist/cli/package-config.d.ts.map +1 -0
  121. package/dist/cli/package-config.js +37 -0
  122. package/dist/cli/package-config.js.map +1 -0
  123. package/dist/cli/package.d.ts +23 -0
  124. package/dist/cli/package.d.ts.map +1 -0
  125. package/dist/cli/package.js +285 -0
  126. package/dist/cli/package.js.map +1 -0
  127. package/dist/cli/package.test.d.ts +2 -0
  128. package/dist/cli/package.test.d.ts.map +1 -0
  129. package/dist/cli/package.test.js +236 -0
  130. package/dist/cli/package.test.js.map +1 -0
  131. package/dist/cli/pipeline.d.ts +6 -0
  132. package/dist/cli/pipeline.d.ts.map +1 -1
  133. package/dist/cli/pipeline.js +15 -2
  134. package/dist/cli/pipeline.js.map +1 -1
  135. package/dist/cli/run/schema.d.ts.map +1 -1
  136. package/dist/cli/run/schema.js +32 -0
  137. package/dist/cli/run/schema.js.map +1 -1
  138. package/dist/cli/run/schema.test.js +51 -0
  139. package/dist/cli/run/schema.test.js.map +1 -1
  140. package/dist/cli/skills.d.ts +3 -0
  141. package/dist/cli/skills.d.ts.map +1 -0
  142. package/dist/cli/skills.js +107 -0
  143. package/dist/cli/skills.js.map +1 -0
  144. package/dist/cli/types.d.ts +4 -1
  145. package/dist/cli/types.d.ts.map +1 -1
  146. package/dist/dashboard/scripts/etl.d.ts.map +1 -1
  147. package/dist/dashboard/scripts/etl.js +44 -11
  148. package/dist/dashboard/scripts/etl.js.map +1 -1
  149. package/package.json +2 -1
  150. package/src/cli/agents.ts +20 -5
  151. package/src/cli/artifacts-cli.ts +41 -0
  152. package/src/cli/baselines.ts +12 -0
  153. package/src/cli/convoy/artifacts.test.ts +201 -0
  154. package/src/cli/convoy/artifacts.ts +186 -0
  155. package/src/cli/convoy/compaction.test.ts +245 -0
  156. package/src/cli/convoy/compaction.ts +164 -0
  157. package/src/cli/convoy/contracts.test.ts +279 -0
  158. package/src/cli/convoy/contracts.ts +280 -0
  159. package/src/cli/convoy/dag-analysis.test.ts +349 -0
  160. package/src/cli/convoy/dag-analysis.ts +371 -0
  161. package/src/cli/convoy/effort-scaling.test.ts +140 -0
  162. package/src/cli/convoy/effort-scaling.ts +90 -0
  163. package/src/cli/convoy/engine.test.ts +175 -18
  164. package/src/cli/convoy/engine.ts +301 -7
  165. package/src/cli/convoy/event-schemas.ts +55 -0
  166. package/src/cli/convoy/isolation.test.ts +137 -0
  167. package/src/cli/convoy/isolation.ts +165 -0
  168. package/src/cli/convoy/review-stages.test.ts +235 -0
  169. package/src/cli/convoy/review-stages.ts +166 -0
  170. package/src/cli/convoy/skill-refinement.test.ts +277 -0
  171. package/src/cli/convoy/skill-refinement.ts +306 -0
  172. package/src/cli/convoy/spec-builder.test.ts +61 -0
  173. package/src/cli/convoy/spec-builder.ts +9 -0
  174. package/src/cli/convoy/store.test.ts +15 -15
  175. package/src/cli/convoy/store.ts +26 -4
  176. package/src/cli/convoy/tdd-gate.test.ts +281 -0
  177. package/src/cli/convoy/tdd-gate.ts +154 -0
  178. package/src/cli/convoy/types.ts +51 -0
  179. package/src/cli/insights.ts +99 -0
  180. package/src/cli/lesson.ts +8 -0
  181. package/src/cli/log.ts +8 -0
  182. package/src/cli/package-config.ts +48 -0
  183. package/src/cli/package.test.ts +276 -0
  184. package/src/cli/package.ts +329 -0
  185. package/src/cli/pipeline.ts +21 -2
  186. package/src/cli/run/schema.test.ts +58 -0
  187. package/src/cli/run/schema.ts +33 -0
  188. package/src/cli/skills.ts +121 -0
  189. package/src/cli/types.ts +4 -1
  190. package/src/dashboard/dist/_astro/index.D6quLrA6.css +1 -0
  191. package/src/dashboard/dist/data/convoy-list.json +21 -7
  192. package/src/dashboard/dist/data/convoys/demo-api-v2.json +3 -3
  193. package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +5 -5
  194. package/src/dashboard/dist/data/convoys/demo-convoy-1.json +2 -2
  195. package/src/dashboard/dist/data/convoys/demo-convoy-2.json +1 -1
  196. package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +7 -7
  197. package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +3 -3
  198. package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +2 -2
  199. package/src/dashboard/dist/data/convoys/demo-docs-update.json +2 -2
  200. package/src/dashboard/dist/data/convoys/demo-perf-opt.json +4 -4
  201. package/src/dashboard/dist/index.html +306 -33
  202. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  203. package/src/dashboard/public/data/convoy-list.json +21 -7
  204. package/src/dashboard/public/data/convoys/demo-api-v2.json +3 -3
  205. package/src/dashboard/public/data/convoys/demo-auth-revamp.json +5 -5
  206. package/src/dashboard/public/data/convoys/demo-convoy-1.json +2 -2
  207. package/src/dashboard/public/data/convoys/demo-convoy-2.json +1 -1
  208. package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +7 -7
  209. package/src/dashboard/public/data/convoys/demo-data-pipeline.json +3 -3
  210. package/src/dashboard/public/data/convoys/demo-deploy-ci.json +2 -2
  211. package/src/dashboard/public/data/convoys/demo-docs-update.json +2 -2
  212. package/src/dashboard/public/data/convoys/demo-perf-opt.json +4 -4
  213. package/src/dashboard/scripts/etl.test.ts +14 -0
  214. package/src/dashboard/scripts/etl.ts +48 -16
  215. package/src/dashboard/scripts/generate-demo-db.ts +18 -10
  216. package/src/dashboard/src/pages/index.astro +348 -45
  217. package/src/dashboard/src/styles/dashboard.css +56 -0
  218. package/src/orchestrator/prompts/assess-complexity.prompt.md +13 -0
  219. package/src/orchestrator/prompts/generate-convoy.prompt.md +19 -0
  220. package/src/dashboard/dist/_astro/index.BRDFmNzR.css +0 -1
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import {
6
+ getArtifactDir,
7
+ writeArtifact,
8
+ listArtifacts,
9
+ readArtifact,
10
+ extractArtifactRefs,
11
+ pruneArtifacts,
12
+ } from './artifacts.js'
13
+
14
+ describe('artifacts', () => {
15
+ let tmpDir: string
16
+
17
+ beforeEach(() => {
18
+ tmpDir = mkdtempSync(join(tmpdir(), 'artifacts-test-'))
19
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
20
+ })
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks()
24
+ rmSync(tmpDir, { recursive: true, force: true })
25
+ })
26
+
27
+ describe('writeArtifact', () => {
28
+ it('creates file in correct directory structure', () => {
29
+ const artifact = writeArtifact('convoy-1', 'task-1', 'report.md', '# Report\nContent here', 'report')
30
+ expect(artifact.path).toContain('convoy-1')
31
+ expect(artifact.path).toContain('task-1')
32
+ expect(artifact.path).toContain('report.md')
33
+ expect(existsSync(artifact.path)).toBe(true)
34
+ })
35
+
36
+ it('creates .meta.json sidecar', () => {
37
+ const artifact = writeArtifact('convoy-1', 'task-1', 'report.md', '# Report\nContent here', 'report')
38
+ const metaPath = artifact.path + '.meta.json'
39
+ expect(existsSync(metaPath)).toBe(true)
40
+ })
41
+
42
+ it('returns Artifact with correct metadata', () => {
43
+ const content = '# Report\nLine 2'
44
+ const artifact = writeArtifact('convoy-1', 'task-1', 'report.md', content, 'report')
45
+ expect(artifact.task_id).toBe('task-1')
46
+ expect(artifact.convoy_id).toBe('convoy-1')
47
+ expect(artifact.filename).toBe('report.md')
48
+ expect(artifact.type).toBe('report')
49
+ expect(artifact.size_bytes).toBe(Buffer.byteLength(content, 'utf8'))
50
+ expect(artifact.summary).toBe('# Report')
51
+ })
52
+
53
+ it('summary is truncated to 120 chars when first line is long', () => {
54
+ const longFirstLine = 'A'.repeat(200)
55
+ const content = longFirstLine + '\nSecond line'
56
+ const artifact = writeArtifact('convoy-1', 'task-1', 'long.md', content, 'report')
57
+ expect(artifact.summary.length).toBe(120)
58
+ })
59
+ })
60
+
61
+ describe('listArtifacts', () => {
62
+ it('returns refs for all files in task dir excluding .meta.json', () => {
63
+ writeArtifact('convoy-1', 'task-1', 'report.md', '# Report', 'report')
64
+ writeArtifact('convoy-1', 'task-1', 'migration.sql', 'ALTER TABLE users', 'code')
65
+ const refs = listArtifacts('convoy-1', 'task-1')
66
+ expect(refs).toHaveLength(2)
67
+ const filenames = refs.map(r => r.filename)
68
+ expect(filenames).toContain('report.md')
69
+ expect(filenames).toContain('migration.sql')
70
+ })
71
+
72
+ it('returns empty array when dir does not exist', () => {
73
+ const refs = listArtifacts('nonexistent-convoy', 'nonexistent-task')
74
+ expect(refs).toHaveLength(0)
75
+ })
76
+
77
+ it('populates summary from .meta.json sidecar', () => {
78
+ writeArtifact('convoy-1', 'task-1', 'diff.patch', 'diff --git a/file.ts\nchanges here', 'diff')
79
+ const refs = listArtifacts('convoy-1', 'task-1')
80
+ expect(refs[0].summary).toBe('diff --git a/file.ts')
81
+ })
82
+ })
83
+
84
+ describe('readArtifact', () => {
85
+ it('returns file content', () => {
86
+ const content = '# Report\nDetailed content here'
87
+ const artifact = writeArtifact('convoy-1', 'task-1', 'report.md', content, 'report')
88
+ const ref = { task_id: 'task-1', filename: 'report.md', summary: '# Report', path: artifact.path }
89
+ expect(readArtifact(ref)).toBe(content)
90
+ })
91
+ })
92
+
93
+ describe('extractArtifactRefs', () => {
94
+ it('parses [ARTIFACT: filename] summary pattern correctly', () => {
95
+ writeArtifact('convoy-1', 'task-1', 'report.md', '# Report', 'report')
96
+ const output = 'I completed the work.\n[ARTIFACT: report.md] Full analysis report\nSee the artifact for details.'
97
+ const refs = extractArtifactRefs('task-1', 'convoy-1', output)
98
+ expect(refs).toHaveLength(1)
99
+ expect(refs[0].filename).toBe('report.md')
100
+ expect(refs[0].summary).toBe('Full analysis report')
101
+ })
102
+
103
+ it('parses multiple artifacts in one output', () => {
104
+ writeArtifact('convoy-1', 'task-1', 'report.md', '# Report', 'report')
105
+ writeArtifact('convoy-1', 'task-1', 'migration.sql', 'ALTER TABLE', 'code')
106
+ const output = '[ARTIFACT: report.md] Analysis report\n[ARTIFACT: migration.sql] Database migration script'
107
+ const refs = extractArtifactRefs('task-1', 'convoy-1', output)
108
+ expect(refs).toHaveLength(2)
109
+ })
110
+
111
+ it('logs warning to stderr for referenced but missing artifacts', () => {
112
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
113
+ const output = '[ARTIFACT: missing.md] A report that does not exist on disk'
114
+ const refs = extractArtifactRefs('task-1', 'convoy-1', output)
115
+ expect(refs).toHaveLength(0)
116
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('[artifacts] Warning'))
117
+ })
118
+
119
+ it('returns empty array when no artifact patterns found', () => {
120
+ const refs = extractArtifactRefs('task-1', 'convoy-1', 'No artifacts here, just a normal response.')
121
+ expect(refs).toHaveLength(0)
122
+ })
123
+ })
124
+
125
+ describe('pruneArtifacts', () => {
126
+ it('removes convoy dirs beyond keepCount', () => {
127
+ writeArtifact('convoy-a', 'task-1', 'file.md', 'content', 'report')
128
+ writeArtifact('convoy-b', 'task-1', 'file.md', 'content', 'report')
129
+ writeArtifact('convoy-c', 'task-1', 'file.md', 'content', 'report')
130
+ const result = pruneArtifacts(1)
131
+ expect(result.removed).toBe(2)
132
+ expect(result.freed_bytes).toBeGreaterThan(0)
133
+ })
134
+
135
+ it('returns zero removed when within keepCount', () => {
136
+ writeArtifact('convoy-1', 'task-1', 'file.md', 'content', 'report')
137
+ writeArtifact('convoy-2', 'task-1', 'file.md', 'content', 'report')
138
+ const result = pruneArtifacts(5)
139
+ expect(result.removed).toBe(0)
140
+ expect(result.freed_bytes).toBe(0)
141
+ })
142
+
143
+ it('returns zero when no artifacts directory exists', () => {
144
+ const result = pruneArtifacts(10)
145
+ expect(result.removed).toBe(0)
146
+ expect(result.freed_bytes).toBe(0)
147
+ })
148
+
149
+ it('returns correct freed bytes count', () => {
150
+ const content = 'A'.repeat(1000)
151
+ writeArtifact('convoy-old1', 'task-1', 'large.md', content, 'report')
152
+ writeArtifact('convoy-old2', 'task-1', 'large.md', content, 'report')
153
+ writeArtifact('convoy-new', 'task-1', 'large.md', content, 'report')
154
+ const result = pruneArtifacts(1)
155
+ expect(result.removed).toBe(2)
156
+ expect(result.freed_bytes).toBeGreaterThan(0)
157
+ })
158
+ })
159
+
160
+ describe('round-trip', () => {
161
+ it('write → list → read produces identical content', () => {
162
+ const content = '# Report\nLine 2\nLine 3'
163
+ writeArtifact('convoy-1', 'task-1', 'report.md', content, 'report')
164
+ const refs = listArtifacts('convoy-1', 'task-1')
165
+ expect(refs).toHaveLength(1)
166
+ const retrieved = readArtifact(refs[0])
167
+ expect(retrieved).toBe(content)
168
+ })
169
+ })
170
+
171
+ describe('path sanitization', () => {
172
+ it('rejects filenames with ..', () => {
173
+ expect(() => writeArtifact('convoy-1', 'task-1', '../evil.ts', 'content', 'code')).toThrow()
174
+ })
175
+
176
+ it('rejects filenames with /', () => {
177
+ expect(() => writeArtifact('convoy-1', 'task-1', 'sub/evil.ts', 'content', 'code')).toThrow()
178
+ })
179
+
180
+ it('rejects convoy IDs with path traversal', () => {
181
+ expect(() => writeArtifact('../evil', 'task-1', 'file.md', 'content', 'report')).toThrow()
182
+ })
183
+
184
+ it('rejects task IDs with backslash', () => {
185
+ expect(() => writeArtifact('convoy-1', 'task\\evil', 'file.md', 'content', 'report')).toThrow()
186
+ })
187
+ })
188
+
189
+ describe('getArtifactDir', () => {
190
+ it('returns path ending with trailing slash', () => {
191
+ const dir = getArtifactDir('convoy-1', 'task-1')
192
+ expect(dir.endsWith('/')).toBe(true)
193
+ })
194
+
195
+ it('includes convoy-id and task-id in path', () => {
196
+ const dir = getArtifactDir('convoy-abc', 'task-xyz')
197
+ expect(dir).toContain('convoy-abc')
198
+ expect(dir).toContain('task-xyz')
199
+ })
200
+ })
201
+ })
@@ -0,0 +1,186 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import { basename, join } from 'node:path'
11
+
12
+ // ── Types ─────────────────────────────────────────────────────────────────────
13
+
14
+ export interface Artifact {
15
+ task_id: string
16
+ convoy_id: string
17
+ filename: string
18
+ type: 'report' | 'code' | 'data' | 'diff' | 'log' | 'other'
19
+ size_bytes: number
20
+ summary: string
21
+ path: string
22
+ }
23
+
24
+ export interface ArtifactRef {
25
+ task_id: string
26
+ filename: string
27
+ summary: string
28
+ path: string
29
+ }
30
+
31
+ interface ArtifactMeta {
32
+ type: Artifact['type']
33
+ summary: string
34
+ size_bytes: number
35
+ created_at: string
36
+ }
37
+
38
+ // ── Sanitization ──────────────────────────────────────────────────────────────
39
+
40
+ function sanitizeSegment(input: string): string {
41
+ if (input.includes('..') || input.includes('/') || input.includes('\\')) {
42
+ throw new Error(`Invalid path segment "${input}": path traversal characters not allowed`)
43
+ }
44
+ return input.replace(/[^a-zA-Z0-9\-_.]/g, '')
45
+ }
46
+
47
+ // ── Core ──────────────────────────────────────────────────────────────────────
48
+
49
+ export function getArtifactDir(convoyId: string, taskId: string): string {
50
+ const safeConvoyId = sanitizeSegment(convoyId)
51
+ const safeTaskId = sanitizeSegment(taskId)
52
+ return join(process.cwd(), '.opencastle', 'artifacts', safeConvoyId, safeTaskId) + '/'
53
+ }
54
+
55
+ export function writeArtifact(
56
+ convoyId: string,
57
+ taskId: string,
58
+ filename: string,
59
+ content: string,
60
+ type: Artifact['type'],
61
+ ): Artifact {
62
+ const safeFilename = sanitizeSegment(filename)
63
+ const dir = getArtifactDir(convoyId, taskId)
64
+ mkdirSync(dir, { recursive: true })
65
+
66
+ const filePath = join(dir, safeFilename)
67
+ writeFileSync(filePath, content, 'utf8')
68
+
69
+ const size_bytes = Buffer.byteLength(content, 'utf8')
70
+ const firstLine = content.split('\n')[0] ?? ''
71
+ const summary = firstLine.slice(0, 120)
72
+
73
+ const meta: ArtifactMeta = { type, summary, size_bytes, created_at: new Date().toISOString() }
74
+ writeFileSync(join(dir, safeFilename + '.meta.json'), JSON.stringify(meta, null, 2), 'utf8')
75
+
76
+ return { task_id: taskId, convoy_id: convoyId, filename: safeFilename, type, size_bytes, summary, path: filePath }
77
+ }
78
+
79
+ export function listArtifacts(convoyId: string, taskId: string): ArtifactRef[] {
80
+ const dir = getArtifactDir(convoyId, taskId)
81
+ if (!existsSync(dir)) return []
82
+
83
+ const refs: ArtifactRef[] = []
84
+ for (const entry of readdirSync(dir)) {
85
+ if (entry.endsWith('.meta.json')) continue
86
+
87
+ const filePath = join(dir, entry)
88
+ const metaPath = join(dir, entry + '.meta.json')
89
+
90
+ let summary = ''
91
+ if (existsSync(metaPath)) {
92
+ try {
93
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as ArtifactMeta
94
+ summary = meta.summary
95
+ } catch { /* fallback */ }
96
+ }
97
+ if (!summary) {
98
+ try {
99
+ const firstLine = readFileSync(filePath, 'utf8').split('\n')[0] ?? ''
100
+ summary = firstLine.slice(0, 120)
101
+ } catch { /* non-critical */ }
102
+ }
103
+
104
+ refs.push({ task_id: taskId, filename: entry, summary, path: filePath })
105
+ }
106
+
107
+ return refs
108
+ }
109
+
110
+ export function readArtifact(ref: ArtifactRef): string {
111
+ return readFileSync(ref.path, 'utf8')
112
+ }
113
+
114
+ export function extractArtifactRefs(taskId: string, convoyId: string, output: string): ArtifactRef[] {
115
+ const pattern = /\[ARTIFACT:\s*([^\]]+)\]\s*(.+)/g
116
+ const refs: ArtifactRef[] = []
117
+ let match: RegExpExecArray | null
118
+
119
+ while ((match = pattern.exec(output)) !== null) {
120
+ // Use basename to prevent path traversal from untrusted agent output
121
+ const filename = basename(match[1].trim())
122
+ const summary = match[2].trim()
123
+
124
+ if (!filename || filename === '..') {
125
+ process.stderr.write('[artifacts] Warning: invalid artifact filename from agent output\n')
126
+ continue
127
+ }
128
+
129
+ const dir = getArtifactDir(convoyId, taskId)
130
+ const filePath = join(dir, filename)
131
+
132
+ if (!existsSync(filePath)) {
133
+ process.stderr.write(`[artifacts] Warning: referenced artifact not found: ${filePath}\n`)
134
+ continue
135
+ }
136
+
137
+ refs.push({ task_id: taskId, filename, summary, path: filePath })
138
+ }
139
+
140
+ return refs
141
+ }
142
+
143
+ export function pruneArtifacts(keepCount: number): { removed: number; freed_bytes: number } {
144
+ const artifactsRoot = join(process.cwd(), '.opencastle', 'artifacts')
145
+ if (!existsSync(artifactsRoot)) return { removed: 0, freed_bytes: 0 }
146
+
147
+ const convoyDirs = readdirSync(artifactsRoot)
148
+ .map(name => {
149
+ const dirPath = join(artifactsRoot, name)
150
+ try {
151
+ return { name, path: dirPath, mtime: statSync(dirPath).mtime.getTime() }
152
+ } catch {
153
+ return { name, path: dirPath, mtime: 0 }
154
+ }
155
+ })
156
+ .sort((a, b) => b.mtime - a.mtime)
157
+
158
+ const toRemove = convoyDirs.slice(keepCount)
159
+ let removed = 0
160
+ let freed_bytes = 0
161
+
162
+ for (const dir of toRemove) {
163
+ try {
164
+ freed_bytes += calcDirSize(dir.path)
165
+ rmSync(dir.path, { recursive: true, force: true })
166
+ removed++
167
+ } catch { /* non-critical */ }
168
+ }
169
+
170
+ return { removed, freed_bytes }
171
+ }
172
+
173
+ function calcDirSize(dirPath: string): number {
174
+ let total = 0
175
+ try {
176
+ for (const entry of readdirSync(dirPath)) {
177
+ const p = join(dirPath, entry)
178
+ try {
179
+ const s = statSync(p)
180
+ if (s.isDirectory()) total += calcDirSize(p)
181
+ else total += s.size
182
+ } catch { /* skip */ }
183
+ }
184
+ } catch { /* skip */ }
185
+ return total
186
+ }
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ shouldCompact,
7
+ generateCompactionPrompt,
8
+ parseCompactionSummary,
9
+ saveCompaction,
10
+ loadCompaction,
11
+ buildContinuationPrompt,
12
+ canCompact,
13
+ getMaxCompactions,
14
+ getCompactionDir,
15
+ MODEL_CONTEXT_WINDOWS,
16
+ type CompactionSummary,
17
+ } from './compaction.js'
18
+ import type { CompactionConfig } from './types.js'
19
+
20
+ const baseConfig: CompactionConfig = {
21
+ enabled: true,
22
+ token_threshold_pct: 70,
23
+ summary_max_tokens: 4096,
24
+ }
25
+
26
+ describe('shouldCompact', () => {
27
+ it('returns false when config.enabled is false', () => {
28
+ expect(shouldCompact(150_000, 'claude-sonnet-4-6', { ...baseConfig, enabled: false })).toBe(false)
29
+ })
30
+
31
+ it('returns false below threshold', () => {
32
+ // 69% of 200_000 = 138_000
33
+ expect(shouldCompact(138_000, 'claude-sonnet-4-6', baseConfig)).toBe(false)
34
+ })
35
+
36
+ it('returns true at exactly the threshold', () => {
37
+ // 70% of 200_000 = 140_000
38
+ expect(shouldCompact(140_000, 'claude-sonnet-4-6', baseConfig)).toBe(true)
39
+ })
40
+
41
+ it('returns true above threshold', () => {
42
+ expect(shouldCompact(180_000, 'claude-sonnet-4-6', baseConfig)).toBe(true)
43
+ })
44
+
45
+ it('uses DEFAULT_CONTEXT_WINDOW (128_000) for unknown model', () => {
46
+ // 70% of 128_000 = 89_600
47
+ expect(shouldCompact(90_000, 'unknown-model-xyz', baseConfig)).toBe(true)
48
+ expect(shouldCompact(88_000, 'unknown-model-xyz', baseConfig)).toBe(false)
49
+ })
50
+
51
+ it('uses correct window for gpt-5-mini (128_000)', () => {
52
+ expect(MODEL_CONTEXT_WINDOWS['gpt-5-mini']).toBe(128_000)
53
+ expect(shouldCompact(90_000, 'gpt-5-mini', baseConfig)).toBe(true)
54
+ })
55
+ })
56
+
57
+ describe('generateCompactionPrompt', () => {
58
+ it('returns a string containing COMPACTION_SUMMARY marker', () => {
59
+ const prompt = generateCompactionPrompt('task-42')
60
+ expect(prompt).toContain('COMPACTION_SUMMARY')
61
+ expect(prompt).toContain('Context Compaction Required')
62
+ expect(typeof prompt).toBe('string')
63
+ })
64
+
65
+ it('includes JSON structure placeholders', () => {
66
+ const prompt = generateCompactionPrompt('task-1')
67
+ expect(prompt).toContain('"phase"')
68
+ expect(prompt).toContain('"completed_steps"')
69
+ expect(prompt).toContain('"pending_steps"')
70
+ })
71
+ })
72
+
73
+ describe('parseCompactionSummary', () => {
74
+ it('parses valid summary from agent output', () => {
75
+ const output = [
76
+ 'Some output text',
77
+ '',
78
+ '<!-- COMPACTION_SUMMARY',
79
+ '{',
80
+ ' "phase": "implementation",',
81
+ ' "completed_steps": ["created types", "wrote tests"],',
82
+ ' "pending_steps": ["integrate with engine"],',
83
+ ' "key_decisions": ["used valibot for validation"],',
84
+ ' "files_modified": ["src/foo.ts"],',
85
+ ' "artifact_refs": [".opencastle/artifacts/convoy-1/task-1/report.md"]',
86
+ '}',
87
+ '-->',
88
+ '',
89
+ 'More text',
90
+ ].join('\n')
91
+ const result = parseCompactionSummary(output, 'task-1', 'convoy-1')
92
+ expect(result).not.toBeNull()
93
+ expect(result!.task_id).toBe('task-1')
94
+ expect(result!.convoy_id).toBe('convoy-1')
95
+ expect(result!.phase).toBe('implementation')
96
+ expect(result!.completed_steps).toEqual(['created types', 'wrote tests'])
97
+ expect(result!.pending_steps).toEqual(['integrate with engine'])
98
+ expect(result!.key_decisions).toEqual(['used valibot for validation'])
99
+ expect(result!.files_modified).toEqual(['src/foo.ts'])
100
+ expect(result!.artifact_refs).toEqual(['.opencastle/artifacts/convoy-1/task-1/report.md'])
101
+ expect(typeof result!.timestamp).toBe('string')
102
+ })
103
+
104
+ it('returns null when no COMPACTION_SUMMARY marker found', () => {
105
+ const result = parseCompactionSummary('just some output without the marker', 'task-1', 'convoy-1')
106
+ expect(result).toBeNull()
107
+ })
108
+
109
+ it('returns null for invalid JSON inside the marker', () => {
110
+ const output = '<!-- COMPACTION_SUMMARY\nnot valid json\n-->'
111
+ const result = parseCompactionSummary(output, 'task-1', 'convoy-1')
112
+ expect(result).toBeNull()
113
+ })
114
+
115
+ it('uses empty arrays for missing or non-array fields', () => {
116
+ const output = '<!-- COMPACTION_SUMMARY\n{"phase": "testing"}\n-->'
117
+ const result = parseCompactionSummary(output, 'task-1', 'convoy-1')
118
+ expect(result).not.toBeNull()
119
+ expect(result!.completed_steps).toEqual([])
120
+ expect(result!.pending_steps).toEqual([])
121
+ expect(result!.files_modified).toEqual([])
122
+ })
123
+ })
124
+
125
+ describe('saveCompaction / loadCompaction', () => {
126
+ let tmpDir: string
127
+
128
+ afterEach(() => {
129
+ if (tmpDir && existsSync(tmpDir)) {
130
+ rmSync(tmpDir, { recursive: true, force: true })
131
+ }
132
+ })
133
+
134
+ it('saves and restores a compaction summary', () => {
135
+ tmpDir = mkdtempSync(join(tmpdir(), 'compaction-test-'))
136
+ const summary: CompactionSummary = {
137
+ task_id: 'task-1',
138
+ convoy_id: 'convoy-abc',
139
+ phase: 'testing',
140
+ completed_steps: ['step A'],
141
+ pending_steps: ['step B'],
142
+ key_decisions: ['chose approach X'],
143
+ files_modified: ['src/foo.ts'],
144
+ artifact_refs: [],
145
+ timestamp: new Date().toISOString(),
146
+ }
147
+
148
+ const origCwd = process.cwd()
149
+ process.chdir(tmpDir)
150
+ try {
151
+ const savedPath = saveCompaction('convoy-abc', 'task-1', summary, 1)
152
+ expect(existsSync(savedPath)).toBe(true)
153
+ const loaded = loadCompaction(savedPath)
154
+ expect(loaded).not.toBeNull()
155
+ expect(loaded!.task_id).toBe('task-1')
156
+ expect(loaded!.phase).toBe('testing')
157
+ expect(loaded!.completed_steps).toEqual(['step A'])
158
+ } finally {
159
+ process.chdir(origCwd)
160
+ }
161
+ })
162
+
163
+ it('returns null for a non-existent path', () => {
164
+ const result = loadCompaction('/tmp/definitely-does-not-exist-abc123.json')
165
+ expect(result).toBeNull()
166
+ })
167
+ })
168
+
169
+ describe('buildContinuationPrompt', () => {
170
+ let tmpDir: string
171
+
172
+ afterEach(() => {
173
+ if (tmpDir && existsSync(tmpDir)) {
174
+ rmSync(tmpDir, { recursive: true, force: true })
175
+ }
176
+ })
177
+
178
+ it('includes original prompt, summary, and isolation preamble', () => {
179
+ tmpDir = mkdtempSync(join(tmpdir(), 'compaction-test-'))
180
+ const origCwd = process.cwd()
181
+ process.chdir(tmpDir)
182
+ try {
183
+ const summary: CompactionSummary = {
184
+ task_id: 'task-1',
185
+ convoy_id: 'convoy-abc',
186
+ phase: 'integration',
187
+ completed_steps: ['wrote types'],
188
+ pending_steps: ['update engine'],
189
+ key_decisions: ['decided to reuse store'],
190
+ files_modified: ['src/types.ts'],
191
+ artifact_refs: [],
192
+ timestamp: new Date().toISOString(),
193
+ }
194
+ const savedPath = saveCompaction('convoy-abc', 'task-1', summary, 1)
195
+ const result = buildContinuationPrompt('Do the remaining work', savedPath, '## Isolation preamble\n')
196
+ expect(result).toContain('## Isolation preamble')
197
+ expect(result).toContain('Do the remaining work')
198
+ expect(result).toContain('Continuation from Compacted Context')
199
+ expect(result).toContain('wrote types')
200
+ expect(result).toContain('update engine')
201
+ } finally {
202
+ process.chdir(origCwd)
203
+ }
204
+ })
205
+
206
+ it('handles missing summary file gracefully', () => {
207
+ const result = buildContinuationPrompt(
208
+ 'Do the work',
209
+ '/tmp/missing-summary-def456.json',
210
+ '## Preamble\n',
211
+ )
212
+ expect(result).toContain('Do the work')
213
+ expect(result).toContain('## Preamble')
214
+ expect(result).not.toContain('Continuation from Compacted Context')
215
+ })
216
+ })
217
+
218
+ describe('canCompact', () => {
219
+ it('returns true below max (3)', () => {
220
+ expect(canCompact(0)).toBe(true)
221
+ expect(canCompact(1)).toBe(true)
222
+ expect(canCompact(2)).toBe(true)
223
+ })
224
+
225
+ it('returns false at max (3)', () => {
226
+ expect(canCompact(3)).toBe(false)
227
+ expect(canCompact(4)).toBe(false)
228
+ })
229
+ })
230
+
231
+ describe('getMaxCompactions', () => {
232
+ it('returns 3', () => {
233
+ expect(getMaxCompactions()).toBe(3)
234
+ })
235
+ })
236
+
237
+ describe('getCompactionDir', () => {
238
+ it('returns path inside .opencastle/artifacts/{convoyId}/{taskId}', () => {
239
+ const result = getCompactionDir('convoy-abc', 'task-1')
240
+ expect(result).toContain('.opencastle')
241
+ expect(result).toContain('artifacts')
242
+ expect(result).toContain('convoy-abc')
243
+ expect(result).toContain('task-1')
244
+ })
245
+ })