pi-pier 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +12 -0
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +60 -10
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +121 -47
  19. package/src/core/todo.ts +25 -11
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -1
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +3 -1
  33. package/src/index.ts +330 -34
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +4 -2
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +82 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -0,0 +1,361 @@
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
+ }
@@ -77,9 +77,11 @@ export function paneCells(root: ShapeNode, width = 200, height = 50): PaneCell[]
77
77
  */
78
78
  export function pickGridSplit(
79
79
  root: ShapeNode,
80
- opts: { exclude?: ReadonlySet<string>; width?: number; height?: number } = {},
80
+ opts: { exclude?: ReadonlySet<string>; width?: number; height?: number; cells?: PaneCell[] } = {},
81
81
  ): { targetPaneId: string; direction: 'right' | 'down' } | null {
82
- const cells = paneCells(root, opts.width ?? 200, opts.height ?? 50)
82
+ const cells = (opts.cells && opts.cells.length > 0
83
+ ? opts.cells
84
+ : paneCells(root, opts.width ?? 200, opts.height ?? 50))
83
85
  .filter((c) => !opts.exclude?.has(c.id));
84
86
  if (cells.length === 0) return null;
85
87
  let best = cells[0];
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Controlled mirror of workbench heat-layout for spawn-time pane.split ratio.
3
+ *
4
+ * Cannot import pier-workbench (npm pi-pier has no workbench; workbench user-mode
5
+ * checkout has no pi-pier). Canonical tree shape matches workbench LayoutNode.
6
+ * Lockstep: packages/pier-workbench/test/heat-grid.test.ts.
7
+ *
8
+ * herdr v0.9.1 src/layout.rs split_at: first = original, second = new, ratio = first share.
9
+ */
10
+ import { type ShapeNode } from './grid-shape.ts';
11
+
12
+ export const SPAWN_PLACEHOLDER_ID = '__pier_new__';
13
+ export const RATIO_FLOOR = 0.10;
14
+ export const FOCUS_SHARE = 0.72;
15
+ export const FOCUS_SHARE_BLOCKED = 0.60;
16
+ export const MAX_AUTO_LAYOUT_PANES = 10;
17
+ export const BLOCKED_WEIGHT = 3;
18
+ export const ASK_WEIGHT = 2.5;
19
+ export const WORKING_WEIGHT = 1.4;
20
+ export const IDLE_WEIGHT = 1;
21
+ export const SLIM_THRESHOLD = 4;
22
+ export const SLIM_FACTOR = 0.6;
23
+
24
+ /** Same shape as pier-workbench heat-layout LayoutNode. */
25
+ export type LayoutNode =
26
+ | { type: 'pane'; pane_id: string }
27
+ | { type: 'split'; direction: 'right' | 'down'; ratio: number; first: LayoutNode; second: LayoutNode };
28
+
29
+ export type AgentStatusMap = Record<string, string>;
30
+ export type AskFlagMap = Record<string, boolean>;
31
+
32
+ /** parseShapeTree (paneId) → LayoutNode (pane_id). */
33
+ export function fromShapeTree(node: ShapeNode): LayoutNode {
34
+ if (node.type === 'pane') return { type: 'pane', pane_id: node.paneId };
35
+ const direction = node.direction === 'down' || node.direction === 'vertical' ? 'down' : 'right';
36
+ return {
37
+ type: 'split',
38
+ direction,
39
+ ratio: node.ratio,
40
+ first: fromShapeTree(node.first),
41
+ second: fromShapeTree(node.second),
42
+ };
43
+ }
44
+
45
+ export function countPanes(node: LayoutNode): number {
46
+ return node.type === 'pane' ? 1 : countPanes(node.first) + countPanes(node.second);
47
+ }
48
+
49
+ export function flattenPanes(node: LayoutNode): string[] {
50
+ return node.type === 'pane' ? [node.pane_id] : [...flattenPanes(node.first), ...flattenPanes(node.second)];
51
+ }
52
+
53
+ export function containsPane(node: LayoutNode, paneId: string): boolean {
54
+ if (node.type === 'pane') return node.pane_id === paneId;
55
+ return containsPane(node.first, paneId) || containsPane(node.second, paneId);
56
+ }
57
+
58
+ function clampRatio(r: number): number {
59
+ return Math.min(1 - RATIO_FLOOR, Math.max(RATIO_FLOOR, r));
60
+ }
61
+
62
+ function tierWeight(status: string | undefined, isAsk = false, slim = false): number {
63
+ if (status === 'blocked') return isAsk ? ASK_WEIGHT : BLOCKED_WEIGHT;
64
+ if (status === 'working') return slim ? WORKING_WEIGHT * SLIM_FACTOR : WORKING_WEIGHT;
65
+ return slim ? IDLE_WEIGHT * SLIM_FACTOR : IDLE_WEIGHT;
66
+ }
67
+
68
+ type SplitNode = Extract<LayoutNode, { type: 'split' }>;
69
+ interface PathStep { node: SplitNode; side: 'first' | 'second' }
70
+
71
+ function findPath(node: LayoutNode, targetId: string): PathStep[] | null {
72
+ if (node.type === 'pane') return node.pane_id === targetId ? [] : null;
73
+ if (containsPane(node.first, targetId)) {
74
+ return [{ node, side: 'first' }, ...(findPath(node.first, targetId) ?? [])];
75
+ }
76
+ if (containsPane(node.second, targetId)) {
77
+ return [{ node, side: 'second' }, ...(findPath(node.second, targetId) ?? [])];
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function weightOf(node: LayoutNode, statuses: AgentStatusMap, askFlags: AskFlagMap, slim: boolean): number {
83
+ return flattenPanes(node).reduce((sum, id) => sum + tierWeight(statuses[id], askFlags[id] === true, slim), 0);
84
+ }
85
+
86
+ /** Replace target leaf with split(first=old, second=new). Matches herdr split_at. */
87
+ export function simulateSplit(
88
+ root: LayoutNode,
89
+ targetPaneId: string,
90
+ newPaneId: string,
91
+ direction: 'right' | 'down',
92
+ ): LayoutNode | null {
93
+ const rec = (n: LayoutNode): LayoutNode | null => {
94
+ if (n.type === 'pane') {
95
+ if (n.pane_id !== targetPaneId) return null;
96
+ return {
97
+ type: 'split',
98
+ direction,
99
+ ratio: 0.5,
100
+ first: n,
101
+ second: { type: 'pane', pane_id: newPaneId },
102
+ };
103
+ }
104
+ const first = rec(n.first);
105
+ if (first) return { ...n, first };
106
+ const second = rec(n.second);
107
+ if (second) return { ...n, second };
108
+ return null;
109
+ };
110
+ return rec(root);
111
+ }
112
+
113
+ /**
114
+ * First-child ratio for pane.split so the new layer matches post-spawn heat.
115
+ * On-path: T^(1/depth) if focus is first, else 1-T. Off-path: weighted first share.
116
+ * Null → omit ratio (herdr 0.5). Caller must skip when the tab is zoomed.
117
+ */
118
+ export function planSpawnSplitRatio(opts: {
119
+ root: LayoutNode;
120
+ targetPaneId: string;
121
+ focusPaneId: string;
122
+ direction: 'right' | 'down';
123
+ statuses?: AgentStatusMap;
124
+ askFlags?: AskFlagMap;
125
+ newPaneId?: string;
126
+ }): number | null {
127
+ const newId = opts.newPaneId ?? SPAWN_PLACEHOLDER_ID;
128
+ const simulated = simulateSplit(opts.root, opts.targetPaneId, newId, opts.direction);
129
+ if (!simulated) return null;
130
+ const paneCount = countPanes(simulated);
131
+ if (paneCount <= 1 || paneCount > MAX_AUTO_LAYOUT_PANES) return null;
132
+ if (!containsPane(simulated, opts.focusPaneId) || !containsPane(simulated, newId)) return null;
133
+
134
+ const statuses = opts.statuses ?? {};
135
+ const askFlags = opts.askFlags ?? {};
136
+ const slim = paneCount - 1 >= SLIM_THRESHOLD;
137
+ const focusSteps = findPath(simulated, opts.focusPaneId);
138
+ const newSteps = findPath(simulated, newId);
139
+ if (!focusSteps || !newSteps || newSteps.length === 0) return null;
140
+
141
+ const newSplit = newSteps[newSteps.length - 1]!.node;
142
+ const onFocusPath = focusSteps.some((s) => s.node === newSplit);
143
+
144
+ if (onFocusPath) {
145
+ const hasBlocked = focusSteps.some((step) => {
146
+ const sibling = step.side === 'first' ? step.node.second : step.node.first;
147
+ return flattenPanes(sibling).some((id) => statuses[id] === 'blocked');
148
+ });
149
+ const target = hasBlocked ? FOCUS_SHARE_BLOCKED : FOCUS_SHARE;
150
+ const r = clampRatio(target ** (1 / focusSteps.length));
151
+ const at = focusSteps.find((s) => s.node === newSplit);
152
+ if (!at) return null;
153
+ return at.side === 'first' ? r : 1 - r;
154
+ }
155
+
156
+ const wFirst = weightOf(newSplit.first, statuses, askFlags, slim);
157
+ const wSecond = weightOf(newSplit.second, statuses, askFlags, slim);
158
+ if (wFirst + wSecond <= 0) return null;
159
+ return clampRatio(wFirst / (wFirst + wSecond));
160
+ }