@juicesharp/rpiv-pi 1.18.2 → 1.19.1

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 (39) hide show
  1. package/extensions/rpiv-core/built-in-workflows.test.ts +542 -107
  2. package/extensions/rpiv-core/built-in-workflows.ts +357 -149
  3. package/extensions/rpiv-core/index.ts +12 -6
  4. package/extensions/rpiv-core/models-config.test.ts +3 -3
  5. package/extensions/rpiv-core/outcome-derivation.test.ts +417 -0
  6. package/extensions/rpiv-core/outcome-derivation.ts +110 -0
  7. package/extensions/rpiv-core/register-built-in-workflows.test.ts +4 -3
  8. package/extensions/rpiv-core/register-built-in-workflows.ts +2 -2
  9. package/extensions/rpiv-core/skill-contracts-source.test.ts +469 -0
  10. package/extensions/rpiv-core/skill-contracts-source.ts +185 -0
  11. package/extensions/rpiv-core/utils.test.ts +17 -1
  12. package/extensions/rpiv-core/utils.ts +21 -6
  13. package/package.json +62 -60
  14. package/skills/annotate-guidance/SKILL.md +8 -0
  15. package/skills/annotate-inline/SKILL.md +8 -0
  16. package/skills/architecture-review/SKILL.md +36 -2
  17. package/skills/blueprint/SKILL.md +41 -1
  18. package/skills/changelog/SKILL.md +8 -0
  19. package/skills/code-review/SKILL.md +30 -13
  20. package/skills/code-review/_helpers/review-range.mjs +13 -0
  21. package/skills/code-review/templates/review.md +1 -1
  22. package/skills/commit/SKILL.md +8 -0
  23. package/skills/create-handoff/SKILL.md +20 -0
  24. package/skills/design/SKILL.md +18 -0
  25. package/skills/discover/SKILL.md +11 -1
  26. package/skills/discover/templates/frd.md +1 -1
  27. package/skills/explore/SKILL.md +24 -4
  28. package/skills/frontend-design/SKILL.md +5 -0
  29. package/skills/implement/SKILL.md +11 -1
  30. package/skills/migrate-to-guidance/SKILL.md +8 -0
  31. package/skills/plan/SKILL.md +42 -2
  32. package/skills/pr-triage/SKILL.md +375 -0
  33. package/skills/pr-triage/_helpers/pr-fetch.mjs +357 -0
  34. package/skills/pr-triage/templates/triage.md +159 -0
  35. package/skills/research/SKILL.md +14 -1
  36. package/skills/resume-handoff/SKILL.md +8 -0
  37. package/skills/revise/SKILL.md +15 -0
  38. package/skills/validate/SKILL.md +26 -9
  39. package/skills/validate/templates/validation.md +3 -2
@@ -1,27 +1,20 @@
1
1
  /**
2
- * Regression tests for blockers identified in the 2026-05-23 code review
3
- * of feat/rpiv-workflow-command. Each describe block asserts the
4
- * EXPECTED (post-fix) behavior, so a test fails on the current source
5
- * until that blocker is resolved.
2
+ * Regression tests for built-in workflow behaviour. Each describe block
3
+ * asserts the expected behaviour for one previously-broken path:
6
4
  *
7
- * Critical:
8
- * - I1 — `validate commit` auto edge skips the code-review fix loop.
9
- * - I2 `writeHeader` silent failure drops the first stage row.
10
- * - I6 Missing `severeIssueCount` silently routes to `commit`.
11
- * - I7 — `StopReason ∈ {"length","toolUse"}` collapses to `"ok"`.
5
+ * - the `validate → commit` auto edge must not skip the code-review fix loop;
6
+ * - `writeHeader` failure must not silently drop the first stage row;
7
+ * - a missing routing field must not silently route to `commit`;
8
+ * - a truncated reply (`stopReason ∈ {"length","toolUse"}`) must not collapse
9
+ * to `"ok"`;
10
+ * - `recordStage` must not reuse stageNumbers after an append failure, so
11
+ * `stagesCompleted` can't drift above the on-disk row count;
12
+ * - phase fanout must label JSONL rows by `stage.skill`, not stage id (which
13
+ * is wrong for aliased implement stages);
14
+ * - the runner must not reuse `originalInput` past the first stage, so later
15
+ * stages receive the upstream output rather than the user's brief.
12
16
  *
13
- * Important:
14
- * - I3 — recordStage swallows append failures and reuses stageNumbers on
15
- * the next successful write; stagesCompleted drifts above the
16
- * actual on-disk row count.
17
- * - I9 — Phase fanout labels JSONL rows by stage id (wrong for aliased
18
- * implement stages); should label by stage.skill instead.
19
- * - Q7 — runner reuses originalInput whenever artifactPath is unset, not
20
- * just at the first stage; later stages silently receive the user's
21
- * original brief instead of the upstream stage's output.
22
- *
23
- * These tests follow Phase 5 of the TS-native workflow migration — they
24
- * exercise the new `Workflow` shape directly, not the legacy `WorkflowDag`.
17
+ * They exercise the `Workflow` shape directly.
25
18
  */
26
19
 
27
20
  import { appendFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -34,6 +27,7 @@ import {
34
27
  defineWorkflow,
35
28
  type EdgeFn,
36
29
  type FanoutFn,
30
+ type Output,
37
31
  produces,
38
32
  type RunState,
39
33
  runsDir,
@@ -42,9 +36,49 @@ import {
42
36
  validateWorkflow,
43
37
  type Workflow,
44
38
  } from "@juicesharp/rpiv-workflow";
39
+ import { describeFlow, fs as fsHandle } from "@juicesharp/rpiv-workflow/registration";
45
40
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
46
41
  import { rpivArtifactMdOutcome } from "./artifact-collector.js";
47
42
  import { builtInWorkflows } from "./built-in-workflows.js";
43
+ import { deriveOutcomes } from "./outcome-derivation.js";
44
+ import { BUNDLED_SKILLS_DIR } from "./paths.js";
45
+ import { buildSkillContractsFromFrontmatter } from "./skill-contracts-source.js";
46
+
47
+ // Built-ins are validated in production with the declared skill contracts
48
+ // threaded in (load/index.ts: buildEffectiveContracts → validateWorkflow). The
49
+ // code-review stages carry no inline outputSchema — `blockers_count` is sourced
50
+ // from the contract — so the same contracts must be supplied here, or the
51
+ // contract-backed routing lint (checkPredicateSchemas) fires a false warning.
52
+ const DECLARED_CONTRACTS = new Map(buildSkillContractsFromFrontmatter(BUNDLED_SKILLS_DIR));
53
+
54
+ /**
55
+ * Prepare a workflow for validation by deriving contract-sourced outcomes onto
56
+ * a mutable copy. The built-in workflows no longer carry explicit outcomes —
57
+ * they are contract-derived. The deriver must run before validateWorkflow
58
+ * checks the `produces-without-outcome` guard.
59
+ */
60
+ const deriveAndValidate = (
61
+ wf: Workflow,
62
+ opts?: { skillContracts?: Map<string, import("@juicesharp/rpiv-workflow/registration").SkillContract> },
63
+ ) => {
64
+ return validateWorkflow(withDerivedOutcomes(wf, opts?.skillContracts), opts);
65
+ };
66
+
67
+ /**
68
+ * Create a mutable copy of a workflow with contract-derived outcomes. Used by
69
+ * tests that bypass the loader (passing workflows directly to `runWorkflow`).
70
+ */
71
+ const withDerivedOutcomes = (
72
+ wf: Workflow,
73
+ skillContracts?: Map<string, import("@juicesharp/rpiv-workflow/registration").SkillContract>,
74
+ ): Workflow => {
75
+ const mutable: Workflow = { ...wf, stages: { ...wf.stages } };
76
+ for (const [name, stage] of Object.entries(wf.stages)) {
77
+ (mutable.stages as Record<string, typeof stage>)[name] = { ...stage };
78
+ }
79
+ deriveOutcomes([mutable], skillContracts ?? DECLARED_CONTRACTS, () => {});
80
+ return mutable;
81
+ };
48
82
 
49
83
  const findWorkflow = (name: string): Workflow => {
50
84
  const w = builtInWorkflows.find((x) => x.name === name);
@@ -53,10 +87,10 @@ const findWorkflow = (name: string): Workflow => {
53
87
  };
54
88
 
55
89
  // ---------------------------------------------------------------------------
56
- // I1 — validate must route to code-review (not commit) in build/arch workflows.
90
+ // validate must route to code-review (not commit) in build/arch workflows.
57
91
  // ---------------------------------------------------------------------------
58
92
 
59
- describe("[I1] validate → code-review routing in built-in workflows", () => {
93
+ describe("validate → code-review routing in built-in workflows", () => {
60
94
  it("routes validate → code-review in build", () => {
61
95
  const build = findWorkflow("build");
62
96
  expect(build.edges.validate).toBe("code-review");
@@ -84,11 +118,11 @@ describe("[I1] validate → code-review routing in built-in workflows", () => {
84
118
  });
85
119
 
86
120
  // ---------------------------------------------------------------------------
87
- // I2 — When writeHeader silently fails, the first stage row written by
88
- // appendStage lands at line 0 and is dropped by every reader.
121
+ // When writeHeader silently fails, the first stage row written by appendStage
122
+ // lands at line 0 and is dropped by every reader.
89
123
  // ---------------------------------------------------------------------------
90
124
 
91
- describe("[I2] readers must not silently drop the first row when no header is on disk", () => {
125
+ describe("readers must not silently drop the first row when no header is on disk", () => {
92
126
  let tmpDir: string;
93
127
 
94
128
  beforeEach(() => {
@@ -120,30 +154,28 @@ describe("[I2] readers must not silently drop the first row when no header is on
120
154
  });
121
155
 
122
156
  // ---------------------------------------------------------------------------
123
- // I6 Predicate fires on un-validated frontmatter; missing severeIssueCount
124
- // must not silently route to commit. The output-schema layer is what
125
- // makes missing data impossible to reach the predicate.
157
+ // A predicate firing on un-validated frontmatter (missing severeIssueCount)
158
+ // must not silently route to commit. The output-schema layer is what makes
159
+ // missing data impossible to reach the predicate.
126
160
  // ---------------------------------------------------------------------------
127
161
 
128
- describe("[I6] code-review predicate must not silently route to commit on missing field", () => {
129
- it("built-in code-review stage carries an outputSchema", () => {
130
- const build = findWorkflow("build");
131
- const codeReview = build.stages["code-review"];
132
- expect(codeReview?.outputSchema).toBeDefined();
162
+ describe("code-review routing field is sourced + validated from the contract", () => {
163
+ it("no built-in code-review stage carries an inline outputSchema", () => {
164
+ // Single source of truth: blockers_count lives in the skill contract,
165
+ // not copy-pasted per workflow. Sourced at runtime by effectiveOutputSchema.
166
+ for (const name of ["build", "arch", "vet", "polish"]) {
167
+ expect(findWorkflow(name).stages["code-review"]?.outputSchema, `${name} code-review`).toBeUndefined();
168
+ }
133
169
  });
134
170
 
135
- it("the declared schema rejects an empty data object", async () => {
136
- const build = findWorkflow("build");
137
- const schema = build.stages["code-review"]?.outputSchema;
138
- if (!schema) throw new Error("code-review outputSchema missing — fix I6 first");
139
- const { validateOutputData } = await import("@juicesharp/rpiv-workflow");
140
- const result = await validateOutputData(schema, {});
141
- expect(result.valid).toBe(false);
171
+ it("the code-review contract requires blockers_count (so a missing field can't NaN-route)", () => {
172
+ const data = DECLARED_CONTRACTS.get("code-review")?.produces?.data as { required?: string[] } | undefined;
173
+ expect(data?.required).toContain("blockers_count");
142
174
  });
143
175
 
144
- it("every built-in workflow validates without errors or warnings", () => {
176
+ it("every built-in workflow validates without errors or warnings (with contracts threaded in)", () => {
145
177
  for (const wf of builtInWorkflows) {
146
- const issues = validateWorkflow(wf);
178
+ const issues = deriveAndValidate(wf, { skillContracts: DECLARED_CONTRACTS });
147
179
  expect(
148
180
  issues.filter((i) => i.severity === "error"),
149
181
  `${wf.name} errors`,
@@ -157,11 +189,11 @@ describe("[I6] code-review predicate must not silently route to commit on missin
157
189
  });
158
190
 
159
191
  // ---------------------------------------------------------------------------
160
- // I7 — A `stopReason: "length"` reply on a side-effect stage must NOT be
161
- // recorded as a successful "completed" stage.
192
+ // A `stopReason: "length"` reply on a side-effect stage must NOT be recorded
193
+ // as a successful "completed" stage.
162
194
  // ---------------------------------------------------------------------------
163
195
 
164
- describe("[I7] truncated reply (stopReason=length) must not record as completed", () => {
196
+ describe("truncated reply (stopReason=length) must not record as completed", () => {
165
197
  let tmpDir: string;
166
198
 
167
199
  beforeEach(() => {
@@ -218,11 +250,11 @@ describe("[I7] truncated reply (stopReason=length) must not record as completed"
218
250
  });
219
251
 
220
252
  // ---------------------------------------------------------------------------
221
- // I3 — recordStage must signal write success/failure so stagesCompleted
222
- // stays aligned with on-disk rows, and stageNumbers never repeat.
253
+ // recordStage must signal write success/failure so stagesCompleted stays
254
+ // aligned with on-disk rows, and stageNumbers never repeat.
223
255
  // ---------------------------------------------------------------------------
224
256
 
225
- describe("[I3] recordStage signals success and advances stageNumber monotonically", () => {
257
+ describe("recordStage signals success and advances stageNumber monotonically", () => {
226
258
  let tmpDir: string;
227
259
  let warnSpy: ReturnType<typeof vi.spyOn>;
228
260
 
@@ -290,11 +322,11 @@ describe("[I3] recordStage signals success and advances stageNumber monotonicall
290
322
  });
291
323
 
292
324
  // ---------------------------------------------------------------------------
293
- // Q7 — Non-first stages must NOT silently fall back to originalInput when
294
- // their upstream produced no artifactPath.
325
+ // Non-first stages must NOT silently fall back to originalInput when their
326
+ // upstream produced no artifactPath.
295
327
  // ---------------------------------------------------------------------------
296
328
 
297
- describe("[Q7] non-first stage with no artifactPath halts instead of reusing originalInput", () => {
329
+ describe("non-first stage with no artifactPath halts instead of reusing originalInput", () => {
298
330
  let tmpDir: string;
299
331
 
300
332
  beforeEach(() => {
@@ -333,10 +365,10 @@ describe("[Q7] non-first stage with no artifactPath halts instead of reusing ori
333
365
  });
334
366
 
335
367
  // ---------------------------------------------------------------------------
336
- // I9 — Phase fanout must label JSONL rows by stage.skill, not by the stage name.
368
+ // Phase fanout must label JSONL rows by stage.skill, not by the stage name.
337
369
  // ---------------------------------------------------------------------------
338
370
 
339
- describe("[I9] phase fanout rows preserve both stage name (record key) and skill body across aliasing", () => {
371
+ describe("phase fanout rows preserve both stage name (record key) and skill body across aliasing", () => {
340
372
  let tmpDir: string;
341
373
 
342
374
  beforeEach(() => {
@@ -360,9 +392,9 @@ describe("[I9] phase fanout rows preserve both stage name (record key) and skill
360
392
  mkdirSync(join(tmpDir, ".rpiv", "artifacts", "plans"), { recursive: true });
361
393
  writeFileSync(join(tmpDir, planRelPath), "# Plan\n\n## Phase 1: a\nbody\n## Phase 2: b\nbody\n");
362
394
 
363
- // Local copy of the `## Phase N:` convention used by rpiv-pi's built-in
364
- // workflows mirrors `PHASE_FANOUT` in `built-in-workflows.ts`. Inlined
365
- // rather than imported so the test exercises the public FanoutFn shape.
395
+ // Local `## Phase N:` fanout inlined (not imported) so the test exercises
396
+ // the public FanoutFn shape; aliasing audit is what's under test, not phase
397
+ // parsing, so a minimal number-only fanout suffices.
366
398
  const phaseFanout: FanoutFn = ({ artifact: primary, cwd }) => {
367
399
  if (primary?.handle.kind !== "fs") return [];
368
400
  const path = primary.handle.path;
@@ -417,11 +449,10 @@ describe("[I9] phase fanout rows preserve both stage name (record key) and skill
417
449
  });
418
450
 
419
451
  // ---------------------------------------------------------------------------
420
- // Q4 — Dedicated tests for vet workflow routing predicate and
421
- // backward-jump loop behavior.
452
+ // vet workflow routing predicate and backward-jump loop behavior.
422
453
  // ---------------------------------------------------------------------------
423
454
 
424
- describe("[Q4] vet workflow", () => {
455
+ describe("vet workflow", () => {
425
456
  const findEdge = (): EdgeFn => {
426
457
  const wf = findWorkflow("vet");
427
458
  const edge = wf.edges["code-review"];
@@ -429,12 +460,12 @@ describe("[Q4] vet workflow", () => {
429
460
  return edge as EdgeFn;
430
461
  };
431
462
 
432
- const ctxWithStatus = (status: string) =>
463
+ const ctxWithBlockers = (blockers_count: number) =>
433
464
  ({
434
465
  output: {
435
466
  kind: "artifact-md",
436
467
  artifacts: [],
437
- data: { status },
468
+ data: { blockers_count },
438
469
  meta: { stage: "code-review", skill: "code-review", stageNumber: 1, ts: "", runId: "" },
439
470
  },
440
471
  state: {} as RunState,
@@ -448,49 +479,35 @@ describe("[Q4] vet workflow", () => {
448
479
  expect(edge.targets).toEqual(["blueprint", "commit"]);
449
480
  });
450
481
 
451
- it('routes status="approved" to "commit"', () => {
452
- const edge = findEdge();
453
- expect(edge(ctxWithStatus("approved"))).toBe("commit");
454
- });
455
-
456
- it('routes status="needs_changes" to "blueprint"', () => {
482
+ it("routes blockers_count: 0 to commit (same numeric gate as build/arch/polish)", () => {
457
483
  const edge = findEdge();
458
- expect(edge(ctxWithStatus("needs_changes"))).toBe("blueprint");
484
+ expect(edge(ctxWithBlockers(0))).toBe("commit");
459
485
  });
460
486
 
461
- it('routes status="requesting_changes" to "blueprint"', () => {
487
+ it("routes blockers_count > 0 to blueprint (fix loop)", () => {
462
488
  const edge = findEdge();
463
- expect(edge(ctxWithStatus("requesting_changes"))).toBe("blueprint");
489
+ expect(edge(ctxWithBlockers(3))).toBe("blueprint");
490
+ expect(edge(ctxWithBlockers(1))).toBe("blueprint");
464
491
  });
465
492
 
466
- it('routes undefined output to "blueprint" (defensive fallback)', () => {
493
+ it("a missing blockers_count falls to the gate's commit fallback — guarded upstream by output validation", () => {
494
+ // The code-review contract requires blockers_count, so the output loop
495
+ // rejects a missing field before routing. If it somehow reaches the gate,
496
+ // Number(undefined)=NaN satisfies neither gt(0) nor eq(0) → fallback (commit).
467
497
  const edge = findEdge();
468
- expect(edge({ output: undefined, state: {} as RunState })).toBe("blueprint");
469
- });
470
-
471
- it('routes output with missing status to "blueprint" (defensive fallback)', () => {
472
- const edge = findEdge();
473
- expect(
474
- edge({
475
- output: {
476
- kind: "artifact-md",
477
- artifacts: [],
478
- data: {},
479
- meta: { stage: "code-review", skill: "code-review", stageNumber: 1, ts: "", runId: "" },
480
- },
481
- state: {} as RunState,
482
- }),
483
- ).toBe("blueprint");
498
+ expect(edge({ output: undefined, state: {} as RunState })).toBe("commit");
484
499
  });
485
500
  });
486
501
 
487
502
  // --- Structural tests ---
488
503
 
489
504
  describe("structural validation", () => {
490
- it("code-review stage carries REVIEW_STATUS_SCHEMA outputSchema", () => {
505
+ it("code-review stage carries no inline outputSchema (sourced from contract) and gates on blockers_count", () => {
491
506
  const wf = findWorkflow("vet");
492
- const codeReview = wf.stages["code-review"];
493
- expect(codeReview?.outputSchema).toBeDefined();
507
+ expect(wf.stages["code-review"]?.outputSchema).toBeUndefined();
508
+ const edge = wf.edges["code-review"];
509
+ if (typeof edge !== "function") throw new Error("code-review edge is not an EdgeFn");
510
+ expect([...(edge.targets ?? [])].sort()).toEqual(["blueprint", "commit"]);
494
511
  });
495
512
 
496
513
  it("validate routes back to code-review (backward-jump cycle)", () => {
@@ -607,11 +624,11 @@ describe("[Q4] vet workflow", () => {
607
624
  describe("polish workflow", () => {
608
625
  describe("structural validation", () => {
609
626
  it("validates with zero errors", () => {
610
- expect(validateWorkflow(findWorkflow("polish")).filter((i) => i.severity === "error")).toEqual([]);
627
+ expect(deriveAndValidate(findWorkflow("polish")).filter((i) => i.severity === "error")).toEqual([]);
611
628
  });
612
629
 
613
630
  it("all stages are reachable from start", () => {
614
- const issues = validateWorkflow(findWorkflow("polish"));
631
+ const issues = deriveAndValidate(findWorkflow("polish"));
615
632
  expect(
616
633
  issues.filter((i) => /unreachable/.test(i.message)),
617
634
  "polish has unreachable stages",
@@ -625,9 +642,9 @@ describe("polish workflow", () => {
625
642
  expect(typeof wf.stages.implement?.fanout).toBe("function");
626
643
  });
627
644
 
628
- it("code-review carries CODE_REVIEW_SCHEMA and gates to commit | blueprint", () => {
645
+ it("code-review sources its schema from the contract (no inline outputSchema) and gates to commit | blueprint", () => {
629
646
  const wf = findWorkflow("polish");
630
- expect(wf.stages["code-review"]?.outputSchema).toBeDefined();
647
+ expect(wf.stages["code-review"]?.outputSchema).toBeUndefined();
631
648
  const edge = wf.edges["code-review"];
632
649
  if (typeof edge !== "function") throw new Error("code-review edge is not an EdgeFn");
633
650
  expect([...(edge.targets ?? [])].sort()).toEqual(["blueprint", "commit"]);
@@ -648,9 +665,14 @@ describe("polish workflow", () => {
648
665
  mkdirSync(join(tmpDir, ...parts.slice(0, -1)), { recursive: true });
649
666
  writeFileSync(join(tmpDir, relPath), content);
650
667
  };
651
- const plan = (phase = 1) => `---\ntopic: t\n---\n## Phase ${phase}: do the thing\nbody\n`;
652
- const review2 = "# Arch Review\n\n### Phase 1 — Alpha\nbody\n### Phase 2 — Beta\nbody\n";
653
- const review1 = "# Arch Review\n\n### Phase 1 Alpha\nbody\n";
668
+ // Each plan carries the `phases:` array the implement fanout enumerates.
669
+ const plan = (phase = 1) =>
670
+ `---\ntopic: t\nphase_count: 1\nphases:\n - { n: ${phase}, title: do the thing }\n---\n## Phase ${phase}: do the thing\nbody\n`;
671
+ // The review carries a structured `phases:` array (derived from its
672
+ // `### Phase N — name` headings) — what the iterate enumerates over.
673
+ const review2 =
674
+ "---\nphases:\n - { n: 1, title: Alpha }\n - { n: 2, title: Beta }\n---\n# Arch Review\n\n### Phase 1 — Alpha\nbody\n### Phase 2 — Beta\nbody\n";
675
+ const review1 = "---\nphases:\n - { n: 1, title: Alpha }\n---\n# Arch Review\n\n### Phase 1 — Alpha\nbody\n";
654
676
  const cr = (blockers: number) => `---\nblockers_count: ${blockers}\n---\n`;
655
677
  const impl = (m: string) => ({ branch: [mockAssistantMessage(m)] });
656
678
 
@@ -675,7 +697,10 @@ describe("polish workflow", () => {
675
697
  ],
676
698
  });
677
699
 
678
- const result = await runWorkflow(chain.ctx, { workflow: findWorkflow("polish"), input: "x" });
700
+ const result = await runWorkflow(chain.ctx, {
701
+ workflow: withDerivedOutcomes(findWorkflow("polish")),
702
+ input: "x",
703
+ });
679
704
 
680
705
  expect(result.success).toBe(true);
681
706
  // arch-review + blueprint×2 + implement×2 + validate + code-review + commit
@@ -688,10 +713,10 @@ describe("polish workflow", () => {
688
713
  "/skill:blueprint .rpiv/artifacts/architecture-reviews/rev.md Implement Phase 2: Beta\n" +
689
714
  "Prior phase plans (read first; build on them, don't duplicate): .rpiv/artifacts/plans/plan-1.md",
690
715
  );
691
- // implement fanned out the `## Phase N:` heading of each accumulated plan.
716
+ // implement fanned out each accumulated plan's `phases:` array, title-enriched.
692
717
  expect(chain.sentMessages.filter((m) => m.startsWith("/skill:implement"))).toEqual([
693
- "/skill:implement .rpiv/artifacts/plans/plan-1.md Phase 1",
694
- "/skill:implement .rpiv/artifacts/plans/plan-2.md Phase 1",
718
+ "/skill:implement .rpiv/artifacts/plans/plan-1.md Phase 1: do the thing",
719
+ "/skill:implement .rpiv/artifacts/plans/plan-2.md Phase 1: do the thing",
695
720
  ]);
696
721
  });
697
722
 
@@ -716,7 +741,10 @@ describe("polish workflow", () => {
716
741
  ],
717
742
  });
718
743
 
719
- const result = await runWorkflow(chain.ctx, { workflow: findWorkflow("polish"), input: "x" });
744
+ const result = await runWorkflow(chain.ctx, {
745
+ workflow: withDerivedOutcomes(findWorkflow("polish")),
746
+ input: "x",
747
+ });
720
748
 
721
749
  expect(result.success).toBe(true);
722
750
  // The single validate session is handed ALL accumulated plans — not just
@@ -755,16 +783,19 @@ describe("polish workflow", () => {
755
783
  ],
756
784
  });
757
785
 
758
- const result = await runWorkflow(chain.ctx, { workflow: findWorkflow("polish"), input: "x" });
786
+ const result = await runWorkflow(chain.ctx, {
787
+ workflow: withDerivedOutcomes(findWorkflow("polish")),
788
+ input: "x",
789
+ });
759
790
 
760
791
  expect(result.success).toBe(false);
761
792
  expect(result.error).toMatch(/backward-jump limit exceeded/i);
762
793
  // Each implement round saw ONLY that pass's plan — the latest-pass slice
763
794
  // dropped the stale generations, so no plan is implemented twice.
764
795
  expect(chain.sentMessages.filter((m) => m.startsWith("/skill:implement"))).toEqual([
765
- "/skill:implement .rpiv/artifacts/plans/plan-1.md Phase 1",
766
- "/skill:implement .rpiv/artifacts/plans/plan-2.md Phase 1",
767
- "/skill:implement .rpiv/artifacts/plans/plan-3.md Phase 1",
796
+ "/skill:implement .rpiv/artifacts/plans/plan-1.md Phase 1: do the thing",
797
+ "/skill:implement .rpiv/artifacts/plans/plan-2.md Phase 1: do the thing",
798
+ "/skill:implement .rpiv/artifacts/plans/plan-3.md Phase 1: do the thing",
768
799
  ]);
769
800
  // validate shares the same latest-pass slice — each round validates only
770
801
  // that pass's plan, never a stale generation.
@@ -863,3 +894,407 @@ describe("design-to-code example (prompt dispatch)", () => {
863
894
  }
864
895
  });
865
896
  });
897
+
898
+ // ---------------------------------------------------------------------------
899
+ // ship — fast path: blueprint → implement → validate → commit. implement fans
900
+ // out over the plan's structured `phases:` array (derived by blueprint from its
901
+ // `## Phase N:` headings, title-enriched and verified against those headings).
902
+ // ---------------------------------------------------------------------------
903
+
904
+ describe("ship workflow", () => {
905
+ it("chains blueprint → implement → validate → commit", () => {
906
+ const wf = findWorkflow("ship");
907
+ expect(wf.start).toBe("blueprint");
908
+ expect(Object.keys(wf.stages)).toEqual(["blueprint", "implement", "validate", "commit"]);
909
+ expect(wf.edges.blueprint).toBe("implement");
910
+ expect(wf.edges.implement).toBe("validate");
911
+ expect(wf.edges.validate).toBe("commit");
912
+ expect(wf.edges.commit).toBe("stop");
913
+ });
914
+
915
+ it("blueprint stage carries no inline outputSchema (phases sourced from the skill contract)", () => {
916
+ expect(findWorkflow("ship").stages.blueprint?.outputSchema).toBeUndefined();
917
+ });
918
+
919
+ it("validates without errors or warnings (contracts threaded in)", () => {
920
+ const issues = deriveAndValidate(findWorkflow("ship"), { skillContracts: DECLARED_CONTRACTS });
921
+ expect(issues.filter((i) => i.severity === "error")).toEqual([]);
922
+ expect(issues.filter((i) => i.severity === "warning")).toEqual([]);
923
+ });
924
+
925
+ describe("FRONTMATTER_PHASE_FANOUT", () => {
926
+ let tmpDir: string;
927
+ beforeEach(() => {
928
+ tmpDir = mkdtempSync(join(tmpdir(), "rpiv-ship-"));
929
+ });
930
+ afterEach(() => {
931
+ rmSync(tmpDir, { recursive: true, force: true });
932
+ });
933
+
934
+ const fanout = () => {
935
+ const f = findWorkflow("ship").stages.implement?.fanout;
936
+ if (!f) throw new Error("ship implement stage has no fanout");
937
+ return f;
938
+ };
939
+ const writePlan = (rel: string, body: string) => {
940
+ const parts = rel.split("/");
941
+ mkdirSync(join(tmpDir, ...parts.slice(0, -1)), { recursive: true });
942
+ writeFileSync(join(tmpDir, rel), body);
943
+ };
944
+ const runFanout = (rel: string) =>
945
+ fanout()({
946
+ cwd: tmpDir,
947
+ artifact: undefined,
948
+ state: {
949
+ named: { plans: [{ artifacts: [{ handle: fsHandle(rel) }], data: undefined, kind: "", meta: {} }] },
950
+ } as unknown as RunState,
951
+ });
952
+
953
+ it("reads phases from frontmatter and dispatches one title-enriched unit per phase", async () => {
954
+ const rel = ".rpiv/artifacts/plans/p.md";
955
+ writePlan(
956
+ rel,
957
+ `---\nstatus: ready\nphase_count: 2\nphases:\n - { n: 1, title: Schema layer }\n - { n: 2, title: Runtime wiring }\n---\n# Plan\n## Phase 1: Schema layer\n## Phase 2: Runtime wiring\n`,
958
+ );
959
+ const units = await runFanout(rel);
960
+ expect(units.map((u) => u.prompt)).toEqual([`${rel} Phase 1: Schema layer`, `${rel} Phase 2: Runtime wiring`]);
961
+ expect(units.map((u) => u.label)).toEqual(["phase 1/2", "phase 2/2"]);
962
+ });
963
+
964
+ it("throws when the frontmatter phases disagree with the body headings (stale derive)", () => {
965
+ const rel = ".rpiv/artifacts/plans/mismatch.md";
966
+ writePlan(
967
+ rel,
968
+ `---\nphases:\n - { n: 1, title: Only one }\n---\n## Phase 1: a\n## Phase 2: b\n## Phase 3: c\n`,
969
+ );
970
+ expect(() => runFanout(rel)).toThrow(/frontmatter phases \(1\) ≠ '## Phase N:' headings \(3\)/);
971
+ });
972
+
973
+ it("returns no units for a plan with neither structured phases nor body headings", async () => {
974
+ const rel = ".rpiv/artifacts/plans/empty.md";
975
+ writePlan(rel, `---\nstatus: ready\n---\n# Plan with no phases\n`);
976
+ expect(await runFanout(rel)).toEqual([]);
977
+ });
978
+
979
+ it('returns [] when no plan is published to the named "plans" channel', async () => {
980
+ const units = await fanout()({
981
+ cwd: tmpDir,
982
+ artifact: undefined,
983
+ state: { named: {} } as unknown as RunState,
984
+ });
985
+ expect(units).toEqual([]);
986
+ });
987
+
988
+ it("throws when phase_count disagrees with the derived phases length", () => {
989
+ const rel = ".rpiv/artifacts/plans/pc-mismatch.md";
990
+ writePlan(
991
+ rel,
992
+ `---\nstatus: ready\nphase_count: 3\nphases:\n - { n: 1, title: A }\n - { n: 2, title: B }\n---\n## Phase 1: A\n## Phase 2: B\n`,
993
+ );
994
+ expect(() => runFanout(rel)).toThrow(/phase_count \(3\) ≠ phases length \(2\)/);
995
+ });
996
+
997
+ it("throws when a phased plan omits the required phase_count", () => {
998
+ const rel = ".rpiv/artifacts/plans/pc-absent.md";
999
+ writePlan(rel, `---\nstatus: ready\nphases:\n - { n: 1, title: A }\n---\n## Phase 1: A\n`);
1000
+ expect(() => runFanout(rel)).toThrow(/phase_count \(undefined\) ≠ phases length \(1\)/);
1001
+ });
1002
+ });
1003
+
1004
+ describe("end-to-end via runWorkflow", () => {
1005
+ let tmpDir: string;
1006
+ beforeEach(() => {
1007
+ tmpDir = mkdtempSync(join(tmpdir(), "rpiv-ship-e2e-"));
1008
+ });
1009
+ afterEach(() => {
1010
+ rmSync(tmpDir, { recursive: true, force: true });
1011
+ });
1012
+ const write = (rel: string, body: string) => {
1013
+ const parts = rel.split("/");
1014
+ mkdirSync(join(tmpDir, ...parts.slice(0, -1)), { recursive: true });
1015
+ writeFileSync(join(tmpDir, rel), body);
1016
+ };
1017
+ const step = (m: string) => ({ branch: [mockAssistantMessage(m)] });
1018
+
1019
+ it("drives blueprint → implement (fanned from the derived phases) → validate → commit", async () => {
1020
+ write(
1021
+ ".rpiv/artifacts/plans/plan.md",
1022
+ `---\nstatus: ready\nphase_count: 2\nphases:\n - { n: 1, title: Schema layer }\n - { n: 2, title: Runtime wiring }\n---\n# Plan\n## Phase 1: Schema layer\n## Phase 2: Runtime wiring\n`,
1023
+ );
1024
+ write(".rpiv/artifacts/validation/val.md", "");
1025
+
1026
+ const chain = createMockSessionChain({
1027
+ cwd: tmpDir,
1028
+ steps: [
1029
+ step("wrote .rpiv/artifacts/plans/plan.md"), // blueprint
1030
+ step("phase done"), // implement — phase 1 unit
1031
+ step("phase done"), // implement — phase 2 unit
1032
+ step("wrote .rpiv/artifacts/validation/val.md"), // validate
1033
+ step("committed"), // commit
1034
+ ],
1035
+ });
1036
+
1037
+ const result = await runWorkflow(chain.ctx, {
1038
+ workflow: withDerivedOutcomes(findWorkflow("ship")),
1039
+ input: "add a feature",
1040
+ });
1041
+
1042
+ expect(result.success).toBe(true);
1043
+ // blueprint + implement×2 (one per derived phase) + validate + commit
1044
+ expect(result.stagesCompleted).toBe(5);
1045
+ expect(chain.sentMessages.filter((m) => m.startsWith("/skill:implement"))).toEqual([
1046
+ "/skill:implement .rpiv/artifacts/plans/plan.md Phase 1: Schema layer",
1047
+ "/skill:implement .rpiv/artifacts/plans/plan.md Phase 2: Runtime wiring",
1048
+ ]);
1049
+ });
1050
+ });
1051
+ });
1052
+
1053
+ // ---------------------------------------------------------------------------
1054
+ // pr-triage security-gate — the script-stage guard must fail closed on
1055
+ // missing / malformed security_flag (NaN), not silently pass as SAFE.
1056
+ // ---------------------------------------------------------------------------
1057
+
1058
+ describe("pr-triage security-gate", () => {
1059
+ const gateRun = () => {
1060
+ const stage = findWorkflow("pr-triage").stages["security-gate"];
1061
+ if (!stage?.run) throw new Error("pr-triage security-gate stage has no run function");
1062
+ return stage.run as (ctx: { input?: { data?: unknown } }) => void;
1063
+ };
1064
+
1065
+ it("throws on missing input (undefined)", () => {
1066
+ expect(() => gateRun()({ input: undefined })).toThrow(/BLOCK/);
1067
+ });
1068
+
1069
+ it("throws on non-numeric security_flag (NaN)", () => {
1070
+ expect(() => gateRun()({ input: { data: { security_flag: "not-a-number" } } })).toThrow(/BLOCK/);
1071
+ });
1072
+
1073
+ it("throws on BLOCK (security_flag = 2)", () => {
1074
+ expect(() => gateRun()({ input: { data: { security_flag: 2 } } })).toThrow(/BLOCK/);
1075
+ });
1076
+
1077
+ it("does not throw on SAFE (security_flag = 0)", () => {
1078
+ expect(() => gateRun()({ input: { data: { security_flag: 0 } } })).not.toThrow();
1079
+ });
1080
+
1081
+ it("does not throw on REVIEW (security_flag = 1)", () => {
1082
+ expect(() => gateRun()({ input: { data: { security_flag: 1 } } })).not.toThrow();
1083
+ });
1084
+ });
1085
+
1086
+ // ---------------------------------------------------------------------------
1087
+ // implement reads wiring — every implement stage declares reads: ["plans"]
1088
+ // and validates clean with contracts threaded in.
1089
+ // ---------------------------------------------------------------------------
1090
+
1091
+ describe("implement reads wiring", () => {
1092
+ it('every implement stage declares reads: ["plans"]', () => {
1093
+ for (const wf of builtInWorkflows) {
1094
+ // Not every workflow has an implement stage (pr-triage is read-only triage).
1095
+ if (!wf.stages.implement) continue;
1096
+ expect(wf.stages.implement.reads, `${wf.name}.implement`).toEqual(["plans"]);
1097
+ }
1098
+ });
1099
+
1100
+ it("every built-in workflow with an implement stage validates clean (contracts threaded in)", () => {
1101
+ for (const name of ["ship", "build", "arch", "vet", "polish"]) {
1102
+ const issues = deriveAndValidate(findWorkflow(name), { skillContracts: DECLARED_CONTRACTS });
1103
+ expect(
1104
+ issues.filter((i) => i.severity === "error"),
1105
+ name,
1106
+ ).toEqual([]);
1107
+ }
1108
+ });
1109
+ });
1110
+
1111
+ // ---------------------------------------------------------------------------
1112
+ // polish — REVIEW_PHASE_ITERATE enumerates the review's structured `phases:`
1113
+ // array (derived by architecture-review from its `### Phase N — name` headings)
1114
+ // and verifies that array against the headings.
1115
+ // ---------------------------------------------------------------------------
1116
+
1117
+ describe("polish — REVIEW_PHASE_ITERATE (frontmatter-driven)", () => {
1118
+ const reviewWithPhases = (phaseCount: number) => {
1119
+ const phases = Array.from(
1120
+ { length: phaseCount },
1121
+ (_, i) => ` - { n: ${i + 1}, title: Phase ${i + 1} name }`,
1122
+ ).join("\n");
1123
+ const headings = Array.from(
1124
+ { length: phaseCount },
1125
+ (_, i) => `### Phase ${i + 1} — Phase ${i + 1} name\nbody`,
1126
+ ).join("\n");
1127
+ return `---\nstatus: ready\nphases:\n${phases}\n---\n# Arch Review\n\n${headings}\n`;
1128
+ };
1129
+
1130
+ let tmpDir: string;
1131
+ beforeEach(() => {
1132
+ tmpDir = mkdtempSync(join(tmpdir(), "rpiv-polish-iter-"));
1133
+ });
1134
+ afterEach(() => {
1135
+ rmSync(tmpDir, { recursive: true, force: true });
1136
+ });
1137
+
1138
+ const iterate = () => {
1139
+ const iter = findWorkflow("polish").stages.blueprint?.iterate;
1140
+ if (!iter) throw new Error("polish blueprint stage has no iterate");
1141
+ return iter;
1142
+ };
1143
+ const write = (rel: string, body: string) => {
1144
+ const parts = rel.split("/");
1145
+ mkdirSync(join(tmpDir, ...parts.slice(0, -1)), { recursive: true });
1146
+ writeFileSync(join(tmpDir, rel), body);
1147
+ };
1148
+ const stateFor = (rel: string) => {
1149
+ const artifact = { handle: fsHandle(rel) };
1150
+ return {
1151
+ artifact,
1152
+ state: {
1153
+ named: { "architecture-reviews": [{ artifacts: [artifact], data: undefined, kind: "", meta: {} }] },
1154
+ } as unknown as RunState,
1155
+ };
1156
+ };
1157
+ const out = () => ({ artifacts: [], data: undefined, kind: "", meta: {} }) as unknown as Output;
1158
+
1159
+ it("reads phases from frontmatter and dispatches one title-enriched unit per phase", async () => {
1160
+ const rel = ".rpiv/artifacts/architecture-reviews/rev.md";
1161
+ write(rel, reviewWithPhases(2));
1162
+ const { artifact, state } = stateFor(rel);
1163
+
1164
+ const unit1 = await iterate()({ cwd: tmpDir, artifact, state, accumulated: [], index: 0 });
1165
+ expect(unit1?.prompt).toBe(`${rel} Implement Phase 1: Phase 1 name`);
1166
+ expect(unit1?.label).toBe("phase 1/2 — Phase 1 name");
1167
+
1168
+ const unit2 = await iterate()({ cwd: tmpDir, artifact, state, accumulated: [out()], index: 1 });
1169
+ expect(unit2?.prompt).toBe(`${rel} Implement Phase 2: Phase 2 name`);
1170
+
1171
+ const unit3 = await iterate()({ cwd: tmpDir, artifact, state, accumulated: [out(), out()], index: 2 });
1172
+ expect(unit3).toBeNull(); // every phase planned → terminate
1173
+ });
1174
+
1175
+ it("reads only the depended-on prior plans; blast_radius/effort tag the label", async () => {
1176
+ const rel = ".rpiv/artifacts/architecture-reviews/rev.md";
1177
+ write(
1178
+ rel,
1179
+ `---\nstatus: ready\nphases:\n` +
1180
+ ` - { n: 1, title: Foundation, blast_radius: internal, effort: S }\n` +
1181
+ ` - { n: 2, title: Vocabulary, depends_on: [1], effort: M }\n` +
1182
+ ` - { n: 3, title: Behavioural, depends_on: [1], blast_radius: public-API, effort: L }\n` +
1183
+ `---\n# Arch Review\n\n### Phase 1 — Foundation\nbody\n### Phase 2 — Vocabulary\nbody\n### Phase 3 — Behavioural\nbody\n`,
1184
+ );
1185
+ const { artifact, state } = stateFor(rel);
1186
+ const planOut = (n: number) =>
1187
+ ({
1188
+ artifacts: [{ handle: fsHandle(`.rpiv/artifacts/plans/plan-${n}.md`) }],
1189
+ data: undefined,
1190
+ kind: "",
1191
+ meta: {},
1192
+ }) as unknown as Output;
1193
+
1194
+ const u1 = await iterate()({ cwd: tmpDir, artifact, state, accumulated: [], index: 0 });
1195
+ expect(u1?.label).toBe("phase 1/3 — Foundation [S, internal]");
1196
+
1197
+ // Phase 3 depends_on [1] only → reads plan-1, not the accumulated plan-2.
1198
+ const u3 = await iterate()({ cwd: tmpDir, artifact, state, accumulated: [planOut(1), planOut(2)], index: 2 });
1199
+ expect(u3?.prompt).toBe(
1200
+ `${rel} Implement Phase 3: Behavioural\n` +
1201
+ `Prior phase plans (read first; build on them, don't duplicate): .rpiv/artifacts/plans/plan-1.md`,
1202
+ );
1203
+ expect(u3?.label).toBe("phase 3/3 — Behavioural [L, public-API]");
1204
+ });
1205
+
1206
+ it("throws when the frontmatter phases disagree with the body headings (stale derive)", () => {
1207
+ const rel = ".rpiv/artifacts/architecture-reviews/mismatch.md";
1208
+ // 1 structured phase, 2 `### Phase N —` headings.
1209
+ write(
1210
+ rel,
1211
+ `---\nphases:\n - { n: 1, title: Only one }\n---\n# Arch Review\n\n### Phase 1 — Only one\nbody\n### Phase 2 — Extra\nbody\n`,
1212
+ );
1213
+ const { artifact, state } = stateFor(rel);
1214
+ expect(() => iterate()({ cwd: tmpDir, artifact, state, accumulated: [], index: 0 })).toThrow(
1215
+ /frontmatter phases \(1\) ≠ '### Phase N —' headings \(2\)/,
1216
+ );
1217
+ });
1218
+
1219
+ it("returns null for a review with neither structured phases nor body headings", async () => {
1220
+ const rel = ".rpiv/artifacts/architecture-reviews/empty.md";
1221
+ write(rel, `---\nstatus: ready\n---\n# No phases\n`);
1222
+ const { artifact, state } = stateFor(rel);
1223
+ expect(await iterate()({ cwd: tmpDir, artifact, state, accumulated: [], index: 0 })).toBeNull();
1224
+ });
1225
+ });
1226
+
1227
+ // ---------------------------------------------------------------------------
1228
+ // contract ownership drift guards — no built-in workflow stage re-declares
1229
+ // a schema its skill's contract owns, and routed fields are owned by their producer.
1230
+ // ---------------------------------------------------------------------------
1231
+
1232
+ describe("contract ownership drift guards", () => {
1233
+ it("no built-in workflow stage re-declares a schema its skill's contract owns", () => {
1234
+ for (const wf of builtInWorkflows) {
1235
+ for (const [stageName, stage] of Object.entries(wf.stages)) {
1236
+ const skill = stage.skill ?? stageName;
1237
+ const contract = DECLARED_CONTRACTS.get(skill);
1238
+ if (contract?.produces?.data) {
1239
+ expect(
1240
+ stage.outputSchema,
1241
+ `${wf.name}.${stageName} re-declares outputSchema the ${skill} contract owns`,
1242
+ ).toBeUndefined();
1243
+ }
1244
+ if (contract?.consumes?.data) {
1245
+ expect(
1246
+ stage.inputSchema,
1247
+ `${wf.name}.${stageName} re-declares inputSchema the ${skill} contract owns`,
1248
+ ).toBeUndefined();
1249
+ }
1250
+ }
1251
+ }
1252
+ });
1253
+
1254
+ it("the gate-routed field (blockers_count) is owned by the code-review contract's produces.data", () => {
1255
+ // Built-in workflows route only on `blockers_count` (gate("blockers_count", …)); gate()
1256
+ // captures the field in a closure (not introspectable), so assert the known routed
1257
+ // field is a required produces.data property of its producer. Complements the
1258
+ // runtime check that it is sourced + output-validated.
1259
+ const data = DECLARED_CONTRACTS.get("code-review")?.produces?.data as
1260
+ | { required?: string[]; properties?: Record<string, unknown> }
1261
+ | undefined;
1262
+ expect(data?.properties?.blockers_count).toBeDefined();
1263
+ expect(data?.required).toContain("blockers_count");
1264
+ });
1265
+ });
1266
+
1267
+ describe("control-flow specs are introspectable (presets self-describe)", () => {
1268
+ const shapeOf = (workflow: string, stage: string) => {
1269
+ const wf = builtInWorkflows.find((w) => w.name === workflow);
1270
+ if (!wf) throw new Error(`workflow ${workflow} not found`);
1271
+ return describeFlow(wf).find((s) => s.stage === stage);
1272
+ };
1273
+
1274
+ it("build/implement reports a fanout spec sourcing the plans channel", () => {
1275
+ const impl = shapeOf("build", "implement");
1276
+ expect(impl?.control.mode).toBe("fanout");
1277
+ expect(impl?.control.spec).toMatchObject({
1278
+ kind: "fanout",
1279
+ source: "plans",
1280
+ unit: { by: "frontmatter-array", pattern: "phases" },
1281
+ max: 32,
1282
+ });
1283
+ });
1284
+
1285
+ it("polish/blueprint reports an iterate spec sourcing architecture-reviews", () => {
1286
+ const bp = shapeOf("polish", "blueprint");
1287
+ expect(bp?.control.mode).toBe("iterate");
1288
+ expect(bp?.control.spec).toMatchObject({
1289
+ kind: "iterate",
1290
+ dependsOnPrior: true,
1291
+ source: "architecture-reviews",
1292
+ });
1293
+ });
1294
+
1295
+ it("code-review reports a route edge with both branch targets", () => {
1296
+ const cr = shapeOf("build", "code-review");
1297
+ expect(cr?.edge.mode).toBe("route");
1298
+ expect(cr?.edge.targets).toEqual(expect.arrayContaining(["revise", "commit"]));
1299
+ });
1300
+ });