@juicesharp/rpiv-workflow 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/api.ts +557 -0
  4. package/audit.ts +217 -0
  5. package/built-ins.ts +65 -0
  6. package/command.ts +137 -0
  7. package/docs/cover.png +0 -0
  8. package/docs/cover.svg +120 -0
  9. package/docs/workflow-authoring.md +629 -0
  10. package/docs/workflow-basics.md +122 -0
  11. package/docs-protocol.ts +106 -0
  12. package/fanout.ts +96 -0
  13. package/host.ts +97 -0
  14. package/index.ts +230 -0
  15. package/internal-utils.ts +69 -0
  16. package/internal.ts +27 -0
  17. package/layers.ts +33 -0
  18. package/lifecycle.ts +274 -0
  19. package/load/cache.test.ts +82 -0
  20. package/load/cache.ts +40 -0
  21. package/load/index.ts +159 -0
  22. package/load/merge.ts +136 -0
  23. package/load/normalize.ts +73 -0
  24. package/load/paths.ts +32 -0
  25. package/load/resolve-default.ts +43 -0
  26. package/load/shape-guards.test.ts +74 -0
  27. package/load/shape-guards.ts +42 -0
  28. package/messages.ts +185 -0
  29. package/outcomes/collectors/directory-path.test.ts +64 -0
  30. package/outcomes/collectors/directory-path.ts +40 -0
  31. package/outcomes/collectors/index.ts +21 -0
  32. package/outcomes/collectors/tool-call.test.ts +110 -0
  33. package/outcomes/collectors/tool-call.ts +63 -0
  34. package/outcomes/collectors/transcript-path.test.ts +70 -0
  35. package/outcomes/collectors/transcript-path.ts +53 -0
  36. package/outcomes/collectors/union.test.ts +59 -0
  37. package/outcomes/collectors/union.ts +55 -0
  38. package/outcomes/collectors/url.test.ts +67 -0
  39. package/outcomes/collectors/url.ts +45 -0
  40. package/outcomes/collectors/workspace-diff.test.ts +107 -0
  41. package/outcomes/collectors/workspace-diff.ts +123 -0
  42. package/outcomes/git-commit.test.ts +194 -0
  43. package/outcomes/git-commit.ts +192 -0
  44. package/outcomes/index.ts +22 -0
  45. package/outcomes/parsers/index.ts +11 -0
  46. package/outcomes/parsers/json-body.test.ts +80 -0
  47. package/outcomes/parsers/json-body.ts +50 -0
  48. package/outcomes/side-effect.ts +26 -0
  49. package/output-spec.ts +170 -0
  50. package/output.ts +98 -0
  51. package/package.json +83 -0
  52. package/preview.ts +120 -0
  53. package/routing.ts +79 -0
  54. package/runner/chain-advance.ts +185 -0
  55. package/runner/index.ts +7 -0
  56. package/runner/runner.ts +356 -0
  57. package/runner/script-stage.ts +240 -0
  58. package/runner/stage-lifecycle.ts +447 -0
  59. package/sessions/extraction.ts +297 -0
  60. package/sessions/index.ts +7 -0
  61. package/sessions/sessions.ts +269 -0
  62. package/sessions/spawn.ts +135 -0
  63. package/state/index.ts +27 -0
  64. package/state/paths.ts +46 -0
  65. package/state/reads.ts +190 -0
  66. package/state/state.ts +115 -0
  67. package/state/writes.ts +58 -0
  68. package/transcript.ts +156 -0
  69. package/triggers.ts +27 -0
  70. package/typebox-adapter.ts +48 -0
  71. package/types.ts +237 -0
  72. package/validate-output.ts +120 -0
  73. package/validate-workflow.ts +491 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Internal utilities shared across the rpiv-workflow package.
3
+ *
4
+ * Not part of the public surface — not re-exported from `index.ts`. If a
5
+ * helper graduates into the documented authoring or embedding contract,
6
+ * move it out of here and into the appropriate domain module.
7
+ */
8
+
9
+ import { isAbsolute, join } from "node:path";
10
+ import type { StageDef } from "./api.js";
11
+ import type { Artifact } from "./handle.js";
12
+ import type { RunState } from "./types.js";
13
+
14
+ /** Exhaustiveness guard for discriminated-union switches. */
15
+ export function assertNever(value: never): never {
16
+ throw new Error(`assertNever: unreachable value ${String(value)}`);
17
+ }
18
+
19
+ /**
20
+ * Canonical accessor for "the primary artifact the chain is currently
21
+ * carrying." Reads the rolling slot maintained by the runner —
22
+ * produces stages update it on success; side-effect stages leave it
23
+ * alone. Replaces the load-bearing single-string artifact_path mirror
24
+ * from the pre-collector shape.
25
+ */
26
+ export function currentPrimaryArtifact(state: RunState): Artifact | undefined {
27
+ return state.primaryArtifact;
28
+ }
29
+
30
+ /**
31
+ * Resolve the `state.named` key a produces stage appends its `Output`
32
+ * envelope onto. Two layers of fallback, in priority order:
33
+ * 1. `stage.outcome?.name` — categorical name carried by the outcome.
34
+ * 2. The stage's record key — always defined.
35
+ *
36
+ * Single source of truth for the key derivation so the skill-stage path
37
+ * and the script-stage path stay in lockstep, and so `validateWorkflow`
38
+ * can compute the same key set at load time.
39
+ */
40
+ export function resolvePublishName(def: StageDef, stageName: string): string {
41
+ return def.outcome?.name ?? stageName;
42
+ }
43
+
44
+ /**
45
+ * Race a promise against `ms`. The inner promise is NOT cancelled — Pi's
46
+ * `ctx.waitForIdle()` has no abort signal today; the dangling promise becomes
47
+ * inert when the next stage's `newSession` replaces the ctx.
48
+ */
49
+ export async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
50
+ let timer: ReturnType<typeof setTimeout> | undefined;
51
+ const timeout = new Promise<never>((_, reject) => {
52
+ timer = setTimeout(() => reject(new Error(message)), ms);
53
+ });
54
+ try {
55
+ return await Promise.race([promise, timeout]);
56
+ } finally {
57
+ if (timer !== undefined) clearTimeout(timer);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve `p` against `cwd`. Returns `p` unchanged if it is already absolute;
63
+ * otherwise joins `cwd + p` with the platform path separator. Uses
64
+ * `path.isAbsolute` so Windows drive-letter paths are handled correctly
65
+ * (POSIX-only `startsWith("/")` checks miss `C:\...`).
66
+ */
67
+ export function resolveUnderCwd(cwd: string, p: string): string {
68
+ return isAbsolute(p) ? p : join(cwd, p);
69
+ }
package/internal.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Test-only / framework-internal surface.
3
+ *
4
+ * Exports below are NOT part of the package's authoring or embedding
5
+ * contract. They exist solely for:
6
+ *
7
+ * - rpiv-pi's `[I3]` regression test that exercises `recordStage`
8
+ * directly (asserting the JSONL append + stageNumber monotonicity).
9
+ * - `test/setup.ts`'s per-worker `beforeEach` reset of module-level
10
+ * singleton state (the built-in workflow registry, the jiti import
11
+ * cache).
12
+ *
13
+ * Anything that consumers can rely on for production work lives on
14
+ * `./index.js`. This file is reachable as
15
+ * `@juicesharp/rpiv-workflow/internal` via the package's `exports`
16
+ * field — keep that path stable so the rpiv-pi test + repo-wide
17
+ * setup don't break.
18
+ *
19
+ * Adding a new export here is a signal you have test-coupling to
20
+ * production state. Reach for it sparingly; prefer making the
21
+ * production module itself idempotent across `beforeEach` resets.
22
+ */
23
+
24
+ export { recordStage } from "./audit.js";
25
+ export { __resetBuiltIns, getBuiltIns } from "./built-ins.js";
26
+ export { __resetLifecycleRegistry } from "./lifecycle.js";
27
+ export { __resetLoadCache } from "./load/cache.js";
package/layers.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared layer-vocabulary for the workflow loader + validator.
3
+ *
4
+ * Lives in its own module so `load.ts` (loader / merge) and `validate-workflow.ts`
5
+ * (issue attribution) can both reference the same union without a circular
6
+ * import. `load.ts` depends on `validate-workflow.ts` for `validateWorkflow`, so
7
+ * declaring `ConfigLayer` here keeps the dependency direction strict and
8
+ * eliminates the silent-drift risk of two parallel string-literal unions.
9
+ */
10
+
11
+ import { assertNever } from "./internal-utils.js";
12
+
13
+ export type ConfigLayer = "built-in" | "user" | "project";
14
+
15
+ /**
16
+ * Single source for layer → display string. Wrap every interpolation site
17
+ * (`${layer}` in template strings, banner builders, issue prefixes) in this
18
+ * helper so adding a new `ConfigLayer` value surfaces as a compile error at
19
+ * every consumer, not just at the loader's switch. The current display
20
+ * form is the layer name verbatim — keep that invariant if you change it.
21
+ */
22
+ export function renderConfigLayer(layer: ConfigLayer): string {
23
+ switch (layer) {
24
+ case "built-in":
25
+ return "built-in";
26
+ case "user":
27
+ return "user";
28
+ case "project":
29
+ return "project";
30
+ default:
31
+ return assertNever(layer);
32
+ }
33
+ }
package/lifecycle.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Lifecycle callbacks — typed observation surface for in-flight runs.
3
+ *
4
+ * Two subscription paths converge through the same `LifecycleListeners`
5
+ * shape:
6
+ *
7
+ * - **Per-call** — embedders set `RunWorkflowOptions.lifecycle?` when
8
+ * driving `runWorkflow` directly.
9
+ * - **Global** — sibling extensions (rpiv-pi widget, future metrics)
10
+ * call `registerLifecycle(listeners)` at extension load. Returns a
11
+ * disposer; multiple registrations fan out per event.
12
+ *
13
+ * Every event fires AFTER the corresponding JSONL row lands on disk
14
+ * (onStageEnd / onStageError / onRoute / onFanoutUnitEnd). Listeners
15
+ * thus see consistent state when they read past rows via
16
+ * `readLastStage` / `readAllStages`.
17
+ *
18
+ * Listener throws are caught + logged via `ctx.ui.notify(..., "warning")`
19
+ * and never halt the run — listeners are observers, not gates. Other
20
+ * registered listeners still fire normally.
21
+ *
22
+ * Pre-flight rejections (`workflow.start` not declared, continue-policy
23
+ * without a host) fire ZERO lifecycle events — the run never acquired
24
+ * a `runId` and lifecycle events would deliver an incomplete context.
25
+ * The `RunWorkflowResult` envelope still surfaces the error to the
26
+ * caller through the existing path.
27
+ */
28
+
29
+ import type { FanoutUnit } from "./api.js";
30
+ import { MSG_LIFECYCLE_THREW } from "./messages.js";
31
+ import type { Output } from "./output.js";
32
+ import type { RunWorkflowResult } from "./runner/runner.js";
33
+ import type { RunTrigger } from "./triggers.js";
34
+ import type { RunState } from "./types.js";
35
+
36
+ /**
37
+ * Run-scoped context shared by every lifecycle callback. Mirrors the
38
+ * shape `EdgeContext` and `FanoutContext` already use (frozen identity
39
+ * + `Readonly<RunState>`) so listeners reconstruct "where am I" without
40
+ * widening the per-event payload.
41
+ */
42
+ export interface LifecycleContext {
43
+ cwd: string;
44
+ runId: string;
45
+ workflow: string;
46
+ totalStages: number;
47
+ /** What triggered this run; defaulted at `runWorkflow` entry if `options.trigger` was omitted. */
48
+ trigger: RunTrigger;
49
+ state: Readonly<RunState>;
50
+ }
51
+
52
+ /**
53
+ * Projection of a stage as observed by a listener — the runner's
54
+ * internal resolved view minus its `def` field (which leaks DSL
55
+ * internals). Distinct from `StageDef` (authored shape) and
56
+ * `WorkflowStage` (JSONL row).
57
+ *
58
+ * Discriminated on `kind`:
59
+ * - `"skill"` — stage dispatches a Pi skill body (`/skill:<skill>`).
60
+ * - `"script"` — stage runs a TS function; no skill body.
61
+ */
62
+ export type StageRef =
63
+ | { kind: "skill"; name: string; stageNumber: number; skill: string }
64
+ | { kind: "script"; name: string; stageNumber: number };
65
+
66
+ /**
67
+ * Opt-in lifecycle listeners. Single subscriber per slot per bundle —
68
+ * fan-out is a userland wrapper. Every callback may return a Promise;
69
+ * the runner awaits it before advancing (back-pressure for free).
70
+ *
71
+ * Every event fires AFTER its corresponding JSONL row lands on disk
72
+ * (onStageEnd / onStageError / onRoute / onFanoutUnitEnd), so a
73
+ * listener that calls `readLastStage(cwd, ctx.runId)` from inside the
74
+ * callback is guaranteed to observe the just-recorded row.
75
+ *
76
+ * Listener throws are caught + surfaced via `ctx.ui.notify(..., "warning")`
77
+ * and never halt the run — listeners are observers, not gates.
78
+ *
79
+ * @example Per-call observation from an embedder
80
+ * ```ts
81
+ * import { runWorkflow, type LifecycleListeners } from "@juicesharp/rpiv-workflow";
82
+ *
83
+ * const listeners: LifecycleListeners = {
84
+ * onWorkflowStart: (ctx) =>
85
+ * console.log(`▶ ${ctx.workflow} (${ctx.totalStages} stages) [${ctx.trigger.kind}]`),
86
+ * onStageStart: (stage) => console.log(` → ${stage.stageNumber}. ${stage.name}`),
87
+ * onStageEnd: (stage, output) => console.log(` ✓ ${stage.name}: ${output.kind}`),
88
+ * onStageRetry: (stage, attempt) => console.warn(` ⟲ ${stage.name} retry #${attempt}`),
89
+ * onStageError: (stage, error) => console.error(` ✗ ${stage.name}: ${error}`),
90
+ * onRoute: (from, to) => console.log(` ↪ ${from.name} → ${to}`),
91
+ * onFanoutStart: (stage, units) => console.log(` ⇉ ${stage.name} × ${units.length}`),
92
+ * onFanoutUnitEnd: (stage, _u, i) => console.log(` · ${stage.name} #${i}`),
93
+ * onWorkflowEnd: (result) =>
94
+ * console.log(result.success ? "✓ done" : `✗ ${result.error ?? "halted"}`),
95
+ * };
96
+ *
97
+ * await runWorkflow({ workflow, input, host, lifecycle: listeners });
98
+ * ```
99
+ *
100
+ * @example Cross-package fan-out via `registerLifecycle`
101
+ * ```ts
102
+ * import { registerLifecycle } from "@juicesharp/rpiv-workflow";
103
+ *
104
+ * const dispose = registerLifecycle({
105
+ * onWorkflowStart: (ctx) => widget.open(ctx.runId, ctx.workflow),
106
+ * onStageEnd: (stage, _o, ctx) => widget.markDone(ctx.runId, stage.name),
107
+ * });
108
+ * ```
109
+ */
110
+ export interface LifecycleListeners {
111
+ /** After JSONL header lands; before the start stage's preflight. */
112
+ onWorkflowStart?(ctx: LifecycleContext): void | Promise<void>;
113
+
114
+ /** After preflight + skill check; before the Pi session opens (or `run()` is called for script stages). */
115
+ onStageStart?(stage: StageRef, ctx: LifecycleContext): void | Promise<void>;
116
+
117
+ /** After the stage's success row lands in JSONL. `output` is the validated envelope. */
118
+ onStageEnd?(stage: StageRef, output: Output, ctx: LifecycleContext): void | Promise<void>;
119
+
120
+ /** After `outputSchema` rejection, before the runner re-prompts. `attempt` is 1-based. */
121
+ onStageRetry?(stage: StageRef, attempt: number, ctx: LifecycleContext): void | Promise<void>;
122
+
123
+ /** After the stage's "failed"/"aborted" row lands in JSONL. Terminal for the run. */
124
+ onStageError?(stage: StageRef, error: string, ctx: LifecycleContext): void | Promise<void>;
125
+
126
+ /** After an `EdgeFn` picks and its routing-decision row lands. `to` may be the `STOP` sentinel literal `"stop"`. */
127
+ onRoute?(from: StageRef, to: string, ctx: LifecycleContext): void | Promise<void>;
128
+
129
+ /** After the `FanoutFn` returns ≥1 units; before unit 1's session opens. */
130
+ onFanoutStart?(stage: StageRef, units: readonly FanoutUnit[], ctx: LifecycleContext): void | Promise<void>;
131
+
132
+ /** Per-unit, before the unit's session opens. `unitIndex` is 1-based. */
133
+ onFanoutUnitStart?(
134
+ stage: StageRef,
135
+ unit: FanoutUnit,
136
+ unitIndex: number,
137
+ ctx: LifecycleContext,
138
+ ): void | Promise<void>;
139
+
140
+ /** Per-unit, after the unit's JSONL row lands. */
141
+ onFanoutUnitEnd?(stage: StageRef, unit: FanoutUnit, unitIndex: number, ctx: LifecycleContext): void | Promise<void>;
142
+
143
+ /** Last call — `result` is the same envelope `runWorkflow` returns. */
144
+ onWorkflowEnd?(result: RunWorkflowResult, ctx: LifecycleContext): void | Promise<void>;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Internal — dispatcher (consumed by the runner; not exported publicly)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /** Subset of `WorkflowContext` the dispatcher needs for throw-safe logging. */
152
+ export interface DispatchHost {
153
+ ui: { notify(message: string, level?: "info" | "warning" | "error"): void };
154
+ }
155
+
156
+ /**
157
+ * Fan-out + throw-safe invoker for one event across every registered
158
+ * listener bundle. Constructed once per `runWorkflow` call and threaded
159
+ * through `RunContext` so every firing site uses the same instance.
160
+ *
161
+ * Snapshot semantics: `collectBundles` is called per `fire(...)`, so a
162
+ * registration made mid-event (`registerLifecycle` from inside a
163
+ * callback) applies to subsequent events but not the in-flight one.
164
+ *
165
+ * Sequential await: bundles run in registration order; the per-call
166
+ * bundle (when present) fires after every globally-registered bundle.
167
+ * `await` between them gives listeners back-pressure for free.
168
+ */
169
+ export class LifecycleDispatcher {
170
+ constructor(private readonly perCall: LifecycleListeners | undefined) {}
171
+
172
+ async fire<E extends keyof LifecycleListeners>(
173
+ host: DispatchHost,
174
+ event: E,
175
+ ...args: Parameters<NonNullable<LifecycleListeners[E]>>
176
+ ): Promise<void> {
177
+ for (const bundle of collectBundles(this.perCall)) {
178
+ const fn = bundle[event];
179
+ if (!fn) continue;
180
+ try {
181
+ await (fn as (...a: unknown[]) => unknown)(...(args as unknown[]));
182
+ } catch (e) {
183
+ const reason = e instanceof Error ? e.message : String(e);
184
+ host.ui.notify(MSG_LIFECYCLE_THREW(event, reason), "warning");
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Global registry — cross-package fan-out
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Anchored on a `Symbol.for` slot so a duplicate module load (extension +
196
+ * sibling cross-package resolution) shares one registry. Same pattern
197
+ * `registerBuiltIns` uses for the workflow registry.
198
+ */
199
+ const REGISTRY_KEY = Symbol.for("@juicesharp/rpiv-workflow:lifecycle");
200
+
201
+ type Global = Record<symbol, unknown>;
202
+
203
+ function getRegistry(): LifecycleListeners[] {
204
+ const g = globalThis as unknown as Global;
205
+ let registry = g[REGISTRY_KEY] as LifecycleListeners[] | undefined;
206
+ if (!registry) {
207
+ registry = [];
208
+ g[REGISTRY_KEY] = registry;
209
+ }
210
+ return registry;
211
+ }
212
+
213
+ /**
214
+ * Register a cross-package lifecycle-listener bundle. Returns a disposer
215
+ * that removes it. Multiple registrations coexist — every fired event walks
216
+ * the full registry in registration order, then the per-call bundle.
217
+ *
218
+ * Snapshot semantics: each `fire(...)` call observes the registry as it
219
+ * stands at that instant. A registration made mid-event applies to
220
+ * subsequent events but not the in-flight one.
221
+ *
222
+ * Throws from listeners are caught + logged via `ctx.ui.notify(..., "warning")`
223
+ * and never halt the run. One listener bug never affects other listeners
224
+ * or the run itself.
225
+ */
226
+ export function registerLifecycle(listeners: LifecycleListeners): () => void {
227
+ const registry = getRegistry();
228
+ registry.push(listeners);
229
+ return () => {
230
+ const idx = registry.indexOf(listeners);
231
+ if (idx >= 0) registry.splice(idx, 1);
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Test reset — wired into the repo-wide test setup so cross-test
237
+ * registration leaks don't bias the next case.
238
+ */
239
+ export function __resetLifecycleRegistry(): void {
240
+ getRegistry().length = 0;
241
+ }
242
+
243
+ /**
244
+ * Globally-registered bundles fire first (in registration order), then
245
+ * the per-call bundle. Snapshot at fire time — a registration made
246
+ * inside a listener body applies to subsequent events, not the
247
+ * in-flight one.
248
+ */
249
+ function collectBundles(perCall: LifecycleListeners | undefined): readonly LifecycleListeners[] {
250
+ const global = getRegistry();
251
+ return perCall ? [...global, perCall] : [...global];
252
+ }
253
+
254
+ /** Build a `LifecycleContext` from the runner's per-run identity. */
255
+ export function buildLifecycleContext(args: {
256
+ cwd: string;
257
+ runId: string;
258
+ workflow: string;
259
+ totalStages: number;
260
+ trigger: RunTrigger;
261
+ state: Readonly<RunState>;
262
+ }): LifecycleContext {
263
+ return args;
264
+ }
265
+
266
+ /** Build the `"skill"` arm of `StageRef`. */
267
+ export function skillStageRef(name: string, stageNumber: number, skill: string): StageRef {
268
+ return { kind: "skill", name, stageNumber, skill };
269
+ }
270
+
271
+ /** Build the `"script"` arm of `StageRef` — skillless TS stages. */
272
+ export function scriptStageRef(name: string, stageNumber: number): StageRef {
273
+ return { kind: "script", name, stageNumber };
274
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Direct tests for the mtime-keyed jiti import cache. Exercises both the
3
+ * cache-miss path (fresh `jiti.import`) and the cache-hit path (returns
4
+ * the stored parsed value without re-evaluating top-level code).
5
+ *
6
+ * `loadWorkflows` integration tests cover the cache miss every time
7
+ * (each `loadWorkflows` call hits the file once); the cache-hit branch
8
+ * only fires when the same path is imported twice within one
9
+ * `__resetLoadCache()` boundary, which `test/setup.ts:beforeEach`
10
+ * normally prevents. These tests opt out of the global reset by calling
11
+ * `cachedImport` directly.
12
+ */
13
+
14
+ import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+ import { __resetLoadCache, cachedImport } from "./cache.js";
19
+
20
+ const TMP_ROOT = mkdtempSync(join(tmpdir(), "rpiv-workflow-cache-"));
21
+
22
+ beforeEach(() => {
23
+ __resetLoadCache();
24
+ });
25
+
26
+ afterEach(() => {
27
+ __resetLoadCache();
28
+ });
29
+
30
+ // One-shot cleanup so we don't leak the tmp directory.
31
+ process.on("exit", () => {
32
+ rmSync(TMP_ROOT, { recursive: true, force: true });
33
+ });
34
+
35
+ const writeFixture = (name: string, body: string): string => {
36
+ const path = join(TMP_ROOT, name);
37
+ writeFileSync(path, body, "utf-8");
38
+ return path;
39
+ };
40
+
41
+ describe("cachedImport", () => {
42
+ it("returns the parsed default export on first call (cache miss)", async () => {
43
+ const path = writeFixture("miss.ts", "export default 'first';\n");
44
+ const value = await cachedImport(path);
45
+ expect(value).toBe("first");
46
+ });
47
+
48
+ it("returns the cached value when mtime is unchanged (cache hit)", async () => {
49
+ const path = writeFixture("hit.ts", "export default { v: 1 };\n");
50
+ const first = await cachedImport(path);
51
+ const second = await cachedImport(path);
52
+ // Same reference — proves the second call short-circuited via the cache
53
+ // (a re-evaluated `jiti.import` would return a freshly-allocated object).
54
+ expect(second).toBe(first);
55
+ });
56
+
57
+ it("re-imports when mtime advances (edit invalidates the cache)", async () => {
58
+ const path = writeFixture("edit.ts", "export default 'v1';\n");
59
+ const first = await cachedImport(path);
60
+ expect(first).toBe("v1");
61
+
62
+ // Rewrite + bump mtime far enough that the cache key changes even on
63
+ // coarse-mtime filesystems.
64
+ writeFileSync(path, "export default 'v2';\n", "utf-8");
65
+ const future = new Date(Date.now() + 60_000);
66
+ utimesSync(path, future, future);
67
+
68
+ const second = await cachedImport(path);
69
+ expect(second).toBe("v2");
70
+ });
71
+
72
+ it("__resetLoadCache forces a fresh re-import on the next call", async () => {
73
+ const path = writeFixture("reset.ts", "export default { tag: 'x' };\n");
74
+ const first = await cachedImport(path);
75
+ __resetLoadCache();
76
+ const second = await cachedImport(path);
77
+ // Different references prove the reset cleared the entry — jiti returned
78
+ // a freshly-evaluated object.
79
+ expect(second).not.toBe(first);
80
+ expect(second).toEqual(first);
81
+ });
82
+ });
package/load/cache.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * mtime-keyed jiti import cache. jiti's own caches are disabled so `/reload`
3
+ * picks up edits without restart; this wrapper layers a stat-driven cache
4
+ * on top so unchanged overlays don't re-evaluate top-level code on every
5
+ * `/wf` invocation.
6
+ *
7
+ * The `jiti` instance lives here so the cache and the underlying importer
8
+ * co-locate. Other loader modules import `cachedImport` — none touches
9
+ * `jiti` directly.
10
+ *
11
+ * The cache does not invalidate on file deletion — a stale entry for a
12
+ * deleted overlay sits dormant (the enumerator never passes it back to
13
+ * `cachedImport`). The cache resets on `__resetLoadCache()` (wired into
14
+ * `test/setup.ts` `beforeEach`) and on process exit.
15
+ */
16
+
17
+ import { statSync } from "node:fs";
18
+ import { createJiti } from "jiti";
19
+
20
+ const jiti = createJiti(import.meta.url, {
21
+ // Bypass jiti's module cache so /reload picks up edits without restart.
22
+ moduleCache: false,
23
+ fsCache: false,
24
+ });
25
+
26
+ const overlayCache = new Map<string, { mtimeMs: number; parsed: unknown }>();
27
+
28
+ export async function cachedImport(path: string): Promise<unknown> {
29
+ const stat = statSync(path);
30
+ const cached = overlayCache.get(path);
31
+ if (cached && cached.mtimeMs === stat.mtimeMs) return cached.parsed;
32
+ const value = await jiti.import(path, { default: true });
33
+ overlayCache.set(path, { mtimeMs: stat.mtimeMs, parsed: value });
34
+ return value;
35
+ }
36
+
37
+ /** Test-only reset. Wired into `test/setup.ts` `beforeEach`. */
38
+ export function __resetLoadCache(): void {
39
+ overlayCache.clear();
40
+ }
package/load/index.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * jiti-based loader for user-authored workflows.
3
+ *
4
+ * Layered merge: `built-in` ← `user` ← `project`. Within each non-built-in
5
+ * layer, pack files merge first (alpha-sorted filename), then the
6
+ * config file — so the file the user wrote by hand wins over any packs
7
+ * they installed.
8
+ *
9
+ * Paths (per layer):
10
+ * user — config `~/.config/rpiv-workflow/workflows.config.ts`
11
+ * packs `~/.config/rpiv-workflow/workflows/*.ts`
12
+ * project — config `<cwd>/.rpiv-workflow/workflows.config.ts`
13
+ * packs `<cwd>/.rpiv-workflow/workflows/*.ts`
14
+ *
15
+ * Config file — accepts three default-export shapes:
16
+ * 1. A single `Workflow` — single-entry namespace
17
+ * 2. `Workflow[]` — multi-entry, default required if > 1
18
+ * 3. `{ workflows, default? }` — full envelope, explicit default
19
+ *
20
+ * Pack file — accepts only `Workflow | Workflow[]`. The envelope form
21
+ * is rejected because `default` lives in the config file (one source of
22
+ * truth per layer).
23
+ *
24
+ * `default` cascades layer-by-layer (project config > user config >
25
+ * first registered workflow in insertion order). When no workflows are
26
+ * registered at all, `default` is `undefined` and `command.ts` surfaces
27
+ * a "no workflows registered" notify instead of running anything. Within
28
+ * a layer only the config file can set `default`.
29
+ *
30
+ * jiti loads `.ts` directly — no build step required of users. Loader
31
+ * failures (file throws on import, exports the wrong shape) are captured as
32
+ * `LoadIssue`s; the loader itself never throws to its caller.
33
+ *
34
+ * SECURITY NOTE — `jiti.import` synchronously evaluates every overlay
35
+ * file's top-level code on first load and on every edit (mtime-driven
36
+ * invalidation via `cache.ts`). The threat boundary is the same as
37
+ * `npm install` (post-install scripts), `tsx some-script.ts`, or any
38
+ * tool that respects `<cwd>` configuration: Pi already operates in a
39
+ * context that implicitly trusts the current working directory. Users
40
+ * running Pi in a freshly-cloned untrusted repo should diff
41
+ * `.rpiv-workflow/workflows.config.ts` and `.rpiv-workflow/workflows/*.ts`
42
+ * (the config file + pack files) before running `/wf`.
43
+ *
44
+ * Module map:
45
+ * ./paths.ts — OverlayPaths + per-layer path helpers
46
+ * ./shape-guards.ts — isWorkflow, isEnvelope, describe, formatError
47
+ * ./normalize.ts — normalizeDefaultExport + NormalizeResult
48
+ * ./merge.ts — LoadAccumulator, LayerOutcome, loadLayer, mergeOverlay, loadError
49
+ * ./resolve-default.ts — resolveDefault (first-workflow fallback)
50
+ * ./cache.ts — mtime-keyed jiti import cache + __resetLoadCache
51
+ */
52
+
53
+ import type { Workflow } from "../api.js";
54
+ import { getBuiltIns } from "../built-ins.js";
55
+ import type { ConfigLayer } from "../layers.js";
56
+ import { validateWorkflow, type WorkflowValidationIssue } from "../validate-workflow.js";
57
+ import { type LoadAccumulator, loadLayer } from "./merge.js";
58
+ import { projectOverlayPaths, userOverlayPaths } from "./paths.js";
59
+ import { resolveDefault } from "./resolve-default.js";
60
+
61
+ // ===========================================================================
62
+ // Public types
63
+ // ===========================================================================
64
+
65
+ export type { ConfigLayer } from "../layers.js";
66
+ export { __resetLoadCache } from "./cache.js";
67
+ export type { OverlayPaths } from "./paths.js";
68
+ export { projectOverlayPaths, userOverlayPaths } from "./paths.js";
69
+
70
+ export interface LoadIssue {
71
+ kind: "load";
72
+ layer: ConfigLayer;
73
+ path?: string;
74
+ severity: "error" | "warning";
75
+ message: string;
76
+ }
77
+
78
+ export type Issue = LoadIssue | (WorkflowValidationIssue & { kind: "validation"; layer: ConfigLayer; path?: string });
79
+
80
+ export interface LoadedWorkflows {
81
+ workflows: readonly Workflow[];
82
+ /**
83
+ * Resolved default workflow name. `undefined` when no layer registered any
84
+ * workflows — consumers must handle this (see `command.ts`'s
85
+ * "no workflows registered" path).
86
+ */
87
+ default: string | undefined;
88
+ /** Which layer each merged workflow name came from. */
89
+ workflowSources: ReadonlyMap<string, ConfigLayer>;
90
+ /** Every layer that registered at least one workflow, low-to-high. */
91
+ layers: readonly ConfigLayer[];
92
+ /** Aggregated load + validation issues. Errors block the runner; warnings are advisory. */
93
+ issues: readonly Issue[];
94
+ }
95
+
96
+ // ===========================================================================
97
+ // Public lookup helpers
98
+ // ===========================================================================
99
+
100
+ /**
101
+ * Lookup a workflow by name in a merged `LoadedWorkflows`. Anticipates
102
+ * the "rerun by name" / `listRuns` past-runs API that will share the
103
+ * lookup; consolidated here so future callers don't reach back into
104
+ * `loaded.workflows.find(...)` ad-hoc.
105
+ */
106
+ export function findWorkflow(loaded: LoadedWorkflows, name: string): Workflow | undefined {
107
+ return loaded.workflows.find((w) => w.name === name);
108
+ }
109
+
110
+ // ===========================================================================
111
+ // Orchestrator
112
+ // ===========================================================================
113
+
114
+ /**
115
+ * Load every active layer, merge by workflow name, validate, and return the
116
+ * resolved set. Never throws — load + validation errors flow through `issues`.
117
+ */
118
+ export async function loadWorkflows(cwd: string): Promise<LoadedWorkflows> {
119
+ const acc: LoadAccumulator = {
120
+ issues: [],
121
+ workflowMap: new Map(),
122
+ sources: new Map(),
123
+ sourcePaths: new Map(),
124
+ };
125
+ const layers: ConfigLayer[] = getBuiltIns().length > 0 ? ["built-in"] : [];
126
+
127
+ for (const w of getBuiltIns()) {
128
+ acc.workflowMap.set(w.name, w);
129
+ acc.sources.set(w.name, "built-in");
130
+ acc.sourcePaths.set(w.name, undefined);
131
+ }
132
+
133
+ const userOutcome = await loadLayer(userOverlayPaths(), "user", acc);
134
+ if (userOutcome.contributed) layers.push("user");
135
+
136
+ const projectOutcome = await loadLayer(projectOverlayPaths(cwd), "project", acc);
137
+ if (projectOutcome.contributed) layers.push("project");
138
+
139
+ // Validate every merged workflow once. Validation runs even on built-in so
140
+ // that a future built-in regression surfaces in the same channel as user
141
+ // errors. Each issue is attributed to the exact file the surviving workflow
142
+ // came from (pack or config) so `/wf` previews can render
143
+ // `[<layer> config (<path>)] workflow "X": ...` errors.
144
+ for (const w of acc.workflowMap.values()) {
145
+ const layer = acc.sources.get(w.name) ?? "built-in";
146
+ const path = acc.sourcePaths.get(w.name);
147
+ for (const v of validateWorkflow(w)) acc.issues.push({ ...v, kind: "validation", layer, path });
148
+ }
149
+
150
+ const defaultName = resolveDefault(projectOutcome.configDefault, userOutcome.configDefault, acc);
151
+
152
+ return {
153
+ workflows: [...acc.workflowMap.values()],
154
+ default: defaultName,
155
+ workflowSources: acc.sources,
156
+ layers,
157
+ issues: acc.issues,
158
+ };
159
+ }