pi-pier 0.3.0 → 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 (96) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -2
  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 +8 -12
  9. package/src/compact-coordinator.ts +278 -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/dashboard-command.ts +38 -19
  14. package/src/efficiency-config-core.ts +287 -484
  15. package/src/efficiency-store.ts +16 -29
  16. package/src/focus-poller.ts +14 -35
  17. package/src/gc-core.ts +10 -36
  18. package/src/herdr-client.ts +82 -208
  19. package/src/history-store.ts +36 -23
  20. package/src/index-gates.ts +124 -0
  21. package/src/index-locks.ts +2 -7
  22. package/src/index-master.ts +24 -18
  23. package/src/index-notices.ts +28 -15
  24. package/src/index-pipe.ts +17 -12
  25. package/src/index-roles.ts +306 -0
  26. package/src/index-runtime.ts +2 -6
  27. package/src/index-worker.ts +4 -8
  28. package/src/index.ts +305 -564
  29. package/src/jev-client.ts +12 -17
  30. package/src/jev-core.ts +137 -87
  31. package/src/ledger.ts +13 -21
  32. package/src/lock-core.ts +28 -56
  33. package/src/manifest-compose.ts +35 -20
  34. package/src/observation-core.ts +52 -100
  35. package/src/pane-title.ts +19 -38
  36. package/src/pi-surface.ts +34 -22
  37. package/src/pier-options.ts +54 -50
  38. package/src/pipe-channel.ts +27 -59
  39. package/src/platform-paths.ts +19 -52
  40. package/src/{core → plugins}/grid-shape.ts +8 -10
  41. package/src/{core → plugins}/heat-plan.ts +20 -22
  42. package/src/plugins/observation.ts +475 -0
  43. package/src/plugins/subagent.ts +728 -0
  44. package/src/{core → plugins}/terminal.ts +167 -127
  45. package/src/{core → plugins}/todo.ts +61 -100
  46. package/src/progress-core.ts +16 -53
  47. package/src/reconcile-core.ts +24 -33
  48. package/src/reducer-core.ts +24 -60
  49. package/src/reducer-invoker.ts +266 -239
  50. package/src/renderers.ts +76 -87
  51. package/src/role-loader.ts +33 -28
  52. package/src/role-manifest.ts +23 -28
  53. package/src/role-state.ts +119 -0
  54. package/src/routing-telemetry.ts +136 -0
  55. package/src/runtime-policy.ts +25 -40
  56. package/src/session-tail.ts +65 -41
  57. package/src/settle-wake-core.ts +52 -21
  58. package/src/slim-frame.ts +25 -122
  59. package/src/stale-core.ts +17 -24
  60. package/src/storage-layout.ts +14 -43
  61. package/src/subagent-core.ts +253 -118
  62. package/src/subagent-gc.ts +109 -67
  63. package/src/subagent-poller.ts +367 -24
  64. package/src/subagent-session.ts +516 -0
  65. package/src/subagent-spawn.ts +530 -52
  66. package/src/swallow.ts +7 -17
  67. package/src/terminal-core.ts +125 -106
  68. package/src/todo-core.ts +57 -109
  69. package/src/todo-read-hook.ts +23 -39
  70. package/src/todo-reminder-core.ts +26 -51
  71. package/src/todo-window.ts +11 -12
  72. package/src/todos-service.ts +15 -35
  73. package/src/tool-error.ts +6 -13
  74. package/src/tool-gate.ts +38 -21
  75. package/src/vocab.ts +52 -25
  76. package/src/ask-multi.ts +0 -260
  77. package/src/config-guide.ts +0 -361
  78. package/src/core/observation.ts +0 -555
  79. package/src/core/subagent.ts +0 -633
  80. package/src/efficiency-config-io.ts +0 -53
  81. package/src/git-adapter.ts +0 -113
  82. package/src/notice-buffer.ts +0 -23
  83. package/src/subagent-execute.ts +0 -328
  84. package/src/subagent-git-io.ts +0 -80
  85. package/src/subagent-launch.ts +0 -114
  86. package/src/subagent-list-action.ts +0 -69
  87. package/src/subagent-output-action.ts +0 -104
  88. package/src/subagent-output-adapter.ts +0 -27
  89. package/src/subagent-output-core.ts +0 -263
  90. package/src/subagent-poll-loop.ts +0 -305
  91. package/src/subagent-port.ts +0 -27
  92. package/src/subagent-ready.ts +0 -89
  93. package/src/subagent-registry.ts +0 -70
  94. package/src/subagent-resolution.ts +0 -41
  95. package/src/subagent-scope.ts +0 -41
  96. 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) {
@@ -1,5 +1,5 @@
1
1
  import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
2
- import type { TodoItem } from './vocab.ts';
2
+ import { isTodoStatus, type TodoItem, type TodoStatus } from './vocab.ts';
3
3
 
4
4
  export interface DashboardCommandDeps {
5
5
  pi: {
@@ -11,6 +11,25 @@ export interface DashboardCommandDeps {
11
11
  getHeldLocks?: () => readonly string[];
12
12
  }
13
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
+
14
33
  export function formatStandaloneDashboard(opts: {
15
34
  todos: readonly TodoItem[];
16
35
  locks: readonly string[];
@@ -22,26 +41,27 @@ export function formatStandaloneDashboard(opts: {
22
41
  lines.push('Mode: Standalone (Non-Herdr / Local Session)');
23
42
  lines.push('--------------------------------------------------------------------------------');
24
43
 
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');
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
+
31
54
  lines.push(
32
- `Todos: ${todos.length} total (${done.length} done, ${inProg.length} working, ${blocked.length} blocked, ${pending.length} pending)`,
55
+ `Todos: ${todos.length} total (${SUMMARY_ORDER.map((row) => `${groups[row.status].length} ${row.label}`).join(', ')})`,
33
56
  );
34
- if (inProg.length > 0) {
35
- lines.push(`Active: ▶ ${inProg.map((t) => t.content).join(', ')}`);
36
- }
37
- if (blocked.length > 0) {
38
- lines.push(`Blocked: ■ ${blocked.map((t) => `${t.content}${t.blocker ? ` (${t.blocker})` : ''}`).join(', ')}`);
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(', ')}`);
39
60
  }
40
- if (inProg.length === 0 && blocked.length === 0 && done.length > 0) {
41
- lines.push(`Completed: ✓ ${done.slice(-3).map((t) => t.content).join(', ')}`);
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(', ')}`);
42
64
  }
43
- } else {
44
- lines.push('Todos: (none)');
45
65
  }
46
66
 
47
67
  const locks = opts.locks;
@@ -58,7 +78,6 @@ export function installDashboardCommand(deps: DashboardCommandDeps): void {
58
78
  handler: async (_args, ctx) => {
59
79
  const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
60
80
 
61
- // Level 1 / Level 2: Herdr environment
62
81
  if (deps.client.available && deps.env) {
63
82
  try {
64
83
  const res = await deps.client.openPluginPane({
@@ -74,7 +93,7 @@ export function installDashboardCommand(deps: DashboardCommandDeps): void {
74
93
  }
75
94
  return;
76
95
  } catch {
77
- // If plugin is not installed or socket error occurs, fall through to Level 3 local view
96
+ // Plugin missing or socket error: fall through to the Level 3 local view.
78
97
  }
79
98
  }
80
99