@pi-unipi/workflow 2.4.0 → 2.5.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/README.md +8 -0
- package/commands.ts +25 -23
- package/index.ts +172 -81
- package/lifecycle.ts +43 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -128,6 +128,14 @@ Skills define:
|
|
|
128
128
|
|
|
129
129
|
The agent reads the skill, executes the steps, and produces artifacts in the `.unipi/` directory.
|
|
130
130
|
|
|
131
|
+
## Workflow Sandbox
|
|
132
|
+
|
|
133
|
+
Each workflow activates a command-specific sandbox for the duration of its agent run. The sandbox blocks disallowed tool calls by name (for example, `write`, `edit`, or `bash` in read-only workflows) without changing Pi's active tool list. Keeping the provider tool schemas and their order unchanged makes the workflow prefix cache-stable.
|
|
134
|
+
|
|
135
|
+
Sandbox instructions are stored as hidden, append-only `unipi-workflow-sandbox-snapshot` messages rather than being injected into the system prompt. An active snapshot explicitly supersedes older snapshots. After the workflow completes at `agent_end`, the next agent start appends an inactive snapshot when needed so stale restrictions no longer apply. Sessions that have never activated a workflow do not receive a marker.
|
|
136
|
+
|
|
137
|
+
The sandbox preserves the existing command-level semantics; skill instructions still define narrower write locations and setup-only shell usage where applicable.
|
|
138
|
+
|
|
131
139
|
## Directory Structure
|
|
132
140
|
|
|
133
141
|
```
|
package/commands.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { readFileSync, readdirSync, existsSync, statSync } from "fs";
|
|
10
10
|
import { join, basename } from "path";
|
|
11
|
-
import { UNIPI_PREFIX, WORKFLOW_COMMANDS,
|
|
11
|
+
import { UNIPI_PREFIX, WORKFLOW_COMMANDS, type UnipiWorkflowEvent } from "@pi-unipi/core";
|
|
12
12
|
|
|
13
13
|
type CompletionItem = { value: string; label: string; description: string };
|
|
14
14
|
|
|
@@ -16,12 +16,10 @@ type CompletionItem = { value: string; label: string; description: string };
|
|
|
16
16
|
export interface WorkflowCommandOptions {
|
|
17
17
|
/** Check if ralph module is detected */
|
|
18
18
|
isRalphDetected: () => boolean;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
/** Save tools for later restore */
|
|
24
|
-
saveTools: (tools: string[]) => void;
|
|
19
|
+
/** Begin one workflow lifecycle and activate its sandbox. */
|
|
20
|
+
activateSandbox: (event: UnipiWorkflowEvent) => boolean;
|
|
21
|
+
/** Roll back lifecycle and sandbox state after dispatch failure. */
|
|
22
|
+
abortWorkflow: () => void;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
/** Command definition */
|
|
@@ -402,14 +400,17 @@ export function registerWorkflowCommands(
|
|
|
402
400
|
return null;
|
|
403
401
|
},
|
|
404
402
|
handler: async (args, ctx) => {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
options.
|
|
411
|
-
|
|
412
|
-
|
|
403
|
+
const workflowEvent: UnipiWorkflowEvent = {
|
|
404
|
+
command: cmd.name,
|
|
405
|
+
fullCommand: `/${fullCommand}`,
|
|
406
|
+
args: args?.trim() ?? "",
|
|
407
|
+
};
|
|
408
|
+
if (!options.activateSandbox(workflowEvent)) {
|
|
409
|
+
if (ctx.hasUI) ctx.ui.notify("Another UniPi workflow is still active", "warning");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Load skill content from SKILL.md.
|
|
413
414
|
let skillContent = "";
|
|
414
415
|
try {
|
|
415
416
|
const skillPath = join(
|
|
@@ -419,33 +420,34 @@ export function registerWorkflowCommands(
|
|
|
419
420
|
);
|
|
420
421
|
skillContent = readFileSync(skillPath, "utf-8");
|
|
421
422
|
} catch {
|
|
422
|
-
// Skill file not found — continue without it
|
|
423
|
+
// Skill file not found — continue without it.
|
|
423
424
|
}
|
|
424
425
|
|
|
425
|
-
// Build skill invocation message
|
|
426
426
|
let message = `Execute the ${cmd.skillName} workflow.`;
|
|
427
427
|
|
|
428
|
-
// Add args if provided
|
|
429
428
|
if (args?.trim()) {
|
|
430
429
|
message += `\n\nArguments: ${args.trim()}`;
|
|
431
430
|
}
|
|
432
431
|
|
|
433
|
-
// Add ralph hint if applicable
|
|
434
432
|
if (cmd.ralphHint && options.isRalphDetected()) {
|
|
435
433
|
message += `\n\n💡 ${cmd.ralphHint}`;
|
|
436
434
|
}
|
|
437
435
|
|
|
438
|
-
// Inject skill content as context
|
|
439
436
|
if (skillContent) {
|
|
440
437
|
message += `\n\n<skill_content>\n${skillContent}\n</skill_content>`;
|
|
441
438
|
}
|
|
442
439
|
|
|
443
|
-
//
|
|
444
|
-
|
|
440
|
+
// Any synchronous dispatch failure must not leave a phantom workflow
|
|
441
|
+
// lifecycle or sandbox behind.
|
|
442
|
+
try {
|
|
443
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
444
|
+
} catch (error) {
|
|
445
|
+
options.abortWorkflow();
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
445
448
|
|
|
446
449
|
if (ctx.hasUI) {
|
|
447
450
|
ctx.ui.notify(`Running /${fullCommand}`, "info");
|
|
448
|
-
// Update extension status with active command name
|
|
449
451
|
const ralphStatus = options.isRalphDetected() ? "✓ rl" : "○ rl";
|
|
450
452
|
ctx.ui.setStatus("unipi-workflow", `⚡ wf:${cmd.name} ${ralphStatus}`);
|
|
451
453
|
}
|
package/index.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @unipi/workflow — Structured development workflow commands
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
5
|
-
* Emits MODULE_READY
|
|
6
|
-
*
|
|
7
|
-
* Applies sandbox (tool filtering) per command.
|
|
4
|
+
* Registers workflow commands that dispatch to skills for LLM instruction.
|
|
5
|
+
* Emits MODULE_READY for inter-module discovery and detects @unipi/ralph.
|
|
6
|
+
* Enforces workflow sandboxes without changing Pi's active tool schemas.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { dirname } from "node:path";
|
|
11
10
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import
|
|
11
|
+
import {
|
|
12
|
+
buildSessionContext,
|
|
13
|
+
type ExtensionAPI,
|
|
14
|
+
type SessionEntry,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
16
|
import {
|
|
14
17
|
UNIPI_EVENTS,
|
|
15
18
|
MODULES,
|
|
@@ -17,114 +20,209 @@ import {
|
|
|
17
20
|
emitEvent,
|
|
18
21
|
getPackageVersion,
|
|
19
22
|
initUnipiDirs,
|
|
20
|
-
type SandboxLevel,
|
|
21
23
|
getBlockedToolsForLevel,
|
|
24
|
+
getSandboxLevel,
|
|
25
|
+
isToolAllowed,
|
|
26
|
+
type SandboxLevel,
|
|
22
27
|
} from "@pi-unipi/core";
|
|
23
28
|
import { registerWorkflowCommands } from "./commands.js";
|
|
29
|
+
import { WorkflowLifecycle } from "./lifecycle.js";
|
|
24
30
|
|
|
25
31
|
/** Package version (read from package.json at load time) */
|
|
26
32
|
const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
export const WORKFLOW_SANDBOX_SNAPSHOT_TYPE = "unipi-workflow-sandbox-snapshot";
|
|
35
|
+
|
|
36
|
+
interface WorkflowSandboxSnapshotDetails {
|
|
37
|
+
active: boolean;
|
|
38
|
+
command?: string;
|
|
39
|
+
level?: SandboxLevel;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EffectiveSandboxSnapshot {
|
|
43
|
+
content: unknown;
|
|
44
|
+
details?: WorkflowSandboxSnapshotDetails;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sandboxRestrictions(level: SandboxLevel): string[] {
|
|
48
|
+
const common = [
|
|
49
|
+
"Do not attempt to call tools blocked by name.",
|
|
50
|
+
"If the user requests an action that requires a blocked tool, explain that the workflow sandbox does not allow it.",
|
|
51
|
+
];
|
|
30
52
|
|
|
31
|
-
|
|
32
|
-
|
|
53
|
+
if (level === "brainstorm") {
|
|
54
|
+
return [
|
|
55
|
+
"The write tool is restricted to .unipi/docs/specs/ only.",
|
|
56
|
+
"Use bash only for specific setup operations such as git init or mkdir; use grep, find, and ls for discovery instead of bash.",
|
|
57
|
+
...common,
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (level === "write_unipi") {
|
|
62
|
+
return [
|
|
63
|
+
"The write tool is restricted to .unipi/docs/ only (specs and plans).",
|
|
64
|
+
"Use grep, find, and ls for file discovery instead of guessing filenames.",
|
|
65
|
+
"Bash is blocked; use read, write, edit, grep, find, and ls only.",
|
|
66
|
+
...common,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return common;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Build a persistent snapshot that explicitly invalidates earlier sandbox state. */
|
|
74
|
+
function formatActiveSandboxSnapshot(command: string, level: SandboxLevel): string {
|
|
75
|
+
const blocked = getBlockedToolsForLevel(level);
|
|
76
|
+
const blockedLine = blocked.length > 0
|
|
77
|
+
? blocked.join(", ")
|
|
78
|
+
: "none";
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
"# UniPi Workflow Sandbox Snapshot",
|
|
82
|
+
"This snapshot supersedes all prior UniPi workflow sandbox snapshots; use only this snapshot for workflow sandbox status and restrictions.",
|
|
83
|
+
"Status: active",
|
|
84
|
+
`Workflow: /unipi:${command}`,
|
|
85
|
+
`Sandbox level: ${level}`,
|
|
86
|
+
`Blocked tool names: ${blockedLine}`,
|
|
87
|
+
"Pi's provider tool schemas and tool order remain unchanged. Calls to blocked tool names are rejected by the workflow sandbox.",
|
|
88
|
+
["Restrictions:", ...sandboxRestrictions(level).map((restriction) => `- ${restriction}`)].join("\n"),
|
|
89
|
+
].join("\n\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatInactiveSandboxSnapshot(): string {
|
|
93
|
+
return [
|
|
94
|
+
"# UniPi Workflow Sandbox Snapshot",
|
|
95
|
+
"This snapshot supersedes all prior UniPi workflow sandbox snapshots; use only this snapshot for workflow sandbox status and restrictions.",
|
|
96
|
+
"Status: inactive",
|
|
97
|
+
"No UniPi workflow sandbox is active. Prior workflow sandbox restrictions no longer apply.",
|
|
98
|
+
].join("\n\n");
|
|
99
|
+
}
|
|
33
100
|
|
|
34
|
-
|
|
35
|
-
|
|
101
|
+
function latestEffectiveSandboxSnapshot(
|
|
102
|
+
branch: SessionEntry[],
|
|
103
|
+
): EffectiveSandboxSnapshot | undefined {
|
|
104
|
+
const messages = buildSessionContext(branch).messages;
|
|
105
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
106
|
+
const message = messages[index];
|
|
107
|
+
if (message.role === "custom" && message.customType === WORKFLOW_SANDBOX_SNAPSHOT_TYPE) {
|
|
108
|
+
return {
|
|
109
|
+
content: message.content,
|
|
110
|
+
details: message.details as WorkflowSandboxSnapshotDetails | undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
36
116
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
117
|
+
/** Find state that may survive only as prose inside a compaction summary. */
|
|
118
|
+
function latestHistoricalSandboxSnapshot(
|
|
119
|
+
branch: SessionEntry[],
|
|
120
|
+
): EffectiveSandboxSnapshot | undefined {
|
|
121
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
122
|
+
const entry = branch[index];
|
|
123
|
+
if (entry.type === "custom_message" && entry.customType === WORKFLOW_SANDBOX_SNAPSHOT_TYPE) {
|
|
124
|
+
return {
|
|
125
|
+
content: entry.content,
|
|
126
|
+
details: entry.details as WorkflowSandboxSnapshotDetails | undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
39
132
|
|
|
40
|
-
|
|
41
|
-
|
|
133
|
+
function isActiveSnapshot(snapshot: EffectiveSandboxSnapshot): boolean {
|
|
134
|
+
if (typeof snapshot.details?.active === "boolean") return snapshot.details.active;
|
|
135
|
+
return typeof snapshot.content === "string" && snapshot.content.includes("Status: active");
|
|
136
|
+
}
|
|
42
137
|
|
|
43
138
|
export default function (pi: ExtensionAPI) {
|
|
139
|
+
const workflowLifecycle = new WorkflowLifecycle();
|
|
140
|
+
let ralphDetected = false;
|
|
141
|
+
let sandboxCommand: string | null = null;
|
|
44
142
|
|
|
45
|
-
// Register all workflow commands
|
|
46
143
|
registerWorkflowCommands(pi, {
|
|
47
144
|
isRalphDetected: () => ralphDetected,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
currentSandboxTools = tools;
|
|
145
|
+
activateSandbox: (event) => {
|
|
146
|
+
if (!workflowLifecycle.start(event)) return false;
|
|
147
|
+
sandboxCommand = event.command;
|
|
148
|
+
emitEvent(pi, UNIPI_EVENTS.WORKFLOW_START, event);
|
|
149
|
+
return true;
|
|
54
150
|
},
|
|
55
|
-
|
|
56
|
-
|
|
151
|
+
abortWorkflow: () => {
|
|
152
|
+
sandboxCommand = null;
|
|
153
|
+
workflowLifecycle.reset();
|
|
57
154
|
},
|
|
58
155
|
});
|
|
59
156
|
|
|
60
|
-
//
|
|
157
|
+
// Keep tool schemas/order stable and enforce only the existing blocked names.
|
|
61
158
|
pi.on("tool_call", async (event, _ctx) => {
|
|
62
|
-
if (!
|
|
159
|
+
if (!sandboxCommand) return;
|
|
63
160
|
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
161
|
+
const level = getSandboxLevel(sandboxCommand);
|
|
162
|
+
if (!isToolAllowed(level, event.toolName)) {
|
|
163
|
+
const blocked = getBlockedToolsForLevel(level);
|
|
66
164
|
return {
|
|
67
165
|
block: true,
|
|
68
|
-
reason: `Tool "${event.toolName}" is not allowed in ${
|
|
166
|
+
reason: `Tool "${event.toolName}" is not allowed in ${level} sandbox. Blocked: ${blocked.join(", ")}`,
|
|
69
167
|
};
|
|
70
168
|
}
|
|
71
169
|
});
|
|
72
170
|
|
|
73
|
-
//
|
|
74
|
-
pi.on("before_agent_start", async (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const blocked = getBlockedToolsForLevel(currentSandboxLevel);
|
|
171
|
+
// Persist hidden append-only sandbox state without mutating the system prompt.
|
|
172
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
173
|
+
const branch = ctx.sessionManager.getBranch();
|
|
174
|
+
const latest = latestEffectiveSandboxSnapshot(branch);
|
|
175
|
+
const historical = latestHistoricalSandboxSnapshot(branch);
|
|
79
176
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
177
|
+
if (sandboxCommand) {
|
|
178
|
+
const level = getSandboxLevel(sandboxCommand);
|
|
179
|
+
const content = formatActiveSandboxSnapshot(sandboxCommand, level);
|
|
180
|
+
if (latest?.content === content) return undefined;
|
|
84
181
|
|
|
85
|
-
if (currentSandboxLevel === "brainstorm") {
|
|
86
182
|
return {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
183
|
+
message: {
|
|
184
|
+
customType: WORKFLOW_SANDBOX_SNAPSHOT_TYPE,
|
|
185
|
+
content,
|
|
186
|
+
display: false,
|
|
187
|
+
details: {
|
|
188
|
+
active: true,
|
|
189
|
+
command: sandboxCommand,
|
|
190
|
+
level,
|
|
191
|
+
} satisfies WorkflowSandboxSnapshotDetails,
|
|
192
|
+
},
|
|
91
193
|
};
|
|
92
194
|
}
|
|
93
195
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
base +
|
|
99
|
-
`\nWrite tool is restricted to .unipi/docs/ only (specs and plans).\nUse grep, find, ls for file discovery — do NOT guess filenames.\nbash is blocked — use read, write, edit, grep, find, ls only.\nDo NOT attempt to call blocked tools. Do NOT output tool call XML for them.\nIf the user requests an action that requires a blocked tool, respond that you do not have access.\n</sandbox>`,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
196
|
+
// A clean session gets no marker. Only an effective active snapshot needs
|
|
197
|
+
// an append-only inactive successor after agent_end completes the workflow.
|
|
198
|
+
const prior = latest ?? historical;
|
|
199
|
+
if (!prior || !isActiveSnapshot(prior)) return undefined;
|
|
102
200
|
|
|
103
201
|
return {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
202
|
+
message: {
|
|
203
|
+
customType: WORKFLOW_SANDBOX_SNAPSHOT_TYPE,
|
|
204
|
+
content: formatInactiveSandboxSnapshot(),
|
|
205
|
+
display: false,
|
|
206
|
+
details: {
|
|
207
|
+
active: false,
|
|
208
|
+
} satisfies WorkflowSandboxSnapshotDetails,
|
|
209
|
+
},
|
|
108
210
|
};
|
|
109
211
|
});
|
|
110
212
|
|
|
111
|
-
//
|
|
112
|
-
pi.on("agent_end", async (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
currentSandboxTools = null;
|
|
119
|
-
}
|
|
213
|
+
// Pi 0.80.2 compatibility: agent_end remains the workflow completion boundary.
|
|
214
|
+
pi.on("agent_end", async (event, _ctx) => {
|
|
215
|
+
const completedWorkflow = workflowLifecycle.complete(event.messages);
|
|
216
|
+
if (!completedWorkflow) return;
|
|
217
|
+
|
|
218
|
+
sandboxCommand = null;
|
|
219
|
+
emitEvent(pi, UNIPI_EVENTS.WORKFLOW_END, completedWorkflow);
|
|
120
220
|
});
|
|
121
221
|
|
|
122
|
-
// Announce module presence on session start
|
|
222
|
+
// Announce module presence on session start.
|
|
123
223
|
pi.on("session_start", async (_event, ctx) => {
|
|
124
|
-
// Initialize .unipi directory structure
|
|
125
224
|
initUnipiDirs();
|
|
126
225
|
|
|
127
|
-
// Emit MODULE_READY
|
|
128
226
|
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
129
227
|
name: MODULES.WORKFLOW,
|
|
130
228
|
version: VERSION,
|
|
@@ -132,36 +230,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
132
230
|
tools: [],
|
|
133
231
|
});
|
|
134
232
|
|
|
135
|
-
// Listen for ralph module
|
|
136
233
|
if (!ralphDetected) {
|
|
137
234
|
try {
|
|
138
|
-
// Check if ralph tools exist (indicates @unipi/ralph is loaded)
|
|
139
235
|
const allTools = pi.getAllTools();
|
|
140
|
-
ralphDetected = allTools.some((
|
|
236
|
+
ralphDetected = allTools.some((tool) => tool.name === "ralph_start");
|
|
141
237
|
} catch {
|
|
142
|
-
// Ignore — ralph not present
|
|
238
|
+
// Ignore — ralph not present.
|
|
143
239
|
}
|
|
144
240
|
}
|
|
145
241
|
|
|
146
|
-
// Show workflow status in UI
|
|
147
242
|
if (ctx.hasUI) {
|
|
148
243
|
const ralphStatus = ralphDetected ? "✓ rl" : "○ rl";
|
|
149
244
|
ctx.ui.setStatus("unipi-workflow", `⚡ wf ${ralphStatus}`);
|
|
150
245
|
}
|
|
151
246
|
});
|
|
152
247
|
|
|
153
|
-
// Listen for ralph module ready event
|
|
154
248
|
pi.events.on(UNIPI_EVENTS.MODULE_READY, (data) => {
|
|
155
249
|
const event = data as { name?: string };
|
|
156
|
-
if (event?.name === MODULES.RALPH)
|
|
157
|
-
ralphDetected = true;
|
|
158
|
-
}
|
|
250
|
+
if (event?.name === MODULES.RALPH) ralphDetected = true;
|
|
159
251
|
});
|
|
160
252
|
|
|
161
|
-
// Clean up on shutdown
|
|
162
253
|
pi.on("session_shutdown", async () => {
|
|
163
254
|
ralphDetected = false;
|
|
164
|
-
|
|
165
|
-
|
|
255
|
+
sandboxCommand = null;
|
|
256
|
+
workflowLifecycle.reset();
|
|
166
257
|
});
|
|
167
258
|
}
|
package/lifecycle.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { UnipiWorkflowEvent } from "@pi-unipi/core";
|
|
2
|
+
|
|
3
|
+
interface WorkflowMessage {
|
|
4
|
+
role: string;
|
|
5
|
+
stopReason?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CompletedWorkflowEvent extends UnipiWorkflowEvent {
|
|
9
|
+
success: boolean;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Single-active workflow lifecycle state shared by slash handlers and agent_end. */
|
|
14
|
+
export class WorkflowLifecycle {
|
|
15
|
+
private active: (UnipiWorkflowEvent & { startedAt: number }) | null = null;
|
|
16
|
+
|
|
17
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
18
|
+
|
|
19
|
+
start(event: UnipiWorkflowEvent): boolean {
|
|
20
|
+
if (this.active) return false;
|
|
21
|
+
this.active = { ...event, startedAt: this.now() };
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
complete(messages: WorkflowMessage[]): CompletedWorkflowEvent | undefined {
|
|
26
|
+
if (!this.active) return undefined;
|
|
27
|
+
const workflow = this.active;
|
|
28
|
+
this.active = null;
|
|
29
|
+
const finalAssistant = [...messages].reverse().find((message) => message.role === "assistant");
|
|
30
|
+
const success = finalAssistant?.stopReason !== "error" && finalAssistant?.stopReason !== "aborted";
|
|
31
|
+
return {
|
|
32
|
+
command: workflow.command,
|
|
33
|
+
fullCommand: workflow.fullCommand,
|
|
34
|
+
args: workflow.args,
|
|
35
|
+
success,
|
|
36
|
+
durationMs: this.now() - workflow.startedAt,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
reset(): void {
|
|
41
|
+
this.active = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/workflow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Structured development workflow commands for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": "Neuron Mr White",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "npx bun test lifecycle.test.ts tests"
|
|
11
|
+
},
|
|
9
12
|
"repository": {
|
|
10
13
|
"type": "git",
|
|
11
14
|
"url": "git+https://github.com/Neuron-Mr-White/unipi.git",
|
|
@@ -19,6 +22,7 @@
|
|
|
19
22
|
],
|
|
20
23
|
"files": [
|
|
21
24
|
"*.ts",
|
|
25
|
+
"!*.test.ts",
|
|
22
26
|
"skills/**/*",
|
|
23
27
|
"README.md"
|
|
24
28
|
],
|
|
@@ -26,7 +30,7 @@
|
|
|
26
30
|
"access": "public"
|
|
27
31
|
},
|
|
28
32
|
"dependencies": {
|
|
29
|
-
"@pi-unipi/core": "2.
|
|
33
|
+
"@pi-unipi/core": "2.5.0"
|
|
30
34
|
},
|
|
31
35
|
"peerDependencies": {
|
|
32
36
|
"@earendil-works/pi-ai": "^0.80.0",
|