piher 0.0.0-stage → 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.
Files changed (60) hide show
  1. package/README.md +60 -2
  2. package/package.json +58 -4
  3. package/schemas/role-manifest.schema.json +62 -0
  4. package/skills/piher-tools/SKILL.md +212 -0
  5. package/src/ansi-text.ts +143 -0
  6. package/src/bg-job-core.ts +412 -0
  7. package/src/bootstrap.ts +102 -0
  8. package/src/config-catalog-core.ts +200 -0
  9. package/src/config-command.ts +324 -0
  10. package/src/cordis-services.ts +38 -0
  11. package/src/core-client.ts +715 -0
  12. package/src/delegation-core.ts +94 -0
  13. package/src/gc-core.ts +150 -0
  14. package/src/guards.ts +6 -0
  15. package/src/hc-templates.ts +134 -0
  16. package/src/index-locks.ts +118 -0
  17. package/src/index-master.ts +92 -0
  18. package/src/index-notices.ts +79 -0
  19. package/src/index-pipe.ts +71 -0
  20. package/src/index-roles.ts +265 -0
  21. package/src/index-runtime.ts +23 -0
  22. package/src/index.ts +460 -0
  23. package/src/ledger.ts +61 -0
  24. package/src/lock-core.ts +200 -0
  25. package/src/manifest-compose.ts +103 -0
  26. package/src/options.ts +78 -0
  27. package/src/pi-surface.ts +140 -0
  28. package/src/pipe-channel.ts +291 -0
  29. package/src/platform-paths.ts +37 -0
  30. package/src/plugins/bg-job.ts +560 -0
  31. package/src/plugins/subagent.ts +1244 -0
  32. package/src/prompt-store.ts +50 -0
  33. package/src/renderers.ts +195 -0
  34. package/src/role-catalog.ts +99 -0
  35. package/src/role-loader.ts +196 -0
  36. package/src/role-manifest.ts +163 -0
  37. package/src/role-state.ts +119 -0
  38. package/src/roles/master.json +23 -0
  39. package/src/roles/worker-default.json +23 -0
  40. package/src/runtime-policy.ts +45 -0
  41. package/src/session-state.ts +19 -0
  42. package/src/session-tail.ts +276 -0
  43. package/src/settle-wake-core.ts +73 -0
  44. package/src/settlement-card.ts +96 -0
  45. package/src/storage-layout.ts +92 -0
  46. package/src/subagent-core.ts +543 -0
  47. package/src/subagent-gc.ts +312 -0
  48. package/src/subagent-poller.ts +484 -0
  49. package/src/subagent-session.ts +548 -0
  50. package/src/subagent-spawn.ts +695 -0
  51. package/src/swallow.ts +59 -0
  52. package/src/task-row.ts +257 -0
  53. package/src/task-store.ts +173 -0
  54. package/src/tool-error.ts +19 -0
  55. package/src/tool-gate.ts +99 -0
  56. package/src/tree-view.ts +58 -0
  57. package/src/vocab.ts +41 -0
  58. package/src/worker-marker.ts +65 -0
  59. package/src/worktree-io.ts +177 -0
  60. package/src/worktree.ts +280 -0
@@ -0,0 +1,50 @@
1
+ /** The prompt a task was spawned with, kept so a retry costs no tokens: an upstream failure (a
2
+ * provider outage, a dropped stream) is nobody's fault in the task, and the master should not have to
3
+ * regenerate a whole brief to try again. One file per task, next to piher's other user state. */
4
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ export function taskPromptsDir(): string {
9
+ // pi's own agent dir wins when set (a test home, a second profile), matching how piher locates
10
+ // sessions and roles elsewhere.
11
+ const base = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
12
+ return join(base, 'piher', 'prompts');
13
+ }
14
+
15
+ function promptPath(taskId: string): string {
16
+ // A task id is a uuid or a kebab slug; anything else cannot address a file here.
17
+ const safe = taskId.replace(/[^A-Za-z0-9._-]/g, '_');
18
+ return join(taskPromptsDir(), `${safe}.md`);
19
+ }
20
+
21
+ /** Best effort by design: a task that cannot be retried is a smaller problem than a spawn that fails
22
+ * because a state file was unwritable. */
23
+ export function saveTaskPrompt(taskId: string, prompt: string): void {
24
+ if (!taskId || prompt === '') return;
25
+ try {
26
+ mkdirSync(taskPromptsDir(), { recursive: true });
27
+ writeFileSync(promptPath(taskId), prompt, 'utf8');
28
+ } catch {
29
+ /* no stored prompt: `retry` reports it and the master sends the task explicitly */
30
+ }
31
+ }
32
+
33
+ export function readTaskPrompt(taskId: string): string | null {
34
+ const file = promptPath(taskId);
35
+ if (!existsSync(file)) return null;
36
+ try {
37
+ const text = readFileSync(file, 'utf8');
38
+ return text.trim() === '' ? null : text;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export function forgetTaskPrompt(taskId: string): void {
45
+ try {
46
+ rmSync(promptPath(taskId), { force: true });
47
+ } catch {
48
+ /* nothing to forget */
49
+ }
50
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * TUI renderers for piher's own session custom entries and reminder messages: raw JSON rows bury the
3
+ * one line that matters. Pure string builders plus an ANSI-aware truncator, so no pi-tui import is
4
+ * needed and older pi builds degrade to "no renderer".
5
+ */
6
+
7
+ import { DELEGATION_CUSTOM_TYPE } from './delegation-core.ts';
8
+ import { SETTLEMENT_CUSTOM_TYPE, formatElapsed, type SettlementCard } from './settlement-card.ts';
9
+ import { JOB_NOTICE_CUSTOM_TYPE, formatDuration } from './bg-job-core.ts';
10
+
11
+ // Width handling lives in ansi-text.ts so the transcript cards need no pi-tui import.
12
+ import { truncateStyled } from './ansi-text.ts';
13
+
14
+ /** Role manifest entry is written by index-roles.ts; keep the literal here to avoid an import cycle. */
15
+ export const ROLE_MANIFEST_CUSTOM_TYPE = 'piher.role-manifest';
16
+
17
+ /** Structural subset of pi's Theme actually used here. */
18
+ export interface RenderTheme {
19
+ fg(color: string, text: string): string;
20
+ bold(text: string): string;
21
+ }
22
+
23
+ export interface RenderComponent {
24
+ render(width: number): string[];
25
+ invalidate(): void;
26
+ }
27
+
28
+ type Renderer = (payload: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent | undefined;
29
+
30
+ export interface RendererApi {
31
+ registerEntryRenderer?(customType: string, renderer: Renderer): void;
32
+ registerMessageRenderer?(customType: string, renderer: Renderer): void;
33
+ }
34
+
35
+ function clip(text: string, max: number): string {
36
+ const flat = text.replace(/\s+/g, ' ').trim();
37
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
38
+ }
39
+
40
+ function card(lines: readonly string[]): RenderComponent {
41
+ return {
42
+ render: (width: number) => lines.map((line) => truncateStyled(line, width)),
43
+ invalidate() { /* No cached state. */ },
44
+ };
45
+ }
46
+
47
+ /** First-frame delegation card in the worker's own transcript (RFC rfc-delegation-identity §2.3). */
48
+ function delegationLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
49
+ const rec = (data ?? {}) as {
50
+ parentPaneId?: unknown;
51
+ parentSessionId?: unknown;
52
+ taskId?: unknown;
53
+ description?: unknown;
54
+ role?: unknown;
55
+ };
56
+ const parent = typeof rec.parentPaneId === 'string' && rec.parentPaneId ? rec.parentPaneId : '?';
57
+ const head = `${theme.fg('accent', '⟵ delegated')} ${theme.fg('dim', `by master ${parent} · ${clip(String(rec.description ?? ''), 60)}`)}`;
58
+ if (!expanded) return [head];
59
+ const session = typeof rec.parentSessionId === 'string' && rec.parentSessionId ? rec.parentSessionId : '?';
60
+ const role = typeof rec.role === 'string' && rec.role ? rec.role : '?';
61
+ return [
62
+ head,
63
+ theme.fg('dim', ` task ${typeof rec.taskId === 'string' ? rec.taskId : '?'} · role ${role} · parent session ${session}`),
64
+ ];
65
+ }
66
+
67
+ function roleManifestLines(data: unknown, theme: RenderTheme): string[] {
68
+ const rec = (data ?? {}) as {
69
+ role?: unknown;
70
+ manifestVersion?: unknown;
71
+ tools?: unknown;
72
+ permissions?: unknown;
73
+ origin?: unknown;
74
+ switchedBy?: unknown;
75
+ };
76
+ const role = typeof rec.role === 'string' ? rec.role : '?';
77
+ const version = typeof rec.manifestVersion === 'string' || typeof rec.manifestVersion === 'number' ? String(rec.manifestVersion) : '?';
78
+ const tools = Array.isArray(rec.tools) ? rec.tools.length : 0;
79
+ const gates = Object.values((rec.permissions ?? {}) as Record<string, unknown>)
80
+ .filter((v) => v === 'deny').length;
81
+ const tail = gates > 0 ? theme.fg('warning', ` · ${gates} gated`) : '';
82
+ // P0: switch provenance — origin 'switch' marks a mid-session swap (vs the session_start anchor).
83
+ const switched =
84
+ rec.origin === 'switch'
85
+ ? theme.fg('accent', ` ⇄ ${typeof rec.switchedBy === 'string' && rec.switchedBy ? rec.switchedBy : '?'}`)
86
+ : '';
87
+ return [`${theme.fg('accent', theme.bold('role'))} ${role} ${theme.fg('dim', `v${version} · ${tools} tools`)}${tail}${switched}`];
88
+ }
89
+
90
+ /**
91
+ * Settlement card (S57): the header is what the transcript shows — name, role, outcome, elapsed —
92
+ * and the notice text it carries is never rewritten. Missing fields degrade to the plain content.
93
+ */
94
+ function settlementLines(message: unknown, theme: RenderTheme, expanded: boolean): string[] {
95
+ const rec = (message ?? {}) as { content?: unknown; details?: unknown };
96
+ const details = (rec.details ?? {}) as Partial<SettlementCard>;
97
+ const content = typeof rec.content === 'string' ? rec.content : '';
98
+ if (typeof details.name !== 'string' || details.name === '') {
99
+ return reminderLines(message, theme, 'subagent settled', expanded);
100
+ }
101
+ const outcome = details.outcome === 'failed' || details.outcome === 'closed' ? details.outcome : 'settled';
102
+ const tone = outcome === 'failed' ? 'error' : outcome === 'closed' ? 'warning' : 'success';
103
+ const fields = [
104
+ details.role ?? 'task',
105
+ outcome,
106
+ typeof details.elapsedMs === 'number' ? formatElapsed(details.elapsedMs) : null,
107
+ ].filter((field): field is string => typeof field === 'string');
108
+ const head = `${theme.fg(tone, theme.bold('subagent'))} ${theme.bold(details.name)} ${theme.fg('dim', `· ${fields.join(' · ')}`)}`;
109
+ if (!expanded) return [head];
110
+ return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
111
+ }
112
+
113
+ /** Background job notice: the header is what the transcript shows (outcome, name, elapsed); the
114
+ * body is the notice text minus its own headline, so the card never says the same thing twice. */
115
+ function jobNoticeLines(message: unknown, theme: RenderTheme, expanded: boolean): string[] {
116
+ const rec = (message ?? {}) as { content?: unknown; details?: unknown };
117
+ const details = (rec.details ?? {}) as {
118
+ jobId?: unknown;
119
+ label?: unknown;
120
+ status?: unknown;
121
+ exitCode?: unknown;
122
+ durationMs?: unknown;
123
+ checkIn?: unknown;
124
+ };
125
+ const content = typeof rec.content === 'string' ? rec.content : '';
126
+ const id = typeof details.jobId === 'string' && details.jobId ? details.jobId : 'job';
127
+ const name = typeof details.label === 'string' && details.label ? ` ("${details.label}")` : '';
128
+ const failed = details.status === 'failed'
129
+ || (details.status === 'finished' && details.exitCode !== 0 && details.exitCode !== null);
130
+ const checkIn = details.checkIn === true;
131
+ const icon = checkIn ? theme.fg('warning', '⧗') : failed ? theme.fg('error', '✗') : theme.fg('success', '✓');
132
+ const outcome = checkIn
133
+ ? 'still running'
134
+ : details.status === 'stopped'
135
+ ? 'stopped'
136
+ : failed
137
+ ? `exit ${String(details.exitCode ?? '?')}`
138
+ : 'exit 0';
139
+ const elapsed = typeof details.durationMs === 'number' && details.durationMs >= 0
140
+ ? ` · ${formatDuration(details.durationMs)}`
141
+ : '';
142
+ const head = `${icon} ${theme.fg('accent', theme.bold('bg_job'))} ${theme.bold(id)}${name} ${theme.fg('dim', `· ${outcome}${elapsed}`)}`;
143
+ const body = content.split('\n').slice(1).filter((line) => line.trim() !== '');
144
+ if (body.length === 0) return [head];
145
+ if (expanded) return [head, ...body.map((line) => theme.fg('dim', ` ${line}`))];
146
+ const preview = body.slice(0, 4).map((line) => theme.fg('dim', ` ${clip(line, 110)}`));
147
+ const rest = body.length - preview.length;
148
+ return rest > 0 ? [...preview.length > 0 ? [head, ...preview] : [head], theme.fg('muted', ` … ${rest} more lines`)] : [head, ...preview];
149
+ }
150
+
151
+ /** Reminder custom messages carry the full injected text; restyle without altering it. */
152
+ function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
153
+ const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
154
+ ? String((message as { content: string }).content)
155
+ : '';
156
+ const firstLine = content.split('\n').find((line) => line.trim().length > 0) ?? label;
157
+ const head = `${theme.fg('warning', `↻ ${label}`)}`;
158
+ if (!expanded) return [head, theme.fg('dim', ` ${clip(firstLine, 110)}`)];
159
+ return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
160
+ }
161
+
162
+ type LineBuilder = (data: unknown, theme: RenderTheme, expanded: boolean) => string[];
163
+
164
+ /** Register piher's transcript renderers and return the custom types actually registered, so the
165
+ * caller can log coverage and tests can assert the degrade path. Each registration is individually
166
+ * guarded: a pi build without the API must never break tool registration. */
167
+ export function installRenderers(pi: unknown): string[] {
168
+ const api = pi as RendererApi;
169
+ const registered: string[] = [];
170
+ const entry = (type: string, build: LineBuilder): void => {
171
+ if (typeof api.registerEntryRenderer !== 'function') return;
172
+ try {
173
+ api.registerEntryRenderer(type, (entryData, options, theme) =>
174
+ card(build((entryData as { data?: unknown } | undefined)?.data, theme, options.expanded)));
175
+ registered.push(type);
176
+ } catch {
177
+ /* Older pi or a conflicting renderer: keep the default rendering. */
178
+ }
179
+ };
180
+ const message = (type: string, build: LineBuilder): void => {
181
+ if (typeof api.registerMessageRenderer !== 'function') return;
182
+ try {
183
+ api.registerMessageRenderer(type, (msg, options, theme) => card(build(msg, theme, options.expanded)));
184
+ registered.push(type);
185
+ } catch {
186
+ /* A throwing renderer must not break message registration. */
187
+ }
188
+ };
189
+
190
+ entry(DELEGATION_CUSTOM_TYPE, delegationLines);
191
+ entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
192
+ message(SETTLEMENT_CUSTOM_TYPE, (msg, theme, expanded) => settlementLines(msg, theme, expanded));
193
+ message(JOB_NOTICE_CUSTOM_TYPE, (msg, theme, expanded) => jobNoticeLines(msg, theme, expanded));
194
+ return registered;
195
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The `piher-roles` prompt section: which roles a spawn can name, derived from the role manifests
3
+ * (never from prose). S20 — one line per visible role, ordered by layer then name, deduped by name,
4
+ * closing with the default line; `master` and `modelInvocable: false` roles are absent.
5
+ *
6
+ * A static skill file cannot enumerate roles that appear per workspace, and an action would spend a
7
+ * round trip on text that fits in the prompt for free — so the catalog is prompt-only (S21/S22).
8
+ */
9
+ import { readdirSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { RESERVED_ROLE_NAMES, roleLayers } from './role-loader.ts';
12
+
13
+ /** Prompt-budget cap: past this the section says how many roles it left out. */
14
+ export const CATALOG_MAX_ROLES = 12;
15
+ /** The role piher composes when a spawn omits `role`. */
16
+ export const DEFAULT_ROLE_NAME = 'worker-default';
17
+
18
+ export interface CatalogEntry {
19
+ name: string;
20
+ /** Manifest `description`; absent when the file does not carry one. */
21
+ description?: string;
22
+ /** `false` keeps a role launchable by name but out of the catalog. */
23
+ modelInvocable?: boolean;
24
+ /** Layer index (0 = workspace, 1 = user); ordering and dedupe only — the lower layer wins. */
25
+ layer: number;
26
+ }
27
+
28
+ /**
29
+ * Reads the two spawnable layers (workspace → user). An unreadable or unparseable manifest is
30
+ * skipped: it cannot be spawned either, and the spawn error names it.
31
+ */
32
+ export function catalogEntries(opts?: { baseDir?: string; userDir?: string }): CatalogEntry[] {
33
+ const entries: CatalogEntry[] = [];
34
+ roleLayers(opts).slice(0, 2).forEach((layer, layerIndex) => {
35
+ let files: string[];
36
+ try {
37
+ files = readdirSync(layer.dir);
38
+ } catch {
39
+ return; // Layer absent (the workspace one commonly is).
40
+ }
41
+ for (const file of files) {
42
+ if (!file.endsWith('.json')) continue;
43
+ const name = file.slice(0, -'.json'.length);
44
+ let parsed: unknown;
45
+ try {
46
+ parsed = JSON.parse(readFileSync(join(layer.dir, file), 'utf8'));
47
+ } catch {
48
+ continue;
49
+ }
50
+ if (typeof parsed !== 'object' || parsed === null) continue;
51
+ const rec = parsed as { description?: unknown; modelInvocable?: unknown };
52
+ entries.push({
53
+ name,
54
+ ...(typeof rec.description === 'string' ? { description: rec.description } : {}),
55
+ ...(typeof rec.modelInvocable === 'boolean' ? { modelInvocable: rec.modelInvocable } : {}),
56
+ layer: layerIndex,
57
+ });
58
+ }
59
+ });
60
+ return entries;
61
+ }
62
+
63
+ /** Renders the section body. Deterministic: same entries in, byte-identical text out. */
64
+ export function renderRoleCatalog(
65
+ entries: readonly CatalogEntry[],
66
+ opts?: { maxRoles?: number; searchDirs?: readonly string[] },
67
+ ): string {
68
+ const max = opts?.maxRoles ?? CATALOG_MAX_ROLES;
69
+ const ordered = [...entries].sort((a, b) => a.layer - b.layer || a.name.localeCompare(b.name));
70
+ const seen = new Set<string>();
71
+ const visible: CatalogEntry[] = [];
72
+ for (const entry of ordered) {
73
+ if (seen.has(entry.name)) continue; // Higher layer wins.
74
+ seen.add(entry.name);
75
+ if (RESERVED_ROLE_NAMES.includes(entry.name)) continue; // master is the caller; worker-default is the default line.
76
+ if (entry.modelInvocable === false) continue;
77
+ visible.push(entry);
78
+ }
79
+
80
+ const lines = ['Roles a `subagent` spawn can name (`role` parameter):'];
81
+ for (const entry of visible.slice(0, max)) {
82
+ const description = entry.description?.replace(/\s+/g, ' ').trim();
83
+ lines.push(`- ${entry.name} — ${description || '(no description)'}`);
84
+ }
85
+ const hidden = visible.length - max;
86
+ if (hidden > 0) {
87
+ const where = opts?.searchDirs?.length
88
+ ? ` (${opts.searchDirs.join(', ')})`
89
+ : ' (`.piher/roles/`, `~/.pi/agent/piher/roles/`)';
90
+ lines.push(`- … ${hidden} more not listed here${where}; an unknown-role error names them all`);
91
+ }
92
+ lines.push(`- (default) — \`${DEFAULT_ROLE_NAME}\`, used when \`role\` is omitted`);
93
+ return lines.join('\n');
94
+ }
95
+
96
+ /** Every spawn-target directory a truncation notice can point at. */
97
+ export function catalogSearchDirs(opts?: { baseDir?: string; userDir?: string }): string[] {
98
+ return roleLayers(opts).slice(0, 2).map((layer) => layer.dir);
99
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Role manifest loading, three layers in order: workspace `<cwd>/.piher/roles/` (travels with the clone)
3
+ * → user `~/.pi/agent/piher/roles/` → bundled `src/roles/`.
4
+ *
5
+ * Reserved built-in names (master / worker-default) may not be overridden by a user layer: tests and the D82
6
+ * stance are anchored to the bundled manifests.
7
+ *
8
+ * Role names are restricted to [a-z0-9-] (no path traversal; invalid names never touch disk) and the manifest
9
+ * `role` field must match the filename. Errors: unreadable = ROLE_NOT_FOUND (only after every layer missed);
10
+ * parsed-but-invalid JSON/validation/name = INVALID_ROLE_CONFIG at the hit layer — falling through would
11
+ * silently hide a user editing a manifest incorrectly.
12
+ */
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
16
+ import { validateRoleManifest, type RoleManifest } from './role-manifest.ts';
17
+ import { userRolesDir, workspaceRolesDir as layoutWorkspaceRolesDir } from './storage-layout.ts';
18
+
19
+ export { userRolesDir };
20
+
21
+ export const ROLES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'roles');
22
+
23
+ /** Workspace-level directory (the default base is the process cwd, i.e. the master's working directory). */
24
+ export function workspaceRolesDir(baseDir?: string): string {
25
+ return layoutWorkspaceRolesDir(baseDir ?? process.cwd());
26
+ }
27
+
28
+ export const RESERVED_ROLE_NAMES: readonly string[] = ['master', 'worker-default'];
29
+
30
+ const ROLE_NAME_RE = /^[a-z0-9-]+$/;
31
+
32
+ export class RoleLoaderError extends Error {
33
+ readonly code: 'ROLE_NOT_FOUND' | 'INVALID_ROLE_CONFIG' | 'ROLE_RESERVED';
34
+ readonly issues: readonly string[];
35
+ constructor(
36
+ code: 'ROLE_NOT_FOUND' | 'INVALID_ROLE_CONFIG' | 'ROLE_RESERVED',
37
+ message: string,
38
+ issues: readonly string[] = [],
39
+ ) {
40
+ super(message);
41
+ this.name = 'RoleLoaderError';
42
+ this.code = code;
43
+ this.issues = issues;
44
+ }
45
+ }
46
+
47
+ export type RoleReader = (fileName: string) => string;
48
+
49
+ /** Single-layer reader (injectable; production uses existsSync + readFileSync for that directory). */
50
+ export type LayerReader = (dir: string, fileName: string) => string | null;
51
+
52
+ const defaultLayerRead: LayerReader = (dir, fileName) => {
53
+ const p = resolve(dir, fileName);
54
+ if (!existsSync(p)) return null;
55
+ return readFileSync(p, 'utf8');
56
+ };
57
+
58
+ export interface LoadRoleOptions {
59
+ /** Inject one reader (legacy shape: only the bundled layer is hit, for test compatibility). */
60
+ read?: RoleReader;
61
+ layerRead?: LayerReader;
62
+ /** Base directory for the workspace layer (defaults to process.cwd()). */
63
+ baseDir?: string;
64
+ /** Direct built-in lookup (master self-application, WS-D7): reserved names skip user layers and
65
+ * collision checks and always load the bundled manifest — otherwise a workspace master.json could make
66
+ * self-application fail open and silently lose master's manifest. */
67
+ builtinDirect?: boolean;
68
+ }
69
+
70
+ export function roleLayers(opts?: { baseDir?: string; userDir?: string }): Array<{ label: string; dir: string }> {
71
+ const base = opts?.baseDir && isAbsolute(opts.baseDir) ? opts.baseDir
72
+ : resolve(opts?.baseDir ?? process.cwd());
73
+ return [
74
+ { label: 'workspace (.piher/roles/)', dir: workspaceRolesDir(base) },
75
+ // userDir lets tests keep the real ~/.pi out of the search.
76
+ { label: `user (${'~/.pi/agent/piher/roles/'})`, dir: opts?.userDir ?? userRolesDir() },
77
+ { label: 'builtin (src/roles/)', dir: ROLES_DIR },
78
+ ];
79
+ }
80
+
81
+ /** User-layer role names (workspace → user), deduped and sorted; built-ins are not listed — they are
82
+ * exactly RESERVED_ROLE_NAMES, which no user layer may override. */
83
+ export function listRoleNames(baseDir?: string, userDir?: string): string[] {
84
+ const names = new Set<string>();
85
+ for (const layer of roleLayers({ baseDir, userDir }).slice(0, 2)) {
86
+ let entries: string[];
87
+ try {
88
+ entries = readdirSync(layer.dir);
89
+ } catch {
90
+ continue;
91
+ }
92
+ for (const entry of entries) {
93
+ if (entry.endsWith('.json')) names.add(entry.slice(0, -'.json'.length));
94
+ }
95
+ }
96
+ return [...names].sort();
97
+ }
98
+
99
+ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManifest {
100
+ if (typeof name !== 'string' || !ROLE_NAME_RE.test(name)) {
101
+ throw new RoleLoaderError('ROLE_NOT_FOUND', `role not found or invalid name ([a-z0-9-]+): ${JSON.stringify(name)}`);
102
+ }
103
+ const fileName = `${name}.json`;
104
+
105
+ if (opts?.read && !opts.layerRead) {
106
+ let text: string;
107
+ try {
108
+ text = opts.read(fileName);
109
+ } catch (err) {
110
+ const code = (err as { code?: unknown })?.code;
111
+ throw new RoleLoaderError('ROLE_NOT_FOUND', `role "${name}" not found (failed to read ${fileName}: ${typeof code === 'string' ? code : 'error'})`);
112
+ }
113
+ return parseAndValidate(name, fileName, text, 'builtin (src/roles/)');
114
+ }
115
+
116
+ const layerRead = opts?.layerRead ?? defaultLayerRead;
117
+ let layers = roleLayers({ baseDir: opts?.baseDir });
118
+
119
+ if (opts?.builtinDirect && RESERVED_ROLE_NAMES.includes(name)) {
120
+ layers = [layers[2]];
121
+ } else if (RESERVED_ROLE_NAMES.includes(name)) {
122
+ for (const layer of layers.slice(0, 2)) {
123
+ if (layerRead(layer.dir, fileName) != null) {
124
+ throw new RoleLoaderError(
125
+ 'ROLE_RESERVED',
126
+ `role "${name}" is a reserved built-in name and cannot be defined in the ${layer.label} layer (built-in manifests take precedence; delete that file or rename it)`,
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ for (const layer of layers) {
133
+ const text = layerRead(layer.dir, fileName);
134
+ if (text == null) continue; // No manifest in this layer; try the next layer.
135
+ return parseAndValidate(name, fileName, text, layer.label); // At the hit layer, report errors immediately.
136
+ }
137
+ throw new RoleLoaderError(
138
+ 'ROLE_NOT_FOUND',
139
+ `role "${name}" not found (searched layers: ${layers.map((l) => l.label).join(' → ')}; available roles: ${availableRoleNames(layerRead, layers, opts?.baseDir)})`,
140
+ );
141
+ }
142
+
143
+ /** Role names a caller can actually ask for: user layers only (built-ins are reserved), minus
144
+ * `modelInvocable: false` ones, which are launchable by name but not advertised. */
145
+ function availableRoleNames(
146
+ layerRead: LayerReader,
147
+ layers: Array<{ label: string; dir: string }>,
148
+ baseDir?: string,
149
+ ): string {
150
+ let names: string[] = [];
151
+ try {
152
+ names = listRoleNames(baseDir);
153
+ } catch {
154
+ return '(none)';
155
+ }
156
+ const visible = names.filter((n) => {
157
+ const fileName = `${n}.json`;
158
+ for (const layer of layers.slice(0, 2)) {
159
+ const text = layerRead(layer.dir, fileName);
160
+ if (text == null) continue;
161
+ try {
162
+ return JSON.parse(text)?.modelInvocable !== false;
163
+ } catch {
164
+ return true;
165
+ }
166
+ }
167
+ return true;
168
+ });
169
+ return visible.length > 0 ? visible.join(', ') : '(none)';
170
+ }
171
+
172
+ function parseAndValidate(name: string, fileName: string, text: string, layerLabel: string): RoleManifest {
173
+ let parsed: unknown;
174
+ try {
175
+ parsed = JSON.parse(text);
176
+ } catch (err) {
177
+ throw new RoleLoaderError('INVALID_ROLE_CONFIG', `role "${name}" (${layerLabel}) is not valid JSON: ${(err as Error).message}`);
178
+ }
179
+ const result = validateRoleManifest(parsed);
180
+ // `ok === false` (instead of `!ok`) is required to narrow without strictNullChecks.
181
+ if (result.ok === false) {
182
+ throw new RoleLoaderError(
183
+ 'INVALID_ROLE_CONFIG',
184
+ `role "${name}" (${layerLabel}) failed validation:\n${result.issues.map((i) => ` - ${i}`).join('\n')}`,
185
+ result.issues,
186
+ );
187
+ }
188
+ if (result.value.role !== name) {
189
+ throw new RoleLoaderError(
190
+ 'INVALID_ROLE_CONFIG',
191
+ `role manifest name mismatch: role="${result.value.role}" in ${fileName} of ${layerLabel}`,
192
+ [`role field "${result.value.role}" does not match the requested "${name}"`],
193
+ );
194
+ }
195
+ return result.value;
196
+ }