@dieulc/workflow 0.2.0 → 0.3.1

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/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 discarded and reseeded
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 = ["planner", "explorer", "builder"] as const;
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 readJsonFile(fp: string): any | undefined {
164
+ function fileFingerprint(fp: string): string {
136
165
  try {
137
- if (!fs.existsSync(fp)) return undefined;
138
- return JSON.parse(fs.readFileSync(fp, "utf8"));
139
- } catch {
140
- return undefined;
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
- function sanitizeModel(m: any, fallbackThinking: ThinkingLevel): RoleModel | undefined {
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` arrays are rejected so the
160
- // file gets reseeded with single-model defaults (reset, no migration).
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
- 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
- }
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: 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.
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 loadRoleConfig(cwd?: string): RoleConfig {
295
+ export function loadRoleConfigDetailed(
296
+ cwd?: string,
297
+ overrides?: RoleConfigPathOverrides,
298
+ ): LoadedRoleConfig {
220
299
  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;
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
- } catch {
239
- /* fall through with defaults */
323
+ userUsable = true;
240
324
  }
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;
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
- return base;
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(` Active: ${config.activeRole ?? "auto (plan→planner, build→builder)"}`);
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(