pi-pier 0.3.0 → 0.4.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/README.md +16 -12
- package/package.json +3 -3
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +10 -13
- package/src/compact-coordinator.ts +279 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +61 -22
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/focus-poller.ts +0 -244
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/renderers.ts
CHANGED
|
@@ -1,66 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TUI renderers for pier's own session custom entries and reminder messages
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
import { SUBS_CUSTOM_TYPE, type SubsRegistry } from './subagent-core.ts';
|
|
7
|
+
import { SUBS_CUSTOM_TYPE, PROGRESS_CUSTOM_TYPE, subRef, type SubsRegistry } from './subagent-core.ts';
|
|
8
|
+
import { DELEGATION_CUSTOM_TYPE } from './delegation-core.ts';
|
|
13
9
|
import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
|
|
14
10
|
import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
|
|
15
11
|
|
|
16
|
-
// Width handling lives in
|
|
17
|
-
|
|
18
|
-
import { charWidth, styledWidth, truncateStyled } from './ansi-text.ts';
|
|
19
|
-
export { charWidth, styledWidth, truncateStyled };
|
|
12
|
+
// Width handling lives in ansi-text.ts so ask_user_question can reuse it without this import graph.
|
|
13
|
+
import { truncateStyled } from './ansi-text.ts';
|
|
20
14
|
|
|
21
15
|
/** Role manifest entry is written by index.ts; keep the literal here to avoid an import cycle. */
|
|
22
16
|
export const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
|
|
23
|
-
/** Soft-approval trace
|
|
17
|
+
/** Soft-approval trace from the worker manifest gate in index.ts. */
|
|
24
18
|
export const APPROVAL_NEEDED_CUSTOM_TYPE = 'pi-herdr.approval-needed';
|
|
25
19
|
|
|
26
|
-
/** Structural subset of pi's Theme
|
|
20
|
+
/** Structural subset of pi's Theme actually used here. */
|
|
27
21
|
export interface RenderTheme {
|
|
28
22
|
fg(color: string, text: string): string;
|
|
29
23
|
bold(text: string): string;
|
|
30
24
|
}
|
|
31
25
|
|
|
32
|
-
/** Structural subset of pi's Component; enough for `registerEntryRenderer`. */
|
|
33
26
|
export interface RenderComponent {
|
|
34
27
|
render(width: number): string[];
|
|
35
28
|
invalidate(): void;
|
|
36
29
|
}
|
|
37
30
|
|
|
31
|
+
type Renderer = (payload: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent | undefined;
|
|
32
|
+
|
|
38
33
|
export interface RendererApi {
|
|
39
|
-
registerEntryRenderer?(
|
|
40
|
-
|
|
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;
|
|
34
|
+
registerEntryRenderer?(customType: string, renderer: Renderer): void;
|
|
35
|
+
registerMessageRenderer?(customType: string, renderer: Renderer): void;
|
|
47
36
|
}
|
|
48
37
|
|
|
49
|
-
|
|
38
|
+
function clip(text: string, max: number): string {
|
|
39
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
40
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
|
41
|
+
}
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
export function card(lines: readonly string[]): RenderComponent {
|
|
43
|
+
function card(lines: readonly string[]): RenderComponent {
|
|
53
44
|
return {
|
|
54
45
|
render: (width: number) => lines.map((line) => truncateStyled(line, width)),
|
|
55
46
|
invalidate() { /* No cached state. */ },
|
|
56
47
|
};
|
|
57
48
|
}
|
|
58
49
|
|
|
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
50
|
const EDIT_VERB: Record<string, string> = {
|
|
65
51
|
done: '✓ done',
|
|
66
52
|
drop: '■ dropped',
|
|
@@ -68,7 +54,7 @@ const EDIT_VERB: Record<string, string> = {
|
|
|
68
54
|
rm: '✗ removed',
|
|
69
55
|
};
|
|
70
56
|
|
|
71
|
-
|
|
57
|
+
function formatEditCounts(edits: ReadonlyArray<{ op: string }>): string {
|
|
72
58
|
const counts = new Map<string, number>();
|
|
73
59
|
for (const edit of edits) counts.set(edit.op, (counts.get(edit.op) ?? 0) + 1);
|
|
74
60
|
return [...counts.entries()]
|
|
@@ -76,7 +62,7 @@ export function formatEditCounts(edits: ReadonlyArray<{ op: string }>): string {
|
|
|
76
62
|
.join(' · ');
|
|
77
63
|
}
|
|
78
64
|
|
|
79
|
-
|
|
65
|
+
function todoEditLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
80
66
|
const payload = data as TodoEditPayload | undefined;
|
|
81
67
|
const edits = Array.isArray(payload?.edits) ? payload.edits : [];
|
|
82
68
|
if (edits.length === 0) return [theme.fg('dim', 'todo · no edits')];
|
|
@@ -88,52 +74,104 @@ export function todoEditLines(data: unknown, theme: RenderTheme, expanded: boole
|
|
|
88
74
|
];
|
|
89
75
|
}
|
|
90
76
|
|
|
91
|
-
|
|
77
|
+
/** Registry card, shared by the subagent and terminal registries: a titled count line plus one row
|
|
78
|
+
* per entry when expanded; an empty or malformed registry renders one dim placeholder. */
|
|
79
|
+
function registryLines<T>(
|
|
80
|
+
items: readonly T[] | undefined,
|
|
81
|
+
theme: RenderTheme,
|
|
82
|
+
expanded: boolean,
|
|
83
|
+
card: { label: string; unit: string; badge?: string; row: (item: T) => string },
|
|
84
|
+
): string[] {
|
|
85
|
+
if (!items || items.length === 0) return [theme.fg('dim', `${card.label} · none`)];
|
|
86
|
+
const head = `${theme.fg('accent', theme.bold(card.label))} ${theme.fg('dim', `· ${items.length} ${card.unit}`)}${card.badge ? ` ${theme.fg('success', card.badge)}` : ''}`;
|
|
87
|
+
if (!expanded) return [head];
|
|
88
|
+
return [head, ...items.map((item) => theme.fg('dim', card.row(item)))];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function subsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
92
92
|
const subs = (data as SubsRegistry | undefined)?.subs;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
const running = Array.isArray(subs) ? subs.filter((sub) => sub.status === 'running').length : 0;
|
|
94
|
+
return registryLines(Array.isArray(subs) ? subs : undefined, theme, expanded, {
|
|
95
|
+
label: 'subagents',
|
|
96
|
+
unit: 'tracked',
|
|
97
|
+
...(running ? { badge: `${running} running` } : {}),
|
|
98
|
+
// Every registry row IS a delegated worker (↳ marks the delegation edge; status text carries state).
|
|
99
|
+
row: (sub) => ` ↳ ${subRef(sub)} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** First-frame delegation card in the worker's own transcript (RFC rfc-delegation-identity §2.3). */
|
|
104
|
+
function delegationLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
105
|
+
const rec = (data ?? {}) as {
|
|
106
|
+
parentPaneId?: unknown;
|
|
107
|
+
parentSessionId?: unknown;
|
|
108
|
+
taskId?: unknown;
|
|
109
|
+
description?: unknown;
|
|
110
|
+
role?: unknown;
|
|
111
|
+
};
|
|
112
|
+
const parent = typeof rec.parentPaneId === 'string' && rec.parentPaneId ? rec.parentPaneId : '?';
|
|
113
|
+
const head = `${theme.fg('accent', '⟵ delegated')} ${theme.fg('dim', `by master ${parent} · ${clip(String(rec.description ?? ''), 60)}`)}`;
|
|
96
114
|
if (!expanded) return [head];
|
|
115
|
+
const session = typeof rec.parentSessionId === 'string' && rec.parentSessionId ? rec.parentSessionId : '?';
|
|
116
|
+
const role = typeof rec.role === 'string' && rec.role ? rec.role : '?';
|
|
97
117
|
return [
|
|
98
118
|
head,
|
|
99
|
-
|
|
100
|
-
'dim',
|
|
101
|
-
` ${sub.status === 'running' ? '●' : '○'} ${sub.paneId} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
|
|
102
|
-
)),
|
|
119
|
+
theme.fg('dim', ` task ${typeof rec.taskId === 'string' ? rec.taskId : '?'} · role ${role} · parent session ${session}`),
|
|
103
120
|
];
|
|
104
121
|
}
|
|
105
122
|
|
|
106
|
-
|
|
123
|
+
function terminalsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
107
124
|
const terminals = (data as TerminalsRegistry | undefined)?.terminals;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
...terminals.map((t) => theme.fg('dim', ` ${t.paneId} · ${clip(String(t.label ?? t.cwd ?? ''), 70)}`)),
|
|
114
|
-
];
|
|
125
|
+
return registryLines(Array.isArray(terminals) ? terminals : undefined, theme, expanded, {
|
|
126
|
+
label: 'terminals',
|
|
127
|
+
unit: 'open',
|
|
128
|
+
row: (t) => ` ${t.paneId} · ${clip(String(t.label ?? t.cwd ?? ''), 70)}`,
|
|
129
|
+
});
|
|
115
130
|
}
|
|
116
131
|
|
|
117
|
-
|
|
118
|
-
const rec = (data ?? {}) as {
|
|
132
|
+
function roleManifestLines(data: unknown, theme: RenderTheme): string[] {
|
|
133
|
+
const rec = (data ?? {}) as {
|
|
134
|
+
role?: unknown;
|
|
135
|
+
manifestVersion?: unknown;
|
|
136
|
+
tools?: unknown;
|
|
137
|
+
permissions?: unknown;
|
|
138
|
+
origin?: unknown;
|
|
139
|
+
switchedBy?: unknown;
|
|
140
|
+
};
|
|
119
141
|
const role = typeof rec.role === 'string' ? rec.role : '?';
|
|
120
142
|
const version = typeof rec.manifestVersion === 'string' || typeof rec.manifestVersion === 'number' ? String(rec.manifestVersion) : '?';
|
|
121
143
|
const tools = Array.isArray(rec.tools) ? rec.tools.length : 0;
|
|
122
144
|
const gates = Object.values((rec.permissions ?? {}) as Record<string, unknown>)
|
|
123
145
|
.filter((v) => v === 'deny' || v === 'ask').length;
|
|
124
146
|
const tail = gates > 0 ? theme.fg('warning', ` · ${gates} gated`) : '';
|
|
125
|
-
|
|
147
|
+
// P0: switch provenance — origin 'switch' marks a mid-session swap (vs the session_start anchor).
|
|
148
|
+
const switched =
|
|
149
|
+
rec.origin === 'switch'
|
|
150
|
+
? theme.fg('accent', ` ⇄ ${typeof rec.switchedBy === 'string' && rec.switchedBy ? rec.switchedBy : '?'}`)
|
|
151
|
+
: '';
|
|
152
|
+
return [`${theme.fg('accent', theme.bold('role'))} ${role} ${theme.fg('dim', `v${version} · ${tools} tools`)}${tail}${switched}`];
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
|
|
155
|
+
function approvalLines(data: unknown, theme: RenderTheme): string[] {
|
|
129
156
|
const rec = (data ?? {}) as { role?: unknown; tool?: unknown };
|
|
130
157
|
const role = typeof rec.role === 'string' ? rec.role : '?';
|
|
131
158
|
const tool = typeof rec.tool === 'string' ? rec.tool : '?';
|
|
132
159
|
return [`${theme.fg('warning', '⚠ approval needed')} ${theme.fg('dim', `· ${tool} (${role})`)}`];
|
|
133
160
|
}
|
|
134
161
|
|
|
162
|
+
/** Phase 2: worker progress note (display-less custom message) as a one-line card — the sender
|
|
163
|
+
* pane rides `details.paneId`; keep it recognizable without flooding the transcript. */
|
|
164
|
+
function progressLines(message: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
165
|
+
const rec = (message ?? {}) as { content?: unknown; details?: { paneId?: unknown } };
|
|
166
|
+
const from = typeof rec.details?.paneId === 'string' && rec.details.paneId ? rec.details.paneId : '?';
|
|
167
|
+
const content = typeof rec.content === 'string' ? rec.content : '';
|
|
168
|
+
const head = `${theme.fg('accent', '⤴ progress')} ${theme.fg('dim', `· ${from}: ${clip(content, 100)}`)}`;
|
|
169
|
+
if (!expanded) return [head];
|
|
170
|
+
return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
|
|
171
|
+
}
|
|
172
|
+
|
|
135
173
|
/** Reminder custom messages carry the full injected text; restyle without altering it. */
|
|
136
|
-
|
|
174
|
+
function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
|
|
137
175
|
const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
|
|
138
176
|
? String((message as { content: string }).content)
|
|
139
177
|
: '';
|
|
@@ -143,55 +181,41 @@ export function reminderLines(message: unknown, theme: RenderTheme, label: strin
|
|
|
143
181
|
return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
|
|
144
182
|
}
|
|
145
183
|
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
}
|
|
184
|
+
type LineBuilder = (data: unknown, theme: RenderTheme, expanded: boolean) => string[];
|
|
153
185
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
*/
|
|
186
|
+
/** Register pier's transcript renderers and return the custom types actually registered, so the
|
|
187
|
+
* caller can log coverage and tests can assert the degrade path. Each registration is individually
|
|
188
|
+
* guarded: a pi build without the API must never break tool registration. */
|
|
168
189
|
export function installRenderers(pi: unknown): string[] {
|
|
169
190
|
const api = pi as RendererApi;
|
|
170
191
|
const registered: string[] = [];
|
|
171
|
-
const entry = (type: string, build:
|
|
192
|
+
const entry = (type: string, build: LineBuilder): void => {
|
|
172
193
|
if (typeof api.registerEntryRenderer !== 'function') return;
|
|
173
194
|
try {
|
|
174
|
-
api.registerEntryRenderer(type,
|
|
195
|
+
api.registerEntryRenderer(type, (entryData, options, theme) =>
|
|
196
|
+
card(build((entryData as { data?: unknown } | undefined)?.data, theme, options.expanded)));
|
|
175
197
|
registered.push(type);
|
|
176
198
|
} catch {
|
|
177
199
|
/* Older pi or a conflicting renderer: keep the default rendering. */
|
|
178
200
|
}
|
|
179
201
|
};
|
|
180
|
-
const message = (type: string, build:
|
|
202
|
+
const message = (type: string, build: LineBuilder): void => {
|
|
181
203
|
if (typeof api.registerMessageRenderer !== 'function') return;
|
|
182
204
|
try {
|
|
183
|
-
api.registerMessageRenderer(type,
|
|
205
|
+
api.registerMessageRenderer(type, (msg, options, theme) => card(build(msg, theme, options.expanded)));
|
|
184
206
|
registered.push(type);
|
|
185
207
|
} catch {
|
|
186
|
-
/*
|
|
208
|
+
/* A throwing renderer must not break message registration. */
|
|
187
209
|
}
|
|
188
210
|
};
|
|
189
211
|
|
|
190
|
-
entry(TODO_EDIT_CUSTOM_TYPE,
|
|
191
|
-
entry(SUBS_CUSTOM_TYPE,
|
|
192
|
-
entry(TERMINALS_CUSTOM_TYPE,
|
|
212
|
+
entry(TODO_EDIT_CUSTOM_TYPE, todoEditLines);
|
|
213
|
+
entry(SUBS_CUSTOM_TYPE, subsLines);
|
|
214
|
+
entry(TERMINALS_CUSTOM_TYPE, terminalsLines);
|
|
215
|
+
entry(DELEGATION_CUSTOM_TYPE, delegationLines);
|
|
193
216
|
entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
|
|
194
217
|
entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
|
|
218
|
+
message(PROGRESS_CUSTOM_TYPE, progressLines);
|
|
195
219
|
message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
|
|
196
220
|
message(TERM_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'terminal nudge', expanded));
|
|
197
221
|
return registered;
|
package/src/role-loader.ts
CHANGED
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
package/src/role-manifest.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
*/
|
|
7
|
+
import { isPlainObject } from './guards.ts';
|
|
8
|
+
|
|
10
9
|
export type PermissionAction = 'allow' | 'ask' | 'deny';
|
|
11
10
|
export type TodosMode = 'serial' | 'parallel';
|
|
12
11
|
/** D82: stance for unknown tools—visibility is a trust relationship (user→master via install; master→delegated via mirror). */
|
|
@@ -14,24 +13,20 @@ export type UnknownToolStance = 'allow' | 'deny';
|
|
|
14
13
|
|
|
15
14
|
export interface RoleManifest {
|
|
16
15
|
role: string;
|
|
17
|
-
/** Semantic version x.y.z (for tracing manifest evolution; P2). */
|
|
18
16
|
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
|
-
*/
|
|
17
|
+
/** WS-D10: `provider/model` routing by role; omitted means follow the process default (spawn injects `--provider/--model`). */
|
|
23
18
|
model?: string;
|
|
24
19
|
description?: string;
|
|
20
|
+
/** P0 per-role guidelines (RFC §4.6): constraints a tool set cannot express; injected as a prompt section. */
|
|
21
|
+
guidelines?: string[];
|
|
25
22
|
manifest: {
|
|
26
|
-
/** Baseline tools (non-empty; must include todo_write + ask_user_question for coordination). */
|
|
27
23
|
tools: string[];
|
|
28
24
|
/** Three-state permissions; `*` supplies the default; omitted means `{"*":"allow"}`. */
|
|
29
25
|
rules?: Record<string, PermissionAction>;
|
|
30
26
|
/**
|
|
31
|
-
* D82
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* Excluded families (such as worker's subagent/terminal) need explicit deny to remain blocked under allow.
|
|
27
|
+
* D82 stance for tools outside the manifest (default deny; master/worker = allow — install grants
|
|
28
|
+
* the user's extension access). Separate axis from `rules['*']`: visibility vs enforcement.
|
|
29
|
+
* Excluded families (worker's subagent/terminal) need an explicit deny to stay blocked under allow.
|
|
35
30
|
*/
|
|
36
31
|
unknownTools?: UnknownToolStance;
|
|
37
32
|
};
|
|
@@ -54,9 +49,6 @@ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
|
54
49
|
const TOOL_KEY_RE = /^[a-z0-9_*-]+$/;
|
|
55
50
|
const ACTIONS: readonly PermissionAction[] = ['allow', 'ask', 'deny'];
|
|
56
51
|
|
|
57
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
58
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
59
|
-
}
|
|
60
52
|
|
|
61
53
|
export function validateRoleManifest(input: unknown): ValidateResult {
|
|
62
54
|
const issues: string[] = [];
|
|
@@ -64,35 +56,41 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
64
56
|
return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role 档案必须是 JSON 对象'] };
|
|
65
57
|
}
|
|
66
58
|
|
|
67
|
-
|
|
68
|
-
const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'manifest', 'services']);
|
|
59
|
+
const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'guidelines', 'manifest', 'services']);
|
|
69
60
|
for (const k of Object.keys(input)) {
|
|
70
61
|
if (!KNOWN_TOP.has(k)) issues.push(`未知顶层键 "${k}"(契约外字段,检查拼写)`);
|
|
71
62
|
}
|
|
72
63
|
|
|
73
|
-
/* ── role name ── */
|
|
74
64
|
if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
|
|
75
65
|
issues.push(`role 必须是 [a-z0-9-]+ 字符串,收到 ${JSON.stringify(input.role)}`);
|
|
76
66
|
}
|
|
77
67
|
|
|
78
|
-
/* ── version (P2 semver) ── */
|
|
79
68
|
if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
|
|
80
69
|
issues.push(`version 必须是 x.y.z 三段数字(如 1.0.0),收到 ${JSON.stringify(input.version)}`);
|
|
81
70
|
}
|
|
82
71
|
|
|
83
|
-
/* ── model (WS-D10: provider/model role routing; optional) ── */
|
|
84
72
|
if (input.model !== undefined) {
|
|
85
73
|
if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
|
|
86
74
|
issues.push(`model 必须是 provider/model 形态(如 opencode-go/muse-spark-1.2-contributor),收到 ${JSON.stringify(input.model)}`);
|
|
87
75
|
}
|
|
88
76
|
}
|
|
89
77
|
|
|
90
|
-
/* ── optional description ── */
|
|
91
78
|
if (input.description !== undefined && typeof input.description !== 'string') {
|
|
92
79
|
issues.push('description 必须是字符串');
|
|
93
80
|
}
|
|
94
81
|
|
|
95
|
-
|
|
82
|
+
if (input.guidelines !== undefined) {
|
|
83
|
+
if (!Array.isArray(input.guidelines)) {
|
|
84
|
+
issues.push('guidelines 必须是字符串数组');
|
|
85
|
+
} else if (input.guidelines.length === 0) {
|
|
86
|
+
issues.push('guidelines 为空数组时请直接省略该字段');
|
|
87
|
+
} else if (!input.guidelines.every((g) => typeof g === 'string' && g.trim() !== '')) {
|
|
88
|
+
issues.push('guidelines 的每一项必须是非空字符串');
|
|
89
|
+
} else if (input.guidelines.length > 20) {
|
|
90
|
+
issues.push('guidelines 最多 20 条(prompt 预算保护)');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
96
94
|
const m = input.manifest;
|
|
97
95
|
if (!isPlainObject(m)) {
|
|
98
96
|
issues.push('manifest 必须是对象');
|
|
@@ -102,12 +100,10 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
102
100
|
if (!KNOWN_MANIFEST.has(k)) issues.push(`manifest 内未知键 "${k}"`);
|
|
103
101
|
}
|
|
104
102
|
|
|
105
|
-
// D82 unknownTools: enum validation; omitted means deny (safe default).
|
|
106
103
|
if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
|
|
107
104
|
issues.push(`manifest.unknownTools 必须是 allow/deny,收到 ${JSON.stringify(m.unknownTools)}`);
|
|
108
105
|
}
|
|
109
106
|
|
|
110
|
-
// tools must be a unique, non-empty string array containing coordination tools; an empty array also reports the omissions.
|
|
111
107
|
const tools = m.tools;
|
|
112
108
|
if (!Array.isArray(tools) || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
|
|
113
109
|
issues.push('manifest.tools 必须是非空字符串数组(工具名 [a-z0-9_-])');
|
|
@@ -122,7 +118,6 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
122
118
|
}
|
|
123
119
|
}
|
|
124
120
|
|
|
125
|
-
// rules accept three-state values; each key must be a tool name or *.
|
|
126
121
|
if (m.rules !== undefined) {
|
|
127
122
|
if (!isPlainObject(m.rules)) {
|
|
128
123
|
issues.push('manifest.rules 必须是对象');
|
|
@@ -137,7 +132,6 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
137
132
|
}
|
|
138
133
|
}
|
|
139
134
|
|
|
140
|
-
/* ── services ── */
|
|
141
135
|
if (input.services !== undefined) {
|
|
142
136
|
if (!isPlainObject(input.services)) {
|
|
143
137
|
issues.push('services 必须是对象');
|