@dieulc/workflow 0.2.0 → 0.3.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 +188 -6
- package/index.ts +1021 -65
- package/package.json +9 -4
- package/permissions.ts +1042 -0
- package/review-runtime.ts +931 -0
- package/review-tools.ts +749 -0
- package/review-ui.ts +421 -0
- package/review.ts +1704 -0
- package/roles.ts +164 -57
- package/utils.ts +474 -92
package/roles.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Role registry with one model per role.
|
|
5
5
|
*
|
|
6
|
-
* - Built-in roles: planner / explorer / builder (extensible via roles.json)
|
|
6
|
+
* - Built-in roles: planner / explorer / builder / reviewer (extensible via roles.json)
|
|
7
7
|
* - Persistence: ~/.pi/agent/roles.json (user) + <cwd>/.pi/roles.json (project override)
|
|
8
8
|
* - Config: roles.json holds exactly one {provider, id, thinking} per role
|
|
9
|
-
* - v2 schema: legacy v1 `modelPool` files are
|
|
9
|
+
* - v2 schema: legacy v1 `modelPool` files are ignored (in-memory defaults
|
|
10
|
+
* are used and the file is never rewritten)
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import * as fs from "node:fs";
|
|
@@ -49,7 +50,19 @@ export interface RoleConfig {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export const ROLE_CONFIG_VERSION = 2;
|
|
52
|
-
export const BUILT_IN_ROLES = [
|
|
53
|
+
export const BUILT_IN_ROLES = [
|
|
54
|
+
"planner",
|
|
55
|
+
"explorer",
|
|
56
|
+
"builder",
|
|
57
|
+
"reviewer",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Role that Review Mode runs in its own in-process child session.
|
|
62
|
+
* Deliberately NOT wired into MODE_ROLE_MAP: the parent session must never
|
|
63
|
+
* switch to this model (Review Mode has its own session).
|
|
64
|
+
*/
|
|
65
|
+
export const REVIEW_ROLE_NAME = "reviewer";
|
|
53
66
|
|
|
54
67
|
export const VALID_THINKING_LEVELS: ThinkingLevel[] = [
|
|
55
68
|
"off",
|
|
@@ -111,6 +124,22 @@ export function seedDefaultRoles(): Role[] {
|
|
|
111
124
|
"You are in BUILDER role. Implement the assigned task following the plan. Reason lightly. Ask if blocked.",
|
|
112
125
|
builtIn: true,
|
|
113
126
|
},
|
|
127
|
+
{
|
|
128
|
+
// Seeded with a DIFFERENT model than planner so Review Mode brings a
|
|
129
|
+
// genuinely independent perspective (see docs: Review Mode).
|
|
130
|
+
name: "reviewer",
|
|
131
|
+
description:
|
|
132
|
+
"Independent auditor for Review Mode — reviews and rewrites plans before the user sees them",
|
|
133
|
+
model: {
|
|
134
|
+
provider: "opencode-go",
|
|
135
|
+
id: "muse-spark-1.3-contributor",
|
|
136
|
+
thinking: "xhigh",
|
|
137
|
+
},
|
|
138
|
+
tools: ["read", "grep", "find", "ls"],
|
|
139
|
+
systemPromptAddendum:
|
|
140
|
+
"You are in REVIEWER role. Independently audit the plan. Always align with the existing project framework and industry best practice. Flag divergence from established conventions. Do not write code.",
|
|
141
|
+
builtIn: true,
|
|
142
|
+
},
|
|
114
143
|
];
|
|
115
144
|
}
|
|
116
145
|
|
|
@@ -132,16 +161,59 @@ export function projectRolesPath(cwd: string): string {
|
|
|
132
161
|
return path.join(cwd, CONFIG_DIR_NAME, "roles.json");
|
|
133
162
|
}
|
|
134
163
|
|
|
135
|
-
function
|
|
164
|
+
function fileFingerprint(fp: string): string {
|
|
136
165
|
try {
|
|
137
|
-
|
|
138
|
-
return
|
|
139
|
-
} catch {
|
|
140
|
-
|
|
166
|
+
const st = fs.statSync(fp);
|
|
167
|
+
return `${st.mtimeMs}:${st.size}`;
|
|
168
|
+
} catch (e: unknown) {
|
|
169
|
+
const code = (e as { code?: string } | undefined)?.code;
|
|
170
|
+
return code === "ENOENT" ? "missing" : "unreadable";
|
|
141
171
|
}
|
|
142
172
|
}
|
|
143
173
|
|
|
144
|
-
|
|
174
|
+
/** Optional path overrides (tests); production reads the real agent/cwd files. */
|
|
175
|
+
export interface RoleConfigPathOverrides {
|
|
176
|
+
userPath?: string;
|
|
177
|
+
projectPath?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Cheap change detector for the two roles files (existence + mtime + size).
|
|
182
|
+
* Compare two fingerprints to decide whether a reload is needed. Never throws.
|
|
183
|
+
*/
|
|
184
|
+
export function rolesFingerprint(
|
|
185
|
+
cwd?: string,
|
|
186
|
+
overrides?: RoleConfigPathOverrides,
|
|
187
|
+
): string {
|
|
188
|
+
const user = fileFingerprint(overrides?.userPath ?? userRolesPath());
|
|
189
|
+
const projectPath =
|
|
190
|
+
overrides?.projectPath ?? (cwd ? projectRolesPath(cwd) : undefined);
|
|
191
|
+
const project = projectPath ? fileFingerprint(projectPath) : "n/a";
|
|
192
|
+
return `user:${user}|project:${project}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface JsonFileRead {
|
|
196
|
+
exists: boolean;
|
|
197
|
+
raw?: unknown;
|
|
198
|
+
error?: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readJsonFileDetailed(fp: string): JsonFileRead {
|
|
202
|
+
if (!fs.existsSync(fp)) return { exists: false };
|
|
203
|
+
try {
|
|
204
|
+
return { exists: true, raw: JSON.parse(fs.readFileSync(fp, "utf8")) };
|
|
205
|
+
} catch (e: unknown) {
|
|
206
|
+
return {
|
|
207
|
+
exists: true,
|
|
208
|
+
error: e instanceof Error ? e.message : String(e),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function sanitizeModel(
|
|
214
|
+
m: any,
|
|
215
|
+
fallbackThinking: ThinkingLevel,
|
|
216
|
+
): RoleModel | undefined {
|
|
145
217
|
if (!m || typeof m.provider !== "string" || typeof m.id !== "string")
|
|
146
218
|
return undefined;
|
|
147
219
|
if (!m.provider.trim() || !m.id.trim()) return undefined;
|
|
@@ -156,8 +228,8 @@ function sanitizeModel(m: any, fallbackThinking: ThinkingLevel): RoleModel | und
|
|
|
156
228
|
|
|
157
229
|
function sanitizeRole(r: any): Role | undefined {
|
|
158
230
|
if (!r || typeof r.name !== "string" || !r.name.trim()) return undefined;
|
|
159
|
-
// v2 schema only — legacy v1 `modelPool`
|
|
160
|
-
//
|
|
231
|
+
// v2 schema only — legacy v1 `modelPool` entries are ignored (the role
|
|
232
|
+
// falls back to its built-in default; the file itself is left untouched).
|
|
161
233
|
if (Array.isArray((r as any).modelPool)) return undefined;
|
|
162
234
|
const model = sanitizeModel((r as any).model, "medium");
|
|
163
235
|
if (!model) return undefined;
|
|
@@ -201,61 +273,93 @@ function mergeRoles(base: Role[], rawRoles: unknown): Role[] {
|
|
|
201
273
|
return [...byName.values()];
|
|
202
274
|
}
|
|
203
275
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
276
|
+
/** Result of {@link loadRoleConfigDetailed}. */
|
|
277
|
+
export interface LoadedRoleConfig {
|
|
278
|
+
config: RoleConfig;
|
|
279
|
+
/** True when neither the user nor a project file supplied usable roles. */
|
|
280
|
+
usedDefaults: boolean;
|
|
281
|
+
/** Why the user file was skipped (missing / unreadable / legacy). */
|
|
282
|
+
reason?: string;
|
|
283
|
+
/** Fingerprint of the roles files at read time (see rolesFingerprint). */
|
|
284
|
+
fingerprint: string;
|
|
211
285
|
}
|
|
212
286
|
|
|
213
287
|
/**
|
|
214
|
-
* Load merged role config
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* defaults
|
|
288
|
+
* Load the merged role config (user file + project override, per-role replace).
|
|
289
|
+
*
|
|
290
|
+
* Non-destructive by design: a missing, unreadable, corrupt, or legacy
|
|
291
|
+
* `roles.json` is never rewritten. In-memory defaults are used instead and the
|
|
292
|
+
* reason is reported via `usedDefaults`/`reason`, so callers can warn without
|
|
293
|
+
* risking the user's file. Never throws.
|
|
218
294
|
*/
|
|
219
|
-
export function
|
|
295
|
+
export function loadRoleConfigDetailed(
|
|
296
|
+
cwd?: string,
|
|
297
|
+
overrides?: RoleConfigPathOverrides,
|
|
298
|
+
): LoadedRoleConfig {
|
|
220
299
|
const base = defaultRoleConfig();
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
300
|
+
const fingerprint = rolesFingerprint(cwd, overrides);
|
|
301
|
+
let reason: string | undefined;
|
|
302
|
+
let userUsable = false;
|
|
303
|
+
|
|
304
|
+
const userPath = overrides?.userPath ?? userRolesPath();
|
|
305
|
+
const userRead = readJsonFileDetailed(userPath);
|
|
306
|
+
if (!userRead.exists) {
|
|
307
|
+
reason = `no roles.json at ${userPath} — using in-memory defaults`;
|
|
308
|
+
} else if (userRead.error) {
|
|
309
|
+
reason = `roles.json is not valid JSON (${userRead.error}) — using in-memory defaults`;
|
|
310
|
+
} else if (!isCurrentVersion(userRead.raw)) {
|
|
311
|
+
const v = (userRead.raw as { version?: unknown } | undefined)?.version;
|
|
312
|
+
reason = `incompatible roles.json${
|
|
313
|
+
v === undefined ? "" : ` (version ${String(v)})`
|
|
314
|
+
} — using in-memory defaults`;
|
|
315
|
+
} else {
|
|
316
|
+
base.roles = mergeRoles(base.roles, (userRead.raw as any).roles);
|
|
317
|
+
if (
|
|
318
|
+
typeof (userRead.raw as any).activeRole === "string" ||
|
|
319
|
+
(userRead.raw as any).activeRole === null
|
|
320
|
+
) {
|
|
321
|
+
base.activeRole = (userRead.raw as any).activeRole;
|
|
237
322
|
}
|
|
238
|
-
|
|
239
|
-
/* fall through with defaults */
|
|
323
|
+
userUsable = true;
|
|
240
324
|
}
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
325
|
+
|
|
326
|
+
// Project override: per-role replace + activeRole if present. A bad or
|
|
327
|
+
// legacy project file is ignored and never rewritten (it is user-owned).
|
|
328
|
+
let projectUsable = false;
|
|
329
|
+
const projectPath =
|
|
330
|
+
overrides?.projectPath ?? (cwd ? projectRolesPath(cwd) : undefined);
|
|
331
|
+
if (projectPath) {
|
|
332
|
+
const projectRead = readJsonFileDetailed(projectPath);
|
|
333
|
+
if (
|
|
334
|
+
projectRead.exists &&
|
|
335
|
+
!projectRead.error &&
|
|
336
|
+
isCurrentVersion(projectRead.raw)
|
|
337
|
+
) {
|
|
338
|
+
base.roles = mergeRoles(base.roles, (projectRead.raw as any).roles);
|
|
339
|
+
if (
|
|
340
|
+
typeof (projectRead.raw as any).activeRole === "string" ||
|
|
341
|
+
(projectRead.raw as any).activeRole === null
|
|
342
|
+
) {
|
|
343
|
+
base.activeRole = (projectRead.raw as any).activeRole;
|
|
253
344
|
}
|
|
345
|
+
projectUsable = true;
|
|
254
346
|
}
|
|
255
|
-
} catch {
|
|
256
|
-
/* ignore project errors */
|
|
257
347
|
}
|
|
258
|
-
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
config: base,
|
|
351
|
+
usedDefaults: !userUsable && !projectUsable,
|
|
352
|
+
...(reason === undefined ? {} : { reason }),
|
|
353
|
+
fingerprint,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Convenience wrapper returning just the config. Never throws. */
|
|
358
|
+
export function loadRoleConfig(
|
|
359
|
+
cwd?: string,
|
|
360
|
+
overrides?: RoleConfigPathOverrides,
|
|
361
|
+
): RoleConfig {
|
|
362
|
+
return loadRoleConfigDetailed(cwd, overrides).config;
|
|
259
363
|
}
|
|
260
364
|
|
|
261
365
|
export function saveRoleConfig(
|
|
@@ -300,6 +404,7 @@ const ROLE_ICONS: Record<string, string> = {
|
|
|
300
404
|
planner: "🧠",
|
|
301
405
|
explorer: "🔍",
|
|
302
406
|
builder: "🔨",
|
|
407
|
+
reviewer: "🧪",
|
|
303
408
|
};
|
|
304
409
|
|
|
305
410
|
export function roleIcon(name: string): string {
|
|
@@ -309,7 +414,9 @@ export function roleIcon(name: string): string {
|
|
|
309
414
|
export function formatRolesForDisplay(config: RoleConfig): string {
|
|
310
415
|
const lines: string[] = [];
|
|
311
416
|
lines.push("Model Roles (one model per role):");
|
|
312
|
-
lines.push(
|
|
417
|
+
lines.push(
|
|
418
|
+
` Active: ${config.activeRole ?? "auto (plan→planner, build→builder)"}`,
|
|
419
|
+
);
|
|
313
420
|
lines.push("");
|
|
314
421
|
for (const r of config.roles) {
|
|
315
422
|
lines.push(
|