@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
@@ -0,0 +1,687 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
7
+ import { stringify } from "yaml";
8
+ const TEST_DIR = dirname(fileURLToPath(import.meta.url));
9
+ const CLI_PATH = join(TEST_DIR, "..", "..", "dist", "cli.js");
10
+ function runValidate(filePath, extraArgs = [], envOverride) {
11
+ const args = [CLI_PATH, "workflow", "validate", filePath, ...extraArgs];
12
+ try {
13
+ const stdout = execFileSync(process.execPath, args, {
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "pipe"],
16
+ env: envOverride ?? process.env,
17
+ timeout: 15_000,
18
+ });
19
+ return { stdout, stderr: "", exitCode: 0 };
20
+ }
21
+ catch (e) {
22
+ const err = e;
23
+ return {
24
+ stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
25
+ stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
26
+ exitCode: err.status ?? 1,
27
+ };
28
+ }
29
+ }
30
+ function runCli(args) {
31
+ try {
32
+ const stdout = execFileSync(process.execPath, [CLI_PATH, ...args], {
33
+ encoding: "utf8",
34
+ stdio: ["ignore", "pipe", "pipe"],
35
+ env: process.env,
36
+ timeout: 15_000,
37
+ });
38
+ return { stdout, stderr: "", exitCode: 0 };
39
+ }
40
+ catch (e) {
41
+ const err = e;
42
+ return {
43
+ stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
44
+ stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
45
+ exitCode: err.status ?? 1,
46
+ };
47
+ }
48
+ }
49
+ /** Build a valid single-role workflow payload (writer→$END, status `done`). */
50
+ function makeMinimalPayload(name) {
51
+ return {
52
+ name,
53
+ description: `${name} workflow`,
54
+ roles: {
55
+ writer: {
56
+ description: "Writes content",
57
+ goal: "Write content",
58
+ capabilities: ["writing"],
59
+ procedure: "Write it",
60
+ output: "The content",
61
+ frontmatter: {
62
+ type: "object",
63
+ properties: {
64
+ $status: { const: "done" },
65
+ },
66
+ required: ["$status"],
67
+ },
68
+ },
69
+ },
70
+ graph: {
71
+ $START: {
72
+ new: { role: "writer", prompt: "Begin", location: null },
73
+ resume: { role: "writer", prompt: "Resume", location: null },
74
+ },
75
+ writer: { done: { role: "$END", prompt: "Done", location: null } },
76
+ },
77
+ };
78
+ }
79
+ /** Build a valid writer→reviewer workflow with Liquid var. */
80
+ function makeMultiRolePayload(name) {
81
+ return {
82
+ name,
83
+ description: `${name} workflow`,
84
+ roles: {
85
+ writer: {
86
+ description: "Writes content",
87
+ goal: "Write content",
88
+ capabilities: ["writing"],
89
+ procedure: "Write it",
90
+ output: "The content",
91
+ frontmatter: {
92
+ type: "object",
93
+ properties: {
94
+ $status: { const: "done" },
95
+ plan: { type: "string" },
96
+ },
97
+ required: ["$status", "plan"],
98
+ },
99
+ },
100
+ reviewer: {
101
+ description: "Reviews content",
102
+ goal: "Review content",
103
+ capabilities: ["reviewing"],
104
+ procedure: "Review it",
105
+ output: "The review",
106
+ frontmatter: {
107
+ type: "object",
108
+ properties: {
109
+ $status: { const: "approved" },
110
+ summary: { type: "string" },
111
+ },
112
+ required: ["$status", "summary"],
113
+ },
114
+ },
115
+ },
116
+ graph: {
117
+ $START: {
118
+ new: { role: "writer", prompt: "Begin writing", location: null },
119
+ resume: { role: "writer", prompt: "Continue", location: null },
120
+ },
121
+ writer: {
122
+ done: { role: "reviewer", prompt: "Review this: {{ plan }}", location: null },
123
+ },
124
+ reviewer: {
125
+ approved: { role: "$END", prompt: "Done: {{ summary }}", location: null },
126
+ },
127
+ },
128
+ };
129
+ }
130
+ /** Build a valid reviewer with oneOf multi-exit. */
131
+ function makeOneOfPayload(name) {
132
+ return {
133
+ name,
134
+ description: `${name} workflow`,
135
+ roles: {
136
+ writer: {
137
+ description: "Writes content",
138
+ goal: "Write",
139
+ capabilities: ["writing"],
140
+ procedure: "Write",
141
+ output: "Content",
142
+ frontmatter: {
143
+ type: "object",
144
+ properties: {
145
+ $status: { const: "done" },
146
+ plan: { type: "string" },
147
+ },
148
+ required: ["$status", "plan"],
149
+ },
150
+ },
151
+ reviewer: {
152
+ description: "Reviews",
153
+ goal: "Review",
154
+ capabilities: ["reviewing"],
155
+ procedure: "Review",
156
+ output: "Review",
157
+ frontmatter: {
158
+ type: "object",
159
+ oneOf: [
160
+ {
161
+ properties: {
162
+ $status: { const: "approved" },
163
+ summary: { type: "string" },
164
+ },
165
+ required: ["$status", "summary"],
166
+ },
167
+ {
168
+ properties: {
169
+ $status: { const: "rejected" },
170
+ reason: { type: "string" },
171
+ },
172
+ required: ["$status", "reason"],
173
+ },
174
+ ],
175
+ },
176
+ },
177
+ },
178
+ graph: {
179
+ $START: {
180
+ new: { role: "writer", prompt: "Begin", location: null },
181
+ resume: { role: "writer", prompt: "Resume", location: null },
182
+ },
183
+ writer: {
184
+ done: { role: "reviewer", prompt: "Review: {{ plan }}", location: null },
185
+ },
186
+ reviewer: {
187
+ approved: { role: "$END", prompt: "Done: {{ summary }}", location: null },
188
+ rejected: { role: "writer", prompt: "Fix: {{ reason }}", location: null },
189
+ },
190
+ },
191
+ };
192
+ }
193
+ let tmpDir;
194
+ beforeAll(() => {
195
+ // Confirm CLI is built before running tests
196
+ // (proman build should have produced dist/cli.js)
197
+ });
198
+ beforeEach(async () => {
199
+ tmpDir = await mkdtemp(join(tmpdir(), "wf-validate-test-"));
200
+ });
201
+ afterEach(async () => {
202
+ // chmod back in case a test modified a directory
203
+ try {
204
+ await chmod(tmpDir, 0o755);
205
+ }
206
+ catch {
207
+ // ignore
208
+ }
209
+ await rm(tmpDir, { recursive: true, force: true });
210
+ });
211
+ // ── Suite A: Success Path ────────────────────────────────────────────────────
212
+ describe("workflow validate — Suite A: Success Path", () => {
213
+ test("A.1 valid single-role workflow exits 0 silent", async () => {
214
+ const file = join(tmpDir, "test-wf.yaml");
215
+ await writeFile(file, stringify(makeMinimalPayload("test-wf")));
216
+ const result = runValidate(file);
217
+ expect(result.exitCode).toBe(0);
218
+ expect(result.stdout).toBe("");
219
+ expect(result.stderr).toBe("");
220
+ });
221
+ test("A.2 valid multi-role workflow with Liquid vars exits 0 silent", async () => {
222
+ const file = join(tmpDir, "writer-flow.yaml");
223
+ await writeFile(file, stringify(makeMultiRolePayload("writer-flow")));
224
+ const result = runValidate(file);
225
+ expect(result.exitCode).toBe(0);
226
+ expect(result.stdout).toBe("");
227
+ expect(result.stderr).toBe("");
228
+ });
229
+ test("A.3 valid oneOf multi-exit workflow exits 0 silent", async () => {
230
+ const file = join(tmpDir, "review-flow.yaml");
231
+ await writeFile(file, stringify(makeOneOfPayload("review-flow")));
232
+ const result = runValidate(file);
233
+ expect(result.exitCode).toBe(0);
234
+ expect(result.stdout).toBe("");
235
+ expect(result.stderr).toBe("");
236
+ });
237
+ test("A.4 !include tag resolution against YAML directory", async () => {
238
+ const subDir = join(tmpDir, "sub");
239
+ await mkdir(subDir, { recursive: true });
240
+ // The include payload is the workflow's roles section
241
+ const rolesYaml = stringify({
242
+ writer: {
243
+ description: "Writes",
244
+ goal: "Write",
245
+ capabilities: ["writing"],
246
+ procedure: "Write",
247
+ output: "Content",
248
+ frontmatter: {
249
+ type: "object",
250
+ properties: {
251
+ $status: { const: "done" },
252
+ },
253
+ required: ["$status"],
254
+ },
255
+ },
256
+ });
257
+ await writeFile(join(subDir, "roles.yaml"), rolesYaml);
258
+ const mainYaml = `name: main-wf
259
+ description: Main workflow
260
+ roles: !include sub/roles.yaml
261
+ graph:
262
+ $START:
263
+ new: { role: writer, prompt: Begin, location: null }
264
+ resume: { role: writer, prompt: Resume, location: null }
265
+ writer:
266
+ done: { role: $END, prompt: Done, location: null }
267
+ `;
268
+ const file = join(tmpDir, "main-wf.yaml");
269
+ await writeFile(file, mainYaml);
270
+ const result = runValidate(file);
271
+ expect(result.exitCode).toBe(0);
272
+ expect(result.stdout).toBe("");
273
+ expect(result.stderr).toBe("");
274
+ });
275
+ test("A.5 --format yaml does not change silent success output", async () => {
276
+ const file = join(tmpDir, "test-wf.yaml");
277
+ await writeFile(file, stringify(makeMinimalPayload("test-wf")));
278
+ // --format is a global option on `program`, must come before the subcommand
279
+ const args = [CLI_PATH, "--format", "yaml", "workflow", "validate", file];
280
+ let result;
281
+ try {
282
+ const stdout = execFileSync(process.execPath, args, {
283
+ encoding: "utf8",
284
+ stdio: ["ignore", "pipe", "pipe"],
285
+ env: process.env,
286
+ timeout: 15_000,
287
+ });
288
+ result = { stdout, stderr: "", exitCode: 0 };
289
+ }
290
+ catch (e) {
291
+ const err = e;
292
+ result = {
293
+ stdout: typeof err.stdout === "string" ? err.stdout : (err.stdout?.toString() ?? ""),
294
+ stderr: typeof err.stderr === "string" ? err.stderr : (err.stderr?.toString() ?? ""),
295
+ exitCode: err.status ?? 1,
296
+ };
297
+ }
298
+ expect(result.exitCode).toBe(0);
299
+ expect(result.stdout).toBe("");
300
+ expect(result.stderr).toBe("");
301
+ });
302
+ });
303
+ // ── Suite B: File / IO Errors ────────────────────────────────────────────────
304
+ describe("workflow validate — Suite B: File / IO Errors", () => {
305
+ test("B.1 missing file exits 1 with file-not-found error", () => {
306
+ const file = join(tmpDir, "does-not-exist.yaml");
307
+ const result = runValidate(file);
308
+ expect(result.exitCode).toBe(1);
309
+ expect(result.stdout).toBe("");
310
+ expect(result.stderr).toContain("file not found:");
311
+ expect(result.stderr).toContain(file);
312
+ });
313
+ test("B.2 directory passed as file exits 1 with non-empty stderr", () => {
314
+ const result = runValidate(tmpDir);
315
+ expect(result.exitCode).toBe(1);
316
+ expect(result.stderr.length).toBeGreaterThan(0);
317
+ });
318
+ });
319
+ // ── Suite C: YAML / Shape Errors ─────────────────────────────────────────────
320
+ describe("workflow validate — Suite C: YAML / Shape Errors", () => {
321
+ test("C.1 malformed YAML exits 1 with 'invalid YAML' prefix", async () => {
322
+ const file = join(tmpDir, "broken.yaml");
323
+ await writeFile(file, ":::: not yaml ::::");
324
+ const result = runValidate(file);
325
+ expect(result.exitCode).toBe(1);
326
+ expect(result.stdout).toBe("");
327
+ expect(result.stderr).toContain("invalid YAML:");
328
+ });
329
+ test("C.2 valid YAML but wrong shape exits 1 with WorkflowPayload error", async () => {
330
+ const file = join(tmpDir, "wrong-shape.yaml");
331
+ await writeFile(file, stringify({ hello: "world" }));
332
+ const result = runValidate(file);
333
+ expect(result.exitCode).toBe(1);
334
+ expect(result.stderr).toContain("invalid workflow YAML: expected WorkflowPayload shape");
335
+ });
336
+ test("C.3 empty file exits 1", async () => {
337
+ const file = join(tmpDir, "empty.yaml");
338
+ await writeFile(file, "");
339
+ const result = runValidate(file);
340
+ expect(result.exitCode).toBe(1);
341
+ // either "invalid YAML:" or the WorkflowPayload-shape error is acceptable
342
+ const okMessage = result.stderr.includes("invalid YAML:") || result.stderr.includes("invalid workflow YAML:");
343
+ expect(okMessage).toBe(true);
344
+ });
345
+ });
346
+ // ── Suite D: Filename Consistency ────────────────────────────────────────────
347
+ describe("workflow validate — Suite D: Filename Consistency", () => {
348
+ test("D.1 name mismatch with filename exits 1", async () => {
349
+ const file = join(tmpDir, "foo-bar.yaml");
350
+ // write a payload whose name is baz-qux, file is foo-bar
351
+ await writeFile(file, stringify(makeMinimalPayload("baz-qux")));
352
+ const result = runValidate(file);
353
+ expect(result.exitCode).toBe(1);
354
+ expect(result.stderr).toContain("workflow name mismatch:");
355
+ expect(result.stderr).toContain("foo-bar");
356
+ expect(result.stderr).toContain("baz-qux");
357
+ });
358
+ test("D.2 index.yaml accepts directory name as workflow name", async () => {
359
+ const dir = join(tmpDir, "my-flow");
360
+ await mkdir(dir, { recursive: true });
361
+ const file = join(dir, "index.yaml");
362
+ await writeFile(file, stringify(makeMinimalPayload("my-flow")));
363
+ const result = runValidate(file);
364
+ expect(result.exitCode).toBe(0);
365
+ expect(result.stdout).toBe("");
366
+ expect(result.stderr).toBe("");
367
+ });
368
+ });
369
+ // ── Suite E: Semantic Errors ─────────────────────────────────────────────────
370
+ describe("workflow validate — Suite E: Semantic Errors", () => {
371
+ test("E.1 graph prompt references variable absent from frontmatter", async () => {
372
+ const payload = {
373
+ name: "comment-pr",
374
+ description: "Comment on PR",
375
+ roles: {
376
+ commenter: {
377
+ description: "Commenter",
378
+ goal: "Comment",
379
+ capabilities: ["commenting"],
380
+ procedure: "Comment",
381
+ output: "Comment",
382
+ // NB: no `prNumber` property declared
383
+ frontmatter: {
384
+ type: "object",
385
+ properties: {
386
+ $status: { const: "approved" },
387
+ },
388
+ required: ["$status"],
389
+ },
390
+ },
391
+ },
392
+ graph: {
393
+ $START: {
394
+ new: { role: "commenter", prompt: "Begin", location: null },
395
+ resume: { role: "commenter", prompt: "Resume", location: null },
396
+ },
397
+ commenter: {
398
+ approved: {
399
+ role: "$END",
400
+ prompt: "Comment on PR #{{ prNumber }}",
401
+ location: null,
402
+ },
403
+ },
404
+ },
405
+ };
406
+ const file = join(tmpDir, "comment-pr.yaml");
407
+ await writeFile(file, stringify(payload));
408
+ const result = runValidate(file);
409
+ expect(result.exitCode).toBe(1);
410
+ expect(result.stderr).toContain("workflow validation failed:");
411
+ expect(result.stderr).toContain('template variable "prNumber"');
412
+ expect(result.stderr).toContain("commenter");
413
+ });
414
+ test("E.2 multi-exit oneOf variant prompt references variable not in that variant", async () => {
415
+ const payload = {
416
+ name: "review-bad",
417
+ description: "Bad review",
418
+ roles: {
419
+ writer: {
420
+ description: "Writes",
421
+ goal: "Write",
422
+ capabilities: ["writing"],
423
+ procedure: "Write",
424
+ output: "Content",
425
+ frontmatter: {
426
+ type: "object",
427
+ properties: {
428
+ $status: { const: "done" },
429
+ plan: { type: "string" },
430
+ },
431
+ required: ["$status", "plan"],
432
+ },
433
+ },
434
+ reviewer: {
435
+ description: "Reviews",
436
+ goal: "Review",
437
+ capabilities: ["reviewing"],
438
+ procedure: "Review",
439
+ output: "Review",
440
+ frontmatter: {
441
+ type: "object",
442
+ oneOf: [
443
+ {
444
+ properties: {
445
+ $status: { const: "approved" },
446
+ summary: { type: "string" },
447
+ },
448
+ required: ["$status", "summary"],
449
+ },
450
+ {
451
+ properties: {
452
+ $status: { const: "rejected" },
453
+ reason: { type: "string" },
454
+ },
455
+ required: ["$status", "reason"],
456
+ },
457
+ ],
458
+ },
459
+ },
460
+ },
461
+ graph: {
462
+ $START: {
463
+ new: { role: "writer", prompt: "Begin", location: null },
464
+ resume: { role: "writer", prompt: "Resume", location: null },
465
+ },
466
+ writer: { done: { role: "reviewer", prompt: "Review: {{ plan }}", location: null } },
467
+ reviewer: {
468
+ // approved branch references {{ reason }} which only exists in rejected variant
469
+ approved: { role: "$END", prompt: "Approved because: {{ reason }}", location: null },
470
+ rejected: { role: "writer", prompt: "Fix: {{ reason }}", location: null },
471
+ },
472
+ },
473
+ };
474
+ const file = join(tmpDir, "review-bad.yaml");
475
+ await writeFile(file, stringify(payload));
476
+ const result = runValidate(file);
477
+ expect(result.exitCode).toBe(1);
478
+ expect(result.stderr).toContain('template variable "reason"');
479
+ expect(result.stderr).toContain('variant "approved"');
480
+ });
481
+ test("E.3 graph references unknown role", async () => {
482
+ const payload = makeMinimalPayload("orphan-graph");
483
+ const graph = payload.graph;
484
+ graph.bogus = { done: { role: "$END", prompt: "x", location: null } };
485
+ const file = join(tmpDir, "orphan-graph.yaml");
486
+ await writeFile(file, stringify(payload));
487
+ const result = runValidate(file);
488
+ expect(result.exitCode).toBe(1);
489
+ expect(result.stderr).toContain('unknown role "bogus"');
490
+ });
491
+ test("E.4 $START missing resume edge", async () => {
492
+ const payload = makeMinimalPayload("no-resume");
493
+ const graph = payload.graph;
494
+ graph.$START = {
495
+ new: { role: "writer", prompt: "Begin", location: null },
496
+ // no resume edge
497
+ };
498
+ const file = join(tmpDir, "no-resume.yaml");
499
+ await writeFile(file, stringify(payload));
500
+ const result = runValidate(file);
501
+ expect(result.exitCode).toBe(1);
502
+ expect(result.stderr).toContain('$START must have edges with statuses "new" and "resume"');
503
+ });
504
+ test("E.5 unreachable role exits 1", async () => {
505
+ const payload = makeMultiRolePayload("unreachable");
506
+ // add an extra role that is in roles + graph but no edge points to it
507
+ const roles = payload.roles;
508
+ roles.orphan = {
509
+ description: "Orphan",
510
+ goal: "nothing",
511
+ capabilities: [],
512
+ procedure: "none",
513
+ output: "none",
514
+ frontmatter: {
515
+ type: "object",
516
+ properties: { $status: { const: "done" } },
517
+ required: ["$status"],
518
+ },
519
+ };
520
+ const graph = payload.graph;
521
+ graph.orphan = { done: { role: "$END", prompt: "ok", location: null } };
522
+ const file = join(tmpDir, "unreachable.yaml");
523
+ await writeFile(file, stringify(payload));
524
+ const result = runValidate(file);
525
+ expect(result.exitCode).toBe(1);
526
+ expect(result.stderr).toContain("is not reachable from $START");
527
+ });
528
+ test("E.6 $SUSPEND used as edge target exits 1", async () => {
529
+ const payload = makeMinimalPayload("bad-suspend");
530
+ const graph = payload.graph;
531
+ graph.writer = { done: { role: "$SUSPEND", prompt: "x", location: null } };
532
+ const file = join(tmpDir, "bad-suspend.yaml");
533
+ await writeFile(file, stringify(payload));
534
+ const result = runValidate(file);
535
+ expect(result.exitCode).toBe(1);
536
+ expect(result.stderr).toContain("$SUSPEND");
537
+ });
538
+ test("E.7 multiple semantic errors are all reported", async () => {
539
+ const payload = makeMinimalPayload("multi-error");
540
+ // 1) unknown role: graph node referencing undefined role
541
+ const graph = payload.graph;
542
+ graph.bogus = { done: { role: "$END", prompt: "x", location: null } };
543
+ // 2) $START missing resume
544
+ graph.$START = {
545
+ new: { role: "writer", prompt: "Begin", location: null },
546
+ };
547
+ // 3) bad Liquid variable
548
+ graph.writer = {
549
+ done: { role: "$END", prompt: "Use {{ missing }}", location: null },
550
+ };
551
+ const file = join(tmpDir, "multi-error.yaml");
552
+ await writeFile(file, stringify(payload));
553
+ const result = runValidate(file);
554
+ expect(result.exitCode).toBe(1);
555
+ expect(result.stderr).toContain("workflow validation failed:");
556
+ expect(result.stderr).toContain('unknown role "bogus"');
557
+ expect(result.stderr).toContain("$START must have edges");
558
+ expect(result.stderr).toContain("missing");
559
+ // each error is bullet-prefixed with ` - `
560
+ expect(result.stderr).toContain(" - ");
561
+ });
562
+ });
563
+ // ── Suite F: Isolation From CAS / Store ──────────────────────────────────────
564
+ describe("workflow validate — Suite F: Isolation From CAS / Store", () => {
565
+ test("F.1 runs without OCAS_HOME set", async () => {
566
+ const file = join(tmpDir, "iso-wf.yaml");
567
+ await writeFile(file, stringify(makeMinimalPayload("iso-wf")));
568
+ // Strip OCAS_HOME / UWF_HOME from env, point HOME at empty tmp.
569
+ const isolatedHome = join(tmpDir, "isolated-home");
570
+ await mkdir(isolatedHome, { recursive: true });
571
+ const env = { ...process.env };
572
+ delete env.OCAS_HOME;
573
+ delete env.UWF_HOME;
574
+ env.HOME = isolatedHome;
575
+ const result = runValidate(file, [], env);
576
+ expect(result.exitCode).toBe(0);
577
+ expect(result.stdout).toBe("");
578
+ expect(result.stderr).toBe("");
579
+ });
580
+ test("F.2 runs even when HOME is read-only (skip on win32)", {
581
+ skip: process.platform === "win32",
582
+ }, async () => {
583
+ const file = join(tmpDir, "ro-home-wf.yaml");
584
+ await writeFile(file, stringify(makeMinimalPayload("ro-home-wf")));
585
+ const ro = join(tmpDir, "ro-home");
586
+ await mkdir(ro, { recursive: true });
587
+ await chmod(ro, 0o500); // r-x------
588
+ try {
589
+ const env = { ...process.env };
590
+ delete env.OCAS_HOME;
591
+ delete env.UWF_HOME;
592
+ env.HOME = ro;
593
+ const result = runValidate(file, [], env);
594
+ expect(result.exitCode).toBe(0);
595
+ expect(result.stdout).toBe("");
596
+ expect(result.stderr).toBe("");
597
+ }
598
+ finally {
599
+ // restore permissions so afterEach can clean up
600
+ await chmod(ro, 0o755);
601
+ }
602
+ });
603
+ test("F.3 does not modify registry on success", async () => {
604
+ const file = join(tmpDir, "reg-wf.yaml");
605
+ await writeFile(file, stringify(makeMinimalPayload("reg-wf")));
606
+ const ocasHome = join(tmpDir, "ocas-home");
607
+ await mkdir(ocasHome, { recursive: true });
608
+ const env = { ...process.env, OCAS_HOME: ocasHome, UWF_HOME: ocasHome };
609
+ const beforeListing = await listingSnapshot(ocasHome);
610
+ const result = runValidate(file, [], env);
611
+ expect(result.exitCode).toBe(0);
612
+ expect(result.stdout).toBe("");
613
+ expect(result.stderr).toBe("");
614
+ const afterListing = await listingSnapshot(ocasHome);
615
+ expect(afterListing).toEqual(beforeListing);
616
+ });
617
+ test("F.4 does not write any nodes to CAS on success", async () => {
618
+ const file = join(tmpDir, "cas-iso-wf.yaml");
619
+ await writeFile(file, stringify(makeMinimalPayload("cas-iso-wf")));
620
+ const ocasHome = join(tmpDir, "ocas2");
621
+ await mkdir(ocasHome, { recursive: true });
622
+ const env = { ...process.env, OCAS_HOME: ocasHome, UWF_HOME: ocasHome };
623
+ const beforeListing = await listingSnapshot(ocasHome);
624
+ const result = runValidate(file, [], env);
625
+ expect(result.exitCode).toBe(0);
626
+ const afterListing = await listingSnapshot(ocasHome);
627
+ expect(afterListing).toEqual(beforeListing);
628
+ });
629
+ });
630
+ // ── Suite G: Argument Surface ────────────────────────────────────────────────
631
+ describe("workflow validate — Suite G: Argument Surface", () => {
632
+ test("G.1 missing <file> argument fails with non-zero exit", () => {
633
+ const result = runCli(["workflow", "validate"]);
634
+ expect(result.exitCode).not.toBe(0);
635
+ // commander phrasing varies; check for the broad `missing required argument` string.
636
+ expect(result.stderr.toLowerCase()).toContain("missing required argument");
637
+ });
638
+ test("G.2 'workflow --help' lists 'validate'", () => {
639
+ const result = runCli(["workflow", "--help"]);
640
+ expect(result.exitCode).toBe(0);
641
+ expect(result.stdout).toContain("validate");
642
+ });
643
+ test("G.3 'workflow validate --help' describes the command", () => {
644
+ const result = runCli(["workflow", "validate", "--help"]);
645
+ expect(result.exitCode).toBe(0);
646
+ expect(result.stdout).toContain("file");
647
+ expect(result.stdout.length).toBeGreaterThan(0);
648
+ });
649
+ });
650
+ // ── helpers for snapshot ─────────────────────────────────────────────────────
651
+ /**
652
+ * Recursively snapshot a directory's listing (paths + sizes).
653
+ * Used to assert no files are written during validate.
654
+ */
655
+ async function listingSnapshot(root) {
656
+ const out = {};
657
+ async function walk(dir) {
658
+ let entries = [];
659
+ try {
660
+ entries = await readdir(dir);
661
+ }
662
+ catch {
663
+ return;
664
+ }
665
+ for (const entry of entries) {
666
+ const full = join(dir, entry);
667
+ let st;
668
+ try {
669
+ st = await stat(full);
670
+ }
671
+ catch {
672
+ continue;
673
+ }
674
+ if (st.isDirectory()) {
675
+ await walk(full);
676
+ }
677
+ else {
678
+ out[full.slice(root.length)] = st.size;
679
+ }
680
+ }
681
+ }
682
+ await walk(root);
683
+ return out;
684
+ }
685
+ // Suppress unused warnings in tests that don't currently use these helpers
686
+ void readFile;
687
+ //# sourceMappingURL=workflow-validate.test.js.map