@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.
@@ -0,0 +1,272 @@
1
+ ---
2
+ name: workflow-generation
3
+ description: Build pi-workflow definitions (workflows, phases, subworkflows, agent-profiles). Use when asked to create or modify a workflow for the pi-workflows extension. Covers the full file-based schema, directory layout, phase frontmatter, subworkflow references, looping, tool blacklists/whitelists, and agent-profile authoring.
4
+ ---
5
+
6
+ # Workflow Generation
7
+
8
+ This skill teaches how to build **pi-workflows** — file-based workflow definitions loaded from `~/.pi/agent/workflows/` (global) or `.pi/workflows/` (project-local) by the `pi-workflows` extension.
9
+
10
+ Read this entire file before building any workflow.
11
+
12
+ ## Directory Layout
13
+
14
+ ```
15
+ ~/.pi/agent/workflows/
16
+ ├── my-workflow/ # One directory per user-facing workflow
17
+ │ └── workflow.yaml # Entry point: name, commandName, phases list
18
+ ├── _shared/ # Convention: underscore-prefixed dirs hold reusable phases
19
+ │ ├── scouting.md
20
+ │ └── planning.md
21
+ └── my-other-workflow/
22
+ └── workflow.yaml # Can reference ../_shared/*.md phases
23
+ ```
24
+
25
+ - Each workflow is a **directory** containing a `workflow.yaml`.
26
+ - Phase instructions live in **`.md` files** with YAML frontmatter.
27
+ - Directories starting with `_` are convention for shared phase libraries (not loaded as standalone workflows since they lack `workflow.yaml`).
28
+ - Phase `.md` files are referenced by **relative path** from the workflow directory.
29
+
30
+ ## Agent Profiles
31
+
32
+ Agent profiles live in `~/.pi/agent/agent-profiles/` as `.md` files with YAML frontmatter:
33
+
34
+ ```markdown
35
+ ---
36
+ name: my-profile
37
+ provider: openai # Provider ID
38
+ model: gpt-4o # Model ID
39
+ thinkingLevel: medium # low | medium | high
40
+ tools: read,bash,edit,write,lsp-diagnostics,lsp-find-references,lsp-goto-definition
41
+ ---
42
+
43
+ System prompt for the agent goes here as free-form markdown.
44
+ ```
45
+
46
+ **Key fields:**
47
+
48
+ - `name` — must match the filename (without `.md`)
49
+ - `tools` — comma-separated list of tools the subagent can use. Omitting a tool prevents the subagent from using it.
50
+ - `thinkingLevel` — controls reasoning effort
51
+
52
+ ### Designing Profiles
53
+
54
+ 1. **One responsibility per profile.** A scout scouts. A writer writes. A reviewer reviews. Never combine.
55
+ 2. **Restrict tools to the minimum needed.** Scouts don't need `edit`/`write`. Writers do. Reviewers only need `read`-family tools.
56
+ 3. **Set thinking level appropriately.** Low for fast scouting. Medium for implementation/writing. High for reviewing and planning.
57
+ 4. **Reuse before creating.** Check `~/.pi/agent/agent-profiles/` for existing profiles before creating new ones.
58
+
59
+ ## workflow.yaml Schema
60
+
61
+ ```yaml
62
+ name: Human-Readable Workflow Name # Required
63
+ commandName: my-command # Required for user-facing workflows. Used as: /workflow my-command <desc>
64
+ sessionNamePrefix: "Prefix: " # Optional. Shown in TUI session name
65
+ sessionNameMaxLength: 50 # Optional. Default 50
66
+ initialMessage: | # Required for user-facing workflows
67
+ Start the {workflowName} for: "{description}"
68
+ Begin with Phase 1 ({firstPhaseName}).
69
+ show: user # Optional. "user" (default) or "workflows" (subworkflow-only)
70
+ phases: # Required. Array of phase file paths or subworkflow references
71
+ - ../_shared/scouting.md
72
+ - ../_shared/planning.md
73
+ - ./implementing.md
74
+ - { subworkflow: my-sub-workflow } # Delegates to another workflow
75
+ ```
76
+
77
+ **Template variables** available in `initialMessage`:
78
+
79
+ - `{workflowName}`, `{description}`, `{firstPhaseId}`, `{firstPhaseName}`, `{firstPhaseEmoji}`, `{firstPhaseProfiles}`
80
+
81
+ **Optional workflow-level fields:**
82
+
83
+ - `show: "workflows"` — makes the workflow invisible to `/workflow` command; only usable as a subworkflow
84
+ - `loopable: false` — prevents looping (restarting from phase 0). Default is `true`.
85
+
86
+ ## Phase .md File Schema
87
+
88
+ ```markdown
89
+ ---
90
+ id: my-phase-id # Required. Unique within the workflow.
91
+ name: My Phase # Required. Display name.
92
+ emoji: "🔍" # Required. Single emoji.
93
+ tools: # Optional. Exactly one of blacklist or whitelist.
94
+ blacklist:
95
+ - edit
96
+ - write
97
+ availableProfiles: # Optional. Profiles the agent may delegate to.
98
+ - vertical-scout
99
+ - horizontal-scout
100
+ ---
101
+
102
+ Phase instructions go here as free-form markdown.
103
+
104
+ These instructions are injected into the agent's context during this phase.
105
+ They tell the agent WHAT to do and HOW to use subagents.
106
+ ```
107
+
108
+ ### Tool Configuration
109
+
110
+ Each phase can restrict tools via `tools`:
111
+
112
+ - **`blacklist`**: Block these specific tools. Everything else is allowed.
113
+ - **`whitelist`**: Allow ONLY these tools. Everything else is blocked.
114
+ - Cannot use both simultaneously.
115
+ - `workflow_step` is always allowed regardless of configuration.
116
+
117
+ Common pattern: read-only phases use `blacklist: [edit, write]` so the agent can scout/plan/review but not modify files directly — it must delegate to subagent profiles that have those tools.
118
+
119
+ ### Phase Instructions Best Practices
120
+
121
+ Phase instructions are the core of your workflow. They must be:
122
+
123
+ 1. **Self-contained** — The agent only sees the current phase's instructions. Include all context the agent needs.
124
+ 2. **Actionable** — Tell the agent exactly what to do. Specify when to use `delegate_to_subagents`, `workflow_step next`, `workflow_step loop`.
125
+ 3. **Profile-aware** — List which profiles to use and what prompts to send them.
126
+ 4. **Terminal** — Every phase MUST end with either `workflow_step next` (advance) or `workflow_step loop` (restart the current workflow scope from phase 0).
127
+
128
+ Example instruction patterns:
129
+
130
+ ```
131
+ Spawn 1-4 parallel subagents:
132
+ delegate_to_subagents: [{ name: "scout-N", prompt: "Investigate: [topic]", profile: "vertical-scout" }]
133
+
134
+ Collect results:
135
+ get_subagent_output for each sessionId
136
+
137
+ Advance:
138
+ workflow_step next
139
+ ```
140
+
141
+ ## Subworkflows
142
+
143
+ A subworkflow delegates a phase to an entire other workflow. In `workflow.yaml`:
144
+
145
+ ```yaml
146
+ phases:
147
+ - { subworkflow: my-sub-workflow }
148
+ ```
149
+
150
+ This resolves `my-sub-workflow` by its **directory name** in the workflows root. The parent workflow's phase becomes the entire subworkflow's phase sequence.
151
+
152
+ ### Subworkflow Rules
153
+
154
+ 1. The referenced key is the **directory name**, not the workflow's `name` field.
155
+ 2. Subworkflows can be marked `show: "workflows"` to hide them from `/workflow`.
156
+ 3. Subworkflow nesting is supported (a subworkflow can reference another subworkflow).
157
+ 4. **Cycles are detected and rejected** — the reference graph must be a DAG.
158
+ 5. If a referenced subworkflow doesn't exist, the parent workflow is skipped with a warning.
159
+
160
+ ### When to Use Subworkflows
161
+
162
+ Use subworkflows when:
163
+
164
+ - Multiple top-level workflows share the same phase sequence (e.g., research → plan → implement → review)
165
+ - You want to reuse a "loop" boundary (looping restarts the current scope)
166
+
167
+ Example — a shared implementation+review loop used by two workflows:
168
+
169
+ ```
170
+ workflows/
171
+ ├── rpir-dev/
172
+ │ └── workflow.yaml # phases: [research, plan, {subworkflow: rpir-implement}]
173
+ ├── rpir-improve/
174
+ │ └── workflow.yaml # phases: [research, plan, {subworkflow: rpir-implement}]
175
+ └── rpir-implement/
176
+ └── workflow.yaml # show: workflows
177
+ # phases: [implement.md, review.md] ← loopable unit
178
+ ```
179
+
180
+ ## Looping
181
+
182
+ When a phase calls `workflow_step loop`, the **current workflow scope** restarts from its **phase 0**. This is how iterative refinement works:
183
+
184
+ 1. An "implement" phase writes code
185
+ 2. A "review" phase checks it
186
+ 3. If review finds issues → `workflow_step loop` → back to implement
187
+ 4. If review is clean → `workflow_step next` → advance past the loop
188
+
189
+ **Loop scope** is determined by the workflow boundary:
190
+
191
+ - In a flat workflow, `loop` restarts the entire workflow
192
+ - In a subworkflow reference, `loop` restarts only the subworkflow's phases
193
+
194
+ Set `loopable: false` to prevent looping (useful for planning phases that should run once).
195
+
196
+ ## Phase Reuse Pattern
197
+
198
+ Avoid copy-pasting phase `.md` files across workflows. Instead:
199
+
200
+ 1. Create a shared directory (convention: `_myshared/` with leading underscore)
201
+ 2. Put reusable phases there
202
+ 3. Reference them with relative paths: `../_myshared/scouting.md`
203
+
204
+ ```
205
+ workflows/
206
+ ├── workflow-a/
207
+ │ └── workflow.yaml # phases: [../_shared/scouting.md, ./a-specific.md]
208
+ ├── workflow-b/
209
+ │ └── workflow.yaml # phases: [../_shared/scouting.md, ./b-specific.md]
210
+ └── _shared/
211
+ ├── scouting.md # Shared phase
212
+ └── planning.md # Shared phase
213
+ ```
214
+
215
+ ## Building a New Workflow — Checklist
216
+
217
+ 1. **Define the workflow** — What is the goal? What phases does it need?
218
+ 2. **Check for reusable phases** — Look in `~/.pi/agent/workflows/` for `_`-prefixed shared directories with phases you can reference.
219
+ 3. **Check for reusable profiles** — Look in `~/.pi/agent/agent-profiles/` before creating new ones.
220
+ 4. **Create the directory** — `~/.pi/agent/workflows/my-workflow/`
221
+ 5. **Write `workflow.yaml`** — name, commandName, initialMessage, phases list
222
+ 6. **Write phase `.md` files** — Each with frontmatter (id, name, emoji) and instructions. Place shared phases in a `_shared/` directory and reference with `../_shared/...` paths.
223
+ 7. **Create agent profiles** — Only if no existing profile fits. Place in `~/.pi/agent/agent-profiles/`.
224
+ 8. **Validate** — Ensure every phase `.md` has `id`, `name`, `emoji`. Ensure all referenced profiles exist. Ensure YAML is valid.
225
+
226
+ ## Common Patterns
227
+
228
+ ### Read-Only Phase (Scouting/Planning/Reviewing)
229
+
230
+ ```yaml
231
+ # frontmatter
232
+ tools:
233
+ blacklist:
234
+ - edit
235
+ - write
236
+ ```
237
+
238
+ The agent delegates to subagent profiles that have write tools. The orchestrator itself cannot modify files.
239
+
240
+ ### Implementation Phase
241
+
242
+ ```yaml
243
+ # frontmatter
244
+ tools:
245
+ blacklist:
246
+ - edit
247
+ - write
248
+ availableProfiles:
249
+ - task-worker
250
+ ```
251
+
252
+ Same pattern — the orchestrator delegates to `task-worker` which has edit/write tools.
253
+
254
+ ### Skip-if-Clean Phase
255
+
256
+ In the phase instructions, include:
257
+
258
+ ```
259
+ If there are no issues, immediately perform `workflow_step next` to skip.
260
+ Otherwise, fix issues and use `workflow_step loop` to re-review.
261
+ ```
262
+
263
+ ### Parallel Subagent Delegation
264
+
265
+ ```
266
+ Spawn 1-4 parallel subagents:
267
+ delegate_to_subagents: [
268
+ { name: "task-1", prompt: "...", profile: "my-profile" },
269
+ { name: "task-2", prompt: "...", profile: "my-profile" }
270
+ ]
271
+ Collect results with get_subagent_output for each sessionId.
272
+ ```
@@ -0,0 +1,67 @@
1
+ /**
2
+ * TimerManager: tracks a single active setInterval and setTimeout.
3
+ *
4
+ * Prevents stale callbacks by verifying the stored handle still matches
5
+ * the one that was set at the time the callback was scheduled.
6
+ */
7
+ export class TimerManager {
8
+ private intervalHandle: ReturnType<typeof setInterval> | null = null;
9
+ private timeoutHandle: ReturnType<typeof setTimeout> | null = null;
10
+
11
+ /**
12
+ * Start a new interval. Any previously tracked interval is cleared first.
13
+ */
14
+ startInterval(delay: number, callback: () => void): void {
15
+ this.clearInterval();
16
+
17
+ const handle = setInterval(() => {
18
+ // Only execute if the handle hasn't been replaced since scheduling
19
+ if (this.intervalHandle === handle) {
20
+ callback();
21
+ }
22
+ }, delay);
23
+
24
+ this.intervalHandle = handle;
25
+ }
26
+
27
+ /**
28
+ * Start a new timeout. Any previously tracked timeout is cleared first.
29
+ */
30
+ startTimeout(delay: number, callback: () => void): void {
31
+ this.clearTimeout();
32
+
33
+ const handle = setTimeout(() => {
34
+ // Only execute if the handle hasn't been replaced since scheduling
35
+ if (this.timeoutHandle === handle) {
36
+ callback();
37
+ }
38
+ }, delay);
39
+
40
+ this.timeoutHandle = handle;
41
+ }
42
+
43
+ /**
44
+ * Clear all tracked timers and set handles to null.
45
+ */
46
+ clearAll(): void {
47
+ this.clearInterval();
48
+ this.clearTimeout();
49
+ }
50
+
51
+ private clearInterval(): void {
52
+ if (this.intervalHandle !== null) {
53
+ clearInterval(this.intervalHandle);
54
+ this.intervalHandle = null;
55
+ }
56
+ }
57
+
58
+ private clearTimeout(): void {
59
+ if (this.timeoutHandle !== null) {
60
+ clearTimeout(this.timeoutHandle);
61
+ this.timeoutHandle = null;
62
+ }
63
+ }
64
+ }
65
+
66
+ /** Module-level singleton. */
67
+ export const timerManager = new TimerManager();
package/src/command.ts ADDED
@@ -0,0 +1,199 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { WorkflowState, SetState, ReloadDefinitions, WorkflowDefinition } from "./types";
3
+ import { isSubworkflowRef } from "./types";
4
+ import {
5
+ createInitialState,
6
+ persistState,
7
+ isActive,
8
+ resolveActive,
9
+ autoEnterSubworkflowRefs,
10
+ resolveFirstPhase,
11
+ } from "./state";
12
+ import { loadWorkflows, findWorkflowByCommandName, resolveTemplate } from "./config";
13
+
14
+ /** Show available workflows to the user. */
15
+ function showAvailableWorkflows(ctx: ExtensionCommandContext): void {
16
+ const workflows = loadWorkflows(ctx.cwd);
17
+ const entries = Object.entries(workflows)
18
+ .filter(([_key, def]) => (def.show ?? "user") === "user")
19
+ .map(([_key, def]) => ` ${def.commandName} — ${def.name}`);
20
+ ctx.ui.notify(
21
+ "Usage: /workflow {name} {description}\n\nAvailable workflows:\n" + entries.join("\n"),
22
+ "info",
23
+ );
24
+ }
25
+
26
+ /** Set the session name and send the initial workflow message. */
27
+ function startWorkflowSession(
28
+ pi: ExtensionAPI,
29
+ definition: WorkflowDefinition,
30
+ workflowKey: string,
31
+ description: string,
32
+ ): void {
33
+ const prefix = definition.sessionNamePrefix ?? "Workflow: ";
34
+ const maxLen = definition.sessionNameMaxLength ?? 50;
35
+ pi.setSessionName(
36
+ `${prefix}${description.slice(0, maxLen)}${description.length > maxLen ? "…" : ""}`,
37
+ );
38
+ const firstPhase = resolveFirstPhase(definition.phases);
39
+ if (!firstPhase) return;
40
+ const initialMessage = resolveTemplate(definition.initialMessage, {
41
+ workflowName: definition.name,
42
+ workflowKey,
43
+ description,
44
+ firstPhaseId: firstPhase.id,
45
+ firstPhaseName: firstPhase.name,
46
+ firstPhaseEmoji: firstPhase.emoji,
47
+ firstPhaseProfiles: firstPhase.availableProfiles?.join(", ") ?? "(none)",
48
+ });
49
+ pi.sendUserMessage(initialMessage);
50
+ }
51
+
52
+ /**
53
+ * Register the /workflow command.
54
+ * Usage: /workflow {commandName} {description}
55
+ */
56
+ export function registerWorkflowCommand(
57
+ pi: ExtensionAPI,
58
+ getState: () => WorkflowState | null,
59
+ reloadDefinitions: ReloadDefinitions,
60
+ setState: SetState,
61
+ ): void {
62
+ pi.registerCommand("workflow", {
63
+ description: "Start a configured workflow. Usage: /workflow {name} {description}",
64
+ getArgumentCompletions(prefix: string) {
65
+ const workflows = loadWorkflows();
66
+ const names = Object.values(workflows)
67
+ .filter((w) => (w.show ?? "user") === "user")
68
+ .map((w) => w.commandName);
69
+ const filtered = names.filter((n) => n.startsWith(prefix));
70
+ return filtered.length > 0 ? filtered.map((n) => ({ value: n, label: n })) : null;
71
+ },
72
+ handler: async (args, ctx) => {
73
+ // Parse: split on first whitespace to get commandName and description
74
+ const input = typeof args === "string" ? args : "";
75
+ const parts = input.trim().match(/^(\S+)\s*(.*)/s);
76
+ if (!parts) {
77
+ showAvailableWorkflows(ctx);
78
+ return;
79
+ }
80
+
81
+ const commandName = parts[1];
82
+ const description = parts[2];
83
+ if (commandName === undefined || description === undefined) {
84
+ showAvailableWorkflows(ctx);
85
+ return;
86
+ }
87
+ if (!description || description.trim() === "") {
88
+ ctx.ui.notify(`Usage: /workflow ${commandName} {description}`, "warning");
89
+ return;
90
+ }
91
+
92
+ // Reload definitions to get latest
93
+ const definitions = await reloadDefinitions(ctx.cwd);
94
+
95
+ // Find the workflow
96
+ const match = findWorkflowByCommandName(definitions, commandName);
97
+ if (!match) {
98
+ const available = Object.values(definitions)
99
+ .map((d) => d.commandName)
100
+ .join(", ");
101
+ ctx.ui.notify(
102
+ `Unknown workflow: "${commandName}". Available: ${available || "(none)"}`,
103
+ "error",
104
+ );
105
+ return;
106
+ }
107
+
108
+ const [workflowKey, definition] = match;
109
+
110
+ // Safety check: reject workflows not shown to users
111
+ if (definition.show === "workflows") {
112
+ ctx.ui.notify(
113
+ `"${commandName}" is a subworkflow that can only run as part of another workflow. It cannot be started directly.`,
114
+ "error",
115
+ );
116
+ return;
117
+ }
118
+ const state = getState();
119
+
120
+ // Check for existing active workflow
121
+ if (isActive(state)) {
122
+ const active = resolveActive(state, definitions);
123
+ const phaseName = active ? active.currentPhase.name : "unknown";
124
+ const existingDesc = state.taskDescription;
125
+ const ok = await ctx.ui.confirm(
126
+ "Workflow already active",
127
+ `Phase: ${phaseName}\nTask: ${existingDesc}\n\nStart a new one?`,
128
+ );
129
+ if (!ok) return;
130
+ }
131
+
132
+ // Create new state
133
+ const newState = createInitialState(workflowKey, description.trim());
134
+
135
+ // Auto-enter subworkflow if first phase is a SubworkflowRef
136
+ const firstEntry = definition.phases[0];
137
+ let stateToSet = newState;
138
+ if (isSubworkflowRef(firstEntry)) {
139
+ const { newState: enteredState } = autoEnterSubworkflowRefs(newState, firstEntry);
140
+ stateToSet = enteredState;
141
+ }
142
+
143
+ setState(stateToSet);
144
+ persistState(pi, stateToSet);
145
+ ctx.ui.setStatus("workflow", undefined);
146
+ startWorkflowSession(pi, definition, workflowKey, description.trim());
147
+ },
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Register the /cancel-workflow command.
153
+ * Immediately jumps the active workflow to DONE, bypassing the not-done reminder loop.
154
+ */
155
+ export function registerCancelWorkflowCommand(
156
+ pi: ExtensionAPI,
157
+ getState: () => WorkflowState | null,
158
+ setState: (s: WorkflowState | null) => void,
159
+ ): void {
160
+ pi.registerCommand("cancel-workflow", {
161
+ description:
162
+ "Cancel the active workflow and jump to DONE. Bypasses the not-done reminder loop.",
163
+ handler: (_args, ctx) => {
164
+ const state = getState();
165
+ if (!state || !state.active) {
166
+ ctx.ui.notify("No active workflow to cancel.", "info");
167
+ return Promise.resolve();
168
+ }
169
+
170
+ // Jump straight to DONE state
171
+ const doneState: WorkflowState = {
172
+ ...state,
173
+ currentPath: state.currentPath.map((seg) => ({ ...seg })),
174
+ active: false,
175
+ cancelled: true,
176
+ completionNotified: false,
177
+ };
178
+
179
+ // Persist so session resume knows it was cancelled
180
+ persistState(pi, doneState);
181
+
182
+ // Clear the status
183
+ ctx.ui.setStatus("workflow", undefined);
184
+
185
+ // Send cancellation notification immediately (bypass agent_end hook)
186
+ const msg = `❌ **Workflow Cancelled**\n\n**Task:** ${state.taskDescription}\n**Task ID:** ${state.taskId}`;
187
+ pi.sendMessage(
188
+ { customType: "workflow:complete", content: msg, display: true },
189
+ { triggerTurn: false },
190
+ );
191
+
192
+ // Unload immediately so agent_end hook sees null state and does nothing
193
+ setState(null);
194
+
195
+ ctx.ui.notify("Workflow cancelled.", "info");
196
+ return Promise.resolve();
197
+ },
198
+ });
199
+ }
@@ -0,0 +1,11 @@
1
+ // Barrel file — re-exports the public API from config modules.
2
+ // All existing imports from "./config" or "../config" resolve through this file.
3
+
4
+ export { resolveTemplate, getBlockedTools, getWhitelist } from "./templates";
5
+ export { validateWorkflowDefinition, detectCycles, VALID_COMMAND_NAME_RE } from "./validation";
6
+ export {
7
+ findWorkflowByCommandName,
8
+ loadWorkflowFromDir,
9
+ loadWorkflowsFromDir,
10
+ loadWorkflows,
11
+ } from "./loading";