@lumenflow/cli 4.24.0 → 5.0.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 (294) hide show
  1. package/README.md +54 -52
  2. package/dist/agent-issues-query.js +10 -2
  3. package/dist/agent-issues-query.js.map +1 -1
  4. package/dist/agent-runtime-enrollment-events.js +44 -0
  5. package/dist/agent-runtime-enrollment-events.js.map +1 -0
  6. package/dist/agent-session-end.js +47 -0
  7. package/dist/agent-session-end.js.map +1 -1
  8. package/dist/agent-session-heartbeat.js +250 -0
  9. package/dist/agent-session-heartbeat.js.map +1 -0
  10. package/dist/agent-session.js +299 -5
  11. package/dist/agent-session.js.map +1 -1
  12. package/dist/capacity-snapshot-emitter.js +73 -0
  13. package/dist/capacity-snapshot-emitter.js.map +1 -0
  14. package/dist/claim-queue.js +276 -0
  15. package/dist/claim-queue.js.map +1 -0
  16. package/dist/config-set.js +22 -3
  17. package/dist/config-set.js.map +1 -1
  18. package/dist/control-plane-sidecar-runner.js +145 -0
  19. package/dist/control-plane-sidecar-runner.js.map +1 -0
  20. package/dist/delegation-list.js +160 -1
  21. package/dist/delegation-list.js.map +1 -1
  22. package/dist/delegation-role-resolver.js +69 -0
  23. package/dist/delegation-role-resolver.js.map +1 -0
  24. package/dist/docs-generate-pack-reference.js +500 -0
  25. package/dist/docs-generate-pack-reference.js.map +1 -0
  26. package/dist/docs-sync.js +116 -1
  27. package/dist/docs-sync.js.map +1 -1
  28. package/dist/file-edit.js +28 -8
  29. package/dist/file-edit.js.map +1 -1
  30. package/dist/file-write.js +29 -5
  31. package/dist/file-write.js.map +1 -1
  32. package/dist/gate-co-change.js +25 -7
  33. package/dist/gate-co-change.js.map +1 -1
  34. package/dist/gate-conditional.js +19 -7
  35. package/dist/gate-conditional.js.map +1 -1
  36. package/dist/gates-runners.js +42 -33
  37. package/dist/gates-runners.js.map +1 -1
  38. package/dist/gates-utils.js +34 -20
  39. package/dist/gates-utils.js.map +1 -1
  40. package/dist/gates.js +79 -7
  41. package/dist/gates.js.map +1 -1
  42. package/dist/hooks/config-resolver.js +10 -1
  43. package/dist/hooks/config-resolver.js.map +1 -1
  44. package/dist/init-package-config.js +1 -1
  45. package/dist/init-package-config.js.map +1 -1
  46. package/dist/init-scaffolding.js +5 -1
  47. package/dist/init-scaffolding.js.map +1 -1
  48. package/dist/init-templates.js +10 -0
  49. package/dist/init-templates.js.map +1 -1
  50. package/dist/init.js +1 -1
  51. package/dist/init.js.map +1 -1
  52. package/dist/initiative-create.js +17 -0
  53. package/dist/initiative-create.js.map +1 -1
  54. package/dist/initiative-remove-wu.js +17 -3
  55. package/dist/initiative-remove-wu.js.map +1 -1
  56. package/dist/kernel-event-sync/emitters.js +104 -0
  57. package/dist/kernel-event-sync/emitters.js.map +1 -0
  58. package/dist/kernel-event-sync/index.js +13 -0
  59. package/dist/kernel-event-sync/index.js.map +1 -0
  60. package/dist/kernel-event-sync/lifecycle-emitters.js +160 -0
  61. package/dist/kernel-event-sync/lifecycle-emitters.js.map +1 -0
  62. package/dist/kernel-event-sync/narrow-emissions.js +89 -0
  63. package/dist/kernel-event-sync/narrow-emissions.js.map +1 -0
  64. package/dist/kernel-event-sync/software-delivery-emitters.js +297 -0
  65. package/dist/kernel-event-sync/software-delivery-emitters.js.map +1 -0
  66. package/dist/lane-lock.js +14 -1
  67. package/dist/lane-lock.js.map +1 -1
  68. package/dist/lane-suggest.js +21 -0
  69. package/dist/lane-suggest.js.map +1 -1
  70. package/dist/lumenflow-upgrade.js +7 -5
  71. package/dist/lumenflow-upgrade.js.map +1 -1
  72. package/dist/mem-context.js +145 -0
  73. package/dist/mem-context.js.map +1 -1
  74. package/dist/mem-create.js +39 -6
  75. package/dist/mem-create.js.map +1 -1
  76. package/dist/mem-inbox.js +16 -0
  77. package/dist/mem-inbox.js.map +1 -1
  78. package/dist/mem-roster.js +95 -0
  79. package/dist/mem-roster.js.map +1 -0
  80. package/dist/mem-signal.js +97 -2
  81. package/dist/mem-signal.js.map +1 -1
  82. package/dist/metrics-snapshot.js +3 -2
  83. package/dist/metrics-snapshot.js.map +1 -1
  84. package/dist/orchestrate-init-status.js +117 -2
  85. package/dist/orchestrate-init-status.js.map +1 -1
  86. package/dist/orchestrate-initiative.js +83 -10
  87. package/dist/orchestrate-initiative.js.map +1 -1
  88. package/dist/orchestrate-monitor-quality.js +289 -0
  89. package/dist/orchestrate-monitor-quality.js.map +1 -0
  90. package/dist/orchestrate-monitor.js +85 -0
  91. package/dist/orchestrate-monitor.js.map +1 -1
  92. package/dist/pack-validate.js +127 -2
  93. package/dist/pack-validate.js.map +1 -1
  94. package/dist/plan-create.js +18 -0
  95. package/dist/plan-create.js.map +1 -1
  96. package/dist/plan-link.js +13 -0
  97. package/dist/plan-link.js.map +1 -1
  98. package/dist/plan-promote.js +14 -0
  99. package/dist/plan-promote.js.map +1 -1
  100. package/dist/pre-commit-check.js +4 -3
  101. package/dist/pre-commit-check.js.map +1 -1
  102. package/dist/public-manifest.js +17 -3
  103. package/dist/public-manifest.js.map +1 -1
  104. package/dist/release.js +10 -10
  105. package/dist/release.js.map +1 -1
  106. package/dist/session-cross-link.js +139 -0
  107. package/dist/session-cross-link.js.map +1 -0
  108. package/dist/sidecar-manager.js +208 -0
  109. package/dist/sidecar-manager.js.map +1 -0
  110. package/dist/state-path-resolvers.js +18 -0
  111. package/dist/state-path-resolvers.js.map +1 -1
  112. package/dist/stream-heartbeat.js +151 -0
  113. package/dist/stream-heartbeat.js.map +1 -0
  114. package/dist/sync-templates.js +56 -2
  115. package/dist/sync-templates.js.map +1 -1
  116. package/dist/wu-block.js +47 -5
  117. package/dist/wu-block.js.map +1 -1
  118. package/dist/wu-claim-branch.js +8 -4
  119. package/dist/wu-claim-branch.js.map +1 -1
  120. package/dist/wu-claim-state.js +5 -3
  121. package/dist/wu-claim-state.js.map +1 -1
  122. package/dist/wu-claim-worktree.js +5 -3
  123. package/dist/wu-claim-worktree.js.map +1 -1
  124. package/dist/wu-claim.js +261 -9
  125. package/dist/wu-claim.js.map +1 -1
  126. package/dist/wu-done-auto-cleanup.js +3 -2
  127. package/dist/wu-done-auto-cleanup.js.map +1 -1
  128. package/dist/wu-done-git-ops.js +12 -8
  129. package/dist/wu-done-git-ops.js.map +1 -1
  130. package/dist/wu-done-preflight.js +3 -3
  131. package/dist/wu-done-preflight.js.map +1 -1
  132. package/dist/wu-done.js +46 -10
  133. package/dist/wu-done.js.map +1 -1
  134. package/dist/wu-lifecycle-sync/gate-scope-resolver.js +16 -0
  135. package/dist/wu-lifecycle-sync/gate-scope-resolver.js.map +1 -0
  136. package/dist/wu-lifecycle-sync/kernel-event-sync-shim.js +10 -0
  137. package/dist/wu-lifecycle-sync/kernel-event-sync-shim.js.map +1 -0
  138. package/dist/wu-prep.js +363 -22
  139. package/dist/wu-prep.js.map +1 -1
  140. package/dist/wu-prune.js +68 -27
  141. package/dist/wu-prune.js.map +1 -1
  142. package/dist/wu-release.js +34 -3
  143. package/dist/wu-release.js.map +1 -1
  144. package/dist/wu-review.js +167 -0
  145. package/dist/wu-review.js.map +1 -0
  146. package/dist/wu-spawn-prompt-builders.js +296 -40
  147. package/dist/wu-spawn-prompt-builders.js.map +1 -1
  148. package/dist/wu-spawn-strategy-resolver.js +126 -14
  149. package/dist/wu-spawn-strategy-resolver.js.map +1 -1
  150. package/dist/wu-unblock.js +52 -22
  151. package/dist/wu-unblock.js.map +1 -1
  152. package/package.json +13 -8
  153. package/packs/agent-runtime/.turbo/turbo-build.log +1 -1
  154. package/packs/agent-runtime/.turbo/turbo-test.log +25 -0
  155. package/packs/agent-runtime/.turbo/turbo-typecheck.log +4 -0
  156. package/packs/agent-runtime/agent-heartbeat.ts +163 -0
  157. package/packs/agent-runtime/auto-session-integration.ts +874 -0
  158. package/packs/agent-runtime/delegation-registry-schema.ts +220 -0
  159. package/packs/agent-runtime/delegation-registry-store.ts +269 -0
  160. package/packs/agent-runtime/delegation-tree.ts +328 -0
  161. package/packs/agent-runtime/index.ts +9 -0
  162. package/packs/agent-runtime/manifest.ts +103 -19
  163. package/packs/agent-runtime/manifest.yaml +132 -0
  164. package/packs/agent-runtime/memory-coordination-contract.ts +86 -0
  165. package/packs/agent-runtime/memory.d.ts +19 -0
  166. package/packs/agent-runtime/orchestration.ts +238 -23
  167. package/packs/agent-runtime/package.json +11 -2
  168. package/packs/agent-runtime/remote-controls/index.ts +7 -0
  169. package/packs/agent-runtime/remote-controls/operations.ts +399 -0
  170. package/packs/agent-runtime/remote-controls/port.ts +48 -0
  171. package/packs/agent-runtime/remote-controls/state-store.ts +258 -0
  172. package/packs/agent-runtime/remote-controls/types.ts +105 -0
  173. package/packs/agent-runtime/session-schema.ts +423 -0
  174. package/packs/agent-runtime/tool-impl/index.ts +1 -0
  175. package/packs/agent-runtime/tool-impl/remote-controls.mock.ts +252 -0
  176. package/packs/agent-runtime/tool-impl/remote-controls.ts +273 -0
  177. package/packs/agent-runtime/tsconfig.json +1 -1
  178. package/packs/agent-runtime/turn-lifecycle-events.ts +501 -0
  179. package/packs/sidekick/.lumenflow/state/conductor/outbox/sidekick-events.jsonl +213 -0
  180. package/packs/sidekick/.turbo/turbo-build.log +1 -1
  181. package/packs/sidekick/.turbo/turbo-test.log +25 -0
  182. package/packs/sidekick/.turbo/turbo-typecheck.log +4 -0
  183. package/packs/sidekick/channel-ingress.ts +137 -0
  184. package/packs/sidekick/manifest.ts +74 -0
  185. package/packs/sidekick/manifest.yaml +88 -0
  186. package/packs/sidekick/package.json +3 -1
  187. package/packs/sidekick/sidekick-events.ts +517 -0
  188. package/packs/sidekick/src/adapters/cloud-queue.ts +101 -0
  189. package/packs/sidekick/src/adapters/control-plane-bridge.adapter.ts +378 -0
  190. package/packs/sidekick/src/adapters/filesystem-bridge.adapter.ts +224 -0
  191. package/packs/sidekick/src/domain/channel.types.ts +84 -0
  192. package/packs/sidekick/src/ports/channel-bridge.port.ts +75 -0
  193. package/packs/sidekick/src/routines/commit.ts +74 -0
  194. package/packs/sidekick/tool-impl/channel-tools.ts +47 -0
  195. package/packs/sidekick/tool-impl/memory-tools.ts +17 -0
  196. package/packs/sidekick/tool-impl/routine-commit.ts +102 -0
  197. package/packs/sidekick/tool-impl/routine-tools.ts +67 -7
  198. package/packs/sidekick/tool-impl/runtime-context.ts +4 -0
  199. package/packs/sidekick/tool-impl/storage.ts +3 -0
  200. package/packs/sidekick/tool-impl/system-tools.ts +7 -0
  201. package/packs/sidekick/tool-impl/task-tools.ts +46 -0
  202. package/packs/sidekick/tsconfig.json +1 -1
  203. package/packs/software-delivery/.turbo/turbo-build.log +1 -1
  204. package/packs/software-delivery/.turbo/turbo-test.log +63 -0
  205. package/packs/software-delivery/.turbo/turbo-typecheck.log +4 -0
  206. package/packs/software-delivery/manifest-schema.ts +30 -0
  207. package/packs/software-delivery/manifest.ts +99 -1
  208. package/packs/software-delivery/manifest.yaml +46 -0
  209. package/packs/software-delivery/package.json +88 -3
  210. package/packs/software-delivery/src/commands/index.ts +5 -0
  211. package/packs/software-delivery/src/config/delivery-review-contract.ts +20 -0
  212. package/packs/software-delivery/src/config/env-accessors.ts +19 -0
  213. package/packs/software-delivery/src/config/index.ts +8 -0
  214. package/packs/software-delivery/src/config/normalize-config-keys.ts +19 -0
  215. package/packs/software-delivery/src/config/schemas/lumenflow-config-schema-types.ts +436 -0
  216. package/packs/software-delivery/src/config/workspace-reader.ts +310 -0
  217. package/packs/software-delivery/src/constants/backlog-patterns.ts +31 -0
  218. package/packs/software-delivery/src/constants/client-ids.ts +19 -0
  219. package/packs/software-delivery/src/constants/config-contract.ts +7 -0
  220. package/packs/software-delivery/src/constants/docs-layout-presets.ts +50 -0
  221. package/packs/software-delivery/src/constants/duration-constants.ts +20 -0
  222. package/packs/software-delivery/src/constants/gate-constants.ts +32 -0
  223. package/packs/software-delivery/src/constants/index.ts +29 -0
  224. package/packs/software-delivery/src/constants/lock-constants.ts +35 -0
  225. package/packs/software-delivery/src/constants/object-guards.ts +12 -0
  226. package/packs/software-delivery/src/constants/section-headings.ts +107 -0
  227. package/packs/software-delivery/src/constants/wu-cli-constants.ts +485 -0
  228. package/packs/software-delivery/src/constants/wu-domain-constants.ts +466 -0
  229. package/packs/software-delivery/src/constants/wu-git-constants.ts +7 -0
  230. package/packs/software-delivery/src/constants/wu-id-format.ts +327 -0
  231. package/packs/software-delivery/src/constants/wu-paths-constants.ts +358 -0
  232. package/packs/software-delivery/src/constants/wu-statuses.ts +287 -0
  233. package/packs/software-delivery/src/constants/wu-type-helpers.ts +67 -0
  234. package/packs/software-delivery/src/constants/wu-ui-constants.ts +267 -0
  235. package/packs/software-delivery/src/constants/wu-validation-constants.ts +73 -0
  236. package/packs/software-delivery/src/domain/index.ts +5 -0
  237. package/packs/software-delivery/src/domain/orchestration.constants.ts +168 -0
  238. package/packs/software-delivery/src/domain/orchestration.schemas.ts +239 -0
  239. package/packs/software-delivery/src/domain/orchestration.types.ts +178 -0
  240. package/packs/software-delivery/src/methodology/incremental-test.ts +90 -0
  241. package/packs/software-delivery/src/methodology/index.ts +6 -0
  242. package/packs/software-delivery/src/methodology/manual-test-validator.ts +292 -0
  243. package/packs/software-delivery/src/policy/coverage-gate.ts +270 -0
  244. package/packs/software-delivery/src/policy/gates-agent-mode.ts +223 -0
  245. package/packs/software-delivery/src/policy/gates-config-internal.ts +121 -0
  246. package/packs/software-delivery/src/policy/gates-config.ts +293 -0
  247. package/packs/software-delivery/src/policy/gates-coverage.ts +247 -0
  248. package/packs/software-delivery/src/policy/gates-presets.ts +134 -0
  249. package/packs/software-delivery/src/policy/gates-schemas.ts +173 -0
  250. package/packs/software-delivery/src/policy/index.ts +22 -0
  251. package/packs/software-delivery/src/policy/package-manager-resolver.ts +319 -0
  252. package/packs/software-delivery/src/policy/resolve-policy.ts +518 -0
  253. package/packs/software-delivery/src/ports/config.ports.ts +90 -0
  254. package/packs/software-delivery/src/ports/dashboard-renderer.port.ts +125 -0
  255. package/packs/software-delivery/src/ports/index.ts +10 -0
  256. package/packs/software-delivery/src/ports/sync-validator.ports.ts +59 -0
  257. package/packs/software-delivery/src/ports/wu-helpers.ports.ts +168 -0
  258. package/packs/software-delivery/src/ports/wu-state.ports.ts +241 -0
  259. package/packs/software-delivery/src/primitives/index.ts +5 -0
  260. package/packs/software-delivery/src/runtime/index.ts +6 -0
  261. package/packs/software-delivery/src/runtime/work-classifier.ts +561 -0
  262. package/packs/software-delivery/src/sandbox/index.ts +10 -0
  263. package/packs/software-delivery/src/sandbox/sandbox-allowlist.ts +118 -0
  264. package/packs/software-delivery/src/sandbox/sandbox-backend-linux.ts +88 -0
  265. package/packs/software-delivery/src/sandbox/sandbox-backend-macos.ts +154 -0
  266. package/packs/software-delivery/src/sandbox/sandbox-backend-windows.ts +47 -0
  267. package/packs/software-delivery/src/sandbox/sandbox-profile.ts +153 -0
  268. package/packs/software-delivery/src/schemas/index.ts +5 -0
  269. package/packs/software-delivery/src/state/date-utils.ts +158 -0
  270. package/packs/software-delivery/src/state/index.ts +15 -0
  271. package/packs/software-delivery/src/state/state-machine.ts +119 -0
  272. package/packs/software-delivery/src/state/wu-doc-types.ts +51 -0
  273. package/packs/software-delivery/src/state/wu-paths.ts +381 -0
  274. package/packs/software-delivery/src/state/wu-schema.ts +1139 -0
  275. package/packs/software-delivery/src/state/wu-state-schema.ts +255 -0
  276. package/packs/software-delivery/src/state/wu-yaml.ts +338 -0
  277. package/packs/software-delivery/src/types.d.ts +16 -0
  278. package/packs/software-delivery/tool-impl/wu-lifecycle-tools.ts +18 -0
  279. package/packs/software-delivery/tsconfig.json +28 -2
  280. package/templates/core/AGENTS.md.template +76 -17
  281. package/templates/core/LUMENFLOW.md.template +265 -66
  282. package/templates/core/_frameworks/lumenflow/wu-sizing-guide.md.template +180 -116
  283. package/templates/core/ai/onboarding/agent-invocation-guide.md.template +26 -8
  284. package/templates/core/ai/onboarding/existing-project-bootstrap.md.template +171 -0
  285. package/templates/core/ai/onboarding/first-15-mins.md.template +3 -1
  286. package/templates/core/ai/onboarding/first-wu-mistakes.md.template +1 -1
  287. package/templates/core/ai/onboarding/initiative-orchestration.md.template +46 -30
  288. package/templates/core/ai/onboarding/quick-ref-commands.md.template +36 -33
  289. package/templates/core/ai/onboarding/release-process.md.template +8 -7
  290. package/templates/core/ai/onboarding/starting-prompt.md.template +2 -0
  291. package/templates/core/ai/onboarding/troubleshooting-wu-done.md.template +62 -0
  292. package/templates/vendors/claude/.claude/CLAUDE.md.template +29 -54
  293. package/templates/vendors/cursor/.cursor/rules/lumenflow.md.template +24 -52
  294. package/templates/vendors/windsurf/.windsurf/rules/lumenflow.md.template +24 -52
@@ -0,0 +1,378 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ /**
5
+ * WU-2737 (INIT-060 WU-7b, ADR-013 §ChannelBridge):
6
+ * Control-plane ChannelBridge adapter.
7
+ *
8
+ * Cloud-backed implementation of the ChannelBridge port. Codes directly
9
+ * against the contract published in
10
+ * `docs/operations/coordination/channel-bridge-cloud-stub.md` (WU-2736):
11
+ *
12
+ * POST /api/v1/workspaces/{workspace_id}/sidekick/channel
13
+ *
14
+ * Responsibilities:
15
+ * - Outbound `send()` POSTs each envelope with `Bearer <enrollment_token>`.
16
+ * - At-least-once delivery scoped by `(channel_id, envelope.id)` dedup key;
17
+ * safe to replay on transport failure (cloud returns `deduped: true`).
18
+ * - Backpressure split (ADR-013 §4):
19
+ * - `kind: "ephemeral"` fails silently on 5xx / 429 / network errors
20
+ * (no outbox, no retry, no elevated logging).
21
+ * - `kind: "queue"` parks in the local outbox on any non-2xx and drains
22
+ * FIFO on reconnect / explicit `flush()` / `disconnect()`.
23
+ * - Inbound `receive()` drains a loopback-friendly poll endpoint. The stub
24
+ * contract (WU-2736) does not yet freeze the inbound wire shape; the
25
+ * adapter exposes `pollIntervalMs` + a minimal GET fetcher so a mock
26
+ * server or future coord-stub-signed inbound path can plug in without
27
+ * changing the port.
28
+ * - `register()` is idempotent on BridgeConfig identity (deterministic id
29
+ * from provider/name/options hash — matches the filesystem adapter pattern).
30
+ * - `disconnect()` flushes queued envelopes before resolving; subsequent
31
+ * `send()` on the same channel id rejects (port contract).
32
+ */
33
+
34
+ import { createHash } from 'node:crypto';
35
+ import { setTimeout as delay } from 'node:timers/promises';
36
+
37
+ import type {
38
+ BridgeConfig,
39
+ ChannelEnvelope,
40
+ ChannelId,
41
+ SendResult,
42
+ } from '../domain/channel.types.js';
43
+ import type { ChannelBridge } from '../ports/channel-bridge.port.js';
44
+
45
+ import { CloudOutbox } from './cloud-queue.js';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Configuration surface
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface ControlPlaneChannelBridgeOptions {
52
+ /** Cloud API base URL, e.g. `https://app.lumenflow.dev` (no trailing slash). */
53
+ baseUrl: string;
54
+ /** Workspace id — path parameter on the POST route. */
55
+ workspaceId: string;
56
+ /**
57
+ * Returns the current enrollment token. Called on every request so token
58
+ * rotation (re-auth) is transparent to callers.
59
+ */
60
+ tokenProvider: () => Promise<string>;
61
+ /** Local outbox root for queue-kind envelopes. */
62
+ outboxDir: string;
63
+ /** Polling interval for `receive()`. Defaults to 1000ms. */
64
+ pollIntervalMs?: number;
65
+ /**
66
+ * Optional override for `fetch`. Primarily a test seam; production wiring
67
+ * uses the platform global `fetch`.
68
+ */
69
+ fetchImpl?: typeof fetch;
70
+ }
71
+
72
+ /**
73
+ * `ChannelBridge` extended with `flush()` so operators (and the disconnect
74
+ * path) can explicitly drain the outbox. The port contract itself does NOT
75
+ * require `flush`; it's an adapter affordance.
76
+ */
77
+ export interface ControlPlaneChannelBridge extends ChannelBridge {
78
+ flush(): Promise<void>;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Internal helpers
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const DEFAULT_POLL_INTERVAL_MS = 1000;
86
+ const QUEUED_REASON = 'queued_for_replay';
87
+ const DROPPED_EPHEMERAL_REASON = 'dropped_ephemeral';
88
+ const INVALID_ENVELOPE_REASON = 'invalid_envelope';
89
+ const DISCONNECTED_REASON = 'disconnected';
90
+
91
+ interface CloudSuccessBody {
92
+ accepted?: boolean;
93
+ delivery_id?: string;
94
+ deduped?: boolean;
95
+ trace_id?: string;
96
+ reason?: string;
97
+ }
98
+
99
+ interface CloudErrorBody {
100
+ error?: {
101
+ code?: string;
102
+ message?: string;
103
+ retryable?: boolean;
104
+ trace_id?: string;
105
+ };
106
+ }
107
+
108
+ function hashOptions(options: Record<string, unknown> | undefined): string {
109
+ const payload = JSON.stringify(options ?? {}, Object.keys(options ?? {}).sort());
110
+ return createHash('sha256').update(payload).digest('hex').slice(0, 16);
111
+ }
112
+
113
+ function mintChannelId(config: BridgeConfig): ChannelId {
114
+ const payload = `${config.provider}::${config.name}::${hashOptions(
115
+ config.options as Record<string, unknown> | undefined,
116
+ )}`;
117
+ const digest = createHash('sha256').update(payload).digest('hex').slice(0, 24);
118
+ return `chan-${digest}` as ChannelId;
119
+ }
120
+
121
+ function safeParseJson<T>(text: string): T | null {
122
+ try {
123
+ return JSON.parse(text) as T;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Adapter factory
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export function createControlPlaneChannelBridge(
134
+ options: ControlPlaneChannelBridgeOptions,
135
+ ): ControlPlaneChannelBridge {
136
+ const baseUrl = options.baseUrl.replace(/\/+$/, '');
137
+ const workspaceId = options.workspaceId;
138
+ const tokenProvider = options.tokenProvider;
139
+ const outbox = new CloudOutbox(options.outboxDir);
140
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
141
+ const fetchImpl = options.fetchImpl ?? fetch;
142
+
143
+ const registry = new Map<string, ChannelId>();
144
+ const disconnected = new Set<ChannelId>();
145
+
146
+ function postUrl(): string {
147
+ return `${baseUrl}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/sidekick/channel`;
148
+ }
149
+
150
+ function inboundUrl(channelId: ChannelId): string {
151
+ return `${postUrl()}?channel_id=${encodeURIComponent(channelId)}`;
152
+ }
153
+
154
+ async function authHeaders(): Promise<Record<string, string>> {
155
+ const token = await tokenProvider();
156
+ return {
157
+ 'Content-Type': 'application/json',
158
+ Authorization: `Bearer ${token}`,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * One attempt at a single POST. Returns a discriminated result so the
164
+ * caller decides what to do with retryable / non-retryable outcomes.
165
+ */
166
+ type PostOutcome =
167
+ | { kind: 'accepted'; body: CloudSuccessBody }
168
+ | {
169
+ kind: 'retryable';
170
+ status: number;
171
+ error: CloudErrorBody['error'];
172
+ retryAfterSeconds?: number;
173
+ }
174
+ | { kind: 'non_retryable'; status: number; error: CloudErrorBody['error'] }
175
+ | { kind: 'network_error'; cause: unknown };
176
+
177
+ async function postOnce(channelId: ChannelId, envelope: ChannelEnvelope): Promise<PostOutcome> {
178
+ let response: Response;
179
+ try {
180
+ response = await fetchImpl(postUrl(), {
181
+ method: 'POST',
182
+ headers: await authHeaders(),
183
+ body: JSON.stringify({ channel_id: channelId, envelope }),
184
+ });
185
+ } catch (error) {
186
+ return { kind: 'network_error', cause: error };
187
+ }
188
+
189
+ const text = await response.text();
190
+ if (response.ok) {
191
+ const body = safeParseJson<CloudSuccessBody>(text) ?? {};
192
+ return { kind: 'accepted', body };
193
+ }
194
+
195
+ const errorBody = safeParseJson<CloudErrorBody>(text) ?? {};
196
+ const retryAfterHeader = response.headers.get('retry-after');
197
+ const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined;
198
+
199
+ // 4xx non-retryable per contract §Error Response Shape — everything
200
+ // other than 429 is a correctness bug / auth failure we don't retry.
201
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
202
+ return { kind: 'non_retryable', status: response.status, error: errorBody.error };
203
+ }
204
+
205
+ return {
206
+ kind: 'retryable',
207
+ status: response.status,
208
+ error: errorBody.error,
209
+ retryAfterSeconds: Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
210
+ };
211
+ }
212
+
213
+ function nonRetryableReason(error: CloudErrorBody['error']): string {
214
+ // Contract codes map 1:1 onto SendResult.reason for observability. Unknown
215
+ // codes collapse into a stable catch-all so audit logs stay readable.
216
+ const code = error?.code;
217
+ if (code === 'invalid_envelope') return INVALID_ENVELOPE_REASON;
218
+ if (code) return code;
219
+ return 'rejected_non_retryable';
220
+ }
221
+
222
+ async function drainOutbox(): Promise<void> {
223
+ const pending = await outbox.list();
224
+ for (const record of pending) {
225
+ // Cast: outbox entries are keyed by the same branded ids we minted.
226
+ const cid = record.channel_id as ChannelId;
227
+ const outcome = await postOnce(cid, record.envelope);
228
+ if (outcome.kind === 'accepted') {
229
+ await outbox.remove(record._filename);
230
+ continue;
231
+ }
232
+ if (outcome.kind === 'non_retryable') {
233
+ // Correctness bug on a parked envelope — drop it so the outbox does
234
+ // not grow unbounded on permanent errors (matches contract table:
235
+ // `invalid_envelope` / `workspace_mismatch` are log-and-drop).
236
+ await outbox.remove(record._filename);
237
+ continue;
238
+ }
239
+ // Retryable / network error — stop the drain; caller retries later.
240
+ return;
241
+ }
242
+ }
243
+
244
+ async function sendInternal(
245
+ channelId: ChannelId,
246
+ envelope: ChannelEnvelope,
247
+ ): Promise<SendResult> {
248
+ // Opportunistic drain: a successful path clears any previous parked
249
+ // envelopes before the new one POSTs, preserving FIFO order under the
250
+ // "replay-on-reconnect" rule (§4).
251
+ if (envelope.kind === 'queue') {
252
+ await drainOutbox();
253
+ }
254
+
255
+ const outcome = await postOnce(channelId, envelope);
256
+
257
+ if (outcome.kind === 'accepted') {
258
+ const body = outcome.body;
259
+ const result: SendResult = {
260
+ accepted: body.accepted ?? true,
261
+ };
262
+ if (body.delivery_id !== undefined) {
263
+ result.delivery_id = body.delivery_id;
264
+ }
265
+ if (body.deduped === true) {
266
+ result.deduped = true;
267
+ }
268
+ if (body.reason !== undefined) {
269
+ result.reason = body.reason;
270
+ }
271
+ return result;
272
+ }
273
+
274
+ if (outcome.kind === 'non_retryable') {
275
+ // Both kinds: log-and-drop per §Error Response Shape.
276
+ return {
277
+ accepted: false,
278
+ reason: nonRetryableReason(outcome.error),
279
+ };
280
+ }
281
+
282
+ // Retryable (5xx / 429) OR network error: split by envelope.kind.
283
+ if (envelope.kind === 'ephemeral') {
284
+ // Fail-silent per §4; no outbox, no throw.
285
+ return { accepted: false, reason: DROPPED_EPHEMERAL_REASON };
286
+ }
287
+
288
+ // queue: park for replay.
289
+ await outbox.enqueue(channelId, envelope);
290
+ return { accepted: false, reason: QUEUED_REASON };
291
+ }
292
+
293
+ // -------------------------------------------------------------------------
294
+ // Port methods
295
+ // -------------------------------------------------------------------------
296
+
297
+ return {
298
+ async register(bridgeConfig: BridgeConfig): Promise<ChannelId> {
299
+ const key = `${bridgeConfig.provider}::${bridgeConfig.name}::${hashOptions(
300
+ bridgeConfig.options as Record<string, unknown> | undefined,
301
+ )}`;
302
+ const existing = registry.get(key);
303
+ if (existing) {
304
+ return existing;
305
+ }
306
+ const id = mintChannelId(bridgeConfig);
307
+ registry.set(key, id);
308
+ return id;
309
+ },
310
+
311
+ async send(channelId: ChannelId, envelope: ChannelEnvelope): Promise<SendResult> {
312
+ if (disconnected.has(channelId)) {
313
+ throw new Error(
314
+ `ControlPlaneChannelBridge: channel ${channelId} is disconnected; send rejected (reason=${DISCONNECTED_REASON}).`,
315
+ );
316
+ }
317
+ return sendInternal(channelId, envelope);
318
+ },
319
+
320
+ async *receive(channelId: ChannelId): AsyncIterable<ChannelEnvelope> {
321
+ // Poll-based inbound. One pass drains whatever is currently queued on
322
+ // the cloud side; the loop continues until `disconnect()` flips the
323
+ // flag so the iterator terminates without hanging (port contract).
324
+ while (!disconnected.has(channelId)) {
325
+ let response: Response;
326
+ try {
327
+ response = await fetchImpl(inboundUrl(channelId), {
328
+ method: 'GET',
329
+ headers: await authHeaders(),
330
+ });
331
+ } catch {
332
+ // Transient network error — back off and retry.
333
+ await delay(pollIntervalMs);
334
+ continue;
335
+ }
336
+
337
+ if (!response.ok) {
338
+ // 4xx/5xx on poll: back off. Errors here do NOT terminate the
339
+ // iterator; the port contract treats the iterator as long-lived
340
+ // until `disconnect()`.
341
+ await delay(pollIntervalMs);
342
+ continue;
343
+ }
344
+
345
+ const text = await response.text();
346
+ const body = safeParseJson<{ envelopes?: ChannelEnvelope[] }>(text) ?? {};
347
+ const envelopes = body.envelopes ?? [];
348
+
349
+ for (const env of envelopes) {
350
+ if (disconnected.has(channelId)) {
351
+ return;
352
+ }
353
+ yield env;
354
+ }
355
+
356
+ if (envelopes.length === 0) {
357
+ await delay(pollIntervalMs);
358
+ }
359
+ }
360
+ },
361
+
362
+ async disconnect(channelId: ChannelId): Promise<void> {
363
+ // Flush outbox before closing so queued envelopes for any channel have
364
+ // a chance to drain (port contract: "queued envelopes flush before
365
+ // this resolves").
366
+ try {
367
+ await drainOutbox();
368
+ } catch {
369
+ // Drain best-effort; disconnect must always resolve.
370
+ }
371
+ disconnected.add(channelId);
372
+ },
373
+
374
+ async flush(): Promise<void> {
375
+ await drainOutbox();
376
+ },
377
+ };
378
+ }
@@ -0,0 +1,224 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ /**
5
+ * WU-2735 (INIT-060 WU-7a, ADR-013 §ChannelBridge):
6
+ * Filesystem implementation of the ChannelBridge port.
7
+ *
8
+ * Backing layout:
9
+ *
10
+ * <rootDir>/channels/<channel-id>/envelopes.jsonl
11
+ * <rootDir>/channels/<channel-id>/config.json
12
+ * <rootDir>/registry.json (BridgeConfig identity → ChannelId)
13
+ *
14
+ * Writes are atomic: a new envelope is written to a per-pid tmp file and
15
+ * `rename(2)`'d on top of a sibling, then append to the JSONL via
16
+ * `appendFile` with `O_APPEND` semantics (Node delegates to the kernel, which
17
+ * guarantees single-write atomicity for writes below PIPE_BUF; our JSON lines
18
+ * sit well below that for realistic envelopes and we additionally bound the
19
+ * write under a per-channel in-process mutex).
20
+ *
21
+ * The adapter is deliberately minimal: the sidekick pack's transport registry
22
+ * (`channel-transports.ts`) remains the runtime dispatch path for the existing
23
+ * `channel:send` tool; WU-2735 lands the port/adapter skeleton only.
24
+ */
25
+
26
+ import { createHash } from 'node:crypto';
27
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
28
+ import path from 'node:path';
29
+
30
+ import type {
31
+ BridgeConfig,
32
+ ChannelEnvelope,
33
+ ChannelId,
34
+ SendResult,
35
+ } from '../domain/channel.types.js';
36
+ import type { ChannelBridge } from '../ports/channel-bridge.port.js';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Configuration surface
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export interface FilesystemChannelBridgeOptions {
43
+ /**
44
+ * Directory under which the bridge materialises channel state. Tests set
45
+ * this to a tmpdir; in production the caller passes
46
+ * `.lumenflow/state/packs/sidekick/` (or equivalent per ADR-013).
47
+ */
48
+ rootDir: string;
49
+ }
50
+
51
+ interface RegistryEntry {
52
+ id: ChannelId;
53
+ provider: string;
54
+ name: string;
55
+ options_hash: string;
56
+ }
57
+
58
+ interface Registry {
59
+ entries: RegistryEntry[];
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Internal helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const REGISTRY_FILENAME = 'registry.json';
67
+ const CHANNELS_SUBDIR = 'channels';
68
+ const ENVELOPES_FILENAME = 'envelopes.jsonl';
69
+ const CONFIG_FILENAME = 'config.json';
70
+
71
+ function hashOptions(options: Record<string, unknown> | undefined): string {
72
+ const payload = JSON.stringify(options ?? {}, Object.keys(options ?? {}).sort());
73
+ return createHash('sha256').update(payload).digest('hex').slice(0, 16);
74
+ }
75
+
76
+ function mintChannelId(config: BridgeConfig): ChannelId {
77
+ // Deterministic id per (provider, name, options) — stable across bridge
78
+ // instances backed by the same rootDir so `register` is idempotent even on
79
+ // cold start without reading the registry.
80
+ const payload = `${config.provider}::${config.name}::${hashOptions(config.options as Record<string, unknown> | undefined)}`;
81
+ const digest = createHash('sha256').update(payload).digest('hex').slice(0, 24);
82
+ return `chan-${digest}` as ChannelId;
83
+ }
84
+
85
+ async function readRegistry(registryPath: string): Promise<Registry> {
86
+ try {
87
+ const raw = await readFile(registryPath, 'utf8');
88
+ return JSON.parse(raw) as Registry;
89
+ } catch (error) {
90
+ const err = error as NodeJS.ErrnoException;
91
+ if (err.code === 'ENOENT') {
92
+ return { entries: [] };
93
+ }
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ async function writeRegistryAtomic(registryPath: string, registry: Registry): Promise<void> {
99
+ await mkdir(path.dirname(registryPath), { recursive: true });
100
+ const tmpPath = `${registryPath}.tmp-${process.pid}-${Date.now()}`;
101
+ await writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf8');
102
+ const { rename } = await import('node:fs/promises');
103
+ await rename(tmpPath, registryPath);
104
+ }
105
+
106
+ // Per-bridge-instance lock map so concurrent send() calls on a single channel
107
+ // serialise their appends. Filesystem atomicity still holds under crash, but
108
+ // the mutex ensures emit order is preserved (ADR-013 §3).
109
+ function createLockGate(): {
110
+ withLock<T>(key: string, fn: () => Promise<T>): Promise<T>;
111
+ } {
112
+ const chains = new Map<string, Promise<unknown>>();
113
+ return {
114
+ async withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
115
+ const previous = chains.get(key) ?? Promise.resolve();
116
+ const next = previous.then(fn, fn);
117
+ chains.set(
118
+ key,
119
+ next.then(
120
+ () => undefined,
121
+ () => undefined,
122
+ ),
123
+ );
124
+ return next as Promise<T>;
125
+ },
126
+ };
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Adapter factory
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export function createFilesystemChannelBridge(
134
+ options: FilesystemChannelBridgeOptions,
135
+ ): ChannelBridge {
136
+ const rootDir = path.resolve(options.rootDir);
137
+ const registryPath = path.join(rootDir, REGISTRY_FILENAME);
138
+ const lockGate = createLockGate();
139
+ const disconnected = new Set<ChannelId>();
140
+
141
+ function channelDir(id: ChannelId): string {
142
+ return path.join(rootDir, CHANNELS_SUBDIR, id);
143
+ }
144
+
145
+ async function ensureRegistered(config: BridgeConfig): Promise<ChannelId> {
146
+ const registry = await readRegistry(registryPath);
147
+ const id = mintChannelId(config);
148
+ const optionsHash = hashOptions(config.options as Record<string, unknown> | undefined);
149
+ const existing = registry.entries.find((entry) => entry.id === id);
150
+ if (existing) {
151
+ return existing.id;
152
+ }
153
+
154
+ registry.entries.push({
155
+ id,
156
+ provider: config.provider,
157
+ name: config.name,
158
+ options_hash: optionsHash,
159
+ });
160
+ await writeRegistryAtomic(registryPath, registry);
161
+
162
+ const dir = channelDir(id);
163
+ await mkdir(dir, { recursive: true });
164
+ await writeFile(
165
+ path.join(dir, CONFIG_FILENAME),
166
+ JSON.stringify({ provider: config.provider, name: config.name }, null, 2),
167
+ 'utf8',
168
+ );
169
+ return id;
170
+ }
171
+
172
+ async function assertActive(channelId: ChannelId): Promise<void> {
173
+ if (disconnected.has(channelId)) {
174
+ throw new Error(`ChannelBridge: channel ${channelId} is disconnected; send rejected.`);
175
+ }
176
+ const registry = await readRegistry(registryPath);
177
+ if (!registry.entries.some((entry) => entry.id === channelId)) {
178
+ throw new Error(`ChannelBridge: channel ${channelId} is not registered.`);
179
+ }
180
+ }
181
+
182
+ return {
183
+ async register(bridgeConfig: BridgeConfig): Promise<ChannelId> {
184
+ return ensureRegistered(bridgeConfig);
185
+ },
186
+
187
+ async send(channelId: ChannelId, envelope: ChannelEnvelope): Promise<SendResult> {
188
+ await assertActive(channelId);
189
+ const envelopesPath = path.join(channelDir(channelId), ENVELOPES_FILENAME);
190
+ await lockGate.withLock(channelId, async () => {
191
+ await mkdir(path.dirname(envelopesPath), { recursive: true });
192
+ await appendFile(envelopesPath, `${JSON.stringify(envelope)}\n`, 'utf8');
193
+ });
194
+ return { accepted: true, delivery_id: envelope.id };
195
+ },
196
+
197
+ async *receive(channelId: ChannelId): AsyncIterable<ChannelEnvelope> {
198
+ if (disconnected.has(channelId)) {
199
+ return;
200
+ }
201
+ const envelopesPath = path.join(channelDir(channelId), ENVELOPES_FILENAME);
202
+ let raw: string;
203
+ try {
204
+ raw = await readFile(envelopesPath, 'utf8');
205
+ } catch (error) {
206
+ const err = error as NodeJS.ErrnoException;
207
+ if (err.code === 'ENOENT') {
208
+ return;
209
+ }
210
+ throw error;
211
+ }
212
+ for (const line of raw.split('\n')) {
213
+ if (line.length === 0) {
214
+ continue;
215
+ }
216
+ yield JSON.parse(line) as ChannelEnvelope;
217
+ }
218
+ },
219
+
220
+ async disconnect(channelId: ChannelId): Promise<void> {
221
+ disconnected.add(channelId);
222
+ },
223
+ };
224
+ }
@@ -0,0 +1,84 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ /**
5
+ * WU-2735 (INIT-060 WU-7a, ADR-013 §ChannelBridge):
6
+ * Pure domain types for the ChannelBridge port.
7
+ *
8
+ * These types are pure domain: they MUST NOT reference Node `fs`, network
9
+ * sockets, cloud endpoints, or any adapter-specific concept. Both the
10
+ * filesystem adapter (this WU) and the control-plane adapter (WU-2737) shape
11
+ * themselves around these definitions.
12
+ */
13
+
14
+ /**
15
+ * Branded channel identifier. The bridge mints these during `register`; the
16
+ * brand prevents callers from forging ids from raw strings.
17
+ */
18
+ export type ChannelId = string & { readonly __brand: 'sidekick.ChannelId' };
19
+
20
+ /**
21
+ * Envelope dispatch classification per ADR-013 §4 backpressure split.
22
+ *
23
+ * - `ephemeral` — observational; may fail-silent when transport is down.
24
+ * - `queue` — commands / approvals; must be queued and replayed on reconnect.
25
+ */
26
+ export type EnvelopeKind = 'ephemeral' | 'queue';
27
+
28
+ /**
29
+ * A channel envelope. Kept transport-agnostic: `body` is an opaque
30
+ * JSON-serialisable payload, `content_type` labels it so receivers can dispatch
31
+ * without snooping the body.
32
+ */
33
+ export interface ChannelEnvelope {
34
+ /** Unique id per envelope (content-hash or uuid — chosen by the emitter). */
35
+ id: string;
36
+ /** §4 backpressure policy — drives send path (fail-silent vs queued replay). */
37
+ kind: EnvelopeKind;
38
+ /** MIME-like content descriptor, e.g. `application/json`, `text/plain`. */
39
+ content_type: string;
40
+ /** Payload. JSON-serialisable; no host-specific handles. */
41
+ body: unknown;
42
+ /** ISO-8601 UTC timestamp of emit; per-channel ordering key (ADR-013 §3). */
43
+ emitted_at: string;
44
+ /** Optional emitter-supplied metadata (trace ids, correlation keys). */
45
+ metadata?: Readonly<Record<string, unknown>>;
46
+ }
47
+
48
+ /**
49
+ * Bridge registration config — identity under which a channel is opened.
50
+ *
51
+ * The (`provider`, `name`) pair is the canonical identity: re-registering the
52
+ * same pair returns the same `ChannelId` (ADR-013 §ChannelBridge contract
53
+ * rule #3 — idempotent register).
54
+ *
55
+ * `options` is provider-specific bag. The port does NOT inspect it; adapters
56
+ * interpret.
57
+ */
58
+ export interface BridgeConfig {
59
+ /** Provider slug — e.g. `filesystem`, `control-plane`. */
60
+ provider: string;
61
+ /** Human-readable channel name scoped within the provider. */
62
+ name: string;
63
+ /** Provider-specific options (paths, tokens, etc.). Opaque to the port. */
64
+ options?: Readonly<Record<string, unknown>>;
65
+ }
66
+
67
+ /**
68
+ * Result of a `send` attempt. `accepted` reflects the port contract: for
69
+ * ephemeral envelopes with an unreachable transport, adapters may return
70
+ * `{ accepted: false, reason: '...' }` rather than throwing (§4 fail-silent).
71
+ */
72
+ export interface SendResult {
73
+ accepted: boolean;
74
+ /** Transport-assigned id when the sink confirms receipt (optional). */
75
+ delivery_id?: string;
76
+ /** Reason when `accepted === false` (not meant for programmatic handling). */
77
+ reason?: string;
78
+ /**
79
+ * `true` when the sink recognised `envelope.id` on a registered channel as
80
+ * an at-least-once replay (WU-2737 §Idempotency; cloud contract surface).
81
+ * Adapters that cannot observe dedup SHOULD omit this field.
82
+ */
83
+ deduped?: boolean;
84
+ }