@dieulc/workflow 0.2.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/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@dieulc/workflow",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "keywords": ["pi-package", "workflow", "plan-mode", "questionnaire", "rewind"],
7
+ "description": "Plan↔Build mode, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
8
+ "license": "MIT",
9
+ "scripts": {
10
+ "test": "node --test utils.todo.test.ts",
11
+ "typecheck": "tsc --noEmit -p tsconfig.json"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/dieuluucanh/pi-workflow.git",
16
+ "directory": "agent/extensions/workflow"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/dieuluucanh/pi-workflow/issues"
20
+ },
21
+ "homepage": "https://github.com/dieuluucanh/pi-workflow#readme",
22
+ "pi": {
23
+ "extensions": ["./index.ts"]
24
+ },
25
+ "files": [
26
+ "index.ts",
27
+ "utils.ts",
28
+ "checkpoint.ts",
29
+ "roles.ts",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "engines": {
37
+ "node": ">=22.19.0"
38
+ },
39
+ "dependencies": {},
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-agent-core": "*",
42
+ "@earendil-works/pi-ai": "*",
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "@earendil-works/pi-tui": "*",
45
+ "typebox": "*"
46
+ },
47
+ "devDependencies": {
48
+ "@earendil-works/pi-agent-core": "0.84.4",
49
+ "@earendil-works/pi-ai": "0.84.4",
50
+ "@earendil-works/pi-coding-agent": "0.84.4",
51
+ "@earendil-works/pi-tui": "0.84.4",
52
+ "typebox": "1.3.7",
53
+ "@types/node": "^20.19.43",
54
+ "typescript": "5.5.4"
55
+ }
56
+ }
package/roles.ts ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Workflow Extension — Model Roles
3
+ *
4
+ * Role registry with one model per role.
5
+ *
6
+ * - Built-in roles: planner / explorer / builder (extensible via roles.json)
7
+ * - Persistence: ~/.pi/agent/roles.json (user) + <cwd>/.pi/roles.json (project override)
8
+ * - Config: roles.json holds exactly one {provider, id, thinking} per role
9
+ * - v2 schema: legacy v1 `modelPool` files are discarded and reseeded
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
15
+
16
+ // ── Types ────────────────────────────────────────────────────────────
17
+
18
+ export type ThinkingLevel =
19
+ | "off"
20
+ | "minimal"
21
+ | "low"
22
+ | "medium"
23
+ | "high"
24
+ | "xhigh"
25
+ | "max";
26
+
27
+ export interface RoleModel {
28
+ provider: string;
29
+ id: string;
30
+ thinking: ThinkingLevel;
31
+ }
32
+
33
+ export interface Role {
34
+ name: string;
35
+ description: string;
36
+ /** Single model for this role (pool idea removed — see roles.json v2). */
37
+ model: RoleModel;
38
+ /** tool allowlist; empty = inherit all */
39
+ tools: string[];
40
+ systemPromptAddendum: string;
41
+ builtIn: boolean;
42
+ }
43
+
44
+ export interface RoleConfig {
45
+ version: number;
46
+ roles: Role[];
47
+ /** null = follow workflow mode default; otherwise force a role */
48
+ activeRole: string | null;
49
+ }
50
+
51
+ export const ROLE_CONFIG_VERSION = 2;
52
+ export const BUILT_IN_ROLES = ["planner", "explorer", "builder"] as const;
53
+
54
+ export const VALID_THINKING_LEVELS: ThinkingLevel[] = [
55
+ "off",
56
+ "minimal",
57
+ "low",
58
+ "medium",
59
+ "high",
60
+ "xhigh",
61
+ "max",
62
+ ];
63
+
64
+ export function isValidThinkingLevel(s: string): s is ThinkingLevel {
65
+ return (VALID_THINKING_LEVELS as string[]).includes(s);
66
+ }
67
+
68
+ // ── Defaults (seeded from the user's enabledModels so they resolve) ──
69
+
70
+ export function seedDefaultRoles(): Role[] {
71
+ return [
72
+ {
73
+ name: "planner",
74
+ description:
75
+ "Frontier reasoning model for planning, orchestration, and ambiguous decisions",
76
+ model: {
77
+ provider: "opencode-go",
78
+ id: "muse-spark-1.2-contributor",
79
+ thinking: "high",
80
+ },
81
+ tools: [],
82
+ systemPromptAddendum:
83
+ "You are in PLANNER role. Focus on reasoning, architecture, and planning. Delegate implementation to Builder and research to Explorer.",
84
+ builtIn: true,
85
+ },
86
+ {
87
+ name: "explorer",
88
+ description:
89
+ "Fast, cheap model for read-only codebase exploration, web search, and summarization",
90
+ model: {
91
+ provider: "opencode",
92
+ id: "muse-spark-1.3-contributor-free",
93
+ thinking: "low",
94
+ },
95
+ tools: ["read", "grep", "find", "ls", "bash"],
96
+ systemPromptAddendum:
97
+ "You are in EXPLORER role. Read-only research only. Do NOT edit/write. Return concise findings.",
98
+ builtIn: true,
99
+ },
100
+ {
101
+ name: "builder",
102
+ description:
103
+ "Capable model for code generation following a plan, with light reasoning",
104
+ model: {
105
+ provider: "opencode-go",
106
+ id: "muse-spark-1.2-contributor",
107
+ thinking: "medium",
108
+ },
109
+ tools: [],
110
+ systemPromptAddendum:
111
+ "You are in BUILDER role. Implement the assigned task following the plan. Reason lightly. Ask if blocked.",
112
+ builtIn: true,
113
+ },
114
+ ];
115
+ }
116
+
117
+ export function defaultRoleConfig(): RoleConfig {
118
+ return {
119
+ version: ROLE_CONFIG_VERSION,
120
+ roles: seedDefaultRoles(),
121
+ activeRole: null,
122
+ };
123
+ }
124
+
125
+ // ── Paths & persistence ──────────────────────────────────────────────
126
+
127
+ export function userRolesPath(): string {
128
+ return path.join(getAgentDir(), "roles.json");
129
+ }
130
+
131
+ export function projectRolesPath(cwd: string): string {
132
+ return path.join(cwd, CONFIG_DIR_NAME, "roles.json");
133
+ }
134
+
135
+ function readJsonFile(fp: string): any | undefined {
136
+ try {
137
+ if (!fs.existsSync(fp)) return undefined;
138
+ return JSON.parse(fs.readFileSync(fp, "utf8"));
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ function sanitizeModel(m: any, fallbackThinking: ThinkingLevel): RoleModel | undefined {
145
+ if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
146
+ return undefined;
147
+ if (!m.provider.trim() || !m.id.trim()) return undefined;
148
+ return {
149
+ provider: m.provider.trim(),
150
+ id: m.id.trim(),
151
+ thinking: isValidThinkingLevel(String(m.thinking))
152
+ ? (String(m.thinking) as ThinkingLevel)
153
+ : fallbackThinking,
154
+ };
155
+ }
156
+
157
+ function sanitizeRole(r: any): Role | undefined {
158
+ if (!r || typeof r.name !== "string" || !r.name.trim()) return undefined;
159
+ // v2 schema only — legacy v1 `modelPool` arrays are rejected so the
160
+ // file gets reseeded with single-model defaults (reset, no migration).
161
+ if (Array.isArray((r as any).modelPool)) return undefined;
162
+ const model = sanitizeModel((r as any).model, "medium");
163
+ if (!model) return undefined;
164
+ return {
165
+ name: r.name.trim().toLowerCase(),
166
+ description:
167
+ typeof r.description === "string" ? r.description : "Custom role",
168
+ model,
169
+ tools: Array.isArray(r.tools)
170
+ ? r.tools.filter((t: any) => typeof t === "string")
171
+ : [],
172
+ systemPromptAddendum:
173
+ typeof r.systemPromptAddendum === "string"
174
+ ? r.systemPromptAddendum
175
+ : `You are in ${r.name} role.`,
176
+ builtIn: false,
177
+ };
178
+ }
179
+
180
+ function isCurrentVersion(raw: any): boolean {
181
+ return (
182
+ raw &&
183
+ typeof raw === "object" &&
184
+ (raw as any).version === ROLE_CONFIG_VERSION
185
+ );
186
+ }
187
+
188
+ function mergeRoles(base: Role[], rawRoles: unknown): Role[] {
189
+ if (!Array.isArray(rawRoles)) return base;
190
+ const byName = new Map<string, Role>();
191
+ for (const r of base) byName.set(r.name, r);
192
+ for (const r of rawRoles as any[]) {
193
+ const s = sanitizeRole(r);
194
+ if (s) {
195
+ // preserve builtIn flag for the three built-ins
196
+ if ((BUILT_IN_ROLES as readonly string[]).includes(s.name))
197
+ s.builtIn = true;
198
+ byName.set(s.name, s);
199
+ }
200
+ }
201
+ return [...byName.values()];
202
+ }
203
+
204
+ function seedUserFile(fp: string, base: RoleConfig): void {
205
+ try {
206
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
207
+ fs.writeFileSync(fp, JSON.stringify(base, null, 2), "utf8");
208
+ } catch {
209
+ /* best effort */
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Load merged role config: user file + project override (per-role replace).
215
+ * v1 `modelPool` files (or any non-v2 version) are discarded and reseeded
216
+ * with single-model defaults. Never throws — falls back to in-memory
217
+ * defaults on corrupt config.
218
+ */
219
+ export function loadRoleConfig(cwd?: string): RoleConfig {
220
+ const base = defaultRoleConfig();
221
+ try {
222
+ const fp = userRolesPath();
223
+ const raw = readJsonFile(fp);
224
+ if (raw === undefined) {
225
+ // first run — seed file
226
+ seedUserFile(fp, base);
227
+ } else if (!isCurrentVersion(raw)) {
228
+ // legacy v1 (modelPool) or unknown version — reset to defaults
229
+ seedUserFile(fp, base);
230
+ } else {
231
+ base.roles = mergeRoles(base.roles, (raw as any).roles);
232
+ if (
233
+ typeof (raw as any).activeRole === "string" ||
234
+ (raw as any).activeRole === null
235
+ )
236
+ base.activeRole = (raw as any).activeRole;
237
+ }
238
+ } catch {
239
+ /* fall through with defaults */
240
+ }
241
+ // project override: per-role replace + activeRole if present.
242
+ // Legacy project files are ignored (never reseeded — user-owned).
243
+ try {
244
+ if (cwd) {
245
+ const praw = readJsonFile(projectRolesPath(cwd));
246
+ if (praw && typeof praw === "object" && isCurrentVersion(praw)) {
247
+ base.roles = mergeRoles(base.roles, (praw as any).roles);
248
+ if (
249
+ typeof (praw as any).activeRole === "string" ||
250
+ (praw as any).activeRole === null
251
+ )
252
+ base.activeRole = (praw as any).activeRole;
253
+ }
254
+ }
255
+ } catch {
256
+ /* ignore project errors */
257
+ }
258
+ return base;
259
+ }
260
+
261
+ export function saveRoleConfig(
262
+ config: RoleConfig,
263
+ scope: "user" | "project",
264
+ cwd?: string,
265
+ ): void {
266
+ const payload: RoleConfig = {
267
+ version: ROLE_CONFIG_VERSION,
268
+ roles: config.roles,
269
+ activeRole: config.activeRole,
270
+ };
271
+ const fp =
272
+ scope === "project" && cwd ? projectRolesPath(cwd) : userRolesPath();
273
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
274
+ fs.writeFileSync(fp, JSON.stringify(payload, null, 2), "utf8");
275
+ }
276
+
277
+ // ── Lookup & model refs ──────────────────────────────────────────────
278
+
279
+ export function getRole(config: RoleConfig, name: string): Role | undefined {
280
+ return config.roles.find(
281
+ (r) => r.name.toLowerCase() === String(name).toLowerCase(),
282
+ );
283
+ }
284
+
285
+ /** Parse "provider/model" or bare "model" (provider = ""). */
286
+ export function parseModelRef(ref: string): { provider: string; id: string } {
287
+ const s = String(ref).trim();
288
+ const slash = s.indexOf("/");
289
+ if (slash > 0) return { provider: s.slice(0, slash), id: s.slice(slash + 1) };
290
+ return { provider: "", id: s };
291
+ }
292
+
293
+ export function formatModelRef(m: RoleModel): string {
294
+ return `${m.provider}/${m.id}`;
295
+ }
296
+
297
+ // ── Display ──────────────────────────────────────────────────────────
298
+
299
+ const ROLE_ICONS: Record<string, string> = {
300
+ planner: "🧠",
301
+ explorer: "🔍",
302
+ builder: "🔨",
303
+ };
304
+
305
+ export function roleIcon(name: string): string {
306
+ return ROLE_ICONS[name.toLowerCase()] ?? "⚙️";
307
+ }
308
+
309
+ export function formatRolesForDisplay(config: RoleConfig): string {
310
+ const lines: string[] = [];
311
+ lines.push("Model Roles (one model per role):");
312
+ lines.push(` Active: ${config.activeRole ?? "auto (plan→planner, build→builder)"}`);
313
+ lines.push("");
314
+ for (const r of config.roles) {
315
+ lines.push(
316
+ ` ${roleIcon(r.name)} ${r.name}: ${r.model.provider}/${r.model.id} (${r.model.thinking})`,
317
+ );
318
+ lines.push(` ${r.description}`);
319
+ }
320
+ lines.push("");
321
+ lines.push(
322
+ "Usage: /role (picker) | /role set <role> <provider/model> [thinking] | /role use <role|auto> | /role add|remove|reset",
323
+ );
324
+ return lines.join("\n");
325
+ }