@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
package/load/merge.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Per-layer + per-file loading and merge. Owns the `LoadAccumulator`
3
+ * struct (mutable bag of state threaded through the layer/file/merge
4
+ * helpers) and the `LayerOutcome` return shape.
5
+ *
6
+ * `loadLayer` walks the packs directory then the config file,
7
+ * calling `mergeOverlay` for each successful parse. `mergeOverlay`
8
+ * writes into the accumulator's maps in place — the config file's
9
+ * workflows win over packs of the same name because the config pass
10
+ * runs second.
11
+ *
12
+ * Load-error issues construct via `loadError` so the `Issue` shape is
13
+ * centralised.
14
+ */
15
+
16
+ import { existsSync, readdirSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import type { Workflow } from "../api.js";
19
+ import type { ConfigLayer } from "../layers.js";
20
+ import { cachedImport } from "./cache.js";
21
+ import type { Issue } from "./index.js";
22
+ import { type FileKind, normalizeDefaultExport, type ParsedConfig } from "./normalize.js";
23
+ import type { OverlayPaths } from "./paths.js";
24
+ import { formatError } from "./shape-guards.js";
25
+
26
+ /**
27
+ * Mutable bag of state threaded through `loadLayer` → `loadOverlayFile`
28
+ * → `mergeOverlay`. Each helper writes into `acc.issues` /
29
+ * `acc.workflowMap` / `acc.sources` / `acc.sourcePaths` in place;
30
+ * `loadWorkflows` reads them at the end to project the public
31
+ * `LoadedWorkflows` envelope.
32
+ *
33
+ * Lives in a struct so future loader features add fields here rather
34
+ * than threading another mutable parameter through three call layers.
35
+ */
36
+ export interface LoadAccumulator {
37
+ issues: Issue[];
38
+ workflowMap: Map<string, Workflow>;
39
+ sources: Map<string, ConfigLayer>;
40
+ sourcePaths: Map<string, string | undefined>;
41
+ }
42
+
43
+ /**
44
+ * What a per-layer load returns to the orchestrator. `contributed`
45
+ * controls the `LoadedWorkflows.layers` banner; `configDefault`
46
+ * feeds `resolveDefault` (pack files don't set defaults — see
47
+ * `normalizeDefaultExport`'s pack hard-reject).
48
+ */
49
+ export interface LayerOutcome {
50
+ contributed: boolean;
51
+ configDefault: string | undefined;
52
+ }
53
+
54
+ export function loadError(acc: LoadAccumulator, layer: ConfigLayer, path: string | undefined, message: string): void {
55
+ acc.issues.push({ kind: "load", layer, path, severity: "error", message });
56
+ }
57
+
58
+ /**
59
+ * Load one layer's packs (alpha-sorted) then its config file, merging
60
+ * into the accumulator in that order so the config file's workflows win
61
+ * over packs of the same name. The returned `LayerOutcome.configDefault`
62
+ * carries the config file's `default` field (or `undefined`) — pack
63
+ * `default` fields are rejected at normalisation, so they never participate
64
+ * in default resolution.
65
+ *
66
+ * `LayerOutcome.contributed` is `false` only when neither the config
67
+ * file nor any pack existed; that signals to `loadWorkflows` not to
68
+ * append the layer to the `layers` banner.
69
+ */
70
+ export async function loadLayer(paths: OverlayPaths, layer: ConfigLayer, acc: LoadAccumulator): Promise<LayerOutcome> {
71
+ let contributed = false;
72
+ let configDefault: string | undefined;
73
+
74
+ for (const packPath of enumeratePacks(paths.packsDir)) {
75
+ const parsed = await loadOverlayFile(packPath, layer, acc, "pack");
76
+ if (!parsed) continue;
77
+ mergeOverlay(parsed, layer, packPath, acc);
78
+ contributed = true;
79
+ }
80
+
81
+ if (existsSync(paths.configFile)) {
82
+ const configParsed = await loadOverlayFile(paths.configFile, layer, acc, "config");
83
+ if (configParsed) {
84
+ mergeOverlay(configParsed, layer, paths.configFile, acc);
85
+ configDefault = configParsed.default;
86
+ contributed = true;
87
+ }
88
+ }
89
+
90
+ return { contributed, configDefault };
91
+ }
92
+
93
+ /** Alpha-sorted `*.ts` files directly under `dir`. Empty array if `dir` doesn't exist. */
94
+ function enumeratePacks(dir: string): string[] {
95
+ if (!existsSync(dir)) return [];
96
+ let entries: string[];
97
+ try {
98
+ entries = readdirSync(dir);
99
+ } catch {
100
+ return [];
101
+ }
102
+ return entries
103
+ .filter((name) => name.endsWith(".ts"))
104
+ .sort()
105
+ .map((name) => join(dir, name));
106
+ }
107
+
108
+ async function loadOverlayFile(
109
+ path: string,
110
+ layer: ConfigLayer,
111
+ acc: LoadAccumulator,
112
+ kind: FileKind,
113
+ ): Promise<ParsedConfig | undefined> {
114
+ let raw: unknown;
115
+ try {
116
+ raw = await cachedImport(path);
117
+ } catch (e) {
118
+ loadError(acc, layer, path, `failed to import ${path}: ${formatError(e)}`);
119
+ return undefined;
120
+ }
121
+
122
+ const parsed = normalizeDefaultExport(raw, kind);
123
+ if (parsed.kind === "err") {
124
+ loadError(acc, layer, path, parsed.error);
125
+ return undefined;
126
+ }
127
+ return parsed.value;
128
+ }
129
+
130
+ function mergeOverlay(parsed: ParsedConfig, layer: ConfigLayer, path: string, acc: LoadAccumulator): void {
131
+ for (const w of parsed.workflows) {
132
+ acc.workflowMap.set(w.name, w);
133
+ acc.sources.set(w.name, layer);
134
+ acc.sourcePaths.set(w.name, path);
135
+ }
136
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Default-export normalisation. Config files accept three default-export
3
+ * shapes; packs accept only the first two (`Workflow | Workflow[]`).
4
+ * The envelope form is rejected for packs so authors don't trip the
5
+ * silent "default lives somewhere else" gotcha.
6
+ *
7
+ * 1. A single `Workflow` — single-entry namespace
8
+ * 2. `Workflow[]` — multi-entry, default required if > 1
9
+ * 3. `{ workflows, default? }` — full envelope, explicit default
10
+ *
11
+ * Missing-field policy for `gate(...)` routes is documented at
12
+ * `api.ts:gate`; loader-side, missing fields surface as `NaN` after
13
+ * `Number(...)` coercion in the predicate body — not a loader concern.
14
+ */
15
+
16
+ import type { Workflow } from "../api.js";
17
+ import { describe, isEnvelope, isWorkflow } from "./shape-guards.js";
18
+
19
+ export type FileKind = "config" | "pack";
20
+
21
+ export interface ParsedConfig {
22
+ workflows: Workflow[];
23
+ default?: string;
24
+ }
25
+
26
+ export type NormalizeResult = { kind: "ok"; value: ParsedConfig } | { kind: "err"; error: string };
27
+
28
+ export function normalizeDefaultExport(raw: unknown, kind: FileKind): NormalizeResult {
29
+ if (isWorkflow(raw)) return { kind: "ok", value: { workflows: [raw] } };
30
+ if (Array.isArray(raw)) {
31
+ if (raw.length === 0) {
32
+ return { kind: "err", error: "default-export `Workflow[]` must contain at least one Workflow" };
33
+ }
34
+ if (!raw.every(isWorkflow)) {
35
+ return { kind: "err", error: "default export array must contain only Workflow objects" };
36
+ }
37
+ // A bare Workflow[] omits the `default` slot; with more than one entry
38
+ // there's no unambiguous pick. Require the envelope form so the choice
39
+ // is explicit. (Single-entry arrays are accepted — only one workflow
40
+ // to default to.) Packs reject the envelope anyway, so a multi-entry
41
+ // pack array gets the same hard error as a config-file one — that's
42
+ // fine; the author should split into one file per workflow.
43
+ if (raw.length > 1) {
44
+ return {
45
+ kind: "err",
46
+ error:
47
+ "default-export `Workflow[]` with more than one entry must be wrapped as " +
48
+ '`{ workflows: [...], default: "<name>" }` so the default workflow is explicit',
49
+ };
50
+ }
51
+ return { kind: "ok", value: { workflows: raw as Workflow[] } };
52
+ }
53
+ if (isEnvelope(raw)) {
54
+ if (kind === "pack") {
55
+ return {
56
+ kind: "err",
57
+ error:
58
+ "pack workflow files must export a `Workflow` or `Workflow[]` — the " +
59
+ "`{ workflows, default? }` envelope is only accepted in the config file workflows.config.ts.",
60
+ };
61
+ }
62
+ if (!raw.workflows.every(isWorkflow)) {
63
+ return { kind: "err", error: "default-export `workflows` must contain only Workflow objects" };
64
+ }
65
+ return { kind: "ok", value: { workflows: raw.workflows, default: raw.default } };
66
+ }
67
+ return {
68
+ kind: "err",
69
+ error:
70
+ "default export must be a Workflow, Workflow[], or { workflows: Workflow[]; default?: string } — " +
71
+ `got ${describe(raw)}`,
72
+ };
73
+ }
package/load/paths.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Overlay file system paths for the user and project layers.
3
+ *
4
+ * user — config `~/.config/rpiv-workflow/workflows.config.ts`
5
+ * packs `~/.config/rpiv-workflow/workflows/*.ts`
6
+ * project — config `<cwd>/.rpiv-workflow/workflows.config.ts`
7
+ * packs `<cwd>/.rpiv-workflow/workflows/*.ts`
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { configPath } from "@juicesharp/rpiv-config";
12
+
13
+ export interface OverlayPaths {
14
+ /** Config file — the only place `default` may live. */
15
+ configFile: string;
16
+ /** Packs directory — alpha-sorted `*.ts` files merged before the config file. */
17
+ packsDir: string;
18
+ }
19
+
20
+ /** Project overlay paths under `<cwd>/.rpiv-workflow/`. */
21
+ export function projectOverlayPaths(cwd: string): OverlayPaths {
22
+ const root = join(cwd, ".rpiv-workflow");
23
+ return { configFile: join(root, "workflows.config.ts"), packsDir: join(root, "workflows") };
24
+ }
25
+
26
+ /** User overlay paths under `~/.config/rpiv-workflow/`. */
27
+ export function userOverlayPaths(): OverlayPaths {
28
+ return {
29
+ configFile: configPath("rpiv-workflow", "workflows.config.ts"),
30
+ packsDir: configPath("rpiv-workflow", "workflows"),
31
+ };
32
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Default workflow resolution. Project config default wins over user
3
+ * config default; if neither layer set one, the first workflow in
4
+ * insertion order (low-to-high layer: built-in → user → project) is
5
+ * returned. When no workflows are registered at all, returns `undefined`
6
+ * — `command.ts` surfaces this as a "no workflows registered" notify
7
+ * rather than running anything.
8
+ *
9
+ * Only the config file in each layer can set `default` — pack
10
+ * `default` fields are hard-rejected at normalisation. An explicit
11
+ * `default` that doesn't name an existing workflow records an error and
12
+ * falls through to the next layer.
13
+ *
14
+ * Historic note: this used to fall back to a hard-coded `"mid"` sentinel,
15
+ * which encoded an rpiv-pi-specific bias inside a skill-agnostic package.
16
+ * Siblings that want to ship a preferred default set it via the
17
+ * config-file envelope at their own load time.
18
+ */
19
+
20
+ import type { ConfigLayer } from "../layers.js";
21
+ import { type LoadAccumulator, loadError } from "./merge.js";
22
+
23
+ export function resolveDefault(
24
+ projectDefault: string | undefined,
25
+ userDefault: string | undefined,
26
+ acc: LoadAccumulator,
27
+ ): string | undefined {
28
+ const candidates: Array<{ name: string | undefined; layer: ConfigLayer }> = [
29
+ { name: projectDefault, layer: "project" },
30
+ { name: userDefault, layer: "user" },
31
+ ];
32
+
33
+ for (const { name, layer } of candidates) {
34
+ if (!name) continue;
35
+ if (acc.workflowMap.has(name)) return name;
36
+ loadError(acc, layer, undefined, `default workflow "${name}" (from ${layer} config) is not declared`);
37
+ }
38
+
39
+ // Last resort: first workflow in insertion order. `Map.keys().next().value`
40
+ // is `undefined` for an empty map — callers must handle the "no workflows
41
+ // registered" case explicitly.
42
+ return acc.workflowMap.keys().next().value;
43
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Direct unit tests for the loader's small shape guards + formatting
3
+ * helpers. `isWorkflow` / `isEnvelope` are exercised transitively through
4
+ * `loadWorkflows` integration tests — but `describe` + `formatError`
5
+ * have branches that aren't reachable from the integration path
6
+ * (`normalizeDefaultExport` handles Arrays before reaching `describe`;
7
+ * jiti's `default: true` extraction rewrites `null` / `undefined`
8
+ * defaults). These tests pin the contract at the unit level.
9
+ */
10
+
11
+ import { describe, expect, it } from "vitest";
12
+ import { describe as describeValue, formatError, isEnvelope, isWorkflow } from "./shape-guards.js";
13
+
14
+ describe("describe", () => {
15
+ it("returns 'null' for null", () => {
16
+ expect(describeValue(null)).toBe("null");
17
+ });
18
+
19
+ it("returns 'undefined' for undefined", () => {
20
+ expect(describeValue(undefined)).toBe("undefined");
21
+ });
22
+
23
+ it("returns 'an array' for arrays", () => {
24
+ expect(describeValue([])).toBe("an array");
25
+ expect(describeValue([1, 2, 3])).toBe("an array");
26
+ });
27
+
28
+ it("returns the typeof for primitives", () => {
29
+ expect(describeValue(42)).toBe("number");
30
+ expect(describeValue("hi")).toBe("string");
31
+ expect(describeValue(true)).toBe("boolean");
32
+ });
33
+
34
+ it("returns 'object' for plain objects", () => {
35
+ expect(describeValue({})).toBe("object");
36
+ });
37
+ });
38
+
39
+ describe("formatError", () => {
40
+ it("returns the message for Error instances", () => {
41
+ expect(formatError(new Error("boom"))).toBe("boom");
42
+ });
43
+
44
+ it("returns the string form for non-Error values", () => {
45
+ expect(formatError("nope")).toBe("nope");
46
+ expect(formatError(42)).toBe("42");
47
+ expect(formatError({ toString: () => "obj" })).toBe("obj");
48
+ });
49
+ });
50
+
51
+ describe("isWorkflow", () => {
52
+ it("rejects truthy non-objects on stages/edges", () => {
53
+ expect(isWorkflow({ name: "x", start: "y", stages: "foo", edges: 1 })).toBe(false);
54
+ expect(isWorkflow({ name: "x", start: "y", stages: {}, edges: [] })).toBe(true);
55
+ });
56
+
57
+ it("rejects null/undefined", () => {
58
+ expect(isWorkflow(null)).toBe(false);
59
+ expect(isWorkflow(undefined)).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("isEnvelope", () => {
64
+ it("recognizes an envelope by its workflows array", () => {
65
+ expect(isEnvelope({ workflows: [] })).toBe(true);
66
+ expect(isEnvelope({ workflows: [{}], default: "x" })).toBe(true);
67
+ });
68
+
69
+ it("rejects shapes missing workflows", () => {
70
+ expect(isEnvelope({})).toBe(false);
71
+ expect(isEnvelope(null)).toBe(false);
72
+ expect(isEnvelope({ workflows: "not an array" })).toBe(false);
73
+ });
74
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Runtime shape guards + small formatting helpers used during default-export
3
+ * normalisation. `isWorkflow` / `isEnvelope` narrow `unknown` default exports
4
+ * to the structural shapes `normalizeDefaultExport` accepts; `describe` /
5
+ * `formatError` synthesize human-readable strings for load-issue messages.
6
+ */
7
+
8
+ import type { Workflow } from "../api.js";
9
+
10
+ export interface Envelope {
11
+ workflows: Workflow[];
12
+ default?: string;
13
+ }
14
+
15
+ export function isWorkflow(v: unknown): v is Workflow {
16
+ if (!v || typeof v !== "object") return false;
17
+ const o = v as Record<string, unknown>;
18
+ return (
19
+ typeof o.name === "string" &&
20
+ typeof o.start === "string" &&
21
+ typeof o.stages === "object" &&
22
+ o.stages !== null &&
23
+ typeof o.edges === "object" &&
24
+ o.edges !== null
25
+ );
26
+ }
27
+
28
+ export function isEnvelope(v: unknown): v is Envelope {
29
+ if (!v || typeof v !== "object") return false;
30
+ return Array.isArray((v as Record<string, unknown>).workflows);
31
+ }
32
+
33
+ export function describe(v: unknown): string {
34
+ if (v === null) return "null";
35
+ if (v === undefined) return "undefined";
36
+ if (Array.isArray(v)) return "an array";
37
+ return typeof v;
38
+ }
39
+
40
+ export function formatError(e: unknown): string {
41
+ return e instanceof Error ? e.message : String(e);
42
+ }
package/messages.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * User-facing message constants.
3
+ * - `STATUS_*` via `ctx.ui.setStatus` — persists across `newSession`.
4
+ * - `MSG_*` / `ERR_*` via `ctx.ui.notify` — one-shot; may be repainted by
5
+ * Pi's session transition (the status line is the durable channel).
6
+ */
7
+
8
+ export const STATUS_KEY = "rpiv-workflow";
9
+
10
+ export const STATUS_STAGE = (stage: number, total: number, skill: string) => `rpiv: stage ${stage}/${total} — ${skill}`;
11
+
12
+ /**
13
+ * Status line for a fanout unit. `skill` is the node's resolved skill body,
14
+ * `label` is whatever the user's `FanoutFn` returned for this unit
15
+ * (`"phase 2/5"`, `"task 3/8"`, ...). The runner adds no implicit wording.
16
+ */
17
+ export const STATUS_FANOUT_UNIT = (stage: number, total: number, skill: string, label: string) =>
18
+ `rpiv: stage ${stage}/${total} — ${skill} (${label})`;
19
+
20
+ export const MSG_STAGE_COMPLETE = (skill: string) => `✓ ${skill} completed`;
21
+ export const MSG_STAGE_FAILED = (skill: string) => `✗ ${skill} failed — stopping workflow`;
22
+ export const MSG_STAGE_ABORTED = (skill: string) => `⏸ ${skill} aborted (ESC) — stopping workflow`;
23
+ export const MSG_STAGE_TRUNCATED = (skill: string) =>
24
+ `✗ ${skill} truncated — model hit output cap mid-reply, stopping workflow`;
25
+ export const MSG_STAGE_TOOL_STALLED = (skill: string) => `✗ ${skill} tool loop did not settle — stopping workflow`;
26
+ export const MSG_STAGE_NO_RESPONSE = (skill: string) => `✗ ${skill} produced no response — stopping workflow`;
27
+
28
+ export const ERR_STAGE_ABORTED = (skill: string) => `${skill} aborted by user (ESC)`;
29
+ export const ERR_STAGE_TRUNCATED = (skill: string) => `${skill} truncated — model hit output-length cap mid-reply`;
30
+ export const ERR_STAGE_TOOL_STALLED = (skill: string) =>
31
+ `${skill} tool loop did not settle before the orchestrator inspected the branch`;
32
+ export const ERR_STAGE_NO_RESPONSE = (skill: string) => `${skill} produced no assistant message`;
33
+
34
+ export const MSG_WORKFLOW_COMPLETE = (stages: number) => `rpiv: workflow complete (${stages} stages)`;
35
+ export const MSG_WORKFLOW_CANCELLED = "rpiv: workflow cancelled";
36
+
37
+ export const MSG_VALIDATION_RETRY = (skill: string, attempt: number) =>
38
+ `rpiv: ${skill} output validation failed — asking agent to fix (attempt ${attempt})`;
39
+ export const MSG_VALIDATION_EXHAUSTED = (skill: string) => `rpiv: ${skill} output validation exhausted retries`;
40
+ export const ERR_VALIDATION_FAILED = (skill: string, failures: string) =>
41
+ `${skill} output validation failed after retries: ${failures}`;
42
+
43
+ /**
44
+ * Sent to the agent as a follow-up message when an output-schema validation
45
+ * fails — instructs the agent to re-write the artifact at the same path with
46
+ * a corrected frontmatter. `errorLines` is a pre-joined bullet list (one
47
+ * line per failure) so the factory stays single-arg-typed.
48
+ */
49
+ export const MSG_VALIDATION_RETRY_PROMPT = (skill: string, errorLines: string) =>
50
+ `The artifact you produced for ${skill} doesn't satisfy the expected output schema. ` +
51
+ "Please update the frontmatter and re-write the artifact at the same path.\n\n" +
52
+ `Errors:\n${errorLines}`;
53
+
54
+ export const MSG_INPUT_VALIDATION_FAILED = (currentSkill: string, prevSkill: string) =>
55
+ `✗ ${currentSkill} input validation failed — upstream ${prevSkill} produced invalid data`;
56
+ export const ERR_INPUT_VALIDATION_FAILED = (currentSkill: string, prevSkill: string, failures: string) =>
57
+ `Input validation failed for '${currentSkill}': upstream '${prevSkill}' produced invalid data: ${failures}`;
58
+
59
+ /**
60
+ * Bound on a single schema-validate call. Sync schemas resolve in one
61
+ * microtask and never trip this; async schemas (filesystem probes, registry
62
+ * lookups, async-by-default libs) that fail to settle within
63
+ * `validateTimeoutMs` halt the stage rather than hang it. Skill
64
+ * attribution is added by the caller's fatal-extraction wrapper, so the
65
+ * factory itself doesn't repeat the skill prefix.
66
+ */
67
+ export const ERR_SCHEMA_TIMEOUT = (slot: "outputSchema" | "inputSchema", ms: number) =>
68
+ `${slot} validation exceeded ${ms}ms — schema's ~standard.validate did not settle`;
69
+
70
+ export const MSG_MISSING_ARTIFACT = (currentSkill: string) =>
71
+ `✗ ${currentSkill} has no upstream artifact to consume — stopping workflow`;
72
+ export const ERR_MISSING_ARTIFACT = (currentSkill: string, stageNumber: number) =>
73
+ `Stage ${stageNumber} (${currentSkill}) has no upstream artifactPath; only stage 1 may consume the user's original input`;
74
+
75
+ /**
76
+ * A stage declares `reads: [..., name, ...]` but `state.named[name]` is
77
+ * empty at preflight time. Either the producing stage hasn't run yet on
78
+ * this path (workflow-load reachability catches the impossible case;
79
+ * this surfaces the "haven't reached the producer" runtime case), or
80
+ * the producer was authored with no outcome and a name that doesn't
81
+ * match any stage record key.
82
+ */
83
+ export const MSG_MISSING_NAMED_READ = (currentSkill: string, name: string) =>
84
+ `✗ ${currentSkill} reads "${name}" but no upstream produces stage has published it yet — stopping workflow`;
85
+ export const ERR_MISSING_NAMED_READ = (currentSkill: string, name: string, stageNumber: number) =>
86
+ `Stage ${stageNumber} (${currentSkill}) reads "${name}" but state.named["${name}"] is empty; check that an upstream produces stage publishes this name`;
87
+
88
+ export const MSG_BACKWARD_JUMP_EXHAUSTED = (jumps: number, max: number) =>
89
+ `rpiv: backward-jump limit exceeded (${jumps}/${max}) — stopping workflow to prevent infinite loop`;
90
+
91
+ export const ERR_BACKWARD_JUMP_EXHAUSTED = (jumps: number, max: number) =>
92
+ `Backward-jump limit exceeded: ${jumps} backward jumps (max ${max})`;
93
+
94
+ export const MSG_AUDIT_WRITE_FAILED = (skill: string) =>
95
+ `✗ ${skill} completed but audit row could not be written — stopping workflow`;
96
+ export const ERR_AUDIT_WRITE_FAILED = (skill: string) =>
97
+ `${skill} completed but the JSONL audit row could not be appended; halting to keep in-memory state aligned with disk`;
98
+
99
+ export const MSG_CHAIN_ADVANCE_FAILED = (fromStage: string, reason: string) =>
100
+ `✗ chain advance after ${fromStage} failed: ${reason} — stopping workflow`;
101
+
102
+ /**
103
+ * Stage threw before it could record its own audit row — covers
104
+ * `enforceSessionInvariants` violations, session-machinery errors, and any
105
+ * other path that escapes `runStage` directly. Distinguished from
106
+ * `MSG_CHAIN_ADVANCE_FAILED` (which is about an edge throwing AFTER a stage
107
+ * succeeded) — the user needs to see *which* stage failed to start, not
108
+ * which one preceded the failure.
109
+ */
110
+ export const MSG_STAGE_THREW = (skill: string, reason: string) =>
111
+ `✗ stage ${skill} failed to start: ${reason} — stopping workflow`;
112
+
113
+ /**
114
+ * Stage references a Pi skill that isn't registered with the running Pi
115
+ * instance. Surfaced loudly here instead of letting the `/skill:<name>` text
116
+ * leak verbatim into the LLM context — `rpiv-args` is the only expander on
117
+ * the programmatic dispatch path (`expandPromptTemplates: false`), so an
118
+ * unknown skill name would otherwise reach the model as a bare user-message
119
+ * imperative outside the `<skill>...</skill>` contract.
120
+ */
121
+ export const MSG_SKILL_NOT_REGISTERED = (skill: string) =>
122
+ `✗ ${skill} is not a registered Pi skill — stopping workflow`;
123
+ export const ERR_SKILL_NOT_REGISTERED = (skill: string, stageNumber: number) =>
124
+ `Stage ${stageNumber} requires Pi skill "${skill}" but no skill by that name is registered with Pi (check installed sibling packages and \`pi.skills\` manifest entries)`;
125
+
126
+ /**
127
+ * Notified live when a routing-decision row could not be appended. The chain
128
+ * continues (the decision has already been made), but the user must know the
129
+ * audit trail for this run has a gap — otherwise an absent row reads as
130
+ * "no decision was made" rather than "decision made, write dropped."
131
+ */
132
+ export const MSG_ROUTING_AUDIT_DROPPED = (fromStage: string, decision: string) =>
133
+ `⚠ rpiv: routing decision ${fromStage} → ${decision} not persisted to audit trail (continuing run)`;
134
+
135
+ /** Recap surfaced on stage failure — pre-joined bullet list of artifact paths. */
136
+ export const MSG_PARTIAL_ARTIFACTS = (artifactList: string) => `Artifacts produced before failure:\n${artifactList}`;
137
+
138
+ /** Lifecycle listener threw — warning so the user sees it but the run never halts. */
139
+ export const MSG_LIFECYCLE_THREW = (event: string, reason: string) =>
140
+ `⚠ rpiv: lifecycle listener (${event}) threw: ${reason}`;
141
+
142
+ /**
143
+ * Script stage's `run()` body threw. Distinct from `MSG_STAGE_THREW`
144
+ * (which covers session-machinery and preflight throws) so users see
145
+ * the failure surface attributed to the script function rather than to
146
+ * the runner.
147
+ */
148
+ export const MSG_SCRIPT_THREW = (stage: string, reason: string) =>
149
+ `✗ ${stage} script threw — stopping workflow: ${reason}`;
150
+ export const ERR_SCRIPT_THREW = (stage: string, reason: string) => `${stage} script threw: ${reason}`;
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // /wf command shell — notify-only (never lands in state.error; ERR_ reserved)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export const MSG_INTERACTIVE_ONLY = "/wf requires interactive mode";
157
+
158
+ export const MSG_WORKFLOW_THREW = (reason: string) => `/wf: workflow runner failed unexpectedly: ${reason}`;
159
+
160
+ export const MSG_LOAD_ABORTED = (count: number) =>
161
+ `/wf: ${count} ${count === 1 ? "config error" : "config errors"} — see warnings above (fix and re-run)`;
162
+
163
+ export const MSG_WORKFLOW_NOT_FOUND = (name: string) => `/wf: workflow "${name}" not found`;
164
+
165
+ /**
166
+ * No layer (built-in / user / project) contributed a workflow. Surfaced
167
+ * instead of trying to run with an undefined default — without rpiv-pi
168
+ * installed and no user overlay, the merged registry is genuinely empty
169
+ * and the user needs to install a sibling that bundles workflows or
170
+ * author one in `.rpiv-workflow/workflows.config.ts`.
171
+ */
172
+ export const MSG_NO_WORKFLOWS_REGISTERED =
173
+ "/wf: no workflows registered — install a sibling that bundles workflows or author one in `.rpiv-workflow/workflows.config.ts`";
174
+
175
+ /** Pi command registry — displayed by Pi's `/?` / command list. */
176
+ export const CMD_DESCRIPTION = "Run a skill workflow: /wf [workflow] [description]";
177
+
178
+ /** No-args listing footer — generic usage hint. */
179
+ export const CMD_USAGE_LIST = "Usage: /wf [workflow] <description>";
180
+
181
+ /** No-args listing footer — preview-mode hint paired with CMD_USAGE_LIST. */
182
+ export const CMD_USAGE_PREVIEW = "/wf <workflow> — preview stages";
183
+
184
+ /** Per-workflow details footer — narrowed to the workflow the user previewed. */
185
+ export const CMD_USAGE_RUN = (name: string) => `Usage: /wf ${name} <description>`;
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { BranchEntry } from "../../transcript.js";
3
+ import { directoryPathCollector } from "./directory-path.js";
4
+
5
+ const asst = (text: string): BranchEntry => ({
6
+ type: "message",
7
+ message: { role: "assistant", content: [{ type: "text", text }] },
8
+ });
9
+
10
+ const ctxOf = (branch: BranchEntry[]) => ({
11
+ cwd: "/tmp",
12
+ runId: "test",
13
+ stageIndex: 0,
14
+ state: {} as never,
15
+ branch,
16
+ branchOffset: undefined,
17
+ snapshot: undefined,
18
+ skill: "test",
19
+ });
20
+
21
+ describe("directoryPathCollector", () => {
22
+ it("throws when dir is missing or empty", () => {
23
+ // @ts-expect-error — intentional misuse
24
+ expect(() => directoryPathCollector({})).toThrow(/dir.*required/);
25
+ expect(() => directoryPathCollector({ dir: "" })).toThrow(/dir.*required/);
26
+ });
27
+
28
+ it("matches files under the directory with any extension when ext omitted", async () => {
29
+ const collector = directoryPathCollector({ dir: "docs/adr" });
30
+ const ctx = ctxOf([asst("Wrote docs/adr/0042-init.md and docs/adr/notes.txt")]);
31
+ const result = await collector.collect(ctx);
32
+ expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({
33
+ kind: "fs",
34
+ path: "docs/adr/notes.txt",
35
+ });
36
+ });
37
+
38
+ it("narrows by extension when supplied", async () => {
39
+ const collector = directoryPathCollector({ dir: "docs/adr", ext: "md" });
40
+ const ctx = ctxOf([asst("Wrote docs/adr/0042-init.md and docs/adr/notes.txt")]);
41
+ const result = await collector.collect(ctx);
42
+ expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({
43
+ kind: "fs",
44
+ path: "docs/adr/0042-init.md",
45
+ });
46
+ });
47
+
48
+ it("escapes regex metacharacters in dir (e.g. dots in subfolder names)", async () => {
49
+ const collector = directoryPathCollector({ dir: ".rpiv/artifacts/research.v2", ext: "md" });
50
+ const ctx = ctxOf([asst("Result: .rpiv/artifacts/research.v2/topic.md")]);
51
+ const result = await collector.collect(ctx);
52
+ expect(result.kind === "ok" && result.artifacts[0]?.handle).toEqual({
53
+ kind: "fs",
54
+ path: ".rpiv/artifacts/research.v2/topic.md",
55
+ });
56
+ });
57
+
58
+ it("fatals when nothing matches the directory", async () => {
59
+ const collector = directoryPathCollector({ dir: "docs/adr", ext: "md" });
60
+ const ctx = ctxOf([asst("Wrote elsewhere/file.md")]);
61
+ const result = await collector.collect(ctx);
62
+ expect(result.kind).toBe("fatal");
63
+ });
64
+ });