@united-workforce/cli 0.4.0 → 0.6.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 (223) hide show
  1. package/README.md +30 -3
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/__tests__/adapter-json-roundtrip.test.js +16 -6
  4. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  5. package/dist/__tests__/concurrency.test.d.ts +2 -0
  6. package/dist/__tests__/concurrency.test.d.ts.map +1 -0
  7. package/dist/__tests__/concurrency.test.js +196 -0
  8. package/dist/__tests__/concurrency.test.js.map +1 -0
  9. package/dist/__tests__/config-text-renderer.test.d.ts +2 -0
  10. package/dist/__tests__/config-text-renderer.test.d.ts.map +1 -0
  11. package/dist/__tests__/config-text-renderer.test.js +137 -0
  12. package/dist/__tests__/config-text-renderer.test.js.map +1 -0
  13. package/dist/__tests__/e2e-mock-agent.test.js +23 -7
  14. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  15. package/dist/__tests__/format-text-default.test.d.ts +2 -0
  16. package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
  17. package/dist/__tests__/format-text-default.test.js +43 -0
  18. package/dist/__tests__/format-text-default.test.js.map +1 -0
  19. package/dist/__tests__/format-text-registry.test.d.ts +2 -0
  20. package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
  21. package/dist/__tests__/format-text-registry.test.js +158 -0
  22. package/dist/__tests__/format-text-registry.test.js.map +1 -0
  23. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +1 -1
  24. package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
  25. package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
  26. package/dist/__tests__/log-text-renderer.test.js +265 -0
  27. package/dist/__tests__/log-text-renderer.test.js.map +1 -0
  28. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
  29. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
  30. package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
  31. package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
  32. package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
  33. package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
  34. package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
  35. package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
  36. package/dist/__tests__/pid-recycling.test.js +9 -7
  37. package/dist/__tests__/pid-recycling.test.js.map +1 -1
  38. package/dist/__tests__/prompt.test.js +46 -4
  39. package/dist/__tests__/prompt.test.js.map +1 -1
  40. package/dist/__tests__/resolve-head-hash.test.js +8 -0
  41. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  42. package/dist/__tests__/solve-issue-tea-worktree.test.js +3 -1
  43. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  44. package/dist/__tests__/step-ask.test.js +9 -1
  45. package/dist/__tests__/step-ask.test.js.map +1 -1
  46. package/dist/__tests__/store-unified-threads.test.js +19 -17
  47. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  48. package/dist/__tests__/thread-agent-failure-suspended.test.d.ts +2 -0
  49. package/dist/__tests__/thread-agent-failure-suspended.test.d.ts.map +1 -0
  50. package/dist/__tests__/thread-agent-failure-suspended.test.js +332 -0
  51. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -0
  52. package/dist/__tests__/thread-cancel-status.test.js +19 -13
  53. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  54. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
  55. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
  56. package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
  57. package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
  58. package/dist/__tests__/thread-join.test.d.ts +2 -0
  59. package/dist/__tests__/thread-join.test.d.ts.map +1 -0
  60. package/dist/__tests__/thread-join.test.js +77 -0
  61. package/dist/__tests__/thread-join.test.js.map +1 -0
  62. package/dist/__tests__/thread-list-filters.test.js +10 -8
  63. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  64. package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
  65. package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
  66. package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
  67. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
  68. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
  69. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
  70. package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
  71. package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
  72. package/dist/__tests__/thread-poke.test.js +15 -2
  73. package/dist/__tests__/thread-poke.test.js.map +1 -1
  74. package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
  75. package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
  76. package/dist/__tests__/thread-resume.test.js +11 -1
  77. package/dist/__tests__/thread-resume.test.js.map +1 -1
  78. package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
  79. package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
  80. package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
  81. package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
  82. package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
  83. package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
  84. package/dist/__tests__/thread-suspend-step.test.js +5 -2
  85. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  86. package/dist/__tests__/thread-test-helpers.d.ts +7 -0
  87. package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
  88. package/dist/__tests__/thread-test-helpers.js +13 -0
  89. package/dist/__tests__/thread-test-helpers.js.map +1 -1
  90. package/dist/__tests__/thread.test.js +11 -9
  91. package/dist/__tests__/thread.test.js.map +1 -1
  92. package/dist/__tests__/validate-semantic.test.js +56 -2
  93. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  94. package/dist/__tests__/workflow-list-recursive.test.js +10 -7
  95. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -1
  96. package/dist/__tests__/workflow-paths.test.d.ts +2 -0
  97. package/dist/__tests__/workflow-paths.test.d.ts.map +1 -0
  98. package/dist/__tests__/workflow-paths.test.js +261 -0
  99. package/dist/__tests__/workflow-paths.test.js.map +1 -0
  100. package/dist/__tests__/workflow-resolution.test.js +10 -7
  101. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  102. package/dist/__tests__/workflow-show-resolution.test.js +10 -7
  103. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -1
  104. package/dist/__tests__/workflow-validate.test.js +75 -55
  105. package/dist/__tests__/workflow-validate.test.js.map +1 -1
  106. package/dist/__tests__/write-envelope.test.d.ts +2 -0
  107. package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
  108. package/dist/__tests__/write-envelope.test.js +201 -0
  109. package/dist/__tests__/write-envelope.test.js.map +1 -0
  110. package/dist/cli.js +76 -36
  111. package/dist/cli.js.map +1 -1
  112. package/dist/commands/config.d.ts +5 -0
  113. package/dist/commands/config.d.ts.map +1 -1
  114. package/dist/commands/config.js +81 -3
  115. package/dist/commands/config.js.map +1 -1
  116. package/dist/commands/prompt.d.ts.map +1 -1
  117. package/dist/commands/prompt.js +42 -29
  118. package/dist/commands/prompt.js.map +1 -1
  119. package/dist/commands/setup.d.ts +9 -4
  120. package/dist/commands/setup.d.ts.map +1 -1
  121. package/dist/commands/setup.js +51 -7
  122. package/dist/commands/setup.js.map +1 -1
  123. package/dist/commands/thread.d.ts +12 -0
  124. package/dist/commands/thread.d.ts.map +1 -1
  125. package/dist/commands/thread.js +226 -9
  126. package/dist/commands/thread.js.map +1 -1
  127. package/dist/commands/workflow.d.ts +2 -2
  128. package/dist/commands/workflow.d.ts.map +1 -1
  129. package/dist/commands/workflow.js +26 -10
  130. package/dist/commands/workflow.js.map +1 -1
  131. package/dist/concurrency/concurrency.d.ts +34 -0
  132. package/dist/concurrency/concurrency.d.ts.map +1 -0
  133. package/dist/concurrency/concurrency.js +216 -0
  134. package/dist/concurrency/concurrency.js.map +1 -0
  135. package/dist/concurrency/index.d.ts +3 -0
  136. package/dist/concurrency/index.d.ts.map +1 -0
  137. package/dist/concurrency/index.js +2 -0
  138. package/dist/concurrency/index.js.map +1 -0
  139. package/dist/concurrency/types.d.ts +19 -0
  140. package/dist/concurrency/types.d.ts.map +1 -0
  141. package/dist/concurrency/types.js +2 -0
  142. package/dist/concurrency/types.js.map +1 -0
  143. package/dist/format.d.ts +69 -2
  144. package/dist/format.d.ts.map +1 -1
  145. package/dist/format.js +198 -1
  146. package/dist/format.js.map +1 -1
  147. package/dist/output-mappers.d.ts +122 -0
  148. package/dist/output-mappers.d.ts.map +1 -0
  149. package/dist/output-mappers.js +134 -0
  150. package/dist/output-mappers.js.map +1 -0
  151. package/dist/schemas.d.ts +4 -1
  152. package/dist/schemas.d.ts.map +1 -1
  153. package/dist/schemas.js +31 -4
  154. package/dist/schemas.js.map +1 -1
  155. package/dist/store.d.ts +11 -0
  156. package/dist/store.d.ts.map +1 -1
  157. package/dist/store.js +20 -1
  158. package/dist/store.js.map +1 -1
  159. package/dist/text-renderers.d.ts +30 -0
  160. package/dist/text-renderers.d.ts.map +1 -0
  161. package/dist/text-renderers.js +251 -0
  162. package/dist/text-renderers.js.map +1 -0
  163. package/dist/validate-semantic.d.ts.map +1 -1
  164. package/dist/validate-semantic.js +28 -11
  165. package/dist/validate-semantic.js.map +1 -1
  166. package/examples/brainstorm.yaml +130 -0
  167. package/examples/debate.yaml +169 -0
  168. package/examples/socratic-questioning.yaml +112 -0
  169. package/package.json +12 -11
  170. package/src/__tests__/adapter-json-roundtrip.test.ts +15 -6
  171. package/src/__tests__/concurrency.test.ts +266 -0
  172. package/src/__tests__/config-text-renderer.test.ts +156 -0
  173. package/src/__tests__/e2e-mock-agent.test.ts +45 -7
  174. package/src/__tests__/format-text-default.test.ts +49 -0
  175. package/src/__tests__/format-text-registry.test.ts +173 -0
  176. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +1 -1
  177. package/src/__tests__/log-text-renderer.test.ts +294 -0
  178. package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
  179. package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
  180. package/src/__tests__/pid-recycling.test.ts +9 -8
  181. package/src/__tests__/prompt.test.ts +48 -4
  182. package/src/__tests__/resolve-head-hash.test.ts +7 -0
  183. package/src/__tests__/solve-issue-tea-worktree.test.ts +3 -1
  184. package/src/__tests__/step-ask.test.ts +8 -1
  185. package/src/__tests__/store-unified-threads.test.ts +21 -18
  186. package/src/__tests__/thread-agent-failure-suspended.test.ts +406 -0
  187. package/src/__tests__/thread-cancel-status.test.ts +21 -14
  188. package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
  189. package/src/__tests__/thread-join.test.ts +103 -0
  190. package/src/__tests__/thread-list-filters.test.ts +9 -9
  191. package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
  192. package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
  193. package/src/__tests__/thread-poke.test.ts +14 -2
  194. package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
  195. package/src/__tests__/thread-resume.test.ts +10 -1
  196. package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
  197. package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
  198. package/src/__tests__/thread-suspend-step.test.ts +5 -2
  199. package/src/__tests__/thread-test-helpers.ts +15 -1
  200. package/src/__tests__/thread.test.ts +10 -10
  201. package/src/__tests__/validate-semantic.test.ts +59 -2
  202. package/src/__tests__/workflow-list-recursive.test.ts +9 -9
  203. package/src/__tests__/workflow-paths.test.ts +337 -0
  204. package/src/__tests__/workflow-resolution.test.ts +9 -8
  205. package/src/__tests__/workflow-show-resolution.test.ts +9 -8
  206. package/src/__tests__/workflow-validate.test.ts +78 -56
  207. package/src/__tests__/write-envelope.test.ts +257 -0
  208. package/src/cli.ts +111 -35
  209. package/src/commands/config.ts +85 -3
  210. package/src/commands/prompt.ts +42 -29
  211. package/src/commands/setup.ts +57 -7
  212. package/src/commands/thread.ts +280 -9
  213. package/src/commands/workflow.ts +32 -11
  214. package/src/concurrency/concurrency.ts +245 -0
  215. package/src/concurrency/index.ts +10 -0
  216. package/src/concurrency/types.ts +19 -0
  217. package/src/format.ts +282 -2
  218. package/src/output-mappers.ts +255 -0
  219. package/src/schemas.ts +39 -3
  220. package/src/store.ts +25 -1
  221. package/src/text-renderers.ts +355 -0
  222. package/src/validate-semantic.ts +33 -12
  223. package/LICENSE +0 -21
@@ -43,6 +43,7 @@ import {
43
43
  isThreadRunning,
44
44
  readMarker,
45
45
  } from "../background/index.js";
46
+ import { acquireSlot, DEFAULT_MAX_RUNNING, installSlotCleanup } from "../concurrency/index.js";
46
47
  import { createIncludeTag } from "../include.js";
47
48
  import { evaluate } from "../moderator/index.js";
48
49
  import {
@@ -60,6 +61,13 @@ import {
60
61
  } from "../store.js";
61
62
  import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
62
63
  import { validateWorkflow } from "../validate-semantic.js";
64
+ import {
65
+ getConfigPath,
66
+ getNestedValue,
67
+ loadConfig,
68
+ loadWorkflowPaths,
69
+ parseDotPath,
70
+ } from "./config.js";
63
71
  import {
64
72
  type ChainState,
65
73
  collectOrderedSteps,
@@ -166,6 +174,13 @@ async function resolveActiveThreadStatus(
166
174
  return "running";
167
175
  }
168
176
 
177
+ // Check the persisted entry status first — agent failure suspends the thread
178
+ // via markThreadSuspended() without producing a $SUSPEND output in CAS.
179
+ const entry = getThread(uwf.varStore, threadId);
180
+ if (entry !== null && entry.status === "suspended") {
181
+ return "suspended";
182
+ }
183
+
169
184
  const chain = walkChain(uwf, head);
170
185
  const { lastOutput } = resolveEvaluateArgs(uwf, chain);
171
186
  if (readSuspendReason(lastOutput) !== null) {
@@ -225,9 +240,20 @@ function buildResumePrompt(graphPrompt: string, supplement: string | null): stri
225
240
  return `${graphPrompt}\n\n${supplement}`;
226
241
  }
227
242
 
243
+ /**
244
+ * Error thrown by failStep so that callers can catch it, persist
245
+ * thread state (e.g. suspend), and then re-throw / exit.
246
+ */
247
+ class StepFailureError extends Error {
248
+ constructor(message: string) {
249
+ super(message);
250
+ this.name = "StepFailureError";
251
+ }
252
+ }
253
+
228
254
  function failStep(plog: ProcessLogger, message: string): never {
229
255
  plog.log(PL_STEP_ERROR, message, null);
230
- fail(message);
256
+ throw new StepFailureError(message);
231
257
  }
232
258
 
233
259
  /**
@@ -344,6 +370,49 @@ async function findWorkflowInParents(startDir: string, name: string): Promise<st
344
370
  return null;
345
371
  }
346
372
 
373
+ /**
374
+ * Search for a workflow by name directly in a directory (not inside .workflows/).
375
+ * Used for workflowPaths resolution — each path dir contains YAMLs at top level.
376
+ * Checks flat files (<name>.yaml/.yml) and folder layout (<name>/index.yaml/.yml).
377
+ */
378
+ async function findWorkflowInPath(dir: string, name: string): Promise<string | null> {
379
+ // Check flat YAML files
380
+ for (const ext of [".yaml", ".yml"]) {
381
+ const result = await workflowFileExists(dir, name, ext);
382
+ if (result !== null) {
383
+ return result;
384
+ }
385
+ }
386
+ // Check folder-based layout (<name>/index.yaml)
387
+ for (const indexName of ["index.yaml", "index.yml"]) {
388
+ const candidate = resolvePath(dir, name, indexName);
389
+ try {
390
+ await access(candidate);
391
+ return candidate;
392
+ } catch {
393
+ /* not found */
394
+ }
395
+ }
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Search workflowPaths directories for a workflow by name.
401
+ * Searches each directory in order; first match wins.
402
+ */
403
+ async function findWorkflowInPaths(
404
+ dirs: ReadonlyArray<string>,
405
+ name: string,
406
+ ): Promise<string | null> {
407
+ for (const dir of dirs) {
408
+ const found = await findWorkflowInPath(dir, name);
409
+ if (found !== null) {
410
+ return found;
411
+ }
412
+ }
413
+ return null;
414
+ }
415
+
347
416
  async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
348
417
  let text: string;
349
418
  try {
@@ -419,6 +488,13 @@ async function resolveWorkflowCasRef(
419
488
  return materializeLocalWorkflow(uwf, localPath);
420
489
  }
421
490
 
491
+ // Strategy 3.5: workflowPaths global directories
492
+ const workflowPaths = loadWorkflowPaths(uwf.storageRoot);
493
+ const pathsFile = await findWorkflowInPaths(workflowPaths, trimmed);
494
+ if (pathsFile !== null) {
495
+ return materializeLocalWorkflow(uwf, pathsFile);
496
+ }
497
+
422
498
  // Strategy 4: Global registry fallback
423
499
  const registry = loadWorkflowRegistry(uwf.varStore);
424
500
  const hash = resolveWorkflowHash(registry, trimmed);
@@ -992,6 +1068,15 @@ type EvaluateLastOutput = Record<string, unknown>;
992
1068
 
993
1069
  const STATUS_KEY = "$status";
994
1070
 
1071
+ /**
1072
+ * Strip YAML frontmatter (---...---) from a raw markdown string,
1073
+ * returning only the body portion.
1074
+ */
1075
+ function stripFrontmatter(raw: string): string {
1076
+ const match = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
1077
+ return match ? raw.slice(match[0].length).trim() : raw.trim();
1078
+ }
1079
+
995
1080
  function resolveEvaluateArgs(
996
1081
  uwf: UwfStore,
997
1082
  chain: ChainState,
@@ -1011,6 +1096,13 @@ function resolveEvaluateArgs(
1011
1096
  ? (raw as Record<string, unknown>)
1012
1097
  : {};
1013
1098
 
1099
+ // Inject _body — the markdown body (after frontmatter) from the last step's
1100
+ // assistant output. Workflow edge prompts can reference it via {{ _body }}.
1101
+ const content = extractLastAssistantContent(uwf, lastStep.detail);
1102
+ if (content !== null) {
1103
+ base._body = stripFrontmatter(content);
1104
+ }
1105
+
1014
1106
  return {
1015
1107
  lastRole: lastStep.role,
1016
1108
  lastOutput: base,
@@ -1020,10 +1112,10 @@ function resolveEvaluateArgs(
1020
1112
  function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
1021
1113
  const node = uwf.store.cas.get(workflowRef);
1022
1114
  if (node === null) {
1023
- fail(`workflow CAS node not found: ${workflowRef}`);
1115
+ throw new Error(`workflow CAS node not found: ${workflowRef}`);
1024
1116
  }
1025
1117
  if (node.type !== uwf.schemas.workflow) {
1026
- fail(`node ${workflowRef} is not a Workflow`);
1118
+ throw new Error(`node ${workflowRef} is not a Workflow`);
1027
1119
  }
1028
1120
  return node.payload as WorkflowPayload;
1029
1121
  }
@@ -1360,7 +1452,18 @@ export async function cmdThreadPoke(
1360
1452
  // Spawn the agent. The agent will create a new StepNode with prev=oldHead (it reads
1361
1453
  // the active thread head). After the agent returns, we rewrite that node's prev so
1362
1454
  // that the new head replaces the old head instead of appending after it.
1363
- const agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
1455
+ let agentResult: AdapterOutput;
1456
+ try {
1457
+ agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
1458
+ } catch (e) {
1459
+ if (e instanceof StepFailureError) {
1460
+ // Fatal agent failure in poke — persist suspended state before propagating
1461
+ const uwfErr = await createUwfStore(storageRoot);
1462
+ const errEntry = getThread(uwfErr.varStore, threadId) ?? entry;
1463
+ setThread(uwfErr.varStore, threadId, markThreadSuspended(errEntry, role, e.message));
1464
+ }
1465
+ throw e;
1466
+ }
1364
1467
  const agentStepHash = agentResult.stepHash as CasRef;
1365
1468
 
1366
1469
  plog.log(PL_AGENT_DONE, `agent returned head=${agentStepHash}`, null);
@@ -1406,6 +1509,25 @@ export function validateCount(count: number): void {
1406
1509
  }
1407
1510
  }
1408
1511
 
1512
+ /**
1513
+ * Resolve the effective maxRunning limit.
1514
+ * Priority: config file > DEFAULT_MAX_RUNNING (2).
1515
+ */
1516
+ async function resolveMaxRunning(storageRoot: string): Promise<number> {
1517
+ try {
1518
+ const configPath = getConfigPath(storageRoot);
1519
+ const config = loadConfig(configPath);
1520
+ const path = parseDotPath("concurrency.maxRunning");
1521
+ const value = getNestedValue(config, path);
1522
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
1523
+ return value;
1524
+ }
1525
+ } catch {
1526
+ // Config file missing or invalid — fall through to default
1527
+ }
1528
+ return DEFAULT_MAX_RUNNING;
1529
+ }
1530
+
1409
1531
  export async function cmdThreadExec(
1410
1532
  storageRoot: string,
1411
1533
  threadId: ThreadId,
@@ -1446,6 +1568,13 @@ export async function cmdThreadExec(
1446
1568
  processStartTime: getProcessStartTime(process.pid),
1447
1569
  });
1448
1570
 
1571
+ // Resolve concurrency limit: config > default
1572
+ const effectiveMaxRunning = await resolveMaxRunning(storageRoot);
1573
+
1574
+ // Acquire concurrency slot (blocks if at capacity)
1575
+ const slotHandle = await acquireSlot(storageRoot, effectiveMaxRunning);
1576
+ const uninstallCleanup = installSlotCleanup(slotHandle);
1577
+
1449
1578
  try {
1450
1579
  const results: StepOutput[] = [];
1451
1580
  for (let i = 0; i < count; i++) {
@@ -1457,10 +1586,101 @@ export async function cmdThreadExec(
1457
1586
  }
1458
1587
  return results;
1459
1588
  } finally {
1589
+ uninstallCleanup();
1590
+ await slotHandle.release();
1460
1591
  await deleteMarker(storageRoot, threadId);
1461
1592
  }
1462
1593
  }
1463
1594
 
1595
+ const JOIN_POLL_INTERVAL_MS = 1000;
1596
+
1597
+ /**
1598
+ * Block until a running thread finishes (marker disappears), then return the
1599
+ * final thread state in the same `StepOutput[]` format that `cmdThreadExec`
1600
+ * produces.
1601
+ *
1602
+ * - If the thread is currently running → poll until it stops, then return
1603
+ * its final state.
1604
+ * - If the thread is not running → return its current state immediately.
1605
+ *
1606
+ * An optional `timeoutMs` aborts the wait with an error when exceeded.
1607
+ */
1608
+ export async function cmdThreadJoin(
1609
+ storageRoot: string,
1610
+ threadId: ThreadId,
1611
+ timeoutMs: number | null,
1612
+ ): Promise<StepOutput[]> {
1613
+ const uwf = await createUwfStore(storageRoot);
1614
+ const entry = getThread(uwf.varStore, threadId);
1615
+ if (entry === null) {
1616
+ fail(`thread not found: ${threadId}`);
1617
+ }
1618
+
1619
+ // Wait for running marker to disappear
1620
+ const deadline = timeoutMs !== null ? Date.now() + timeoutMs : null;
1621
+
1622
+ while ((await isThreadRunning(storageRoot, threadId)) !== null) {
1623
+ if (deadline !== null && Date.now() >= deadline) {
1624
+ fail(`join timed out after ${timeoutMs}ms — thread ${threadId} is still running`);
1625
+ }
1626
+ await new Promise<void>((resolve) => {
1627
+ setTimeout(resolve, JOIN_POLL_INTERVAL_MS);
1628
+ });
1629
+ }
1630
+
1631
+ // Thread is no longer running — read final state.
1632
+ // Re-open the store to get the latest state written by the worker.
1633
+ const freshUwf = await createUwfStore(storageRoot);
1634
+ const freshEntry = getThread(freshUwf.varStore, threadId);
1635
+ if (freshEntry === null) {
1636
+ fail(`thread disappeared after join: ${threadId}`);
1637
+ }
1638
+
1639
+ const activeHead = freshEntry.head;
1640
+ const workflowHash = resolveWorkflowFromHead(freshUwf, activeHead);
1641
+ if (workflowHash === null) {
1642
+ fail(`failed to resolve workflow from head: ${activeHead}`);
1643
+ }
1644
+
1645
+ // Build the StepOutput matching exec's format
1646
+ if (freshEntry.status === "end" || freshEntry.status === "cancelled") {
1647
+ return [
1648
+ {
1649
+ workflow: workflowHash,
1650
+ thread: threadId,
1651
+ head: activeHead,
1652
+ status: freshEntry.status,
1653
+ currentRole: null,
1654
+ suspendedRole: null,
1655
+ suspendMessage: null,
1656
+ done: true,
1657
+ background: null,
1658
+ error: null,
1659
+ },
1660
+ ];
1661
+ }
1662
+
1663
+ // Active thread — resolve detailed status
1664
+ const status = await resolveActiveThreadStatus(storageRoot, threadId, freshUwf, activeHead);
1665
+ const currentRole = resolveCurrentRole(freshUwf, activeHead, workflowHash);
1666
+ const suspendFields = resolveSuspendFieldsForShow(freshEntry, status, freshUwf, activeHead);
1667
+
1668
+ return [
1669
+ {
1670
+ workflow: workflowHash,
1671
+ thread: threadId,
1672
+ head: activeHead,
1673
+ status,
1674
+ currentRole,
1675
+ suspendedRole: suspendFields.suspendedRole,
1676
+ suspendMessage: suspendFields.suspendMessage,
1677
+ done: false,
1678
+ background: null,
1679
+ error: null,
1680
+ },
1681
+ ];
1682
+ }
1683
+
1464
1684
  async function resolveActiveThreadWorkflowHash(
1465
1685
  storageRoot: string,
1466
1686
  threadId: ThreadId,
@@ -1715,6 +1935,51 @@ async function cmdThreadStepOnce(
1715
1935
  });
1716
1936
 
1717
1937
  loadDotenv({ path: getEnvPath(storageRoot) });
1938
+
1939
+ // Wrap agent execution in a try-catch: when the agent command crashes
1940
+ // (non-zero exit, unparseable output, invalid CAS node, etc.), failStep throws
1941
+ // StepFailureError. We catch it to persist suspended state before re-throwing
1942
+ // so the CLI still exits non-zero.
1943
+ try {
1944
+ return await executeAndProcessAgentStep(
1945
+ storageRoot,
1946
+ threadId,
1947
+ headHash,
1948
+ workflowHash,
1949
+ workflow,
1950
+ role,
1951
+ edgePrompt,
1952
+ effectiveCwd,
1953
+ agent,
1954
+ plog,
1955
+ );
1956
+ } catch (e) {
1957
+ if (e instanceof StepFailureError) {
1958
+ // Fatal agent failure — persist suspended state before propagating
1959
+ const uwfErr = await createUwfStore(storageRoot);
1960
+ const errEntry = getThread(uwfErr.varStore, threadId) ?? createThreadIndexEntry(headHash);
1961
+ setThread(uwfErr.varStore, threadId, markThreadSuspended(errEntry, role, e.message));
1962
+ }
1963
+ throw e;
1964
+ }
1965
+ }
1966
+
1967
+ /**
1968
+ * Execute the agent command and process the result. Separated from cmdThreadStepOnce
1969
+ * so that fatal failures (StepFailureError) can be caught and handled by the caller.
1970
+ */
1971
+ async function executeAndProcessAgentStep(
1972
+ storageRoot: string,
1973
+ threadId: ThreadId,
1974
+ headHash: CasRef,
1975
+ workflowHash: CasRef,
1976
+ workflow: WorkflowPayload,
1977
+ role: string,
1978
+ edgePrompt: string,
1979
+ effectiveCwd: string,
1980
+ agent: AgentConfig,
1981
+ plog: ProcessLogger,
1982
+ ): Promise<StepOutput> {
1718
1983
  const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
1719
1984
  const newHead = agentResult.stepHash as CasRef;
1720
1985
 
@@ -1732,22 +1997,28 @@ async function cmdThreadStepOnce(
1732
1997
  // next exec (until eventual success records `previousAttempts` linking the
1733
1998
  // failed step hashes).
1734
1999
  if (agentResult.isError === true) {
2000
+ const errorMsg = agentResult.errorMessage ?? "agent reported error";
1735
2001
  plog.log(
1736
2002
  PL_AGENT_ERROR,
1737
- `agent reported recoverable failure stepHash=${newHead} message=${agentResult.errorMessage ?? ""}`,
2003
+ `agent reported recoverable failure stepHash=${newHead} message=${errorMsg}`,
1738
2004
  null,
1739
2005
  );
2006
+
2007
+ // Persist suspended state so `thread list --status suspended` reflects the failure.
2008
+ const priorEntry = getThread(uwfAfter.varStore, threadId) ?? createThreadIndexEntry(headHash);
2009
+ setThread(uwfAfter.varStore, threadId, markThreadSuspended(priorEntry, role, errorMsg));
2010
+
1740
2011
  return {
1741
2012
  workflow: workflowHash,
1742
2013
  thread: threadId,
1743
2014
  head: headHash,
1744
- status: "idle",
2015
+ status: "suspended",
1745
2016
  currentRole: role,
1746
- suspendedRole: null,
1747
- suspendMessage: null,
2017
+ suspendedRole: role,
2018
+ suspendMessage: errorMsg,
1748
2019
  done: false,
1749
2020
  background: null,
1750
- error: { stepHash: newHead, message: agentResult.errorMessage ?? "agent reported error" },
2021
+ error: { stepHash: newHead, message: errorMsg },
1751
2022
  };
1752
2023
  }
1753
2024
 
@@ -10,6 +10,7 @@ import { createIncludeTag } from "../include.js";
10
10
  import {
11
11
  createUwfStore,
12
12
  discoverProjectWorkflows,
13
+ discoverWorkflowPathsEntries,
13
14
  findRegistryName,
14
15
  loadWorkflowRegistry,
15
16
  resolveProjectWorkflowFile,
@@ -24,8 +25,9 @@ import {
24
25
  parseWorkflowPayload,
25
26
  } from "../validate.js";
26
27
  import { validateWorkflow } from "../validate-semantic.js";
28
+ import { loadWorkflowPaths } from "./config.js";
27
29
 
28
- export type WorkflowOrigin = "local" | "global";
30
+ export type WorkflowOrigin = "local" | "paths" | "global";
29
31
 
30
32
  export type WorkflowListEntry = {
31
33
  name: string;
@@ -126,7 +128,7 @@ export async function materializeWorkflowPayload(
126
128
  * returns silently (no stdout/stderr) and exits 0. On any error, writes a
127
129
  * single message to stderr and exits 1.
128
130
  */
129
- export async function cmdWorkflowValidate(filePath: string): Promise<void> {
131
+ export async function cmdWorkflowValidate(filePath: string): Promise<string[]> {
130
132
  let text: string;
131
133
  try {
132
134
  text = await readFile(filePath, "utf8");
@@ -150,20 +152,19 @@ export async function cmdWorkflowValidate(filePath: string): Promise<void> {
150
152
 
151
153
  const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
152
154
  if (filenameError !== null) {
153
- fail(filenameError);
155
+ return [filenameError];
154
156
  }
155
157
 
156
- const semanticErrors = validateWorkflow(payload);
157
- if (semanticErrors.length > 0) {
158
- fail(`workflow validation failed:\n${semanticErrors.map((e) => ` - ${e}`).join("\n")}`);
159
- }
160
- // success: silent return
158
+ return validateWorkflow(payload);
161
159
  }
162
160
 
163
161
  export async function cmdWorkflowAdd(
164
162
  storageRoot: string,
165
163
  filePath: string,
166
164
  ): Promise<WorkflowAddOutput> {
165
+ process.stderr.write(
166
+ "warning: `uwf workflow add` is deprecated. Use workflowPaths in ~/.uwf/config.yaml instead. See issue #360.\n",
167
+ );
167
168
  let text: string;
168
169
  try {
169
170
  text = await readFile(filePath, "utf8");
@@ -299,6 +300,14 @@ async function resolveWorkflowCasRefForShow(
299
300
  return materializeLocalWorkflowForShow(uwf, localPath);
300
301
  }
301
302
 
303
+ // Strategy 3.5: workflowPaths global directories
304
+ const workflowPaths = loadWorkflowPaths(uwf.storageRoot);
305
+ const pathsEntries = await discoverWorkflowPathsEntries(workflowPaths);
306
+ const pathsFile = resolveProjectWorkflowFile(pathsEntries, trimmed);
307
+ if (pathsFile !== null) {
308
+ return materializeLocalWorkflowForShow(uwf, pathsFile);
309
+ }
310
+
302
311
  // Strategy 4: Global registry fallback
303
312
  const registry = loadWorkflowRegistry(uwf.varStore);
304
313
  const hash = resolveWorkflowHash(registry, trimmed);
@@ -348,18 +357,30 @@ export async function cmdWorkflowList(
348
357
  ): Promise<WorkflowListEntry[]> {
349
358
  const uwf = await createUwfStore(storageRoot);
350
359
  const localEntries = await discoverProjectWorkflows(projectRoot);
360
+ const workflowPaths = loadWorkflowPaths(storageRoot);
361
+ const pathsEntries = await discoverWorkflowPathsEntries(workflowPaths);
351
362
  const registry = loadWorkflowRegistry(uwf.varStore);
352
363
 
353
364
  const result: WorkflowListEntry[] = [];
354
- const localNames = new Set<string>();
365
+ const seenNames = new Set<string>();
355
366
 
367
+ // Layer 1: local .workflows/ (highest priority)
356
368
  for (const entry of localEntries) {
357
- localNames.add(entry.name);
369
+ seenNames.add(entry.name);
358
370
  result.push({ name: entry.name, hash: "(local)", origin: "local" });
359
371
  }
360
372
 
373
+ // Layer 2: workflowPaths directories
374
+ for (const entry of pathsEntries) {
375
+ if (!seenNames.has(entry.name)) {
376
+ seenNames.add(entry.name);
377
+ result.push({ name: entry.name, hash: "(paths)", origin: "paths" });
378
+ }
379
+ }
380
+
381
+ // Layer 3: global registry (lowest priority)
361
382
  for (const [name, hash] of Object.entries(registry)) {
362
- if (!localNames.has(name)) {
383
+ if (!seenNames.has(name)) {
363
384
  result.push({ name, hash, origin: "global" });
364
385
  }
365
386
  }