@united-workforce/cli 0.2.1-rc.9 → 0.4.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 (219) hide show
  1. package/README.md +15 -8
  2. package/dist/__tests__/adapter-json-roundtrip.test.js +1 -1
  3. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  4. package/dist/__tests__/agent-resolution-llm-free.test.d.ts +2 -0
  5. package/dist/__tests__/agent-resolution-llm-free.test.d.ts.map +1 -0
  6. package/dist/__tests__/agent-resolution-llm-free.test.js +30 -0
  7. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -0
  8. package/dist/__tests__/build-step-entry.test.d.ts +2 -0
  9. package/dist/__tests__/build-step-entry.test.d.ts.map +1 -0
  10. package/dist/__tests__/build-step-entry.test.js +173 -0
  11. package/dist/__tests__/build-step-entry.test.js.map +1 -0
  12. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts +2 -0
  13. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts.map +1 -0
  14. package/dist/__tests__/clear-thread-failed-attempts.test.js +93 -0
  15. package/dist/__tests__/clear-thread-failed-attempts.test.js.map +1 -0
  16. package/dist/__tests__/config.test.js +26 -302
  17. package/dist/__tests__/config.test.js.map +1 -1
  18. package/dist/__tests__/current-role.test.js +7 -6
  19. package/dist/__tests__/current-role.test.js.map +1 -1
  20. package/dist/__tests__/e2e-mock-agent.test.js +20 -23
  21. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  22. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts +2 -0
  23. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts.map +1 -0
  24. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +40 -0
  25. package/dist/__tests__/issue-180-workflow-ref-removed.test.js.map +1 -0
  26. package/dist/__tests__/moderator-evaluate.test.js +9 -50
  27. package/dist/__tests__/moderator-evaluate.test.js.map +1 -1
  28. package/dist/__tests__/pid-recycling.test.d.ts +2 -0
  29. package/dist/__tests__/pid-recycling.test.d.ts.map +1 -0
  30. package/dist/__tests__/pid-recycling.test.js +271 -0
  31. package/dist/__tests__/pid-recycling.test.js.map +1 -0
  32. package/dist/__tests__/prompt.test.js +321 -0
  33. package/dist/__tests__/prompt.test.js.map +1 -1
  34. package/dist/__tests__/resolve-head-hash.test.js +4 -4
  35. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  36. package/dist/__tests__/setup-agent-discovery.test.js +21 -30
  37. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  38. package/dist/__tests__/setup-complexity.test.js +2 -168
  39. package/dist/__tests__/setup-complexity.test.js.map +1 -1
  40. package/dist/__tests__/setup-no-llm.test.d.ts +2 -0
  41. package/dist/__tests__/setup-no-llm.test.d.ts.map +1 -0
  42. package/dist/__tests__/setup-no-llm.test.js +52 -0
  43. package/dist/__tests__/setup-no-llm.test.js.map +1 -0
  44. package/dist/__tests__/solve-issue-tea-worktree.test.js +24 -27
  45. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  46. package/dist/__tests__/step-ask.test.d.ts +2 -0
  47. package/dist/__tests__/step-ask.test.d.ts.map +1 -0
  48. package/dist/__tests__/step-ask.test.js +499 -0
  49. package/dist/__tests__/step-ask.test.js.map +1 -0
  50. package/dist/__tests__/step-show-json.test.js +1 -0
  51. package/dist/__tests__/step-show-json.test.js.map +1 -1
  52. package/dist/__tests__/step-timing.test.js +2 -0
  53. package/dist/__tests__/step-timing.test.js.map +1 -1
  54. package/dist/__tests__/store-global-cas.test.js +2 -2
  55. package/dist/__tests__/store-global-cas.test.js.map +1 -1
  56. package/dist/__tests__/store-unified-threads.test.js +9 -9
  57. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  58. package/dist/__tests__/thread-cancel-status.test.js +6 -6
  59. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  60. package/dist/__tests__/thread-list-filters.test.js +344 -9
  61. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  62. package/dist/__tests__/thread-poke.test.d.ts +2 -0
  63. package/dist/__tests__/thread-poke.test.d.ts.map +1 -0
  64. package/dist/__tests__/thread-poke.test.js +412 -0
  65. package/dist/__tests__/thread-poke.test.js.map +1 -0
  66. package/dist/__tests__/thread-resume.test.js +10 -14
  67. package/dist/__tests__/thread-resume.test.js.map +1 -1
  68. package/dist/__tests__/thread-show-status.test.js +17 -28
  69. package/dist/__tests__/thread-show-status.test.js.map +1 -1
  70. package/dist/__tests__/thread-suspend-step.test.js +8 -14
  71. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  72. package/dist/__tests__/thread-suspended-display.test.js +10 -22
  73. package/dist/__tests__/thread-suspended-display.test.js.map +1 -1
  74. package/dist/__tests__/thread.test.js +4 -4
  75. package/dist/__tests__/thread.test.js.map +1 -1
  76. package/dist/__tests__/validate-semantic.test.js +49 -21
  77. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  78. package/dist/__tests__/workflow-list-recursive.test.d.ts +2 -0
  79. package/dist/__tests__/workflow-list-recursive.test.d.ts.map +1 -0
  80. package/dist/__tests__/workflow-list-recursive.test.js +283 -0
  81. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -0
  82. package/dist/__tests__/workflow-resolution.test.js +36 -21
  83. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  84. package/dist/__tests__/workflow-show-resolution.test.d.ts +2 -0
  85. package/dist/__tests__/workflow-show-resolution.test.d.ts.map +1 -0
  86. package/dist/__tests__/workflow-show-resolution.test.js +210 -0
  87. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -0
  88. package/dist/__tests__/workflow-validate.test.d.ts +2 -0
  89. package/dist/__tests__/workflow-validate.test.d.ts.map +1 -0
  90. package/dist/__tests__/workflow-validate.test.js +687 -0
  91. package/dist/__tests__/workflow-validate.test.js.map +1 -0
  92. package/dist/background/background.d.ts +22 -1
  93. package/dist/background/background.d.ts.map +1 -1
  94. package/dist/background/background.js +83 -6
  95. package/dist/background/background.js.map +1 -1
  96. package/dist/background/index.d.ts +1 -1
  97. package/dist/background/index.d.ts.map +1 -1
  98. package/dist/background/index.js +1 -1
  99. package/dist/background/index.js.map +1 -1
  100. package/dist/background/types.d.ts +1 -0
  101. package/dist/background/types.d.ts.map +1 -1
  102. package/dist/cli.js +66 -31
  103. package/dist/cli.js.map +1 -1
  104. package/dist/commands/config.d.ts +3 -1
  105. package/dist/commands/config.d.ts.map +1 -1
  106. package/dist/commands/config.js +7 -33
  107. package/dist/commands/config.js.map +1 -1
  108. package/dist/commands/prompt.d.ts.map +1 -1
  109. package/dist/commands/prompt.js +15 -2
  110. package/dist/commands/prompt.js.map +1 -1
  111. package/dist/commands/setup.d.ts +7 -39
  112. package/dist/commands/setup.d.ts.map +1 -1
  113. package/dist/commands/setup.js +27 -302
  114. package/dist/commands/setup.js.map +1 -1
  115. package/dist/commands/step.d.ts +44 -1
  116. package/dist/commands/step.d.ts.map +1 -1
  117. package/dist/commands/step.js +255 -11
  118. package/dist/commands/step.js.map +1 -1
  119. package/dist/commands/thread.d.ts +16 -3
  120. package/dist/commands/thread.d.ts.map +1 -1
  121. package/dist/commands/thread.js +379 -140
  122. package/dist/commands/thread.js.map +1 -1
  123. package/dist/commands/workflow.d.ts +9 -1
  124. package/dist/commands/workflow.d.ts.map +1 -1
  125. package/dist/commands/workflow.js +130 -6
  126. package/dist/commands/workflow.js.map +1 -1
  127. package/dist/moderator/__tests__/evaluate.test.js +31 -17
  128. package/dist/moderator/__tests__/evaluate.test.js.map +1 -1
  129. package/dist/moderator/evaluate.d.ts.map +1 -1
  130. package/dist/moderator/evaluate.js +4 -16
  131. package/dist/moderator/evaluate.js.map +1 -1
  132. package/dist/moderator/index.d.ts +1 -2
  133. package/dist/moderator/index.d.ts.map +1 -1
  134. package/dist/moderator/index.js +0 -1
  135. package/dist/moderator/index.js.map +1 -1
  136. package/dist/moderator/types.d.ts +6 -10
  137. package/dist/moderator/types.d.ts.map +1 -1
  138. package/dist/moderator/types.js +1 -3
  139. package/dist/moderator/types.js.map +1 -1
  140. package/dist/schemas.d.ts +2 -0
  141. package/dist/schemas.d.ts.map +1 -1
  142. package/dist/schemas.js +5 -3
  143. package/dist/schemas.js.map +1 -1
  144. package/dist/store.d.ts +28 -9
  145. package/dist/store.d.ts.map +1 -1
  146. package/dist/store.js +75 -16
  147. package/dist/store.js.map +1 -1
  148. package/dist/validate-semantic.d.ts.map +1 -1
  149. package/dist/validate-semantic.js +83 -66
  150. package/dist/validate-semantic.js.map +1 -1
  151. package/dist/validate.d.ts +6 -0
  152. package/dist/validate.d.ts.map +1 -1
  153. package/dist/validate.js +24 -0
  154. package/dist/validate.js.map +1 -1
  155. package/package.json +8 -10
  156. package/src/__tests__/adapter-json-roundtrip.test.ts +1 -1
  157. package/src/__tests__/agent-resolution-llm-free.test.ts +39 -0
  158. package/src/__tests__/build-step-entry.test.ts +203 -0
  159. package/src/__tests__/clear-thread-failed-attempts.test.ts +122 -0
  160. package/src/__tests__/config.test.ts +33 -321
  161. package/src/__tests__/current-role.test.ts +7 -6
  162. package/src/__tests__/e2e-mock-agent.test.ts +20 -23
  163. package/src/__tests__/fixtures/e2e-count.workflow.yaml +1 -0
  164. package/src/__tests__/fixtures/e2e-linear.workflow.yaml +1 -0
  165. package/src/__tests__/fixtures/{e2e-mustache.workflow.yaml → e2e-liquid.workflow.yaml} +3 -2
  166. package/src/__tests__/fixtures/e2e-loop.workflow.yaml +1 -0
  167. package/src/__tests__/fixtures/e2e-suspend.mock.yaml +2 -2
  168. package/src/__tests__/fixtures/e2e-suspend.workflow.yaml +6 -10
  169. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +43 -0
  170. package/src/__tests__/moderator-evaluate.test.ts +9 -52
  171. package/src/__tests__/pid-recycling.test.ts +328 -0
  172. package/src/__tests__/prompt.test.ts +397 -0
  173. package/src/__tests__/resolve-head-hash.test.ts +4 -4
  174. package/src/__tests__/setup-agent-discovery.test.ts +26 -51
  175. package/src/__tests__/setup-complexity.test.ts +1 -203
  176. package/src/__tests__/setup-no-llm.test.ts +68 -0
  177. package/src/__tests__/solve-issue-tea-worktree.test.ts +24 -30
  178. package/src/__tests__/step-ask.test.ts +670 -0
  179. package/src/__tests__/step-show-json.test.ts +1 -0
  180. package/src/__tests__/step-timing.test.ts +2 -0
  181. package/src/__tests__/store-global-cas.test.ts +2 -2
  182. package/src/__tests__/store-unified-threads.test.ts +9 -9
  183. package/src/__tests__/thread-cancel-status.test.ts +6 -6
  184. package/src/__tests__/thread-list-filters.test.ts +434 -8
  185. package/src/__tests__/thread-poke.test.ts +545 -0
  186. package/src/__tests__/thread-resume.test.ts +10 -14
  187. package/src/__tests__/thread-show-status.test.ts +17 -29
  188. package/src/__tests__/thread-suspend-step.test.ts +8 -14
  189. package/src/__tests__/thread-suspended-display.test.ts +10 -22
  190. package/src/__tests__/thread.test.ts +4 -4
  191. package/src/__tests__/validate-semantic.test.ts +59 -31
  192. package/src/__tests__/workflow-list-recursive.test.ts +370 -0
  193. package/src/__tests__/workflow-resolution.test.ts +39 -21
  194. package/src/__tests__/workflow-show-resolution.test.ts +285 -0
  195. package/src/__tests__/workflow-validate.test.ts +806 -0
  196. package/src/background/background.ts +88 -6
  197. package/src/background/index.ts +2 -0
  198. package/src/background/types.ts +1 -0
  199. package/src/cli.ts +97 -47
  200. package/src/commands/config.ts +7 -35
  201. package/src/commands/prompt.ts +15 -2
  202. package/src/commands/setup.ts +29 -357
  203. package/src/commands/step.ts +339 -12
  204. package/src/commands/thread.ts +463 -169
  205. package/src/commands/workflow.ts +159 -4
  206. package/src/moderator/__tests__/evaluate.test.ts +34 -17
  207. package/src/moderator/evaluate.ts +5 -17
  208. package/src/moderator/index.ts +1 -6
  209. package/src/moderator/types.ts +6 -14
  210. package/src/schemas.ts +13 -3
  211. package/src/store.ts +86 -20
  212. package/src/validate-semantic.ts +109 -78
  213. package/src/validate.ts +27 -0
  214. package/dist/__tests__/setup-validate.test.d.ts +0 -2
  215. package/dist/__tests__/setup-validate.test.d.ts.map +0 -1
  216. package/dist/__tests__/setup-validate.test.js +0 -108
  217. package/dist/__tests__/setup-validate.test.js.map +0 -1
  218. package/src/__tests__/setup-validate.test.ts +0 -148
  219. /package/src/__tests__/fixtures/{e2e-mustache.mock.yaml → e2e-liquid.mock.yaml} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { dirname, resolve as resolvePath } from "node:path";
2
+ import { basename, dirname, isAbsolute, resolve as resolvePath } from "node:path";
3
3
 
4
4
  import type { JSONSchema } from "@ocas/core";
5
5
  import { putSchema, validate } from "@ocas/core";
@@ -12,11 +12,17 @@ import {
12
12
  discoverProjectWorkflows,
13
13
  findRegistryName,
14
14
  loadWorkflowRegistry,
15
+ resolveProjectWorkflowFile,
15
16
  resolveWorkflowHash,
16
17
  saveWorkflowRegistry,
17
18
  type UwfStore,
18
19
  } from "../store.js";
19
- import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
20
+ import {
21
+ checkWorkflowFilenameConsistency,
22
+ isCasRef,
23
+ isMissingVersion,
24
+ parseWorkflowPayload,
25
+ } from "../validate.js";
20
26
  import { validateWorkflow } from "../validate-semantic.js";
21
27
 
22
28
  export type WorkflowOrigin = "local" | "global";
@@ -105,6 +111,7 @@ export async function materializeWorkflowPayload(
105
111
  };
106
112
  }
107
113
  return {
114
+ version: raw.version,
108
115
  name: raw.name,
109
116
  description: raw.description,
110
117
  roles,
@@ -112,6 +119,47 @@ export async function materializeWorkflowPayload(
112
119
  };
113
120
  }
114
121
 
122
+ /**
123
+ * Validate a workflow YAML file without registering it.
124
+ *
125
+ * CI-friendly: does not touch CAS or the workflow registry. On success,
126
+ * returns silently (no stdout/stderr) and exits 0. On any error, writes a
127
+ * single message to stderr and exits 1.
128
+ */
129
+ export async function cmdWorkflowValidate(filePath: string): Promise<void> {
130
+ let text: string;
131
+ try {
132
+ text = await readFile(filePath, "utf8");
133
+ } catch {
134
+ fail(`file not found: ${filePath}`);
135
+ }
136
+
137
+ let raw: unknown;
138
+ try {
139
+ raw = parse(text, {
140
+ customTags: [createIncludeTag(dirname(resolvePath(filePath)))],
141
+ }) as unknown;
142
+ } catch (e) {
143
+ fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
144
+ }
145
+
146
+ const payload = parseWorkflowPayload(raw);
147
+ if (payload === null) {
148
+ fail("invalid workflow YAML: expected WorkflowPayload shape");
149
+ }
150
+
151
+ const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
152
+ if (filenameError !== null) {
153
+ fail(filenameError);
154
+ }
155
+
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
161
+ }
162
+
115
163
  export async function cmdWorkflowAdd(
116
164
  storageRoot: string,
117
165
  filePath: string,
@@ -137,6 +185,12 @@ export async function cmdWorkflowAdd(
137
185
  fail("invalid workflow YAML: expected WorkflowPayload shape");
138
186
  }
139
187
 
188
+ if (isMissingVersion(raw)) {
189
+ process.stderr.write(
190
+ `warning: workflow YAML "${basename(filePath)}" is missing top-level \`version\` field; falling back to version 1. Add \`version: 1\` to silence this warning.\n`,
191
+ );
192
+ }
193
+
140
194
  const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
141
195
  if (filenameError !== null) {
142
196
  fail(filenameError);
@@ -161,13 +215,113 @@ export async function cmdWorkflowAdd(
161
215
  return { name: materialized.name, hash };
162
216
  }
163
217
 
218
+ // ── workflow show resolution helpers ──────────────────────────────────────────
219
+
220
+ function isFilePath(input: string): boolean {
221
+ return (
222
+ input.includes("/") || input.includes("\\") || input.endsWith(".yaml") || input.endsWith(".yml")
223
+ );
224
+ }
225
+
226
+ async function materializeLocalWorkflowForShow(uwf: UwfStore, filePath: string): Promise<CasRef> {
227
+ let text: string;
228
+ try {
229
+ text = await readFile(filePath, "utf8");
230
+ } catch {
231
+ fail(`project workflow file not found: ${filePath}`);
232
+ }
233
+
234
+ let raw: unknown;
235
+ try {
236
+ raw = parse(text, { customTags: [createIncludeTag(dirname(filePath))] }) as unknown;
237
+ } catch (e) {
238
+ fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
239
+ }
240
+
241
+ const payload = parseWorkflowPayload(raw);
242
+ if (payload === null) {
243
+ fail(`invalid workflow YAML in ${filePath}: expected WorkflowPayload shape`);
244
+ }
245
+
246
+ const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
247
+ if (filenameError !== null) {
248
+ fail(filenameError);
249
+ }
250
+
251
+ const semanticErrors = validateWorkflow(payload);
252
+ if (semanticErrors.length > 0) {
253
+ fail(`workflow validation failed:\n${semanticErrors.map((e) => ` - ${e}`).join("\n")}`);
254
+ }
255
+
256
+ const materialized = await materializeWorkflowPayload(uwf, payload);
257
+ const hash = await uwf.store.cas.put(uwf.schemas.workflow, materialized);
258
+ const stored = uwf.store.cas.get(hash);
259
+ if (stored === null || !validate(uwf.store, stored)) {
260
+ fail("stored local workflow failed schema validation");
261
+ }
262
+
263
+ return hash;
264
+ }
265
+
266
+ async function resolveWorkflowCasRefForShow(
267
+ uwf: UwfStore,
268
+ workflowId: string,
269
+ projectRoot: string,
270
+ ): Promise<CasRef> {
271
+ // Validate input
272
+ const trimmed = workflowId.trim();
273
+ if (trimmed === "") {
274
+ fail("workflow ID cannot be empty");
275
+ }
276
+
277
+ // Strategy 1: Direct CAS hash
278
+ if (isCasRef(trimmed)) {
279
+ const node = uwf.store.cas.get(trimmed);
280
+ if (node === null) {
281
+ fail(`CAS node not found: ${trimmed}`);
282
+ }
283
+ if (node.type !== uwf.schemas.workflow) {
284
+ fail(`node ${trimmed} is not a Workflow (type ${node.type})`);
285
+ }
286
+ return trimmed;
287
+ }
288
+
289
+ // Strategy 2: Explicit file path (relative or absolute)
290
+ if (isFilePath(trimmed)) {
291
+ const absolutePath = isAbsolute(trimmed) ? trimmed : resolvePath(projectRoot, trimmed);
292
+ return materializeLocalWorkflowForShow(uwf, absolutePath);
293
+ }
294
+
295
+ // Strategy 3: Local discovery (reuses discoverProjectWorkflows from store.ts)
296
+ const localEntries = await discoverProjectWorkflows(projectRoot);
297
+ const localPath = resolveProjectWorkflowFile(localEntries, trimmed);
298
+ if (localPath !== null) {
299
+ return materializeLocalWorkflowForShow(uwf, localPath);
300
+ }
301
+
302
+ // Strategy 4: Global registry fallback
303
+ const registry = loadWorkflowRegistry(uwf.varStore);
304
+ const hash = resolveWorkflowHash(registry, trimmed);
305
+ if (!isCasRef(hash)) {
306
+ fail(`workflow not found: ${trimmed}`);
307
+ }
308
+ const node = uwf.store.cas.get(hash);
309
+ if (node === null) {
310
+ fail(`CAS node not found: ${hash}`);
311
+ }
312
+ if (node.type !== uwf.schemas.workflow) {
313
+ fail(`node ${hash} is not a Workflow (type ${node.type})`);
314
+ }
315
+ return hash;
316
+ }
317
+
164
318
  export async function cmdWorkflowShow(
165
319
  storageRoot: string,
166
320
  id: string,
321
+ projectRoot: string,
167
322
  ): Promise<WorkflowShowOutput> {
168
323
  const uwf = await createUwfStore(storageRoot);
169
- const registry = loadWorkflowRegistry(uwf.varStore);
170
- const hash = resolveWorkflowHash(registry, id);
324
+ const hash = await resolveWorkflowCasRefForShow(uwf, id, projectRoot);
171
325
 
172
326
  const node = uwf.store.cas.get(hash);
173
327
  if (node === null) {
@@ -178,6 +332,7 @@ export async function cmdWorkflowShow(
178
332
  }
179
333
 
180
334
  const payload = node.payload as WorkflowPayload;
335
+ const registry = loadWorkflowRegistry(uwf.varStore);
181
336
  return {
182
337
  hash,
183
338
  name: findRegistryName(registry, hash),
@@ -1,12 +1,11 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { evaluate } from "../evaluate.js";
3
- import { isSuspendResult } from "../types.js";
4
3
 
5
4
  describe("Edge prompt template variable resolution", () => {
6
5
  test("returns error when rendered prompt is empty string", () => {
7
6
  const graph = {
8
7
  $START: {
9
- new: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
8
+ new: { role: "classifier", prompt: "{{ userPrompt }}", location: null },
10
9
  },
11
10
  };
12
11
 
@@ -22,7 +21,7 @@ describe("Edge prompt template variable resolution", () => {
22
21
  test("returns error when rendered prompt is whitespace-only", () => {
23
22
  const graph = {
24
23
  $START: {
25
- new: { role: "classifier", prompt: " {{{userPrompt}}} ", location: null },
24
+ new: { role: "classifier", prompt: " {{ userPrompt }} ", location: null },
26
25
  },
27
26
  };
28
27
 
@@ -38,7 +37,7 @@ describe("Edge prompt template variable resolution", () => {
38
37
  test("succeeds when all template variables resolve to non-empty values", () => {
39
38
  const graph = {
40
39
  $START: {
41
- new: { role: "classifier", prompt: "{{{userPrompt}}}", location: null },
40
+ new: { role: "classifier", prompt: "{{ userPrompt }}", location: null },
42
41
  },
43
42
  };
44
43
 
@@ -68,7 +67,7 @@ describe("Edge prompt template variable resolution", () => {
68
67
  test("succeeds when prompt has mix of static text and unresolved variables", () => {
69
68
  const graph = {
70
69
  $START: {
71
- new: { role: "classifier", prompt: "Please handle: {{{userPrompt}}}", location: null },
70
+ new: { role: "classifier", prompt: "Please handle: {{ userPrompt }}", location: null },
72
71
  },
73
72
  };
74
73
 
@@ -83,7 +82,7 @@ describe("Edge prompt template variable resolution", () => {
83
82
  test("returns error when ALL variables missing and no static text remains", () => {
84
83
  const graph = {
85
84
  $START: {
86
- new: { role: "classifier", prompt: "{{{a}}}{{{b}}}", location: null },
85
+ new: { role: "classifier", prompt: "{{ a }}{{ b }}", location: null },
87
86
  },
88
87
  };
89
88
 
@@ -91,6 +90,24 @@ describe("Edge prompt template variable resolution", () => {
91
90
 
92
91
  expect(result.ok).toBe(false);
93
92
  });
93
+
94
+ test("does not HTML-escape characters like <, >, &", () => {
95
+ const graph = {
96
+ $START: {
97
+ new: { role: "classifier", prompt: "{{ content }}", location: null },
98
+ },
99
+ };
100
+
101
+ const result = evaluate(graph, "$START", {
102
+ $status: "new",
103
+ content: "<div>Hello & welcome</div>",
104
+ });
105
+
106
+ expect(result.ok).toBe(true);
107
+ if (result.ok) {
108
+ expect(result.value.prompt).toBe("<div>Hello & welcome</div>");
109
+ }
110
+ });
94
111
  });
95
112
 
96
113
  describe("Moderator location resolution", () => {
@@ -108,7 +125,7 @@ describe("Moderator location resolution", () => {
108
125
  const result = evaluate(graph, "planner", { $status: "ready" });
109
126
 
110
127
  expect(result.ok).toBe(true);
111
- if (result.ok && !isSuspendResult(result.value)) {
128
+ if (result.ok) {
112
129
  expect(result.value.location).toBe(null);
113
130
  }
114
131
  });
@@ -127,18 +144,18 @@ describe("Moderator location resolution", () => {
127
144
  const result = evaluate(graph, "planner", { $status: "ready" });
128
145
 
129
146
  expect(result.ok).toBe(true);
130
- if (result.ok && !isSuspendResult(result.value)) {
147
+ if (result.ok) {
131
148
  expect(result.value.location).toBe("/static/path");
132
149
  }
133
150
  });
134
151
 
135
- test("resolves mustache template location", () => {
152
+ test("resolves liquid template location", () => {
136
153
  const graph = {
137
154
  planner: {
138
155
  ready: {
139
156
  role: "coder",
140
157
  prompt: "Implement the code",
141
- location: "{{{repoPath}}}",
158
+ location: "{{ repoPath }}",
142
159
  },
143
160
  },
144
161
  };
@@ -149,18 +166,18 @@ describe("Moderator location resolution", () => {
149
166
  });
150
167
 
151
168
  expect(result.ok).toBe(true);
152
- if (result.ok && !isSuspendResult(result.value)) {
169
+ if (result.ok) {
153
170
  expect(result.value.location).toBe("/home/user/repo");
154
171
  }
155
172
  });
156
173
 
157
- test("resolves mustache template with multiple variables", () => {
174
+ test("resolves liquid template with multiple variables", () => {
158
175
  const graph = {
159
176
  planner: {
160
177
  ready: {
161
178
  role: "coder",
162
179
  prompt: "Implement the code",
163
- location: "{{{basePath}}}/{{{projectName}}}",
180
+ location: "{{ basePath }}/{{ projectName }}",
164
181
  },
165
182
  },
166
183
  };
@@ -172,7 +189,7 @@ describe("Moderator location resolution", () => {
172
189
  });
173
190
 
174
191
  expect(result.ok).toBe(true);
175
- if (result.ok && !isSuspendResult(result.value)) {
192
+ if (result.ok) {
176
193
  expect(result.value.location).toBe("/home/user/myproject");
177
194
  }
178
195
  });
@@ -183,7 +200,7 @@ describe("Moderator location resolution", () => {
183
200
  ready: {
184
201
  role: "coder",
185
202
  prompt: "Implement the code",
186
- location: "{{{repoPath}}}",
203
+ location: "{{ repoPath }}",
187
204
  },
188
205
  },
189
206
  };
@@ -191,8 +208,8 @@ describe("Moderator location resolution", () => {
191
208
  const result = evaluate(graph, "planner", { $status: "ready" });
192
209
 
193
210
  expect(result.ok).toBe(true);
194
- if (result.ok && !isSuspendResult(result.value)) {
195
- // Mustache renders missing variables as empty string
211
+ if (result.ok) {
212
+ // LiquidJS renders missing variables as empty string
196
213
  expect(result.value.location).toBe("");
197
214
  }
198
215
  });
@@ -1,12 +1,9 @@
1
1
  import type { Target } from "@united-workforce/protocol";
2
- import mustache from "mustache";
2
+ import { Liquid } from "liquidjs";
3
3
 
4
4
  import type { EvaluateResult, Result } from "./types.js";
5
5
 
6
- // Disable HTML escaping — prompts are plain text, not HTML.
7
- mustache.escape = (text: string) => text;
8
-
9
- const SUSPEND_ROLE = "$SUSPEND";
6
+ const engine = new Liquid();
10
7
 
11
8
  type LastOutput = Record<string, unknown>;
12
9
 
@@ -44,7 +41,7 @@ export function evaluate(
44
41
  }
45
42
 
46
43
  try {
47
- const prompt = mustache.render(target.prompt, lastOutput);
44
+ const prompt = engine.parseAndRenderSync(target.prompt, lastOutput);
48
45
  if (prompt.trim() === "") {
49
46
  return {
50
47
  ok: false,
@@ -53,18 +50,9 @@ export function evaluate(
53
50
  ),
54
51
  };
55
52
  }
56
- if (target.role === SUSPEND_ROLE) {
57
- return {
58
- ok: true,
59
- value: {
60
- action: "suspend",
61
- suspendedRole: lastRole,
62
- prompt,
63
- },
64
- };
65
- }
66
53
 
67
- const location = target.location !== null ? mustache.render(target.location, lastOutput) : null;
54
+ const location =
55
+ target.location !== null ? engine.parseAndRenderSync(target.location, lastOutput) : null;
68
56
  return { ok: true, value: { role: target.role, prompt, location } };
69
57
  } catch (error) {
70
58
  return {
@@ -1,7 +1,2 @@
1
1
  export { evaluate } from "./evaluate.js";
2
- export type {
3
- EvaluateResult,
4
- EvaluateRouteResult,
5
- EvaluateSuspendResult,
6
- } from "./types.js";
7
- export { isSuspendResult } from "./types.js";
2
+ export type { EvaluateResult, EvaluateRouteResult } from "./types.js";
@@ -8,17 +8,9 @@ export type EvaluateRouteResult = {
8
8
  location: string | null;
9
9
  };
10
10
 
11
- /** Moderator routes the thread to `$SUSPEND` — waiting for external input. */
12
- export type EvaluateSuspendResult = {
13
- action: "suspend";
14
- /** Role whose output triggered the suspend transition. */
15
- suspendedRole: string;
16
- prompt: string;
17
- };
18
-
19
- /** The result of moderator evaluation. */
20
- export type EvaluateResult = EvaluateRouteResult | EvaluateSuspendResult;
21
-
22
- export function isSuspendResult(result: EvaluateResult): result is EvaluateSuspendResult {
23
- return "action" in result && result.action === "suspend";
24
- }
11
+ /**
12
+ * The result of moderator evaluation. `$SUSPEND` is no longer a moderator
13
+ * concern — it is an engine-level reserved `$status` intercepted before the
14
+ * moderator runs.
15
+ */
16
+ export type EvaluateResult = EvaluateRouteResult;
package/src/schemas.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { Hash, Store } from "@ocas/core";
2
2
  import { putSchema } from "@ocas/core";
3
- import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@united-workforce/protocol";
3
+ import {
4
+ ERROR_OUTPUT_SCHEMA,
5
+ START_NODE_SCHEMA,
6
+ STEP_NODE_SCHEMA,
7
+ SUSPEND_OUTPUT_SCHEMA,
8
+ WORKFLOW_SCHEMA,
9
+ } from "@united-workforce/protocol";
4
10
 
5
11
  export const TEXT_SCHEMA = { type: "string" as const };
6
12
 
@@ -9,6 +15,8 @@ export type UwfSchemaHashes = {
9
15
  startNode: Hash;
10
16
  stepNode: Hash;
11
17
  text: Hash;
18
+ errorOutput: Hash;
19
+ suspendOutput: Hash;
12
20
  };
13
21
 
14
22
  /**
@@ -16,11 +24,13 @@ export type UwfSchemaHashes = {
16
24
  * Idempotent: safe to call on every CLI invocation.
17
25
  */
18
26
  export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
19
- const [workflow, startNode, stepNode, text] = await Promise.all([
27
+ const [workflow, startNode, stepNode, text, errorOutput, suspendOutput] = await Promise.all([
20
28
  putSchema(store, WORKFLOW_SCHEMA),
21
29
  putSchema(store, START_NODE_SCHEMA),
22
30
  putSchema(store, STEP_NODE_SCHEMA),
23
31
  putSchema(store, TEXT_SCHEMA),
32
+ putSchema(store, ERROR_OUTPUT_SCHEMA),
33
+ putSchema(store, SUSPEND_OUTPUT_SCHEMA),
24
34
  ]);
25
- return { workflow, startNode, stepNode, text };
35
+ return { workflow, startNode, stepNode, text, errorOutput, suspendOutput };
26
36
  }
package/src/store.ts CHANGED
@@ -2,7 +2,7 @@ import type { Dirent } from "node:fs";
2
2
  import { existsSync } from "node:fs";
3
3
  import { access, mkdir, readdir, readFile, rename } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
5
+ import { dirname, join, resolve as resolvePath } from "node:path";
6
6
 
7
7
  import { bootstrap, type Hash, type Store, type VarStore } from "@ocas/core";
8
8
  import { createFsStore, createSqliteVarStore } from "@ocas/fs";
@@ -20,7 +20,7 @@ export const REGISTRY_VAR_PREFIX = "@uwf/registry/";
20
20
  /** Variable name prefix for active thread entries (`@uwf/thread/<thread-id>`). */
21
21
  export const THREAD_VAR_PREFIX = "@uwf/thread/";
22
22
 
23
- /** A workflow entry discovered from the project-local .workflows/ directory. */
23
+ /** A workflow entry discovered from the project-local .workflows/ (primary) or .workflow/ (legacy) directory. */
24
24
  export type ProjectWorkflowEntry = {
25
25
  /** Workflow name (from YAML `name` field, equals filename stem). */
26
26
  name: string;
@@ -82,16 +82,11 @@ async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
82
82
  return result;
83
83
  }
84
84
 
85
- /**
86
- * Scan `<projectRoot>/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries.
87
- * .workflow/ takes priority: if a name is found in both, .workflow/ wins.
88
- * Returns an empty array if neither directory exists.
89
- */
90
- export async function discoverProjectWorkflows(
91
- projectRoot: string,
92
- ): Promise<ProjectWorkflowEntry[]> {
93
- const primary = await scanWorkflowDir(join(projectRoot, ".workflow"));
94
- const legacy = await scanWorkflowDir(join(projectRoot, ".workflows"));
85
+ /** Merge primary (.workflows/) and legacy (.workflow/) entries, primary wins on name collision. */
86
+ function mergeWorkflowEntries(
87
+ primary: ProjectWorkflowEntry[],
88
+ legacy: ProjectWorkflowEntry[],
89
+ ): ProjectWorkflowEntry[] {
95
90
  const seen = new Set(primary.map((e) => e.name));
96
91
  const merged = [...primary];
97
92
  for (const entry of legacy) {
@@ -102,6 +97,63 @@ export async function discoverProjectWorkflows(
102
97
  return merged;
103
98
  }
104
99
 
100
+ /** Check if a directory contains a .git marker (directory or file). */
101
+ async function hasGitMarker(dir: string): Promise<boolean> {
102
+ try {
103
+ await access(join(dir, ".git"));
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Discover project-local workflows by walking from `startDir` up through parent
112
+ * directories. The nearest directory that contains a `.workflows/` or `.workflow/`
113
+ * directory wins — once a match is found, traversal stops (entries from more
114
+ * distant ancestors are NOT merged in).
115
+ *
116
+ * Within the winning directory:
117
+ * - `.workflows/` (preferred/primary) takes priority over `.workflow/` (legacy fallback).
118
+ * - If both exist in that directory, `.workflows/` entries win when names collide.
119
+ *
120
+ * This matches the resolution strategy of `findWorkflowInParents` used by
121
+ * `uwf thread start`, so `uwf workflow list` and `uwf thread start` agree on
122
+ * what's discoverable from any given subdirectory.
123
+ *
124
+ * Traversal stops at the first `.git` boundary (directory or file) or the
125
+ * filesystem root. Returns an empty array if no `.workflows/` or `.workflow/`
126
+ * directory exists within that range.
127
+ */
128
+ export async function discoverProjectWorkflows(startDir: string): Promise<ProjectWorkflowEntry[]> {
129
+ let currentDir = resolvePath(startDir);
130
+ const root = resolvePath("/");
131
+
132
+ while (true) {
133
+ const primary = await scanWorkflowDir(join(currentDir, ".workflows"));
134
+ const legacy = await scanWorkflowDir(join(currentDir, ".workflow"));
135
+
136
+ if (primary.length > 0 || legacy.length > 0) {
137
+ return mergeWorkflowEntries(primary, legacy);
138
+ }
139
+
140
+ // Stop at .git boundary (repo root)
141
+ if (await hasGitMarker(currentDir)) {
142
+ return [];
143
+ }
144
+
145
+ // Stop at filesystem root
146
+ if (currentDir === root) {
147
+ return [];
148
+ }
149
+ const parentDir = dirname(currentDir);
150
+ if (parentDir === currentDir) {
151
+ return [];
152
+ }
153
+ currentDir = parentDir;
154
+ }
155
+ }
156
+
105
157
  /** Default filesystem root for uwf data (`~/.uwf`). */
106
158
  export function getDefaultStorageRoot(): string {
107
159
  return join(homedir(), ".uwf");
@@ -335,35 +387,35 @@ export function setThread(varStore: VarStore, threadId: ThreadId, entry: ThreadI
335
387
  varStore.set(name, entry.head, { tags });
336
388
  }
337
389
 
338
- /** Load only active threads (status not in completed/cancelled). */
390
+ /** Load only active threads (status not in end/cancelled). */
339
391
  export function loadActiveThreads(varStore: VarStore): ThreadsIndex {
340
392
  const all = loadAllThreads(varStore);
341
393
  const active: ThreadsIndex = {};
342
394
  for (const [threadId, entry] of Object.entries(all)) {
343
- if (entry.status !== "completed" && entry.status !== "cancelled") {
395
+ if (entry.status !== "end" && entry.status !== "cancelled") {
344
396
  active[threadId as ThreadId] = entry;
345
397
  }
346
398
  }
347
399
  return active;
348
400
  }
349
401
 
350
- /** Load only completed/cancelled threads (history). */
402
+ /** Load only end/cancelled threads (history). */
351
403
  export function loadHistoryThreads(varStore: VarStore): ThreadsIndex {
352
404
  const all = loadAllThreads(varStore);
353
405
  const history: ThreadsIndex = {};
354
406
  for (const [threadId, entry] of Object.entries(all)) {
355
- if (entry.status === "completed" || entry.status === "cancelled") {
407
+ if (entry.status === "end" || entry.status === "cancelled") {
356
408
  history[threadId as ThreadId] = entry;
357
409
  }
358
410
  }
359
411
  return history;
360
412
  }
361
413
 
362
- /** Complete a thread by marking it completed or cancelled. */
414
+ /** Complete a thread by marking it end or cancelled. */
363
415
  export function completeThread(
364
416
  varStore: VarStore,
365
417
  threadId: ThreadId,
366
- reason: "completed" | "cancelled",
418
+ reason: "end" | "cancelled",
367
419
  ): void {
368
420
  const entry = getThread(varStore, threadId);
369
421
  if (entry === null) {
@@ -377,6 +429,20 @@ export function completeThread(
377
429
  completedAt: Date.now(),
378
430
  } as ThreadIndexEntry;
379
431
  setThread(varStore, threadId, completed);
432
+ clearThreadFailedAttempts(varStore, threadId);
433
+ }
434
+
435
+ /**
436
+ * Remove all `@uwf/thread-failed/<threadId>/*` variables for a thread.
437
+ * Called on thread completion / cancellation so retry-lineage state does not
438
+ * leak into the variable store after the thread is archived.
439
+ */
440
+ export function clearThreadFailedAttempts(varStore: VarStore, threadId: ThreadId): void {
441
+ const prefix = `@uwf/thread-failed/${threadId}/`;
442
+ const vars = varStore.list({ namePrefix: prefix });
443
+ for (const v of vars) {
444
+ varStore.remove(v.name);
445
+ }
380
446
  }
381
447
 
382
448
  type LegacyHistoryEntry = {
@@ -439,7 +505,7 @@ export async function migrateHistoryIfNeeded(
439
505
  }
440
506
  const entry = parseLegacyHistoryJsonlLine(trimmed);
441
507
  if (entry !== null) {
442
- const status = entry.reason === "cancelled" ? "cancelled" : "completed";
508
+ const status = entry.reason === "cancelled" ? "cancelled" : "end";
443
509
  const threadEntry: ThreadIndexEntry = {
444
510
  head: entry.head,
445
511
  status: status as ThreadIndexEntry["status"],
@@ -462,7 +528,7 @@ export function migrateHistoryVarsToThreadVars(varStore: VarStore): void {
462
528
  for (const v of vars) {
463
529
  const threadId = v.name.slice(LEGACY_HISTORY_VAR_PREFIX.length) as ThreadId;
464
530
  const reason = v.tags.reason;
465
- const status = reason === "cancelled" ? "cancelled" : "completed";
531
+ const status = reason === "cancelled" ? "cancelled" : "end";
466
532
  const completedAt = Number(v.tags.completedAt ?? Date.now());
467
533
 
468
534
  const threadEntry: ThreadIndexEntry = {