@juicesharp/rpiv-pi 1.19.0 → 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.
@@ -1050,6 +1050,39 @@ describe("ship workflow", () => {
1050
1050
  });
1051
1051
  });
1052
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
+
1053
1086
  // ---------------------------------------------------------------------------
1054
1087
  // implement reads wiring — every implement stage declares reads: ["plans"]
1055
1088
  // and validates clean with contracts threaded in.
@@ -1058,11 +1091,13 @@ describe("ship workflow", () => {
1058
1091
  describe("implement reads wiring", () => {
1059
1092
  it('every implement stage declares reads: ["plans"]', () => {
1060
1093
  for (const wf of builtInWorkflows) {
1061
- expect(wf.stages.implement?.reads, `${wf.name}.implement`).toEqual(["plans"]);
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"]);
1062
1097
  }
1063
1098
  });
1064
1099
 
1065
- it('all five built-in workflows validate clean with reads:["plans"] (contracts threaded in)', () => {
1100
+ it("every built-in workflow with an implement stage validates clean (contracts threaded in)", () => {
1066
1101
  for (const name of ["ship", "build", "arch", "vet", "polish"]) {
1067
1102
  const issues = deriveAndValidate(findWorkflow(name), { skillContracts: DECLARED_CONTRACTS });
1068
1103
  expect(
@@ -79,6 +79,14 @@ interface PhaseRecord {
79
79
  total: number;
80
80
  }
81
81
 
82
+ /** Read an artifact file, resolving a workflow-relative path against `cwd`. */
83
+ const readArtifactFile = (path: string, cwd: string): string =>
84
+ readFileSync(isAbsolute(path) ? path : join(cwd, path), "utf-8");
85
+
86
+ /** Build the halting `StagePreflightError` shape every phase fanout/iterate guard `throw`s. */
87
+ const haltPreflight = (who: string, summary: string, detail: string): StagePreflightError =>
88
+ new StagePreflightError("halt", who, summary, detail, true);
89
+
82
90
  /**
83
91
  * Parse a plan's `phases:` frontmatter into records, derive-checked against the
84
92
  * body's `## Phase N:` headings — the source of truth both the single-plan
@@ -94,12 +102,10 @@ const planPhaseRecords = (content: string, who: string, path: string): readonly
94
102
  const phases = Array.isArray(raw) ? raw : [];
95
103
  const headingCount = [...content.matchAll(PLAN_PHASE_RE)].length;
96
104
  if (phases.length !== headingCount) {
97
- throw new StagePreflightError(
98
- "halt",
105
+ throw haltPreflight(
99
106
  who,
100
107
  `${who}: plan ${path} has mismatched phases`,
101
108
  `${who}: plan ${path} frontmatter phases (${phases.length}) ≠ '## Phase N:' headings (${headingCount}) — the derived array is stale against the body`,
102
- true,
103
109
  );
104
110
  }
105
111
  // The REQUIRED scalar `phase_count` must equal the derived phase count — it
@@ -108,12 +114,10 @@ const planPhaseRecords = (content: string, who: string, path: string): readonly
108
114
  // still degrades to [] (the existing "neither phases nor headings" path); a plan
109
115
  // that declares phases but omits phase_count THROWS (the field is contract-required).
110
116
  if ((phases.length > 0 || fm.phase_count !== undefined) && fm.phase_count !== phases.length) {
111
- throw new StagePreflightError(
112
- "halt",
117
+ throw haltPreflight(
113
118
  who,
114
119
  `${who}: plan ${path} has invalid phase_count`,
115
120
  `${who}: plan ${path} frontmatter phase_count (${String(fm.phase_count)}) ≠ phases length (${phases.length}) — rebuild phase_count from the '## Phase N:' headings`,
116
- true,
117
121
  );
118
122
  }
119
123
  return phases.map((entry, index) => {
@@ -132,9 +136,6 @@ const planPhaseRecords = (content: string, who: string, path: string): readonly
132
136
  const latestFsArtifact = (state: Readonly<RunState>, name: string): Artifact | undefined =>
133
137
  state.named[name]?.at(-1)?.artifacts.find((a) => a.handle.kind === "fs");
134
138
 
135
- /** Resolve a workflow-relative path against `cwd`. */
136
- const resolveCwd = (path: string, cwd: string): string => (isAbsolute(path) ? path : join(cwd, path));
137
-
138
139
  /**
139
140
  * Fan `implement` out over the structured `phases:` frontmatter array of the
140
141
  * latest plan published to the named `"plans"` channel. Sourcing from the named
@@ -153,24 +154,20 @@ const FRONTMATTER_PHASE_FANOUT = fanoutOver({
153
154
  const path = plan.handle.path;
154
155
  let content: string;
155
156
  try {
156
- content = readFileSync(resolveCwd(path, cwd), "utf-8");
157
+ content = readArtifactFile(path, cwd);
157
158
  } catch (err) {
158
- throw new StagePreflightError(
159
- "halt",
159
+ throw haltPreflight(
160
160
  "FRONTMATTER_PHASE_FANOUT",
161
161
  `FRONTMATTER_PHASE_FANOUT: plan file not found`,
162
162
  `FRONTMATTER_PHASE_FANOUT: could not read ${path} — ${err instanceof Error ? err.message : String(err)}`,
163
- true,
164
163
  );
165
164
  }
166
165
  const records = planPhaseRecords(content, "FRONTMATTER_PHASE_FANOUT", path);
167
166
  if (records.length > MAX_PHASES) {
168
- throw new StagePreflightError(
169
- "halt",
167
+ throw haltPreflight(
170
168
  "FRONTMATTER_PHASE_FANOUT",
171
169
  `FRONTMATTER_PHASE_FANOUT: plan ${path} exceeds phase limit`,
172
170
  `FRONTMATTER_PHASE_FANOUT: plan ${path} declares ${records.length} phases — exceeds MAX_PHASES (${MAX_PHASES}); split into smaller plans`,
173
- true,
174
171
  );
175
172
  }
176
173
  const promptPath = handleToString(plan.handle);
@@ -330,7 +327,7 @@ const REVIEW_PHASE_RE = /^### Phase (\d+) — (.+)$/gm;
330
327
  const reviewPhaseCount = (state: Readonly<RunState>, cwd: string): number => {
331
328
  const review = latestFsArtifact(state, "architecture-reviews");
332
329
  if (review?.handle.kind !== "fs") return 0;
333
- const { frontmatter } = parseFrontmatter(readFileSync(resolveCwd(review.handle.path, cwd), "utf-8"));
330
+ const { frontmatter } = parseFrontmatter(readArtifactFile(review.handle.path, cwd));
334
331
  const raw = (frontmatter as Record<string, unknown>).phases;
335
332
  return Array.isArray(raw) ? raw.length : 0;
336
333
  };
@@ -386,7 +383,7 @@ const REVIEW_PHASE_ITERATE = iterateOver({
386
383
  const review = latestFsArtifact(state, "architecture-reviews") ?? artifact;
387
384
  if (review?.handle.kind !== "fs") return null;
388
385
  const reviewPath = review.handle.path; // captured: narrowing is lost inside nested closures below
389
- const content = readFileSync(resolveCwd(reviewPath, cwd), "utf-8");
386
+ const content = readArtifactFile(reviewPath, cwd);
390
387
  const { frontmatter } = parseFrontmatter(content);
391
388
  const raw = (frontmatter as Record<string, unknown>).phases;
392
389
  const phases = Array.isArray(raw) ? raw : [];
@@ -394,12 +391,10 @@ const REVIEW_PHASE_ITERATE = iterateOver({
394
391
  if (i === 0) {
395
392
  const headingCount = [...content.matchAll(REVIEW_PHASE_RE)].length;
396
393
  if (phases.length !== headingCount) {
397
- throw new StagePreflightError(
398
- "halt",
394
+ throw haltPreflight(
399
395
  "REVIEW_PHASE_ITERATE",
400
396
  `REVIEW_PHASE_ITERATE: review ${reviewPath} has mismatched phases`,
401
397
  `REVIEW_PHASE_ITERATE: review ${reviewPath} frontmatter phases (${phases.length}) ≠ '### Phase N —' headings (${headingCount}) — the derived array is stale against the body`,
402
- true,
403
398
  );
404
399
  }
405
400
  const indexByN = new Map(phases.map((e, idx) => [phaseNum(e, idx), idx]));
@@ -407,20 +402,16 @@ const REVIEW_PHASE_ITERATE = iterateOver({
407
402
  for (const d of phaseDeps(e)) {
408
403
  const di = indexByN.get(d);
409
404
  if (di === undefined)
410
- throw new StagePreflightError(
411
- "halt",
405
+ throw haltPreflight(
412
406
  "REVIEW_PHASE_ITERATE",
413
407
  `REVIEW_PHASE_ITERATE: review ${reviewPath} has invalid depends_on`,
414
408
  `REVIEW_PHASE_ITERATE: review ${reviewPath} phase ${phaseNum(e, idx)} depends_on ${d}, which is not a declared phase`,
415
- true,
416
409
  );
417
410
  if (di >= idx)
418
- throw new StagePreflightError(
419
- "halt",
411
+ throw haltPreflight(
420
412
  "REVIEW_PHASE_ITERATE",
421
413
  `REVIEW_PHASE_ITERATE: review ${reviewPath} has cyclic dependency`,
422
414
  `REVIEW_PHASE_ITERATE: review ${reviewPath} phase ${phaseNum(e, idx)} depends_on ${d}, which is not an earlier phase (self/forward/cyclic dependency)`,
423
- true,
424
415
  );
425
416
  }
426
417
  });
@@ -471,7 +462,7 @@ const PLANS_PHASE_FANOUT = fanoutOver({
471
462
  for (const a of out.artifacts) {
472
463
  if (a.handle.kind !== "fs") continue;
473
464
  const path = a.handle.path;
474
- const content = readFileSync(resolveCwd(path, cwd), "utf-8");
465
+ const content = readArtifactFile(path, cwd);
475
466
  const promptPath = handleToString(a.handle);
476
467
  for (const r of planPhaseRecords(content, "PLANS_PHASE_FANOUT", path)) {
477
468
  units.push({
@@ -482,12 +473,10 @@ const PLANS_PHASE_FANOUT = fanoutOver({
482
473
  }
483
474
  }
484
475
  if (units.length > MAX_PHASES) {
485
- throw new StagePreflightError(
486
- "halt",
476
+ throw haltPreflight(
487
477
  "PLANS_PHASE_FANOUT",
488
478
  `PLANS_PHASE_FANOUT: phase limit exceeded`,
489
479
  `PLANS_PHASE_FANOUT: ${units.length} phases exceeds MAX_PHASES (${MAX_PHASES})`,
490
- true,
491
480
  );
492
481
  }
493
482
  return units;
@@ -535,6 +524,58 @@ const polishWorkflow = defineWorkflow({
535
524
  },
536
525
  });
537
526
 
527
+ // ===========================================================================
528
+ // pr-triage — pr-triage → security-gate → stop
529
+ // Read-only front door for an incoming GitHub PR. The `pr-triage` skill
530
+ // fetches the PR thread, assesses the diff against whatever standard the repo
531
+ // actually carries, writes a triage artifact, and recommends a triage
532
+ // disposition (Review / Request changes / Hold / Decline) as a plain next
533
+ // action. The `security-gate` script stage reads the skill's
534
+ // `security_flag` and HALTS the run on a BLOCK (≥ 2) via `haltPreflight` — the
535
+ // "security gate first, before any checkout" posture — while SAFE/REVIEW (< 2)
536
+ // fall through to `stop` carrying the verdict.
537
+ //
538
+ // It terminates rather than dispatching a follow-up workflow: triage gates entry
539
+ // to review, it does not merge. The disposition is a plain action; `vet` (the
540
+ // review stage) is offered as optional sugar under Review. Only the security gate
541
+ // is enforced by the graph. A linear guard stage (not a `gate(...)`
542
+ // edge) keeps the halt off the data-routing path — the throw lives inside the
543
+ // script, read from the prior stage's output.
544
+ // ===========================================================================
545
+
546
+ /** BLOCK tier the pr-triage `security_flag` contract field emits (0 SAFE · 1 REVIEW · 2 BLOCK). */
547
+ const PR_TRIAGE_BLOCK = 2;
548
+
549
+ const prTriageWorkflow = defineWorkflow({
550
+ name: "pr-triage",
551
+ description:
552
+ "Read-only triage of a GitHub PR before any review effort. Fetches the PR thread, assesses the diff against the repo's own standards, writes a triage artifact, and recommends a triage disposition (Review / Request changes / Hold / Decline). A security BLOCK halts the run before any checkout. Best as the entry point for an incoming PR. Chain: pr-triage → security-gate → stop.",
553
+ start: "pr-triage",
554
+ stages: {
555
+ "pr-triage": produces(),
556
+ // Skillless guard: read the triage skill's `security_flag` from the prior
557
+ // stage's output and halt on BLOCK. A script stage (not a skill) — no LLM,
558
+ // no session — so the gate is free. On SAFE/REVIEW it is a no-op side effect
559
+ // and the chain advances to `stop`.
560
+ "security-gate": acts.script({
561
+ run: ({ input }) => {
562
+ const flag = Number((input?.data as { security_flag?: unknown } | undefined)?.security_flag);
563
+ if (Number.isNaN(flag) || flag >= PR_TRIAGE_BLOCK) {
564
+ throw haltPreflight(
565
+ "pr-triage",
566
+ "pr-triage: security BLOCK — do not proceed",
567
+ `pr-triage: security_flag=${flag} (BLOCK) — the PR diff carries a high-confidence security risk. Resolve it before any checkout or review; see the triage artifact for the traced finding.`,
568
+ );
569
+ }
570
+ },
571
+ }),
572
+ },
573
+ edges: {
574
+ "pr-triage": "security-gate",
575
+ "security-gate": "stop",
576
+ },
577
+ });
578
+
538
579
  // ===========================================================================
539
580
  // Exports
540
581
  // ===========================================================================
@@ -545,4 +586,5 @@ export const builtInWorkflows: readonly Workflow[] = [
545
586
  archWorkflow,
546
587
  vetWorkflow,
547
588
  polishWorkflow,
589
+ prTriageWorkflow,
548
590
  ];
@@ -7,7 +7,7 @@
7
7
  * Tool-owning plugins are siblings (see siblings.ts); install via /rpiv-setup.
8
8
  *
9
9
  * Workflow runtime + `/wf` command live in `@juicesharp/rpiv-workflow`. We
10
- * contribute five built-in workflows (ship / build / arch / vet / polish) via the
10
+ * contribute six built-in workflows (ship / build / arch / vet / polish / pr-triage) via the
11
11
  * sibling's `registerBuiltIns` programmatic API so they're available to
12
12
  * users running `/wf` without authoring their own.
13
13
  */
@@ -46,8 +46,8 @@ function stripOutcomes(w: Workflow): Workflow {
46
46
  // ---------------------------------------------------------------------------
47
47
 
48
48
  describe("BUCKET_BY_KIND", () => {
49
- it("contains exactly 9 entries", () => {
50
- expect(Object.keys(BUCKET_BY_KIND)).toHaveLength(9);
49
+ it("contains exactly 10 entries", () => {
50
+ expect(Object.keys(BUCKET_BY_KIND)).toHaveLength(10);
51
51
  });
52
52
 
53
53
  it("covers all artifactKinds used by produces skills", () => {
@@ -61,6 +61,7 @@ describe("BUCKET_BY_KIND", () => {
61
61
  "architecture-review",
62
62
  "frd",
63
63
  "handoff",
64
+ "triage",
64
65
  ];
65
66
  for (const kind of expectedKinds) {
66
67
  expect(BUCKET_BY_KIND[kind]).toBeDefined();
@@ -242,10 +243,11 @@ describe("equivalence — built-in workflows", () => {
242
243
  ["validate", "validation"],
243
244
  ["code-review", "review"],
244
245
  ["revise", "plan"],
246
+ ["pr-triage", "triage"],
245
247
  ];
246
248
 
247
249
  /**
248
- * Expected bucket name for each produces stage across all 5 workflows.
250
+ * Expected bucket name for each produces stage across all 6 workflows.
249
251
  * Key: "workflowName::stageName". Value: expected outcome.name.
250
252
  */
251
253
  const EXPECTED: Record<string, string> = {
@@ -273,6 +275,8 @@ describe("equivalence — built-in workflows", () => {
273
275
  "polish::blueprint": "plans",
274
276
  "polish::validate": "validation",
275
277
  "polish::code-review": "reviews",
278
+ // pr-triage
279
+ "pr-triage::pr-triage": "triage",
276
280
  };
277
281
 
278
282
  /**
@@ -343,14 +347,14 @@ describe("equivalence — built-in workflows", () => {
343
347
  });
344
348
  }
345
349
 
346
- it("total produces stages across all workflows = 19", () => {
350
+ it("total produces stages across all workflows = 20", () => {
347
351
  let count = 0;
348
352
  for (const w of builtInWorkflows) {
349
353
  for (const stage of Object.values(w.stages)) {
350
354
  if (stage.kind === "produces") count++;
351
355
  }
352
356
  }
353
- expect(count).toBe(19);
357
+ expect(count).toBe(20);
354
358
  });
355
359
  });
356
360
 
@@ -35,6 +35,7 @@ export const BUCKET_BY_KIND: Readonly<Record<string, string>> = {
35
35
  "architecture-review": "architecture-reviews",
36
36
  frd: "discover",
37
37
  handoff: "handoffs",
38
+ triage: "triage",
38
39
  };
39
40
 
40
41
  /**
@@ -11,18 +11,18 @@
11
11
  import { afterEach, describe, expect, it, vi } from "vitest";
12
12
  import { registerBuiltInWorkflows } from "./register-built-in-workflows.js";
13
13
 
14
- // Alphabetical: the five shipped presets.
15
- const BUILT_IN_NAMES = ["arch", "build", "polish", "ship", "vet"];
14
+ // Alphabetical: the six shipped presets.
15
+ const BUILT_IN_NAMES = ["arch", "build", "polish", "pr-triage", "ship", "vet"];
16
16
 
17
17
  describe("registerBuiltInWorkflows", () => {
18
- it("registers all built-in workflows (five presets) when rpiv-workflow is present", async () => {
18
+ it("registers all built-in workflows (six presets) when rpiv-workflow is present", async () => {
19
19
  const { getBuiltIns, flushBuiltInProviders } = await import("@juicesharp/rpiv-workflow/internal");
20
20
  expect(getBuiltIns()).toEqual([]); // setup.ts beforeEach resets the registry
21
21
 
22
22
  await registerBuiltInWorkflows();
23
23
  // registerBuiltInWorkflows now registers a LAZY provider — the registry
24
24
  // stays empty until the first loadWorkflows() flushes it. Flush directly
25
- // to assert the provider contributes the five definitions.
25
+ // to assert the provider contributes the six definitions.
26
26
  expect(getBuiltIns()).toEqual([]);
27
27
  await flushBuiltInProviders();
28
28
 
@@ -21,7 +21,7 @@
21
21
  import { isModuleNotFound } from "./utils.js";
22
22
 
23
23
  /**
24
- * Register the five built-in workflows (ship / build / arch / vet / polish)
24
+ * Register the six built-in workflows (ship / build / arch / vet / polish / pr-triage)
25
25
  * with the rpiv-workflow runtime, if that sibling is installed. A missing
26
26
  * sibling resolves to a no-op; any other failure is re-thrown so genuine bugs
27
27
  * surface rather than hiding behind the absent-sibling path.
@@ -29,7 +29,7 @@ import { isModuleNotFound } from "./utils.js";
29
29
  export async function registerBuiltInWorkflows(): Promise<void> {
30
30
  try {
31
31
  // Thin `/startup` entry (~9ms, no DSL/runner). Register a LAZY provider so
32
- // `built-in-workflows.js` (the ~180ms authoring-DSL graph) builds the five
32
+ // `built-in-workflows.js` (the ~180ms authoring-DSL graph) builds the six
33
33
  // definitions on first `/wf`, not at startup. Missing sibling →
34
34
  // ERR_MODULE_NOT_FOUND → no-op (no `/wf` without it).
35
35
  const { registerBuiltInsProvider, registerBuiltIns } = await import("@juicesharp/rpiv-workflow/startup");
@@ -206,8 +206,8 @@ describe("bundled skill contracts", () => {
206
206
  // dropped, or fails to parse (a malformed block is silently skipped).
207
207
  const declared = new Map(buildSkillContractsFromFrontmatter(BUNDLED_SKILLS_DIR));
208
208
 
209
- it("declares a contract for the 19 pipeline + orthogonal skills", () => {
210
- expect(declared.size).toBe(19);
209
+ it("declares a contract for the 20 pipeline + orthogonal skills", () => {
210
+ expect(declared.size).toBe(20);
211
211
  for (const name of [
212
212
  "discover",
213
213
  "research",
@@ -228,6 +228,7 @@ describe("bundled skill contracts", () => {
228
228
  "resume-handoff",
229
229
  "frontend-design",
230
230
  "migrate-to-guidance",
231
+ "pr-triage",
231
232
  ]) {
232
233
  expect(declared.has(name)).toBe(true);
233
234
  }
@@ -252,6 +253,7 @@ describe("bundled skill contracts", () => {
252
253
  it("documents the declared-but-not-harvested orthogonal set", () => {
253
254
  // These skills declare a contract but don't appear in any built-in workflow.
254
255
  // The orthogonal set: 7 new + discover + explore + commit = 10 skills.
256
+ // (pr-triage IS harvested — it's dispatched by the pr-triage workflow.)
255
257
  const harvested = harvestStageContracts(builtInWorkflows);
256
258
  const notHarvested: string[] = [];
257
259
  for (const [name] of declared) {
@@ -273,7 +275,7 @@ describe("bundled skill contracts", () => {
273
275
  );
274
276
  });
275
277
 
276
- it("every declared kind matches the harvested kind for the five built-in workflows", () => {
278
+ it("every declared kind matches the harvested kind for the six built-in workflows", () => {
277
279
  // Harvest derives each dispatched skill's kind from how the built-ins use
278
280
  // it (produces() → "produces", acts() → "side-effect"). A declared kind
279
281
  // that disagrees would make the rendered graph lie — catch it here.
@@ -19,7 +19,6 @@ import type {
19
19
  SchemaCompatResult,
20
20
  SkillContract,
21
21
  } from "@juicesharp/rpiv-workflow/registration";
22
- import { registerOutcomeDerivation } from "./outcome-derivation.js";
23
22
  import { BUNDLED_SKILLS_DIR } from "./paths.js";
24
23
  import { isModuleNotFound } from "./utils.js";
25
24
 
@@ -169,6 +168,15 @@ export async function registerSkillContractsSource(): Promise<void> {
169
168
  });
170
169
  // Register the contract-derived outcome resolver so `produces` stages
171
170
  // auto-wire `rpivBucketOutcome(bucket)` from `artifactKind` at load time.
171
+ // Dynamic import (not static): `outcome-derivation.js` pulls in
172
+ // `artifact-collector.js`, which value-imports `@juicesharp/rpiv-workflow/registration`
173
+ // at module-eval. A static edge would make THAT a load-time requirement of the
174
+ // whole extension — so when the sibling is absent or not co-located, rpiv-core
175
+ // fails to load entirely (the bug in #66). Deferring it here keeps the sibling
176
+ // edge off the entry path: it only resolves after the `/startup` import above
177
+ // already succeeded, and a missing/non-resolvable sibling degrades to the
178
+ // isModuleNotFound no-op below instead of crashing the extension.
179
+ const { registerOutcomeDerivation } = await import("./outcome-derivation.js");
172
180
  await registerOutcomeDerivation();
173
181
  } catch (err) {
174
182
  if (isModuleNotFound(err)) return; // sibling absent — /rpiv-setup prompts the user
@@ -74,11 +74,27 @@ describe("toErrorMessage", () => {
74
74
  });
75
75
 
76
76
  describe("isModuleNotFound", () => {
77
- it("is true for an ERR_MODULE_NOT_FOUND error", () => {
77
+ it("is true for an ERR_MODULE_NOT_FOUND error (Node native ESM resolver)", () => {
78
78
  const err = Object.assign(new Error("Cannot find package"), { code: "ERR_MODULE_NOT_FOUND" });
79
79
  expect(isModuleNotFound(err)).toBe(true);
80
80
  });
81
81
 
82
+ it("is true for a MODULE_NOT_FOUND error (jiti resolver — what Pi actually uses)", () => {
83
+ // jiti 2.7.0 rejects a missing `import("@juicesharp/rpiv-…")` with this
84
+ // CJS-style code; the absent-sibling guards must treat it as a no-op.
85
+ const err = Object.assign(new Error("Cannot find module '@juicesharp/rpiv-workflow/startup'"), {
86
+ code: "MODULE_NOT_FOUND",
87
+ });
88
+ expect(isModuleNotFound(err)).toBe(true);
89
+ });
90
+
91
+ it("is true when the resolution code is nested under cause", () => {
92
+ const wrapped = Object.assign(new Error("wrapped"), {
93
+ cause: Object.assign(new Error("Cannot find module"), { code: "MODULE_NOT_FOUND" }),
94
+ });
95
+ expect(isModuleNotFound(wrapped)).toBe(true);
96
+ });
97
+
82
98
  it("is false for other error codes and codeless errors", () => {
83
99
  expect(isModuleNotFound(Object.assign(new Error("boom"), { code: "ERR_OTHER" }))).toBe(false);
84
100
  expect(isModuleNotFound(new Error("no code"))).toBe(false);
@@ -52,13 +52,28 @@ export function toErrorMessage(e: unknown, fallback?: string): string {
52
52
  // ---------------------------------------------------------------------------
53
53
 
54
54
  /**
55
- * True for a Node module-resolution failure (the sibling isn't installed).
55
+ * Error codes that mean "module-resolution failed" (the sibling isn't installed
56
+ * / isn't resolvable). Two distinct codes because two distinct loaders are in
57
+ * play:
58
+ * - `ERR_MODULE_NOT_FOUND` — Node's native ESM resolver (a plain
59
+ * `await import(...)` under stock Node).
60
+ * - `MODULE_NOT_FOUND` — jiti's resolver, which is what Pi ACTUALLY uses to
61
+ * load `.ts` extensions. A missing nested `import("@juicesharp/rpiv-…")`
62
+ * inside a jiti-loaded module rejects with this CJS-style code (verified on
63
+ * jiti 2.7.0, both `tryNative:false` and the native-fallback path). Matching
64
+ * only the ESM code let these fall through to a logged `[rpiv-core] failed
65
+ * to register …` instead of the intended silent absent-sibling no-op.
66
+ */
67
+ const MODULE_NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]);
68
+
69
+ /**
70
+ * True for a module-resolution failure (the sibling isn't installed / not
71
+ * resolvable from rpiv-pi's location).
56
72
  *
57
73
  * Walks the `cause` chain: a clean `await import(...)` of a missing package
58
- * rejects with `code === "ERR_MODULE_NOT_FOUND"` directly, but ESM loaders and
59
- * tooling (vitest's mock layer, some bundlers) wrap that error, nesting the
60
- * real code under `.cause`. Bounded against pathological self-referential
61
- * chains.
74
+ * rejects with the resolution code directly, but ESM loaders and tooling
75
+ * (vitest's mock layer, some bundlers) wrap that error, nesting the real code
76
+ * under `.cause`. Bounded against pathological self-referential chains.
62
77
  */
63
78
  export function isModuleNotFound(err: unknown): boolean {
64
79
  for (
@@ -66,7 +81,7 @@ export function isModuleNotFound(err: unknown): boolean {
66
81
  cur != null && depth < 16;
67
82
  cur = (cur as { cause?: unknown }).cause, depth++
68
83
  ) {
69
- if (typeof cur === "object" && (cur as { code?: unknown }).code === "ERR_MODULE_NOT_FOUND") {
84
+ if (typeof cur === "object" && MODULE_NOT_FOUND_CODES.has((cur as { code?: unknown }).code as string)) {
70
85
  return true;
71
86
  }
72
87
  }
package/package.json CHANGED
@@ -1,62 +1,64 @@
1
1
  {
2
- "name": "@juicesharp/rpiv-pi",
3
- "version": "1.19.0",
4
- "description": "A skill-based development workflow for Pi Agent. Five skills (research, design, plan, implement, validate) and the shared subagents that compose its ship-loop.",
5
- "keywords": [
6
- "pi-package",
7
- "pi-extension",
8
- "rpiv",
9
- "skills",
10
- "workflow"
11
- ],
12
- "license": "MIT",
13
- "author": "juicesharp",
14
- "type": "module",
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/juicesharp/rpiv-mono.git",
18
- "directory": "packages/rpiv-pi"
19
- },
20
- "homepage": "https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-pi#readme",
21
- "bugs": {
22
- "url": "https://github.com/juicesharp/rpiv-mono/issues"
23
- },
24
- "publishConfig": {
25
- "access": "public"
26
- },
27
- "scripts": {
28
- "test": "vitest run"
29
- },
30
- "files": [
31
- "extensions/",
32
- "skills/",
33
- "agents/",
34
- "scripts/",
35
- "README.md",
36
- "LICENSE"
37
- ],
38
- "pi": {
39
- "extensions": [
40
- "./extensions"
41
- ],
42
- "skills": [
43
- "./skills"
44
- ]
45
- },
46
- "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": "*",
48
- "@tintinweb/pi-subagents": "*",
49
- "@juicesharp/rpiv-ask-user-question": "*",
50
- "@juicesharp/rpiv-todo": "*",
51
- "@juicesharp/rpiv-advisor": "*",
52
- "@juicesharp/rpiv-i18n": "*",
53
- "@juicesharp/rpiv-web-tools": "*",
54
- "@juicesharp/rpiv-args": "*",
55
- "@juicesharp/rpiv-workflow": "*",
56
- "@juicesharp/rpiv-config": "*",
57
- "yaml": "*",
58
- "typebox": "*",
59
- "jiti": "*",
60
- "@standard-schema/spec": "*"
61
- }
2
+ "name": "@juicesharp/rpiv-pi",
3
+ "version": "1.19.1",
4
+ "description": "A skill-based development workflow for Pi Agent. Five skills (research, design, plan, implement, validate) and the shared subagents that compose its ship-loop.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "rpiv",
9
+ "skills",
10
+ "workflow"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "juicesharp",
14
+ "type": "module",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/juicesharp/rpiv-mono.git",
18
+ "directory": "packages/rpiv-pi"
19
+ },
20
+ "homepage": "https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-pi#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/juicesharp/rpiv-mono/issues"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "test": "vitest run"
29
+ },
30
+ "files": [
31
+ "extensions/",
32
+ "skills/",
33
+ "agents/",
34
+ "scripts/",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "pi": {
39
+ "extensions": [
40
+ "./extensions"
41
+ ],
42
+ "skills": [
43
+ "./skills"
44
+ ]
45
+ },
46
+ "dependencies": {
47
+ "@juicesharp/rpiv-config": "^1.19.1"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "@tintinweb/pi-subagents": "*",
52
+ "@juicesharp/rpiv-ask-user-question": "*",
53
+ "@juicesharp/rpiv-todo": "*",
54
+ "@juicesharp/rpiv-advisor": "*",
55
+ "@juicesharp/rpiv-i18n": "*",
56
+ "@juicesharp/rpiv-web-tools": "*",
57
+ "@juicesharp/rpiv-args": "*",
58
+ "@juicesharp/rpiv-workflow": "*",
59
+ "yaml": "*",
60
+ "typebox": "*",
61
+ "jiti": "*",
62
+ "@standard-schema/spec": "*"
63
+ }
62
64
  }