@lumenflow/cli 4.23.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 (296) 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-cli.js +3 -2
  83. package/dist/metrics-cli.js.map +1 -1
  84. package/dist/metrics-snapshot.js +271 -13
  85. package/dist/metrics-snapshot.js.map +1 -1
  86. package/dist/orchestrate-init-status.js +117 -2
  87. package/dist/orchestrate-init-status.js.map +1 -1
  88. package/dist/orchestrate-initiative.js +83 -10
  89. package/dist/orchestrate-initiative.js.map +1 -1
  90. package/dist/orchestrate-monitor-quality.js +289 -0
  91. package/dist/orchestrate-monitor-quality.js.map +1 -0
  92. package/dist/orchestrate-monitor.js +85 -0
  93. package/dist/orchestrate-monitor.js.map +1 -1
  94. package/dist/pack-validate.js +127 -2
  95. package/dist/pack-validate.js.map +1 -1
  96. package/dist/plan-create.js +18 -0
  97. package/dist/plan-create.js.map +1 -1
  98. package/dist/plan-link.js +13 -0
  99. package/dist/plan-link.js.map +1 -1
  100. package/dist/plan-promote.js +14 -0
  101. package/dist/plan-promote.js.map +1 -1
  102. package/dist/pre-commit-check.js +4 -3
  103. package/dist/pre-commit-check.js.map +1 -1
  104. package/dist/public-manifest.js +17 -3
  105. package/dist/public-manifest.js.map +1 -1
  106. package/dist/release.js +10 -10
  107. package/dist/release.js.map +1 -1
  108. package/dist/session-cross-link.js +139 -0
  109. package/dist/session-cross-link.js.map +1 -0
  110. package/dist/sidecar-manager.js +208 -0
  111. package/dist/sidecar-manager.js.map +1 -0
  112. package/dist/state-path-resolvers.js +18 -0
  113. package/dist/state-path-resolvers.js.map +1 -1
  114. package/dist/stream-heartbeat.js +151 -0
  115. package/dist/stream-heartbeat.js.map +1 -0
  116. package/dist/sync-templates.js +56 -2
  117. package/dist/sync-templates.js.map +1 -1
  118. package/dist/wu-block.js +47 -5
  119. package/dist/wu-block.js.map +1 -1
  120. package/dist/wu-claim-branch.js +8 -4
  121. package/dist/wu-claim-branch.js.map +1 -1
  122. package/dist/wu-claim-state.js +5 -3
  123. package/dist/wu-claim-state.js.map +1 -1
  124. package/dist/wu-claim-worktree.js +5 -3
  125. package/dist/wu-claim-worktree.js.map +1 -1
  126. package/dist/wu-claim.js +261 -9
  127. package/dist/wu-claim.js.map +1 -1
  128. package/dist/wu-done-auto-cleanup.js +3 -2
  129. package/dist/wu-done-auto-cleanup.js.map +1 -1
  130. package/dist/wu-done-git-ops.js +12 -8
  131. package/dist/wu-done-git-ops.js.map +1 -1
  132. package/dist/wu-done-preflight.js +3 -3
  133. package/dist/wu-done-preflight.js.map +1 -1
  134. package/dist/wu-done.js +46 -10
  135. package/dist/wu-done.js.map +1 -1
  136. package/dist/wu-lifecycle-sync/gate-scope-resolver.js +16 -0
  137. package/dist/wu-lifecycle-sync/gate-scope-resolver.js.map +1 -0
  138. package/dist/wu-lifecycle-sync/kernel-event-sync-shim.js +10 -0
  139. package/dist/wu-lifecycle-sync/kernel-event-sync-shim.js.map +1 -0
  140. package/dist/wu-prep.js +363 -22
  141. package/dist/wu-prep.js.map +1 -1
  142. package/dist/wu-prune.js +68 -27
  143. package/dist/wu-prune.js.map +1 -1
  144. package/dist/wu-release.js +34 -3
  145. package/dist/wu-release.js.map +1 -1
  146. package/dist/wu-review.js +167 -0
  147. package/dist/wu-review.js.map +1 -0
  148. package/dist/wu-spawn-prompt-builders.js +296 -40
  149. package/dist/wu-spawn-prompt-builders.js.map +1 -1
  150. package/dist/wu-spawn-strategy-resolver.js +126 -14
  151. package/dist/wu-spawn-strategy-resolver.js.map +1 -1
  152. package/dist/wu-unblock.js +52 -22
  153. package/dist/wu-unblock.js.map +1 -1
  154. package/package.json +13 -8
  155. package/packs/agent-runtime/.turbo/turbo-build.log +1 -1
  156. package/packs/agent-runtime/.turbo/turbo-test.log +25 -0
  157. package/packs/agent-runtime/.turbo/turbo-typecheck.log +4 -0
  158. package/packs/agent-runtime/agent-heartbeat.ts +163 -0
  159. package/packs/agent-runtime/auto-session-integration.ts +874 -0
  160. package/packs/agent-runtime/delegation-registry-schema.ts +220 -0
  161. package/packs/agent-runtime/delegation-registry-store.ts +269 -0
  162. package/packs/agent-runtime/delegation-tree.ts +328 -0
  163. package/packs/agent-runtime/index.ts +9 -0
  164. package/packs/agent-runtime/manifest.ts +103 -19
  165. package/packs/agent-runtime/manifest.yaml +132 -0
  166. package/packs/agent-runtime/memory-coordination-contract.ts +86 -0
  167. package/packs/agent-runtime/memory.d.ts +19 -0
  168. package/packs/agent-runtime/orchestration.ts +238 -23
  169. package/packs/agent-runtime/package.json +11 -2
  170. package/packs/agent-runtime/remote-controls/index.ts +7 -0
  171. package/packs/agent-runtime/remote-controls/operations.ts +399 -0
  172. package/packs/agent-runtime/remote-controls/port.ts +48 -0
  173. package/packs/agent-runtime/remote-controls/state-store.ts +258 -0
  174. package/packs/agent-runtime/remote-controls/types.ts +105 -0
  175. package/packs/agent-runtime/session-schema.ts +423 -0
  176. package/packs/agent-runtime/tool-impl/index.ts +1 -0
  177. package/packs/agent-runtime/tool-impl/remote-controls.mock.ts +252 -0
  178. package/packs/agent-runtime/tool-impl/remote-controls.ts +273 -0
  179. package/packs/agent-runtime/tsconfig.json +1 -1
  180. package/packs/agent-runtime/turn-lifecycle-events.ts +501 -0
  181. package/packs/sidekick/.lumenflow/state/conductor/outbox/sidekick-events.jsonl +213 -0
  182. package/packs/sidekick/.turbo/turbo-build.log +1 -1
  183. package/packs/sidekick/.turbo/turbo-test.log +25 -0
  184. package/packs/sidekick/.turbo/turbo-typecheck.log +4 -0
  185. package/packs/sidekick/channel-ingress.ts +137 -0
  186. package/packs/sidekick/manifest.ts +74 -0
  187. package/packs/sidekick/manifest.yaml +88 -0
  188. package/packs/sidekick/package.json +3 -1
  189. package/packs/sidekick/sidekick-events.ts +517 -0
  190. package/packs/sidekick/src/adapters/cloud-queue.ts +101 -0
  191. package/packs/sidekick/src/adapters/control-plane-bridge.adapter.ts +378 -0
  192. package/packs/sidekick/src/adapters/filesystem-bridge.adapter.ts +224 -0
  193. package/packs/sidekick/src/domain/channel.types.ts +84 -0
  194. package/packs/sidekick/src/ports/channel-bridge.port.ts +75 -0
  195. package/packs/sidekick/src/routines/commit.ts +74 -0
  196. package/packs/sidekick/tool-impl/channel-tools.ts +47 -0
  197. package/packs/sidekick/tool-impl/memory-tools.ts +17 -0
  198. package/packs/sidekick/tool-impl/routine-commit.ts +102 -0
  199. package/packs/sidekick/tool-impl/routine-tools.ts +67 -7
  200. package/packs/sidekick/tool-impl/runtime-context.ts +4 -0
  201. package/packs/sidekick/tool-impl/storage.ts +3 -0
  202. package/packs/sidekick/tool-impl/system-tools.ts +7 -0
  203. package/packs/sidekick/tool-impl/task-tools.ts +46 -0
  204. package/packs/sidekick/tsconfig.json +1 -1
  205. package/packs/software-delivery/.turbo/turbo-build.log +1 -1
  206. package/packs/software-delivery/.turbo/turbo-test.log +63 -0
  207. package/packs/software-delivery/.turbo/turbo-typecheck.log +4 -0
  208. package/packs/software-delivery/manifest-schema.ts +30 -0
  209. package/packs/software-delivery/manifest.ts +99 -1
  210. package/packs/software-delivery/manifest.yaml +46 -0
  211. package/packs/software-delivery/package.json +88 -3
  212. package/packs/software-delivery/src/commands/index.ts +5 -0
  213. package/packs/software-delivery/src/config/delivery-review-contract.ts +20 -0
  214. package/packs/software-delivery/src/config/env-accessors.ts +19 -0
  215. package/packs/software-delivery/src/config/index.ts +8 -0
  216. package/packs/software-delivery/src/config/normalize-config-keys.ts +19 -0
  217. package/packs/software-delivery/src/config/schemas/lumenflow-config-schema-types.ts +436 -0
  218. package/packs/software-delivery/src/config/workspace-reader.ts +310 -0
  219. package/packs/software-delivery/src/constants/backlog-patterns.ts +31 -0
  220. package/packs/software-delivery/src/constants/client-ids.ts +19 -0
  221. package/packs/software-delivery/src/constants/config-contract.ts +7 -0
  222. package/packs/software-delivery/src/constants/docs-layout-presets.ts +50 -0
  223. package/packs/software-delivery/src/constants/duration-constants.ts +20 -0
  224. package/packs/software-delivery/src/constants/gate-constants.ts +32 -0
  225. package/packs/software-delivery/src/constants/index.ts +29 -0
  226. package/packs/software-delivery/src/constants/lock-constants.ts +35 -0
  227. package/packs/software-delivery/src/constants/object-guards.ts +12 -0
  228. package/packs/software-delivery/src/constants/section-headings.ts +107 -0
  229. package/packs/software-delivery/src/constants/wu-cli-constants.ts +485 -0
  230. package/packs/software-delivery/src/constants/wu-domain-constants.ts +466 -0
  231. package/packs/software-delivery/src/constants/wu-git-constants.ts +7 -0
  232. package/packs/software-delivery/src/constants/wu-id-format.ts +327 -0
  233. package/packs/software-delivery/src/constants/wu-paths-constants.ts +358 -0
  234. package/packs/software-delivery/src/constants/wu-statuses.ts +287 -0
  235. package/packs/software-delivery/src/constants/wu-type-helpers.ts +67 -0
  236. package/packs/software-delivery/src/constants/wu-ui-constants.ts +267 -0
  237. package/packs/software-delivery/src/constants/wu-validation-constants.ts +73 -0
  238. package/packs/software-delivery/src/domain/index.ts +5 -0
  239. package/packs/software-delivery/src/domain/orchestration.constants.ts +168 -0
  240. package/packs/software-delivery/src/domain/orchestration.schemas.ts +239 -0
  241. package/packs/software-delivery/src/domain/orchestration.types.ts +178 -0
  242. package/packs/software-delivery/src/methodology/incremental-test.ts +90 -0
  243. package/packs/software-delivery/src/methodology/index.ts +6 -0
  244. package/packs/software-delivery/src/methodology/manual-test-validator.ts +292 -0
  245. package/packs/software-delivery/src/policy/coverage-gate.ts +270 -0
  246. package/packs/software-delivery/src/policy/gates-agent-mode.ts +223 -0
  247. package/packs/software-delivery/src/policy/gates-config-internal.ts +121 -0
  248. package/packs/software-delivery/src/policy/gates-config.ts +293 -0
  249. package/packs/software-delivery/src/policy/gates-coverage.ts +247 -0
  250. package/packs/software-delivery/src/policy/gates-presets.ts +134 -0
  251. package/packs/software-delivery/src/policy/gates-schemas.ts +173 -0
  252. package/packs/software-delivery/src/policy/index.ts +22 -0
  253. package/packs/software-delivery/src/policy/package-manager-resolver.ts +319 -0
  254. package/packs/software-delivery/src/policy/resolve-policy.ts +518 -0
  255. package/packs/software-delivery/src/ports/config.ports.ts +90 -0
  256. package/packs/software-delivery/src/ports/dashboard-renderer.port.ts +125 -0
  257. package/packs/software-delivery/src/ports/index.ts +10 -0
  258. package/packs/software-delivery/src/ports/sync-validator.ports.ts +59 -0
  259. package/packs/software-delivery/src/ports/wu-helpers.ports.ts +168 -0
  260. package/packs/software-delivery/src/ports/wu-state.ports.ts +241 -0
  261. package/packs/software-delivery/src/primitives/index.ts +5 -0
  262. package/packs/software-delivery/src/runtime/index.ts +6 -0
  263. package/packs/software-delivery/src/runtime/work-classifier.ts +561 -0
  264. package/packs/software-delivery/src/sandbox/index.ts +10 -0
  265. package/packs/software-delivery/src/sandbox/sandbox-allowlist.ts +118 -0
  266. package/packs/software-delivery/src/sandbox/sandbox-backend-linux.ts +88 -0
  267. package/packs/software-delivery/src/sandbox/sandbox-backend-macos.ts +154 -0
  268. package/packs/software-delivery/src/sandbox/sandbox-backend-windows.ts +47 -0
  269. package/packs/software-delivery/src/sandbox/sandbox-profile.ts +153 -0
  270. package/packs/software-delivery/src/schemas/index.ts +5 -0
  271. package/packs/software-delivery/src/state/date-utils.ts +158 -0
  272. package/packs/software-delivery/src/state/index.ts +15 -0
  273. package/packs/software-delivery/src/state/state-machine.ts +119 -0
  274. package/packs/software-delivery/src/state/wu-doc-types.ts +51 -0
  275. package/packs/software-delivery/src/state/wu-paths.ts +381 -0
  276. package/packs/software-delivery/src/state/wu-schema.ts +1139 -0
  277. package/packs/software-delivery/src/state/wu-state-schema.ts +255 -0
  278. package/packs/software-delivery/src/state/wu-yaml.ts +338 -0
  279. package/packs/software-delivery/src/types.d.ts +16 -0
  280. package/packs/software-delivery/tool-impl/wu-lifecycle-tools.ts +18 -0
  281. package/packs/software-delivery/tsconfig.json +28 -2
  282. package/templates/core/AGENTS.md.template +76 -17
  283. package/templates/core/LUMENFLOW.md.template +265 -66
  284. package/templates/core/_frameworks/lumenflow/wu-sizing-guide.md.template +180 -116
  285. package/templates/core/ai/onboarding/agent-invocation-guide.md.template +26 -8
  286. package/templates/core/ai/onboarding/existing-project-bootstrap.md.template +171 -0
  287. package/templates/core/ai/onboarding/first-15-mins.md.template +3 -1
  288. package/templates/core/ai/onboarding/first-wu-mistakes.md.template +1 -1
  289. package/templates/core/ai/onboarding/initiative-orchestration.md.template +46 -30
  290. package/templates/core/ai/onboarding/quick-ref-commands.md.template +36 -33
  291. package/templates/core/ai/onboarding/release-process.md.template +8 -7
  292. package/templates/core/ai/onboarding/starting-prompt.md.template +2 -0
  293. package/templates/core/ai/onboarding/troubleshooting-wu-done.md.template +62 -0
  294. package/templates/vendors/claude/.claude/CLAUDE.md.template +29 -54
  295. package/templates/vendors/cursor/.cursor/rules/lumenflow.md.template +24 -52
  296. package/templates/vendors/windsurf/.windsurf/rules/lumenflow.md.template +24 -52
@@ -0,0 +1,874 @@
1
+ // Copyright (c) 2026 Hellmai Ltd
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ /**
5
+ * Auto-Session Integration for wu:claim and wu:done lifecycle (WU-1438, WU-1466)
6
+ *
7
+ * Provides wrapper functions around agent-session.ts that:
8
+ * 1. Auto-start sessions on wu:claim with silent no-op if already active
9
+ * 2. Auto-end sessions on wu:done with silent no-op if not active
10
+ * 3. Store session_id in WU YAML for tracking
11
+ * 4. Create memory layer session nodes for context restoration (WU-1466)
12
+ *
13
+ * Design principles:
14
+ * - Composition over modification (wraps existing agent-session.ts)
15
+ * - Silent failures for idempotent operations (no throw on duplicate start/end)
16
+ * - Configurable session directory for testing
17
+ */
18
+ import { randomUUID } from 'crypto';
19
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { parse as parseYAML } from 'yaml';
22
+ import {
23
+ HeartbeatManager,
24
+ type AgentHeartbeatInput,
25
+ type AgentHeartbeatResult,
26
+ type HeartbeatHealth,
27
+ } from './agent-heartbeat.js';
28
+ import {
29
+ acquireDisplayName,
30
+ releaseDisplayName,
31
+ SESSION_SCHEMA_VERSION_V2,
32
+ withV2RoleDefaults,
33
+ type SessionSchemaVersion,
34
+ } from './session-schema.js';
35
+
36
+ const SESSION_FILENAME = 'current.json';
37
+ const SESSION_DIR = '.lumenflow/sessions';
38
+ const WORKSPACE_CONFIG_FILE = 'workspace.yaml';
39
+ const CONTROL_PLANE_REGISTER_PATH = '/api/v1/sessions/register';
40
+ const CONTROL_PLANE_DEREGISTER_PATH = '/api/v1/sessions/deregister';
41
+ const CONTROL_PLANE_HEARTBEAT_PATH = '/api/v1/heartbeat';
42
+
43
+ // Default context tier for auto-started sessions
44
+ const DEFAULT_TIER: 1 | 2 | 3 = 2;
45
+
46
+ // Agent type for auto-started sessions
47
+ const DEFAULT_AGENT_TYPE = 'claude-code';
48
+ const DEFAULT_AGENT_VERSION = 'unknown';
49
+ const DEFAULT_HOST_ID = 'unknown';
50
+ const DEFAULT_AGENT_CAPABILITIES = ['session_lifecycle', 'heartbeat'] as const;
51
+ const AGENT_CAPABILITIES_ENV = 'LUMENFLOW_AGENT_CAPABILITIES';
52
+ const AGENT_VERSION_ENV = 'LUMENFLOW_AGENT_VERSION';
53
+
54
+ type SessionMetadataValue = string | number | boolean | string[];
55
+
56
+ /**
57
+ * Session data stored in current.json
58
+ *
59
+ * WU-2754 (ADR-014 extension 2): `schemaVersion`, `lifecycle_role`,
60
+ * `specialty_profile`, and `delegation_id` are optional additive fields.
61
+ * v1 legacy records (no `schemaVersion`) continue to load without them.
62
+ */
63
+ interface SessionFileData {
64
+ session_id: string;
65
+ wu_id: string;
66
+ started: string;
67
+ last_heartbeat?: string;
68
+ completed?: string;
69
+ lane?: string;
70
+ agent_type: string;
71
+ client_type?: string;
72
+ capabilities?: string[];
73
+ agent_version?: string;
74
+ host_id?: string;
75
+ context_tier: number;
76
+ incidents_logged: number;
77
+ incidents_major: number;
78
+ auto_started?: boolean;
79
+ /** ADR-014 extension 2 — schema version stamp for v2 records (WU-2754). */
80
+ schemaVersion?: SessionSchemaVersion;
81
+ /** ADR-014 extension 2 — what phase of delivery this session owns (WU-2754). */
82
+ lifecycle_role?: string;
83
+ /** ADR-014 extension 2 — which skill bundle the agent identifies as carrying (WU-2754). */
84
+ specialty_profile?: string;
85
+ display_name?: string;
86
+ /** ADR-014 extension 2 — parent delegation registry reference (WU-2754). */
87
+ delegation_id?: string;
88
+ }
89
+
90
+ interface RegisterSessionInput {
91
+ workspace_id: string;
92
+ session_id: string;
93
+ agent_id: string;
94
+ started_at: string;
95
+ lane?: string;
96
+ wu_id?: string;
97
+ client_type?: string;
98
+ capabilities?: string[];
99
+ agent_version?: string;
100
+ host_id?: string;
101
+ metadata?: Record<string, SessionMetadataValue>;
102
+ }
103
+
104
+ interface DeregisterSessionInput {
105
+ workspace_id: string;
106
+ session_id: string;
107
+ ended_at?: string;
108
+ reason?: string;
109
+ }
110
+
111
+ interface ControlPlaneSessionSyncPort {
112
+ registerSession(input: RegisterSessionInput): Promise<unknown>;
113
+ deregisterSession(input: DeregisterSessionInput): Promise<unknown>;
114
+ heartbeat?(input: AgentHeartbeatInput): Promise<AgentHeartbeatResult>;
115
+ }
116
+
117
+ interface ResolvedControlPlaneSyncConfig {
118
+ workspaceId: string;
119
+ endpoint: string;
120
+ token: string;
121
+ }
122
+
123
+ interface WorkspaceControlPlaneDocument {
124
+ id?: unknown;
125
+ control_plane?: {
126
+ endpoint?: unknown;
127
+ auth?: {
128
+ token_env?: unknown;
129
+ };
130
+ };
131
+ }
132
+
133
+ function parseCapabilities(rawValue: string | null): string[] {
134
+ if (!rawValue) {
135
+ return [...DEFAULT_AGENT_CAPABILITIES];
136
+ }
137
+
138
+ const parsed = rawValue
139
+ .split(',')
140
+ .map((entry) => entry.trim())
141
+ .filter((entry) => entry.length > 0);
142
+ return parsed.length > 0 ? parsed : [...DEFAULT_AGENT_CAPABILITIES];
143
+ }
144
+
145
+ function asNonEmptyString(value: unknown): string | null {
146
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
147
+ }
148
+
149
+ function normalizeEndpoint(endpoint: string): string {
150
+ return endpoint.endsWith('/') ? endpoint.slice(0, endpoint.length - 1) : endpoint;
151
+ }
152
+
153
+ function resolveControlPlaneSessionSyncConfig(
154
+ workspaceRoot: string,
155
+ environment: NodeJS.ProcessEnv,
156
+ ): ResolvedControlPlaneSyncConfig | null {
157
+ const workspacePath = join(workspaceRoot, WORKSPACE_CONFIG_FILE);
158
+ if (!existsSync(workspacePath)) {
159
+ return null;
160
+ }
161
+
162
+ let parsedWorkspace: WorkspaceControlPlaneDocument | null;
163
+ try {
164
+ parsedWorkspace = parseYAML(
165
+ readFileSync(workspacePath, { encoding: 'utf-8' }),
166
+ ) as WorkspaceControlPlaneDocument | null;
167
+ } catch {
168
+ return null;
169
+ }
170
+
171
+ const workspaceId = asNonEmptyString(parsedWorkspace?.id);
172
+ const endpoint = asNonEmptyString(parsedWorkspace?.control_plane?.endpoint);
173
+ const tokenEnv = asNonEmptyString(parsedWorkspace?.control_plane?.auth?.token_env);
174
+ if (!workspaceId || !endpoint || !tokenEnv) {
175
+ return null;
176
+ }
177
+
178
+ const token = asNonEmptyString(environment[tokenEnv]);
179
+ if (!token) {
180
+ return null;
181
+ }
182
+
183
+ try {
184
+ // Validate endpoint before creating outbound requests.
185
+ void new URL(endpoint);
186
+ } catch {
187
+ return null;
188
+ }
189
+
190
+ return {
191
+ workspaceId,
192
+ endpoint: normalizeEndpoint(endpoint),
193
+ token,
194
+ };
195
+ }
196
+
197
+ function resolveStandardSessionMetadata(input: {
198
+ agentType: string;
199
+ environment: NodeJS.ProcessEnv;
200
+ }): {
201
+ client_type: string;
202
+ capabilities: string[];
203
+ agent_version: string;
204
+ host_id: string;
205
+ metadata: Record<string, SessionMetadataValue>;
206
+ } {
207
+ const clientType = input.agentType;
208
+ const capabilities = parseCapabilities(
209
+ asNonEmptyString(input.environment[AGENT_CAPABILITIES_ENV]),
210
+ );
211
+ const agentVersion =
212
+ asNonEmptyString(input.environment[AGENT_VERSION_ENV]) ?? DEFAULT_AGENT_VERSION;
213
+ const hostId =
214
+ asNonEmptyString(input.environment.HOSTNAME) ??
215
+ asNonEmptyString(input.environment.COMPUTERNAME) ??
216
+ DEFAULT_HOST_ID;
217
+
218
+ return {
219
+ client_type: clientType,
220
+ capabilities,
221
+ agent_version: agentVersion,
222
+ host_id: hostId,
223
+ metadata: {
224
+ client_type: clientType,
225
+ capabilities,
226
+ agent_version: agentVersion,
227
+ host_id: hostId,
228
+ },
229
+ };
230
+ }
231
+
232
+ async function postControlPlaneSessionHook(
233
+ endpoint: string,
234
+ token: string,
235
+ path: string,
236
+ payload: object,
237
+ fetchFn: typeof fetch,
238
+ ): Promise<void> {
239
+ const response = await fetchFn(`${endpoint}${path}`, {
240
+ method: 'POST',
241
+ headers: {
242
+ authorization: `Bearer ${token}`,
243
+ 'content-type': 'application/json',
244
+ },
245
+ body: JSON.stringify(payload),
246
+ });
247
+
248
+ if (!response.ok) {
249
+ throw new Error(`HTTP ${response.status}`);
250
+ }
251
+ }
252
+
253
+ function asFiniteNumber(value: unknown): number | undefined {
254
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
255
+ }
256
+
257
+ function normalizeSessionFileData(session: SessionFileData): SessionFileData {
258
+ return {
259
+ ...session,
260
+ client_type: session.client_type ?? session.agent_type,
261
+ capabilities: Array.isArray(session.capabilities)
262
+ ? session.capabilities.filter((entry): entry is string => typeof entry === 'string')
263
+ : [...DEFAULT_AGENT_CAPABILITIES],
264
+ agent_version: session.agent_version ?? DEFAULT_AGENT_VERSION,
265
+ host_id: session.host_id ?? DEFAULT_HOST_ID,
266
+ };
267
+ }
268
+
269
+ function normalizeHeartbeatResult(
270
+ raw: AgentHeartbeatResult | Record<string, unknown>,
271
+ ): AgentHeartbeatResult {
272
+ return {
273
+ status: 'ok',
274
+ server_time:
275
+ typeof raw.server_time === 'string' && raw.server_time.length > 0
276
+ ? raw.server_time
277
+ : new Date().toISOString(),
278
+ ...(asFiniteNumber(raw.next_heartbeat_ms) !== undefined
279
+ ? { next_heartbeat_ms: asFiniteNumber(raw.next_heartbeat_ms) }
280
+ : {}),
281
+ ...(asFiniteNumber(raw.budget_remaining_usd) !== undefined
282
+ ? { budget_remaining_usd: asFiniteNumber(raw.budget_remaining_usd) }
283
+ : {}),
284
+ ...(asFiniteNumber(raw.coalesced_signals) !== undefined
285
+ ? { coalesced_signals: asFiniteNumber(raw.coalesced_signals) }
286
+ : {}),
287
+ ...(typeof raw.assignment === 'object' && raw.assignment !== null
288
+ ? { assignment: raw.assignment as AgentHeartbeatResult['assignment'] }
289
+ : {}),
290
+ };
291
+ }
292
+
293
+ async function postControlPlaneHeartbeat(
294
+ endpoint: string,
295
+ token: string,
296
+ payload: AgentHeartbeatInput,
297
+ fetchFn: typeof fetch,
298
+ ): Promise<AgentHeartbeatResult> {
299
+ const response = await fetchFn(`${endpoint}${CONTROL_PLANE_HEARTBEAT_PATH}`, {
300
+ method: 'POST',
301
+ headers: {
302
+ authorization: `Bearer ${token}`,
303
+ 'content-type': 'application/json',
304
+ },
305
+ body: JSON.stringify(payload),
306
+ });
307
+
308
+ if (!response.ok) {
309
+ throw new Error(`HTTP ${response.status}`);
310
+ }
311
+
312
+ const raw = (await response.json().catch(() => ({}))) as AgentHeartbeatResult;
313
+ return normalizeHeartbeatResult(raw);
314
+ }
315
+
316
+ /**
317
+ * Get the session file path for a given session directory
318
+ * @param sessionDir - Session directory path
319
+ * @returns Full path to current.json
320
+ */
321
+ function getSessionFilePath(sessionDir: string): string {
322
+ return join(sessionDir, SESSION_FILENAME);
323
+ }
324
+
325
+ function getCanonicalSessionFilePath(sessionDir: string, wuId: string): string {
326
+ return join(sessionDir, `${wuId}.json`);
327
+ }
328
+
329
+ function ensureSessionDirectory(sessionDir: string): void {
330
+ if (!existsSync(sessionDir)) {
331
+ mkdirSync(sessionDir, { recursive: true });
332
+ }
333
+ }
334
+
335
+ function readSessionFile(filePath: string): SessionFileData | null {
336
+ if (!existsSync(filePath)) {
337
+ return null;
338
+ }
339
+
340
+ return normalizeSessionFileData(
341
+ JSON.parse(readFileSync(filePath, { encoding: 'utf-8' })) as SessionFileData,
342
+ );
343
+ }
344
+
345
+ function writeSessionFile(filePath: string, session: SessionFileData): void {
346
+ writeFileSync(filePath, JSON.stringify(session, null, 2), { encoding: 'utf-8' });
347
+ }
348
+
349
+ function getCanonicalSessions(sessionDir: string): SessionFileData[] {
350
+ if (!existsSync(sessionDir)) {
351
+ return [];
352
+ }
353
+
354
+ return readdirSync(sessionDir)
355
+ .filter((entry) => entry.endsWith('.json') && entry !== SESSION_FILENAME)
356
+ .map((entry) => readSessionFile(join(sessionDir, entry)))
357
+ .filter((session): session is SessionFileData => session !== null);
358
+ }
359
+
360
+ function getMostRecentActiveSession(sessionDir: string): SessionFileData | null {
361
+ const sessions = getCanonicalSessions(sessionDir);
362
+ if (sessions.length === 0) {
363
+ return null;
364
+ }
365
+
366
+ const [latest] = [...sessions].sort((left, right) => {
367
+ const leftTime = Date.parse(left.started);
368
+ const rightTime = Date.parse(right.started);
369
+
370
+ if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) {
371
+ return left.wu_id.localeCompare(right.wu_id);
372
+ }
373
+ if (Number.isNaN(leftTime)) {
374
+ return 1;
375
+ }
376
+ if (Number.isNaN(rightTime)) {
377
+ return -1;
378
+ }
379
+
380
+ return rightTime - leftTime;
381
+ });
382
+
383
+ return latest ?? null;
384
+ }
385
+
386
+ function refreshCompatibilityPointer(
387
+ sessionDir: string,
388
+ preferredSession: SessionFileData | null = null,
389
+ ): void {
390
+ const compatibilityPath = getSessionFilePath(sessionDir);
391
+ const activeSession = preferredSession ?? getMostRecentActiveSession(sessionDir);
392
+
393
+ if (!activeSession) {
394
+ if (existsSync(compatibilityPath)) {
395
+ unlinkSync(compatibilityPath);
396
+ }
397
+ return;
398
+ }
399
+
400
+ ensureSessionDirectory(sessionDir);
401
+ writeSessionFile(compatibilityPath, activeSession);
402
+ }
403
+
404
+ function resolveSessionForWU(sessionDir: string, wuId: string): SessionFileData | null {
405
+ const canonicalSession = readSessionFile(getCanonicalSessionFilePath(sessionDir, wuId));
406
+ if (canonicalSession) {
407
+ return canonicalSession;
408
+ }
409
+
410
+ const compatibilitySession = readSessionFile(getSessionFilePath(sessionDir));
411
+ if (compatibilitySession?.wu_id === wuId) {
412
+ ensureSessionDirectory(sessionDir);
413
+ writeSessionFile(getCanonicalSessionFilePath(sessionDir, wuId), compatibilitySession);
414
+ refreshCompatibilityPointer(sessionDir, compatibilitySession);
415
+ return compatibilitySession;
416
+ }
417
+
418
+ return null;
419
+ }
420
+
421
+ function resolveCurrentCompatibilitySession(sessionDir: string): SessionFileData | null {
422
+ const compatibilitySession = readSessionFile(getSessionFilePath(sessionDir));
423
+ if (compatibilitySession) {
424
+ return compatibilitySession;
425
+ }
426
+
427
+ const latestSession = getMostRecentActiveSession(sessionDir);
428
+ if (latestSession) {
429
+ refreshCompatibilityPointer(sessionDir, latestSession);
430
+ }
431
+ return latestSession;
432
+ }
433
+
434
+ /**
435
+ * Options for starting a session for a WU
436
+ *
437
+ * WU-2754: `lifecycleRole`, `specialtyProfile`, `delegationId` are optional
438
+ * ADR-014 extension 2 axes. When omitted, `startSessionForWU` applies
439
+ * kernel-agnostic v2 defaults (executor/general) — the CLI pack layer owns
440
+ * the canonical vocabulary (see `delegation-role-resolver.ts`).
441
+ */
442
+ interface StartSessionOptions {
443
+ wuId: string;
444
+ tier?: 1 | 2 | 3;
445
+ agentType?: string;
446
+ lane?: string;
447
+ sessionDir?: string;
448
+ workspaceRoot?: string;
449
+ environment?: NodeJS.ProcessEnv;
450
+ controlPlaneSyncPort?: ControlPlaneSessionSyncPort;
451
+ fetchFn?: typeof fetch;
452
+ baseDir?: string;
453
+ /** ADR-014 lifecycle_role for this session (e.g. 'orchestrator'). */
454
+ lifecycleRole?: string;
455
+ /** ADR-014 specialty_profile for this session (e.g. 'delivery'). */
456
+ specialtyProfile?: string;
457
+ displayName?: string;
458
+ /** Parent delegation-registry record reference (dlg-XXXX). */
459
+ delegationId?: string;
460
+ }
461
+
462
+ /**
463
+ * Result of starting a session
464
+ */
465
+ interface StartSessionResult {
466
+ sessionId: string;
467
+ alreadyActive?: boolean;
468
+ memoryNodeId?: string | null;
469
+ }
470
+
471
+ interface HeartbeatSessionOptions {
472
+ wuId?: string;
473
+ sessionDir?: string;
474
+ workspaceRoot?: string;
475
+ environment?: NodeJS.ProcessEnv;
476
+ controlPlaneSyncPort?: ControlPlaneSessionSyncPort;
477
+ heartbeatManager?: HeartbeatManager;
478
+ fetchFn?: typeof fetch;
479
+ health?: HeartbeatHealth;
480
+ }
481
+
482
+ interface HeartbeatSessionResult {
483
+ sent: boolean;
484
+ reason?: 'no_active_session' | 'control_plane_unavailable';
485
+ heartbeat?: AgentHeartbeatResult;
486
+ }
487
+
488
+ /**
489
+ * Start a session for a WU (called by wu:claim)
490
+ *
491
+ * Unlike startSession in agent-session.ts, this function:
492
+ * - Does NOT throw if a session already exists (returns existing session)
493
+ * - Uses default tier 2 if not specified
494
+ * - Supports custom session directory for testing
495
+ * - Creates memory layer session node for context restoration (WU-1466)
496
+ *
497
+ * @param options - Session options
498
+ * @returns Session result
499
+ */
500
+ export async function startSessionForWU(options: StartSessionOptions): Promise<StartSessionResult> {
501
+ const {
502
+ wuId,
503
+ tier = DEFAULT_TIER,
504
+ agentType = DEFAULT_AGENT_TYPE,
505
+ lane,
506
+ sessionDir,
507
+ workspaceRoot = process.cwd(),
508
+ environment = process.env,
509
+ controlPlaneSyncPort,
510
+ fetchFn = fetch,
511
+ lifecycleRole,
512
+ specialtyProfile,
513
+ displayName,
514
+ delegationId,
515
+ } = options;
516
+
517
+ const sessDir = sessionDir ?? SESSION_DIR;
518
+ const canonicalSessionFile = getCanonicalSessionFilePath(sessDir, wuId);
519
+
520
+ const existing = resolveSessionForWU(sessDir, wuId);
521
+ if (existing) {
522
+ refreshCompatibilityPointer(sessDir, existing);
523
+ return {
524
+ sessionId: existing.session_id,
525
+ alreadyActive: true,
526
+ };
527
+ }
528
+
529
+ // Create session directory if needed
530
+ ensureSessionDirectory(sessDir);
531
+
532
+ const standardizedMetadata = resolveStandardSessionMetadata({
533
+ agentType,
534
+ environment,
535
+ });
536
+
537
+ // Create new session
538
+ const sessionId = randomUUID();
539
+ // WU-2754 (ADR-014 extension 2): every new session is v2-stamped and
540
+ // carries lifecycle_role + specialty_profile (caller-supplied or defaulted).
541
+ const roleAxes = withV2RoleDefaults({
542
+ lifecycle_role: lifecycleRole,
543
+ specialty_profile: specialtyProfile,
544
+ display_name: displayName,
545
+ delegation_id: delegationId,
546
+ });
547
+ const resolvedDisplayName = roleAxes.display_name ?? acquireDisplayName(workspaceRoot, sessionId);
548
+ const session: SessionFileData = {
549
+ session_id: sessionId,
550
+ wu_id: wuId,
551
+ started: new Date().toISOString(),
552
+ last_heartbeat: new Date().toISOString(),
553
+ lane,
554
+ agent_type: agentType,
555
+ client_type: standardizedMetadata.client_type,
556
+ capabilities: standardizedMetadata.capabilities,
557
+ agent_version: standardizedMetadata.agent_version,
558
+ host_id: standardizedMetadata.host_id,
559
+ context_tier: tier,
560
+ incidents_logged: 0,
561
+ incidents_major: 0,
562
+ auto_started: true, // Mark as auto-started by wu:claim
563
+ schemaVersion: SESSION_SCHEMA_VERSION_V2,
564
+ lifecycle_role: roleAxes.lifecycle_role,
565
+ specialty_profile: roleAxes.specialty_profile,
566
+ display_name: resolvedDisplayName,
567
+ ...(roleAxes.delegation_id ? { delegation_id: roleAxes.delegation_id } : {}),
568
+ };
569
+
570
+ writeSessionFile(canonicalSessionFile, session);
571
+ refreshCompatibilityPointer(sessDir, session);
572
+
573
+ // WU-2153: Optional control-plane session registration.
574
+ // Fail-open by design: session lifecycle must not be blocked by remote errors.
575
+ const controlPlaneConfig = resolveControlPlaneSessionSyncConfig(workspaceRoot, environment);
576
+ if (controlPlaneConfig) {
577
+ const registerInput: RegisterSessionInput = {
578
+ workspace_id: controlPlaneConfig.workspaceId,
579
+ session_id: sessionId,
580
+ agent_id: agentType,
581
+ started_at: session.started,
582
+ lane,
583
+ wu_id: wuId,
584
+ client_type: standardizedMetadata.client_type,
585
+ capabilities: standardizedMetadata.capabilities,
586
+ agent_version: standardizedMetadata.agent_version,
587
+ host_id: standardizedMetadata.host_id,
588
+ metadata: standardizedMetadata.metadata,
589
+ };
590
+
591
+ try {
592
+ if (controlPlaneSyncPort) {
593
+ await controlPlaneSyncPort.registerSession(registerInput);
594
+ } else {
595
+ await postControlPlaneSessionHook(
596
+ controlPlaneConfig.endpoint,
597
+ controlPlaneConfig.token,
598
+ CONTROL_PLANE_REGISTER_PATH,
599
+ registerInput,
600
+ fetchFn,
601
+ );
602
+ }
603
+ } catch {
604
+ // Fail-open: remote registration must not block wu:claim.
605
+ }
606
+ }
607
+
608
+ return {
609
+ sessionId,
610
+ alreadyActive: false,
611
+ };
612
+ }
613
+
614
+ /**
615
+ * Send a control-plane heartbeat for the active WU session.
616
+ *
617
+ * This is fail-open for local workflows: if no active session or no control-plane config
618
+ * is present, no heartbeat is sent and a structured reason is returned.
619
+ */
620
+ export async function heartbeatSessionForWU(
621
+ options: HeartbeatSessionOptions = {},
622
+ ): Promise<HeartbeatSessionResult> {
623
+ const {
624
+ wuId,
625
+ sessionDir,
626
+ workspaceRoot = process.cwd(),
627
+ environment = process.env,
628
+ controlPlaneSyncPort,
629
+ heartbeatManager,
630
+ fetchFn = fetch,
631
+ health,
632
+ } = options;
633
+ const sessDir = sessionDir ?? SESSION_DIR;
634
+
635
+ const currentSession = getCurrentSessionForWU({ sessionDir, wuId });
636
+ if (!currentSession) {
637
+ return {
638
+ sent: false,
639
+ reason: 'no_active_session',
640
+ };
641
+ }
642
+
643
+ const controlPlaneConfig = resolveControlPlaneSessionSyncConfig(workspaceRoot, environment);
644
+ if (!controlPlaneConfig) {
645
+ return {
646
+ sent: false,
647
+ reason: 'control_plane_unavailable',
648
+ };
649
+ }
650
+
651
+ const heartbeatPort = controlPlaneSyncPort?.heartbeat
652
+ ? {
653
+ heartbeat: controlPlaneSyncPort.heartbeat.bind(controlPlaneSyncPort),
654
+ }
655
+ : {
656
+ heartbeat: async (input: AgentHeartbeatInput): Promise<AgentHeartbeatResult> =>
657
+ postControlPlaneHeartbeat(
658
+ controlPlaneConfig.endpoint,
659
+ controlPlaneConfig.token,
660
+ input,
661
+ fetchFn,
662
+ ),
663
+ };
664
+
665
+ const manager = heartbeatManager ?? new HeartbeatManager(heartbeatPort);
666
+ const heartbeat = await manager.heartbeat({
667
+ workspace_id: controlPlaneConfig.workspaceId,
668
+ session_id: currentSession.session_id,
669
+ agent_id: currentSession.agent_type,
670
+ wu_id: currentSession.wu_id,
671
+ ...(health ? { health } : {}),
672
+ });
673
+
674
+ currentSession.last_heartbeat = new Date().toISOString();
675
+ writeSessionFile(getCanonicalSessionFilePath(sessDir, currentSession.wu_id), currentSession);
676
+ refreshCompatibilityPointer(sessDir, currentSession);
677
+
678
+ return {
679
+ sent: true,
680
+ heartbeat,
681
+ };
682
+ }
683
+
684
+ /**
685
+ * Options for ending a session
686
+ */
687
+ interface EndSessionOptions {
688
+ wuId?: string;
689
+ sessionDir?: string;
690
+ workspaceRoot?: string;
691
+ environment?: NodeJS.ProcessEnv;
692
+ controlPlaneSyncPort?: ControlPlaneSessionSyncPort;
693
+ fetchFn?: typeof fetch;
694
+ }
695
+
696
+ /**
697
+ * Session summary
698
+ *
699
+ * WU-2754: includes optional ADR-014 extension 2 axes (lifecycle_role,
700
+ * specialty_profile, delegation_id). Absent on v1 legacy sessions.
701
+ */
702
+ interface SessionSummary {
703
+ wu_id: string;
704
+ session_id: string;
705
+ started: string;
706
+ completed: string;
707
+ agent_type: string;
708
+ context_tier: number;
709
+ incidents_logged: number;
710
+ incidents_major: number;
711
+ /** ADR-014 extension 2 — present on v2 sessions (WU-2754). */
712
+ lifecycle_role?: string;
713
+ /** ADR-014 extension 2 — present on v2 sessions (WU-2754). */
714
+ specialty_profile?: string;
715
+ display_name?: string;
716
+ /** ADR-014 extension 2 — present when session linked to a delegation (WU-2754). */
717
+ delegation_id?: string;
718
+ /** ADR-014 extension 2 — 'v2' when session was written with the v2 schema (WU-2754). */
719
+ schemaVersion?: SessionSchemaVersion;
720
+ }
721
+
722
+ /**
723
+ * Result of ending a session
724
+ */
725
+ interface EndSessionResult {
726
+ ended: boolean;
727
+ summary?: SessionSummary;
728
+ reason?: string;
729
+ }
730
+
731
+ /**
732
+ * End the current session (called by wu:done)
733
+ *
734
+ * Unlike endSession in agent-session.ts, this function:
735
+ * - Does NOT throw if no active session (returns { ended: false })
736
+ * - Returns structured result with summary
737
+ * - Supports custom session directory for testing
738
+ *
739
+ * @param options - Session options
740
+ * @returns Session end result
741
+ */
742
+ export function endSessionForWU(options: EndSessionOptions = {}): EndSessionResult {
743
+ const {
744
+ wuId,
745
+ sessionDir,
746
+ workspaceRoot = process.cwd(),
747
+ environment = process.env,
748
+ controlPlaneSyncPort,
749
+ fetchFn = fetch,
750
+ } = options;
751
+
752
+ const sessDir = sessionDir ?? SESSION_DIR;
753
+ const canonicalSessionFile =
754
+ typeof wuId === 'string' && wuId.length > 0 ? getCanonicalSessionFilePath(sessDir, wuId) : null;
755
+ const compatibilitySessionFile = getSessionFilePath(sessDir);
756
+ const session =
757
+ (typeof wuId === 'string' && wuId.length > 0
758
+ ? resolveSessionForWU(sessDir, wuId)
759
+ : resolveCurrentCompatibilitySession(sessDir)) ?? null;
760
+
761
+ if (!session) {
762
+ return {
763
+ ended: false,
764
+ reason: 'no_active_session',
765
+ };
766
+ }
767
+
768
+ // Finalize session
769
+ session.completed = new Date().toISOString();
770
+ releaseDisplayName(workspaceRoot, session.session_id);
771
+
772
+ // Build summary for WU YAML
773
+ // WU-2754 (ADR-014 extension 2): include the three role axes when the
774
+ // session was stamped v2 or otherwise carries them.
775
+ const summary: SessionSummary = {
776
+ wu_id: session.wu_id,
777
+ session_id: session.session_id,
778
+ started: session.started,
779
+ completed: session.completed,
780
+ agent_type: session.agent_type,
781
+ context_tier: session.context_tier,
782
+ incidents_logged: session.incidents_logged,
783
+ incidents_major: session.incidents_major,
784
+ ...(session.schemaVersion ? { schemaVersion: session.schemaVersion } : {}),
785
+ ...(session.lifecycle_role ? { lifecycle_role: session.lifecycle_role } : {}),
786
+ ...(session.specialty_profile ? { specialty_profile: session.specialty_profile } : {}),
787
+ ...(session.display_name ? { display_name: session.display_name } : {}),
788
+ ...(session.delegation_id ? { delegation_id: session.delegation_id } : {}),
789
+ };
790
+
791
+ const resolvedCanonicalSessionFile = getCanonicalSessionFilePath(sessDir, session.wu_id);
792
+ if (existsSync(resolvedCanonicalSessionFile)) {
793
+ unlinkSync(resolvedCanonicalSessionFile);
794
+ }
795
+
796
+ const compatibilitySession = readSessionFile(compatibilitySessionFile);
797
+ if (
798
+ compatibilitySession?.session_id === session.session_id &&
799
+ existsSync(compatibilitySessionFile)
800
+ ) {
801
+ unlinkSync(compatibilitySessionFile);
802
+ }
803
+
804
+ if (canonicalSessionFile && existsSync(canonicalSessionFile)) {
805
+ unlinkSync(canonicalSessionFile);
806
+ }
807
+ refreshCompatibilityPointer(sessDir);
808
+
809
+ // WU-2153: Optional control-plane session deregistration.
810
+ // Fail-open by design: completion path must not block on remote errors.
811
+ const controlPlaneConfig = resolveControlPlaneSessionSyncConfig(workspaceRoot, environment);
812
+ if (controlPlaneConfig) {
813
+ const deregisterInput: DeregisterSessionInput = {
814
+ workspace_id: controlPlaneConfig.workspaceId,
815
+ session_id: session.session_id,
816
+ ended_at: session.completed,
817
+ reason: 'wu_done',
818
+ };
819
+
820
+ if (controlPlaneSyncPort) {
821
+ void controlPlaneSyncPort.deregisterSession(deregisterInput).catch(() => {});
822
+ } else {
823
+ void postControlPlaneSessionHook(
824
+ controlPlaneConfig.endpoint,
825
+ controlPlaneConfig.token,
826
+ CONTROL_PLANE_DEREGISTER_PATH,
827
+ deregisterInput,
828
+ fetchFn,
829
+ ).catch(() => {});
830
+ }
831
+ }
832
+
833
+ return {
834
+ ended: true,
835
+ summary,
836
+ };
837
+ }
838
+
839
+ /**
840
+ * Options for getting current session
841
+ */
842
+ interface GetSessionOptions {
843
+ wuId?: string;
844
+ sessionDir?: string;
845
+ }
846
+
847
+ /**
848
+ * Get the current active session
849
+ *
850
+ * @param options - Session options
851
+ * @returns Session object or null if no active session
852
+ */
853
+ export function getCurrentSessionForWU(options: GetSessionOptions = {}): SessionFileData | null {
854
+ const { wuId, sessionDir } = options;
855
+
856
+ const sessDir = sessionDir ?? SESSION_DIR;
857
+ if (typeof wuId === 'string' && wuId.length > 0) {
858
+ return resolveSessionForWU(sessDir, wuId);
859
+ }
860
+
861
+ return resolveCurrentCompatibilitySession(sessDir);
862
+ }
863
+
864
+ /**
865
+ * Check if there's an active session for a specific WU
866
+ *
867
+ * @param wuId - WU ID to check
868
+ * @param options - Session options
869
+ * @returns True if session exists and matches WU ID
870
+ */
871
+ export function hasActiveSessionForWU(wuId: string, options: GetSessionOptions = {}): boolean {
872
+ const session = getCurrentSessionForWU(options);
873
+ return session !== null && session.wu_id === wuId;
874
+ }