@united-workforce/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) 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__/e2e-mock-agent.test.js +23 -7
  10. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  11. package/dist/__tests__/format-text-default.test.d.ts +2 -0
  12. package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
  13. package/dist/__tests__/format-text-default.test.js +43 -0
  14. package/dist/__tests__/format-text-default.test.js.map +1 -0
  15. package/dist/__tests__/format-text-registry.test.d.ts +2 -0
  16. package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
  17. package/dist/__tests__/format-text-registry.test.js +158 -0
  18. package/dist/__tests__/format-text-registry.test.js.map +1 -0
  19. package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
  20. package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
  21. package/dist/__tests__/log-text-renderer.test.js +265 -0
  22. package/dist/__tests__/log-text-renderer.test.js.map +1 -0
  23. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
  24. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
  25. package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
  26. package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
  27. package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
  28. package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
  29. package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
  30. package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
  31. package/dist/__tests__/pid-recycling.test.js +9 -7
  32. package/dist/__tests__/pid-recycling.test.js.map +1 -1
  33. package/dist/__tests__/prompt.test.js +46 -4
  34. package/dist/__tests__/prompt.test.js.map +1 -1
  35. package/dist/__tests__/resolve-head-hash.test.js +8 -0
  36. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  37. package/dist/__tests__/solve-issue-tea-worktree.test.js +3 -1
  38. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  39. package/dist/__tests__/step-ask.test.js +9 -1
  40. package/dist/__tests__/step-ask.test.js.map +1 -1
  41. package/dist/__tests__/store-unified-threads.test.js +19 -17
  42. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  43. package/dist/__tests__/thread-cancel-status.test.js +19 -13
  44. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  45. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
  46. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
  47. package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
  48. package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
  49. package/dist/__tests__/thread-list-filters.test.js +10 -8
  50. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  51. package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
  52. package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
  53. package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
  54. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
  55. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
  56. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
  57. package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
  58. package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
  59. package/dist/__tests__/thread-poke.test.js +11 -1
  60. package/dist/__tests__/thread-poke.test.js.map +1 -1
  61. package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
  62. package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
  63. package/dist/__tests__/thread-resume.test.js +11 -1
  64. package/dist/__tests__/thread-resume.test.js.map +1 -1
  65. package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
  66. package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
  67. package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
  68. package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
  69. package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
  70. package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
  71. package/dist/__tests__/thread-suspend-step.test.js +5 -2
  72. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  73. package/dist/__tests__/thread-test-helpers.d.ts +7 -0
  74. package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
  75. package/dist/__tests__/thread-test-helpers.js +13 -0
  76. package/dist/__tests__/thread-test-helpers.js.map +1 -1
  77. package/dist/__tests__/thread.test.js +11 -9
  78. package/dist/__tests__/thread.test.js.map +1 -1
  79. package/dist/__tests__/validate-semantic.test.js +56 -2
  80. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  81. package/dist/__tests__/workflow-list-recursive.test.js +10 -7
  82. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -1
  83. package/dist/__tests__/workflow-resolution.test.js +10 -7
  84. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  85. package/dist/__tests__/workflow-show-resolution.test.js +10 -7
  86. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -1
  87. package/dist/__tests__/workflow-validate.test.js +75 -55
  88. package/dist/__tests__/workflow-validate.test.js.map +1 -1
  89. package/dist/__tests__/write-envelope.test.d.ts +2 -0
  90. package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
  91. package/dist/__tests__/write-envelope.test.js +201 -0
  92. package/dist/__tests__/write-envelope.test.js.map +1 -0
  93. package/dist/cli.js +58 -35
  94. package/dist/cli.js.map +1 -1
  95. package/dist/commands/config.d.ts.map +1 -1
  96. package/dist/commands/config.js +12 -0
  97. package/dist/commands/config.js.map +1 -1
  98. package/dist/commands/prompt.d.ts.map +1 -1
  99. package/dist/commands/prompt.js +42 -29
  100. package/dist/commands/prompt.js.map +1 -1
  101. package/dist/commands/setup.d.ts +9 -4
  102. package/dist/commands/setup.d.ts.map +1 -1
  103. package/dist/commands/setup.js +51 -7
  104. package/dist/commands/setup.js.map +1 -1
  105. package/dist/commands/thread.d.ts.map +1 -1
  106. package/dist/commands/thread.js +44 -2
  107. package/dist/commands/thread.js.map +1 -1
  108. package/dist/commands/workflow.d.ts +1 -1
  109. package/dist/commands/workflow.d.ts.map +1 -1
  110. package/dist/commands/workflow.js +2 -6
  111. package/dist/commands/workflow.js.map +1 -1
  112. package/dist/concurrency/concurrency.d.ts +34 -0
  113. package/dist/concurrency/concurrency.d.ts.map +1 -0
  114. package/dist/concurrency/concurrency.js +216 -0
  115. package/dist/concurrency/concurrency.js.map +1 -0
  116. package/dist/concurrency/index.d.ts +3 -0
  117. package/dist/concurrency/index.d.ts.map +1 -0
  118. package/dist/concurrency/index.js +2 -0
  119. package/dist/concurrency/index.js.map +1 -0
  120. package/dist/concurrency/types.d.ts +19 -0
  121. package/dist/concurrency/types.d.ts.map +1 -0
  122. package/dist/concurrency/types.js +2 -0
  123. package/dist/concurrency/types.js.map +1 -0
  124. package/dist/format.d.ts +69 -2
  125. package/dist/format.d.ts.map +1 -1
  126. package/dist/format.js +198 -1
  127. package/dist/format.js.map +1 -1
  128. package/dist/output-mappers.d.ts +122 -0
  129. package/dist/output-mappers.d.ts.map +1 -0
  130. package/dist/output-mappers.js +134 -0
  131. package/dist/output-mappers.js.map +1 -0
  132. package/dist/schemas.d.ts +4 -1
  133. package/dist/schemas.d.ts.map +1 -1
  134. package/dist/schemas.js +31 -4
  135. package/dist/schemas.js.map +1 -1
  136. package/dist/text-renderers.d.ts +30 -0
  137. package/dist/text-renderers.d.ts.map +1 -0
  138. package/dist/text-renderers.js +251 -0
  139. package/dist/text-renderers.js.map +1 -0
  140. package/dist/validate-semantic.d.ts.map +1 -1
  141. package/dist/validate-semantic.js +28 -11
  142. package/dist/validate-semantic.js.map +1 -1
  143. package/examples/brainstorm.yaml +130 -0
  144. package/examples/debate.yaml +169 -0
  145. package/examples/socratic-questioning.yaml +112 -0
  146. package/package.json +5 -4
  147. package/src/__tests__/adapter-json-roundtrip.test.ts +15 -6
  148. package/src/__tests__/concurrency.test.ts +266 -0
  149. package/src/__tests__/e2e-mock-agent.test.ts +45 -7
  150. package/src/__tests__/format-text-default.test.ts +49 -0
  151. package/src/__tests__/format-text-registry.test.ts +173 -0
  152. package/src/__tests__/log-text-renderer.test.ts +294 -0
  153. package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
  154. package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
  155. package/src/__tests__/pid-recycling.test.ts +9 -8
  156. package/src/__tests__/prompt.test.ts +48 -4
  157. package/src/__tests__/resolve-head-hash.test.ts +7 -0
  158. package/src/__tests__/solve-issue-tea-worktree.test.ts +3 -1
  159. package/src/__tests__/step-ask.test.ts +8 -1
  160. package/src/__tests__/store-unified-threads.test.ts +21 -18
  161. package/src/__tests__/thread-cancel-status.test.ts +21 -14
  162. package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
  163. package/src/__tests__/thread-list-filters.test.ts +9 -9
  164. package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
  165. package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
  166. package/src/__tests__/thread-poke.test.ts +10 -1
  167. package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
  168. package/src/__tests__/thread-resume.test.ts +10 -1
  169. package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
  170. package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
  171. package/src/__tests__/thread-suspend-step.test.ts +5 -2
  172. package/src/__tests__/thread-test-helpers.ts +15 -1
  173. package/src/__tests__/thread.test.ts +10 -10
  174. package/src/__tests__/validate-semantic.test.ts +59 -2
  175. package/src/__tests__/workflow-list-recursive.test.ts +9 -9
  176. package/src/__tests__/workflow-resolution.test.ts +9 -8
  177. package/src/__tests__/workflow-show-resolution.test.ts +9 -8
  178. package/src/__tests__/workflow-validate.test.ts +78 -56
  179. package/src/__tests__/write-envelope.test.ts +257 -0
  180. package/src/cli.ts +92 -35
  181. package/src/commands/config.ts +11 -0
  182. package/src/commands/prompt.ts +42 -29
  183. package/src/commands/setup.ts +57 -7
  184. package/src/commands/thread.ts +48 -2
  185. package/src/commands/workflow.ts +3 -7
  186. package/src/concurrency/concurrency.ts +245 -0
  187. package/src/concurrency/index.ts +10 -0
  188. package/src/concurrency/types.ts +19 -0
  189. package/src/format.ts +282 -2
  190. package/src/output-mappers.ts +254 -0
  191. package/src/schemas.ts +39 -3
  192. package/src/text-renderers.ts +355 -0
  193. package/src/validate-semantic.ts +33 -12
@@ -233,38 +233,42 @@ afterEach(async () => {
233
233
  });
234
234
 
235
235
  // ── Suite A: Success Path ────────────────────────────────────────────────────
236
+ //
237
+ // Note: Issue #308 makes validate an envelope-emitting command. Default
238
+ // `--format text` renders `✓ valid` (or `✗ invalid (N errors)`) to stdout.
239
+ // Tests below assert the new envelope contract.
236
240
 
237
241
  describe("workflow validate — Suite A: Success Path", () => {
238
- test("A.1 valid single-role workflow exits 0 silent", async () => {
242
+ test("A.1 valid single-role workflow exits 0 with text envelope", async () => {
239
243
  const file = join(tmpDir, "test-wf.yaml");
240
244
  await writeFile(file, stringify(makeMinimalPayload("test-wf")));
241
245
 
242
246
  const result = runValidate(file);
243
247
 
244
248
  expect(result.exitCode).toBe(0);
245
- expect(result.stdout).toBe("");
249
+ expect(result.stdout.trim()).toBe("✓ valid");
246
250
  expect(result.stderr).toBe("");
247
251
  });
248
252
 
249
- test("A.2 valid multi-role workflow with Liquid vars exits 0 silent", async () => {
253
+ test("A.2 valid multi-role workflow with Liquid vars exits 0 with text envelope", async () => {
250
254
  const file = join(tmpDir, "writer-flow.yaml");
251
255
  await writeFile(file, stringify(makeMultiRolePayload("writer-flow")));
252
256
 
253
257
  const result = runValidate(file);
254
258
 
255
259
  expect(result.exitCode).toBe(0);
256
- expect(result.stdout).toBe("");
260
+ expect(result.stdout.trim()).toBe("✓ valid");
257
261
  expect(result.stderr).toBe("");
258
262
  });
259
263
 
260
- test("A.3 valid oneOf multi-exit workflow exits 0 silent", async () => {
264
+ test("A.3 valid oneOf multi-exit workflow exits 0 with text envelope", async () => {
261
265
  const file = join(tmpDir, "review-flow.yaml");
262
266
  await writeFile(file, stringify(makeOneOfPayload("review-flow")));
263
267
 
264
268
  const result = runValidate(file);
265
269
 
266
270
  expect(result.exitCode).toBe(0);
267
- expect(result.stdout).toBe("");
271
+ expect(result.stdout.trim()).toBe("✓ valid");
268
272
  expect(result.stderr).toBe("");
269
273
  });
270
274
 
@@ -307,16 +311,16 @@ graph:
307
311
  const result = runValidate(file);
308
312
 
309
313
  expect(result.exitCode).toBe(0);
310
- expect(result.stdout).toBe("");
314
+ expect(result.stdout.trim()).toBe("✓ valid");
311
315
  expect(result.stderr).toBe("");
312
316
  });
313
317
 
314
- test("A.5 --format yaml does not change silent success output", async () => {
318
+ test("A.5 --format raw-json emits bare valid envelope value", async () => {
315
319
  const file = join(tmpDir, "test-wf.yaml");
316
320
  await writeFile(file, stringify(makeMinimalPayload("test-wf")));
317
321
 
318
322
  // --format is a global option on `program`, must come before the subcommand
319
- const args = [CLI_PATH, "--format", "yaml", "workflow", "validate", file];
323
+ const args = [CLI_PATH, "--format", "raw-json", "workflow", "validate", file];
320
324
  let result: RunResult;
321
325
  try {
322
326
  const stdout = execFileSync(process.execPath, args, {
@@ -340,7 +344,7 @@ graph:
340
344
  }
341
345
 
342
346
  expect(result.exitCode).toBe(0);
343
- expect(result.stdout).toBe("");
347
+ expect(JSON.parse(result.stdout)).toEqual({ valid: true, errors: [] });
344
348
  expect(result.stderr).toBe("");
345
349
  });
346
350
  });
@@ -415,9 +419,10 @@ describe("workflow validate — Suite D: Filename Consistency", () => {
415
419
  const result = runValidate(file);
416
420
 
417
421
  expect(result.exitCode).toBe(1);
418
- expect(result.stderr).toContain("workflow name mismatch:");
419
- expect(result.stderr).toContain("foo-bar");
420
- expect(result.stderr).toContain("baz-qux");
422
+ // text envelope contains the error in stdout
423
+ expect(result.stdout).toContain("workflow name mismatch:");
424
+ expect(result.stdout).toContain("foo-bar");
425
+ expect(result.stdout).toContain("baz-qux");
421
426
  });
422
427
 
423
428
  test("D.2 index.yaml accepts directory name as workflow name", async () => {
@@ -429,12 +434,15 @@ describe("workflow validate — Suite D: Filename Consistency", () => {
429
434
  const result = runValidate(file);
430
435
 
431
436
  expect(result.exitCode).toBe(0);
432
- expect(result.stdout).toBe("");
437
+ expect(result.stdout.trim()).toBe("✓ valid");
433
438
  expect(result.stderr).toBe("");
434
439
  });
435
440
  });
436
441
 
437
442
  // ── Suite E: Semantic Errors ─────────────────────────────────────────────────
443
+ //
444
+ // Issue #308: errors are now rendered to stdout via the validate-result
445
+ // envelope template (`✗ invalid (N errors)\n - <error>...`). Exit code is 1.
438
446
 
439
447
  describe("workflow validate — Suite E: Semantic Errors", () => {
440
448
  test("E.1 graph prompt references variable absent from frontmatter", async () => {
@@ -478,9 +486,9 @@ describe("workflow validate — Suite E: Semantic Errors", () => {
478
486
  const result = runValidate(file);
479
487
 
480
488
  expect(result.exitCode).toBe(1);
481
- expect(result.stderr).toContain("workflow validation failed:");
482
- expect(result.stderr).toContain('template variable "prNumber"');
483
- expect(result.stderr).toContain("commenter");
489
+ expect(result.stdout).toContain(" invalid");
490
+ expect(result.stdout).toContain('template variable "prNumber"');
491
+ expect(result.stdout).toContain("commenter");
484
492
  });
485
493
 
486
494
  test("E.2 multi-exit oneOf variant prompt references variable not in that variant", async () => {
@@ -549,8 +557,8 @@ describe("workflow validate — Suite E: Semantic Errors", () => {
549
557
  const result = runValidate(file);
550
558
 
551
559
  expect(result.exitCode).toBe(1);
552
- expect(result.stderr).toContain('template variable "reason"');
553
- expect(result.stderr).toContain('variant "approved"');
560
+ expect(result.stdout).toContain('template variable "reason"');
561
+ expect(result.stdout).toContain('variant "approved"');
554
562
  });
555
563
 
556
564
  test("E.3 graph references unknown role", async () => {
@@ -564,7 +572,7 @@ describe("workflow validate — Suite E: Semantic Errors", () => {
564
572
  const result = runValidate(file);
565
573
 
566
574
  expect(result.exitCode).toBe(1);
567
- expect(result.stderr).toContain('unknown role "bogus"');
575
+ expect(result.stdout).toContain('unknown role "bogus"');
568
576
  });
569
577
 
570
578
  test("E.4 $START missing resume edge", async () => {
@@ -581,7 +589,7 @@ describe("workflow validate — Suite E: Semantic Errors", () => {
581
589
  const result = runValidate(file);
582
590
 
583
591
  expect(result.exitCode).toBe(1);
584
- expect(result.stderr).toContain('$START must have edges with statuses "new" and "resume"');
592
+ expect(result.stdout).toContain('$START must have edges with statuses "new" and "resume"');
585
593
  });
586
594
 
587
595
  test("E.5 unreachable role exits 1", async () => {
@@ -609,7 +617,7 @@ describe("workflow validate — Suite E: Semantic Errors", () => {
609
617
  const result = runValidate(file);
610
618
 
611
619
  expect(result.exitCode).toBe(1);
612
- expect(result.stderr).toContain("is not reachable from $START");
620
+ expect(result.stdout).toContain("is not reachable from $START");
613
621
  });
614
622
 
615
623
  test("E.6 $SUSPEND used as edge target exits 1", async () => {
@@ -623,7 +631,7 @@ describe("workflow validate — Suite E: Semantic Errors", () => {
623
631
  const result = runValidate(file);
624
632
 
625
633
  expect(result.exitCode).toBe(1);
626
- expect(result.stderr).toContain("$SUSPEND");
634
+ expect(result.stdout).toContain("$SUSPEND");
627
635
  });
628
636
 
629
637
  test("E.7 multiple semantic errors are all reported", async () => {
@@ -646,16 +654,24 @@ describe("workflow validate — Suite E: Semantic Errors", () => {
646
654
  const result = runValidate(file);
647
655
 
648
656
  expect(result.exitCode).toBe(1);
649
- expect(result.stderr).toContain("workflow validation failed:");
650
- expect(result.stderr).toContain('unknown role "bogus"');
651
- expect(result.stderr).toContain("$START must have edges");
652
- expect(result.stderr).toContain("missing");
657
+ expect(result.stdout).toContain(" invalid");
658
+ expect(result.stdout).toContain('unknown role "bogus"');
659
+ expect(result.stdout).toContain("$START must have edges");
660
+ expect(result.stdout).toContain("missing");
653
661
  // each error is bullet-prefixed with ` - `
654
- expect(result.stderr).toContain(" - ");
662
+ expect(result.stdout).toContain(" - ");
655
663
  });
656
664
  });
657
665
 
658
666
  // ── Suite F: Isolation From CAS / Store ──────────────────────────────────────
667
+ //
668
+ // Issue #308: validate now uses the unified envelope writer, which requires
669
+ // the CAS store (for `@uwf/output/validate-result` schema lookup and the
670
+ // `@ocas/template/text/<hash>` template). The store is initialized
671
+ // idempotently on startup. These tests assert that:
672
+ // - validate works without explicit OCAS_HOME
673
+ // - validate runs idempotently (second run modifies nothing on success)
674
+ // - validate does not modify the workflow registry on success
659
675
 
660
676
  describe("workflow validate — Suite F: Isolation From CAS / Store", () => {
661
677
  test("F.1 runs without OCAS_HOME set", async () => {
@@ -673,38 +689,32 @@ describe("workflow validate — Suite F: Isolation From CAS / Store", () => {
673
689
  const result = runValidate(file, [], env);
674
690
 
675
691
  expect(result.exitCode).toBe(0);
676
- expect(result.stdout).toBe("");
692
+ expect(result.stdout.trim()).toBe("✓ valid");
677
693
  expect(result.stderr).toBe("");
678
694
  });
679
695
 
680
- test("F.2 runs even when HOME is read-only (skip on win32)", {
696
+ test("F.2 runs even when ocas store is empty/uninitialized (skip on win32)", {
681
697
  skip: process.platform === "win32",
682
698
  }, async () => {
683
699
  const file = join(tmpDir, "ro-home-wf.yaml");
684
700
  await writeFile(file, stringify(makeMinimalPayload("ro-home-wf")));
685
701
 
686
- const ro = join(tmpDir, "ro-home");
687
- await mkdir(ro, { recursive: true });
688
- await chmod(ro, 0o500); // r-x------
702
+ // Use a writable but empty OCAS_HOME — schema registration writes
703
+ // happen at startup but the validate command should still succeed.
704
+ const ocasHome = join(tmpDir, "fresh-ocas");
705
+ await mkdir(ocasHome, { recursive: true });
706
+ const env: NodeJS.ProcessEnv = { ...process.env };
707
+ delete env.UWF_HOME;
708
+ env.OCAS_HOME = ocasHome;
689
709
 
690
- try {
691
- const env: NodeJS.ProcessEnv = { ...process.env };
692
- delete env.OCAS_HOME;
693
- delete env.UWF_HOME;
694
- env.HOME = ro;
695
-
696
- const result = runValidate(file, [], env);
697
-
698
- expect(result.exitCode).toBe(0);
699
- expect(result.stdout).toBe("");
700
- expect(result.stderr).toBe("");
701
- } finally {
702
- // restore permissions so afterEach can clean up
703
- await chmod(ro, 0o755);
704
- }
710
+ const result = runValidate(file, [], env);
711
+
712
+ expect(result.exitCode).toBe(0);
713
+ expect(result.stdout.trim()).toBe("✓ valid");
714
+ expect(result.stderr).toBe("");
705
715
  });
706
716
 
707
- test("F.3 does not modify registry on success", async () => {
717
+ test("F.3 does not modify workflow registry on success", async () => {
708
718
  const file = join(tmpDir, "reg-wf.yaml");
709
719
  await writeFile(file, stringify(makeMinimalPayload("reg-wf")));
710
720
 
@@ -712,18 +722,25 @@ describe("workflow validate — Suite F: Isolation From CAS / Store", () => {
712
722
  await mkdir(ocasHome, { recursive: true });
713
723
  const env: NodeJS.ProcessEnv = { ...process.env, OCAS_HOME: ocasHome, UWF_HOME: ocasHome };
714
724
 
725
+ // First run primes the schema/template registrations
726
+ const first = runValidate(file, [], env);
727
+ expect(first.exitCode).toBe(0);
728
+ expect(first.stdout.trim()).toBe("✓ valid");
729
+
730
+ // Capture state after registrations are present
715
731
  const beforeListing = await listingSnapshot(ocasHome);
716
732
 
717
- const result = runValidate(file, [], env);
718
- expect(result.exitCode).toBe(0);
719
- expect(result.stdout).toBe("");
720
- expect(result.stderr).toBe("");
733
+ // Second run must not modify the registry (no @uwf/registry/<name> binding)
734
+ const second = runValidate(file, [], env);
735
+ expect(second.exitCode).toBe(0);
736
+ expect(second.stdout.trim()).toBe("✓ valid");
737
+ expect(second.stderr).toBe("");
721
738
 
722
739
  const afterListing = await listingSnapshot(ocasHome);
723
740
  expect(afterListing).toEqual(beforeListing);
724
741
  });
725
742
 
726
- test("F.4 does not write any nodes to CAS on success", async () => {
743
+ test("F.4 second run on the same workflow is idempotent (no further CAS writes)", async () => {
727
744
  const file = join(tmpDir, "cas-iso-wf.yaml");
728
745
  await writeFile(file, stringify(makeMinimalPayload("cas-iso-wf")));
729
746
 
@@ -731,10 +748,15 @@ describe("workflow validate — Suite F: Isolation From CAS / Store", () => {
731
748
  await mkdir(ocasHome, { recursive: true });
732
749
  const env: NodeJS.ProcessEnv = { ...process.env, OCAS_HOME: ocasHome, UWF_HOME: ocasHome };
733
750
 
751
+ // First run: schema/template registration is allowed to write to CAS
752
+ const first = runValidate(file, [], env);
753
+ expect(first.exitCode).toBe(0);
754
+
734
755
  const beforeListing = await listingSnapshot(ocasHome);
735
756
 
736
- const result = runValidate(file, [], env);
737
- expect(result.exitCode).toBe(0);
757
+ // Second run: registrations are idempotent → no new writes
758
+ const second = runValidate(file, [], env);
759
+ expect(second.exitCode).toBe(0);
738
760
 
739
761
  const afterListing = await listingSnapshot(ocasHome);
740
762
  expect(afterListing).toEqual(beforeListing);
@@ -0,0 +1,257 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { openStore } from "@ocas/fs";
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
6
+ import { isOutputFormat, type OutputFormat, SUPPORTED_FORMATS, writeEnvelope } from "../format.js";
7
+ import { registerUwfSchemas, type UwfSchemaHashes } from "../schemas.js";
8
+
9
+ let tmp: string;
10
+ let store: Awaited<ReturnType<typeof openStore>>;
11
+ let schemas: UwfSchemaHashes;
12
+
13
+ beforeEach(async () => {
14
+ tmp = await mkdtemp(join(tmpdir(), "uwf-write-envelope-"));
15
+ store = await openStore(tmp);
16
+ schemas = await registerUwfSchemas(store);
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await rm(tmp, { recursive: true, force: true });
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ function captureStdout<T>(fn: () => Promise<T>): { result: Promise<T>; output: string[] } {
25
+ const buf: string[] = [];
26
+ const spy = vi.spyOn(process.stdout, "write").mockImplementation(((
27
+ chunk: string | Uint8Array,
28
+ ): boolean => {
29
+ buf.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
30
+ return true;
31
+ }) as typeof process.stdout.write);
32
+ return {
33
+ result: (async () => {
34
+ try {
35
+ return await fn();
36
+ } finally {
37
+ spy.mockRestore();
38
+ }
39
+ })(),
40
+ output: buf,
41
+ };
42
+ }
43
+
44
+ describe("isOutputFormat type guard", () => {
45
+ test("accepts every supported format", () => {
46
+ for (const fmt of SUPPORTED_FORMATS) {
47
+ expect(isOutputFormat(fmt)).toBe(true);
48
+ }
49
+ });
50
+
51
+ test("rejects unknown formats", () => {
52
+ expect(isOutputFormat("xml")).toBe(false);
53
+ expect(isOutputFormat("")).toBe(false);
54
+ expect(isOutputFormat("JSON")).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe("SUPPORTED_FORMATS", () => {
59
+ test("contains exactly the five formats specified in cli-envelope-writer.md", () => {
60
+ expect([...SUPPORTED_FORMATS].sort()).toEqual(["json", "raw-json", "raw-yaml", "text", "yaml"]);
61
+ });
62
+ });
63
+
64
+ describe("writeEnvelope — json format", () => {
65
+ test("emits {type,value} JSON envelope with trailing newline", async () => {
66
+ const payload = { valid: true, errors: [] };
67
+ const { result, output } = captureStdout(async () =>
68
+ writeEnvelope(payload, "validate-result", { format: "json", store, schemas }),
69
+ );
70
+ await result;
71
+
72
+ const out = output.join("");
73
+ expect(out.endsWith("\n")).toBe(true);
74
+ const parsed = JSON.parse(out);
75
+ expect(parsed).toEqual({
76
+ type: schemas.outputs["validate-result"],
77
+ value: { valid: true, errors: [] },
78
+ });
79
+ });
80
+ });
81
+
82
+ describe("writeEnvelope — yaml format", () => {
83
+ test("emits envelope yaml with type then value keys", async () => {
84
+ const payload = { valid: false, errors: ["a", "b"] };
85
+ const { result, output } = captureStdout(async () =>
86
+ writeEnvelope(payload, "validate-result", { format: "yaml", store, schemas }),
87
+ );
88
+ await result;
89
+
90
+ const out = output.join("");
91
+ expect(out.endsWith("\n")).toBe(true);
92
+ expect(out).toContain(`type: ${schemas.outputs["validate-result"]}`);
93
+ expect(out).toContain("value:");
94
+ expect(out).toContain("valid: false");
95
+ // type must precede value
96
+ expect(out.indexOf("type:")).toBeLessThan(out.indexOf("value:"));
97
+ });
98
+ });
99
+
100
+ describe("writeEnvelope — raw-json format", () => {
101
+ test("emits bare value JSON without envelope (legacy 0.5.0 shape)", async () => {
102
+ const payload = { valid: true, errors: [] };
103
+ const { result, output } = captureStdout(async () =>
104
+ writeEnvelope(payload, "validate-result", { format: "raw-json", store, schemas }),
105
+ );
106
+ await result;
107
+
108
+ const out = output.join("");
109
+ expect(out.endsWith("\n")).toBe(true);
110
+ const parsed = JSON.parse(out);
111
+ expect(parsed).toEqual({ valid: true, errors: [] });
112
+ // Must NOT contain envelope keys
113
+ expect(parsed.type).toBeUndefined();
114
+ expect(parsed.value).toBeUndefined();
115
+ });
116
+ });
117
+
118
+ describe("writeEnvelope — raw-yaml format", () => {
119
+ test("emits bare value YAML without envelope (legacy 0.5.0 shape)", async () => {
120
+ const payload = { valid: true, errors: [] };
121
+ const { result, output } = captureStdout(async () =>
122
+ writeEnvelope(payload, "validate-result", { format: "raw-yaml", store, schemas }),
123
+ );
124
+ await result;
125
+
126
+ const out = output.join("");
127
+ expect(out.endsWith("\n")).toBe(true);
128
+ expect(out).toContain("valid: true");
129
+ expect(out).toContain("errors:");
130
+ expect(out).not.toContain("type:");
131
+ expect(out).not.toContain("value:");
132
+ });
133
+ });
134
+
135
+ describe("writeEnvelope — text format (Liquid template)", () => {
136
+ test("renders validate-result valid case as `✓ valid`", async () => {
137
+ const payload = { valid: true, errors: [] };
138
+ const { result, output } = captureStdout(async () =>
139
+ writeEnvelope(payload, "validate-result", { format: "text", store, schemas }),
140
+ );
141
+ await result;
142
+
143
+ const out = output.join("");
144
+ expect(out.trim()).toBe("✓ valid");
145
+ });
146
+
147
+ test("renders validate-result invalid case with bulleted errors", async () => {
148
+ const payload = {
149
+ valid: false,
150
+ errors: ['unknown role "bogus"', "$START missing resume edge"],
151
+ };
152
+ const { result, output } = captureStdout(async () =>
153
+ writeEnvelope(payload, "validate-result", { format: "text", store, schemas }),
154
+ );
155
+ await result;
156
+
157
+ const out = output.join("");
158
+ expect(out).toContain("✗ invalid (2 errors)");
159
+ expect(out).toContain(' - unknown role "bogus"');
160
+ expect(out).toContain(" - $START missing resume edge");
161
+ });
162
+
163
+ test("renders workflow-add as Registered/Hash key-value lines", async () => {
164
+ const payload = { name: "review-pr", hash: "2TBP6T37TZAJZ" };
165
+ const { result, output } = captureStdout(async () =>
166
+ writeEnvelope(payload, "workflow-add", { format: "text", store, schemas }),
167
+ );
168
+ await result;
169
+
170
+ const out = output.join("");
171
+ expect(out).toBe("Registered review-pr\nHash 2TBP6T37TZAJZ\n");
172
+ // No JSON envelope leakage
173
+ expect(out).not.toContain("{");
174
+ expect(out).not.toContain("undefined");
175
+ // Single trailing newline
176
+ expect(out.endsWith("\n")).toBe(true);
177
+ expect(out.endsWith("\n\n")).toBe(false);
178
+ });
179
+
180
+ test("workflow-add tolerates empty hash field without throwing", async () => {
181
+ const payload = { name: "review-pr", hash: "" };
182
+ const { result, output } = captureStdout(async () =>
183
+ writeEnvelope(payload, "workflow-add", { format: "text", store, schemas }),
184
+ );
185
+ await result;
186
+
187
+ const out = output.join("");
188
+ // Renders without throwing; empty hash leaves trailing whitespace
189
+ expect(out).toContain("Registered review-pr");
190
+ expect(out).toContain("Hash ");
191
+ expect(out).not.toContain("undefined");
192
+ });
193
+ });
194
+
195
+ describe("writeEnvelope — workflow-add formats", () => {
196
+ test("json format wraps payload in {type,value} envelope", async () => {
197
+ const payload = { name: "review-pr", hash: "2TBP6T37TZAJZ" };
198
+ const { result, output } = captureStdout(async () =>
199
+ writeEnvelope(payload, "workflow-add", { format: "json", store, schemas }),
200
+ );
201
+ await result;
202
+
203
+ const out = output.join("");
204
+ const parsed = JSON.parse(out);
205
+ expect(parsed).toEqual({
206
+ type: schemas.outputs["workflow-add"],
207
+ value: { name: "review-pr", hash: "2TBP6T37TZAJZ" },
208
+ });
209
+ });
210
+
211
+ test("raw-json format emits bare payload (legacy 0.5.0 shape)", async () => {
212
+ const payload = { name: "review-pr", hash: "2TBP6T37TZAJZ" };
213
+ const { result, output } = captureStdout(async () =>
214
+ writeEnvelope(payload, "workflow-add", { format: "raw-json", store, schemas }),
215
+ );
216
+ await result;
217
+
218
+ const out = output.join("");
219
+ expect(out).toBe('{"name":"review-pr","hash":"2TBP6T37TZAJZ"}\n');
220
+ });
221
+
222
+ test("yaml format emits envelope with type and value keys", async () => {
223
+ const payload = { name: "review-pr", hash: "2TBP6T37TZAJZ" };
224
+ const { result, output } = captureStdout(async () =>
225
+ writeEnvelope(payload, "workflow-add", { format: "yaml", store, schemas }),
226
+ );
227
+ await result;
228
+
229
+ const out = output.join("");
230
+ expect(out).toContain(`type: ${schemas.outputs["workflow-add"]}`);
231
+ expect(out).toContain("value:");
232
+ expect(out).toContain("name: review-pr");
233
+ expect(out).toContain("hash: 2TBP6T37TZAJZ");
234
+ });
235
+ });
236
+
237
+ describe("writeEnvelope — schema lookup", () => {
238
+ test("throws when schema short name is unknown", async () => {
239
+ await expect(
240
+ // @ts-expect-error invalid schema name on purpose
241
+ writeEnvelope({}, "not-a-real-schema", { format: "json", store, schemas }),
242
+ ).rejects.toThrow(/output schema not registered/);
243
+ });
244
+
245
+ test("each format calls in to the same registered schema hash", async () => {
246
+ const payload = { valid: true, errors: [] };
247
+ const formats: OutputFormat[] = ["json", "yaml"];
248
+ for (const fmt of formats) {
249
+ const { result, output } = captureStdout(async () =>
250
+ writeEnvelope(payload, "validate-result", { format: fmt, store, schemas }),
251
+ );
252
+ await result;
253
+ const out = output.join("");
254
+ expect(out).toContain(schemas.outputs["validate-result"]);
255
+ }
256
+ });
257
+ });