@united-workforce/cli 0.3.0 → 0.5.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 (319) hide show
  1. package/README.md +45 -11
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/__tests__/adapter-json-roundtrip.test.js +17 -7
  4. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  5. package/dist/__tests__/agent-resolution-llm-free.test.d.ts +2 -0
  6. package/dist/__tests__/agent-resolution-llm-free.test.d.ts.map +1 -0
  7. package/dist/__tests__/agent-resolution-llm-free.test.js +30 -0
  8. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -0
  9. package/dist/__tests__/build-step-entry.test.d.ts +2 -0
  10. package/dist/__tests__/build-step-entry.test.d.ts.map +1 -0
  11. package/dist/__tests__/build-step-entry.test.js +173 -0
  12. package/dist/__tests__/build-step-entry.test.js.map +1 -0
  13. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts +2 -0
  14. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts.map +1 -0
  15. package/dist/__tests__/clear-thread-failed-attempts.test.js +93 -0
  16. package/dist/__tests__/clear-thread-failed-attempts.test.js.map +1 -0
  17. package/dist/__tests__/concurrency.test.d.ts +2 -0
  18. package/dist/__tests__/concurrency.test.d.ts.map +1 -0
  19. package/dist/__tests__/concurrency.test.js +196 -0
  20. package/dist/__tests__/concurrency.test.js.map +1 -0
  21. package/dist/__tests__/config.test.js +26 -302
  22. package/dist/__tests__/config.test.js.map +1 -1
  23. package/dist/__tests__/current-role.test.js +7 -6
  24. package/dist/__tests__/current-role.test.js.map +1 -1
  25. package/dist/__tests__/e2e-mock-agent.test.js +43 -30
  26. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  27. package/dist/__tests__/format-text-default.test.d.ts +2 -0
  28. package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
  29. package/dist/__tests__/format-text-default.test.js +43 -0
  30. package/dist/__tests__/format-text-default.test.js.map +1 -0
  31. package/dist/__tests__/format-text-registry.test.d.ts +2 -0
  32. package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
  33. package/dist/__tests__/format-text-registry.test.js +158 -0
  34. package/dist/__tests__/format-text-registry.test.js.map +1 -0
  35. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts +2 -0
  36. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts.map +1 -0
  37. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +40 -0
  38. package/dist/__tests__/issue-180-workflow-ref-removed.test.js.map +1 -0
  39. package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
  40. package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
  41. package/dist/__tests__/log-text-renderer.test.js +265 -0
  42. package/dist/__tests__/log-text-renderer.test.js.map +1 -0
  43. package/dist/__tests__/moderator-evaluate.test.js +9 -50
  44. package/dist/__tests__/moderator-evaluate.test.js.map +1 -1
  45. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
  46. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
  47. package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
  48. package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
  49. package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
  50. package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
  51. package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
  52. package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
  53. package/dist/__tests__/pid-recycling.test.d.ts +2 -0
  54. package/dist/__tests__/pid-recycling.test.d.ts.map +1 -0
  55. package/dist/__tests__/pid-recycling.test.js +273 -0
  56. package/dist/__tests__/pid-recycling.test.js.map +1 -0
  57. package/dist/__tests__/prompt.test.js +365 -2
  58. package/dist/__tests__/prompt.test.js.map +1 -1
  59. package/dist/__tests__/resolve-head-hash.test.js +12 -4
  60. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  61. package/dist/__tests__/setup-agent-discovery.test.js +21 -30
  62. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  63. package/dist/__tests__/setup-complexity.test.js +2 -168
  64. package/dist/__tests__/setup-complexity.test.js.map +1 -1
  65. package/dist/__tests__/setup-no-llm.test.d.ts +2 -0
  66. package/dist/__tests__/setup-no-llm.test.d.ts.map +1 -0
  67. package/dist/__tests__/setup-no-llm.test.js +52 -0
  68. package/dist/__tests__/setup-no-llm.test.js.map +1 -0
  69. package/dist/__tests__/solve-issue-tea-worktree.test.js +27 -28
  70. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  71. package/dist/__tests__/step-ask.test.d.ts +2 -0
  72. package/dist/__tests__/step-ask.test.d.ts.map +1 -0
  73. package/dist/__tests__/step-ask.test.js +507 -0
  74. package/dist/__tests__/step-ask.test.js.map +1 -0
  75. package/dist/__tests__/step-show-json.test.js +1 -0
  76. package/dist/__tests__/step-show-json.test.js.map +1 -1
  77. package/dist/__tests__/step-timing.test.js +2 -0
  78. package/dist/__tests__/step-timing.test.js.map +1 -1
  79. package/dist/__tests__/store-global-cas.test.js +2 -2
  80. package/dist/__tests__/store-global-cas.test.js.map +1 -1
  81. package/dist/__tests__/store-unified-threads.test.js +28 -26
  82. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  83. package/dist/__tests__/thread-cancel-status.test.js +25 -19
  84. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  85. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
  86. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
  87. package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
  88. package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
  89. package/dist/__tests__/thread-list-filters.test.js +354 -17
  90. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  91. package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
  92. package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
  93. package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
  94. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
  95. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
  96. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
  97. package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
  98. package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
  99. package/dist/__tests__/thread-poke.test.d.ts +2 -0
  100. package/dist/__tests__/thread-poke.test.d.ts.map +1 -0
  101. package/dist/__tests__/thread-poke.test.js +422 -0
  102. package/dist/__tests__/thread-poke.test.js.map +1 -0
  103. package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
  104. package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
  105. package/dist/__tests__/thread-resume.test.js +21 -15
  106. package/dist/__tests__/thread-resume.test.js.map +1 -1
  107. package/dist/__tests__/thread-show-status.test.js +17 -28
  108. package/dist/__tests__/thread-show-status.test.js.map +1 -1
  109. package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
  110. package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
  111. package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
  112. package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
  113. package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
  114. package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
  115. package/dist/__tests__/thread-suspend-step.test.js +13 -16
  116. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  117. package/dist/__tests__/thread-suspended-display.test.js +10 -22
  118. package/dist/__tests__/thread-suspended-display.test.js.map +1 -1
  119. package/dist/__tests__/thread-test-helpers.d.ts +7 -0
  120. package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
  121. package/dist/__tests__/thread-test-helpers.js +13 -0
  122. package/dist/__tests__/thread-test-helpers.js.map +1 -1
  123. package/dist/__tests__/thread.test.js +15 -13
  124. package/dist/__tests__/thread.test.js.map +1 -1
  125. package/dist/__tests__/validate-semantic.test.js +105 -23
  126. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  127. package/dist/__tests__/workflow-list-recursive.test.d.ts +2 -0
  128. package/dist/__tests__/workflow-list-recursive.test.d.ts.map +1 -0
  129. package/dist/__tests__/workflow-list-recursive.test.js +286 -0
  130. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -0
  131. package/dist/__tests__/workflow-resolution.test.js +46 -28
  132. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  133. package/dist/__tests__/workflow-show-resolution.test.d.ts +2 -0
  134. package/dist/__tests__/workflow-show-resolution.test.d.ts.map +1 -0
  135. package/dist/__tests__/workflow-show-resolution.test.js +213 -0
  136. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -0
  137. package/dist/__tests__/workflow-validate.test.d.ts +2 -0
  138. package/dist/__tests__/workflow-validate.test.d.ts.map +1 -0
  139. package/dist/__tests__/workflow-validate.test.js +707 -0
  140. package/dist/__tests__/workflow-validate.test.js.map +1 -0
  141. package/dist/__tests__/write-envelope.test.d.ts +2 -0
  142. package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
  143. package/dist/__tests__/write-envelope.test.js +201 -0
  144. package/dist/__tests__/write-envelope.test.js.map +1 -0
  145. package/dist/background/background.d.ts +22 -1
  146. package/dist/background/background.d.ts.map +1 -1
  147. package/dist/background/background.js +83 -6
  148. package/dist/background/background.js.map +1 -1
  149. package/dist/background/index.d.ts +1 -1
  150. package/dist/background/index.d.ts.map +1 -1
  151. package/dist/background/index.js +1 -1
  152. package/dist/background/index.js.map +1 -1
  153. package/dist/background/types.d.ts +1 -0
  154. package/dist/background/types.d.ts.map +1 -1
  155. package/dist/cli.js +120 -62
  156. package/dist/cli.js.map +1 -1
  157. package/dist/commands/config.d.ts +3 -1
  158. package/dist/commands/config.d.ts.map +1 -1
  159. package/dist/commands/config.js +17 -31
  160. package/dist/commands/config.js.map +1 -1
  161. package/dist/commands/prompt.d.ts.map +1 -1
  162. package/dist/commands/prompt.js +57 -31
  163. package/dist/commands/prompt.js.map +1 -1
  164. package/dist/commands/setup.d.ts +12 -39
  165. package/dist/commands/setup.d.ts.map +1 -1
  166. package/dist/commands/setup.js +72 -303
  167. package/dist/commands/setup.js.map +1 -1
  168. package/dist/commands/step.d.ts +44 -1
  169. package/dist/commands/step.d.ts.map +1 -1
  170. package/dist/commands/step.js +255 -11
  171. package/dist/commands/step.js.map +1 -1
  172. package/dist/commands/thread.d.ts +16 -3
  173. package/dist/commands/thread.d.ts.map +1 -1
  174. package/dist/commands/thread.js +423 -142
  175. package/dist/commands/thread.js.map +1 -1
  176. package/dist/commands/workflow.d.ts +9 -1
  177. package/dist/commands/workflow.d.ts.map +1 -1
  178. package/dist/commands/workflow.js +126 -6
  179. package/dist/commands/workflow.js.map +1 -1
  180. package/dist/concurrency/concurrency.d.ts +34 -0
  181. package/dist/concurrency/concurrency.d.ts.map +1 -0
  182. package/dist/concurrency/concurrency.js +216 -0
  183. package/dist/concurrency/concurrency.js.map +1 -0
  184. package/dist/concurrency/index.d.ts +3 -0
  185. package/dist/concurrency/index.d.ts.map +1 -0
  186. package/dist/concurrency/index.js +2 -0
  187. package/dist/concurrency/index.js.map +1 -0
  188. package/dist/concurrency/types.d.ts +19 -0
  189. package/dist/concurrency/types.d.ts.map +1 -0
  190. package/dist/concurrency/types.js +2 -0
  191. package/dist/concurrency/types.js.map +1 -0
  192. package/dist/format.d.ts +69 -2
  193. package/dist/format.d.ts.map +1 -1
  194. package/dist/format.js +198 -1
  195. package/dist/format.js.map +1 -1
  196. package/dist/moderator/__tests__/evaluate.test.js +31 -17
  197. package/dist/moderator/__tests__/evaluate.test.js.map +1 -1
  198. package/dist/moderator/evaluate.d.ts.map +1 -1
  199. package/dist/moderator/evaluate.js +4 -16
  200. package/dist/moderator/evaluate.js.map +1 -1
  201. package/dist/moderator/index.d.ts +1 -2
  202. package/dist/moderator/index.d.ts.map +1 -1
  203. package/dist/moderator/index.js +0 -1
  204. package/dist/moderator/index.js.map +1 -1
  205. package/dist/moderator/types.d.ts +6 -10
  206. package/dist/moderator/types.d.ts.map +1 -1
  207. package/dist/moderator/types.js +1 -3
  208. package/dist/moderator/types.js.map +1 -1
  209. package/dist/output-mappers.d.ts +122 -0
  210. package/dist/output-mappers.d.ts.map +1 -0
  211. package/dist/output-mappers.js +134 -0
  212. package/dist/output-mappers.js.map +1 -0
  213. package/dist/schemas.d.ts +6 -1
  214. package/dist/schemas.d.ts.map +1 -1
  215. package/dist/schemas.js +34 -5
  216. package/dist/schemas.js.map +1 -1
  217. package/dist/store.d.ts +28 -9
  218. package/dist/store.d.ts.map +1 -1
  219. package/dist/store.js +75 -16
  220. package/dist/store.js.map +1 -1
  221. package/dist/text-renderers.d.ts +30 -0
  222. package/dist/text-renderers.d.ts.map +1 -0
  223. package/dist/text-renderers.js +251 -0
  224. package/dist/text-renderers.js.map +1 -0
  225. package/dist/validate-semantic.d.ts.map +1 -1
  226. package/dist/validate-semantic.js +95 -61
  227. package/dist/validate-semantic.js.map +1 -1
  228. package/dist/validate.d.ts +6 -0
  229. package/dist/validate.d.ts.map +1 -1
  230. package/dist/validate.js +24 -0
  231. package/dist/validate.js.map +1 -1
  232. package/examples/brainstorm.yaml +130 -0
  233. package/examples/debate.yaml +169 -0
  234. package/examples/socratic-questioning.yaml +112 -0
  235. package/package.json +9 -10
  236. package/src/__tests__/adapter-json-roundtrip.test.ts +16 -7
  237. package/src/__tests__/agent-resolution-llm-free.test.ts +39 -0
  238. package/src/__tests__/build-step-entry.test.ts +203 -0
  239. package/src/__tests__/clear-thread-failed-attempts.test.ts +122 -0
  240. package/src/__tests__/concurrency.test.ts +266 -0
  241. package/src/__tests__/config.test.ts +33 -321
  242. package/src/__tests__/current-role.test.ts +7 -6
  243. package/src/__tests__/e2e-mock-agent.test.ts +65 -30
  244. package/src/__tests__/fixtures/e2e-count.workflow.yaml +1 -0
  245. package/src/__tests__/fixtures/e2e-linear.workflow.yaml +1 -0
  246. package/src/__tests__/fixtures/{e2e-mustache.workflow.yaml → e2e-liquid.workflow.yaml} +3 -2
  247. package/src/__tests__/fixtures/e2e-loop.workflow.yaml +1 -0
  248. package/src/__tests__/fixtures/e2e-suspend.mock.yaml +2 -2
  249. package/src/__tests__/fixtures/e2e-suspend.workflow.yaml +6 -10
  250. package/src/__tests__/format-text-default.test.ts +49 -0
  251. package/src/__tests__/format-text-registry.test.ts +173 -0
  252. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +43 -0
  253. package/src/__tests__/log-text-renderer.test.ts +294 -0
  254. package/src/__tests__/moderator-evaluate.test.ts +9 -52
  255. package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
  256. package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
  257. package/src/__tests__/pid-recycling.test.ts +329 -0
  258. package/src/__tests__/prompt.test.ts +443 -2
  259. package/src/__tests__/resolve-head-hash.test.ts +11 -4
  260. package/src/__tests__/setup-agent-discovery.test.ts +26 -51
  261. package/src/__tests__/setup-complexity.test.ts +1 -203
  262. package/src/__tests__/setup-no-llm.test.ts +68 -0
  263. package/src/__tests__/solve-issue-tea-worktree.test.ts +27 -31
  264. package/src/__tests__/step-ask.test.ts +677 -0
  265. package/src/__tests__/step-show-json.test.ts +1 -0
  266. package/src/__tests__/step-timing.test.ts +2 -0
  267. package/src/__tests__/store-global-cas.test.ts +2 -2
  268. package/src/__tests__/store-unified-threads.test.ts +30 -27
  269. package/src/__tests__/thread-cancel-status.test.ts +27 -20
  270. package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
  271. package/src/__tests__/thread-list-filters.test.ts +443 -17
  272. package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
  273. package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
  274. package/src/__tests__/thread-poke.test.ts +554 -0
  275. package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
  276. package/src/__tests__/thread-resume.test.ts +20 -15
  277. package/src/__tests__/thread-show-status.test.ts +17 -29
  278. package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
  279. package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
  280. package/src/__tests__/thread-suspend-step.test.ts +13 -16
  281. package/src/__tests__/thread-suspended-display.test.ts +10 -22
  282. package/src/__tests__/thread-test-helpers.ts +15 -1
  283. package/src/__tests__/thread.test.ts +14 -14
  284. package/src/__tests__/validate-semantic.test.ts +118 -33
  285. package/src/__tests__/workflow-list-recursive.test.ts +370 -0
  286. package/src/__tests__/workflow-resolution.test.ts +48 -29
  287. package/src/__tests__/workflow-show-resolution.test.ts +286 -0
  288. package/src/__tests__/workflow-validate.test.ts +828 -0
  289. package/src/__tests__/write-envelope.test.ts +257 -0
  290. package/src/background/background.ts +88 -6
  291. package/src/background/index.ts +2 -0
  292. package/src/background/types.ts +1 -0
  293. package/src/cli.ts +184 -77
  294. package/src/commands/config.ts +16 -33
  295. package/src/commands/prompt.ts +57 -31
  296. package/src/commands/setup.ts +80 -358
  297. package/src/commands/step.ts +339 -12
  298. package/src/commands/thread.ts +511 -171
  299. package/src/commands/workflow.ts +155 -4
  300. package/src/concurrency/concurrency.ts +245 -0
  301. package/src/concurrency/index.ts +10 -0
  302. package/src/concurrency/types.ts +19 -0
  303. package/src/format.ts +282 -2
  304. package/src/moderator/__tests__/evaluate.test.ts +34 -17
  305. package/src/moderator/evaluate.ts +5 -17
  306. package/src/moderator/index.ts +1 -6
  307. package/src/moderator/types.ts +6 -14
  308. package/src/output-mappers.ts +254 -0
  309. package/src/schemas.ts +51 -5
  310. package/src/store.ts +86 -20
  311. package/src/text-renderers.ts +355 -0
  312. package/src/validate-semantic.ts +125 -73
  313. package/src/validate.ts +27 -0
  314. package/dist/__tests__/setup-validate.test.d.ts +0 -2
  315. package/dist/__tests__/setup-validate.test.d.ts.map +0 -1
  316. package/dist/__tests__/setup-validate.test.js +0 -108
  317. package/dist/__tests__/setup-validate.test.js.map +0 -1
  318. package/src/__tests__/setup-validate.test.ts +0 -148
  319. /package/src/__tests__/fixtures/{e2e-mustache.mock.yaml → e2e-liquid.mock.yaml} +0 -0
@@ -0,0 +1,257 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { openStore } from "@ocas/fs";
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
6
+ import { isOutputFormat, type OutputFormat, SUPPORTED_FORMATS, writeEnvelope } from "../format.js";
7
+ import { registerUwfSchemas, type UwfSchemaHashes } from "../schemas.js";
8
+
9
+ let tmp: string;
10
+ let store: Awaited<ReturnType<typeof openStore>>;
11
+ let schemas: UwfSchemaHashes;
12
+
13
+ beforeEach(async () => {
14
+ tmp = await mkdtemp(join(tmpdir(), "uwf-write-envelope-"));
15
+ store = await openStore(tmp);
16
+ schemas = await registerUwfSchemas(store);
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await rm(tmp, { recursive: true, force: true });
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ function captureStdout<T>(fn: () => Promise<T>): { result: Promise<T>; output: string[] } {
25
+ const buf: string[] = [];
26
+ const spy = vi.spyOn(process.stdout, "write").mockImplementation(((
27
+ chunk: string | Uint8Array,
28
+ ): boolean => {
29
+ buf.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
30
+ return true;
31
+ }) as typeof process.stdout.write);
32
+ return {
33
+ result: (async () => {
34
+ try {
35
+ return await fn();
36
+ } finally {
37
+ spy.mockRestore();
38
+ }
39
+ })(),
40
+ output: buf,
41
+ };
42
+ }
43
+
44
+ describe("isOutputFormat type guard", () => {
45
+ test("accepts every supported format", () => {
46
+ for (const fmt of SUPPORTED_FORMATS) {
47
+ expect(isOutputFormat(fmt)).toBe(true);
48
+ }
49
+ });
50
+
51
+ test("rejects unknown formats", () => {
52
+ expect(isOutputFormat("xml")).toBe(false);
53
+ expect(isOutputFormat("")).toBe(false);
54
+ expect(isOutputFormat("JSON")).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe("SUPPORTED_FORMATS", () => {
59
+ test("contains exactly the five formats specified in cli-envelope-writer.md", () => {
60
+ expect([...SUPPORTED_FORMATS].sort()).toEqual(["json", "raw-json", "raw-yaml", "text", "yaml"]);
61
+ });
62
+ });
63
+
64
+ describe("writeEnvelope — json format", () => {
65
+ test("emits {type,value} JSON envelope with trailing newline", async () => {
66
+ const payload = { valid: true, errors: [] };
67
+ const { result, output } = captureStdout(async () =>
68
+ writeEnvelope(payload, "validate-result", { format: "json", store, schemas }),
69
+ );
70
+ await result;
71
+
72
+ const out = output.join("");
73
+ expect(out.endsWith("\n")).toBe(true);
74
+ const parsed = JSON.parse(out);
75
+ expect(parsed).toEqual({
76
+ type: schemas.outputs["validate-result"],
77
+ value: { valid: true, errors: [] },
78
+ });
79
+ });
80
+ });
81
+
82
+ describe("writeEnvelope — yaml format", () => {
83
+ test("emits envelope yaml with type then value keys", async () => {
84
+ const payload = { valid: false, errors: ["a", "b"] };
85
+ const { result, output } = captureStdout(async () =>
86
+ writeEnvelope(payload, "validate-result", { format: "yaml", store, schemas }),
87
+ );
88
+ await result;
89
+
90
+ const out = output.join("");
91
+ expect(out.endsWith("\n")).toBe(true);
92
+ expect(out).toContain(`type: ${schemas.outputs["validate-result"]}`);
93
+ expect(out).toContain("value:");
94
+ expect(out).toContain("valid: false");
95
+ // type must precede value
96
+ expect(out.indexOf("type:")).toBeLessThan(out.indexOf("value:"));
97
+ });
98
+ });
99
+
100
+ describe("writeEnvelope — raw-json format", () => {
101
+ test("emits bare value JSON without envelope (legacy 0.5.0 shape)", async () => {
102
+ const payload = { valid: true, errors: [] };
103
+ const { result, output } = captureStdout(async () =>
104
+ writeEnvelope(payload, "validate-result", { format: "raw-json", store, schemas }),
105
+ );
106
+ await result;
107
+
108
+ const out = output.join("");
109
+ expect(out.endsWith("\n")).toBe(true);
110
+ const parsed = JSON.parse(out);
111
+ expect(parsed).toEqual({ valid: true, errors: [] });
112
+ // Must NOT contain envelope keys
113
+ expect(parsed.type).toBeUndefined();
114
+ expect(parsed.value).toBeUndefined();
115
+ });
116
+ });
117
+
118
+ describe("writeEnvelope — raw-yaml format", () => {
119
+ test("emits bare value YAML without envelope (legacy 0.5.0 shape)", async () => {
120
+ const payload = { valid: true, errors: [] };
121
+ const { result, output } = captureStdout(async () =>
122
+ writeEnvelope(payload, "validate-result", { format: "raw-yaml", store, schemas }),
123
+ );
124
+ await result;
125
+
126
+ const out = output.join("");
127
+ expect(out.endsWith("\n")).toBe(true);
128
+ expect(out).toContain("valid: true");
129
+ expect(out).toContain("errors:");
130
+ expect(out).not.toContain("type:");
131
+ expect(out).not.toContain("value:");
132
+ });
133
+ });
134
+
135
+ describe("writeEnvelope — text format (Liquid template)", () => {
136
+ test("renders validate-result valid case as `✓ valid`", async () => {
137
+ const payload = { valid: true, errors: [] };
138
+ const { result, output } = captureStdout(async () =>
139
+ writeEnvelope(payload, "validate-result", { format: "text", store, schemas }),
140
+ );
141
+ await result;
142
+
143
+ const out = output.join("");
144
+ expect(out.trim()).toBe("✓ valid");
145
+ });
146
+
147
+ test("renders validate-result invalid case with bulleted errors", async () => {
148
+ const payload = {
149
+ valid: false,
150
+ errors: ['unknown role "bogus"', "$START missing resume edge"],
151
+ };
152
+ const { result, output } = captureStdout(async () =>
153
+ writeEnvelope(payload, "validate-result", { format: "text", store, schemas }),
154
+ );
155
+ await result;
156
+
157
+ const out = output.join("");
158
+ expect(out).toContain("✗ invalid (2 errors)");
159
+ expect(out).toContain(' - unknown role "bogus"');
160
+ expect(out).toContain(" - $START missing resume edge");
161
+ });
162
+
163
+ test("renders workflow-add as Registered/Hash key-value lines", async () => {
164
+ const payload = { name: "review-pr", hash: "2TBP6T37TZAJZ" };
165
+ const { result, output } = captureStdout(async () =>
166
+ writeEnvelope(payload, "workflow-add", { format: "text", store, schemas }),
167
+ );
168
+ await result;
169
+
170
+ const out = output.join("");
171
+ expect(out).toBe("Registered review-pr\nHash 2TBP6T37TZAJZ\n");
172
+ // No JSON envelope leakage
173
+ expect(out).not.toContain("{");
174
+ expect(out).not.toContain("undefined");
175
+ // Single trailing newline
176
+ expect(out.endsWith("\n")).toBe(true);
177
+ expect(out.endsWith("\n\n")).toBe(false);
178
+ });
179
+
180
+ test("workflow-add tolerates empty hash field without throwing", async () => {
181
+ const payload = { name: "review-pr", hash: "" };
182
+ const { result, output } = captureStdout(async () =>
183
+ writeEnvelope(payload, "workflow-add", { format: "text", store, schemas }),
184
+ );
185
+ await result;
186
+
187
+ const out = output.join("");
188
+ // Renders without throwing; empty hash leaves trailing whitespace
189
+ expect(out).toContain("Registered review-pr");
190
+ expect(out).toContain("Hash ");
191
+ expect(out).not.toContain("undefined");
192
+ });
193
+ });
194
+
195
+ describe("writeEnvelope — workflow-add formats", () => {
196
+ test("json format wraps payload in {type,value} envelope", async () => {
197
+ const payload = { name: "review-pr", hash: "2TBP6T37TZAJZ" };
198
+ const { result, output } = captureStdout(async () =>
199
+ writeEnvelope(payload, "workflow-add", { format: "json", store, schemas }),
200
+ );
201
+ await result;
202
+
203
+ const out = output.join("");
204
+ const parsed = JSON.parse(out);
205
+ expect(parsed).toEqual({
206
+ type: schemas.outputs["workflow-add"],
207
+ value: { name: "review-pr", hash: "2TBP6T37TZAJZ" },
208
+ });
209
+ });
210
+
211
+ test("raw-json format emits bare payload (legacy 0.5.0 shape)", async () => {
212
+ const payload = { name: "review-pr", hash: "2TBP6T37TZAJZ" };
213
+ const { result, output } = captureStdout(async () =>
214
+ writeEnvelope(payload, "workflow-add", { format: "raw-json", store, schemas }),
215
+ );
216
+ await result;
217
+
218
+ const out = output.join("");
219
+ expect(out).toBe('{"name":"review-pr","hash":"2TBP6T37TZAJZ"}\n');
220
+ });
221
+
222
+ test("yaml format emits envelope with type and value keys", async () => {
223
+ const payload = { name: "review-pr", hash: "2TBP6T37TZAJZ" };
224
+ const { result, output } = captureStdout(async () =>
225
+ writeEnvelope(payload, "workflow-add", { format: "yaml", store, schemas }),
226
+ );
227
+ await result;
228
+
229
+ const out = output.join("");
230
+ expect(out).toContain(`type: ${schemas.outputs["workflow-add"]}`);
231
+ expect(out).toContain("value:");
232
+ expect(out).toContain("name: review-pr");
233
+ expect(out).toContain("hash: 2TBP6T37TZAJZ");
234
+ });
235
+ });
236
+
237
+ describe("writeEnvelope — schema lookup", () => {
238
+ test("throws when schema short name is unknown", async () => {
239
+ await expect(
240
+ // @ts-expect-error invalid schema name on purpose
241
+ writeEnvelope({}, "not-a-real-schema", { format: "json", store, schemas }),
242
+ ).rejects.toThrow(/output schema not registered/);
243
+ });
244
+
245
+ test("each format calls in to the same registered schema hash", async () => {
246
+ const payload = { valid: true, errors: [] };
247
+ const formats: OutputFormat[] = ["json", "yaml"];
248
+ for (const fmt of formats) {
249
+ const { result, output } = captureStdout(async () =>
250
+ writeEnvelope(payload, "validate-result", { format: fmt, store, schemas }),
251
+ );
252
+ await result;
253
+ const out = output.join("");
254
+ expect(out).toContain(schemas.outputs["validate-result"]);
255
+ }
256
+ });
257
+ });
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import type { RunningThreadItem, ThreadId } from "@united-workforce/protocol";
@@ -18,6 +19,42 @@ export function getMarkerPath(storageRoot: string, threadId: ThreadId): string {
18
19
  return join(getRunningDir(storageRoot), `${threadId}.json`);
19
20
  }
20
21
 
22
+ /**
23
+ * Read the process start time from /proc/<pid>/stat (field 22, starttime).
24
+ * Returns the value in clock ticks since boot, or null on non-Linux systems
25
+ * or when the process does not exist.
26
+ */
27
+ export function getProcessStartTime(pid: number): number | null {
28
+ try {
29
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
30
+ // /proc/<pid>/stat format: pid (comm) state ... field22_starttime ...
31
+ // The comm field can contain spaces and parentheses, so we find the last ')' first
32
+ const closeParenIdx = stat.lastIndexOf(")");
33
+ if (closeParenIdx === -1) {
34
+ return null;
35
+ }
36
+ // Fields after (comm) start at index 2 (state is field 3, index 2 in 0-based after split)
37
+ // starttime is field 22 (1-based), which is index 19 in the fields after ')'
38
+ const fieldsAfterComm = stat
39
+ .slice(closeParenIdx + 2)
40
+ .trim()
41
+ .split(" ");
42
+ // Field indices after comm (0-based): 0=state(3), 1=ppid(4), ..., 19=starttime(22)
43
+ const startTimeStr = fieldsAfterComm[19];
44
+ if (startTimeStr === undefined) {
45
+ return null;
46
+ }
47
+ const startTime = Number(startTimeStr);
48
+ if (Number.isNaN(startTime)) {
49
+ return null;
50
+ }
51
+ return startTime;
52
+ } catch {
53
+ // /proc not available (non-Linux) or process doesn't exist
54
+ return null;
55
+ }
56
+ }
57
+
21
58
  /**
22
59
  * Check if a PID is still running.
23
60
  * Returns true if the process exists, false otherwise.
@@ -33,6 +70,39 @@ export function isPidAlive(pid: number): boolean {
33
70
  }
34
71
  }
35
72
 
73
+ /**
74
+ * Validate that a running marker still refers to the same process.
75
+ * Checks both that the PID is alive AND that its start time matches.
76
+ * Returns false if:
77
+ * - The PID is no longer alive
78
+ * - The PID is alive but its start time doesn't match (PID was recycled)
79
+ * Returns true if:
80
+ * - PID is alive AND start times match
81
+ * - PID is alive AND marker has null processStartTime (backward compat / non-Linux)
82
+ */
83
+ export function isMarkerValid(marker: RunningMarker): boolean {
84
+ if (!isPidAlive(marker.pid)) {
85
+ return false;
86
+ }
87
+
88
+ // If marker has no processStartTime (legacy marker or non-Linux at creation time),
89
+ // fall back to PID-alive-only check for backward compatibility
90
+ if (marker.processStartTime === null) {
91
+ return true;
92
+ }
93
+
94
+ // Verify process identity by comparing start times
95
+ const actualStartTime = getProcessStartTime(marker.pid);
96
+
97
+ // If we can't read the actual start time (non-Linux runtime), trust PID-alive check
98
+ if (actualStartTime === null) {
99
+ return true;
100
+ }
101
+
102
+ // Start times must match — if they differ, PID was recycled
103
+ return marker.processStartTime === actualStartTime;
104
+ }
105
+
36
106
  /**
37
107
  * Create a marker file for a running thread.
38
108
  * Writes to a temp file in the same directory, then atomically renames.
@@ -63,6 +133,7 @@ export async function deleteMarker(storageRoot: string, threadId: ThreadId): Pro
63
133
 
64
134
  /**
65
135
  * Read a marker file. Returns null if file doesn't exist or is invalid.
136
+ * Handles legacy markers that lack processStartTime by defaulting to null.
66
137
  */
67
138
  export async function readMarker(
68
139
  storageRoot: string,
@@ -71,7 +142,15 @@ export async function readMarker(
71
142
  const markerPath = getMarkerPath(storageRoot, threadId);
72
143
  try {
73
144
  const content = await readFile(markerPath, "utf8");
74
- const marker = JSON.parse(content) as RunningMarker;
145
+ const raw = JSON.parse(content) as Record<string, unknown>;
146
+ // Normalize legacy markers that lack processStartTime
147
+ const marker: RunningMarker = {
148
+ thread: raw.thread as ThreadId,
149
+ workflow: raw.workflow as string,
150
+ pid: raw.pid as number,
151
+ startedAt: raw.startedAt as number,
152
+ processStartTime: typeof raw.processStartTime === "number" ? raw.processStartTime : null,
153
+ };
75
154
  return marker;
76
155
  } catch {
77
156
  return null;
@@ -80,6 +159,8 @@ export async function readMarker(
80
159
 
81
160
  /**
82
161
  * List all running threads, filtering out stale markers.
162
+ * A marker is stale if the PID is dead or if the PID was recycled
163
+ * (processStartTime mismatch).
83
164
  */
84
165
  export async function listRunningThreads(storageRoot: string): Promise<RunningThreadItem[]> {
85
166
  const runningDir = getRunningDir(storageRoot);
@@ -107,8 +188,8 @@ export async function listRunningThreads(storageRoot: string): Promise<RunningTh
107
188
  continue;
108
189
  }
109
190
 
110
- if (!isPidAlive(marker.pid)) {
111
- // Stale marker - process no longer exists
191
+ if (!isMarkerValid(marker)) {
192
+ // Stale marker - process no longer exists or PID was recycled
112
193
  await deleteMarker(storageRoot, threadId);
113
194
  continue;
114
195
  }
@@ -126,7 +207,8 @@ export async function listRunningThreads(storageRoot: string): Promise<RunningTh
126
207
 
127
208
  /**
128
209
  * Check if a thread is currently executing in the background.
129
- * Returns the marker if running, null otherwise.
210
+ * Returns the marker if running (and process identity is verified), null otherwise.
211
+ * Automatically deletes stale markers (dead PID or recycled PID).
130
212
  */
131
213
  export async function isThreadRunning(
132
214
  storageRoot: string,
@@ -137,8 +219,8 @@ export async function isThreadRunning(
137
219
  return null;
138
220
  }
139
221
 
140
- if (!isPidAlive(marker.pid)) {
141
- // Stale marker
222
+ if (!isMarkerValid(marker)) {
223
+ // Stale marker — PID dead or recycled
142
224
  await deleteMarker(storageRoot, threadId);
143
225
  return null;
144
226
  }
@@ -2,7 +2,9 @@ export {
2
2
  createMarker,
3
3
  deleteMarker,
4
4
  getMarkerPath,
5
+ getProcessStartTime,
5
6
  getRunningDir,
7
+ isMarkerValid,
6
8
  isPidAlive,
7
9
  isThreadRunning,
8
10
  listRunningThreads,
@@ -6,4 +6,5 @@ export type RunningMarker = {
6
6
  workflow: CasRef;
7
7
  pid: number;
8
8
  startedAt: number;
9
+ processStartTime: number | null;
9
10
  };