@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.
- package/extensions/rpiv-core/built-in-workflows.test.ts +542 -107
- package/extensions/rpiv-core/built-in-workflows.ts +357 -149
- package/extensions/rpiv-core/index.ts +12 -6
- package/extensions/rpiv-core/models-config.test.ts +3 -3
- package/extensions/rpiv-core/outcome-derivation.test.ts +417 -0
- package/extensions/rpiv-core/outcome-derivation.ts +110 -0
- package/extensions/rpiv-core/register-built-in-workflows.test.ts +4 -3
- package/extensions/rpiv-core/register-built-in-workflows.ts +2 -2
- package/extensions/rpiv-core/skill-contracts-source.test.ts +469 -0
- package/extensions/rpiv-core/skill-contracts-source.ts +185 -0
- package/extensions/rpiv-core/utils.test.ts +17 -1
- package/extensions/rpiv-core/utils.ts +21 -6
- package/package.json +62 -60
- package/skills/annotate-guidance/SKILL.md +8 -0
- package/skills/annotate-inline/SKILL.md +8 -0
- package/skills/architecture-review/SKILL.md +36 -2
- package/skills/blueprint/SKILL.md +41 -1
- package/skills/changelog/SKILL.md +8 -0
- package/skills/code-review/SKILL.md +30 -13
- package/skills/code-review/_helpers/review-range.mjs +13 -0
- package/skills/code-review/templates/review.md +1 -1
- package/skills/commit/SKILL.md +8 -0
- package/skills/create-handoff/SKILL.md +20 -0
- package/skills/design/SKILL.md +18 -0
- package/skills/discover/SKILL.md +11 -1
- package/skills/discover/templates/frd.md +1 -1
- package/skills/explore/SKILL.md +24 -4
- package/skills/frontend-design/SKILL.md +5 -0
- package/skills/implement/SKILL.md +11 -1
- package/skills/migrate-to-guidance/SKILL.md +8 -0
- package/skills/plan/SKILL.md +42 -2
- package/skills/pr-triage/SKILL.md +375 -0
- package/skills/pr-triage/_helpers/pr-fetch.mjs +357 -0
- package/skills/pr-triage/templates/triage.md +159 -0
- package/skills/research/SKILL.md +14 -1
- package/skills/resume-handoff/SKILL.md +8 -0
- package/skills/revise/SKILL.md +15 -0
- package/skills/validate/SKILL.md +26 -9
- package/skills/validate/templates/validation.md +3 -2
|
@@ -1,27 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Regression tests for
|
|
3
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
90
|
+
// validate must route to code-review (not commit) in build/arch workflows.
|
|
57
91
|
// ---------------------------------------------------------------------------
|
|
58
92
|
|
|
59
|
-
describe("
|
|
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
|
-
//
|
|
88
|
-
//
|
|
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("
|
|
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
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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("
|
|
129
|
-
it("built-in code-review stage carries an outputSchema", () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
136
|
-
const
|
|
137
|
-
|
|
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 =
|
|
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
|
-
//
|
|
161
|
-
//
|
|
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("
|
|
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
|
-
//
|
|
222
|
-
//
|
|
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("
|
|
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
|
-
//
|
|
294
|
-
//
|
|
325
|
+
// Non-first stages must NOT silently fall back to originalInput when their
|
|
326
|
+
// upstream produced no artifactPath.
|
|
295
327
|
// ---------------------------------------------------------------------------
|
|
296
328
|
|
|
297
|
-
describe("
|
|
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
|
-
//
|
|
368
|
+
// Phase fanout must label JSONL rows by stage.skill, not by the stage name.
|
|
337
369
|
// ---------------------------------------------------------------------------
|
|
338
370
|
|
|
339
|
-
describe("
|
|
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
|
|
364
|
-
//
|
|
365
|
-
//
|
|
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
|
-
//
|
|
421
|
-
// backward-jump loop behavior.
|
|
452
|
+
// vet workflow routing predicate and backward-jump loop behavior.
|
|
422
453
|
// ---------------------------------------------------------------------------
|
|
423
454
|
|
|
424
|
-
describe("
|
|
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
|
|
463
|
+
const ctxWithBlockers = (blockers_count: number) =>
|
|
433
464
|
({
|
|
434
465
|
output: {
|
|
435
466
|
kind: "artifact-md",
|
|
436
467
|
artifacts: [],
|
|
437
|
-
data: {
|
|
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(
|
|
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(
|
|
484
|
+
expect(edge(ctxWithBlockers(0))).toBe("commit");
|
|
459
485
|
});
|
|
460
486
|
|
|
461
|
-
it(
|
|
487
|
+
it("routes blockers_count > 0 to blueprint (fix loop)", () => {
|
|
462
488
|
const edge = findEdge();
|
|
463
|
-
expect(edge(
|
|
489
|
+
expect(edge(ctxWithBlockers(3))).toBe("blueprint");
|
|
490
|
+
expect(edge(ctxWithBlockers(1))).toBe("blueprint");
|
|
464
491
|
});
|
|
465
492
|
|
|
466
|
-
it(
|
|
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("
|
|
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
|
|
505
|
+
it("code-review stage carries no inline outputSchema (sourced from contract) and gates on blockers_count", () => {
|
|
491
506
|
const wf = findWorkflow("vet");
|
|
492
|
-
|
|
493
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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).
|
|
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
|
-
|
|
652
|
-
const
|
|
653
|
-
|
|
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, {
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
+
});
|