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.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  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 +10 -13
  9. package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
package/src/ask-multi.ts DELETED
@@ -1,260 +0,0 @@
1
- /**
2
- * ask_user_question multi-select: pure state machine + a thin TUI component.
3
- *
4
- * Why: the previous multi path asked the human to *type* "1,3" into a text prompt,
5
- * which is the weakest part of the human gate. This module renders a real toggle
6
- * list (space to toggle, enter to confirm, esc to decline) with the authored
7
- * choices, an optional trailing free-text row, and a live selection count.
8
- *
9
- * The transition function is pure so the UX is testable without a terminal; the
10
- * component only maps keys to it. Installing the free-text row is opt-out
11
- * (`allowOther: false`) for pure-choice questions.
12
- */
13
- import { truncateStyled } from './ansi-text.ts';
14
-
15
- export interface MultiSelectOption {
16
- label: string;
17
- description?: string;
18
- }
19
-
20
- export interface MultiSelectConfig {
21
- options: readonly MultiSelectOption[];
22
- /** Render the trailing free-text row (pi-herdr's "Other"). */
23
- allowOther: boolean;
24
- /** 0-based index of the recommended option, marked in the list. */
25
- recommended?: number;
26
- }
27
-
28
- export interface MultiSelectState {
29
- /** Cursor row: 0..options.length-1 are choices, options.length is the Other row. */
30
- cursor: number;
31
- /** Toggle state per authored option (never includes the Other row). */
32
- selected: readonly boolean[];
33
- }
34
-
35
- export type MultiSelectKey = 'up' | 'down' | 'toggle' | 'confirm' | 'cancel' | 'all' | 'ignore';
36
-
37
- export type MultiSelectOutcome =
38
- | { kind: 'selected'; labels: string[] }
39
- | { kind: 'other' }
40
- | { kind: 'cancel' };
41
-
42
- export interface MultiSelectStep {
43
- state: MultiSelectState;
44
- outcome?: MultiSelectOutcome;
45
- }
46
-
47
- export function createMultiSelectState(config: MultiSelectConfig): MultiSelectState {
48
- return { cursor: 0, selected: config.options.map(() => false) };
49
- }
50
-
51
- /** Total rows including the optional Other row; the cursor wraps inside this. */
52
- export function rowCount(config: MultiSelectConfig): number {
53
- return config.options.length + (config.allowOther ? 1 : 0);
54
- }
55
-
56
- export function isOtherRow(state: MultiSelectState, config: MultiSelectConfig): boolean {
57
- return config.allowOther && state.cursor === config.options.length;
58
- }
59
-
60
- export function selectedLabels(state: MultiSelectState, config: MultiSelectConfig): string[] {
61
- const labels: string[] = [];
62
- config.options.forEach((option, i) => {
63
- if (state.selected[i]) labels.push(option.label);
64
- });
65
- return labels;
66
- }
67
-
68
- /** Apply one key. A returned outcome ends the interaction. */
69
- export function stepMultiSelect(state: MultiSelectState, key: MultiSelectKey, config: MultiSelectConfig): MultiSelectStep {
70
- const rows = rowCount(config);
71
- if (rows === 0) return key === 'cancel' ? { state, outcome: { kind: 'cancel' } } : { state };
72
- switch (key) {
73
- case 'cancel':
74
- return { state, outcome: { kind: 'cancel' } };
75
- case 'up':
76
- case 'down': {
77
- const delta = key === 'up' ? -1 : 1;
78
- const cursor = (state.cursor + delta + rows) % rows;
79
- return { state: { ...state, cursor } };
80
- }
81
- case 'all': {
82
- const anyUnselected = state.selected.some((v) => !v);
83
- return { state: { ...state, selected: state.selected.map(() => anyUnselected) } };
84
- }
85
- case 'toggle': {
86
- if (isOtherRow(state, config)) return { state };
87
- const selected = state.selected.map((v, i) => (i === state.cursor ? !v : v));
88
- return { state: { ...state, selected } };
89
- }
90
- case 'confirm': {
91
- if (isOtherRow(state, config)) return { state, outcome: { kind: 'other' } };
92
- // Enter on a choice row confirms the current toggles; a single-choice
93
- // question (multi: false) needs no separate space press.
94
- if (config.options.length === 1) {
95
- return { state, outcome: { kind: 'selected', labels: [config.options[0]!.label] } };
96
- }
97
- return { state, outcome: { kind: 'selected', labels: selectedLabels(state, config) } };
98
- }
99
- default:
100
- return { state };
101
- }
102
- }
103
-
104
- /* ── Key matching ──────────────────────────────────────────────────── */
105
-
106
- export type KeyMatcher = (data: string, key: Exclude<MultiSelectKey, 'ignore'>) => boolean;
107
-
108
- /**
109
- * Fallback matcher for raw key sequences, used when pi-tui cannot be imported.
110
- * Ordering inside `handleInput` matters for escape: arrow sequences are matched
111
- * before the bare ESC key because both start with 0x1b.
112
- */
113
- export const FALLBACK_KEY_SEQUENCES: Record<Exclude<MultiSelectKey, 'ignore'>, readonly string[]> = {
114
- up: ['\x1b[A', '\x1bOA', 'k'],
115
- down: ['\x1b[B', '\x1bOB', 'j'],
116
- toggle: [' '],
117
- confirm: ['\r', '\n'],
118
- cancel: ['\x1b', '\x03'],
119
- all: ['a', 'A'],
120
- };
121
-
122
- export const fallbackKeyMatcher: KeyMatcher = (data, key) => FALLBACK_KEY_SEQUENCES[key].includes(data);
123
-
124
- /**
125
- * Prefer pi-tui's `matchesKey` (it understands terminal variations and keybindings
126
- * remaps); fall back to the built-in sequences when the package is unavailable.
127
- */
128
- export async function loadKeyMatcher(): Promise<KeyMatcher> {
129
- try {
130
- const tui = await import('@earendil-works/pi-tui') as {
131
- matchesKey?: (data: string, key: unknown) => boolean;
132
- Key?: Record<string, unknown>;
133
- };
134
- const Key = tui.Key;
135
- if (typeof tui.matchesKey === 'function' && Key && typeof Key.up === 'string') {
136
- const map: Record<string, unknown> = {
137
- up: Key.up,
138
- down: Key.down,
139
- toggle: Key.space,
140
- confirm: Key.enter,
141
- cancel: Key.escape,
142
- all: 'a',
143
- };
144
- return (data, key) => tui.matchesKey!(data, map[key]);
145
- }
146
- } catch {
147
- /* pi-tui absent: use the built-in sequences. */
148
- }
149
- return fallbackKeyMatcher;
150
- }
151
-
152
- /* ── TUI component ─────────────────────────────────────────────────── */
153
-
154
- export interface MinimalTheme {
155
- fg(color: string, text: string): string;
156
- bold(text: string): string;
157
- }
158
-
159
- export interface MultiSelectComponent {
160
- render(width: number): string[];
161
- handleInput(data: string): void;
162
- invalidate(): void;
163
- }
164
-
165
- export interface MultiSelectUiOptions {
166
- title: string;
167
- config: MultiSelectConfig;
168
- theme: MinimalTheme;
169
- matcher: KeyMatcher;
170
- /** Called with the outcome; the host resolves ctx.ui.custom. */
171
- done(outcome: MultiSelectOutcome): void;
172
- /** Ask for the gate label used by the renderer (tests only). */
173
- requestRender?: () => void;
174
- }
175
-
176
- const RECOMMENDED = ' (Recommended)';
177
-
178
- export function multiSelectLines(state: MultiSelectState, config: MultiSelectConfig, theme: MinimalTheme): string[] {
179
- const lines: string[] = [];
180
- config.options.forEach((option, i) => {
181
- const cursor = i === state.cursor ? theme.fg('accent', '> ') : ' ';
182
- const box = state.selected[i] ? theme.fg('success', '[x]') : '[ ]';
183
- const label = `${option.label}${config.recommended === i ? RECOMMENDED : ''}`;
184
- const styled = state.selected[i] ? theme.bold(label) : label;
185
- const desc = option.description ? theme.fg('dim', ` — ${option.description}`) : '';
186
- lines.push(`${cursor}${box} ${styled}${desc}`);
187
- });
188
- if (config.allowOther) {
189
- const cursor = isOtherRow(state, config) ? theme.fg('accent', '> ') : ' ';
190
- lines.push(`${cursor}${theme.fg('dim', '[ ] Other (type your own)')}`);
191
- }
192
- return lines;
193
- }
194
-
195
- export function multiSelectFooter(state: MultiSelectState, config: MultiSelectConfig, theme: MinimalTheme): string {
196
- const picked = selectedLabels(state, config).length;
197
- const count = picked > 0 ? theme.fg('success', ` · ${picked} selected`) : '';
198
- return theme.fg('dim', '↑/↓ move · space toggle · a all · enter confirm · esc cancel') + count;
199
- }
200
-
201
- export function createMultiSelectComponent(opts: MultiSelectUiOptions): MultiSelectComponent {
202
- let state = createMultiSelectState(opts.config);
203
- let finished = false;
204
- const finish = (outcome: MultiSelectOutcome): void => {
205
- if (finished) return;
206
- finished = true;
207
- opts.done(outcome);
208
- };
209
- return {
210
- render(width: number): string[] {
211
- const head = opts.theme.bold(opts.title);
212
- const body = multiSelectLines(state, opts.config, opts.theme);
213
- const footer = multiSelectFooter(state, opts.config, opts.theme);
214
- return [head, ...body, '', footer].map((line) => truncateStyled(line, width));
215
- },
216
- handleInput(data: string): void {
217
- if (finished) return;
218
- // Arrow sequences are checked before the bare ESC key (shared 0x1b prefix).
219
- const key: MultiSelectKey = opts.matcher(data, 'up') ? 'up'
220
- : opts.matcher(data, 'down') ? 'down'
221
- : opts.matcher(data, 'toggle') ? 'toggle'
222
- : opts.matcher(data, 'confirm') ? 'confirm'
223
- : opts.matcher(data, 'cancel') ? 'cancel'
224
- : opts.matcher(data, 'all') ? 'all'
225
- : 'ignore';
226
- if (key === 'ignore') return;
227
- const next = stepMultiSelect(state, key, opts.config);
228
- state = next.state;
229
- opts.requestRender?.();
230
- if (next.outcome) finish(next.outcome);
231
- },
232
- invalidate(): void { /* No cached lines. */ },
233
- };
234
- }
235
-
236
- /**
237
- * Run the toggle list through `ctx.ui.custom`.
238
- * Returns null when the host cannot render custom components (RPC mode / older pi),
239
- * so the caller can fall back to the typed-index prompt.
240
- */
241
- export async function runMultiSelect(
242
- custom: (factory: unknown) => Promise<unknown>,
243
- title: string,
244
- config: MultiSelectConfig,
245
- ): Promise<MultiSelectOutcome | null> {
246
- const matcher = await loadKeyMatcher();
247
- const result = await custom((tui: unknown, theme: unknown, _keybindings: unknown, done: (value: unknown) => void) =>
248
- createMultiSelectComponent({
249
- title,
250
- config,
251
- theme: theme as MinimalTheme,
252
- matcher,
253
- done: (outcome) => done(outcome),
254
- requestRender: () => (tui as { requestRender?: () => void } | undefined)?.requestRender?.(),
255
- }));
256
- if (result === undefined || result === null) return null;
257
- const outcome = result as MultiSelectOutcome;
258
- if (outcome.kind === 'cancel' || outcome.kind === 'other' || outcome.kind === 'selected') return outcome;
259
- return null;
260
- }
@@ -1,361 +0,0 @@
1
- /**
2
- * D104 Config Guide adapter.
3
- *
4
- * Thin I/O layer over config-catalog-core: reads the real files/env for the five
5
- * configuration planes, aggregates validation issues, and renders the strings used by
6
- * `/pier-config show|check|doc`. All paths are injectable so tests never touch the
7
- * developer's real home directory.
8
- *
9
- * Fail-open: every read is wrapped; a broken plane degrades to a reported issue rather
10
- * than throwing into pi's command pipeline.
11
- */
12
-
13
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
- import { homedir } from 'node:os';
15
- import { dirname, join, resolve } from 'node:path';
16
- import { fileURLToPath } from 'node:url';
17
- import {
18
- CONFIG_PLANES,
19
- checkEnvKnobs,
20
- formatValue,
21
- readDotted,
22
- renderCheck,
23
- renderIndex,
24
- renderPlane,
25
- renderReport,
26
- resolveConfigKnobs,
27
- resolveEnvKnobs,
28
- type CheckReport,
29
- type ConfigPlaneId,
30
- type ResolvedKnob,
31
- } from './config-catalog-core.ts';
32
- import {
33
- defaultUserEfficiencyConfigFile,
34
- defaultWorkspaceEfficiencyConfigFile,
35
- validateEfficiencyConfig,
36
- } from './efficiency-config-core.ts';
37
- import { loadPiNativeCompactionSettings } from './efficiency-config-io.ts';
38
- import { RESERVED_ROLE_NAMES, loadRoleConfig, roleLayers } from './role-loader.ts';
39
-
40
- export const CONFIG_GUIDANCE_PROMPT = [
41
- '[PIER-CONFIG] The user wants to inspect or change pier configuration. Follow this workflow strictly:',
42
- '',
43
- '1. Ground yourself first: run `/pier-config show all` (or read the files) and use the reported EFFECTIVE VALUE and SOURCE',
44
- ' (env > workspace > user > default). Only entries whose source is `default` are worth changing; an ignored workspace file',
45
- ' (untrusted project) must be reported as such instead of edited blindly.',
46
- '2. Route by plane:',
47
- ' - efficiency (D100-D103): read `docs/efficiency-trial.md` before proposing values.',
48
- ' - roles: read `docs/sidebar-role-config.md` and `schemas/role-manifest.schema.json`; builtin role names cannot be overridden.',
49
- " - pi (settings.json): recommend pi's own `/settings`; the only OCC-relevant keys here are `compaction.enabled` and",
50
- ' `compaction.keepRecentTokens` (read-only from our side).',
51
- ' - boot (boot-config.json): manual edits are risky; prefer `npx pier-setup@latest update --force`, and only patch paths when asked.',
52
- ' - env (PIER_* / PI_HERDR_*): affects new processes only; state that explicitly.',
53
- '3. Explain before changing: 2-3 sentences on what the knob controls, its cost/benefit (tokens, latency, safety, blast radius),',
54
- ' and 2-3 recommended values for common scenarios. Then ask what the user actually wants to achieve.',
55
- '4. Before writing: show a precise diff (file path, current -> proposed value) and the activation path',
56
- ' (hot / needs `/reload` / needs a new session or process restart). Wait for explicit confirmation. Change ONE plane at a time.',
57
- '5. Apply with the normal `edit`/`write` tools (write locks apply), then run `/pier-config check` and report the result back.',
58
- '6. If a change cannot take effect in the current process, say so and tell the user exactly what to restart.',
59
- '',
60
- 'Forbidden: dumping `process.env`; writing secrets or tokens into any config file; editing multiple planes before confirmation;',
61
- 'touching `.pi-herdr/` of an untrusted project.',
62
- ].join('\n');
63
-
64
- export interface ConfigPlaneFile {
65
- readonly path: string;
66
- readonly label: string;
67
- readonly exists: boolean;
68
- readonly ignored?: boolean;
69
- }
70
-
71
- export interface ConfigGuideDeps {
72
- cwd?: string;
73
- env?: Record<string, string | undefined>;
74
- isProjectTrusted?: boolean;
75
- /** Pi agent dir holding settings.json (defaults to ~/.pi/agent; mirrors PI_CODING_AGENT_DIR). */
76
- agentDir?: string;
77
- /** User-level efficiency config path (defaults to ~/.pi/agent/herdr-pi/config.json). */
78
- userConfigPath?: string;
79
- /** herdr plugin config dir holding the user-mode boot-config.json. */
80
- herdrPluginConfigDir?: string;
81
- /** Repository root used to probe the dev-mode boot-config.json. */
82
- repoRoot?: string;
83
- }
84
-
85
- export interface ConfigGuideSnapshot {
86
- readonly entries: readonly ResolvedKnob[];
87
- readonly reports: readonly CheckReport[];
88
- readonly files: Readonly<Record<ConfigPlaneId, readonly ConfigPlaneFile[]>>;
89
- readonly workspaceTrusted: boolean;
90
- readonly roleSummary: string;
91
- readonly bootSummary: string;
92
- }
93
-
94
- function defaultAgentDir(env: Record<string, string | undefined>): string {
95
- return env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
96
- }
97
-
98
- /** Repo root of this checkout (works in-repo; in node_modules the probe simply finds nothing). */
99
- function defaultRepoRoot(): string {
100
- try {
101
- const here = dirname(fileURLToPath(import.meta.url));
102
- return resolve(here, '..', '..', '..');
103
- } catch {
104
- return process.cwd();
105
- }
106
- }
107
-
108
- /** Standard herdr plugin config dirs: XDG config on macOS/Linux, LOCALAPPDATA on Windows. */
109
- export function defaultHerdrPluginConfigDirs(env: Record<string, string | undefined> = process.env): string[] {
110
- const out: string[] = [];
111
- const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim() !== '' ? env.XDG_CONFIG_HOME : join(homedir(), '.config');
112
- out.push(join(xdg, 'herdr', 'plugins', 'config', 'pier.workbench'));
113
- if (env.LOCALAPPDATA && env.LOCALAPPDATA.trim() !== '') {
114
- out.push(join(env.LOCALAPPDATA, 'herdr', 'plugins', 'config', 'pier.workbench'));
115
- }
116
- return out;
117
- }
118
-
119
- function readJsonLayer(path: string): { value: unknown; issue?: string } {
120
- if (!existsSync(path)) return { value: undefined };
121
- try {
122
- return { value: JSON.parse(readFileSync(path, 'utf8')) };
123
- } catch (err) {
124
- return { value: undefined, issue: `${path}: invalid JSON (${err instanceof Error ? err.message : String(err)})` };
125
- }
126
- }
127
-
128
- function listJsonFiles(dir: string): string[] {
129
- try {
130
- return readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
131
- } catch {
132
- return [];
133
- }
134
- }
135
-
136
- /** Collects the whole snapshot for the guide/command. */
137
- export function collectConfigSnapshot(deps: ConfigGuideDeps = {}): ConfigGuideSnapshot {
138
- const env = deps.env ?? process.env;
139
- const cwd = deps.cwd ?? process.cwd();
140
- const workspaceTrusted = deps.isProjectTrusted ?? false;
141
- const agentDir = deps.agentDir ?? defaultAgentDir(env);
142
- const userConfigPath = deps.userConfigPath ?? defaultUserEfficiencyConfigFile();
143
- const workspaceConfigPath = defaultWorkspaceEfficiencyConfigFile(cwd);
144
- const repoRoot = deps.repoRoot ?? defaultRepoRoot();
145
- const herdrPluginConfigDir = deps.herdrPluginConfigDir ?? env.HERDR_PLUGIN_CONFIG_DIR;
146
-
147
- const reports: CheckReport[] = [];
148
- const files: Record<ConfigPlaneId, ConfigPlaneFile[]> = {
149
- efficiency: [],
150
- roles: [],
151
- pi: [],
152
- boot: [],
153
- env: [],
154
- };
155
-
156
- /* ── efficiency ───────────────────────────────────────────────────────── */
157
- const workspaceLayer = readJsonLayer(workspaceConfigPath);
158
- const userLayer = readJsonLayer(userConfigPath);
159
- const efficiencyIssues: string[] = [];
160
- if (workspaceLayer.issue) efficiencyIssues.push(workspaceLayer.issue);
161
- if (userLayer.issue) efficiencyIssues.push(userLayer.issue);
162
-
163
- if (workspaceLayer.value !== undefined) {
164
- if (!workspaceTrusted) {
165
- efficiencyIssues.push(`${workspaceConfigPath}: ignored — the project is not trusted (values below come from user/default)`);
166
- } else {
167
- const validated = validateEfficiencyConfig(workspaceLayer.value);
168
- for (const issue of validated.issues) efficiencyIssues.push(`${workspaceConfigPath}: ${issue}`);
169
- }
170
- }
171
- if (userLayer.value !== undefined) {
172
- const validated = validateEfficiencyConfig(userLayer.value);
173
- for (const issue of validated.issues) efficiencyIssues.push(`${userConfigPath}: ${issue}`);
174
- }
175
-
176
- files.efficiency.push(
177
- { path: workspaceConfigPath, label: 'workspace', exists: workspaceLayer.value !== undefined, ignored: !workspaceTrusted },
178
- { path: userConfigPath, label: 'user', exists: userLayer.value !== undefined },
179
- );
180
-
181
- /* ── pi settings ──────────────────────────────────────────────────────── */
182
- let piSettings: { enabled?: boolean; keepRecentTokens?: number } = {};
183
- const piSettingsPath = join(agentDir, 'settings.json');
184
- try {
185
- piSettings = loadPiNativeCompactionSettings({ cwd, isProjectTrusted: workspaceTrusted, agentDir });
186
- } catch {
187
- piSettings = {};
188
- }
189
- const piIssues: string[] = [];
190
- if (!existsSync(piSettingsPath)) {
191
- piIssues.push(`${piSettingsPath}: not found (pi defaults apply; use pi's /settings to create it)`);
192
- }
193
- files.pi.push({ path: piSettingsPath, label: 'agent', exists: existsSync(piSettingsPath) });
194
-
195
- /* ── env ──────────────────────────────────────────────────────────────── */
196
- const envIssues = checkEnvKnobs(env);
197
- files.env.push({ path: '(process environment)', label: 'env', exists: true });
198
-
199
- /* ── roles ────────────────────────────────────────────────────────────── */
200
- const layerDirs = roleLayers({ baseDir: cwd });
201
- const roleIssues: string[] = [];
202
- const roleNames = new Set<string>();
203
- const customLayerNames = new Set<string>();
204
- const layerCounts: string[] = [];
205
- for (const layer of layerDirs) {
206
- const names = listJsonFiles(layer.dir);
207
- const kind: ConfigPlaneFile['label'] = layer.label.startsWith('workspace')
208
- ? 'workspace'
209
- : layer.label.startsWith('user') ? 'user' : 'builtin';
210
- files.roles.push({ path: layer.dir, label: kind, exists: names.length > 0 });
211
- layerCounts.push(`${kind} ${names.length}`);
212
- for (const file of names) {
213
- const name = file.replace(/\.json$/, '');
214
- roleNames.add(name);
215
- if (kind !== 'builtin') customLayerNames.add(name);
216
- }
217
- }
218
- for (const name of [...roleNames].sort()) {
219
- try {
220
- loadRoleConfig(name, { baseDir: cwd });
221
- } catch (err) {
222
- const issues = (err as { issues?: readonly string[] }).issues ?? [];
223
- roleIssues.push(`role "${name}": ${err instanceof Error ? err.message : String(err)}${issues.length ? ` — ${issues.join('; ')}` : ''}`);
224
- }
225
- }
226
- // Built-in names come from the bundled layer by design; only custom layers are suspicious.
227
- const reservedPresent = [...customLayerNames].filter((n) => RESERVED_ROLE_NAMES.includes(n));
228
- if (reservedPresent.length > 0) {
229
- roleIssues.push(`reserved role name(s) shadowed in a custom layer (ignored for built-ins): ${reservedPresent.join(', ')}`);
230
- }
231
- const roleSummary = `${roleNames.size} role(s): ${layerCounts.join(' / ')}`;
232
-
233
- /* ── boot-config ──────────────────────────────────────────────────────── */
234
- const bootIssues: string[] = [];
235
- const bootCandidates: Array<{ path: string; label: string }> = [];
236
- if (herdrPluginConfigDir) bootCandidates.push({ path: join(herdrPluginConfigDir, 'boot-config.json'), label: 'herdr plugin config-dir' });
237
- // herdr does not always export HERDR_PLUGIN_CONFIG_DIR into the pi process, so production also
238
- // probes the standard plugin config locations (the same file `install.mjs` writes in user mode).
239
- // Tests inject env/herdrPluginConfigDir and therefore stay hermetic.
240
- const probeDefaultDirs = deps.herdrPluginConfigDir === undefined && deps.env === undefined;
241
- if (probeDefaultDirs) {
242
- for (const dir of defaultHerdrPluginConfigDirs(env)) {
243
- bootCandidates.push({ path: join(dir, 'boot-config.json'), label: 'herdr plugin config (default)' });
244
- }
245
- }
246
- bootCandidates.push({ path: join(repoRoot, 'packages', 'pier-workbench', 'scripts', 'boot-config.json'), label: 'dev (repo)' });
247
- let bootFound: { path: string; label: string } | null = null;
248
- for (const candidate of bootCandidates) {
249
- const exists = existsSync(candidate.path);
250
- files.boot.push({ path: candidate.path, label: candidate.label, exists });
251
- if (exists && !bootFound) bootFound = candidate;
252
- }
253
- if (!bootFound) {
254
- bootIssues.push(`boot-config.json not found in: ${bootCandidates.map((c) => c.path).join(' , ')} (run \`npx pier-setup@latest install\`)`);
255
- } else {
256
- const parsed = readJsonLayer(bootFound.path);
257
- if (parsed.issue) {
258
- bootIssues.push(parsed.issue);
259
- } else {
260
- for (const key of ['piNode', 'piCli', 'extPath'] as const) {
261
- const value = readDotted(parsed.value, key);
262
- if (typeof value !== 'string' || value === '') {
263
- bootIssues.push(`${bootFound.path}: missing required key "${key}"`);
264
- continue;
265
- }
266
- // A stale absolute path is the most common post-reinstall breakage (e.g. an npm
267
- // install path that no longer exists while pi still loads the extension elsewhere).
268
- if (!existsSync(value)) {
269
- bootIssues.push(
270
- `${bootFound.path}: "${key}" points to a path that does not exist: ${value} (stale after a reinstall? re-run \`npx pier-setup@latest update --force\`)`,
271
- );
272
- }
273
- }
274
- }
275
- }
276
- const bootSummary = bootFound ? `present (${bootFound.label})` : 'missing (run pier-setup)';
277
-
278
- /* ── resolution + reports ─────────────────────────────────────────────── */
279
- const entries = [
280
- ...resolveConfigKnobs({ env, workspace: workspaceLayer.value, user: userLayer.value, workspaceTrusted, piSettings }),
281
- ...resolveEnvKnobs(env),
282
- ];
283
-
284
- reports.push({ plane: 'efficiency', ok: efficiencyIssues.length === 0, issues: efficiencyIssues });
285
- reports.push({ plane: 'roles', ok: roleIssues.length === 0, issues: roleIssues });
286
- reports.push({ plane: 'pi', ok: true, issues: piIssues });
287
- reports.push({ plane: 'boot', ok: bootIssues.length === 0, issues: bootIssues });
288
- reports.push({ plane: 'env', ok: envIssues.length === 0, issues: envIssues });
289
-
290
- return { entries, reports, files, workspaceTrusted, roleSummary, bootSummary };
291
- }
292
-
293
- /* ── rendering used by the command ───────────────────────────────────────── */
294
-
295
- export function guideIndexLines(snapshot: ConfigGuideSnapshot): string[] {
296
- const pick = (plane: ConfigPlaneId, prefix: string): readonly ResolvedKnob[] =>
297
- snapshot.entries.filter((e) => e.knob.plane === plane && e.knob.key.startsWith(prefix));
298
- return renderIndex({
299
- efficiency: pick('efficiency', ''),
300
- pi: pick('pi', ''),
301
- env: pick('env', ''),
302
- roleSummary: snapshot.roleSummary,
303
- bootSummary: snapshot.bootSummary,
304
- });
305
- }
306
-
307
- export function guidePlaneLines(snapshot: ConfigGuideSnapshot, plane: ConfigPlaneId | 'all'): string[] {
308
- const planes = plane === 'all' ? CONFIG_PLANES.map((p) => p.id) : [plane];
309
- const out: string[] = [];
310
- for (const id of planes) {
311
- const meta = CONFIG_PLANES.find((p) => p.id === id);
312
- if (!meta) continue;
313
- out.push(`${id} — ${meta.title}`);
314
- for (const file of snapshot.files[id]) {
315
- const status = file.exists ? (file.ignored ? 'present (IGNORED: untrusted project)' : 'present') : 'absent';
316
- out.push(` file [${file.label}] ${file.path} — ${status}`);
317
- }
318
- out.push(` hint: ${meta.editHint}`);
319
- const entries = snapshot.entries.filter((e) => e.knob.plane === id);
320
- if (entries.length > 0) out.push(...renderPlane(id, entries));
321
- else out.push(` (per-file plane: ${entrySummaryForPlane(snapshot, id)})`);
322
- out.push('');
323
- }
324
- return out;
325
- }
326
-
327
- function entrySummaryForPlane(snapshot: ConfigGuideSnapshot, plane: ConfigPlaneId): string {
328
- const report = snapshot.reports.find((r) => r.plane === plane);
329
- if (!report) return 'no data';
330
- return report.ok ? 'no issues' : `${report.issues.length} issue(s) — see /pier-config check`;
331
- }
332
-
333
- export function guideCheckLines(snapshot: ConfigGuideSnapshot): string[] {
334
- const lines = renderCheck(snapshot.reports);
335
- const anyFail = snapshot.reports.some((r) => !r.ok);
336
- lines.push(anyFail ? ' → fix the FAIL planes above (values are still shown by `show`)' : ' → all planes look consistent');
337
- return lines;
338
- }
339
-
340
- export function guideReportMarkdown(snapshot: ConfigGuideSnapshot, meta: { generatedAt: string; cwd: string; piVersion?: string }): string {
341
- const body = renderReport(snapshot.entries, {
342
- generatedAt: meta.generatedAt,
343
- cwd: meta.cwd,
344
- workspaceTrusted: snapshot.workspaceTrusted,
345
- piVersion: meta.piVersion,
346
- });
347
- const filesSection = ['', '## Files observed', ''];
348
- for (const plane of CONFIG_PLANES) {
349
- for (const file of snapshot.files[plane.id]) {
350
- filesSection.push(`- [${plane.id}] ${file.label}: \`${file.path}\` — ${file.exists ? (file.ignored ? 'present, ignored (untrusted project)' : 'present') : 'absent'}`);
351
- }
352
- }
353
- const checkSection = ['', '## Checks', '', ...guideCheckLines(snapshot).map((l) => `- ${l.trim()}`)];
354
- return `${body}\n${filesSection.join('\n')}\n${checkSection.join('\n')}\n`;
355
- }
356
-
357
- /** Small helper used by tests and `show` for reading a single knob. */
358
- export function knobValue(snapshot: ConfigGuideSnapshot, plane: ConfigPlaneId, key: string): string {
359
- const entry = snapshot.entries.find((e) => e.knob.plane === plane && e.knob.key === key);
360
- return entry ? `${entry.value} [${entry.source}]` : formatValue(undefined);
361
- }