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
@@ -1,39 +1,256 @@
1
1
  /**
2
- * D104 `/pier-config` command registration.
2
+ * D104 `/pier-config`: probes the four configuration planes (real files + process env), renders
3
+ * `show|check|doc|doctor`, and registers the command.
3
4
  *
4
- * Read-only by design: the command reports effective values with their provenance and
5
- * hands the actual edit to the agent under the existing write-lock + diff-confirmation
6
- * flow. It never writes configuration itself (only the opt-in `doc` report).
7
- *
8
- * Subcommands: (none) | show [plane|all] | check | doc [path]
5
+ * Read-only by design: effective values are reported with their provenance and the actual edit goes
6
+ * to the agent under the write-lock + diff-confirmation flow; only the opt-in `doc` report writes a
7
+ * file. Fail-open: every read is wrapped, so a broken plane degrades to a reported issue instead of
8
+ * throwing into pi's command pipeline. All paths are injectable, so tests never touch a real home dir.
9
9
  */
10
10
 
11
- import { mkdirSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
12
13
  import { dirname, isAbsolute, join, resolve } from 'node:path';
13
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
14
- import { VERSION as PI_VERSION } from '@earendil-works/pi-coding-agent';
15
- import { CONFIG_PLANES, type ConfigPlaneId } from './config-catalog-core.ts';
14
+ import { VERSION as piVersion, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
15
+ import {
16
+ CONFIG_PLANES,
17
+ checkEnvKnobs,
18
+ renderCheck,
19
+ renderIndex,
20
+ renderPlane,
21
+ renderReport,
22
+ resolveConfigKnobs,
23
+ resolveEnvKnobs,
24
+ type CheckReport,
25
+ type ConfigPlaneId,
26
+ type ResolvedKnob,
27
+ } from './config-catalog-core.ts';
16
28
  import {
17
- CONFIG_GUIDANCE_PROMPT,
18
- collectConfigSnapshot,
19
- guideCheckLines,
20
- guideIndexLines,
21
- guidePlaneLines,
22
- guideReportMarkdown,
23
- type ConfigGuideDeps,
24
- type ConfigGuideSnapshot,
25
- } from './config-guide.ts';
29
+ defaultUserEfficiencyConfigFile,
30
+ defaultWorkspaceEfficiencyConfigFile,
31
+ loadPiNativeCompactionSettings,
32
+ validateEfficiencyConfig,
33
+ } from './efficiency-config-core.ts';
34
+ import { RESERVED_ROLE_NAMES, listRoleNames, loadRoleConfig, roleLayers } from './role-loader.ts';
26
35
  import { formatOptionRows } from './pier-options.ts';
27
36
  import { formatSwallowedErrors } from './swallow.ts';
28
37
  import { HERDR_PROTOCOL_EXPECTED } from './herdr-client.ts';
29
38
 
30
- export const CONFIG_COMMAND_NAME = 'pier-config';
31
- export const CONFIG_GUIDE_CUSTOM_TYPE = 'pi-herdr.config-guide';
32
- export const CONFIG_REPORT_FILENAME = 'config-report.md';
33
- export const CONFIG_REPORT_DIR = '.pi-herdr';
39
+ export const CONFIG_GUIDANCE_PROMPT = [
40
+ '[PIER-CONFIG] The user wants to inspect or change pier configuration. Follow this workflow strictly:',
41
+ '',
42
+ '1. Ground yourself first: run `/pier-config show all` (or read the files) and use the reported EFFECTIVE VALUE and SOURCE',
43
+ ' (env > workspace > user > default). Only entries whose source is `default` are worth changing; an ignored workspace file',
44
+ ' (untrusted project) must be reported as such instead of edited blindly.',
45
+ '2. Route by plane:',
46
+ ' - efficiency (D100-D103): read `docs/efficiency-trial.md` before proposing values.',
47
+ ' - roles: read `docs/sidebar-role-config.md` and `schemas/role-manifest.schema.json`; builtin role names cannot be overridden.',
48
+ " - pi (settings.json): recommend pi's own `/settings`; the only OCC-relevant keys here are `compaction.enabled` and",
49
+ ' `compaction.keepRecentTokens` (read-only from our side).',
50
+ ' - env (PIER_* / PI_HERDR_*): affects new processes only; state that explicitly.',
51
+ '3. Explain before changing: 2-3 sentences on what the knob controls, its cost/benefit (tokens, latency, safety, blast radius),',
52
+ ' and 2-3 recommended values for common scenarios. Then ask what the user actually wants to achieve.',
53
+ '4. Before writing: show a precise diff (file path, current -> proposed value) and the activation path',
54
+ ' (hot / needs `/reload` / needs a new session or process restart). Wait for explicit confirmation. Change ONE plane at a time.',
55
+ '5. Apply with the normal `edit`/`write` tools (write locks apply), then run `/pier-config check` and report the result back.',
56
+ '6. If a change cannot take effect in the current process, say so and tell the user exactly what to restart.',
57
+ '',
58
+ 'Forbidden: dumping `process.env`; writing secrets or tokens into any config file; editing multiple planes before confirmation;',
59
+ 'touching `.pi-herdr/` of an untrusted project.',
60
+ ].join('\n');
34
61
 
35
- const PLANE_IDS: readonly ConfigPlaneId[] = CONFIG_PLANES.map((p) => p.id);
36
- const SUBCOMMANDS = ['show', 'check', 'doc', 'doctor'] as const;
62
+ export interface ConfigPlaneFile {
63
+ readonly path: string;
64
+ readonly label: string;
65
+ readonly exists: boolean;
66
+ readonly ignored?: boolean;
67
+ }
68
+
69
+ export interface ConfigGuideDeps {
70
+ cwd?: string;
71
+ env?: Record<string, string | undefined>;
72
+ isProjectTrusted?: boolean;
73
+ /** Pi agent dir holding settings.json (defaults to ~/.pi/agent; mirrors PI_CODING_AGENT_DIR). */
74
+ agentDir?: string;
75
+ /** User-level efficiency config path (defaults to ~/.pi/agent/herdr-pi/config.json). */
76
+ userConfigPath?: string;
77
+ /** User-level roles dir override (defaults to ~/.pi/agent/herdr-pi/roles). */
78
+ rolesUserDir?: string;
79
+ }
80
+
81
+ export interface ConfigGuideSnapshot {
82
+ readonly entries: readonly ResolvedKnob[];
83
+ readonly reports: readonly CheckReport[];
84
+ readonly files: Readonly<Record<ConfigPlaneId, readonly ConfigPlaneFile[]>>;
85
+ readonly workspaceTrusted: boolean;
86
+ readonly roleSummary: string;
87
+ }
88
+
89
+
90
+ function readJsonLayer(path: string): { value: unknown; issue?: string } {
91
+ if (!existsSync(path)) return { value: undefined };
92
+ try {
93
+ return { value: JSON.parse(readFileSync(path, 'utf8')) };
94
+ } catch (err) {
95
+ return { value: undefined, issue: `${path}: invalid JSON (${err instanceof Error ? err.message : String(err)})` };
96
+ }
97
+ }
98
+
99
+ function listJsonFiles(dir: string): string[] {
100
+ try {
101
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ export function collectConfigSnapshot(deps: ConfigGuideDeps = {}): ConfigGuideSnapshot {
108
+ const env = deps.env ?? process.env;
109
+ const cwd = deps.cwd ?? process.cwd();
110
+ const workspaceTrusted = deps.isProjectTrusted ?? false;
111
+ const agentDir = deps.agentDir ?? env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
112
+ const userConfigPath = deps.userConfigPath ?? defaultUserEfficiencyConfigFile();
113
+ const workspaceConfigPath = defaultWorkspaceEfficiencyConfigFile(cwd);
114
+
115
+ const reports: CheckReport[] = [];
116
+ const files: Record<ConfigPlaneId, ConfigPlaneFile[]> = { efficiency: [], roles: [], pi: [], env: [] };
117
+
118
+ /* efficiency: workspace layer (trusted projects only) + user layer, JSON issues reported as-is */
119
+ const workspaceLayer = readJsonLayer(workspaceConfigPath);
120
+ const userLayer = readJsonLayer(userConfigPath);
121
+ const efficiencyIssues: string[] = [];
122
+ for (const layer of [workspaceLayer, userLayer]) if (layer.issue) efficiencyIssues.push(layer.issue);
123
+ if (workspaceLayer.value !== undefined) {
124
+ if (workspaceTrusted) {
125
+ for (const issue of validateEfficiencyConfig(workspaceLayer.value).issues) efficiencyIssues.push(`${workspaceConfigPath}: ${issue}`);
126
+ } else {
127
+ efficiencyIssues.push(`${workspaceConfigPath}: ignored — the project is not trusted (values below come from user/default)`);
128
+ }
129
+ }
130
+ if (userLayer.value !== undefined) {
131
+ for (const issue of validateEfficiencyConfig(userLayer.value).issues) efficiencyIssues.push(`${userConfigPath}: ${issue}`);
132
+ }
133
+ files.efficiency.push(
134
+ { path: workspaceConfigPath, label: 'workspace', exists: workspaceLayer.value !== undefined, ignored: !workspaceTrusted },
135
+ { path: userConfigPath, label: 'user', exists: userLayer.value !== undefined },
136
+ );
137
+
138
+ /* pi settings (read-only here; only compaction.* matters to pier) */
139
+ const piSettingsPath = join(agentDir, 'settings.json');
140
+ const piSettingsExists = existsSync(piSettingsPath);
141
+ const piSettings = loadPiNativeCompactionSettings({ cwd, isProjectTrusted: workspaceTrusted, agentDir });
142
+ const piIssues = piSettingsExists
143
+ ? []
144
+ : [`${piSettingsPath}: not found (pi defaults apply; use pi's /settings to create it)`];
145
+ files.pi.push({ path: piSettingsPath, label: 'agent', exists: piSettingsExists });
146
+
147
+ const envIssues = checkEnvKnobs(env);
148
+ files.env.push({ path: '(process environment)', label: 'env', exists: true });
149
+
150
+ /* roles: every layer's JSON must load; builtin names are intentionally shadow-proof */
151
+ const roleIssues: string[] = [];
152
+ const roleNames = new Set<string>();
153
+ const layerCounts: string[] = [];
154
+ for (const layer of roleLayers({ baseDir: cwd, userDir: deps.rolesUserDir })) {
155
+ const names = listJsonFiles(layer.dir);
156
+ const label: ConfigPlaneFile['label'] = layer.label.startsWith('workspace')
157
+ ? 'workspace'
158
+ : layer.label.startsWith('user') ? 'user' : 'builtin';
159
+ files.roles.push({ path: layer.dir, label, exists: names.length > 0 });
160
+ layerCounts.push(`${label} ${names.length}`);
161
+ for (const file of names) roleNames.add(file.replace(/\.json$/, ''));
162
+ }
163
+ for (const name of [...roleNames].sort()) {
164
+ try {
165
+ loadRoleConfig(name, { baseDir: cwd });
166
+ } catch (err) {
167
+ const issues = (err as { issues?: readonly string[] }).issues ?? [];
168
+ roleIssues.push(`role "${name}": ${err instanceof Error ? err.message : String(err)}${issues.length ? ` — ${issues.join('; ')}` : ''}`);
169
+ }
170
+ }
171
+ const shadowed = listRoleNames(cwd, deps.rolesUserDir).filter((name) => RESERVED_ROLE_NAMES.includes(name));
172
+ if (shadowed.length > 0) {
173
+ roleIssues.push(`reserved role name(s) shadowed in a custom layer (ignored for built-ins): ${shadowed.join(', ')}`);
174
+ }
175
+ const roleSummary = `${roleNames.size} role(s): ${layerCounts.join(' / ')}`;
176
+
177
+
178
+ reports.push(
179
+ { plane: 'efficiency', ok: efficiencyIssues.length === 0, issues: efficiencyIssues },
180
+ { plane: 'roles', ok: roleIssues.length === 0, issues: roleIssues },
181
+ { plane: 'pi', ok: true, issues: piIssues },
182
+ { plane: 'env', ok: envIssues.length === 0, issues: envIssues },
183
+ );
184
+ const entries = [
185
+ ...resolveConfigKnobs({ env, workspace: workspaceLayer.value, user: userLayer.value, workspaceTrusted, piSettings }),
186
+ ...resolveEnvKnobs(env),
187
+ ];
188
+ return { entries, reports, files, workspaceTrusted, roleSummary };
189
+ }
190
+
191
+
192
+ export type GuideView = 'index' | 'check' | ConfigPlaneId | 'all';
193
+
194
+ /** Renders one `/pier-config` view: the plane index, one/all plane listings, or the check report. */
195
+ export function renderGuide(snapshot: ConfigGuideSnapshot, view: GuideView): string[] {
196
+ if (view === 'index') {
197
+ const plane = (id: ConfigPlaneId): readonly ResolvedKnob[] => snapshot.entries.filter((e) => e.knob.plane === id);
198
+ return renderIndex({
199
+ efficiency: plane('efficiency'),
200
+ pi: plane('pi'),
201
+ env: plane('env'),
202
+ roleSummary: snapshot.roleSummary,
203
+ });
204
+ }
205
+ if (view === 'check') {
206
+ const anyFail = snapshot.reports.some((r) => !r.ok);
207
+ return [
208
+ ...renderCheck(snapshot.reports),
209
+ anyFail ? ' → fix the FAIL planes above (values are still shown by `show`)' : ' → all planes look consistent',
210
+ ];
211
+ }
212
+
213
+ const out: string[] = [];
214
+ for (const meta of CONFIG_PLANES) {
215
+ if (view !== 'all' && meta.id !== view) continue;
216
+ out.push(`${meta.id} — ${meta.title}`);
217
+ for (const file of snapshot.files[meta.id]) {
218
+ const status = file.exists ? (file.ignored ? 'present (IGNORED: untrusted project)' : 'present') : 'absent';
219
+ out.push(` file [${file.label}] ${file.path} — ${status}`);
220
+ }
221
+ out.push(` hint: ${meta.editHint}`);
222
+ const entries = snapshot.entries.filter((e) => e.knob.plane === meta.id);
223
+ if (entries.length > 0) {
224
+ out.push(...renderPlane(entries));
225
+ } else {
226
+ const report = snapshot.reports.find((r) => r.plane === meta.id);
227
+ out.push(` (per-file plane: ${!report ? 'no data' : report.ok ? 'no issues' : `${report.issues.length} issue(s) — see /pier-config check`})`);
228
+ }
229
+ out.push('');
230
+ }
231
+ return out;
232
+ }
233
+
234
+ export function guideReportMarkdown(
235
+ snapshot: ConfigGuideSnapshot,
236
+ meta: { generatedAt: string; cwd: string; piVersion?: string },
237
+ ): string {
238
+ const body = renderReport(snapshot.entries, {
239
+ generatedAt: meta.generatedAt,
240
+ cwd: meta.cwd,
241
+ workspaceTrusted: snapshot.workspaceTrusted,
242
+ piVersion: meta.piVersion,
243
+ });
244
+ const filesSection = ['', '## Files observed', ''];
245
+ for (const plane of CONFIG_PLANES) {
246
+ for (const file of snapshot.files[plane.id]) {
247
+ const status = file.exists ? (file.ignored ? 'present, ignored (untrusted project)' : 'present') : 'absent';
248
+ filesSection.push(`- [${plane.id}] ${file.label}: \`${file.path}\` — ${status}`);
249
+ }
250
+ }
251
+ const checkSection = ['', '## Checks', '', ...renderGuide(snapshot, 'check').map((l) => `- ${l.trim()}`)];
252
+ return `${body}\n${filesSection.join('\n')}\n${checkSection.join('\n')}\n`;
253
+ }
37
254
 
38
255
  export interface ConfigCommandDeps {
39
256
  pi: ExtensionAPI;
@@ -43,9 +260,8 @@ export interface ConfigCommandDeps {
43
260
  reportDir?: (cwd: string) => string;
44
261
  }
45
262
 
46
- function isPlaneId(value: string): value is ConfigPlaneId {
47
- return (PLANE_IDS as readonly string[]).includes(value);
48
- }
263
+ const PLANE_IDS: readonly ConfigPlaneId[] = CONFIG_PLANES.map((p) => p.id);
264
+ const SUBCOMMANDS = ['show', 'check', 'doc', 'doctor'] as const;
49
265
 
50
266
  function parseArgs(args: unknown): { sub: (typeof SUBCOMMANDS)[number] | 'index'; rest: string } {
51
267
  const raw = typeof args === 'string'
@@ -64,25 +280,22 @@ function parseArgs(args: unknown): { sub: (typeof SUBCOMMANDS)[number] | 'index'
64
280
  export function installConfigCommand(deps: ConfigCommandDeps): void {
65
281
  const { pi } = deps;
66
282
  const collect = deps.collect ?? collectConfigSnapshot;
67
- const reportDir = deps.reportDir ?? ((cwd: string) => join(cwd, CONFIG_REPORT_DIR));
283
+ const reportDir = deps.reportDir ?? ((cwd: string) => join(cwd, '.pi-herdr'));
68
284
 
69
- pi.registerCommand(CONFIG_COMMAND_NAME, {
285
+ pi.registerCommand('pier-config', {
70
286
  description:
71
- 'Show pier configuration (5 planes) with effective values and sources; `check` validates them; `doctor` lists option values and swallowed errors; no argument hands a guided change to the agent',
287
+ 'Show pier configuration (4 planes) with effective values and sources; `check` validates them; `doctor` lists option values and swallowed errors; no argument hands a guided change to the agent',
72
288
  getArgumentCompletions: (prefix: string) => {
73
289
  const tokens = (prefix ?? '').split(/\s+/);
74
290
  const head = tokens[0] ?? '';
75
291
  if (tokens.length <= 1) {
76
- const candidates = ['show', 'check', 'doc', 'doctor', 'all', ...PLANE_IDS];
77
- return candidates
292
+ return ['show', 'check', 'doc', 'doctor', 'all', ...PLANE_IDS]
78
293
  .filter((c) => c.startsWith(head))
79
294
  .map((c) => ({ value: c, label: c, description: c === 'doc' ? 'write a config report file' : undefined }));
80
295
  }
81
296
  if (head === 'show') {
82
297
  const last = tokens[tokens.length - 1] ?? '';
83
- return ['all', ...PLANE_IDS]
84
- .filter((c) => c.startsWith(last))
85
- .map((c) => ({ value: c, label: c }));
298
+ return ['all', ...PLANE_IDS].filter((c) => c.startsWith(last)).map((c) => ({ value: c, label: c }));
86
299
  }
87
300
  return null;
88
301
  },
@@ -110,24 +323,22 @@ export function installConfigCommand(deps: ConfigCommandDeps): void {
110
323
  const { sub, rest } = parseArgs(args);
111
324
 
112
325
  if (sub === 'check') {
113
- emit([`pier config check (workspace trusted: ${snapshot.workspaceTrusted})`, ...guideCheckLines(snapshot)].join('\n'));
326
+ emit([`pier config check (workspace trusted: ${snapshot.workspaceTrusted})`, ...renderGuide(snapshot, 'check')].join('\n'));
114
327
  return;
115
328
  }
116
329
 
117
330
  if (sub === 'doc') {
118
- const target = rest
119
- ? (isAbsolute(rest) ? rest : resolve(cwd, rest))
120
- : join(reportDir(cwd), CONFIG_REPORT_FILENAME);
331
+ const target = rest ? (isAbsolute(rest) ? rest : resolve(cwd, rest)) : join(reportDir(cwd), 'config-report.md');
121
332
  try {
122
333
  mkdirSync(dirname(target), { recursive: true });
123
334
  writeFileSync(
124
335
  target,
125
- guideReportMarkdown(snapshot, { generatedAt: new Date().toISOString(), cwd, piVersion: PI_VERSION }),
336
+ guideReportMarkdown(snapshot, { generatedAt: new Date().toISOString(), cwd, piVersion }),
126
337
  'utf8',
127
338
  );
128
339
  emit(
129
- `pier config report written: ${target}` +
130
- (rest ? '' : ' (add `.pi-herdr/config-report.md` to .gitignore if you do not want it tracked)'),
340
+ `pier config report written: ${target}`
341
+ + (rest ? '' : ' (add `.pi-herdr/config-report.md` to .gitignore if you do not want it tracked)'),
131
342
  );
132
343
  } catch (err) {
133
344
  emit(`pier-config: could not write report (${err instanceof Error ? err.message : String(err)})`, 'error');
@@ -136,41 +347,37 @@ export function installConfigCommand(deps: ConfigCommandDeps): void {
136
347
  }
137
348
 
138
349
  if (sub === 'doctor') {
139
- // B9/B10: one place to see every pier option (canonical name, effective value, source) and the
140
- // errors that were deliberately swallowed this session. Without it, a silently failing
141
- // best-effort path stays invisible until something else breaks.
142
- emit(
143
- [
144
- 'pier doctor',
145
- '',
146
- `wire: herdr protocol ${HERDR_PROTOCOL_EXPECTED} (pinned by test/fixtures/herdr-contract.json)`,
147
- '',
148
- `options (canonical PIER_*, legacy PI_HERDR_* alias accepted):`,
149
- ...formatOptionRows(),
150
- '',
151
- formatSwallowedErrors(),
152
- ].join('\n'),
153
- );
350
+ // B9/B10: one place for every pier option (canonical name, effective value, source) and the
351
+ // errors deliberately swallowed this session — otherwise a failing best-effort path stays invisible.
352
+ emit([
353
+ 'pier doctor',
354
+ '',
355
+ `wire: herdr protocol ${HERDR_PROTOCOL_EXPECTED} (pinned by test/fixtures/herdr-contract.json)`,
356
+ '',
357
+ 'options (canonical PIER_*, legacy PI_HERDR_* alias accepted):',
358
+ ...formatOptionRows(),
359
+ '',
360
+ formatSwallowedErrors(),
361
+ ].join('\n'));
154
362
  return;
155
363
  }
156
364
 
157
365
  if (sub === 'show') {
158
366
  const plane = rest.trim();
159
- const selector = plane === '' || plane === 'all' ? 'all' : isPlaneId(plane) ? plane : null;
160
- if (selector === null) {
367
+ const selector = plane === '' || plane === 'all' ? 'all' : PLANE_IDS.find((id) => id === plane);
368
+ if (!selector) {
161
369
  emit(`pier-config: unknown plane "${plane}" — expected one of ${PLANE_IDS.join(', ')} or all`, 'warning');
162
370
  return;
163
371
  }
164
- emit(guidePlaneLines(snapshot, selector).join('\n'));
372
+ emit(renderGuide(snapshot, selector).join('\n'));
165
373
  return;
166
374
  }
167
375
 
168
- // Bare `/pier-config`: index + hand the guided change to the agent.
169
- emit([...guideIndexLines(snapshot), '', 'asking the agent to guide the change...'].join('\n'));
376
+ emit([...renderGuide(snapshot, 'index'), '', 'asking the agent to guide the change...'].join('\n'));
170
377
  try {
171
378
  // Call on the receiver: the ExtensionAPI method must not be detached from `pi`.
172
379
  (pi as { sendMessage?: (msg: unknown, opts: unknown) => void }).sendMessage?.(
173
- { customType: CONFIG_GUIDE_CUSTOM_TYPE, content: CONFIG_GUIDANCE_PROMPT, display: false },
380
+ { customType: 'pi-herdr.config-guide', content: CONFIG_GUIDANCE_PROMPT, display: false },
174
381
  { triggerTurn: true },
175
382
  );
176
383
  } catch (err) {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Closed set of cordis services this process provides on the root.
3
+ *
4
+ * `ctx.get`/`ctx.provide` also accept an arbitrary string and return `any`, so a raw call plus `as`
5
+ * neither rejects a mistyped key nor a dropped bag field (fe99252). Mounts and plugins go through
6
+ * the helpers below. Instances stay on the root: a plugin-fiber Service would unload them on HMR.
7
+ */
8
+ import type { Context } from '@deepseek-ai/cordis';
9
+ import type { DisposeLedger } from './ledger.ts';
10
+ import type { PiSurface } from './pi-surface.ts';
11
+ import type { TerminalDeps } from './plugins/terminal.ts';
12
+ import type { TodoDeps, TodoUiSlot } from './plugins/todo.ts';
13
+ import type { TodosService } from './todos-service.ts';
14
+ import type { SubagentDeps } from './plugins/subagent.ts';
15
+
16
+ export interface PierCordisServices {
17
+ 'pi-herdr.ledger': DisposeLedger;
18
+ 'pi-herdr.surface': PiSurface<object>;
19
+ 'pi-herdr.terminal-deps': TerminalDeps;
20
+ 'pi-herdr.todo-deps': TodoDeps;
21
+ 'pi-herdr.subagent-deps': SubagentDeps;
22
+ }
23
+
24
+ declare module '@deepseek-ai/cordis' {
25
+ interface Context extends PierCordisServices {}
26
+ }
27
+
28
+ export function cordisProvide<K extends keyof PierCordisServices>(
29
+ ctx: Context,
30
+ key: K,
31
+ value: PierCordisServices[K],
32
+ ): void {
33
+ ctx.provide(key, value);
34
+ }
35
+
36
+ /** Throws when the root has not provided `key`. `ctx.get` itself returns `T | undefined`. */
37
+ export function cordisGet<K extends keyof PierCordisServices>(ctx: Context, key: K): PierCordisServices[K] {
38
+ const value = ctx.get(key) as PierCordisServices[K] | undefined;
39
+ if (value === undefined) throw new Error(`[pi-herdr] cordis service missing: ${key}`);
40
+ return value;
41
+ }
42
+
43
+ /** The six required 'pi-herdr.todo-deps' fields both mounts assemble identically (maxItems mirrors
44
+ * the tool prompt's "at most 15 entries"); masters and workers spread their own optional seams —
45
+ * getWorkers/getBlockedDepth/stopReminder — on top of this base. */
46
+ export function baseTodoDeps(m: {
47
+ pi: unknown;
48
+ todos: TodosService;
49
+ todoUi: TodoUiSlot;
50
+ mirrorTodos: () => void;
51
+ }): Pick<TodoDeps, 'todos' | 'allowParallelInProgress' | 'maxItems' | 'mirrorTodos' | 'appendEntry' | 'state'> {
52
+ return {
53
+ todos: m.todos,
54
+ allowParallelInProgress: m.todos.config.allowParallelInProgress,
55
+ maxItems: 15,
56
+ mirrorTodos: m.mirrorTodos,
57
+ appendEntry: (customType: string, data: unknown) => {
58
+ // Unchecked cast: pi's ExtensionAPI type omits this optional runtime entry-append seam.
59
+ const pi = m.pi as { appendEntry?: (t: string, d: unknown) => void };
60
+ pi.appendEntry?.(customType, data);
61
+ },
62
+ state: m.todoUi,
63
+ };
64
+ }
@@ -1,5 +1,6 @@
1
1
  import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
2
- import type { TodoItem } from './vocab.ts';
2
+ import { openPierPanel } from './panel-open.ts';
3
+ import { isTodoStatus, type TodoItem, type TodoStatus } from './vocab.ts';
3
4
 
4
5
  export interface DashboardCommandDeps {
5
6
  pi: {
@@ -9,8 +10,29 @@ export interface DashboardCommandDeps {
9
10
  env: HerdrEnv | null;
10
11
  getTodoItems?: () => readonly TodoItem[];
11
12
  getHeldLocks?: () => readonly string[];
13
+ /** Publishes the current delegation tree and returns its snapshot path (panel Tree tab). */
14
+ prepareTree?: () => string | null;
12
15
  }
13
16
 
17
+ /** SUMMARY_ORDER fixes the summary-line order; LIVE_ROWS adds the detail line below it.
18
+ * `abandoned` is counted in the total but never listed. */
19
+ const SUMMARY_ORDER = [
20
+ { status: 'completed', label: 'done' },
21
+ { status: 'in_progress', label: 'working' },
22
+ { status: 'blocked', label: 'blocked' },
23
+ { status: 'pending', label: 'pending' },
24
+ ] as const;
25
+
26
+ const LIVE_ROWS = [
27
+ { status: 'in_progress', label: 'Active', glyph: '▶', line: (todo: TodoItem) => todo.content },
28
+ {
29
+ status: 'blocked',
30
+ label: 'Blocked',
31
+ glyph: '■',
32
+ line: (todo: TodoItem) => `${todo.content}${todo.blocker ? ` (${todo.blocker})` : ''}`,
33
+ },
34
+ ] as const;
35
+
14
36
  export function formatStandaloneDashboard(opts: {
15
37
  todos: readonly TodoItem[];
16
38
  locks: readonly string[];
@@ -22,26 +44,27 @@ export function formatStandaloneDashboard(opts: {
22
44
  lines.push('Mode: Standalone (Non-Herdr / Local Session)');
23
45
  lines.push('--------------------------------------------------------------------------------');
24
46
 
25
- const todos = opts.todos;
26
- if (todos.length > 0) {
27
- const inProg = todos.filter((t) => t.status === 'in_progress');
28
- const blocked = todos.filter((t) => t.status === 'blocked');
29
- const done = todos.filter((t) => t.status === 'completed');
30
- const pending = todos.filter((t) => t.status === 'pending');
47
+ if (opts.todos.length === 0) {
48
+ lines.push('Todos: (none)');
49
+ } else {
50
+ const groups: Record<TodoStatus, TodoItem[]> = {
51
+ pending: [], in_progress: [], completed: [], blocked: [], abandoned: [],
52
+ };
53
+ // Corrupt snapshots can carry any status; a prototype key would make `groups[...].push` throw.
54
+ const todos = opts.todos.filter((todo) => isTodoStatus(todo.status));
55
+ for (const todo of todos) groups[todo.status].push(todo);
56
+
31
57
  lines.push(
32
- `Todos: ${todos.length} total (${done.length} done, ${inProg.length} working, ${blocked.length} blocked, ${pending.length} pending)`,
58
+ `Todos: ${todos.length} total (${SUMMARY_ORDER.map((row) => `${groups[row.status].length} ${row.label}`).join(', ')})`,
33
59
  );
34
- if (inProg.length > 0) {
35
- lines.push(`Active: ▶ ${inProg.map((t) => t.content).join(', ')}`);
60
+ for (const row of LIVE_ROWS) {
61
+ const items = groups[row.status];
62
+ if (items.length > 0) lines.push(`${row.label}: ${row.glyph} ${items.map(row.line).join(', ')}`);
36
63
  }
37
- if (blocked.length > 0) {
38
- lines.push(`Blocked: ■ ${blocked.map((t) => `${t.content}${t.blocker ? ` (${t.blocker})` : ''}`).join(', ')}`);
64
+ // Recent completions only carry signal when nothing is in flight.
65
+ if (groups.in_progress.length === 0 && groups.blocked.length === 0 && groups.completed.length > 0) {
66
+ lines.push(`Completed: ✓ ${groups.completed.slice(-3).map((todo) => todo.content).join(', ')}`);
39
67
  }
40
- if (inProg.length === 0 && blocked.length === 0 && done.length > 0) {
41
- lines.push(`Completed: ✓ ${done.slice(-3).map((t) => t.content).join(', ')}`);
42
- }
43
- } else {
44
- lines.push('Todos: (none)');
45
68
  }
46
69
 
47
70
  const locks = opts.locks;
@@ -54,27 +77,48 @@ export function formatStandaloneDashboard(opts: {
54
77
 
55
78
  export function installDashboardCommand(deps: DashboardCommandDeps): void {
56
79
  deps.pi.registerCommand('dashboard', {
57
- description: 'Open the Pier Ops Dashboard (modal popup in Herdr 0.9.1, tab fallback, or local TUI view)',
58
- handler: async (_args, ctx) => {
80
+ description: 'Open the Pier Ops Dashboard (modal popup by default; `/dashboard overlay` = independent full-window pane)',
81
+ handler: async (args, ctx) => {
59
82
  const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
83
+ const overlay = typeof args === 'string' && args.trim().toLowerCase() === 'overlay';
60
84
 
61
- // Level 1 / Level 2: Herdr environment
62
85
  if (deps.client.available && deps.env) {
63
- try {
64
- const res = await deps.client.openPluginPane({
65
- pluginId: 'pier.workbench',
66
- entrypoint: 'dashboard',
67
- placement: 'popup',
68
- width: '80%',
69
- height: '80%',
70
- focus: true,
71
- });
72
- if (res.mode === 'fallback_tab') {
86
+ if (overlay) {
87
+ // Escape hatch: a real full-window pane instead of the singleton popup.
88
+ const treeFile = deps.prepareTree?.() ?? null;
89
+ try {
90
+ const res = await deps.client.openPluginPane({
91
+ pluginId: 'pier.workbench',
92
+ entrypoint: 'dashboard',
93
+ placement: 'overlay',
94
+ focus: true,
95
+ env: {
96
+ PIER_PANEL: 'dashboard',
97
+ ...(treeFile ? { PIER_TREE_FILE: treeFile } : {}),
98
+ },
99
+ });
100
+ if (res.mode === 'fallback_tab') {
101
+ ui?.notify?.('Herdr < 0.9.1: Pier Dashboard opened in new tab', 'info');
102
+ }
103
+ return;
104
+ } catch {
105
+ // Fall through to the local view.
106
+ }
107
+ } else {
108
+ const res = await openPierPanel(
109
+ { client: deps.client, env: deps.env },
110
+ { panel: 'dashboard', treeFile: deps.prepareTree?.() ?? null },
111
+ );
112
+ if (res === 'popup') return;
113
+ if (res === 'tab') {
73
114
  ui?.notify?.('Herdr < 0.9.1: Pier Dashboard opened in new tab', 'info');
115
+ return;
116
+ }
117
+ if (res === 'busy') {
118
+ ui?.notify?.('Another popup is already open — close it and run /dashboard again.', 'warning');
119
+ return;
74
120
  }
75
- return;
76
- } catch {
77
- // If plugin is not installed or socket error occurs, fall through to Level 3 local view
121
+ // 'unavailable' → Level 3 local view.
78
122
  }
79
123
  }
80
124