@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
@@ -5,6 +5,7 @@ import { validateWorkflow } from "../validate-semantic.js";
5
5
  /** Build a valid two-role workflow that passes all checks. */
6
6
  function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
7
7
  const base: WorkflowPayload = {
8
+ version: 1,
8
9
  name: "test-workflow",
9
10
  description: "A test workflow",
10
11
  roles: {
@@ -55,10 +56,10 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
55
56
  new: { role: "writer", prompt: "Begin writing", location: null },
56
57
  resume: { role: "writer", prompt: "Review previous output and continue", location: null },
57
58
  },
58
- writer: { done: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } },
59
+ writer: { done: { role: "reviewer", prompt: "Review this: {{ plan }}", location: null } },
59
60
  reviewer: {
60
- approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null },
61
- rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
61
+ approved: { role: "$END", prompt: "Done: {{ summary }}", location: null },
62
+ rejected: { role: "writer", prompt: "Fix: {{ reason }}", location: null },
62
63
  },
63
64
  },
64
65
  };
@@ -208,8 +209,6 @@ describe("Suite 2: Graph Structure", () => {
208
209
 
209
210
  describe("Suite 3: Status-Edge Consistency", () => {
210
211
  test("3.1 user role using _ graph key is treated as an unknown status", () => {
211
- // "_" is no longer special-cased — it's just a status key that does not
212
- // match the role's $status enum, so it surfaces as extra/missing keys.
213
212
  const wf = makeWorkflow();
214
213
  wf.graph.writer = { _: { role: "reviewer", prompt: "Review", location: null } };
215
214
  const errors = validateWorkflow(wf);
@@ -288,7 +287,7 @@ describe("Suite 3b: Enum-Based $status is Rejected", () => {
288
287
  };
289
288
  wf.graph.reviewer = {
290
289
  approved: { role: "$END", prompt: "Done", location: null },
291
- rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
290
+ rejected: { role: "writer", prompt: "Fix: {{ comments }}", location: null },
292
291
  };
293
292
  const errors = validateWorkflow(wf);
294
293
  expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
@@ -307,7 +306,9 @@ describe("Suite 3b: Enum-Based $status is Rejected", () => {
307
306
  required: ["$status", "plan"],
308
307
  } as unknown as string,
309
308
  };
310
- wf.graph.writer = { ready: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
309
+ wf.graph.writer = {
310
+ ready: { role: "reviewer", prompt: "Review: {{ plan }}", location: null },
311
+ };
311
312
  const errors = validateWorkflow(wf);
312
313
  expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
313
314
  });
@@ -352,7 +353,7 @@ describe("Suite 3c: Const-Based Flat Schema", () => {
352
353
  expect(errors.some((e) => e.includes("extra status keys") && e.includes("extra"))).toBe(true);
353
354
  });
354
355
 
355
- test("3c.3 flat schema with const $status validates mustache vars", () => {
356
+ test("3c.3 flat schema with const $status validates template vars", () => {
356
357
  const wf = makeWorkflow();
357
358
  wf.roles.writer = {
358
359
  ...wf.roles.writer,
@@ -366,56 +367,48 @@ describe("Suite 3c: Const-Based Flat Schema", () => {
366
367
  } as unknown as string,
367
368
  };
368
369
  wf.graph.writer = {
369
- done: { role: "reviewer", prompt: "Review: {{{nonexistent}}}", location: null },
370
+ done: { role: "reviewer", prompt: "Review: {{ nonexistent }}", location: null },
370
371
  };
371
372
  const errors = validateWorkflow(wf);
372
- expect(
373
- errors.some(
374
- (e) => e.includes('prompt variable "nonexistent"') && e.includes('role "writer"'),
375
- ),
376
- ).toBe(true);
373
+ expect(errors.some((e) => e.includes("nonexistent") && e.includes('role "writer"'))).toBe(true);
377
374
  });
378
375
  });
379
376
 
380
- describe("Suite 4: Mustache Template Variable Existence", () => {
381
- test("4.1 prompt references nonexistent variable (enum status)", () => {
377
+ describe("Suite 4: Template Variable Existence (LiquidJS strict-render)", () => {
378
+ test("4.1 prompt references nonexistent variable (flat schema)", () => {
382
379
  const wf = makeWorkflow();
383
380
  wf.graph.writer = {
384
- done: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null },
381
+ done: { role: "reviewer", prompt: "Review: {{ branch }}", location: null },
385
382
  };
386
383
  const errors = validateWorkflow(wf);
387
- expect(
388
- errors.some(
389
- (e) => e.includes('prompt variable "branch"') && e.includes('role "writer" frontmatter'),
390
- ),
391
- ).toBe(true);
384
+ expect(errors.some((e) => e.includes("branch") && e.includes('role "writer"'))).toBe(true);
392
385
  });
393
386
 
394
387
  test("4.2 prompt references nonexistent variable (multi-exit)", () => {
395
388
  const wf = makeWorkflow();
396
389
  wf.graph.reviewer = {
397
- approved: { role: "$END", prompt: "Done: {{{branch}}}", location: null },
398
- rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
390
+ approved: { role: "$END", prompt: "Done: {{ branch }}", location: null },
391
+ rejected: { role: "writer", prompt: "Fix: {{ reason }}", location: null },
399
392
  };
400
393
  const errors = validateWorkflow(wf);
401
394
  expect(
402
- errors.some((e) =>
403
- e.includes('prompt variable "branch" not found in role "reviewer" variant "approved"'),
404
- ),
395
+ errors.some((e) => e.includes("branch") && e.includes("reviewer") && e.includes("approved")),
405
396
  ).toBe(true);
406
397
  });
407
398
 
408
- test("4.3 valid mustache variables pass", () => {
399
+ test("4.3 valid template variables pass", () => {
409
400
  const wf = makeWorkflow();
410
401
  const errors = validateWorkflow(wf);
411
402
  expect(errors).toEqual([]);
412
403
  });
413
404
 
414
- test("4.4 $status variable is always valid", () => {
405
+ test("4.4 $status in template is rejected ($ prefix invalid in LiquidJS)", () => {
415
406
  const wf = makeWorkflow();
416
- wf.graph.writer = { done: { role: "reviewer", prompt: "Status: {{$status}}", location: null } };
407
+ wf.graph.writer = {
408
+ done: { role: "reviewer", prompt: "Status: {{ $status }}", location: null },
409
+ };
417
410
  const errors = validateWorkflow(wf);
418
- expect(errors).toEqual([]);
411
+ expect(errors.length).toBeGreaterThan(0);
419
412
  });
420
413
  });
421
414
 
@@ -469,6 +462,41 @@ describe("Suite 5: oneOf Discriminant Validity", () => {
469
462
  });
470
463
  });
471
464
 
465
+ describe("Suite 7: $SUSPEND is no longer a valid graph target", () => {
466
+ test("7.1 edge targeting $SUSPEND is rejected with a migration hint", () => {
467
+ const wf = makeWorkflow();
468
+ wf.graph.writer = {
469
+ done: { role: "$SUSPEND", prompt: "Need more info", location: null },
470
+ };
471
+ const errors = validateWorkflow(wf);
472
+ expect(
473
+ errors.some(
474
+ (e) =>
475
+ e.includes("$SUSPEND") &&
476
+ e.includes("no longer a valid graph target") &&
477
+ e.includes('Emit $status: "$SUSPEND"'),
478
+ ),
479
+ ).toBe(true);
480
+ });
481
+
482
+ test("7.2 $SUSPEND as a graph node is rejected", () => {
483
+ const wf = makeWorkflow();
484
+ (wf.graph as Record<string, unknown>).$SUSPEND = {
485
+ done: { role: "$END", prompt: "done", location: null },
486
+ };
487
+ const errors = validateWorkflow(wf);
488
+ expect(
489
+ errors.some((e) => e.includes("$SUSPEND") && e.includes("no longer a valid graph node")),
490
+ ).toBe(true);
491
+ });
492
+
493
+ test("7.3 a role emitting $SUSPEND from its output (not the graph) passes", () => {
494
+ const wf = makeWorkflow();
495
+ const errors = validateWorkflow(wf);
496
+ expect(errors.some((e) => e.includes("$SUSPEND"))).toBe(false);
497
+ });
498
+ });
499
+
472
500
  describe("Suite 6: Multiple Errors Collection", () => {
473
501
  test("6.1 multiple errors collected", () => {
474
502
  const wf = makeWorkflow();
@@ -487,9 +515,66 @@ describe("Suite 6: Multiple Errors Collection", () => {
487
515
  };
488
516
  // unknown graph reference
489
517
  wf.graph.nonexistent = { done: { role: "$END", prompt: "done", location: null } };
490
- // bad mustache var
491
- wf.graph.writer = { done: { role: "reviewer", prompt: "{{{badvar}}}", location: null } };
518
+ // bad template var
519
+ wf.graph.writer = { done: { role: "reviewer", prompt: "{{ badvar }}", location: null } };
492
520
  const errors = validateWorkflow(wf);
493
521
  expect(errors.length).toBeGreaterThanOrEqual(3);
494
522
  });
495
523
  });
524
+
525
+ describe("Suite 7: Reserved Frontmatter Properties", () => {
526
+ test("7.1 flat schema with _body property is rejected", () => {
527
+ const wf = makeWorkflow();
528
+ wf.roles.writer = {
529
+ ...wf.roles.writer,
530
+ frontmatter: {
531
+ type: "object",
532
+ properties: {
533
+ $status: { const: "done" },
534
+ _body: { type: "string" },
535
+ },
536
+ required: ["$status"],
537
+ } as unknown as string,
538
+ };
539
+ wf.graph.writer = { done: { role: "reviewer", prompt: "ok", location: null } };
540
+ const errors = validateWorkflow(wf);
541
+ expect(errors.some((e) => e.includes("_body") && e.includes("reserved"))).toBe(true);
542
+ });
543
+
544
+ test("7.2 oneOf schema with _body in a variant is rejected", () => {
545
+ const wf = makeWorkflow();
546
+ wf.roles.writer = {
547
+ ...wf.roles.writer,
548
+ frontmatter: {
549
+ oneOf: [
550
+ {
551
+ properties: {
552
+ $status: { const: "done" },
553
+ _body: { type: "string" },
554
+ },
555
+ required: ["$status"],
556
+ },
557
+ {
558
+ properties: {
559
+ $status: { const: "failed" },
560
+ reason: { type: "string" },
561
+ },
562
+ required: ["$status", "reason"],
563
+ },
564
+ ],
565
+ } as unknown as string,
566
+ };
567
+ wf.graph.writer = {
568
+ done: { role: "reviewer", prompt: "ok", location: null },
569
+ failed: { role: "$END", prompt: "failed", location: null },
570
+ };
571
+ const errors = validateWorkflow(wf);
572
+ expect(errors.some((e) => e.includes("_body") && e.includes("reserved"))).toBe(true);
573
+ });
574
+
575
+ test("7.3 schema without _body passes", () => {
576
+ const wf = makeWorkflow();
577
+ const errors = validateWorkflow(wf);
578
+ expect(errors.some((e) => e.includes("_body"))).toBe(false);
579
+ });
580
+ });
@@ -0,0 +1,370 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { CasRef, WorkflowPayload } from "@united-workforce/protocol";
5
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
6
+ import { stringify } from "yaml";
7
+ import { cmdThreadStart } from "../commands/thread.js";
8
+ import { cmdWorkflowList } from "../commands/workflow.js";
9
+ import { discoverProjectWorkflows } from "../store.js";
10
+ import { makeUwfStore } from "./thread-test-helpers.js";
11
+
12
+ // ── helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ function makeMinimalPayload(name: string, description: string): WorkflowPayload {
15
+ return {
16
+ version: 1,
17
+ name,
18
+ description,
19
+ roles: {
20
+ worker: {
21
+ description: "worker role",
22
+ goal: "do work",
23
+ capabilities: [],
24
+ procedure: "",
25
+ output: "",
26
+ frontmatter: {
27
+ type: "object",
28
+ properties: {
29
+ $status: { const: "done" },
30
+ },
31
+ required: ["$status"],
32
+ } as unknown as CasRef,
33
+ },
34
+ },
35
+ graph: {
36
+ $START: {
37
+ new: { role: "worker", prompt: "start working", location: null },
38
+ resume: { role: "worker", prompt: "resume working", location: null },
39
+ },
40
+ worker: { done: { role: "$END", prompt: "done", location: null } },
41
+ },
42
+ };
43
+ }
44
+
45
+ async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
46
+ const payload = makeMinimalPayload(
47
+ name,
48
+ version !== null ? `Test workflow (${version})` : "Test workflow",
49
+ );
50
+ return stringify(payload);
51
+ }
52
+
53
+ // ── fixture ───────────────────────────────────────────────────────────────────
54
+
55
+ let tmpDir: string;
56
+ let storageRoot: string;
57
+ let projectRoot: string;
58
+ let savedOcasHome: string | undefined;
59
+
60
+ beforeEach(async () => {
61
+ savedOcasHome = process.env.OCAS_HOME;
62
+ tmpDir = await mkdtemp(join(tmpdir(), "uwf-wf-list-recursive-"));
63
+ storageRoot = join(tmpDir, "storage");
64
+ projectRoot = join(tmpDir, "project");
65
+ await mkdir(storageRoot, { recursive: true });
66
+ await mkdir(projectRoot, { recursive: true });
67
+ });
68
+
69
+ afterEach(async () => {
70
+ if (savedOcasHome === undefined) {
71
+ delete process.env.OCAS_HOME;
72
+ } else {
73
+ process.env.OCAS_HOME = savedOcasHome;
74
+ }
75
+ await rm(tmpDir, { recursive: true, force: true });
76
+ });
77
+
78
+ // ── discoverProjectWorkflows — parent traversal ───────────────────────────────
79
+
80
+ describe("discoverProjectWorkflows — parent traversal", () => {
81
+ test("T1: finds workflows in cwd's .workflows/", async () => {
82
+ const wfDir = join(projectRoot, ".workflows");
83
+ await mkdir(wfDir, { recursive: true });
84
+ await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
85
+
86
+ const entries = await discoverProjectWorkflows(projectRoot);
87
+
88
+ expect(entries.map((e) => e.name)).toContain("solve-issue");
89
+ });
90
+
91
+ test("T2: finds workflows in ancestor's .workflows/ when called from subdirectory", async () => {
92
+ const wfDir = join(projectRoot, ".workflows");
93
+ await mkdir(wfDir, { recursive: true });
94
+ await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
95
+
96
+ const subdir = join(projectRoot, "packages", "cli", "src");
97
+ await mkdir(subdir, { recursive: true });
98
+
99
+ const entries = await discoverProjectWorkflows(subdir);
100
+
101
+ expect(entries.map((e) => e.name)).toContain("solve-issue");
102
+ });
103
+
104
+ test("T3: returns [] when no .workflows/ or .workflow/ exists in any ancestor", async () => {
105
+ // Use a deep path under tmpDir that has no .workflows/ or .workflow/ on the way up.
106
+ // (Traversal will stop at filesystem root and find nothing.)
107
+ const deepPath = join(tmpDir, "isolated", "no", "workflow", "here");
108
+ await mkdir(deepPath, { recursive: true });
109
+
110
+ const entries = await discoverProjectWorkflows(deepPath);
111
+
112
+ expect(entries).toEqual([]);
113
+ });
114
+
115
+ test("T4: .workflows/ entries win over .workflow/ within the same directory", async () => {
116
+ const primaryDir = join(projectRoot, ".workflows");
117
+ const legacyDir = join(projectRoot, ".workflow");
118
+ await mkdir(primaryDir, { recursive: true });
119
+ await mkdir(legacyDir, { recursive: true });
120
+
121
+ await writeFile(
122
+ join(primaryDir, "solve-issue.yaml"),
123
+ await createWorkflowYaml("solve-issue", "new"),
124
+ );
125
+ await writeFile(
126
+ join(legacyDir, "solve-issue.yaml"),
127
+ await createWorkflowYaml("solve-issue", "legacy"),
128
+ );
129
+
130
+ const entries = await discoverProjectWorkflows(projectRoot);
131
+
132
+ const match = entries.find((e) => e.name === "solve-issue");
133
+ expect(match).toBeDefined();
134
+ expect(match?.filePath).toBe(join(primaryDir, "solve-issue.yaml"));
135
+ });
136
+
137
+ test("T5: nearest .workflows/ wins over ancestor's .workflows/", async () => {
138
+ const ancestorWf = join(projectRoot, ".workflows");
139
+ await mkdir(ancestorWf, { recursive: true });
140
+ await writeFile(join(ancestorWf, "foo.yaml"), await createWorkflowYaml("foo", "ancestor"));
141
+
142
+ const nearDir = join(projectRoot, "pkg");
143
+ const nearWf = join(nearDir, ".workflows");
144
+ await mkdir(nearWf, { recursive: true });
145
+ await writeFile(join(nearWf, "foo.yaml"), await createWorkflowYaml("foo", "near"));
146
+
147
+ const entries = await discoverProjectWorkflows(nearDir);
148
+
149
+ const match = entries.find((e) => e.name === "foo");
150
+ expect(match).toBeDefined();
151
+ expect(match?.filePath).toBe(join(nearWf, "foo.yaml"));
152
+ // Should not include duplicates from ancestor
153
+ expect(entries.filter((e) => e.name === "foo")).toHaveLength(1);
154
+ });
155
+
156
+ test("T6: returns all entries from the nearest .workflows/ when called from a deep subdir", async () => {
157
+ const wfDir = join(projectRoot, ".workflows");
158
+ await mkdir(wfDir, { recursive: true });
159
+ await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
160
+ await writeFile(join(wfDir, "review-code.yaml"), await createWorkflowYaml("review-code"));
161
+
162
+ const deep = join(projectRoot, "a", "b", "c", "d");
163
+ await mkdir(deep, { recursive: true });
164
+
165
+ const entries = await discoverProjectWorkflows(deep);
166
+
167
+ const names = entries.map((e) => e.name).sort();
168
+ expect(names).toEqual(["review-code", "solve-issue"]);
169
+ });
170
+
171
+ test("T7: discovers folder-based layout (name/index.yaml) via parent traversal under .workflows/", async () => {
172
+ const folderDir = join(projectRoot, ".workflows", "solve-issue");
173
+ await mkdir(folderDir, { recursive: true });
174
+ await writeFile(join(folderDir, "index.yaml"), await createWorkflowYaml("solve-issue"));
175
+
176
+ const subdir = join(projectRoot, "deep", "sub");
177
+ await mkdir(subdir, { recursive: true });
178
+
179
+ const entries = await discoverProjectWorkflows(subdir);
180
+
181
+ const match = entries.find((e) => e.name === "solve-issue");
182
+ expect(match).toBeDefined();
183
+ expect(match?.filePath).toBe(join(folderDir, "index.yaml"));
184
+ });
185
+
186
+ test("T8: .workflow/ (legacy) is still discovered when .workflows/ does not exist", async () => {
187
+ const legacyDir = join(projectRoot, ".workflow");
188
+ await mkdir(legacyDir, { recursive: true });
189
+ await writeFile(join(legacyDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
190
+
191
+ const entries = await discoverProjectWorkflows(projectRoot);
192
+
193
+ const match = entries.find((e) => e.name === "solve-issue");
194
+ expect(match).toBeDefined();
195
+ expect(match?.filePath).toBe(join(legacyDir, "solve-issue.yaml"));
196
+ });
197
+
198
+ test("T9: nearest directory with EITHER variant stops traversal", async () => {
199
+ // Setup: ancestor .workflows/ + near .workflow/ only — near wins, ancestor not merged.
200
+ const ancestorWf = join(tmpDir, ".workflows");
201
+ await mkdir(ancestorWf, { recursive: true });
202
+ await writeFile(join(ancestorWf, "leak.yaml"), await createWorkflowYaml("leak"));
203
+
204
+ const nearLegacyDir = join(projectRoot, ".workflow");
205
+ await mkdir(nearLegacyDir, { recursive: true });
206
+ await writeFile(join(nearLegacyDir, "local.yaml"), await createWorkflowYaml("local"));
207
+
208
+ const entries = await discoverProjectWorkflows(projectRoot);
209
+ const names = entries.map((e) => e.name);
210
+ expect(names).toContain("local");
211
+ expect(names).not.toContain("leak");
212
+ });
213
+ });
214
+
215
+ // ── discoverProjectWorkflows — .git boundary ─────────────────────────────────
216
+
217
+ describe("discoverProjectWorkflows — .git boundary", () => {
218
+ test("G1: .git directory stops traversal", async () => {
219
+ // Setup: tmpDir/repo/.git/ (dir), tmpDir/.workflows/leak.yaml, start from tmpDir/repo/sub/deep/
220
+ const repoDir = join(tmpDir, "repo");
221
+ const gitDir = join(repoDir, ".git");
222
+ await mkdir(gitDir, { recursive: true });
223
+
224
+ // Workflow above repo root — should NOT be reachable
225
+ const leakDir = join(tmpDir, ".workflows");
226
+ await mkdir(leakDir, { recursive: true });
227
+ await writeFile(join(leakDir, "leak.yaml"), await createWorkflowYaml("leak"));
228
+
229
+ const startFrom = join(repoDir, "sub", "deep");
230
+ await mkdir(startFrom, { recursive: true });
231
+
232
+ const entries = await discoverProjectWorkflows(startFrom);
233
+ expect(entries).toEqual([]);
234
+ });
235
+
236
+ test("G2: .git file (worktree) stops traversal", async () => {
237
+ // Setup: tmpDir/repo/.git as a FILE, tmpDir/.workflows/leak.yaml, start from tmpDir/repo/pkg/
238
+ const repoDir = join(tmpDir, "repo");
239
+ await mkdir(repoDir, { recursive: true });
240
+ await writeFile(join(repoDir, ".git"), "gitdir: /some/other/path/.git/worktrees/repo");
241
+
242
+ const leakDir = join(tmpDir, ".workflows");
243
+ await mkdir(leakDir, { recursive: true });
244
+ await writeFile(join(leakDir, "leak.yaml"), await createWorkflowYaml("leak"));
245
+
246
+ const startFrom = join(repoDir, "pkg");
247
+ await mkdir(startFrom, { recursive: true });
248
+
249
+ const entries = await discoverProjectWorkflows(startFrom);
250
+ expect(entries).toEqual([]);
251
+ });
252
+
253
+ test("G3: workflow at .git boundary IS found (primary .workflows/)", async () => {
254
+ // Setup: tmpDir/repo/.git/ (dir), tmpDir/repo/.workflows/local.yaml, start from tmpDir/repo/sub/
255
+ const repoDir = join(tmpDir, "repo");
256
+ const gitDir = join(repoDir, ".git");
257
+ await mkdir(gitDir, { recursive: true });
258
+
259
+ const wfDir = join(repoDir, ".workflows");
260
+ await mkdir(wfDir, { recursive: true });
261
+ await writeFile(join(wfDir, "local.yaml"), await createWorkflowYaml("local"));
262
+
263
+ const startFrom = join(repoDir, "sub");
264
+ await mkdir(startFrom, { recursive: true });
265
+
266
+ const entries = await discoverProjectWorkflows(startFrom);
267
+ expect(entries.map((e) => e.name)).toContain("local");
268
+ });
269
+
270
+ test("G4: workflow below .git is found, above is not", async () => {
271
+ // Setup: tmpDir/repo/.git/ + tmpDir/repo/.workflows/local.yaml + tmpDir/.workflows/leak.yaml
272
+ const repoDir = join(tmpDir, "repo");
273
+ const gitDir = join(repoDir, ".git");
274
+ await mkdir(gitDir, { recursive: true });
275
+
276
+ const localWfDir = join(repoDir, ".workflows");
277
+ await mkdir(localWfDir, { recursive: true });
278
+ await writeFile(join(localWfDir, "local.yaml"), await createWorkflowYaml("local"));
279
+
280
+ const leakDir = join(tmpDir, ".workflows");
281
+ await mkdir(leakDir, { recursive: true });
282
+ await writeFile(join(leakDir, "leak.yaml"), await createWorkflowYaml("leak"));
283
+
284
+ const startFrom = join(repoDir, "sub");
285
+ await mkdir(startFrom, { recursive: true });
286
+
287
+ const entries = await discoverProjectWorkflows(startFrom);
288
+ expect(entries.map((e) => e.name)).toEqual(["local"]);
289
+ });
290
+ });
291
+
292
+ // ── findWorkflowInParents (via cmdThreadStart) — .git boundary ───────────────
293
+
294
+ describe("findWorkflowInParents via cmdThreadStart — .git boundary", () => {
295
+ test("G5: .git stops traversal — workflow above boundary is not found", async () => {
296
+ await makeUwfStore(storageRoot);
297
+ const repoDir = join(tmpDir, "repo");
298
+ const gitDir = join(repoDir, ".git");
299
+ await mkdir(gitDir, { recursive: true });
300
+
301
+ // Workflow above .git boundary
302
+ const leakDir = join(tmpDir, ".workflows");
303
+ await mkdir(leakDir, { recursive: true });
304
+ await writeFile(join(leakDir, "leak.yaml"), await createWorkflowYaml("leak"));
305
+
306
+ const startFrom = join(repoDir, "sub");
307
+ await mkdir(startFrom, { recursive: true });
308
+
309
+ // cmdThreadStart should fail — "leak" is above the .git boundary
310
+ await expect(cmdThreadStart(storageRoot, "leak", "prompt", startFrom)).rejects.toThrow();
311
+ });
312
+
313
+ test("G6: workflow at .git boundary IS found via cmdThreadStart", async () => {
314
+ await makeUwfStore(storageRoot);
315
+ const repoDir = join(tmpDir, "repo");
316
+ const gitDir = join(repoDir, ".git");
317
+ await mkdir(gitDir, { recursive: true });
318
+
319
+ const wfDir = join(repoDir, ".workflows");
320
+ await mkdir(wfDir, { recursive: true });
321
+ await writeFile(join(wfDir, "local.yaml"), await createWorkflowYaml("local"));
322
+
323
+ const startFrom = join(repoDir, "sub");
324
+ await mkdir(startFrom, { recursive: true });
325
+
326
+ const result = await cmdThreadStart(storageRoot, "local", "prompt", startFrom);
327
+ expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
328
+ });
329
+ });
330
+
331
+ // ── cmdWorkflowList — parent traversal ───────────────────────────────────────
332
+
333
+ describe("cmdWorkflowList — parent traversal", () => {
334
+ test("B9: lists local workflows discovered from a subdirectory", async () => {
335
+ await makeUwfStore(storageRoot);
336
+ const wfDir = join(projectRoot, ".workflows");
337
+ await mkdir(wfDir, { recursive: true });
338
+ await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
339
+
340
+ const subdir = join(projectRoot, "packages", "foo", "src");
341
+ await mkdir(subdir, { recursive: true });
342
+
343
+ const result = await cmdWorkflowList(storageRoot, subdir);
344
+
345
+ const match = result.find((e) => e.name === "solve-issue");
346
+ expect(match).toBeDefined();
347
+ expect(match?.hash).toBe("(local)");
348
+ expect(match?.origin).toBe("local");
349
+ });
350
+
351
+ test("aligns with cmdThreadStart discovery from same subdirectory", async () => {
352
+ await makeUwfStore(storageRoot);
353
+ const wfDir = join(projectRoot, ".workflows");
354
+ await mkdir(wfDir, { recursive: true });
355
+ await writeFile(join(wfDir, "foo.yaml"), await createWorkflowYaml("foo"));
356
+
357
+ const subdir = join(projectRoot, "packages", "foo", "src");
358
+ await mkdir(subdir, { recursive: true });
359
+
360
+ // cmdThreadStart already resolves foo successfully from subdir (existing behavior)
361
+ const startResult = await cmdThreadStart(storageRoot, "foo", "prompt", subdir);
362
+ expect(startResult.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
363
+
364
+ // cmdWorkflowList must ALSO include foo (newly aligned behavior)
365
+ const listResult = await cmdWorkflowList(storageRoot, subdir);
366
+ const match = listResult.find((e) => e.name === "foo");
367
+ expect(match).toBeDefined();
368
+ expect(match?.origin).toBe("local");
369
+ });
370
+ });