@harms-haus/pi-workflows 1.0.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.
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/docs/architecture.md +318 -0
- package/docs/configuration-reference.md +427 -0
- package/docs/contributing.md +132 -0
- package/docs/examples.md +1242 -0
- package/docs/hook-lifecycle.md +380 -0
- package/docs/state-management.md +534 -0
- package/docs/subworkflows.md +428 -0
- package/docs/template-variables.md +383 -0
- package/docs/testing.md +479 -0
- package/package.json +69 -0
- package/skills/workflow-generation/SKILL.md +272 -0
- package/src/TimerManager.ts +67 -0
- package/src/command.ts +199 -0
- package/src/config/index.ts +11 -0
- package/src/config/loading-parse.ts +205 -0
- package/src/config/loading-phases.ts +78 -0
- package/src/config/loading-resolve.ts +82 -0
- package/src/config/loading.ts +202 -0
- package/src/config/templates.ts +25 -0
- package/src/config/validation.ts +258 -0
- package/src/hooks.ts +265 -0
- package/src/index.ts +98 -0
- package/src/prompts.ts +141 -0
- package/src/renderers.ts +46 -0
- package/src/state.ts +426 -0
- package/src/tool.ts +364 -0
- package/src/types.ts +211 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { parse as yamlParse } from "yaml";
|
|
2
|
+
import type { PhaseToolConfig } from "../types";
|
|
3
|
+
|
|
4
|
+
// ── Types ──
|
|
5
|
+
|
|
6
|
+
/** Shape of the raw YAML workflow data before validation. */
|
|
7
|
+
export interface RawWorkflowYaml {
|
|
8
|
+
name?: unknown;
|
|
9
|
+
commandName?: unknown;
|
|
10
|
+
initialMessage?: unknown;
|
|
11
|
+
show?: unknown;
|
|
12
|
+
phases?: unknown;
|
|
13
|
+
loopable?: unknown;
|
|
14
|
+
sessionNamePrefix?: unknown;
|
|
15
|
+
sessionNameMaxLength?: unknown;
|
|
16
|
+
roleInstruction?: unknown;
|
|
17
|
+
advanceReminder?: unknown;
|
|
18
|
+
blockReasonTemplate?: unknown;
|
|
19
|
+
completionMessage?: unknown;
|
|
20
|
+
notDoneReminder?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parsed workflow data extracted from a workflow.yaml file. */
|
|
24
|
+
export interface ParsedWorkflow {
|
|
25
|
+
name: string;
|
|
26
|
+
commandName: string;
|
|
27
|
+
initialMessage: string;
|
|
28
|
+
show?: "workflows";
|
|
29
|
+
loopable?: boolean;
|
|
30
|
+
sessionNamePrefix?: string;
|
|
31
|
+
sessionNameMaxLength?: number;
|
|
32
|
+
roleInstruction?: string;
|
|
33
|
+
advanceReminder?: string;
|
|
34
|
+
blockReasonTemplate?: string;
|
|
35
|
+
completionMessage?: string;
|
|
36
|
+
notDoneReminder?: string;
|
|
37
|
+
/** Raw phase entries from the YAML (string filenames or subworkflow ref objects). */
|
|
38
|
+
rawPhases: unknown[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parsed phase metadata extracted from a phase .md file's frontmatter. */
|
|
42
|
+
export interface PhaseMetadata {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
emoji: string;
|
|
46
|
+
instructions: string;
|
|
47
|
+
tools?: PhaseToolConfig;
|
|
48
|
+
availableProfiles?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Workflow YAML Parsing ──
|
|
52
|
+
|
|
53
|
+
/** Parse the YAML string and return it typed, or null if not a valid object. */
|
|
54
|
+
function validateYamlObject(yamlContent: string, sourcePath: string): RawWorkflowYaml | null {
|
|
55
|
+
const parsed: unknown = yamlParse(yamlContent);
|
|
56
|
+
if (!parsed || typeof parsed !== "object") {
|
|
57
|
+
console.warn(`[pi-workflows] Invalid workflow.yaml in ${sourcePath}: not a valid YAML object`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Extract the show field from parsed YAML. */
|
|
64
|
+
function parseShowField(raw: RawWorkflowYaml): "workflows" | undefined {
|
|
65
|
+
return raw.show === "workflows" ? "workflows" : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Validate and extract required fields (name, commandName, initialMessage). */
|
|
69
|
+
function extractRequiredFields(
|
|
70
|
+
raw: RawWorkflowYaml,
|
|
71
|
+
sourcePath: string,
|
|
72
|
+
show: "workflows" | undefined,
|
|
73
|
+
): { name: string; commandName: string; initialMessage: string } | null {
|
|
74
|
+
if (typeof raw.name !== "string" || !raw.name) {
|
|
75
|
+
console.warn(`[pi-workflows] Missing or invalid "name" in ${sourcePath}`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let commandName: string;
|
|
80
|
+
let initialMessage: string;
|
|
81
|
+
|
|
82
|
+
if (show === "workflows") {
|
|
83
|
+
commandName = typeof raw.commandName === "string" ? raw.commandName : "";
|
|
84
|
+
initialMessage = typeof raw.initialMessage === "string" ? raw.initialMessage : "";
|
|
85
|
+
} else {
|
|
86
|
+
if (typeof raw.commandName !== "string" || !raw.commandName) {
|
|
87
|
+
console.warn(`[pi-workflows] Missing or invalid "commandName" in ${sourcePath}`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (typeof raw.initialMessage !== "string" || !raw.initialMessage) {
|
|
91
|
+
console.warn(`[pi-workflows] Missing or invalid "initialMessage" in ${sourcePath}`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
commandName = raw.commandName;
|
|
95
|
+
initialMessage = raw.initialMessage;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { name: raw.name, commandName, initialMessage };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Set optional string/number/boolean fields on the parsed workflow. */
|
|
102
|
+
function setOptionalFields(raw: RawWorkflowYaml, target: ParsedWorkflow): void {
|
|
103
|
+
if (typeof raw.loopable === "boolean") target.loopable = raw.loopable;
|
|
104
|
+
if (typeof raw.sessionNamePrefix === "string") target.sessionNamePrefix = raw.sessionNamePrefix;
|
|
105
|
+
if (typeof raw.sessionNameMaxLength === "number")
|
|
106
|
+
target.sessionNameMaxLength = raw.sessionNameMaxLength;
|
|
107
|
+
if (typeof raw.roleInstruction === "string") target.roleInstruction = raw.roleInstruction;
|
|
108
|
+
if (typeof raw.advanceReminder === "string") target.advanceReminder = raw.advanceReminder;
|
|
109
|
+
if (typeof raw.blockReasonTemplate === "string")
|
|
110
|
+
target.blockReasonTemplate = raw.blockReasonTemplate;
|
|
111
|
+
if (typeof raw.completionMessage === "string") target.completionMessage = raw.completionMessage;
|
|
112
|
+
if (typeof raw.notDoneReminder === "string") target.notDoneReminder = raw.notDoneReminder;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse a workflow.yaml content string and extract all fields.
|
|
117
|
+
* Returns a ParsedWorkflow object with extracted fields, or null if invalid.
|
|
118
|
+
*/
|
|
119
|
+
export function parseWorkflowYaml(yamlContent: string, sourcePath: string): ParsedWorkflow | null {
|
|
120
|
+
const raw = validateYamlObject(yamlContent, sourcePath);
|
|
121
|
+
if (!raw) return null;
|
|
122
|
+
|
|
123
|
+
const show = parseShowField(raw);
|
|
124
|
+
const required = extractRequiredFields(raw, sourcePath, show);
|
|
125
|
+
if (!required) return null;
|
|
126
|
+
|
|
127
|
+
if (!Array.isArray(raw.phases) || raw.phases.length < 1) {
|
|
128
|
+
console.warn(`[pi-workflows] Missing or invalid "phases" array in ${sourcePath}`);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const result: ParsedWorkflow = {
|
|
133
|
+
...required,
|
|
134
|
+
rawPhases: raw.phases,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (show !== undefined) result.show = show;
|
|
138
|
+
setOptionalFields(raw, result);
|
|
139
|
+
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Phase Metadata Extraction ──
|
|
144
|
+
|
|
145
|
+
/** Extract optional tools config from frontmatter.tools */
|
|
146
|
+
function extractToolsConfig(toolsRaw: unknown): PhaseToolConfig | undefined {
|
|
147
|
+
if (!toolsRaw || typeof toolsRaw !== "object" || Array.isArray(toolsRaw)) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
const toolsConfig = toolsRaw as Record<string, unknown>;
|
|
151
|
+
const tools: PhaseToolConfig = {};
|
|
152
|
+
|
|
153
|
+
if (Array.isArray(toolsConfig.blacklist)) {
|
|
154
|
+
tools.blacklist = toolsConfig.blacklist.map(String);
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(toolsConfig.whitelist)) {
|
|
157
|
+
tools.whitelist = toolsConfig.whitelist.map(String);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return tools.blacklist || tools.whitelist ? tools : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Extract optional available profiles from frontmatter.availableProfiles */
|
|
164
|
+
function extractAvailableProfiles(profilesRaw: unknown): string[] | undefined {
|
|
165
|
+
if (!Array.isArray(profilesRaw)) return undefined;
|
|
166
|
+
return profilesRaw.map(String);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Extract phase metadata from a parsed frontmatter/YAML object.
|
|
171
|
+
* Returns a PhaseMetadata object, or null if required fields are missing.
|
|
172
|
+
*/
|
|
173
|
+
export function extractPhaseMetadata(phaseYaml: unknown, phaseId: string): PhaseMetadata | null {
|
|
174
|
+
if (!phaseYaml || typeof phaseYaml !== "object") {
|
|
175
|
+
console.warn(`[pi-workflows] Invalid frontmatter in phase ${phaseId}`);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const frontmatter = phaseYaml as Record<string, unknown>;
|
|
180
|
+
|
|
181
|
+
if (typeof frontmatter.id !== "string" || !frontmatter.id) {
|
|
182
|
+
console.warn(`[pi-workflows] Missing or invalid "id" in ${phaseId}`);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (typeof frontmatter.name !== "string" || !frontmatter.name) {
|
|
186
|
+
console.warn(`[pi-workflows] Missing or invalid "name" in ${phaseId}`);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
if (typeof frontmatter.emoji !== "string" || !frontmatter.emoji) {
|
|
190
|
+
console.warn(`[pi-workflows] Missing or invalid "emoji" in ${phaseId}`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const metadata: PhaseMetadata = {
|
|
195
|
+
id: frontmatter.id,
|
|
196
|
+
name: frontmatter.name,
|
|
197
|
+
emoji: frontmatter.emoji,
|
|
198
|
+
instructions: "",
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
metadata.tools = extractToolsConfig(frontmatter.tools);
|
|
202
|
+
metadata.availableProfiles = extractAvailableProfiles(frontmatter.availableProfiles);
|
|
203
|
+
|
|
204
|
+
return metadata;
|
|
205
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { PhaseDefinition } from "../types";
|
|
5
|
+
import { extractPhaseMetadata } from "./loading-parse";
|
|
6
|
+
|
|
7
|
+
// ── Path Safety ──
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check that a phase file path does not escape the workflows root directory.
|
|
11
|
+
* Validates by resolving both the canonical root and the phase file path,
|
|
12
|
+
* then ensuring the phase path is a subpath of the root.
|
|
13
|
+
*
|
|
14
|
+
* @param phaseEntry - Relative phase filename from the YAML
|
|
15
|
+
* @param dirPath - Directory containing the workflow.yaml
|
|
16
|
+
* @param workflowsRoot - Parent directory containing all workflows
|
|
17
|
+
* @param yamlPath - Path to workflow.yaml (used in warning messages)
|
|
18
|
+
* @returns true if the path is safe, false otherwise
|
|
19
|
+
*/
|
|
20
|
+
export function checkPathSafety(
|
|
21
|
+
phaseEntry: string,
|
|
22
|
+
dirPath: string,
|
|
23
|
+
workflowsRoot: string,
|
|
24
|
+
yamlPath: string,
|
|
25
|
+
): boolean {
|
|
26
|
+
const canonicalRoot = realpathSync(resolve(workflowsRoot));
|
|
27
|
+
const phaseFilePath = resolve(dirPath, phaseEntry);
|
|
28
|
+
try {
|
|
29
|
+
const canonicalPhase = realpathSync(phaseFilePath);
|
|
30
|
+
if (!canonicalPhase.startsWith(canonicalRoot + sep)) {
|
|
31
|
+
console.warn(
|
|
32
|
+
`[pi-workflows] Phase file path escapes workflows root: ${phaseEntry} in ${yamlPath}`,
|
|
33
|
+
);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
const resolvedPath = resolve(dirPath, phaseEntry);
|
|
38
|
+
if (!resolvedPath.startsWith(canonicalRoot + sep)) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[pi-workflows] Phase file path escapes workflows root: ${phaseEntry} in ${yamlPath}`,
|
|
41
|
+
);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Phase Loading ──
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Load a phase definition from a markdown file with frontmatter.
|
|
52
|
+
*
|
|
53
|
+
* Expects the file to contain YAML frontmatter with required fields:
|
|
54
|
+
* `id`, `name`, `emoji`, and optional `tools` and `availableProfiles`.
|
|
55
|
+
* The body (after frontmatter) becomes the phase instructions.
|
|
56
|
+
*
|
|
57
|
+
* @param phasePath - Absolute path to the .md phase file
|
|
58
|
+
* @returns Parsed PhaseDefinition, or null if the file is invalid
|
|
59
|
+
*/
|
|
60
|
+
export function loadPhaseFromMarkdown(phasePath: string): PhaseDefinition | null {
|
|
61
|
+
const phaseContent = readFileSync(phasePath, "utf-8");
|
|
62
|
+
const { frontmatter, body } = parseFrontmatter(phaseContent);
|
|
63
|
+
|
|
64
|
+
const metadata = extractPhaseMetadata(frontmatter, phasePath);
|
|
65
|
+
if (!metadata) return null;
|
|
66
|
+
|
|
67
|
+
const phase: PhaseDefinition = {
|
|
68
|
+
id: metadata.id,
|
|
69
|
+
name: metadata.name,
|
|
70
|
+
emoji: metadata.emoji,
|
|
71
|
+
instructions: body.trim(),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (metadata.tools) phase.tools = metadata.tools;
|
|
75
|
+
if (metadata.availableProfiles) phase.availableProfiles = metadata.availableProfiles;
|
|
76
|
+
|
|
77
|
+
return phase;
|
|
78
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { WorkflowDefinition } from "../types";
|
|
2
|
+
import { isSubworkflowRef } from "../types";
|
|
3
|
+
import { detectCycles } from "./validation";
|
|
4
|
+
|
|
5
|
+
// ── Post-processing helpers for loadWorkflows ──
|
|
6
|
+
|
|
7
|
+
/** Remove entries with given keys from the record, returning a new record. */
|
|
8
|
+
export function removeKeys(
|
|
9
|
+
record: Record<string, WorkflowDefinition>,
|
|
10
|
+
keys: Iterable<string>,
|
|
11
|
+
): Record<string, WorkflowDefinition> {
|
|
12
|
+
const keySet = new Set(keys);
|
|
13
|
+
return Object.fromEntries(Object.entries(record).filter(([k]) => !keySet.has(k)));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Detect and remove workflow definitions with cyclic subworkflow references. */
|
|
17
|
+
export function removeCycles(
|
|
18
|
+
valid: Record<string, WorkflowDefinition>,
|
|
19
|
+
): Record<string, WorkflowDefinition> {
|
|
20
|
+
const cycleErrors = detectCycles(valid);
|
|
21
|
+
if (cycleErrors.length === 0) return valid;
|
|
22
|
+
|
|
23
|
+
const cycleKeys = new Set<string>();
|
|
24
|
+
for (const msg of cycleErrors) {
|
|
25
|
+
console.warn(`[pi-workflows] ${msg}`);
|
|
26
|
+
const match = msg.match(/^Cycle detected: (.+?)\. /);
|
|
27
|
+
if (match && match[1] !== undefined) {
|
|
28
|
+
for (const k of match[1].split(" → ")) {
|
|
29
|
+
cycleKeys.add(k);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return removeKeys(valid, cycleKeys);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve subworkflow references, removing definitions with broken references. */
|
|
37
|
+
export function resolveSubworkflowRefs(
|
|
38
|
+
valid: Record<string, WorkflowDefinition>,
|
|
39
|
+
): Record<string, WorkflowDefinition> {
|
|
40
|
+
let current = valid;
|
|
41
|
+
let changed = true;
|
|
42
|
+
while (changed) {
|
|
43
|
+
changed = false;
|
|
44
|
+
const keysToRemove: string[] = [];
|
|
45
|
+
for (const [key, def] of Object.entries(current)) {
|
|
46
|
+
for (const phase of def.phases) {
|
|
47
|
+
if (isSubworkflowRef(phase) && phase.resolved === null) {
|
|
48
|
+
const targetDef = current[phase.workflowKey];
|
|
49
|
+
if (!(phase.workflowKey in current) || !targetDef) {
|
|
50
|
+
console.warn(
|
|
51
|
+
`[pi-workflows] Workflow "${key}" references non-existent subworkflow "${phase.workflowKey}". Skipping.`,
|
|
52
|
+
);
|
|
53
|
+
keysToRemove.push(key);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
phase.resolved = targetDef;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (keysToRemove.length > 0) {
|
|
61
|
+
current = removeKeys(current, keysToRemove);
|
|
62
|
+
changed = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return current;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Warn about duplicate commandNames across workflows. */
|
|
69
|
+
export function checkDuplicateCommandNames(valid: Record<string, WorkflowDefinition>): void {
|
|
70
|
+
const commandNameMap = new Map<string, string>();
|
|
71
|
+
for (const [key, def] of Object.entries(valid)) {
|
|
72
|
+
if (!def.commandName) continue;
|
|
73
|
+
const existing = commandNameMap.get(def.commandName);
|
|
74
|
+
if (existing) {
|
|
75
|
+
console.warn(
|
|
76
|
+
`[pi-workflows] Duplicate commandName "${def.commandName}" in workflows "${existing}" and "${key}". The first one found will be used.`,
|
|
77
|
+
);
|
|
78
|
+
} else {
|
|
79
|
+
commandNameMap.set(def.commandName, key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import type { WorkflowDefinition, PhaseEntry } from "../types";
|
|
5
|
+
import { validateWorkflowDefinition } from "./validation";
|
|
6
|
+
import { parseWorkflowYaml } from "./loading-parse";
|
|
7
|
+
import { checkPathSafety, loadPhaseFromMarkdown } from "./loading-phases";
|
|
8
|
+
import {
|
|
9
|
+
removeCycles,
|
|
10
|
+
resolveSubworkflowRefs,
|
|
11
|
+
checkDuplicateCommandNames,
|
|
12
|
+
} from "./loading-resolve";
|
|
13
|
+
|
|
14
|
+
// ── Lookup ──
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Find a workflow by its commandName.
|
|
18
|
+
* Returns [key, definition] tuple or null.
|
|
19
|
+
*/
|
|
20
|
+
export function findWorkflowByCommandName(
|
|
21
|
+
workflows: Record<string, WorkflowDefinition>,
|
|
22
|
+
commandName: string,
|
|
23
|
+
): [string, WorkflowDefinition] | null {
|
|
24
|
+
for (const [key, def] of Object.entries(workflows)) {
|
|
25
|
+
if (def.commandName === commandName) {
|
|
26
|
+
return [key, def];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Phase Entry Loading ──
|
|
33
|
+
|
|
34
|
+
/** Load a single phase entry (string filename or subworkflow reference). */
|
|
35
|
+
function loadPhaseEntry(
|
|
36
|
+
phaseEntry: unknown,
|
|
37
|
+
dirPath: string,
|
|
38
|
+
workflowsRoot: string,
|
|
39
|
+
yamlPath: string,
|
|
40
|
+
): PhaseEntry | null {
|
|
41
|
+
if (typeof phaseEntry === "string") {
|
|
42
|
+
if (!checkPathSafety(phaseEntry, dirPath, workflowsRoot, yamlPath)) return null;
|
|
43
|
+
const phasePath = join(dirPath, phaseEntry);
|
|
44
|
+
try {
|
|
45
|
+
return loadPhaseFromMarkdown(phasePath);
|
|
46
|
+
} catch (phaseErr) {
|
|
47
|
+
const msg = phaseErr instanceof Error ? phaseErr.message : String(phaseErr);
|
|
48
|
+
console.warn(`[pi-workflows] Failed to load phase file ${phasePath}: ${msg}`);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
typeof phaseEntry === "object" &&
|
|
55
|
+
phaseEntry !== null &&
|
|
56
|
+
typeof (phaseEntry as Record<string, unknown>).subworkflow === "string"
|
|
57
|
+
) {
|
|
58
|
+
return {
|
|
59
|
+
subworkflow: true,
|
|
60
|
+
workflowKey: String((phaseEntry as Record<string, unknown>).subworkflow),
|
|
61
|
+
resolved: null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.warn(
|
|
66
|
+
`[pi-workflows] Invalid phase entry in ${yamlPath}: expected string or { subworkflow: key } object`,
|
|
67
|
+
);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Load all phase entries from raw phase data. */
|
|
72
|
+
function loadPhases(
|
|
73
|
+
rawPhases: unknown[],
|
|
74
|
+
yamlPath: string,
|
|
75
|
+
dirPath: string,
|
|
76
|
+
workflowsRoot: string,
|
|
77
|
+
): PhaseEntry[] | null {
|
|
78
|
+
const phases: PhaseEntry[] = [];
|
|
79
|
+
for (const entry of rawPhases) {
|
|
80
|
+
const phase = loadPhaseEntry(entry, dirPath, workflowsRoot, yamlPath);
|
|
81
|
+
if (phase === null) return null;
|
|
82
|
+
phases.push(phase);
|
|
83
|
+
}
|
|
84
|
+
return phases;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Loading from Directory ──
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load a single workflow from a directory containing workflow.yaml and phase .md files.
|
|
91
|
+
* Returns null if the directory is not a valid workflow directory.
|
|
92
|
+
*/
|
|
93
|
+
export function loadWorkflowFromDir(
|
|
94
|
+
dirPath: string,
|
|
95
|
+
workflowsRoot: string,
|
|
96
|
+
): WorkflowDefinition | null {
|
|
97
|
+
const yamlPath = join(dirPath, "workflow.yaml");
|
|
98
|
+
if (!existsSync(yamlPath)) return null;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const yamlContent = readFileSync(yamlPath, "utf-8");
|
|
102
|
+
const parsed = parseWorkflowYaml(yamlContent, dirPath);
|
|
103
|
+
if (!parsed) return null;
|
|
104
|
+
|
|
105
|
+
const phases = loadPhases(parsed.rawPhases, yamlPath, dirPath, workflowsRoot);
|
|
106
|
+
if (!phases) return null;
|
|
107
|
+
|
|
108
|
+
const workflow: WorkflowDefinition = {
|
|
109
|
+
name: parsed.name,
|
|
110
|
+
commandName: parsed.commandName,
|
|
111
|
+
initialMessage: parsed.initialMessage,
|
|
112
|
+
phases,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (parsed.show !== undefined) workflow.show = parsed.show;
|
|
116
|
+
if (parsed.loopable !== undefined) workflow.loopable = parsed.loopable;
|
|
117
|
+
if (parsed.sessionNamePrefix !== undefined)
|
|
118
|
+
workflow.sessionNamePrefix = parsed.sessionNamePrefix;
|
|
119
|
+
if (parsed.sessionNameMaxLength !== undefined)
|
|
120
|
+
workflow.sessionNameMaxLength = parsed.sessionNameMaxLength;
|
|
121
|
+
if (parsed.roleInstruction !== undefined) workflow.roleInstruction = parsed.roleInstruction;
|
|
122
|
+
if (parsed.advanceReminder !== undefined) workflow.advanceReminder = parsed.advanceReminder;
|
|
123
|
+
if (parsed.blockReasonTemplate !== undefined)
|
|
124
|
+
workflow.blockReasonTemplate = parsed.blockReasonTemplate;
|
|
125
|
+
if (parsed.completionMessage !== undefined)
|
|
126
|
+
workflow.completionMessage = parsed.completionMessage;
|
|
127
|
+
if (parsed.notDoneReminder !== undefined) workflow.notDoneReminder = parsed.notDoneReminder;
|
|
128
|
+
|
|
129
|
+
return workflow;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
132
|
+
console.warn(`[pi-workflows] Failed to load workflow from ${dirPath}: ${msg}`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Scan a parent directory for workflow subdirectories and load each one.
|
|
139
|
+
* Returns a record keyed by directory name.
|
|
140
|
+
*/
|
|
141
|
+
export function loadWorkflowsFromDir(parentDir: string): Record<string, WorkflowDefinition> {
|
|
142
|
+
const definitions: Record<string, WorkflowDefinition> = {};
|
|
143
|
+
|
|
144
|
+
if (!existsSync(parentDir)) return definitions;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const entries = readdirSync(parentDir, { withFileTypes: true });
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (!entry.isDirectory()) continue;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const workflow = loadWorkflowFromDir(join(parentDir, entry.name), parentDir);
|
|
153
|
+
if (workflow) {
|
|
154
|
+
definitions[entry.name] = workflow;
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
158
|
+
console.warn(`[pi-workflows] Error loading workflow from ${entry.name}: ${msg}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
163
|
+
console.warn(`[pi-workflows] Failed to read workflow directory ${parentDir}: ${msg}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return definitions;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Loading ──
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Load workflow definitions from global and project-local workflow directories.
|
|
173
|
+
* Project definitions override global definitions with the same key.
|
|
174
|
+
* Invalid definitions are excluded with a console.warn.
|
|
175
|
+
*/
|
|
176
|
+
export function loadWorkflows(cwd?: string): Record<string, WorkflowDefinition> {
|
|
177
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
178
|
+
const globalDir = join(agentDir, "workflows");
|
|
179
|
+
const projectDir = cwd ? join(cwd, ".pi", "workflows") : "";
|
|
180
|
+
|
|
181
|
+
const globalDefs = loadWorkflowsFromDir(globalDir);
|
|
182
|
+
const projectDefs = projectDir ? loadWorkflowsFromDir(projectDir) : {};
|
|
183
|
+
|
|
184
|
+
const merged: Record<string, WorkflowDefinition> = { ...globalDefs, ...projectDefs };
|
|
185
|
+
|
|
186
|
+
// Validate and filter
|
|
187
|
+
const valid: Record<string, WorkflowDefinition> = {};
|
|
188
|
+
for (const [key, def] of Object.entries(merged)) {
|
|
189
|
+
const err = validateWorkflowDefinition(key, def);
|
|
190
|
+
if (err) {
|
|
191
|
+
console.warn(`[pi-workflows] Skipping invalid workflow definition "${key}": ${err}`);
|
|
192
|
+
} else {
|
|
193
|
+
valid[key] = def;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let resolved = removeCycles(valid);
|
|
198
|
+
resolved = resolveSubworkflowRefs(resolved);
|
|
199
|
+
checkDuplicateCommandNames(resolved);
|
|
200
|
+
|
|
201
|
+
return resolved;
|
|
202
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PhaseDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
// ── Template Resolution ──
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Replaces {varName} occurrences in template with values from vars.
|
|
7
|
+
* Unknown variables are left as-is.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveTemplate(template: string, vars: Record<string, string>): string {
|
|
10
|
+
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
|
11
|
+
return Object.hasOwn(vars, key) ? (vars[key] as string) : `{${key}}`;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Utility: get blocked/allowed tools for a phase ──
|
|
16
|
+
|
|
17
|
+
export function getBlockedTools(phase: PhaseDefinition): string[] {
|
|
18
|
+
if (phase.tools?.blacklist) return [...phase.tools.blacklist];
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getWhitelist(phase: PhaseDefinition): string[] | null {
|
|
23
|
+
if (phase.tools?.whitelist) return [...phase.tools.whitelist];
|
|
24
|
+
return null;
|
|
25
|
+
}
|