@citadel-labs/citadel 0.1.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 (307) hide show
  1. package/README.md +145 -0
  2. package/dist/__tests__/append-bead.test.d.ts +2 -0
  3. package/dist/__tests__/append-bead.test.d.ts.map +1 -0
  4. package/dist/__tests__/append-bead.test.js +88 -0
  5. package/dist/__tests__/append-bead.test.js.map +1 -0
  6. package/dist/__tests__/blocked-outposts.test.d.ts +2 -0
  7. package/dist/__tests__/blocked-outposts.test.d.ts.map +1 -0
  8. package/dist/__tests__/blocked-outposts.test.js +142 -0
  9. package/dist/__tests__/blocked-outposts.test.js.map +1 -0
  10. package/dist/__tests__/bugfixes.test.d.ts +2 -0
  11. package/dist/__tests__/bugfixes.test.d.ts.map +1 -0
  12. package/dist/__tests__/bugfixes.test.js +503 -0
  13. package/dist/__tests__/bugfixes.test.js.map +1 -0
  14. package/dist/__tests__/citizen-tribute.test.d.ts +2 -0
  15. package/dist/__tests__/citizen-tribute.test.d.ts.map +1 -0
  16. package/dist/__tests__/citizen-tribute.test.js +106 -0
  17. package/dist/__tests__/citizen-tribute.test.js.map +1 -0
  18. package/dist/__tests__/cli-e2e/dispatch-note-resolve.test.d.ts +2 -0
  19. package/dist/__tests__/cli-e2e/dispatch-note-resolve.test.d.ts.map +1 -0
  20. package/dist/__tests__/cli-e2e/dispatch-note-resolve.test.js +65 -0
  21. package/dist/__tests__/cli-e2e/dispatch-note-resolve.test.js.map +1 -0
  22. package/dist/__tests__/cli-e2e/full-lifecycle.test.d.ts +2 -0
  23. package/dist/__tests__/cli-e2e/full-lifecycle.test.d.ts.map +1 -0
  24. package/dist/__tests__/cli-e2e/full-lifecycle.test.js +157 -0
  25. package/dist/__tests__/cli-e2e/full-lifecycle.test.js.map +1 -0
  26. package/dist/__tests__/cli-e2e/helpers.d.ts +28 -0
  27. package/dist/__tests__/cli-e2e/helpers.d.ts.map +1 -0
  28. package/dist/__tests__/cli-e2e/helpers.js +76 -0
  29. package/dist/__tests__/cli-e2e/helpers.js.map +1 -0
  30. package/dist/__tests__/cli-e2e/init-outpost-status.test.d.ts +2 -0
  31. package/dist/__tests__/cli-e2e/init-outpost-status.test.d.ts.map +1 -0
  32. package/dist/__tests__/cli-e2e/init-outpost-status.test.js +79 -0
  33. package/dist/__tests__/cli-e2e/init-outpost-status.test.js.map +1 -0
  34. package/dist/__tests__/cli-e2e/reset-stop-tribute-trace.test.d.ts +2 -0
  35. package/dist/__tests__/cli-e2e/reset-stop-tribute-trace.test.d.ts.map +1 -0
  36. package/dist/__tests__/cli-e2e/reset-stop-tribute-trace.test.js +158 -0
  37. package/dist/__tests__/cli-e2e/reset-stop-tribute-trace.test.js.map +1 -0
  38. package/dist/__tests__/courier.test.d.ts +2 -0
  39. package/dist/__tests__/courier.test.d.ts.map +1 -0
  40. package/dist/__tests__/courier.test.js +97 -0
  41. package/dist/__tests__/courier.test.js.map +1 -0
  42. package/dist/__tests__/e2e-smoke.test.d.ts +2 -0
  43. package/dist/__tests__/e2e-smoke.test.d.ts.map +1 -0
  44. package/dist/__tests__/e2e-smoke.test.js +137 -0
  45. package/dist/__tests__/e2e-smoke.test.js.map +1 -0
  46. package/dist/__tests__/fo-broadcast.test.d.ts +2 -0
  47. package/dist/__tests__/fo-broadcast.test.d.ts.map +1 -0
  48. package/dist/__tests__/fo-broadcast.test.js +134 -0
  49. package/dist/__tests__/fo-broadcast.test.js.map +1 -0
  50. package/dist/__tests__/fo-command-processor.test.d.ts +2 -0
  51. package/dist/__tests__/fo-command-processor.test.d.ts.map +1 -0
  52. package/dist/__tests__/fo-command-processor.test.js +86 -0
  53. package/dist/__tests__/fo-command-processor.test.js.map +1 -0
  54. package/dist/__tests__/fo-escalation.test.d.ts +2 -0
  55. package/dist/__tests__/fo-escalation.test.d.ts.map +1 -0
  56. package/dist/__tests__/fo-escalation.test.js +126 -0
  57. package/dist/__tests__/fo-escalation.test.js.map +1 -0
  58. package/dist/__tests__/fo-nudge-watcher.test.d.ts +2 -0
  59. package/dist/__tests__/fo-nudge-watcher.test.d.ts.map +1 -0
  60. package/dist/__tests__/fo-nudge-watcher.test.js +154 -0
  61. package/dist/__tests__/fo-nudge-watcher.test.js.map +1 -0
  62. package/dist/__tests__/fo-nudge-wiring.test.d.ts +2 -0
  63. package/dist/__tests__/fo-nudge-wiring.test.d.ts.map +1 -0
  64. package/dist/__tests__/fo-nudge-wiring.test.js +31 -0
  65. package/dist/__tests__/fo-nudge-wiring.test.js.map +1 -0
  66. package/dist/__tests__/fo-relay.test.d.ts +2 -0
  67. package/dist/__tests__/fo-relay.test.d.ts.map +1 -0
  68. package/dist/__tests__/fo-relay.test.js +90 -0
  69. package/dist/__tests__/fo-relay.test.js.map +1 -0
  70. package/dist/__tests__/fo-task-report.test.d.ts +2 -0
  71. package/dist/__tests__/fo-task-report.test.d.ts.map +1 -0
  72. package/dist/__tests__/fo-task-report.test.js +81 -0
  73. package/dist/__tests__/fo-task-report.test.js.map +1 -0
  74. package/dist/__tests__/fo-webhook.test.d.ts +2 -0
  75. package/dist/__tests__/fo-webhook.test.d.ts.map +1 -0
  76. package/dist/__tests__/fo-webhook.test.js +70 -0
  77. package/dist/__tests__/fo-webhook.test.js.map +1 -0
  78. package/dist/__tests__/integration.test.d.ts +2 -0
  79. package/dist/__tests__/integration.test.d.ts.map +1 -0
  80. package/dist/__tests__/integration.test.js +763 -0
  81. package/dist/__tests__/integration.test.js.map +1 -0
  82. package/dist/__tests__/multi-outpost-dep.test.d.ts +2 -0
  83. package/dist/__tests__/multi-outpost-dep.test.d.ts.map +1 -0
  84. package/dist/__tests__/multi-outpost-dep.test.js +173 -0
  85. package/dist/__tests__/multi-outpost-dep.test.js.map +1 -0
  86. package/dist/__tests__/nudge.test.d.ts +2 -0
  87. package/dist/__tests__/nudge.test.d.ts.map +1 -0
  88. package/dist/__tests__/nudge.test.js +103 -0
  89. package/dist/__tests__/nudge.test.js.map +1 -0
  90. package/dist/__tests__/outpost-registry.test.d.ts +2 -0
  91. package/dist/__tests__/outpost-registry.test.d.ts.map +1 -0
  92. package/dist/__tests__/outpost-registry.test.js +72 -0
  93. package/dist/__tests__/outpost-registry.test.js.map +1 -0
  94. package/dist/__tests__/process-registry.test.d.ts +2 -0
  95. package/dist/__tests__/process-registry.test.d.ts.map +1 -0
  96. package/dist/__tests__/process-registry.test.js +108 -0
  97. package/dist/__tests__/process-registry.test.js.map +1 -0
  98. package/dist/__tests__/session-log.test.d.ts +2 -0
  99. package/dist/__tests__/session-log.test.d.ts.map +1 -0
  100. package/dist/__tests__/session-log.test.js +60 -0
  101. package/dist/__tests__/session-log.test.js.map +1 -0
  102. package/dist/__tests__/spawn-citizen.test.d.ts +2 -0
  103. package/dist/__tests__/spawn-citizen.test.d.ts.map +1 -0
  104. package/dist/__tests__/spawn-citizen.test.js +48 -0
  105. package/dist/__tests__/spawn-citizen.test.js.map +1 -0
  106. package/dist/__tests__/timeout-watchdog.test.d.ts +2 -0
  107. package/dist/__tests__/timeout-watchdog.test.d.ts.map +1 -0
  108. package/dist/__tests__/timeout-watchdog.test.js +81 -0
  109. package/dist/__tests__/timeout-watchdog.test.js.map +1 -0
  110. package/dist/__tests__/worktree-manager.test.d.ts +2 -0
  111. package/dist/__tests__/worktree-manager.test.d.ts.map +1 -0
  112. package/dist/__tests__/worktree-manager.test.js +98 -0
  113. package/dist/__tests__/worktree-manager.test.js.map +1 -0
  114. package/dist/cli/aliases.d.ts +10 -0
  115. package/dist/cli/aliases.d.ts.map +1 -0
  116. package/dist/cli/aliases.js +56 -0
  117. package/dist/cli/aliases.js.map +1 -0
  118. package/dist/cli/command.d.ts +3 -0
  119. package/dist/cli/command.d.ts.map +1 -0
  120. package/dist/cli/command.js +63 -0
  121. package/dist/cli/command.js.map +1 -0
  122. package/dist/cli/index.d.ts +3 -0
  123. package/dist/cli/index.d.ts.map +1 -0
  124. package/dist/cli/index.js +29 -0
  125. package/dist/cli/index.js.map +1 -0
  126. package/dist/cli/init.d.ts +3 -0
  127. package/dist/cli/init.d.ts.map +1 -0
  128. package/dist/cli/init.js +57 -0
  129. package/dist/cli/init.js.map +1 -0
  130. package/dist/cli/outpost.d.ts +3 -0
  131. package/dist/cli/outpost.d.ts.map +1 -0
  132. package/dist/cli/outpost.js +65 -0
  133. package/dist/cli/outpost.js.map +1 -0
  134. package/dist/cli/reset.d.ts +3 -0
  135. package/dist/cli/reset.d.ts.map +1 -0
  136. package/dist/cli/reset.js +67 -0
  137. package/dist/cli/reset.js.map +1 -0
  138. package/dist/cli/session.d.ts +3 -0
  139. package/dist/cli/session.d.ts.map +1 -0
  140. package/dist/cli/session.js +112 -0
  141. package/dist/cli/session.js.map +1 -0
  142. package/dist/cli/start.d.ts +3 -0
  143. package/dist/cli/start.d.ts.map +1 -0
  144. package/dist/cli/start.js +105 -0
  145. package/dist/cli/start.js.map +1 -0
  146. package/dist/cli/status.d.ts +3 -0
  147. package/dist/cli/status.d.ts.map +1 -0
  148. package/dist/cli/status.js +128 -0
  149. package/dist/cli/status.js.map +1 -0
  150. package/dist/cli/tribute.d.ts +3 -0
  151. package/dist/cli/tribute.d.ts.map +1 -0
  152. package/dist/cli/tribute.js +160 -0
  153. package/dist/cli/tribute.js.map +1 -0
  154. package/dist/cli/worktree.d.ts +3 -0
  155. package/dist/cli/worktree.d.ts.map +1 -0
  156. package/dist/cli/worktree.js +64 -0
  157. package/dist/cli/worktree.js.map +1 -0
  158. package/dist/core/append-bead.d.ts +13 -0
  159. package/dist/core/append-bead.d.ts.map +1 -0
  160. package/dist/core/append-bead.js +68 -0
  161. package/dist/core/append-bead.js.map +1 -0
  162. package/dist/core/blocked-outposts.d.ts +15 -0
  163. package/dist/core/blocked-outposts.d.ts.map +1 -0
  164. package/dist/core/blocked-outposts.js +77 -0
  165. package/dist/core/blocked-outposts.js.map +1 -0
  166. package/dist/core/citizen-bootstrap.d.ts +17 -0
  167. package/dist/core/citizen-bootstrap.d.ts.map +1 -0
  168. package/dist/core/citizen-bootstrap.js +118 -0
  169. package/dist/core/citizen-bootstrap.js.map +1 -0
  170. package/dist/core/citizen-tribute.d.ts +17 -0
  171. package/dist/core/citizen-tribute.d.ts.map +1 -0
  172. package/dist/core/citizen-tribute.js +55 -0
  173. package/dist/core/citizen-tribute.js.map +1 -0
  174. package/dist/core/command-bead.d.ts +6 -0
  175. package/dist/core/command-bead.d.ts.map +1 -0
  176. package/dist/core/command-bead.js +20 -0
  177. package/dist/core/command-bead.js.map +1 -0
  178. package/dist/core/courier.d.ts +52 -0
  179. package/dist/core/courier.d.ts.map +1 -0
  180. package/dist/core/courier.js +167 -0
  181. package/dist/core/courier.js.map +1 -0
  182. package/dist/core/fo-broadcast.d.ts +15 -0
  183. package/dist/core/fo-broadcast.d.ts.map +1 -0
  184. package/dist/core/fo-broadcast.js +57 -0
  185. package/dist/core/fo-broadcast.js.map +1 -0
  186. package/dist/core/fo-command-processor.d.ts +7 -0
  187. package/dist/core/fo-command-processor.d.ts.map +1 -0
  188. package/dist/core/fo-command-processor.js +123 -0
  189. package/dist/core/fo-command-processor.js.map +1 -0
  190. package/dist/core/fo-dispatch.d.ts +24 -0
  191. package/dist/core/fo-dispatch.d.ts.map +1 -0
  192. package/dist/core/fo-dispatch.js +76 -0
  193. package/dist/core/fo-dispatch.js.map +1 -0
  194. package/dist/core/fo-escalation.d.ts +20 -0
  195. package/dist/core/fo-escalation.d.ts.map +1 -0
  196. package/dist/core/fo-escalation.js +83 -0
  197. package/dist/core/fo-escalation.js.map +1 -0
  198. package/dist/core/fo-handlers/ceiling-hit.d.ts +8 -0
  199. package/dist/core/fo-handlers/ceiling-hit.d.ts.map +1 -0
  200. package/dist/core/fo-handlers/ceiling-hit.js +10 -0
  201. package/dist/core/fo-handlers/ceiling-hit.js.map +1 -0
  202. package/dist/core/fo-handlers/slot-open.d.ts +13 -0
  203. package/dist/core/fo-handlers/slot-open.d.ts.map +1 -0
  204. package/dist/core/fo-handlers/slot-open.js +63 -0
  205. package/dist/core/fo-handlers/slot-open.js.map +1 -0
  206. package/dist/core/fo-nudge-watcher.d.ts +19 -0
  207. package/dist/core/fo-nudge-watcher.d.ts.map +1 -0
  208. package/dist/core/fo-nudge-watcher.js +71 -0
  209. package/dist/core/fo-nudge-watcher.js.map +1 -0
  210. package/dist/core/fo-nudge-wiring.d.ts +13 -0
  211. package/dist/core/fo-nudge-wiring.d.ts.map +1 -0
  212. package/dist/core/fo-nudge-wiring.js +120 -0
  213. package/dist/core/fo-nudge-wiring.js.map +1 -0
  214. package/dist/core/fo-relay.d.ts +7 -0
  215. package/dist/core/fo-relay.d.ts.map +1 -0
  216. package/dist/core/fo-relay.js +47 -0
  217. package/dist/core/fo-relay.js.map +1 -0
  218. package/dist/core/fo-retry-policy.d.ts +17 -0
  219. package/dist/core/fo-retry-policy.d.ts.map +1 -0
  220. package/dist/core/fo-retry-policy.js +89 -0
  221. package/dist/core/fo-retry-policy.js.map +1 -0
  222. package/dist/core/fo-state.d.ts +25 -0
  223. package/dist/core/fo-state.d.ts.map +1 -0
  224. package/dist/core/fo-state.js +99 -0
  225. package/dist/core/fo-state.js.map +1 -0
  226. package/dist/core/fo-task-report.d.ts +16 -0
  227. package/dist/core/fo-task-report.d.ts.map +1 -0
  228. package/dist/core/fo-task-report.js +63 -0
  229. package/dist/core/fo-task-report.js.map +1 -0
  230. package/dist/core/fo-webhook.d.ts +22 -0
  231. package/dist/core/fo-webhook.d.ts.map +1 -0
  232. package/dist/core/fo-webhook.js +43 -0
  233. package/dist/core/fo-webhook.js.map +1 -0
  234. package/dist/core/grand-archives.d.ts +14 -0
  235. package/dist/core/grand-archives.d.ts.map +1 -0
  236. package/dist/core/grand-archives.js +79 -0
  237. package/dist/core/grand-archives.js.map +1 -0
  238. package/dist/core/nudge.d.ts +6 -0
  239. package/dist/core/nudge.d.ts.map +1 -0
  240. package/dist/core/nudge.js +19 -0
  241. package/dist/core/nudge.js.map +1 -0
  242. package/dist/core/outpost-registry.d.ts +16 -0
  243. package/dist/core/outpost-registry.d.ts.map +1 -0
  244. package/dist/core/outpost-registry.js +27 -0
  245. package/dist/core/outpost-registry.js.map +1 -0
  246. package/dist/core/pre-spawn-check.d.ts +16 -0
  247. package/dist/core/pre-spawn-check.d.ts.map +1 -0
  248. package/dist/core/pre-spawn-check.js +50 -0
  249. package/dist/core/pre-spawn-check.js.map +1 -0
  250. package/dist/core/process-registry.d.ts +17 -0
  251. package/dist/core/process-registry.d.ts.map +1 -0
  252. package/dist/core/process-registry.js +71 -0
  253. package/dist/core/process-registry.js.map +1 -0
  254. package/dist/core/spawn-citizen.d.ts +23 -0
  255. package/dist/core/spawn-citizen.d.ts.map +1 -0
  256. package/dist/core/spawn-citizen.js +99 -0
  257. package/dist/core/spawn-citizen.js.map +1 -0
  258. package/dist/core/timeout-watchdog.d.ts +14 -0
  259. package/dist/core/timeout-watchdog.d.ts.map +1 -0
  260. package/dist/core/timeout-watchdog.js +68 -0
  261. package/dist/core/timeout-watchdog.js.map +1 -0
  262. package/dist/core/validate-tribute.d.ts +11 -0
  263. package/dist/core/validate-tribute.d.ts.map +1 -0
  264. package/dist/core/validate-tribute.js +44 -0
  265. package/dist/core/validate-tribute.js.map +1 -0
  266. package/dist/core/worktree-manager.d.ts +20 -0
  267. package/dist/core/worktree-manager.d.ts.map +1 -0
  268. package/dist/core/worktree-manager.js +123 -0
  269. package/dist/core/worktree-manager.js.map +1 -0
  270. package/dist/index.d.ts +30 -0
  271. package/dist/index.d.ts.map +1 -0
  272. package/dist/index.js +30 -0
  273. package/dist/index.js.map +1 -0
  274. package/dist/types/agent.d.ts +47 -0
  275. package/dist/types/agent.d.ts.map +1 -0
  276. package/dist/types/agent.js +4 -0
  277. package/dist/types/agent.js.map +1 -0
  278. package/dist/types/beads.d.ts +175 -0
  279. package/dist/types/beads.d.ts.map +1 -0
  280. package/dist/types/beads.js +4 -0
  281. package/dist/types/beads.js.map +1 -0
  282. package/dist/types/index.d.ts +4 -0
  283. package/dist/types/index.d.ts.map +1 -0
  284. package/dist/types/index.js +4 -0
  285. package/dist/types/index.js.map +1 -0
  286. package/dist/types/registry.d.ts +110 -0
  287. package/dist/types/registry.d.ts.map +1 -0
  288. package/dist/types/registry.js +3 -0
  289. package/dist/types/registry.js.map +1 -0
  290. package/docs/README.md +7 -0
  291. package/docs/architecture.md +106 -0
  292. package/docs/cli-reference.md +81 -0
  293. package/docs/configuration.md +143 -0
  294. package/docs/contributing.md +65 -0
  295. package/docs/getting-started.md +88 -0
  296. package/grand-archives/archetypes/architect/archetype.json +18 -0
  297. package/grand-archives/archetypes/qa-engineer/archetype.json +18 -0
  298. package/grand-archives/archetypes/software-engineer/archetype.json +18 -0
  299. package/grand-archives/archetypes/software-engineer/system-prompt.md +39 -0
  300. package/grand-archives/archetypes/software-engineer/toolset.json +15 -0
  301. package/grand-archives/first-officer/config.json +21 -0
  302. package/grand-archives/first-officer/system-prompt.md +47 -0
  303. package/grand-archives/first-officer/toolset.json +19 -0
  304. package/grand-archives/registry.json +8 -0
  305. package/grand-archives/shared/base-prompt-preamble.md +25 -0
  306. package/grand-archives/shared/base-tools.json +10 -0
  307. package/package.json +73 -0
@@ -0,0 +1,763 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { execFile as execFileCb } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { dispatch } from '../core/fo-dispatch.js';
8
+ import { ProcessRegistry } from '../core/process-registry.js';
9
+ import { appendBead, readBeads, mintBeadId } from '../core/append-bead.js';
10
+ import { handleSlotOpen } from '../core/fo-handlers/slot-open.js';
11
+ import { handleCeilingHit } from '../core/fo-handlers/ceiling-hit.js';
12
+ import { preSpawnCheck } from '../core/pre-spawn-check.js';
13
+ import { applyRetryPolicy } from '../core/fo-retry-policy.js';
14
+ import { citizenBootstrap } from '../core/citizen-bootstrap.js';
15
+ import { nudgeFirstOfficer } from '../core/nudge.js';
16
+ import { broadcastDependencyResolved } from '../core/fo-broadcast.js';
17
+ import { writeCommandBead } from '../core/command-bead.js';
18
+ import { processCommandBead } from '../core/fo-command-processor.js';
19
+ import { reconstructState } from '../core/fo-state.js';
20
+ import { validateTribute } from '../core/validate-tribute.js';
21
+ import { SCHEMA_VERSION } from '../types/index.js';
22
+ const execFile = promisify(execFileCb);
23
+ async function setupTempCitadel(opts) {
24
+ const citadelRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'citadel-int-'));
25
+ const outpostSlug = 'test-outpost';
26
+ const outpostPath = path.join(citadelRoot, 'outposts', outpostSlug);
27
+ // Citadel structure
28
+ await fs.mkdir(path.join(citadelRoot, '.citadel'), { recursive: true });
29
+ await fs.mkdir(path.join(citadelRoot, 'grand-archives', 'archetypes', 'software-engineer'), {
30
+ recursive: true,
31
+ });
32
+ await fs.mkdir(path.join(citadelRoot, 'grand-archives', 'first-officer'), {
33
+ recursive: true,
34
+ });
35
+ // Grand archives registry
36
+ await fs.writeFile(path.join(citadelRoot, 'grand-archives', 'registry.json'), JSON.stringify({
37
+ schema_version: '1.0',
38
+ archetypes: [
39
+ { key: 'software-engineer', description: 'Features, bugs, tests, PRs.', status: 'active' },
40
+ ],
41
+ }));
42
+ // First Officer config
43
+ await fs.writeFile(path.join(citadelRoot, 'grand-archives', 'first-officer', 'config.json'), JSON.stringify({
44
+ schema_version: '1.0',
45
+ display_name: 'First Officer',
46
+ singleton: true,
47
+ assignable: false,
48
+ agent: 'claude-code',
49
+ system_prompt_ref: 'first-officer/system-prompt.md',
50
+ toolset_ref: 'first-officer/toolset.json',
51
+ model: 'claude-opus-4',
52
+ constraints: { temperature: 0.2, max_tokens_per_turn: 8192 },
53
+ retry_policy: {
54
+ max_retries: 3,
55
+ retry_on: ['PARTIAL', 'FAILED', 'TRIBUTE_INVALID'],
56
+ partial_strategy: 'remaining_criteria_only',
57
+ on_ceiling_reached: 'ESCALATE_TO_COMMANDER',
58
+ },
59
+ nudge_retention: 500,
60
+ }));
61
+ // Outpost registry
62
+ const outpostRegistry = {
63
+ schema_version: SCHEMA_VERSION,
64
+ outposts: [
65
+ {
66
+ slug: outpostSlug,
67
+ path: outpostPath,
68
+ status: 'ACTIVE',
69
+ registered_at: new Date().toISOString(),
70
+ default_archetype: 'software-engineer',
71
+ max_citizens: opts?.maxCitizens ?? 2,
72
+ },
73
+ ],
74
+ };
75
+ await fs.writeFile(path.join(citadelRoot, '.citadel', 'outposts.json'), JSON.stringify(outpostRegistry));
76
+ // Nudges file (empty)
77
+ await fs.writeFile(path.join(citadelRoot, '.citadel', 'nudges.jsonl'), '');
78
+ // Outpost structure
79
+ await fs.mkdir(path.join(outpostPath, '.beads', 'mail'), { recursive: true });
80
+ await fs.mkdir(path.join(outpostPath, '.beads', 'tribute'), { recursive: true });
81
+ await fs.mkdir(path.join(outpostPath, 'worktrees', 'citizen'), { recursive: true });
82
+ // Initialize git in outpost (appendBead does git add/commit)
83
+ await execFile('git', ['init'], { cwd: outpostPath });
84
+ await execFile('git', ['config', 'user.email', 'test@citadel.dev'], { cwd: outpostPath });
85
+ await execFile('git', ['config', 'user.name', 'Citadel Test'], { cwd: outpostPath });
86
+ // Initial commit so git add/commit works
87
+ await fs.writeFile(path.join(outpostPath, '.gitkeep'), '');
88
+ await execFile('git', ['add', '.'], { cwd: outpostPath });
89
+ await execFile('git', ['commit', '-m', 'init'], { cwd: outpostPath });
90
+ // Initialize git in citadel root (nudge writes to .citadel/nudges.jsonl)
91
+ await execFile('git', ['init'], { cwd: citadelRoot });
92
+ await execFile('git', ['config', 'user.email', 'test@citadel.dev'], { cwd: citadelRoot });
93
+ await execFile('git', ['config', 'user.name', 'Citadel Test'], { cwd: citadelRoot });
94
+ await execFile('git', ['add', '.'], { cwd: citadelRoot });
95
+ await execFile('git', ['commit', '-m', 'init'], { cwd: citadelRoot });
96
+ return { citadelRoot, outpostPath, outpostSlug };
97
+ }
98
+ async function setupMultiOutpostCitadel(slugs) {
99
+ const citadelRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'citadel-multi-'));
100
+ await fs.mkdir(path.join(citadelRoot, '.citadel'), { recursive: true });
101
+ await fs.mkdir(path.join(citadelRoot, 'grand-archives', 'archetypes', 'software-engineer'), { recursive: true });
102
+ await fs.mkdir(path.join(citadelRoot, 'grand-archives', 'first-officer'), { recursive: true });
103
+ await fs.writeFile(path.join(citadelRoot, 'grand-archives', 'registry.json'), JSON.stringify({
104
+ schema_version: '1.0',
105
+ archetypes: [{ key: 'software-engineer', description: 'Features', status: 'active' }],
106
+ }));
107
+ await fs.writeFile(path.join(citadelRoot, 'grand-archives', 'first-officer', 'config.json'), JSON.stringify({
108
+ schema_version: '1.0', display_name: 'First Officer', singleton: true, assignable: false,
109
+ agent: 'claude-code', system_prompt_ref: 'first-officer/system-prompt.md',
110
+ toolset_ref: 'first-officer/toolset.json', model: 'claude-opus-4',
111
+ constraints: { temperature: 0.2, max_tokens_per_turn: 8192 },
112
+ retry_policy: { max_retries: 3, retry_on: ['PARTIAL', 'FAILED', 'TRIBUTE_INVALID'], partial_strategy: 'remaining_criteria_only', on_ceiling_reached: 'ESCALATE_TO_COMMANDER' },
113
+ nudge_retention: 500,
114
+ }));
115
+ const outposts = [];
116
+ const registryEntries = [];
117
+ for (const slug of slugs) {
118
+ const outpostPath = path.join(citadelRoot, 'outposts', slug);
119
+ await fs.mkdir(path.join(outpostPath, '.beads', 'mail'), { recursive: true });
120
+ await fs.mkdir(path.join(outpostPath, '.beads', 'tribute'), { recursive: true });
121
+ await fs.mkdir(path.join(outpostPath, 'worktrees', 'citizen'), { recursive: true });
122
+ await execFile('git', ['init'], { cwd: outpostPath });
123
+ await execFile('git', ['config', 'user.email', 'test@citadel.dev'], { cwd: outpostPath });
124
+ await execFile('git', ['config', 'user.name', 'Citadel Test'], { cwd: outpostPath });
125
+ await fs.writeFile(path.join(outpostPath, '.gitkeep'), '');
126
+ await execFile('git', ['add', '.'], { cwd: outpostPath });
127
+ await execFile('git', ['commit', '-m', 'init'], { cwd: outpostPath });
128
+ outposts.push({ slug, path: outpostPath });
129
+ registryEntries.push({
130
+ slug, path: outpostPath, status: 'ACTIVE',
131
+ registered_at: new Date().toISOString(), default_archetype: 'software-engineer', max_citizens: 2,
132
+ });
133
+ }
134
+ const registry = { schema_version: SCHEMA_VERSION, outposts: registryEntries };
135
+ await fs.writeFile(path.join(citadelRoot, '.citadel', 'outposts.json'), JSON.stringify(registry));
136
+ await fs.writeFile(path.join(citadelRoot, '.citadel', 'nudges.jsonl'), '');
137
+ await execFile('git', ['init'], { cwd: citadelRoot });
138
+ await execFile('git', ['config', 'user.email', 'test@citadel.dev'], { cwd: citadelRoot });
139
+ await execFile('git', ['config', 'user.name', 'Citadel Test'], { cwd: citadelRoot });
140
+ await execFile('git', ['add', '.'], { cwd: citadelRoot });
141
+ await execFile('git', ['commit', '-m', 'init'], { cwd: citadelRoot });
142
+ return { citadelRoot, outposts };
143
+ }
144
+ function makeTaskDescription(title) {
145
+ return {
146
+ title,
147
+ description: `Implement ${title}`,
148
+ acceptanceCriteria: ['Tests pass', 'No regressions'],
149
+ priority: 'MEDIUM',
150
+ };
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // Test 1: Parallel Citizens on same Outpost
154
+ // ---------------------------------------------------------------------------
155
+ describe('parallel Citizens on same Outpost', () => {
156
+ let env;
157
+ let processRegistry;
158
+ beforeEach(async () => {
159
+ env = await setupTempCitadel({ maxCitizens: 2 });
160
+ processRegistry = new ProcessRegistry();
161
+ });
162
+ afterEach(async () => {
163
+ await fs.rm(env.citadelRoot, { recursive: true, force: true });
164
+ });
165
+ it('creates 2 separate mail files with unique task_id and citizen_id', async () => {
166
+ const mail1 = await dispatch({
167
+ citadelRoot: env.citadelRoot,
168
+ outpostSlug: env.outpostSlug,
169
+ task: makeTaskDescription('Feature A'),
170
+ processRegistry,
171
+ });
172
+ const mail2 = await dispatch({
173
+ citadelRoot: env.citadelRoot,
174
+ outpostSlug: env.outpostSlug,
175
+ task: makeTaskDescription('Feature B'),
176
+ processRegistry,
177
+ });
178
+ // Verify: 2 separate mail files created in .beads/mail/
179
+ const mailDir = path.join(env.outpostPath, '.beads', 'mail');
180
+ const files = await fs.readdir(mailDir);
181
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
182
+ expect(jsonlFiles).toHaveLength(2);
183
+ // Verify: each has unique task_id
184
+ expect(mail1.task_id).toBeDefined();
185
+ expect(mail2.task_id).toBeDefined();
186
+ expect(mail1.task_id).not.toBe(mail2.task_id);
187
+ // Verify: each has unique citizen_id in spawn_hint
188
+ expect(mail1.spawn_hint?.citizen_id).toBeDefined();
189
+ expect(mail2.spawn_hint?.citizen_id).toBeDefined();
190
+ expect(mail1.spawn_hint.citizen_id).not.toBe(mail2.spawn_hint.citizen_id);
191
+ // Verify: both are TASK_ASSIGNMENT, unread
192
+ expect(mail1.mail_type).toBe('TASK_ASSIGNMENT');
193
+ expect(mail2.mail_type).toBe('TASK_ASSIGNMENT');
194
+ expect(mail1.read).toBe(false);
195
+ expect(mail2.read).toBe(false);
196
+ // Read back from disk to confirm persistence
197
+ const beads1 = await readBeads(path.join(mailDir, `${mail1.task_id}.jsonl`));
198
+ const beads2 = await readBeads(path.join(mailDir, `${mail2.task_id}.jsonl`));
199
+ expect(beads1).toHaveLength(1);
200
+ expect(beads2).toHaveLength(1);
201
+ expect(beads1[0].bead_id).toBe(mail1.bead_id);
202
+ expect(beads2[0].bead_id).toBe(mail2.bead_id);
203
+ });
204
+ it('dispatches a 3rd task when ceiling is hit — mail is still queued', async () => {
205
+ // Dispatch 2 tasks to fill the ceiling
206
+ await dispatch({
207
+ citadelRoot: env.citadelRoot,
208
+ outpostSlug: env.outpostSlug,
209
+ task: makeTaskDescription('Task 1'),
210
+ processRegistry,
211
+ });
212
+ await dispatch({
213
+ citadelRoot: env.citadelRoot,
214
+ outpostSlug: env.outpostSlug,
215
+ task: makeTaskDescription('Task 2'),
216
+ processRegistry,
217
+ });
218
+ // Register fake processes so processRegistry.countActive returns 2
219
+ processRegistry.set(env.outpostSlug, 'citizen-fake-1', {
220
+ pid: process.pid,
221
+ spawned_at: new Date().toISOString(),
222
+ worktree_path: '/tmp/wt1',
223
+ task_id: 'task-1',
224
+ });
225
+ processRegistry.set(env.outpostSlug, 'citizen-fake-2', {
226
+ pid: process.pid,
227
+ spawned_at: new Date().toISOString(),
228
+ worktree_path: '/tmp/wt2',
229
+ task_id: 'task-2',
230
+ });
231
+ expect(processRegistry.countActive(env.outpostSlug)).toBe(2);
232
+ // Dispatch 3rd task — should still write mail (queued), not throw
233
+ const mail3 = await dispatch({
234
+ citadelRoot: env.citadelRoot,
235
+ outpostSlug: env.outpostSlug,
236
+ task: makeTaskDescription('Task 3'),
237
+ processRegistry,
238
+ });
239
+ expect(mail3.task_id).toBeDefined();
240
+ expect(mail3.mail_type).toBe('TASK_ASSIGNMENT');
241
+ // 3 mail files total
242
+ const mailDir = path.join(env.outpostPath, '.beads', 'mail');
243
+ const files = await fs.readdir(mailDir);
244
+ const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
245
+ expect(jsonlFiles).toHaveLength(3);
246
+ });
247
+ });
248
+ // ---------------------------------------------------------------------------
249
+ // Test 2: Ceiling hit -> slot opens -> pending dispatched
250
+ // ---------------------------------------------------------------------------
251
+ describe('ceiling hit and slot-open auto-dispatch', () => {
252
+ let env;
253
+ let processRegistry;
254
+ beforeEach(async () => {
255
+ env = await setupTempCitadel({ maxCitizens: 1 });
256
+ processRegistry = new ProcessRegistry();
257
+ });
258
+ afterEach(async () => {
259
+ await fs.rm(env.citadelRoot, { recursive: true, force: true });
260
+ });
261
+ it('ceiling blocks spawn, then slot-open unblocks after tribute', async () => {
262
+ const outpostEntry = {
263
+ slug: env.outpostSlug,
264
+ path: env.outpostPath,
265
+ status: 'ACTIVE',
266
+ registered_at: new Date().toISOString(),
267
+ default_archetype: 'software-engineer',
268
+ max_citizens: 1,
269
+ };
270
+ // Dispatch 1 task — fills the single slot
271
+ const mail1 = await dispatch({
272
+ citadelRoot: env.citadelRoot,
273
+ outpostSlug: env.outpostSlug,
274
+ task: makeTaskDescription('First task'),
275
+ processRegistry,
276
+ });
277
+ // Register a fake process for that citizen (simulates running citizen)
278
+ const citizenId1 = mail1.spawn_hint.citizen_id;
279
+ processRegistry.set(env.outpostSlug, citizenId1, {
280
+ pid: process.pid,
281
+ spawned_at: new Date().toISOString(),
282
+ worktree_path: path.join(env.outpostPath, 'worktrees', 'citizen', mail1.task_id),
283
+ task_id: mail1.task_id,
284
+ });
285
+ // Dispatch 2nd task — mail is written (queued), but ceiling is hit
286
+ const mail2 = await dispatch({
287
+ citadelRoot: env.citadelRoot,
288
+ outpostSlug: env.outpostSlug,
289
+ task: makeTaskDescription('Second task'),
290
+ processRegistry,
291
+ });
292
+ expect(mail2.task_id).toBeDefined();
293
+ // preSpawnCheck should report ceiling hit (1 active, max 1)
294
+ const checkBlocked = await preSpawnCheck(env.citadelRoot, outpostEntry, processRegistry, 1);
295
+ expect(checkBlocked.canSpawn).toBe(false);
296
+ expect(checkBlocked.reason).toMatch(/Ceiling/i);
297
+ expect(checkBlocked.taskMail).not.toBeNull();
298
+ // handleCeilingHit logs the contention (should not throw)
299
+ const ceilingNudge = {
300
+ bead_id: mintBeadId(),
301
+ bead_type: 'NUDGE',
302
+ schema_version: SCHEMA_VERSION,
303
+ created_at: new Date().toISOString(),
304
+ signal_type: 'PARALLELISM_CEILING_HIT',
305
+ payload: {
306
+ outpost: env.outpostSlug,
307
+ active_count: 1,
308
+ ceiling: 1,
309
+ },
310
+ processed: false,
311
+ };
312
+ await handleCeilingHit(ceilingNudge);
313
+ // Now: write a SUCCESS tribute for the first task
314
+ const tribute = {
315
+ bead_id: mintBeadId(),
316
+ bead_type: 'TRIBUTE',
317
+ schema_version: SCHEMA_VERSION,
318
+ created_at: new Date().toISOString(),
319
+ citizen_id: citizenId1,
320
+ task_id: mail1.task_id,
321
+ originating_mail_ref: mail1.bead_id,
322
+ archetype: 'software-engineer',
323
+ outpost: env.outpostSlug,
324
+ worktree: `worktrees/citizen/${mail1.task_id}`,
325
+ attempt: 1,
326
+ status: 'SUCCESS',
327
+ summary: 'All tests pass',
328
+ artifacts: ['src/feature.ts'],
329
+ acceptance_met: [true, true],
330
+ elapsed_seconds: 120,
331
+ };
332
+ const tributePath = path.join(env.outpostPath, '.beads', 'tribute', `${mail1.task_id}_tribute.jsonl`);
333
+ await appendBead(tributePath, tribute);
334
+ // Create TRIBUTE_WRITTEN nudge
335
+ const tributeNudge = {
336
+ bead_id: mintBeadId(),
337
+ bead_type: 'NUDGE',
338
+ schema_version: SCHEMA_VERSION,
339
+ created_at: new Date().toISOString(),
340
+ signal_type: 'TRIBUTE_WRITTEN',
341
+ payload: {
342
+ outpost: env.outpostSlug,
343
+ tribute_bead_id: tribute.bead_id,
344
+ task_id: mail1.task_id,
345
+ worktree: tribute.worktree,
346
+ },
347
+ processed: false,
348
+ };
349
+ // Call handleSlotOpen — should log slot opening with pending count
350
+ await handleSlotOpen(tributeNudge, env.citadelRoot);
351
+ // Remove the fake process from registry (simulating citizen exit)
352
+ processRegistry.delete(env.outpostSlug, citizenId1);
353
+ expect(processRegistry.countActive(env.outpostSlug)).toBe(0);
354
+ // Now preSpawnCheck should return canSpawn=true for the remaining mail
355
+ const checkUnblocked = await preSpawnCheck(env.citadelRoot, outpostEntry, processRegistry, 1);
356
+ expect(checkUnblocked.canSpawn).toBe(true);
357
+ expect(checkUnblocked.taskMail).not.toBeNull();
358
+ expect(checkUnblocked.taskMail.mail_type).toBe('TASK_ASSIGNMENT');
359
+ });
360
+ });
361
+ // ---------------------------------------------------------------------------
362
+ // Test 3: Retry with inherited worktree
363
+ // ---------------------------------------------------------------------------
364
+ describe('retry with inherited worktree', () => {
365
+ let env;
366
+ let processRegistry;
367
+ const savedEnv = {};
368
+ beforeEach(async () => {
369
+ env = await setupTempCitadel({ maxCitizens: 2 });
370
+ processRegistry = new ProcessRegistry();
371
+ // Save env vars we will set
372
+ for (const key of [
373
+ 'CITADEL_CITIZEN_ID',
374
+ 'CITADEL_BEADS_ROOT',
375
+ 'CITADEL_OUTPOST',
376
+ 'CITADEL_TASK_ID',
377
+ 'CITADEL_WORKTREE',
378
+ ]) {
379
+ savedEnv[key] = process.env[key];
380
+ }
381
+ });
382
+ afterEach(async () => {
383
+ // Restore env vars
384
+ for (const [key, val] of Object.entries(savedEnv)) {
385
+ if (val === undefined) {
386
+ delete process.env[key];
387
+ }
388
+ else {
389
+ process.env[key] = val;
390
+ }
391
+ }
392
+ await fs.rm(env.citadelRoot, { recursive: true, force: true });
393
+ });
394
+ it('PARTIAL tribute triggers retry and bootstrap detects isRetry with errors', async () => {
395
+ // 1. Dispatch a task
396
+ const mail = await dispatch({
397
+ citadelRoot: env.citadelRoot,
398
+ outpostSlug: env.outpostSlug,
399
+ task: makeTaskDescription('Retry Feature'),
400
+ processRegistry,
401
+ });
402
+ const taskId = mail.task_id;
403
+ const citizenId = mail.spawn_hint.citizen_id;
404
+ // 2. Write a PARTIAL tribute (attempt 1, some acceptance criteria not met)
405
+ const tribute = {
406
+ bead_id: mintBeadId(),
407
+ bead_type: 'TRIBUTE',
408
+ schema_version: SCHEMA_VERSION,
409
+ created_at: new Date().toISOString(),
410
+ citizen_id: citizenId,
411
+ task_id: taskId,
412
+ originating_mail_ref: mail.bead_id,
413
+ archetype: 'software-engineer',
414
+ outpost: env.outpostSlug,
415
+ worktree: `worktrees/citizen/${taskId}`,
416
+ attempt: 1,
417
+ status: 'PARTIAL',
418
+ summary: 'Tests pass but regressions found',
419
+ artifacts: ['src/feature.ts'],
420
+ acceptance_met: [true, false], // second criterion not met
421
+ elapsed_seconds: 90,
422
+ };
423
+ const tributePath = path.join(env.outpostPath, '.beads', 'tribute', `${taskId}_tribute.jsonl`);
424
+ await appendBead(tributePath, tribute);
425
+ // 3. Create a TRIBUTE_WRITTEN nudge bead
426
+ const nudgeBead = await nudgeFirstOfficer(env.citadelRoot, 'TRIBUTE_WRITTEN', {
427
+ outpost: env.outpostSlug,
428
+ tribute_bead_id: tribute.bead_id,
429
+ task_id: taskId,
430
+ worktree: tribute.worktree,
431
+ });
432
+ // 4. Apply retry policy
433
+ const result = await applyRetryPolicy(env.citadelRoot, nudgeBead);
434
+ // 5. Verify: decision is 'retry'
435
+ expect(result.decision).toBe('retry');
436
+ expect(result.tribute.bead_id).toBe(tribute.bead_id);
437
+ expect(result.errors.length).toBeGreaterThan(0);
438
+ // 6. Verify: TRIBUTE_INVALID mail was written to .beads/mail/
439
+ const mailDir = path.join(env.outpostPath, '.beads', 'mail');
440
+ const mailFiles = await fs.readdir(mailDir);
441
+ const invalidFiles = mailFiles.filter(f => f.startsWith('invalid-'));
442
+ expect(invalidFiles).toHaveLength(1);
443
+ const invalidBeads = await readBeads(path.join(mailDir, invalidFiles[0]));
444
+ expect(invalidBeads).toHaveLength(1);
445
+ expect(invalidBeads[0].mail_type).toBe('TRIBUTE_INVALID');
446
+ expect(invalidBeads[0].task_id).toBe(taskId);
447
+ const invalidPayload = invalidBeads[0].payload;
448
+ expect(invalidPayload.errors.length).toBeGreaterThan(0);
449
+ // 7. Verify: a retry TASK_ASSIGNMENT mail was written (has retry_of set)
450
+ const retryFiles = mailFiles.filter(f => f.startsWith('retry-'));
451
+ expect(retryFiles).toHaveLength(1);
452
+ const retryBeads = await readBeads(path.join(mailDir, retryFiles[0]));
453
+ expect(retryBeads).toHaveLength(1);
454
+ expect(retryBeads[0].mail_type).toBe('TASK_ASSIGNMENT');
455
+ expect(retryBeads[0].retry_of).toBe(mail.bead_id);
456
+ const retryMail = retryBeads[0];
457
+ const retryCitizenId = retryMail.spawn_hint.citizen_id;
458
+ // 8. Simulate citizen bootstrap in retry: set env vars
459
+ process.env.CITADEL_CITIZEN_ID = retryCitizenId;
460
+ process.env.CITADEL_BEADS_ROOT = env.citadelRoot;
461
+ process.env.CITADEL_OUTPOST = env.outpostSlug;
462
+ process.env.CITADEL_TASK_ID = taskId;
463
+ process.env.CITADEL_WORKTREE = `worktrees/citizen/${taskId}`;
464
+ const ctx = await citizenBootstrap();
465
+ // 9. Verify: bootstrap detects isRetry=true with retryErrors from TRIBUTE_INVALID
466
+ expect(ctx.isRetry).toBe(true);
467
+ expect(ctx.retryErrors.length).toBeGreaterThan(0);
468
+ expect(ctx.taskAssignment).not.toBeNull();
469
+ expect(ctx.taskAssignment.mail_type).toBe('TASK_ASSIGNMENT');
470
+ expect(ctx.citizenId).toBe(retryCitizenId);
471
+ expect(ctx.worktree).toBe(`worktrees/citizen/${taskId}`);
472
+ });
473
+ });
474
+ // ---------------------------------------------------------------------------
475
+ // Test 4: 2-Outpost dependency chain with DEPENDENCY_RESOLVED broadcast
476
+ // ---------------------------------------------------------------------------
477
+ describe('2-Outpost dependency chain', () => {
478
+ let multi;
479
+ let processRegistry;
480
+ beforeEach(async () => {
481
+ multi = await setupMultiOutpostCitadel(['backend', 'frontend']);
482
+ processRegistry = new ProcessRegistry();
483
+ });
484
+ afterEach(async () => {
485
+ await fs.rm(multi.citadelRoot, { recursive: true, force: true });
486
+ });
487
+ it('backend SUCCESS triggers DEPENDENCY_RESOLVED to frontend', async () => {
488
+ const backend = multi.outposts.find(o => o.slug === 'backend');
489
+ const frontend = multi.outposts.find(o => o.slug === 'frontend');
490
+ // 1. Dispatch backend task (no dependencies)
491
+ const backendMail = await dispatch({
492
+ citadelRoot: multi.citadelRoot,
493
+ outpostSlug: 'backend',
494
+ task: {
495
+ title: 'Build API endpoint',
496
+ description: 'Create /api/users endpoint',
497
+ acceptanceCriteria: ['Tests pass'],
498
+ },
499
+ processRegistry,
500
+ });
501
+ const backendTaskId = backendMail.task_id;
502
+ // 2. Dispatch frontend task WITH dependency on backend task
503
+ const frontendMail = await dispatch({
504
+ citadelRoot: multi.citadelRoot,
505
+ outpostSlug: 'frontend',
506
+ task: {
507
+ title: 'Build user list UI',
508
+ description: 'Create user list component consuming /api/users',
509
+ acceptanceCriteria: ['Renders users'],
510
+ dependencies: [backendTaskId],
511
+ },
512
+ processRegistry,
513
+ });
514
+ const frontendTaskId = frontendMail.task_id;
515
+ // Verify: frontend mail has dependency on backend task
516
+ const frontendPayload = frontendMail.payload;
517
+ expect(frontendPayload.dependencies).toContain(backendTaskId);
518
+ // 3. Backend completes — write SUCCESS tribute
519
+ const backendTribute = {
520
+ bead_id: mintBeadId(),
521
+ bead_type: 'TRIBUTE',
522
+ schema_version: SCHEMA_VERSION,
523
+ created_at: new Date().toISOString(),
524
+ citizen_id: backendMail.spawn_hint.citizen_id,
525
+ task_id: backendTaskId,
526
+ originating_mail_ref: backendMail.bead_id,
527
+ archetype: 'software-engineer',
528
+ outpost: 'backend',
529
+ worktree: `worktrees/citizen/${backendTaskId}`,
530
+ attempt: 1,
531
+ status: 'SUCCESS',
532
+ pr_url: 'https://github.com/org/repo/pull/10',
533
+ summary: 'API endpoint /api/users implemented with tests',
534
+ artifacts: ['src/api/users.ts', 'src/api/users.test.ts'],
535
+ acceptance_met: [true],
536
+ elapsed_seconds: 180,
537
+ };
538
+ const tributePath = path.join(backend.path, '.beads', 'tribute', `${backendTaskId}_tribute.jsonl`);
539
+ await appendBead(tributePath, backendTribute);
540
+ // 4. First Officer broadcasts DEPENDENCY_RESOLVED
541
+ const broadcastResult = await broadcastDependencyResolved(multi.citadelRoot, backendTribute);
542
+ // 5. Verify: exactly 1 DEPENDENCY_RESOLVED sent to frontend
543
+ expect(broadcastResult.sent).toBe(1);
544
+ expect(broadcastResult.recipients).toHaveLength(1);
545
+ expect(broadcastResult.recipients[0].outpost).toBe('frontend');
546
+ expect(broadcastResult.recipients[0].taskId).toBe(frontendTaskId);
547
+ // 6. Verify: DEPENDENCY_RESOLVED mail file exists in frontend's .beads/mail/
548
+ const frontendMailDir = path.join(frontend.path, '.beads', 'mail');
549
+ const frontendFiles = await fs.readdir(frontendMailDir);
550
+ const depResFiles = frontendFiles.filter(f => f.startsWith('depres-'));
551
+ expect(depResFiles).toHaveLength(1);
552
+ // 7. Verify: mail contents
553
+ const depResMails = await readBeads(path.join(frontendMailDir, depResFiles[0]));
554
+ expect(depResMails).toHaveLength(1);
555
+ const depRes = depResMails[0];
556
+ expect(depRes.mail_type).toBe('DEPENDENCY_RESOLVED');
557
+ expect(depRes.task_id).toBe(frontendTaskId);
558
+ expect(depRes.to).toBe('frontend');
559
+ expect(depRes.from).toBe('citadel');
560
+ const depPayload = depRes.payload;
561
+ expect(depPayload.resolved_task_id).toBe(backendTaskId);
562
+ expect(depPayload.resolved_by_outpost).toBe('backend');
563
+ expect(depPayload.tribute_ref).toBe(backendTribute.bead_id);
564
+ expect(depPayload.pr_url).toBe('https://github.com/org/repo/pull/10');
565
+ expect(depPayload.summary).toBe('API endpoint /api/users implemented with tests');
566
+ // 8. Verify: no DEPENDENCY_RESOLVED sent to backend (it has no dependencies)
567
+ const backendMailDir = path.join(backend.path, '.beads', 'mail');
568
+ const backendFiles = await fs.readdir(backendMailDir);
569
+ const backendDepRes = backendFiles.filter(f => f.startsWith('depres-'));
570
+ expect(backendDepRes).toHaveLength(0);
571
+ });
572
+ });
573
+ // ---------------------------------------------------------------------------
574
+ // Test 5: Concurrent writes stress test — lockfile correctness
575
+ // ---------------------------------------------------------------------------
576
+ describe('concurrent writes stress test', () => {
577
+ let env;
578
+ beforeEach(async () => {
579
+ env = await setupTempCitadel({ maxCitizens: 10 });
580
+ });
581
+ afterEach(async () => {
582
+ await fs.rm(env.citadelRoot, { recursive: true, force: true });
583
+ });
584
+ it('concurrent appendBead calls to separate files — no data corruption', async () => {
585
+ const mailDir = path.join(env.outpostPath, '.beads', 'mail');
586
+ const count = 10;
587
+ // Fire all appends in parallel — each to a separate file (realistic scenario:
588
+ // multiple dispatches create separate mail files concurrently)
589
+ const promises = Array.from({ length: count }, (_, i) => {
590
+ const filePath = path.join(mailDir, `stress-${i}.jsonl`);
591
+ const bead = {
592
+ bead_id: `bead-stress-${i}`,
593
+ bead_type: 'MAIL',
594
+ schema_version: SCHEMA_VERSION,
595
+ created_at: new Date().toISOString(),
596
+ task_id: `task-stress-${i}`,
597
+ mail_type: 'TASK_ASSIGNMENT',
598
+ from: 'citadel',
599
+ to: env.outpostSlug,
600
+ subject: `Stress test ${i}`,
601
+ read: false,
602
+ retry_of: null,
603
+ payload: {
604
+ archetype: 'software-engineer',
605
+ citizen_id: `citizen-stress-${i}`,
606
+ priority: 'MEDIUM',
607
+ title: `Stress ${i}`,
608
+ description: `Concurrent write ${i}`,
609
+ acceptance_criteria: [],
610
+ context_refs: [],
611
+ dependencies: [],
612
+ timeout_seconds: 1800,
613
+ },
614
+ };
615
+ return appendBead(filePath, bead);
616
+ });
617
+ const results = await Promise.all(promises);
618
+ // All 10 should have resolved
619
+ expect(results).toHaveLength(count);
620
+ // Read back each file and verify no data corruption
621
+ for (let i = 0; i < count; i++) {
622
+ const filePath = path.join(mailDir, `stress-${i}.jsonl`);
623
+ const beads = await readBeads(filePath);
624
+ expect(beads).toHaveLength(1);
625
+ expect(beads[0].bead_id).toBe(`bead-stress-${i}`);
626
+ // Verify valid JSON (no corruption from concurrent writes)
627
+ const raw = await fs.readFile(filePath, 'utf-8');
628
+ const lines = raw.split('\n').filter(l => l.trim() !== '');
629
+ expect(lines).toHaveLength(1);
630
+ expect(() => JSON.parse(lines[0])).not.toThrow();
631
+ }
632
+ // Verify total file count
633
+ const allFiles = (await fs.readdir(mailDir)).filter(f => f.startsWith('stress-'));
634
+ expect(allFiles).toHaveLength(count);
635
+ });
636
+ it('sequential writes to same file maintain data integrity', async () => {
637
+ const filePath = path.join(env.outpostPath, '.beads', 'mail', 'seq-stress.jsonl');
638
+ const count = 8;
639
+ // Sequential writes — each waits for the prior to finish
640
+ for (let i = 0; i < count; i++) {
641
+ const bead = {
642
+ bead_id: `bead-seq-${i}`,
643
+ bead_type: 'MAIL',
644
+ schema_version: SCHEMA_VERSION,
645
+ created_at: new Date().toISOString(),
646
+ task_id: `task-seq-${i}`,
647
+ mail_type: 'TASK_ASSIGNMENT',
648
+ from: 'citadel',
649
+ to: env.outpostSlug,
650
+ subject: `Sequential ${i}`,
651
+ read: false,
652
+ retry_of: null,
653
+ payload: {
654
+ archetype: 'software-engineer',
655
+ citizen_id: `citizen-seq-${i}`,
656
+ priority: 'MEDIUM',
657
+ title: `Seq ${i}`,
658
+ description: `Sequential write ${i}`,
659
+ acceptance_criteria: [],
660
+ context_refs: [],
661
+ dependencies: [],
662
+ timeout_seconds: 1800,
663
+ },
664
+ };
665
+ await appendBead(filePath, bead);
666
+ }
667
+ // Read back
668
+ const beads = await readBeads(filePath);
669
+ expect(beads).toHaveLength(count);
670
+ // Verify order preserved (sequential guarantees order)
671
+ for (let i = 0; i < count; i++) {
672
+ expect(beads[i].bead_id).toBe(`bead-seq-${i}`);
673
+ }
674
+ // Verify no corruption
675
+ const raw = await fs.readFile(filePath, 'utf-8');
676
+ const lines = raw.split('\n').filter(l => l.trim() !== '');
677
+ expect(lines).toHaveLength(count);
678
+ for (const line of lines) {
679
+ expect(() => JSON.parse(line)).not.toThrow();
680
+ }
681
+ });
682
+ });
683
+ // ---------------------------------------------------------------------------
684
+ // Test 6: Headless full cycle — no Commander attached
685
+ // ---------------------------------------------------------------------------
686
+ describe('headless full cycle', () => {
687
+ let env;
688
+ let processRegistry;
689
+ beforeEach(async () => {
690
+ env = await setupTempCitadel({ maxCitizens: 2 });
691
+ processRegistry = new ProcessRegistry();
692
+ // Create commands dir
693
+ await fs.mkdir(path.join(env.citadelRoot, '.citadel', 'commands'), { recursive: true });
694
+ });
695
+ afterEach(async () => {
696
+ await fs.rm(env.citadelRoot, { recursive: true, force: true });
697
+ });
698
+ it('Command Bead -> dispatch -> Tribute -> state reconstruction, all headless', async () => {
699
+ // 1. Write a dispatch Command Bead (simulates CLI / API, no Commander)
700
+ const cmdBead = await writeCommandBead(env.citadelRoot, 'dispatch', {
701
+ outpost: env.outpostSlug,
702
+ task: 'Headless feature implementation',
703
+ });
704
+ expect(cmdBead.bead_type).toBe('COMMAND');
705
+ expect(cmdBead.command).toBe('dispatch');
706
+ expect(cmdBead.processed).toBe(false);
707
+ // 2. FO processes the command bead — dispatches task
708
+ await processCommandBead(env.citadelRoot, cmdBead, processRegistry);
709
+ // 3. Verify: TASK_ASSIGNMENT mail exists in outpost
710
+ const mailDir = path.join(env.outpostPath, '.beads', 'mail');
711
+ const mailFiles = (await fs.readdir(mailDir)).filter(f => f.endsWith('.jsonl'));
712
+ expect(mailFiles.length).toBeGreaterThanOrEqual(1);
713
+ // Read the dispatched mail
714
+ const allMails = [];
715
+ for (const file of mailFiles) {
716
+ const beads = await readBeads(path.join(mailDir, file));
717
+ allMails.push(...beads);
718
+ }
719
+ const taskMail = allMails.find(m => m.mail_type === 'TASK_ASSIGNMENT');
720
+ expect(taskMail).toBeDefined();
721
+ const taskId = taskMail.task_id;
722
+ const citizenId = taskMail.spawn_hint.citizen_id;
723
+ // 4. Simulate citizen work — write SUCCESS tribute
724
+ const tribute = {
725
+ bead_id: mintBeadId(),
726
+ bead_type: 'TRIBUTE',
727
+ schema_version: SCHEMA_VERSION,
728
+ created_at: new Date().toISOString(),
729
+ citizen_id: citizenId,
730
+ task_id: taskId,
731
+ originating_mail_ref: taskMail.bead_id,
732
+ archetype: 'software-engineer',
733
+ outpost: env.outpostSlug,
734
+ worktree: `worktrees/citizen/${taskId}`,
735
+ attempt: 1,
736
+ status: 'SUCCESS',
737
+ summary: 'Feature implemented and tested',
738
+ artifacts: ['src/headless.ts'],
739
+ acceptance_met: [],
740
+ elapsed_seconds: 200,
741
+ };
742
+ const tributePath = path.join(env.outpostPath, '.beads', 'tribute', `${taskId}_tribute.jsonl`);
743
+ await appendBead(tributePath, tribute);
744
+ // 5. Validate tribute against task mail (acceptance_criteria is [] from dispatch)
745
+ const validation = validateTribute(tribute, taskMail);
746
+ expect(validation.valid).toBe(true);
747
+ expect(validation.errors).toHaveLength(0);
748
+ // 6. Reconstruct state — verify task shows as succeeded
749
+ const state = await reconstructState(env.citadelRoot);
750
+ expect(state.tasks.length).toBeGreaterThanOrEqual(1);
751
+ const task = state.tasks.find(t => t.taskId === taskId);
752
+ expect(task).toBeDefined();
753
+ expect(task.state).toBe('succeeded');
754
+ expect(task.outpost).toBe(env.outpostSlug);
755
+ expect(task.tributes).toHaveLength(1);
756
+ expect(task.latestTribute.status).toBe('SUCCESS');
757
+ expect(task.attempt).toBe(1);
758
+ // 7. Verify succeeded list
759
+ expect(state.succeeded.map(t => t.taskId)).toContain(taskId);
760
+ expect(state.inFlight.map(t => t.taskId)).not.toContain(taskId);
761
+ });
762
+ });
763
+ //# sourceMappingURL=integration.test.js.map