pi-pier 0.2.7 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
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(
|
|
@@ -143,7 +148,8 @@ function parseAndValidate(name: string, fileName: string, text: string, layerLab
|
|
|
143
148
|
throw new RoleLoaderError('INVALID_ROLE_CONFIG', `role "${name}"(${layerLabel})不是合法 JSON: ${(err as Error).message}`);
|
|
144
149
|
}
|
|
145
150
|
const result = validateRoleManifest(parsed);
|
|
146
|
-
|
|
151
|
+
// `ok === false` (instead of `!ok`) is required to narrow without strictNullChecks.
|
|
152
|
+
if (result.ok === false) {
|
|
147
153
|
throw new RoleLoaderError(
|
|
148
154
|
'INVALID_ROLE_CONFIG',
|
|
149
155
|
`role "${name}"(${layerLabel})校验失败:\n${result.issues.map((i) => ` - ${i}`).join('\n')}`,
|
package/src/role-manifest.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
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
|
*/
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
};
|
|
@@ -58,45 +51,47 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
|
58
51
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
59
52
|
}
|
|
60
53
|
|
|
61
|
-
function isPosInt(v: unknown): boolean {
|
|
62
|
-
return typeof v === 'number' && Number.isInteger(v) && v > 0;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
54
|
export function validateRoleManifest(input: unknown): ValidateResult {
|
|
66
55
|
const issues: string[] = [];
|
|
67
56
|
if (!isPlainObject(input)) {
|
|
68
57
|
return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role 档案必须是 JSON 对象'] };
|
|
69
58
|
}
|
|
70
59
|
|
|
71
|
-
|
|
72
|
-
const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'manifest', 'services']);
|
|
60
|
+
const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'guidelines', 'manifest', 'services']);
|
|
73
61
|
for (const k of Object.keys(input)) {
|
|
74
62
|
if (!KNOWN_TOP.has(k)) issues.push(`未知顶层键 "${k}"(契约外字段,检查拼写)`);
|
|
75
63
|
}
|
|
76
64
|
|
|
77
|
-
/* ── role name ── */
|
|
78
65
|
if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
|
|
79
66
|
issues.push(`role 必须是 [a-z0-9-]+ 字符串,收到 ${JSON.stringify(input.role)}`);
|
|
80
67
|
}
|
|
81
68
|
|
|
82
|
-
/* ── version (P2 semver) ── */
|
|
83
69
|
if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
|
|
84
70
|
issues.push(`version 必须是 x.y.z 三段数字(如 1.0.0),收到 ${JSON.stringify(input.version)}`);
|
|
85
71
|
}
|
|
86
72
|
|
|
87
|
-
/* ── model (WS-D10: provider/model role routing; optional) ── */
|
|
88
73
|
if (input.model !== undefined) {
|
|
89
74
|
if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
|
|
90
75
|
issues.push(`model 必须是 provider/model 形态(如 opencode-go/muse-spark-1.2-contributor),收到 ${JSON.stringify(input.model)}`);
|
|
91
76
|
}
|
|
92
77
|
}
|
|
93
78
|
|
|
94
|
-
/* ── optional description ── */
|
|
95
79
|
if (input.description !== undefined && typeof input.description !== 'string') {
|
|
96
80
|
issues.push('description 必须是字符串');
|
|
97
81
|
}
|
|
98
82
|
|
|
99
|
-
|
|
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
|
+
|
|
100
95
|
const m = input.manifest;
|
|
101
96
|
if (!isPlainObject(m)) {
|
|
102
97
|
issues.push('manifest 必须是对象');
|
|
@@ -106,12 +101,10 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
106
101
|
if (!KNOWN_MANIFEST.has(k)) issues.push(`manifest 内未知键 "${k}"`);
|
|
107
102
|
}
|
|
108
103
|
|
|
109
|
-
// D82 unknownTools: enum validation; omitted means deny (safe default).
|
|
110
104
|
if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
|
|
111
105
|
issues.push(`manifest.unknownTools 必须是 allow/deny,收到 ${JSON.stringify(m.unknownTools)}`);
|
|
112
106
|
}
|
|
113
107
|
|
|
114
|
-
// tools must be a unique, non-empty string array containing coordination tools; an empty array also reports the omissions.
|
|
115
108
|
const tools = m.tools;
|
|
116
109
|
if (!Array.isArray(tools) || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
|
|
117
110
|
issues.push('manifest.tools 必须是非空字符串数组(工具名 [a-z0-9_-])');
|
|
@@ -126,7 +119,6 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
126
119
|
}
|
|
127
120
|
}
|
|
128
121
|
|
|
129
|
-
// rules accept three-state values; each key must be a tool name or *.
|
|
130
122
|
if (m.rules !== undefined) {
|
|
131
123
|
if (!isPlainObject(m.rules)) {
|
|
132
124
|
issues.push('manifest.rules 必须是对象');
|
|
@@ -141,7 +133,6 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
141
133
|
}
|
|
142
134
|
}
|
|
143
135
|
|
|
144
|
-
/* ── services ── */
|
|
145
136
|
if (input.services !== undefined) {
|
|
146
137
|
if (!isPlainObject(input.services)) {
|
|
147
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
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 0 routing telemetry (RFC docs/rfc-jev-role-routing.md §8): observation only, zero behavior
|
|
3
|
+
* change. Three row kinds answer the decision questions before the grants collapse (phase 1) and the
|
|
4
|
+
* jev router (phase 2) are built — spawn profile, deny hits, axis usage.
|
|
5
|
+
*
|
|
6
|
+
* Privacy contract: task text NEVER enters telemetry — only taskSha8 (correlatable, not reversible)
|
|
7
|
+
* and tool/role names. Planners are pure and sessionId-free; the central append (index.ts) stamps the
|
|
8
|
+
* session id once, and every append is best-effort.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
type SpawnProfileRecord = {
|
|
13
|
+
kind: 'spawn';
|
|
14
|
+
ts: number;
|
|
15
|
+
roleExplicit: boolean;
|
|
16
|
+
role: string;
|
|
17
|
+
/** Model-suggested extras from the spawn `allowed_tools` param. */
|
|
18
|
+
allowedTools: string[];
|
|
19
|
+
manifestTools: string[];
|
|
20
|
+
/** sha256(task)[0:8] — correlates spawns without persisting the task text. */
|
|
21
|
+
taskSha8: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type DenyHitRecord = {
|
|
25
|
+
kind: 'deny';
|
|
26
|
+
ts: number;
|
|
27
|
+
role: string;
|
|
28
|
+
tool: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type AxisUsageRecord = {
|
|
32
|
+
kind: 'axis-usage';
|
|
33
|
+
ts: number;
|
|
34
|
+
files: number;
|
|
35
|
+
parsed: number;
|
|
36
|
+
invalid: number;
|
|
37
|
+
askEntries: number;
|
|
38
|
+
stanceAllow: number;
|
|
39
|
+
stanceDeny: number;
|
|
40
|
+
/** [role, tool] pairs carrying an explicit deny — the clamp-table design data. */
|
|
41
|
+
explicitDenies: Array<[string, string]>;
|
|
42
|
+
};
|
|
43
|
+
/** sessionId-free: the central append stamps it into RoutingTelemetryRow. */
|
|
44
|
+
export type RoutingTelemetryRecord = SpawnProfileRecord | DenyHitRecord | AxisUsageRecord;
|
|
45
|
+
|
|
46
|
+
export type RoutingTelemetryRow =
|
|
47
|
+
| ({ sessionId: string } & SpawnProfileRecord)
|
|
48
|
+
| ({ sessionId: string } & DenyHitRecord)
|
|
49
|
+
| ({ sessionId: string } & AxisUsageRecord);
|
|
50
|
+
|
|
51
|
+
export function taskFingerprint(task: string): string {
|
|
52
|
+
return createHash('sha256').update(task).digest('hex').slice(0, 8);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function planSpawnProfileRow(opts: {
|
|
56
|
+
now: number;
|
|
57
|
+
roleExplicit: boolean;
|
|
58
|
+
role: string;
|
|
59
|
+
allowedTools: readonly string[];
|
|
60
|
+
manifestTools: readonly string[];
|
|
61
|
+
task: string;
|
|
62
|
+
}): SpawnProfileRecord {
|
|
63
|
+
return {
|
|
64
|
+
kind: 'spawn',
|
|
65
|
+
ts: opts.now,
|
|
66
|
+
roleExplicit: opts.roleExplicit,
|
|
67
|
+
role: opts.role,
|
|
68
|
+
allowedTools: [...opts.allowedTools],
|
|
69
|
+
manifestTools: [...opts.manifestTools],
|
|
70
|
+
taskSha8: taskFingerprint(opts.task),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function planDenyHitRow(opts: {
|
|
75
|
+
now: number;
|
|
76
|
+
role: string;
|
|
77
|
+
tool: string;
|
|
78
|
+
}): DenyHitRecord {
|
|
79
|
+
return { kind: 'deny', ts: opts.now, role: opts.role, tool: opts.tool };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fold role-file texts into the axis-usage summary. A malformed file counts as invalid and never
|
|
84
|
+
* fails the scan. Shapes are mirrored, not imported, so telemetry cannot break on schema drift.
|
|
85
|
+
*/
|
|
86
|
+
export function scanRoleAxisUsage(opts: {
|
|
87
|
+
now: number;
|
|
88
|
+
files: ReadonlyArray<{ name: string; text: string }>;
|
|
89
|
+
}): AxisUsageRecord {
|
|
90
|
+
let parsed = 0;
|
|
91
|
+
let invalid = 0;
|
|
92
|
+
let askEntries = 0;
|
|
93
|
+
let stanceAllow = 0;
|
|
94
|
+
let stanceDeny = 0;
|
|
95
|
+
const explicitDenies: Array<[string, string]> = [];
|
|
96
|
+
for (const file of opts.files) {
|
|
97
|
+
let doc: {
|
|
98
|
+
role?: unknown;
|
|
99
|
+
manifest?: { unknownTools?: unknown; rules?: Record<string, unknown> | undefined };
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
doc = JSON.parse(file.text) as typeof doc;
|
|
103
|
+
} catch {
|
|
104
|
+
invalid += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Schema contract: only role + manifest are required. `rules` may be omitted (implicit
|
|
108
|
+
// {"*":"allow"}) and unknownTools defaults to deny, so a minimal role still counts as parsed.
|
|
109
|
+
if (typeof doc.role !== 'string' || typeof doc.manifest !== 'object' || doc.manifest === null) {
|
|
110
|
+
invalid += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
parsed += 1;
|
|
114
|
+
if (doc.manifest.unknownTools === 'allow') stanceAllow += 1;
|
|
115
|
+
else stanceDeny += 1;
|
|
116
|
+
const rules =
|
|
117
|
+
typeof doc.manifest.rules === 'object' && doc.manifest.rules !== null
|
|
118
|
+
? doc.manifest.rules
|
|
119
|
+
: ({ '*': 'allow' } as Record<string, unknown>);
|
|
120
|
+
for (const [tool, action] of Object.entries(rules)) {
|
|
121
|
+
if (action === 'ask') askEntries += 1;
|
|
122
|
+
else if (action === 'deny' && tool !== '*') explicitDenies.push([doc.role, tool]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
kind: 'axis-usage',
|
|
127
|
+
ts: opts.now,
|
|
128
|
+
files: opts.files.length,
|
|
129
|
+
parsed,
|
|
130
|
+
invalid,
|
|
131
|
+
askEntries,
|
|
132
|
+
stanceAllow,
|
|
133
|
+
stanceDeny,
|
|
134
|
+
explicitDenies,
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/runtime-policy.ts
CHANGED
|
@@ -1,66 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Why: Prevents drift between hardcoded literals and environment overrides;
|
|
5
|
-
* makes timing behavior explicit and testable.
|
|
2
|
+
* Every field is fed by the `PIER_OPTIONS` registry, so the numbers cannot drift from
|
|
3
|
+
* `/pier-config doctor`; an out-of-range value warns and falls back to the registry default.
|
|
6
4
|
*/
|
|
5
|
+
import { PIER_OPTIONS, pierOption, type PolicyField } from './pier-options.ts';
|
|
7
6
|
|
|
8
7
|
export interface RuntimePolicy {
|
|
9
|
-
/** Subagent overall timeout (ms) */
|
|
10
8
|
readonly subagentTimeoutMs: number
|
|
11
|
-
/** Subagent GC tick interval (ms) */
|
|
12
9
|
readonly gcTickMs: number
|
|
13
|
-
/** Poll interval for subagent state observation (ms) */
|
|
14
10
|
readonly pollIntervalMs: number
|
|
15
|
-
/**
|
|
16
|
-
readonly staleCadenceTurns: number
|
|
17
|
-
/** Settlement notice window / machine-inject grace / takeover idle (ms) */
|
|
11
|
+
/** Settlement notice window / machine-inject grace / takeover idle */
|
|
18
12
|
readonly settlementWindowMs: number
|
|
19
|
-
/** Post-settle observation window before auto-consume
|
|
13
|
+
/** Post-settle observation window before auto-consume */
|
|
20
14
|
readonly observationWindowMs: number
|
|
21
|
-
/** Foreground patience before background promotion (ms) */
|
|
22
15
|
readonly foregroundPatienceMs: number
|
|
23
|
-
/** Default session TTL (seconds) */
|
|
24
16
|
readonly sessionTtlSeconds: number
|
|
25
|
-
/** Terminal read maximum bytes */
|
|
26
|
-
readonly terminalReadMaxBytes: number
|
|
27
|
-
/** Git operation timeout (ms) */
|
|
28
17
|
readonly gitTimeoutMs: number
|
|
29
|
-
/** Subagent pane pipe readiness wait
|
|
18
|
+
/** Subagent pane pipe readiness wait */
|
|
30
19
|
readonly readinessTimeoutMs: number
|
|
31
20
|
}
|
|
32
21
|
|
|
33
|
-
function
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
22
|
+
function policyValue(field: PolicyField): number {
|
|
23
|
+
const spec = PIER_OPTIONS.find((o) => o.policy === field);
|
|
24
|
+
if (!spec) throw new Error(`no PIER_OPTIONS entry feeds RuntimePolicy.${field}`);
|
|
25
|
+
const fallback = Number(spec.fallback);
|
|
26
|
+
const raw = pierOption(spec.name);
|
|
27
|
+
if (raw === undefined) return fallback;
|
|
28
|
+
const parsed = Number.parseInt(raw, 10);
|
|
29
|
+
if (!Number.isFinite(parsed) || parsed < (spec.min ?? 0)) {
|
|
30
|
+
console.warn(`Invalid ${spec.name}="${raw}", using default ${spec.fallback}`);
|
|
31
|
+
return fallback;
|
|
41
32
|
}
|
|
42
|
-
|
|
43
|
-
return parsed
|
|
33
|
+
return parsed;
|
|
44
34
|
}
|
|
45
35
|
|
|
46
36
|
export function createRuntimePolicy(overrides?: Partial<RuntimePolicy>): RuntimePolicy {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
pollIntervalMs: overrides?.pollIntervalMs ?? parseEnvInt('PIER_POLL_INTERVAL_MS', 30_000, 1000),
|
|
51
|
-
staleCadenceTurns: overrides?.staleCadenceTurns ?? parseEnvInt('PIER_STALE_CADENCE_TURNS', 3, 1),
|
|
52
|
-
settlementWindowMs: overrides?.settlementWindowMs ?? parseEnvInt('PIER_SETTLEMENT_WINDOW_MS', 60_000, 0),
|
|
53
|
-
observationWindowMs: overrides?.observationWindowMs ?? parseEnvInt('PIER_OBSERVATION_WINDOW_MS', 30_000, 0),
|
|
54
|
-
foregroundPatienceMs: overrides?.foregroundPatienceMs ?? parseEnvInt('PIER_FOREGROUND_PATIENCE_MS', 300_000, 0),
|
|
55
|
-
sessionTtlSeconds: overrides?.sessionTtlSeconds ?? parseEnvInt('PIER_SESSION_TTL_SECONDS', 600, 0),
|
|
56
|
-
terminalReadMaxBytes: overrides?.terminalReadMaxBytes ?? parseEnvInt('TERM_READ_MAX', 4096, 1),
|
|
57
|
-
gitTimeoutMs: overrides?.gitTimeoutMs ?? parseEnvInt('PIER_GIT_TIMEOUT_MS', 10_000, 1),
|
|
58
|
-
readinessTimeoutMs: overrides?.readinessTimeoutMs ?? parseEnvInt('PIER_READY_TIMEOUT_MS', 30_000, 1000),
|
|
37
|
+
const policy = {} as { -readonly [K in keyof RuntimePolicy]: number };
|
|
38
|
+
for (const spec of PIER_OPTIONS) {
|
|
39
|
+
if (spec.policy) policy[spec.policy] = policyValue(spec.policy);
|
|
59
40
|
}
|
|
41
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
42
|
+
if (typeof value === 'number') policy[key as keyof RuntimePolicy] = value;
|
|
43
|
+
}
|
|
44
|
+
return policy;
|
|
60
45
|
}
|
|
61
46
|
|
|
62
|
-
/**
|
|
63
|
-
* Singleton runtime policy for production use.
|
|
64
|
-
* Tests should inject via createRuntimePolicy().
|
|
65
|
-
*/
|
|
47
|
+
/** Singleton for production; tests inject via createRuntimePolicy(). */
|
|
66
48
|
export const runtimePolicy = createRuntimePolicy()
|