opencastle 0.31.6 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +93 -21
  2. package/README.md +9 -3
  3. package/bin/cli.mjs +15 -0
  4. package/dist/cli/agents.d.ts.map +1 -1
  5. package/dist/cli/agents.js +19 -5
  6. package/dist/cli/agents.js.map +1 -1
  7. package/dist/cli/artifacts-cli.d.ts +3 -0
  8. package/dist/cli/artifacts-cli.d.ts.map +1 -0
  9. package/dist/cli/artifacts-cli.js +36 -0
  10. package/dist/cli/artifacts-cli.js.map +1 -0
  11. package/dist/cli/baselines.d.ts.map +1 -1
  12. package/dist/cli/baselines.js +11 -0
  13. package/dist/cli/baselines.js.map +1 -1
  14. package/dist/cli/convoy/artifacts.d.ts +25 -0
  15. package/dist/cli/convoy/artifacts.d.ts.map +1 -0
  16. package/dist/cli/convoy/artifacts.js +129 -0
  17. package/dist/cli/convoy/artifacts.js.map +1 -0
  18. package/dist/cli/convoy/artifacts.test.d.ts +2 -0
  19. package/dist/cli/convoy/artifacts.test.d.ts.map +1 -0
  20. package/dist/cli/convoy/artifacts.test.js +169 -0
  21. package/dist/cli/convoy/artifacts.test.js.map +1 -0
  22. package/dist/cli/convoy/compaction.d.ts +23 -0
  23. package/dist/cli/convoy/compaction.d.ts.map +1 -0
  24. package/dist/cli/convoy/compaction.js +117 -0
  25. package/dist/cli/convoy/compaction.js.map +1 -0
  26. package/dist/cli/convoy/compaction.test.d.ts +2 -0
  27. package/dist/cli/convoy/compaction.test.d.ts.map +1 -0
  28. package/dist/cli/convoy/compaction.test.js +205 -0
  29. package/dist/cli/convoy/compaction.test.js.map +1 -0
  30. package/dist/cli/convoy/contracts.d.ts +22 -0
  31. package/dist/cli/convoy/contracts.d.ts.map +1 -0
  32. package/dist/cli/convoy/contracts.js +254 -0
  33. package/dist/cli/convoy/contracts.js.map +1 -0
  34. package/dist/cli/convoy/contracts.test.d.ts +2 -0
  35. package/dist/cli/convoy/contracts.test.d.ts.map +1 -0
  36. package/dist/cli/convoy/contracts.test.js +239 -0
  37. package/dist/cli/convoy/contracts.test.js.map +1 -0
  38. package/dist/cli/convoy/dag-analysis.d.ts +40 -0
  39. package/dist/cli/convoy/dag-analysis.d.ts.map +1 -0
  40. package/dist/cli/convoy/dag-analysis.js +282 -0
  41. package/dist/cli/convoy/dag-analysis.js.map +1 -0
  42. package/dist/cli/convoy/dag-analysis.test.d.ts +2 -0
  43. package/dist/cli/convoy/dag-analysis.test.d.ts.map +1 -0
  44. package/dist/cli/convoy/dag-analysis.test.js +289 -0
  45. package/dist/cli/convoy/dag-analysis.test.js.map +1 -0
  46. package/dist/cli/convoy/effort-scaling.d.ts +20 -0
  47. package/dist/cli/convoy/effort-scaling.d.ts.map +1 -0
  48. package/dist/cli/convoy/effort-scaling.js +82 -0
  49. package/dist/cli/convoy/effort-scaling.js.map +1 -0
  50. package/dist/cli/convoy/effort-scaling.test.d.ts +2 -0
  51. package/dist/cli/convoy/effort-scaling.test.d.ts.map +1 -0
  52. package/dist/cli/convoy/effort-scaling.test.js +120 -0
  53. package/dist/cli/convoy/effort-scaling.test.js.map +1 -0
  54. package/dist/cli/convoy/engine.d.ts.map +1 -1
  55. package/dist/cli/convoy/engine.js +298 -11
  56. package/dist/cli/convoy/engine.js.map +1 -1
  57. package/dist/cli/convoy/engine.test.js +155 -18
  58. package/dist/cli/convoy/engine.test.js.map +1 -1
  59. package/dist/cli/convoy/event-schemas.d.ts.map +1 -1
  60. package/dist/cli/convoy/event-schemas.js +55 -0
  61. package/dist/cli/convoy/event-schemas.js.map +1 -1
  62. package/dist/cli/convoy/isolation.d.ts +27 -0
  63. package/dist/cli/convoy/isolation.d.ts.map +1 -0
  64. package/dist/cli/convoy/isolation.js +120 -0
  65. package/dist/cli/convoy/isolation.js.map +1 -0
  66. package/dist/cli/convoy/isolation.test.d.ts +2 -0
  67. package/dist/cli/convoy/isolation.test.d.ts.map +1 -0
  68. package/dist/cli/convoy/isolation.test.js +105 -0
  69. package/dist/cli/convoy/isolation.test.js.map +1 -0
  70. package/dist/cli/convoy/review-stages.d.ts +9 -0
  71. package/dist/cli/convoy/review-stages.d.ts.map +1 -0
  72. package/dist/cli/convoy/review-stages.js +134 -0
  73. package/dist/cli/convoy/review-stages.js.map +1 -0
  74. package/dist/cli/convoy/review-stages.test.d.ts +2 -0
  75. package/dist/cli/convoy/review-stages.test.d.ts.map +1 -0
  76. package/dist/cli/convoy/review-stages.test.js +197 -0
  77. package/dist/cli/convoy/review-stages.test.js.map +1 -0
  78. package/dist/cli/convoy/skill-refinement.d.ts +39 -0
  79. package/dist/cli/convoy/skill-refinement.d.ts.map +1 -0
  80. package/dist/cli/convoy/skill-refinement.js +239 -0
  81. package/dist/cli/convoy/skill-refinement.js.map +1 -0
  82. package/dist/cli/convoy/skill-refinement.test.d.ts +2 -0
  83. package/dist/cli/convoy/skill-refinement.test.d.ts.map +1 -0
  84. package/dist/cli/convoy/skill-refinement.test.js +230 -0
  85. package/dist/cli/convoy/skill-refinement.test.js.map +1 -0
  86. package/dist/cli/convoy/spec-builder.d.ts +1 -0
  87. package/dist/cli/convoy/spec-builder.d.ts.map +1 -1
  88. package/dist/cli/convoy/spec-builder.js +11 -0
  89. package/dist/cli/convoy/spec-builder.js.map +1 -1
  90. package/dist/cli/convoy/spec-builder.test.js +54 -0
  91. package/dist/cli/convoy/spec-builder.test.js.map +1 -1
  92. package/dist/cli/convoy/store.d.ts +3 -2
  93. package/dist/cli/convoy/store.d.ts.map +1 -1
  94. package/dist/cli/convoy/store.js +20 -2
  95. package/dist/cli/convoy/store.js.map +1 -1
  96. package/dist/cli/convoy/store.test.js +15 -15
  97. package/dist/cli/convoy/store.test.js.map +1 -1
  98. package/dist/cli/convoy/tdd-gate.d.ts +15 -0
  99. package/dist/cli/convoy/tdd-gate.d.ts.map +1 -0
  100. package/dist/cli/convoy/tdd-gate.js +119 -0
  101. package/dist/cli/convoy/tdd-gate.js.map +1 -0
  102. package/dist/cli/convoy/tdd-gate.test.d.ts +2 -0
  103. package/dist/cli/convoy/tdd-gate.test.d.ts.map +1 -0
  104. package/dist/cli/convoy/tdd-gate.test.js +227 -0
  105. package/dist/cli/convoy/tdd-gate.test.js.map +1 -0
  106. package/dist/cli/convoy/types.d.ts +91 -0
  107. package/dist/cli/convoy/types.d.ts.map +1 -1
  108. package/dist/cli/convoy/types.js +8 -0
  109. package/dist/cli/convoy/types.js.map +1 -1
  110. package/dist/cli/dashboard.d.ts.map +1 -1
  111. package/dist/cli/dashboard.js +54 -0
  112. package/dist/cli/dashboard.js.map +1 -1
  113. package/dist/cli/insights.d.ts +3 -0
  114. package/dist/cli/insights.d.ts.map +1 -0
  115. package/dist/cli/insights.js +94 -0
  116. package/dist/cli/insights.js.map +1 -0
  117. package/dist/cli/lesson.d.ts.map +1 -1
  118. package/dist/cli/lesson.js +7 -0
  119. package/dist/cli/lesson.js.map +1 -1
  120. package/dist/cli/log.d.ts.map +1 -1
  121. package/dist/cli/log.js +7 -0
  122. package/dist/cli/log.js.map +1 -1
  123. package/dist/cli/package-config.d.ts +12 -0
  124. package/dist/cli/package-config.d.ts.map +1 -0
  125. package/dist/cli/package-config.js +37 -0
  126. package/dist/cli/package-config.js.map +1 -0
  127. package/dist/cli/package.d.ts +23 -0
  128. package/dist/cli/package.d.ts.map +1 -0
  129. package/dist/cli/package.js +285 -0
  130. package/dist/cli/package.js.map +1 -0
  131. package/dist/cli/package.test.d.ts +2 -0
  132. package/dist/cli/package.test.d.ts.map +1 -0
  133. package/dist/cli/package.test.js +236 -0
  134. package/dist/cli/package.test.js.map +1 -0
  135. package/dist/cli/pipeline.d.ts +6 -0
  136. package/dist/cli/pipeline.d.ts.map +1 -1
  137. package/dist/cli/pipeline.js +15 -2
  138. package/dist/cli/pipeline.js.map +1 -1
  139. package/dist/cli/run/schema.d.ts.map +1 -1
  140. package/dist/cli/run/schema.js +32 -0
  141. package/dist/cli/run/schema.js.map +1 -1
  142. package/dist/cli/run/schema.test.js +51 -0
  143. package/dist/cli/run/schema.test.js.map +1 -1
  144. package/dist/cli/run.d.ts.map +1 -1
  145. package/dist/cli/run.js +10 -1
  146. package/dist/cli/run.js.map +1 -1
  147. package/dist/cli/skills.d.ts +3 -0
  148. package/dist/cli/skills.d.ts.map +1 -0
  149. package/dist/cli/skills.js +107 -0
  150. package/dist/cli/skills.js.map +1 -0
  151. package/dist/cli/types.d.ts +4 -1
  152. package/dist/cli/types.d.ts.map +1 -1
  153. package/dist/cli/update.js +2 -2
  154. package/package.json +3 -2
  155. package/src/cli/agents.ts +20 -5
  156. package/src/cli/artifacts-cli.ts +41 -0
  157. package/src/cli/baselines.ts +12 -0
  158. package/src/cli/convoy/artifacts.test.ts +201 -0
  159. package/src/cli/convoy/artifacts.ts +186 -0
  160. package/src/cli/convoy/compaction.test.ts +245 -0
  161. package/src/cli/convoy/compaction.ts +164 -0
  162. package/src/cli/convoy/contracts.test.ts +279 -0
  163. package/src/cli/convoy/contracts.ts +280 -0
  164. package/src/cli/convoy/dag-analysis.test.ts +349 -0
  165. package/src/cli/convoy/dag-analysis.ts +371 -0
  166. package/src/cli/convoy/effort-scaling.test.ts +140 -0
  167. package/src/cli/convoy/effort-scaling.ts +90 -0
  168. package/src/cli/convoy/engine.test.ts +175 -18
  169. package/src/cli/convoy/engine.ts +315 -12
  170. package/src/cli/convoy/event-schemas.ts +55 -0
  171. package/src/cli/convoy/isolation.test.ts +137 -0
  172. package/src/cli/convoy/isolation.ts +165 -0
  173. package/src/cli/convoy/review-stages.test.ts +235 -0
  174. package/src/cli/convoy/review-stages.ts +166 -0
  175. package/src/cli/convoy/skill-refinement.test.ts +277 -0
  176. package/src/cli/convoy/skill-refinement.ts +306 -0
  177. package/src/cli/convoy/spec-builder.test.ts +61 -0
  178. package/src/cli/convoy/spec-builder.ts +9 -0
  179. package/src/cli/convoy/store.test.ts +15 -15
  180. package/src/cli/convoy/store.ts +26 -4
  181. package/src/cli/convoy/tdd-gate.test.ts +281 -0
  182. package/src/cli/convoy/tdd-gate.ts +154 -0
  183. package/src/cli/convoy/types.ts +51 -0
  184. package/src/cli/dashboard.ts +55 -0
  185. package/src/cli/insights.ts +99 -0
  186. package/src/cli/lesson.ts +8 -0
  187. package/src/cli/log.ts +8 -0
  188. package/src/cli/package-config.ts +48 -0
  189. package/src/cli/package.test.ts +276 -0
  190. package/src/cli/package.ts +329 -0
  191. package/src/cli/pipeline.ts +21 -2
  192. package/src/cli/run/schema.test.ts +58 -0
  193. package/src/cli/run/schema.ts +33 -0
  194. package/src/cli/run.ts +14 -1
  195. package/src/cli/skills.ts +121 -0
  196. package/src/cli/types.ts +4 -1
  197. package/src/cli/update.ts +2 -2
  198. package/src/dashboard/dist/_astro/{index.Je1YjU_y.css → index.BRDFmNzR.css} +1 -1
  199. package/src/dashboard/dist/index.html +163 -2
  200. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  201. package/src/dashboard/src/pages/index.astro +162 -1
  202. package/src/dashboard/src/styles/dashboard.css +85 -0
  203. package/src/orchestrator/agents/developer.agent.md +8 -0
  204. package/src/orchestrator/agents/ui-ux-expert.agent.md +7 -0
  205. package/src/orchestrator/prompts/assess-complexity.prompt.md +13 -0
  206. package/src/orchestrator/prompts/brainstorm.prompt.md +18 -0
  207. package/src/orchestrator/prompts/generate-convoy.prompt.md +61 -0
  208. package/src/orchestrator/skills/decomposition/SKILL.md +35 -0
  209. package/src/orchestrator/skills/frontend-design/SKILL.md +27 -1
  210. package/src/orchestrator/skills/project-consistency/SKILL.md +350 -0
@@ -22,7 +22,7 @@ import { createEventEmitter, ndjsonPathForConvoy, recoverNdjson, type ConvoyEven
22
22
  import { createWorktreeManager, type WorktreeManager } from './worktree.js'
23
23
  import { createMergeQueue, MergeConflictError, type MergeQueue } from './merge.js'
24
24
  import { createHealthMonitor, detectDrift } from './health.js'
25
- import type { TaskRecord, ConvoyStatus, ConvoyTaskStatus, GuardConfig, CircuitBreakerConfig, TaskStep, Hook, TaskOutput, TaskInput } from './types.js'
25
+ import type { TaskRecord, ConvoyStatus, ConvoyTaskStatus, GuardConfig, CircuitBreakerConfig, TaskStep, Hook, TaskOutput, TaskInput, TDDGateConfig } from './types.js'
26
26
  import { buildPhases, formatDuration } from '../run/executor.js'
27
27
  import { parseTimeout, parseYaml } from '../run/schema.js'
28
28
  import { getAdapter, detectAdapter } from '../run/adapters/index.js'
@@ -33,6 +33,13 @@ import { readLessons, captureLessons, consolidateLessons } from './lessons.js'
33
33
  import { updateExpertise, feedCircuitBreaker } from './expertise.js'
34
34
  import { buildKnowledgeGraph } from './knowledge.js'
35
35
  import { injectDiscoveredIssuesInstruction, checkDiscoveredIssues, consolidateIssues } from './issues.js'
36
+ import { validateOutput, buildContractInstruction, buildContractRetryPrompt } from './contracts.js'
37
+ import { runTwoStageReview } from './review-stages.js'
38
+ import { buildIsolationPreamble, resolveDependencyResults, detectPartitionViolations } from './isolation.js'
39
+ import { checkTDD, formatTDDFailure, DEFAULT_TDD_CONFIG } from './tdd-gate.js'
40
+ import { runSkillRefinementCheck } from './skill-refinement.js'
41
+ import { getArtifactDir, extractArtifactRefs } from './artifacts.js'
42
+ import { shouldCompact, parseCompactionSummary, saveCompaction, canCompact, getMaxCompactions, generateCompactionPrompt, buildContinuationPrompt } from './compaction.js'
36
43
 
37
44
  const execFile = promisify(execFileCb)
38
45
 
@@ -823,6 +830,7 @@ function pollInjectFile(
823
830
  dispute_id: null,
824
831
  drift_score: null,
825
832
  drift_retried: 0,
833
+ compaction_count: 0,
826
834
  outputs: null,
827
835
  inputs: null,
828
836
  discovered_issues: null,
@@ -1260,6 +1268,36 @@ async function runConvoy(
1260
1268
  const steps: TaskStep[] | undefined = specTask?.steps
1261
1269
  const taskHooks: Hook[] = specTask?.hooks ?? []
1262
1270
 
1271
+ // ── Context isolation preamble (Phase 41) ────────────────────────────
1272
+ try {
1273
+ const taskFiles = taskRecord.files ? JSON.parse(taskRecord.files) as string[] : []
1274
+ const depIds = taskRecord.depends_on ? JSON.parse(taskRecord.depends_on) as string[] : []
1275
+ const depResults = resolveDependencyResults(store, convoyId, depIds)
1276
+ const preamble = buildIsolationPreamble(
1277
+ { id: taskRecord.id, description: taskRecord.prompt.slice(0, 200), prompt: taskRecord.prompt, files: taskFiles, agent: taskRecord.agent },
1278
+ depResults,
1279
+ )
1280
+ task.prompt = preamble + '\n\n' + task.prompt
1281
+ } catch { /* non-critical — isolation preamble is best-effort */ }
1282
+
1283
+ // ── Artifact output instructions (Phase 43) ────────────────────────────
1284
+ try {
1285
+ const artifactDir = getArtifactDir(convoyId, taskRecord.id)
1286
+ const artifactInstructions = [
1287
+ '',
1288
+ '## Artifact Output (for large results)',
1289
+ 'If your output includes large content (>100 lines of code, full reports, data dumps),',
1290
+ 'write it to an artifact file instead of including it inline:',
1291
+ '',
1292
+ '1. Write the content to: ' + artifactDir + '{filename}',
1293
+ '2. In your response, reference it: `[ARTIFACT: {filename}] {1-line summary}`',
1294
+ '3. Keep your inline response focused on the summary and key decisions.',
1295
+ '',
1296
+ 'Small outputs (< 100 lines) can remain inline.',
1297
+ ].join('\n')
1298
+ task.prompt = task.prompt + '\n' + artifactInstructions
1299
+ } catch { /* non-critical */ }
1300
+
1263
1301
  // ── Intelligence: inject lessons (Phase 18.1) ─────────────────────────
1264
1302
  if (spec.defaults?.inject_lessons !== false) {
1265
1303
  try {
@@ -1292,6 +1330,12 @@ async function runConvoy(
1292
1330
  task.prompt = injectDiscoveredIssuesInstruction(task.prompt)
1293
1331
  }
1294
1332
 
1333
+ // ── Output contract injection ─────────────────────────────────────────
1334
+ const contractInstruction = buildContractInstruction(taskRecord.agent)
1335
+ if (contractInstruction) {
1336
+ task.prompt = task.prompt + '\n\n' + contractInstruction
1337
+ }
1338
+
1295
1339
  // ── pre_task hooks ────────────────────────────────────────────────────────
1296
1340
  if (taskHooks.length > 0) {
1297
1341
  const preResult = await runHooks(taskHooks, 'pre_task', {
@@ -1439,11 +1483,11 @@ async function runConvoy(
1439
1483
  try {
1440
1484
  // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
1441
1485
  // They are NOT user-supplied and are part of the trusted build configuration.
1442
- await execFile('sh', ['-c', command], { cwd: worktreePath ?? basePath })
1486
+ await execFile('sh', ['-c', command], { cwd: worktreePath ?? basePath, maxBuffer: 10 * 1024 * 1024 })
1443
1487
  } catch (err) {
1444
1488
  const execErr = err as Error & { code?: unknown; stderr?: string; stdout?: string }
1445
1489
  const code = typeof execErr.code === 'number' ? execErr.code : 1
1446
- const output = execErr.stderr || execErr.stdout || execErr.message || ''
1490
+ const output = [execErr.stderr, execErr.stdout].filter(Boolean).join('\n').trim() || execErr.message || ''
1447
1491
  gateFailure = { command, exitCode: code, output }
1448
1492
  break
1449
1493
  }
@@ -1683,6 +1727,100 @@ async function runConvoy(
1683
1727
  return
1684
1728
  }
1685
1729
  }
1730
+
1731
+ // ── Partition violation check (Phase 41) ────────────────────────────
1732
+ if (changedFiles.length > 0) {
1733
+ try {
1734
+ const taskFiles = taskRecord.files ? JSON.parse(taskRecord.files) as string[] : []
1735
+ if (taskFiles.length > 0) {
1736
+ const violation = detectPartitionViolations(taskRecord.id, taskFiles, changedFiles)
1737
+ if (violation) {
1738
+ events.emit('partition_violation', {
1739
+ task_id: taskRecord.id,
1740
+ allowed: violation.allowedFiles,
1741
+ actual: violation.actualFiles,
1742
+ violations: violation.violations,
1743
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1744
+ process.stdout.write(` ${c.yellow('⚠')} ${c.bold(`[${taskRecord.id}]`)} partition violation: ${violation.violations.join(', ')}\n`)
1745
+ }
1746
+ }
1747
+ } catch { /* non-critical */ }
1748
+ }
1749
+
1750
+ // ── TDD gate ──────────────────────────────────────────────────────────
1751
+ if (builtInGates.tdd_check && changedFiles.length > 0) {
1752
+ const tddConfig: TDDGateConfig = typeof builtInGates.tdd_check === 'object'
1753
+ ? { ...DEFAULT_TDD_CONFIG, ...builtInGates.tdd_check }
1754
+ : DEFAULT_TDD_CONFIG
1755
+ const specTaskForTDD = (spec.tasks ?? []).find(t => t.id === taskRecord.id)
1756
+ const tddResult = checkTDD(changedFiles, changedFiles, tddConfig, specTaskForTDD?.agent ?? taskRecord.agent)
1757
+
1758
+ if (tddResult.skipped) {
1759
+ events.emit('tdd_check_skipped', {
1760
+ task_id: taskRecord.id,
1761
+ reason: tddResult.skip_reason,
1762
+ agent: specTaskForTDD?.agent ?? taskRecord.agent,
1763
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1764
+ } else if (tddResult.passed) {
1765
+ events.emit('tdd_check_passed', {
1766
+ task_id: taskRecord.id,
1767
+ new_source_files: tddResult.new_source_files.length,
1768
+ existing_test_files: tddResult.existing_test_files.length,
1769
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1770
+ } else {
1771
+ const failureMsg = formatTDDFailure(tddResult)
1772
+ events.emit('tdd_check_failed', {
1773
+ task_id: taskRecord.id,
1774
+ missing_test_files: tddResult.missing_test_files,
1775
+ new_source_files: tddResult.new_source_files.length,
1776
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1777
+
1778
+ if (tddConfig.mode === 'block') {
1779
+ await removeWorktree()
1780
+ const freshRecord = store.getTask(taskRecord.id, convoyId)!
1781
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1782
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1783
+ retries: freshRecord.retries + 1,
1784
+ worker_id: null,
1785
+ worktree: null,
1786
+ started_at: null,
1787
+ finished_at: null,
1788
+ prompt: `TDD gate failed.\n${failureMsg}\n\nCreate the missing test files and try again.\n\n${taskRecord.prompt}`,
1789
+ })
1790
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1791
+ process.stdout.write(
1792
+ ` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} TDD gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`,
1793
+ )
1794
+ } else {
1795
+ store.withTransaction(() => {
1796
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1797
+ finished_at: finishedAt,
1798
+ output: `Built-in gate (tdd_check) failed:\n${failureMsg}`,
1799
+ exit_code: 1,
1800
+ })
1801
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1802
+ })
1803
+ completedCount++
1804
+ process.stdout.write(
1805
+ ` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} TDD gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`,
1806
+ )
1807
+ events.emit(
1808
+ 'task_failed',
1809
+ { reason: 'gate-failed', gate: 'tdd_check', worker_id: workerId },
1810
+ { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
1811
+ )
1812
+ handleExhaustion(freshRecord, 'tdd-check', failureMsg)
1813
+ }
1814
+ taskAdapterMap.delete(taskRecord.id)
1815
+ return
1816
+ } else {
1817
+ // warn mode — log but continue
1818
+ process.stdout.write(
1819
+ ` ${c.yellow('⚠')} ${c.bold(`[${taskRecord.id}]`)} TDD gate warning: ${tddResult.missing_test_files.length} source file(s) without tests\n`,
1820
+ )
1821
+ }
1822
+ }
1823
+ }
1686
1824
  }
1687
1825
 
1688
1826
  // ── Drift detection ──────────────────────────────────────────────────
@@ -1804,7 +1942,19 @@ async function runConvoy(
1804
1942
  await reviewSemaphore.acquire()
1805
1943
  let reviewResult: ReviewResult
1806
1944
  try {
1807
- if (reviewRunner) {
1945
+ if (reviewRunner && spec.defaults?.review_stages !== false) {
1946
+ // Two-stage review: spec compliance first, then code quality
1947
+ const twoStageResult = await runTwoStageReview(taskRecord, reviewRunner, reviewerModel)
1948
+ for (const stage of twoStageResult.stages) {
1949
+ events.emit('review_stage_completed', { stage: stage.stage, verdict: stage.verdict, tokens: stage.tokens_used, task_id: taskRecord.id, model: reviewerModel }, { convoy_id: convoyId, task_id: taskRecord.id })
1950
+ }
1951
+ reviewResult = {
1952
+ verdict: twoStageResult.overall_verdict,
1953
+ feedback: twoStageResult.stages.flatMap(s => s.issues).join('\n'),
1954
+ tokens: twoStageResult.total_tokens,
1955
+ model: reviewerModel,
1956
+ }
1957
+ } else if (reviewRunner) {
1808
1958
  reviewResult = await reviewRunner(taskRecord, 'fast', reviewerModel)
1809
1959
  } else {
1810
1960
  reviewResult = { verdict: 'pass', feedback: '', tokens: 0, model: reviewerModel }
@@ -1865,11 +2015,32 @@ async function runConvoy(
1865
2015
  try {
1866
2016
  const noopRunner = (_t: TaskRecord, _l: ReviewLevel, m: string) => Promise.resolve({ verdict: 'pass' as const, feedback: '', tokens: 0, model: m })
1867
2017
  const runner = reviewRunner ?? noopRunner
1868
- panelResults = await Promise.all([
1869
- runner(taskRecord, 'panel', reviewerModel),
1870
- runner(taskRecord, 'panel', reviewerModel),
1871
- runner(taskRecord, 'panel', reviewerModel),
1872
- ])
2018
+ const twoStageEnabled = spec.defaults?.review_stages !== false
2019
+ if (twoStageEnabled && reviewRunner) {
2020
+ // Each panel reviewer runs both stages; majority vote on overall_verdict
2021
+ const twoStageResults = await Promise.all([
2022
+ runTwoStageReview(taskRecord, runner, reviewerModel),
2023
+ runTwoStageReview(taskRecord, runner, reviewerModel),
2024
+ runTwoStageReview(taskRecord, runner, reviewerModel),
2025
+ ])
2026
+ for (const tsr of twoStageResults) {
2027
+ for (const stage of tsr.stages) {
2028
+ events.emit('review_stage_completed', { stage: stage.stage, verdict: stage.verdict, tokens: stage.tokens_used, task_id: taskRecord.id, model: reviewerModel }, { convoy_id: convoyId, task_id: taskRecord.id })
2029
+ }
2030
+ }
2031
+ panelResults = twoStageResults.map(tsr => ({
2032
+ verdict: tsr.overall_verdict,
2033
+ feedback: tsr.stages.flatMap(s => s.issues).join('\n'),
2034
+ tokens: tsr.total_tokens,
2035
+ model: reviewerModel,
2036
+ }))
2037
+ } else {
2038
+ panelResults = await Promise.all([
2039
+ runner(taskRecord, 'panel', reviewerModel),
2040
+ runner(taskRecord, 'panel', reviewerModel),
2041
+ runner(taskRecord, 'panel', reviewerModel),
2042
+ ])
2043
+ }
1873
2044
  } finally {
1874
2045
  reviewSemaphore.release()
1875
2046
  }
@@ -2107,6 +2278,7 @@ async function runConvoy(
2107
2278
  dispute_id: null,
2108
2279
  drift_score: null,
2109
2280
  drift_retried: 0,
2281
+ compaction_count: 0,
2110
2282
  outputs: null,
2111
2283
  inputs: null,
2112
2284
  discovered_issues: null,
@@ -2154,6 +2326,72 @@ async function runConvoy(
2154
2326
  if (result.usage.total_tokens != null) usageExtra.total_tokens = result.usage.total_tokens
2155
2327
  }
2156
2328
 
2329
+ // ── Context compaction check (Phase 44) ─────────────────────────────
2330
+ const compactionConfig = spec.defaults?.compaction
2331
+ if (compactionConfig?.enabled && usageExtra.total_tokens != null && taskRecord.model) {
2332
+ if (shouldCompact(usageExtra.total_tokens, taskRecord.model, compactionConfig)) {
2333
+ if (canCompact(taskRecord.compaction_count)) {
2334
+ const newCount = taskRecord.compaction_count + 1
2335
+ store.updateTaskCompaction(taskRecord.id, convoyId, newCount)
2336
+
2337
+ const summaryFromOutput = parseCompactionSummary(result.output, taskRecord.id, convoyId)
2338
+ let summaryPath: string | undefined
2339
+ if (summaryFromOutput) {
2340
+ try {
2341
+ summaryPath = saveCompaction(convoyId, taskRecord.id, summaryFromOutput, newCount)
2342
+ } catch { /* non-critical */ }
2343
+ }
2344
+
2345
+ const compactionTaskFiles = taskRecord.files ? JSON.parse(taskRecord.files) as string[] : []
2346
+ const compactionDepIds = taskRecord.depends_on ? JSON.parse(taskRecord.depends_on) as string[] : []
2347
+ const compactionDepResults = resolveDependencyResults(store, convoyId, compactionDepIds)
2348
+ const compactionPreamble = buildIsolationPreamble(
2349
+ { id: taskRecord.id, description: taskRecord.prompt.slice(0, 200), prompt: taskRecord.prompt, files: compactionTaskFiles, agent: taskRecord.agent },
2350
+ compactionDepResults,
2351
+ )
2352
+
2353
+ const continuationPrompt = summaryPath
2354
+ ? buildContinuationPrompt(taskRecord.prompt, summaryPath, compactionPreamble)
2355
+ : compactionPreamble + '\n\n' + generateCompactionPrompt(taskRecord.id) + '\n\n' + taskRecord.prompt
2356
+
2357
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
2358
+ worker_id: null,
2359
+ worktree: null,
2360
+ started_at: null,
2361
+ finished_at: null,
2362
+ prompt: continuationPrompt,
2363
+ })
2364
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
2365
+
2366
+ events.emit('context_compacted', {
2367
+ task_id: taskRecord.id,
2368
+ compaction_count: newCount,
2369
+ summary_path: summaryPath ?? '',
2370
+ model: taskRecord.model,
2371
+ tokens_used: usageExtra.total_tokens,
2372
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2373
+
2374
+ taskAdapterMap.delete(taskRecord.id)
2375
+ return
2376
+ } else {
2377
+ // Max compactions exceeded — fail the task
2378
+ const exhaustedAt = new Date().toISOString()
2379
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
2380
+ finished_at: exhaustedAt,
2381
+ output: `Context exhausted: reached maximum ${getMaxCompactions()} compactions`,
2382
+ exit_code: 1,
2383
+ })
2384
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: exhaustedAt })
2385
+ events.emit('task_failed', {
2386
+ reason: 'context_exhausted',
2387
+ worker_id: workerId,
2388
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2389
+ taskAdapterMap.delete(taskRecord.id)
2390
+ return
2391
+ }
2392
+ }
2393
+ }
2394
+
2157
2395
  // ── Capture outputs as artifacts ────────────────────────────────────────
2158
2396
  if (taskRecord.outputs) {
2159
2397
  const outputs: TaskOutput[] = JSON.parse(taskRecord.outputs)
@@ -2190,6 +2428,48 @@ async function runConvoy(
2190
2428
  }
2191
2429
  }
2192
2430
 
2431
+ // ── Extract filesystem artifacts (Phase 43) ────────────────────────
2432
+ try {
2433
+ const fsArtifactRefs = extractArtifactRefs(taskRecord.id, convoyId, result.output)
2434
+ if (fsArtifactRefs.length > 0) {
2435
+ events.emit('artifacts_extracted', {
2436
+ task_id: taskRecord.id,
2437
+ count: fsArtifactRefs.length,
2438
+ artifacts: fsArtifactRefs.map(r => ({ filename: r.filename, summary: r.summary })),
2439
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2440
+ }
2441
+ } catch (err) {
2442
+ process.stderr.write(`[artifacts] Warning: extraction failed for task ${taskRecord.id}: ${(err as Error).message}\n`)
2443
+ }
2444
+
2445
+ // ── Output contract validation ────────────────────────────────────────
2446
+ const contractResult = validateOutput(taskRecord.agent, result.output)
2447
+ if (!contractResult.valid) {
2448
+ const freshRecordForContract = store.getTask(taskRecord.id, convoyId)!
2449
+ if (freshRecordForContract.retries < freshRecordForContract.max_retries) {
2450
+ const retryPrefix = buildContractRetryPrompt(contractResult) + '\n\n'
2451
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
2452
+ retries: freshRecordForContract.retries + 1,
2453
+ worker_id: null,
2454
+ worktree: null,
2455
+ started_at: null,
2456
+ finished_at: null,
2457
+ prompt: retryPrefix + taskRecord.prompt,
2458
+ })
2459
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
2460
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} contract retry ${freshRecordForContract.retries + 1}/${freshRecordForContract.max_retries}\n`)
2461
+ taskAdapterMap.delete(taskRecord.id)
2462
+ return
2463
+ }
2464
+ events.emit('contract_violation', {
2465
+ task_id: taskRecord.id,
2466
+ agent: taskRecord.agent,
2467
+ missing: contractResult.missing,
2468
+ warnings: contractResult.warnings,
2469
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2470
+ process.stdout.write(` ${c.yellow('⚠')} ${c.bold(`[${taskRecord.id}]`)} contract violation: missing ${contractResult.missing.join(', ')}\n`)
2471
+ }
2472
+
2193
2473
  // ── Intelligence: capture persistent agent identity (Phase 17.2) ─────
2194
2474
  const specTaskForCapture = (spec.tasks ?? []).find(t => t.id === taskRecord.id)
2195
2475
  if (specTaskForCapture?.persistent && result.output) {
@@ -2231,6 +2511,7 @@ async function runConvoy(
2231
2511
  output: result.output,
2232
2512
  exit_code: result.exitCode,
2233
2513
  ...usageExtra,
2514
+ contract_result: JSON.stringify(contractResult),
2234
2515
  })
2235
2516
  store.updateWorkerStatus(workerId, 'done', { finished_at: finishedAt })
2236
2517
  })
@@ -2430,13 +2711,13 @@ async function runConvoy(
2430
2711
  try {
2431
2712
  // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
2432
2713
  // They are NOT user-supplied and are part of the trusted build configuration.
2433
- await execFile('sh', ['-c', command], { cwd: basePath })
2714
+ await execFile('sh', ['-c', command], { cwd: basePath, maxBuffer: 10 * 1024 * 1024 })
2434
2715
  gateResults.push({ command, exitCode: 0, passed: true })
2435
2716
  process.stdout.write(` ${c.green('✓')} ${c.dim(command)}\n`)
2436
2717
  } catch (err) {
2437
2718
  const execErr = err as Error & { code?: unknown; stderr?: string; stdout?: string }
2438
2719
  const code = typeof execErr.code === 'number' ? execErr.code : 1
2439
- const output = execErr.stderr || execErr.stdout || execErr.message || ''
2720
+ const output = [execErr.stderr, execErr.stdout].filter(Boolean).join('\n').trim() || execErr.message || ''
2440
2721
  gateResults.push({ command, exitCode: code, passed: false, output })
2441
2722
  process.stdout.write(` ${c.red('✗')} ${c.dim(command)}\n`)
2442
2723
  }
@@ -2454,7 +2735,16 @@ async function runConvoy(
2454
2735
  .map(g => `Command: ${g.command}\nExit code: ${g.exitCode}\nOutput:\n${g.output ?? '(no output)'}`)
2455
2736
  .join('\n\n---\n\n')
2456
2737
 
2457
- const fixPrompt = `The following validation gates failed after all convoy tasks completed. Fix the issues so these commands pass.\n\n${failureSummary}`
2738
+ // Gather files touched by convoy tasks to give the fix agent context
2739
+ const allTasks = store.getTasksByConvoy(convoyId)
2740
+ const touchedFiles = allTasks
2741
+ .filter(t => t.files)
2742
+ .flatMap(t => { try { return JSON.parse(t.files as string) as string[] } catch { return [] } })
2743
+ const filesContext = touchedFiles.length > 0
2744
+ ? `\n\nFiles modified by the convoy tasks:\n${touchedFiles.map(f => `- ${f}`).join('\n')}\n`
2745
+ : ''
2746
+
2747
+ const fixPrompt = `The following validation gates failed after all convoy tasks completed. Fix the issues so these commands pass.${filesContext}\n\n${failureSummary}`
2458
2748
  const fixTaskId = `gate-fix-${gateAttempt}`
2459
2749
 
2460
2750
  process.stdout.write(`\n ${c.yellow('⟳')} ${c.bold(`[${fixTaskId}]`)} fixing gate failures (attempt ${gateAttempt}/${maxGateRetries})\n`)
@@ -2506,6 +2796,18 @@ async function runConvoy(
2506
2796
  try { consolidateIssues(basePath) } catch { /* non-critical */ }
2507
2797
  }
2508
2798
 
2799
+ // ── Intelligence: skill refinement check ───────────────────────────────
2800
+ try {
2801
+ const proposals = runSkillRefinementCheck(convoyId, basePath)
2802
+ for (const p of proposals) {
2803
+ events.emit('skill_refinement_proposed', {
2804
+ skill_name: p.skill,
2805
+ proposal_path: p.proposalPath,
2806
+ }, { convoy_id: convoyId })
2807
+ process.stdout.write(` ${c.yellow('◆')} Skill refinement proposed for "${p.skill}". Review at ${p.proposalPath}\n`)
2808
+ }
2809
+ } catch { /* non-critical */ }
2810
+
2509
2811
  // ── Final status & summary ────────────────────────────────────────────────
2510
2812
 
2511
2813
  const allTasksFinal = store.getTasksByConvoy(convoyId)
@@ -2991,6 +3293,7 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
2991
3293
  dispute_id: null,
2992
3294
  drift_score: null,
2993
3295
  drift_retried: 0,
3296
+ compaction_count: 0,
2994
3297
  outputs: null,
2995
3298
  inputs: null,
2996
3299
  discovered_issues: null,
@@ -178,6 +178,61 @@ export const EVENT_DATA_SCHEMAS: Record<string, AnySchema> = {
178
178
  description: v.optional(v.string()),
179
179
  severity: v.optional(v.string()),
180
180
  }),
181
+ contract_violation: v.looseObject({
182
+ task_id: v.optional(v.string()),
183
+ agent: v.optional(v.string()),
184
+ missing: v.optional(v.array(v.string())),
185
+ warnings: v.optional(v.array(v.string())),
186
+ }),
187
+ partition_violation: v.looseObject({
188
+ task_id: v.optional(v.string()),
189
+ allowed: v.optional(v.array(v.string())),
190
+ actual: v.optional(v.array(v.string())),
191
+ violations: v.optional(v.array(v.string())),
192
+ }),
193
+ context_compacted: v.looseObject({
194
+ task_id: v.optional(v.string()),
195
+ compaction_count: v.optional(v.number()),
196
+ summary_path: v.optional(v.string()),
197
+ model: v.optional(v.string()),
198
+ tokens_used: v.optional(v.number()),
199
+ }),
200
+ skill_refinement_proposed: v.looseObject({
201
+ skill_name: v.optional(v.string()),
202
+ proposal_path: v.optional(v.string()),
203
+ failure_count: v.optional(v.number()),
204
+ confidence: v.optional(v.string()),
205
+ }),
206
+ tdd_check_passed: v.looseObject({
207
+ task_id: v.optional(v.string()),
208
+ new_source_files: v.optional(v.number()),
209
+ existing_test_files: v.optional(v.number()),
210
+ }),
211
+ tdd_check_failed: v.looseObject({
212
+ task_id: v.optional(v.string()),
213
+ missing_test_files: v.optional(v.array(v.string())),
214
+ new_source_files: v.optional(v.number()),
215
+ }),
216
+ tdd_check_skipped: v.looseObject({
217
+ task_id: v.optional(v.string()),
218
+ reason: v.optional(v.string()),
219
+ agent: v.optional(v.string()),
220
+ }),
221
+ review_stage_completed: v.looseObject({
222
+ stage: v.string(),
223
+ verdict: v.string(),
224
+ tokens: v.number(),
225
+ task_id: v.optional(v.string()),
226
+ model: v.optional(v.string()),
227
+ }),
228
+ artifacts_extracted: v.looseObject({
229
+ task_id: v.optional(v.string()),
230
+ count: v.optional(v.number()),
231
+ artifacts: v.optional(v.array(v.looseObject({
232
+ filename: v.string(),
233
+ summary: v.optional(v.string()),
234
+ }))),
235
+ }),
181
236
  }
182
237
  export function validateEventData(
183
238
  type: string,
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ buildIsolationPreamble,
4
+ formatDependencyResults,
5
+ detectPartitionViolations,
6
+ type DependencyResult,
7
+ } from './isolation.js'
8
+
9
+ describe('buildIsolationPreamble', () => {
10
+ const baseTask = {
11
+ id: 'task-1',
12
+ description: 'Implement the auth service',
13
+ prompt: 'Please implement the auth service with JWT tokens',
14
+ files: ['src/auth/', 'src/services/auth.ts'],
15
+ agent: 'developer',
16
+ }
17
+
18
+ it('with no dependencies contains task ID, agent, description, file list, and no-dependency text', () => {
19
+ const result = buildIsolationPreamble(baseTask, [])
20
+ expect(result).toContain('task-1')
21
+ expect(result).toContain('developer')
22
+ expect(result).toContain('Implement the auth service')
23
+ expect(result).toContain('src/auth/')
24
+ expect(result).toContain('src/services/auth.ts')
25
+ expect(result).toContain('No dependencies')
26
+ expect(result).toContain('first phase')
27
+ })
28
+
29
+ it('with 2 completed dependencies includes dependency summaries and files', () => {
30
+ const depResults: DependencyResult[] = [
31
+ { taskId: 'task-0', agent: 'architect', status: 'done', summary: 'Designed the auth schema', filesChanged: ['schema.ts', 'types.ts'] },
32
+ { taskId: 'task-0b', agent: 'developer', status: 'done', summary: 'Set up project structure', filesChanged: ['package.json'] },
33
+ ]
34
+ const result = buildIsolationPreamble(baseTask, depResults)
35
+ expect(result).toContain('task-0')
36
+ expect(result).toContain('Designed the auth schema')
37
+ expect(result).toContain('schema.ts, types.ts')
38
+ expect(result).toContain('task-0b')
39
+ expect(result).toContain('package.json')
40
+ expect(result).not.toContain('No dependencies')
41
+ })
42
+
43
+ it('with failed dependency includes failure status', () => {
44
+ const depResults: DependencyResult[] = [
45
+ { taskId: 'task-x', agent: 'developer', status: 'failed', summary: 'Build failed due to type errors', filesChanged: [] },
46
+ ]
47
+ const result = buildIsolationPreamble(baseTask, depResults)
48
+ expect(result).toContain('failed')
49
+ expect(result).toContain('Build failed due to type errors')
50
+ })
51
+
52
+ it('uses prompt slice when no description', () => {
53
+ const longPrompt = 'A'.repeat(300)
54
+ const task = { ...baseTask, description: '', prompt: longPrompt }
55
+ const result = buildIsolationPreamble(task, [])
56
+ expect(result).toContain('A'.repeat(200))
57
+ expect(result).not.toContain('A'.repeat(201))
58
+ })
59
+ })
60
+
61
+ describe('formatDependencyResults', () => {
62
+ it('compact format includes summary and filesChanged but not full output', () => {
63
+ const deps: DependencyResult[] = [
64
+ {
65
+ taskId: 'dep-1',
66
+ agent: 'developer',
67
+ status: 'done',
68
+ summary: 'Completed auth setup',
69
+ filesChanged: ['src/auth.ts', 'src/index.ts'],
70
+ },
71
+ ]
72
+ const result = formatDependencyResults(deps)
73
+ expect(result).toContain('dep-1')
74
+ expect(result).toContain('developer')
75
+ expect(result).toContain('done')
76
+ expect(result).toContain('Completed auth setup')
77
+ expect(result).toContain('src/auth.ts, src/index.ts')
78
+ })
79
+
80
+ it('shows no-summary placeholder when summary is null', () => {
81
+ const deps: DependencyResult[] = [
82
+ { taskId: 'dep-2', agent: 'architect', status: 'done', summary: null, filesChanged: [] },
83
+ ]
84
+ const result = formatDependencyResults(deps)
85
+ expect(result).toContain('No summary available.')
86
+ expect(result).toContain('Files changed: none')
87
+ })
88
+ })
89
+
90
+ describe('detectPartitionViolations', () => {
91
+ it('returns null when all files are within partition', () => {
92
+ const result = detectPartitionViolations(
93
+ 'task-1',
94
+ ['src/auth/', 'src/types.ts'],
95
+ ['src/auth/service.ts', 'src/auth/utils.ts', 'src/types.ts'],
96
+ )
97
+ expect(result).toBeNull()
98
+ })
99
+
100
+ it('detects files outside partition', () => {
101
+ const result = detectPartitionViolations(
102
+ 'task-1',
103
+ ['src/auth/'],
104
+ ['src/auth/service.ts', 'src/other/unrelated.ts'],
105
+ )
106
+ expect(result).not.toBeNull()
107
+ expect(result!.violations).toContain('src/other/unrelated.ts')
108
+ expect(result!.violations).not.toContain('src/auth/service.ts')
109
+ expect(result!.taskId).toBe('task-1')
110
+ expect(result!.allowedFiles).toEqual(['src/auth/'])
111
+ })
112
+
113
+ it('handles directory paths - src/auth/ allows src/auth/service.ts', () => {
114
+ const result = detectPartitionViolations(
115
+ 'task-1',
116
+ ['src/auth/'],
117
+ ['src/auth/service.ts', 'src/auth/utils/helper.ts'],
118
+ )
119
+ expect(result).toBeNull()
120
+ })
121
+
122
+ it('handles exact file matches - src/index.ts allows only that exact file', () => {
123
+ const result = detectPartitionViolations(
124
+ 'task-1',
125
+ ['src/index.ts'],
126
+ ['src/index.ts', 'src/other.ts'],
127
+ )
128
+ expect(result).not.toBeNull()
129
+ expect(result!.violations).toContain('src/other.ts')
130
+ expect(result!.violations).not.toContain('src/index.ts')
131
+ })
132
+
133
+ it('returns null for empty actualFiles', () => {
134
+ const result = detectPartitionViolations('task-1', ['src/auth/'], [])
135
+ expect(result).toBeNull()
136
+ })
137
+ })