pi-pier 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -0,0 +1,388 @@
1
+ /**
2
+ * D104 `/pier-config`: probes the four configuration planes (real files + process env), renders
3
+ * `show|check|doc|doctor`, and registers the command.
4
+ *
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
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
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';
28
+ import {
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';
35
+ import { formatOptionRows } from './pier-options.ts';
36
+ import { formatSwallowedErrors } from './swallow.ts';
37
+ import { HERDR_PROTOCOL_EXPECTED } from './herdr-client.ts';
38
+
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');
61
+
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
+ }
254
+
255
+ export interface ConfigCommandDeps {
256
+ pi: ExtensionAPI;
257
+ /** Injectable for tests; defaults to collectConfigSnapshot. */
258
+ collect?: (deps: ConfigGuideDeps) => ConfigGuideSnapshot;
259
+ /** Overridable so tests can pin the report destination. */
260
+ reportDir?: (cwd: string) => string;
261
+ }
262
+
263
+ const PLANE_IDS: readonly ConfigPlaneId[] = CONFIG_PLANES.map((p) => p.id);
264
+ const SUBCOMMANDS = ['show', 'check', 'doc', 'doctor'] as const;
265
+
266
+ function parseArgs(args: unknown): { sub: (typeof SUBCOMMANDS)[number] | 'index'; rest: string } {
267
+ const raw = typeof args === 'string'
268
+ ? args.split(/\s+/).filter(Boolean)
269
+ : Array.isArray(args) ? args.map(String) : [];
270
+ const [first, ...rest] = raw;
271
+ const sub = (SUBCOMMANDS as readonly string[]).includes(first ?? '')
272
+ ? (first as (typeof SUBCOMMANDS)[number])
273
+ : 'index';
274
+ // `show` accepts an optional plane; an unknown first token is treated as the plane selector for `show`.
275
+ const restJoined = sub === 'index' && first && first !== 'index' ? [first, ...rest].join(' ') : rest.join(' ');
276
+ return { sub, rest: restJoined };
277
+ }
278
+
279
+ /** Registers `/pier-config`. Safe to call once per extension load (pi replaces commands by name). */
280
+ export function installConfigCommand(deps: ConfigCommandDeps): void {
281
+ const { pi } = deps;
282
+ const collect = deps.collect ?? collectConfigSnapshot;
283
+ const reportDir = deps.reportDir ?? ((cwd: string) => join(cwd, '.pi-herdr'));
284
+
285
+ pi.registerCommand('pier-config', {
286
+ description:
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',
288
+ getArgumentCompletions: (prefix: string) => {
289
+ const tokens = (prefix ?? '').split(/\s+/);
290
+ const head = tokens[0] ?? '';
291
+ if (tokens.length <= 1) {
292
+ return ['show', 'check', 'doc', 'doctor', 'all', ...PLANE_IDS]
293
+ .filter((c) => c.startsWith(head))
294
+ .map((c) => ({ value: c, label: c, description: c === 'doc' ? 'write a config report file' : undefined }));
295
+ }
296
+ if (head === 'show') {
297
+ const last = tokens[tokens.length - 1] ?? '';
298
+ return ['all', ...PLANE_IDS].filter((c) => c.startsWith(last)).map((c) => ({ value: c, label: c }));
299
+ }
300
+ return null;
301
+ },
302
+ handler: async (args: string, ctx: ExtensionContext) => {
303
+ const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
304
+ const emit = (text: string, level: 'info' | 'warning' | 'error' = 'info'): void => {
305
+ if (ui?.notify) ui.notify(text, level);
306
+ else console.log(text); // print/json and test modes have no UI surface
307
+ };
308
+
309
+ const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
310
+ const isProjectTrusted =
311
+ typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
312
+ ? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
313
+ : false;
314
+
315
+ let snapshot: ConfigGuideSnapshot;
316
+ try {
317
+ snapshot = collect({ cwd, isProjectTrusted });
318
+ } catch (err) {
319
+ emit(`pier-config: failed to read configuration (${err instanceof Error ? err.message : String(err)})`, 'error');
320
+ return;
321
+ }
322
+
323
+ const { sub, rest } = parseArgs(args);
324
+
325
+ if (sub === 'check') {
326
+ emit([`pier config check (workspace trusted: ${snapshot.workspaceTrusted})`, ...renderGuide(snapshot, 'check')].join('\n'));
327
+ return;
328
+ }
329
+
330
+ if (sub === 'doc') {
331
+ const target = rest ? (isAbsolute(rest) ? rest : resolve(cwd, rest)) : join(reportDir(cwd), 'config-report.md');
332
+ try {
333
+ mkdirSync(dirname(target), { recursive: true });
334
+ writeFileSync(
335
+ target,
336
+ guideReportMarkdown(snapshot, { generatedAt: new Date().toISOString(), cwd, piVersion }),
337
+ 'utf8',
338
+ );
339
+ emit(
340
+ `pier config report written: ${target}`
341
+ + (rest ? '' : ' (add `.pi-herdr/config-report.md` to .gitignore if you do not want it tracked)'),
342
+ );
343
+ } catch (err) {
344
+ emit(`pier-config: could not write report (${err instanceof Error ? err.message : String(err)})`, 'error');
345
+ }
346
+ return;
347
+ }
348
+
349
+ if (sub === 'doctor') {
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'));
362
+ return;
363
+ }
364
+
365
+ if (sub === 'show') {
366
+ const plane = rest.trim();
367
+ const selector = plane === '' || plane === 'all' ? 'all' : PLANE_IDS.find((id) => id === plane);
368
+ if (!selector) {
369
+ emit(`pier-config: unknown plane "${plane}" — expected one of ${PLANE_IDS.join(', ')} or all`, 'warning');
370
+ return;
371
+ }
372
+ emit(renderGuide(snapshot, selector).join('\n'));
373
+ return;
374
+ }
375
+
376
+ emit([...renderGuide(snapshot, 'index'), '', 'asking the agent to guide the change...'].join('\n'));
377
+ try {
378
+ // Call on the receiver: the ExtensionAPI method must not be detached from `pi`.
379
+ (pi as { sendMessage?: (msg: unknown, opts: unknown) => void }).sendMessage?.(
380
+ { customType: 'pi-herdr.config-guide', content: CONFIG_GUIDANCE_PROMPT, display: false },
381
+ { triggerTurn: true },
382
+ );
383
+ } catch (err) {
384
+ emit(`pier-config: could not start the guided flow (${err instanceof Error ? err.message : String(err)})`, 'warning');
385
+ }
386
+ },
387
+ });
388
+ }
@@ -0,0 +1,108 @@
1
+ import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
2
+ import { isTodoStatus, type TodoItem, type TodoStatus } from './vocab.ts';
3
+
4
+ export interface DashboardCommandDeps {
5
+ pi: {
6
+ registerCommand(name: string, options: { description: string; handler: (args: unknown, ctx: unknown) => Promise<void> }): void;
7
+ };
8
+ client: HerdrClientLike;
9
+ env: HerdrEnv | null;
10
+ getTodoItems?: () => readonly TodoItem[];
11
+ getHeldLocks?: () => readonly string[];
12
+ }
13
+
14
+ /** SUMMARY_ORDER fixes the summary-line order; LIVE_ROWS adds the detail line below it.
15
+ * `abandoned` is counted in the total but never listed. */
16
+ const SUMMARY_ORDER = [
17
+ { status: 'completed', label: 'done' },
18
+ { status: 'in_progress', label: 'working' },
19
+ { status: 'blocked', label: 'blocked' },
20
+ { status: 'pending', label: 'pending' },
21
+ ] as const;
22
+
23
+ const LIVE_ROWS = [
24
+ { status: 'in_progress', label: 'Active', glyph: '▶', line: (todo: TodoItem) => todo.content },
25
+ {
26
+ status: 'blocked',
27
+ label: 'Blocked',
28
+ glyph: '■',
29
+ line: (todo: TodoItem) => `${todo.content}${todo.blocker ? ` (${todo.blocker})` : ''}`,
30
+ },
31
+ ] as const;
32
+
33
+ export function formatStandaloneDashboard(opts: {
34
+ todos: readonly TodoItem[];
35
+ locks: readonly string[];
36
+ now?: number;
37
+ }): string {
38
+ const lines: string[] = [];
39
+ const timeStr = new Date(opts.now ?? Date.now()).toTimeString().split(' ')[0] ?? '';
40
+ lines.push(`==================== PIER OPS DASHBOARD ==================== [${timeStr}] (standalone)`);
41
+ lines.push('Mode: Standalone (Non-Herdr / Local Session)');
42
+ lines.push('--------------------------------------------------------------------------------');
43
+
44
+ if (opts.todos.length === 0) {
45
+ lines.push('Todos: (none)');
46
+ } else {
47
+ const groups: Record<TodoStatus, TodoItem[]> = {
48
+ pending: [], in_progress: [], completed: [], blocked: [], abandoned: [],
49
+ };
50
+ // Corrupt snapshots can carry any status; a prototype key would make `groups[...].push` throw.
51
+ const todos = opts.todos.filter((todo) => isTodoStatus(todo.status));
52
+ for (const todo of todos) groups[todo.status].push(todo);
53
+
54
+ lines.push(
55
+ `Todos: ${todos.length} total (${SUMMARY_ORDER.map((row) => `${groups[row.status].length} ${row.label}`).join(', ')})`,
56
+ );
57
+ for (const row of LIVE_ROWS) {
58
+ const items = groups[row.status];
59
+ if (items.length > 0) lines.push(`${row.label}: ${row.glyph} ${items.map(row.line).join(', ')}`);
60
+ }
61
+ // Recent completions only carry signal when nothing is in flight.
62
+ if (groups.in_progress.length === 0 && groups.blocked.length === 0 && groups.completed.length > 0) {
63
+ lines.push(`Completed: ✓ ${groups.completed.slice(-3).map((todo) => todo.content).join(', ')}`);
64
+ }
65
+ }
66
+
67
+ const locks = opts.locks;
68
+ lines.push(`Write Locks: ${locks.length > 0 ? locks.join(', ') : '(none)'}`);
69
+ lines.push('--------------------------------------------------------------------------------');
70
+ lines.push('Tip: Inside Herdr 0.9.1, /dashboard opens the interactive modal popup.');
71
+ lines.push('================================================================================');
72
+ return lines.join('\n');
73
+ }
74
+
75
+ export function installDashboardCommand(deps: DashboardCommandDeps): void {
76
+ deps.pi.registerCommand('dashboard', {
77
+ description: 'Open the Pier Ops Dashboard (modal popup in Herdr 0.9.1, tab fallback, or local TUI view)',
78
+ handler: async (_args, ctx) => {
79
+ const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
80
+
81
+ if (deps.client.available && deps.env) {
82
+ try {
83
+ const res = await deps.client.openPluginPane({
84
+ pluginId: 'pier.workbench',
85
+ entrypoint: 'dashboard',
86
+ placement: 'popup',
87
+ width: '80%',
88
+ height: '80%',
89
+ focus: true,
90
+ });
91
+ if (res.mode === 'fallback_tab') {
92
+ ui?.notify?.('Herdr < 0.9.1: Pier Dashboard opened in new tab', 'info');
93
+ }
94
+ return;
95
+ } catch {
96
+ // Plugin missing or socket error: fall through to the Level 3 local view.
97
+ }
98
+ }
99
+
100
+ // Level 3: Standalone / Non-Herdr fallback
101
+ const text = formatStandaloneDashboard({
102
+ todos: deps.getTodoItems?.() ?? [],
103
+ locks: deps.getHeldLocks?.() ?? [],
104
+ });
105
+ ui?.notify?.(text, 'info');
106
+ },
107
+ });
108
+ }