pi-pier 0.3.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.
Files changed (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +8 -12
  9. package/src/compact-coordinator.ts +278 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. package/src/subagent-session-io.ts +0 -230
package/src/renderers.ts CHANGED
@@ -1,66 +1,51 @@
1
1
  /**
2
- * TUI renderers for pier's own session custom entries and reminder messages.
3
- *
4
- * Why: pier persists authority in the session JSONL (todo edits, subagent registry,
5
- * terminal registry, role manifests, soft-approvals) and injects reminder messages.
6
- * Without a renderer those transcript rows are raw JSON, which buries the one line
7
- * that matters. The line builders here are pure (unit-tested); the component is a
8
- * thin string array plus an ANSI-aware truncator, so this module needs no pi-tui
9
- * import and degrades to "no renderer" on older pi builds.
2
+ * TUI renderers for pier'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".
10
5
  */
11
6
  import { TODO_EDIT_CUSTOM_TYPE, type TodoEditPayload } from './todo-core.ts';
12
7
  import { SUBS_CUSTOM_TYPE, type SubsRegistry } from './subagent-core.ts';
13
8
  import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
14
9
  import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
15
10
 
16
- // Width handling lives in its own module so the ask_user_question UI can reuse it
17
- // without pulling the todo/subagent/terminal cores into its import graph.
18
- import { charWidth, styledWidth, truncateStyled } from './ansi-text.ts';
19
- export { charWidth, styledWidth, truncateStyled };
11
+ // Width handling lives in ansi-text.ts so ask_user_question can reuse it without this import graph.
12
+ import { truncateStyled } from './ansi-text.ts';
20
13
 
21
14
  /** Role manifest entry is written by index.ts; keep the literal here to avoid an import cycle. */
22
15
  export const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
23
- /** Soft-approval trace written by the worker manifest gate in index.ts. */
16
+ /** Soft-approval trace from the worker manifest gate in index.ts. */
24
17
  export const APPROVAL_NEEDED_CUSTOM_TYPE = 'pi-herdr.approval-needed';
25
18
 
26
- /** Structural subset of pi's Theme that the builders actually use. */
19
+ /** Structural subset of pi's Theme actually used here. */
27
20
  export interface RenderTheme {
28
21
  fg(color: string, text: string): string;
29
22
  bold(text: string): string;
30
23
  }
31
24
 
32
- /** Structural subset of pi's Component; enough for `registerEntryRenderer`. */
33
25
  export interface RenderComponent {
34
26
  render(width: number): string[];
35
27
  invalidate(): void;
36
28
  }
37
29
 
30
+ type Renderer = (payload: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent | undefined;
31
+
38
32
  export interface RendererApi {
39
- registerEntryRenderer?(
40
- customType: string,
41
- renderer: (entry: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent | undefined,
42
- ): void;
43
- registerMessageRenderer?(
44
- customType: string,
45
- renderer: (message: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent | undefined,
46
- ): void;
33
+ registerEntryRenderer?(customType: string, renderer: Renderer): void;
34
+ registerMessageRenderer?(customType: string, renderer: Renderer): void;
47
35
  }
48
36
 
49
- /* ── Pure line builders ────────────────────────────────────────────── */
37
+ function clip(text: string, max: number): string {
38
+ const flat = text.replace(/\s+/g, ' ').trim();
39
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
40
+ }
50
41
 
51
- /** Component wrapper: render is a no-op transform except for width clipping. */
52
- export function card(lines: readonly string[]): RenderComponent {
42
+ function card(lines: readonly string[]): RenderComponent {
53
43
  return {
54
44
  render: (width: number) => lines.map((line) => truncateStyled(line, width)),
55
45
  invalidate() { /* No cached state. */ },
56
46
  };
57
47
  }
58
48
 
59
- export function clip(text: string, max: number): string {
60
- const flat = text.replace(/\s+/g, ' ').trim();
61
- return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
62
- }
63
-
64
49
  const EDIT_VERB: Record<string, string> = {
65
50
  done: '✓ done',
66
51
  drop: '■ dropped',
@@ -68,7 +53,7 @@ const EDIT_VERB: Record<string, string> = {
68
53
  rm: '✗ removed',
69
54
  };
70
55
 
71
- export function formatEditCounts(edits: ReadonlyArray<{ op: string }>): string {
56
+ function formatEditCounts(edits: ReadonlyArray<{ op: string }>): string {
72
57
  const counts = new Map<string, number>();
73
58
  for (const edit of edits) counts.set(edit.op, (counts.get(edit.op) ?? 0) + 1);
74
59
  return [...counts.entries()]
@@ -76,7 +61,7 @@ export function formatEditCounts(edits: ReadonlyArray<{ op: string }>): string {
76
61
  .join(' · ');
77
62
  }
78
63
 
79
- export function todoEditLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
64
+ function todoEditLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
80
65
  const payload = data as TodoEditPayload | undefined;
81
66
  const edits = Array.isArray(payload?.edits) ? payload.edits : [];
82
67
  if (edits.length === 0) return [theme.fg('dim', 'todo · no edits')];
@@ -88,44 +73,64 @@ export function todoEditLines(data: unknown, theme: RenderTheme, expanded: boole
88
73
  ];
89
74
  }
90
75
 
91
- export function subsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
92
- const subs = (data as SubsRegistry | undefined)?.subs;
93
- if (!Array.isArray(subs) || subs.length === 0) return [theme.fg('dim', 'subagents · none')];
94
- const running = subs.filter((s) => s.status === 'running').length;
95
- const head = `${theme.fg('accent', theme.bold('subagents'))} ${theme.fg('dim', `· ${subs.length} tracked`)}${running ? ` ${theme.fg('success', `${running} running`)}` : ''}`;
76
+ /** Registry card, shared by the subagent and terminal registries: a titled count line plus one row
77
+ * per entry when expanded; an empty or malformed registry renders one dim placeholder. */
78
+ function registryLines<T>(
79
+ items: readonly T[] | undefined,
80
+ theme: RenderTheme,
81
+ expanded: boolean,
82
+ card: { label: string; unit: string; badge?: string; row: (item: T) => string },
83
+ ): string[] {
84
+ if (!items || items.length === 0) return [theme.fg('dim', `${card.label} · none`)];
85
+ const head = `${theme.fg('accent', theme.bold(card.label))} ${theme.fg('dim', `· ${items.length} ${card.unit}`)}${card.badge ? ` ${theme.fg('success', card.badge)}` : ''}`;
96
86
  if (!expanded) return [head];
97
- return [
98
- head,
99
- ...subs.map((sub) => theme.fg(
100
- 'dim',
101
- ` ${sub.status === 'running' ? '●' : '○'} ${sub.paneId} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
102
- )),
103
- ];
87
+ return [head, ...items.map((item) => theme.fg('dim', card.row(item)))];
88
+ }
89
+
90
+ function subsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
91
+ const subs = (data as SubsRegistry | undefined)?.subs;
92
+ const running = Array.isArray(subs) ? subs.filter((sub) => sub.status === 'running').length : 0;
93
+ return registryLines(Array.isArray(subs) ? subs : undefined, theme, expanded, {
94
+ label: 'subagents',
95
+ unit: 'tracked',
96
+ ...(running ? { badge: `${running} running` } : {}),
97
+ row: (sub) => ` ${sub.status === 'running' ? '●' : '○'} ${sub.paneId} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
98
+ });
104
99
  }
105
100
 
106
- export function terminalsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
101
+ function terminalsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
107
102
  const terminals = (data as TerminalsRegistry | undefined)?.terminals;
108
- if (!Array.isArray(terminals) || terminals.length === 0) return [theme.fg('dim', 'terminals · none')];
109
- const head = `${theme.fg('accent', theme.bold('terminals'))} ${theme.fg('dim', `· ${terminals.length} open`)}`;
110
- if (!expanded) return [head];
111
- return [
112
- head,
113
- ...terminals.map((t) => theme.fg('dim', ` ${t.paneId} · ${clip(String(t.label ?? t.cwd ?? ''), 70)}`)),
114
- ];
103
+ return registryLines(Array.isArray(terminals) ? terminals : undefined, theme, expanded, {
104
+ label: 'terminals',
105
+ unit: 'open',
106
+ row: (t) => ` ${t.paneId} · ${clip(String(t.label ?? t.cwd ?? ''), 70)}`,
107
+ });
115
108
  }
116
109
 
117
- export function roleManifestLines(data: unknown, theme: RenderTheme): string[] {
118
- const rec = (data ?? {}) as { role?: unknown; manifestVersion?: unknown; tools?: unknown; permissions?: unknown };
110
+ function roleManifestLines(data: unknown, theme: RenderTheme): string[] {
111
+ const rec = (data ?? {}) as {
112
+ role?: unknown;
113
+ manifestVersion?: unknown;
114
+ tools?: unknown;
115
+ permissions?: unknown;
116
+ origin?: unknown;
117
+ switchedBy?: unknown;
118
+ };
119
119
  const role = typeof rec.role === 'string' ? rec.role : '?';
120
120
  const version = typeof rec.manifestVersion === 'string' || typeof rec.manifestVersion === 'number' ? String(rec.manifestVersion) : '?';
121
121
  const tools = Array.isArray(rec.tools) ? rec.tools.length : 0;
122
122
  const gates = Object.values((rec.permissions ?? {}) as Record<string, unknown>)
123
123
  .filter((v) => v === 'deny' || v === 'ask').length;
124
124
  const tail = gates > 0 ? theme.fg('warning', ` · ${gates} gated`) : '';
125
- return [`${theme.fg('accent', theme.bold('role'))} ${role} ${theme.fg('dim', `v${version} · ${tools} tools`)}${tail}`];
125
+ // P0: switch provenance — origin 'switch' marks a mid-session swap (vs the session_start anchor).
126
+ const switched =
127
+ rec.origin === 'switch'
128
+ ? theme.fg('accent', ` ⇄ ${typeof rec.switchedBy === 'string' && rec.switchedBy ? rec.switchedBy : '?'}`)
129
+ : '';
130
+ return [`${theme.fg('accent', theme.bold('role'))} ${role} ${theme.fg('dim', `v${version} · ${tools} tools`)}${tail}${switched}`];
126
131
  }
127
132
 
128
- export function approvalLines(data: unknown, theme: RenderTheme): string[] {
133
+ function approvalLines(data: unknown, theme: RenderTheme): string[] {
129
134
  const rec = (data ?? {}) as { role?: unknown; tool?: unknown };
130
135
  const role = typeof rec.role === 'string' ? rec.role : '?';
131
136
  const tool = typeof rec.tool === 'string' ? rec.tool : '?';
@@ -133,7 +138,7 @@ export function approvalLines(data: unknown, theme: RenderTheme): string[] {
133
138
  }
134
139
 
135
140
  /** Reminder custom messages carry the full injected text; restyle without altering it. */
136
- export function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
141
+ function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
137
142
  const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
138
143
  ? String((message as { content: string }).content)
139
144
  : '';
@@ -143,53 +148,37 @@ export function reminderLines(message: unknown, theme: RenderTheme, label: strin
143
148
  return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
144
149
  }
145
150
 
146
- /* ── Installation ──────────────────────────────────────────────────── */
151
+ type LineBuilder = (data: unknown, theme: RenderTheme, expanded: boolean) => string[];
147
152
 
148
- function entryCard(
149
- build: (data: unknown, theme: RenderTheme, expanded: boolean) => string[],
150
- ): (entry: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent {
151
- return (entry, options, theme) => card(build((entry as { data?: unknown } | undefined)?.data, theme, options.expanded));
152
- }
153
-
154
- function messageCard(
155
- build: (message: unknown, theme: RenderTheme, expanded: boolean) => string[],
156
- ): (message: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent {
157
- return (message, options, theme) => card(build(message, theme, options.expanded));
158
- }
159
-
160
- /**
161
- * Register pier's transcript renderers. Returns the custom types actually registered,
162
- * so the caller can log coverage and tests can assert the degrade path.
163
- *
164
- * Why best-effort: entry/message renderers are display-only. A pi build without the
165
- * API (pre-0.80.4) or a throwing renderer must never break tool registration, so each
166
- * registration is individually guarded.
167
- */
153
+ /** Register pier's transcript renderers and return the custom types actually registered, so the
154
+ * caller can log coverage and tests can assert the degrade path. Each registration is individually
155
+ * guarded: a pi build without the API must never break tool registration. */
168
156
  export function installRenderers(pi: unknown): string[] {
169
157
  const api = pi as RendererApi;
170
158
  const registered: string[] = [];
171
- const entry = (type: string, build: (data: unknown, theme: RenderTheme, expanded: boolean) => string[]): void => {
159
+ const entry = (type: string, build: LineBuilder): void => {
172
160
  if (typeof api.registerEntryRenderer !== 'function') return;
173
161
  try {
174
- api.registerEntryRenderer(type, entryCard(build));
162
+ api.registerEntryRenderer(type, (entryData, options, theme) =>
163
+ card(build((entryData as { data?: unknown } | undefined)?.data, theme, options.expanded)));
175
164
  registered.push(type);
176
165
  } catch {
177
166
  /* Older pi or a conflicting renderer: keep the default rendering. */
178
167
  }
179
168
  };
180
- const message = (type: string, build: (msg: unknown, theme: RenderTheme, expanded: boolean) => string[]): void => {
169
+ const message = (type: string, build: LineBuilder): void => {
181
170
  if (typeof api.registerMessageRenderer !== 'function') return;
182
171
  try {
183
- api.registerMessageRenderer(type, messageCard(build));
172
+ api.registerMessageRenderer(type, (msg, options, theme) => card(build(msg, theme, options.expanded)));
184
173
  registered.push(type);
185
174
  } catch {
186
- /* Best effort; the message still reaches the model. */
175
+ /* A throwing renderer must not break message registration. */
187
176
  }
188
177
  };
189
178
 
190
- entry(TODO_EDIT_CUSTOM_TYPE, (data, theme, expanded) => todoEditLines(data, theme, expanded));
191
- entry(SUBS_CUSTOM_TYPE, (data, theme, expanded) => subsLines(data, theme, expanded));
192
- entry(TERMINALS_CUSTOM_TYPE, (data, theme, expanded) => terminalsLines(data, theme, expanded));
179
+ entry(TODO_EDIT_CUSTOM_TYPE, todoEditLines);
180
+ entry(SUBS_CUSTOM_TYPE, subsLines);
181
+ entry(TERMINALS_CUSTOM_TYPE, terminalsLines);
193
182
  entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
194
183
  entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
195
184
  message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
@@ -1,21 +1,16 @@
1
1
  /**
2
- * Layer 2 Week 1: load role manifests (2026-08-18); v1.1 (2026-08-22) adds two user-directory layers.
2
+ * Role manifest loading, three layers in order: workspace `<cwd>/.pi-herdr/roles/` (travels with the clone)
3
+ * → user `~/.pi/agent/herdr-pi/roles/` → bundled `src/roles/`.
3
4
  *
4
- * Lookup order (v1.1, user-approved):
5
- * 1. Workspace-level `<cwd>/.pi-herdr/roles/<name>.json` (travels with the clone and is team-shared)
6
- * 2. User-global `~/.pi/agent/herdr-pi/roles/<name>.json` (personal preference across repositories)
7
- * 3. Bundled `src/roles/` as the fallback shipped with this package
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.
8
7
  *
9
- * Built-in names remain reserved (master / worker-default): reject collisions in user/workspace layers—D83 mirror.
10
- * Tests and the D82 stance are anchored to bundled manifests; allowing overrides would break those guarantees.
11
- *
12
- * Role names are restricted to [a-z0-9-]—this prevents path traversal and avoids touching disk for invalid names.
13
- * The manifest role field must match the filename to prevent attaching the wrong manifest.
14
- * Validation failures are classified as: unreadable = ROLE_NOT_FOUND (reported only after trying every layer);
15
- * parsed but invalid JSON/validation/name = INVALID_ROLE_CONFIG (report immediately at the hit layer rather than
16
- * silently falling through, because silence would hide a user editing a manifest incorrectly).
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.
17
12
  */
18
- import { existsSync, readFileSync } from 'node:fs';
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
19
14
  import { fileURLToPath } from 'node:url';
20
15
  import { dirname, isAbsolute, join, resolve } from 'node:path';
21
16
  import { validateRoleManifest, type RoleManifest } from './role-manifest.ts';
@@ -23,7 +18,6 @@ import { userRolesDir, workspaceRolesDir as layoutWorkspaceRolesDir } from './st
23
18
 
24
19
  export { userRolesDir };
25
20
 
26
- /** Bundled manifest directory (`src/roles/`). */
27
21
  export const ROLES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'roles');
28
22
 
29
23
  /** Workspace-level directory (the default base is the process cwd, i.e. the master's working directory). */
@@ -31,7 +25,6 @@ export function workspaceRolesDir(baseDir?: string): string {
31
25
  return layoutWorkspaceRolesDir(baseDir ?? process.cwd());
32
26
  }
33
27
 
34
- /** Reserved built-in names (no user layer may override them). */
35
28
  export const RESERVED_ROLE_NAMES: readonly string[] = ['master', 'worker-default'];
36
29
 
37
30
  const ROLE_NAME_RE = /^[a-z0-9-]+$/;
@@ -65,36 +58,50 @@ const defaultLayerRead: LayerReader = (dir, fileName) => {
65
58
  export interface LoadRoleOptions {
66
59
  /** Inject one reader (legacy shape: only the bundled layer is hit, for test compatibility). */
67
60
  read?: RoleReader;
68
- /** Inject layered readers (v1.1). */
69
61
  layerRead?: LayerReader;
70
62
  /** Base directory for the workspace layer (defaults to process.cwd()). */
71
63
  baseDir?: string;
72
- /**
73
- * Direct built-in lookup: used by master's self-application (WS-D7). Reserved names skip user layers and
74
- * collision checks and always load the bundled manifest. Otherwise, a workspace master.json could cause
75
- * self-application to fail open and silently lose master's manifest (the boundary caught by d11 live testing).
76
- */
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. */
77
67
  builtinDirect?: boolean;
78
68
  }
79
69
 
80
- /** Resolve lookup layers in order: workspace → user-global → bundled (each layer has {label, dir}). */
81
- export function roleLayers(opts?: { baseDir?: string }): Array<{ label: string; dir: string }> {
70
+ export function roleLayers(opts?: { baseDir?: string; userDir?: string }): Array<{ label: string; dir: string }> {
82
71
  const base = opts?.baseDir && isAbsolute(opts.baseDir) ? opts.baseDir
83
72
  : resolve(opts?.baseDir ?? process.cwd());
84
73
  return [
85
74
  { label: 'workspace (.pi-herdr/roles/)', dir: workspaceRolesDir(base) },
86
- { label: `user (${'~/.pi/agent/herdr-pi/roles/'})`, dir: userRolesDir() },
75
+ // userDir lets tests isolate from the real ~/.pi.
76
+ { label: `user (${'~/.pi/agent/herdr-pi/roles/'})`, dir: opts?.userDir ?? userRolesDir() },
87
77
  { label: 'builtin (src/roles/)', dir: ROLES_DIR },
88
78
  ];
89
79
  }
90
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
+
91
99
  export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManifest {
92
100
  if (typeof name !== 'string' || !ROLE_NAME_RE.test(name)) {
93
101
  throw new RoleLoaderError('ROLE_NOT_FOUND', `role 不存在或名字非法([a-z0-9-]+): ${JSON.stringify(name)}`);
94
102
  }
95
103
  const fileName = `${name}.json`;
96
104
 
97
- // Legacy single reader: use bundled-layer semantics so existing tests/composer injection paths remain intact.
98
105
  if (opts?.read && !opts.layerRead) {
99
106
  let text: string;
100
107
  try {
@@ -109,11 +116,9 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
109
116
  const layerRead = opts?.layerRead ?? defaultLayerRead;
110
117
  let layers = roleLayers({ baseDir: opts?.baseDir });
111
118
 
112
- // Direct built-in lookup (self-application): inspect only the bundled layer, ignoring user-layer bait/collisions.
113
119
  if (opts?.builtinDirect && RESERVED_ROLE_NAMES.includes(name)) {
114
120
  layers = [layers[2]];
115
121
  } else if (RESERVED_ROLE_NAMES.includes(name)) {
116
- // Reserved built-in name: reject workspace/user collisions instead of silently overriding built-in semantics.
117
122
  for (const layer of layers.slice(0, 2)) {
118
123
  if (layerRead(layer.dir, fileName) != null) {
119
124
  throw new RoleLoaderError(
@@ -1,11 +1,8 @@
1
1
  /**
2
- * Layer 2 Week 1: role manifest types and validator (D76 v2; 2026-08-18).
3
- *
4
- * Mirrors the contract in `schemas/role-manifest.schema.json` (the JSON Schema is the contract document;
5
- * runtime validation is hand-written with zero dependencies so workers carry no ajv overhead).
6
- *
7
- * Strict contract: report unknown top-level keys to prevent typos such as "rulez" from silently failing;
8
- * collect all errors in one pass so manifests can be repaired efficiently.
2
+ * Role manifest file format + validator; the contract document is `schemas/role-manifest.schema.json`.
3
+ * Validation is hand-written with zero dependencies so workers carry no ajv overhead. Strict: unknown
4
+ * top-level keys are reported (a typo like "rulez" must not silently fail) and every issue is
5
+ * collected in one pass so a manifest can be repaired in one go.
9
6
  */
10
7
  export type PermissionAction = 'allow' | 'ask' | 'deny';
11
8
  export type TodosMode = 'serial' | 'parallel';
@@ -14,24 +11,20 @@ export type UnknownToolStance = 'allow' | 'deny';
14
11
 
15
12
  export interface RoleManifest {
16
13
  role: string;
17
- /** Semantic version x.y.z (for tracing manifest evolution; P2). */
18
14
  version: string;
19
- /**
20
- * WS-D10: route models by role using `provider/model` (advisor delegates higher-level third-party judgment).
21
- * Omitted means follow the process default (master/worker leave it unset); spawn injects `--provider/--model`.
22
- */
15
+ /** WS-D10: `provider/model` routing by role; omitted means follow the process default (spawn injects `--provider/--model`). */
23
16
  model?: string;
24
17
  description?: string;
18
+ /** P0 per-role guidelines (RFC §4.6): constraints a tool set cannot express; injected as a prompt section. */
19
+ guidelines?: string[];
25
20
  manifest: {
26
- /** Baseline tools (non-empty; must include todo_write + ask_user_question for coordination). */
27
21
  tools: string[];
28
22
  /** Three-state permissions; `*` supplies the default; omitted means `{"*":"allow"}`. */
29
23
  rules?: Record<string, PermissionAction>;
30
24
  /**
31
- * D82 unknownTools: stance for tools outside the manifest (default deny is the safe default).
32
- * master/worker = allow (user-installed extensions are visible and usable by default—install grants access);
33
- * custom roles default to deny. `*` in rules and this stance cover separate axes: visibility vs enforcement.
34
- * Excluded families (such as worker's subagent/terminal) need explicit deny to remain blocked under allow.
25
+ * D82 stance for tools outside the manifest (default deny; master/worker = allow — install grants
26
+ * the user's extension access). Separate axis from `rules['*']`: visibility vs enforcement.
27
+ * Excluded families (worker's subagent/terminal) need an explicit deny to stay blocked under allow.
35
28
  */
36
29
  unknownTools?: UnknownToolStance;
37
30
  };
@@ -64,35 +57,41 @@ export function validateRoleManifest(input: unknown): ValidateResult {
64
57
  return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role 档案必须是 JSON 对象'] };
65
58
  }
66
59
 
67
- /* ── Top-level keys ── */
68
- const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'manifest', 'services']);
60
+ const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'guidelines', 'manifest', 'services']);
69
61
  for (const k of Object.keys(input)) {
70
62
  if (!KNOWN_TOP.has(k)) issues.push(`未知顶层键 "${k}"(契约外字段,检查拼写)`);
71
63
  }
72
64
 
73
- /* ── role name ── */
74
65
  if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
75
66
  issues.push(`role 必须是 [a-z0-9-]+ 字符串,收到 ${JSON.stringify(input.role)}`);
76
67
  }
77
68
 
78
- /* ── version (P2 semver) ── */
79
69
  if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
80
70
  issues.push(`version 必须是 x.y.z 三段数字(如 1.0.0),收到 ${JSON.stringify(input.version)}`);
81
71
  }
82
72
 
83
- /* ── model (WS-D10: provider/model role routing; optional) ── */
84
73
  if (input.model !== undefined) {
85
74
  if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
86
75
  issues.push(`model 必须是 provider/model 形态(如 opencode-go/muse-spark-1.2-contributor),收到 ${JSON.stringify(input.model)}`);
87
76
  }
88
77
  }
89
78
 
90
- /* ── optional description ── */
91
79
  if (input.description !== undefined && typeof input.description !== 'string') {
92
80
  issues.push('description 必须是字符串');
93
81
  }
94
82
 
95
- /* ── manifest ── */
83
+ if (input.guidelines !== undefined) {
84
+ if (!Array.isArray(input.guidelines)) {
85
+ issues.push('guidelines 必须是字符串数组');
86
+ } else if (input.guidelines.length === 0) {
87
+ issues.push('guidelines 为空数组时请直接省略该字段');
88
+ } else if (!input.guidelines.every((g) => typeof g === 'string' && g.trim() !== '')) {
89
+ issues.push('guidelines 的每一项必须是非空字符串');
90
+ } else if (input.guidelines.length > 20) {
91
+ issues.push('guidelines 最多 20 条(prompt 预算保护)');
92
+ }
93
+ }
94
+
96
95
  const m = input.manifest;
97
96
  if (!isPlainObject(m)) {
98
97
  issues.push('manifest 必须是对象');
@@ -102,12 +101,10 @@ export function validateRoleManifest(input: unknown): ValidateResult {
102
101
  if (!KNOWN_MANIFEST.has(k)) issues.push(`manifest 内未知键 "${k}"`);
103
102
  }
104
103
 
105
- // D82 unknownTools: enum validation; omitted means deny (safe default).
106
104
  if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
107
105
  issues.push(`manifest.unknownTools 必须是 allow/deny,收到 ${JSON.stringify(m.unknownTools)}`);
108
106
  }
109
107
 
110
- // tools must be a unique, non-empty string array containing coordination tools; an empty array also reports the omissions.
111
108
  const tools = m.tools;
112
109
  if (!Array.isArray(tools) || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
113
110
  issues.push('manifest.tools 必须是非空字符串数组(工具名 [a-z0-9_-])');
@@ -122,7 +119,6 @@ export function validateRoleManifest(input: unknown): ValidateResult {
122
119
  }
123
120
  }
124
121
 
125
- // rules accept three-state values; each key must be a tool name or *.
126
122
  if (m.rules !== undefined) {
127
123
  if (!isPlainObject(m.rules)) {
128
124
  issues.push('manifest.rules 必须是对象');
@@ -137,7 +133,6 @@ export function validateRoleManifest(input: unknown): ValidateResult {
137
133
  }
138
134
  }
139
135
 
140
- /* ── services ── */
141
136
  if (input.services !== undefined) {
142
137
  if (!isPlainObject(input.services)) {
143
138
  issues.push('services 必须是对象');
@@ -0,0 +1,119 @@
1
+ /**
2
+ * P0 (RFC docs/rfc-pi-0.86-dynamic-tools.md §4): mutable role state for mid-session switching. pi
3
+ * 0.86 records `setActiveTools` deltas in the transcript, so a switch survives resume and branch
4
+ * navigation; this module owns pier's half — replaying the gate manifest from the last
5
+ * `pi-herdr.role-manifest` entry and planning the active-set transition.
6
+ */
7
+ import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
8
+ import type { RuntimeRoleManifest } from './tool-gate.ts';
9
+ import { filterToolsByStance } from './tool-gate.ts';
10
+
11
+ /** Shape persisted in ROLE_MANIFEST_CUSTOM_TYPE entries: the runtime manifest plus the payload version and
12
+ * the switch provenance. `version` is the payload discriminator and `manifestVersion` the manifest semver
13
+ * — replay must not confuse the two. */
14
+ export type RoleManifestRecord = Omit<RuntimeRoleManifest, 'version'> & {
15
+ version: 1;
16
+ manifestVersion?: string;
17
+ /** 'switch' records a mid-session switch; the session_start anchor write omits it (env origin). */
18
+ origin?: 'switch';
19
+ switchedBy?: string;
20
+ ts?: number;
21
+ };
22
+
23
+ /** State carried across a process for the armed role system; manifest==null means unarmed (bare pi). */
24
+ export interface RoleState {
25
+ manifest: RuntimeRoleManifest | null;
26
+ origin: 'env' | 'switch';
27
+ switchedBy: string | null;
28
+ switchedAt: number | null;
29
+ }
30
+
31
+ export function initialRoleState(manifest: RuntimeRoleManifest | null): RoleState {
32
+ return { manifest, origin: 'env', switchedBy: null, switchedAt: null };
33
+ }
34
+
35
+ /** Last `pi-herdr.role-manifest` record on the branch, or null. Session entries are pi JSONL objects
36
+ * ({ type: 'custom', customType, data }); unknown shapes are skipped, not fatal — replay must never break
37
+ * session startup. */
38
+ export function latestRoleManifestRecord(entries: readonly unknown[]): RoleManifestRecord | null {
39
+ let found: RoleManifestRecord | null = null;
40
+ for (const raw of entries) {
41
+ const entry = raw as { type?: unknown; customType?: unknown; data?: unknown };
42
+ if (entry?.type !== 'custom' || entry.customType !== 'pi-herdr.role-manifest') continue;
43
+ const data = entry.data as Partial<RoleManifestRecord> | undefined;
44
+ if (
45
+ !data ||
46
+ typeof data.role !== 'string' ||
47
+ !Array.isArray(data.tools) ||
48
+ !data.tools.every((t) => typeof t === 'string')
49
+ ) {
50
+ continue;
51
+ }
52
+ const guidelines = Array.isArray(data.guidelines)
53
+ ? data.guidelines.filter((g): g is string => typeof g === 'string')
54
+ : [];
55
+ const rawPermissions = data.permissions;
56
+ found = {
57
+ version: 1,
58
+ role: data.role,
59
+ manifestVersion: typeof data.manifestVersion === 'string' ? data.manifestVersion : undefined,
60
+ tools: data.tools as string[],
61
+ permissions:
62
+ rawPermissions && typeof rawPermissions === 'object' && !Array.isArray(rawPermissions)
63
+ ? (rawPermissions as RuntimeRoleManifest['permissions'])
64
+ : {},
65
+ unknownTools: data.unknownTools === 'allow' ? 'allow' : 'deny',
66
+ guidelines: guidelines.length > 0 ? guidelines : undefined,
67
+ origin: data.origin === 'switch' ? 'switch' : undefined,
68
+ switchedBy: typeof data.switchedBy === 'string' ? data.switchedBy : undefined,
69
+ ts: typeof data.ts === 'number' ? data.ts : undefined,
70
+ };
71
+ }
72
+ return found;
73
+ }
74
+
75
+ export function manifestFromRecord(rec: RoleManifestRecord): RuntimeRoleManifest {
76
+ return {
77
+ role: rec.role,
78
+ version: rec.manifestVersion,
79
+ tools: rec.tools,
80
+ permissions: rec.permissions,
81
+ unknownTools: rec.unknownTools,
82
+ ...(rec.guidelines && rec.guidelines.length > 0 ? { guidelines: rec.guidelines } : {}),
83
+ };
84
+ }
85
+
86
+ /** Whether the record differs from the state that would be written now (drives change-only writes). */
87
+ export function roleRecordDiffers(rec: RoleManifestRecord | null, state: RoleState): boolean {
88
+ const m = state.manifest;
89
+ if (!m) return false;
90
+ if (!rec) return true;
91
+ return rec.role !== m.role || JSON.stringify(rec.tools) !== JSON.stringify(m.tools);
92
+ }
93
+
94
+ /** Switch plan: widening (needs human confirm when driven by /pier-role) + diff summary. */
95
+ export interface RoleSwitchPlan {
96
+ widening: boolean;
97
+ added: string[];
98
+ removed: string[];
99
+ }
100
+
101
+ export function planRoleSwitch(oldTools: readonly string[], newTools: readonly string[]): RoleSwitchPlan {
102
+ const oldSet = new Set(oldTools);
103
+ const newSet = new Set(newTools);
104
+ const added = newTools.filter((t) => !oldSet.has(t));
105
+ const removed = oldTools.filter((t) => !newSet.has(t));
106
+ return { widening: added.length > 0, added, removed };
107
+ }
108
+
109
+ /** Active set for a switch, over ALL registered tools rather than the current active set: a switch may
110
+ * re-admit tools an earlier session_start prune removed, which intersection semantics would lose. Stance
111
+ * handling is shared with planActiveTools (tool-gate.ts); the empty result is NOT a no-op here — a switch
112
+ * to a role with no overlap is a legitimate shrink. */
113
+ export function planSwitchActiveTools(
114
+ manifestTools: readonly string[],
115
+ registeredToolNames: readonly string[],
116
+ opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
117
+ ): string[] {
118
+ return filterToolsByStance(manifestTools, registeredToolNames, opts);
119
+ }