piher 0.0.0-stage → 0.2.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.
- package/README.md +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D104 config catalog core (pure).
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the two configuration planes this extension has: role profiles
|
|
5
|
+
* (per-file, its schema is the reference) and the runtime policy env, derived from the
|
|
6
|
+
* `OPTION_SPECS` registry so the catalog cannot disagree with the runtime readers.
|
|
7
|
+
* No I/O here (config-command.ts does the reading) and test/config-catalog.test.ts guards the mirror.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { OPTION_SPECS } from './options.ts';
|
|
11
|
+
|
|
12
|
+
export type ConfigPlaneId = 'roles' | 'env';
|
|
13
|
+
|
|
14
|
+
export type ConfigKind = 'boolean' | 'number' | 'string';
|
|
15
|
+
|
|
16
|
+
export interface ConfigKnob {
|
|
17
|
+
readonly plane: ConfigPlaneId;
|
|
18
|
+
/** Env var name for the `env` plane; a dotted path inside the plane's file otherwise. */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly kind: ConfigKind;
|
|
21
|
+
readonly defaultValue?: string | number | boolean;
|
|
22
|
+
/** Lower bound for numeric knobs (mirrors the runtime validator). */
|
|
23
|
+
readonly min?: number;
|
|
24
|
+
readonly impact: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConfigPlane {
|
|
28
|
+
readonly id: ConfigPlaneId;
|
|
29
|
+
readonly title: string;
|
|
30
|
+
/** Human-facing path templates, in precedence order. */
|
|
31
|
+
readonly files: readonly string[];
|
|
32
|
+
readonly editHint: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CONFIG_PLANES: readonly ConfigPlane[] = Object.freeze([
|
|
36
|
+
{
|
|
37
|
+
id: 'roles',
|
|
38
|
+
title: 'Role profiles (D82/D11)',
|
|
39
|
+
files: ['<workspace>/.piher/roles/<name>.json', '~/.pi/agent/piher/roles/<name>.json', 'builtin src/roles/'],
|
|
40
|
+
editHint: 'Add or edit a role JSON; builtin names (master/worker-default) cannot be overridden',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'env',
|
|
44
|
+
title: 'Runtime policy env (PIHER_*)',
|
|
45
|
+
files: ['process environment (per-process, no file)'],
|
|
46
|
+
editHint: 'Export the variable before starting pi; it applies to new processes only',
|
|
47
|
+
},
|
|
48
|
+
] as const);
|
|
49
|
+
|
|
50
|
+
/** Runtime policy / behaviour env knobs, derived from the `OPTION_SPECS` registry. */
|
|
51
|
+
const ENV_KNOBS: readonly ConfigKnob[] = OPTION_SPECS.map((option) => ({
|
|
52
|
+
plane: 'env' as const,
|
|
53
|
+
key: option.name,
|
|
54
|
+
kind: option.min === undefined ? ('string' as const) : ('number' as const),
|
|
55
|
+
defaultValue: option.min === undefined
|
|
56
|
+
? (option.fallback === '' ? undefined : option.fallback)
|
|
57
|
+
: Number(option.fallback),
|
|
58
|
+
min: option.min,
|
|
59
|
+
impact: option.description,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
/** Every knob the catalog can resolve (roles is a per-file plane: its schema is the reference). */
|
|
63
|
+
export const CONFIG_KNOBS: readonly ConfigKnob[] = Object.freeze([...ENV_KNOBS]);
|
|
64
|
+
|
|
65
|
+
/** Secret-shaped KEY names (not values). Deliberately narrower than a bare `token` match so that
|
|
66
|
+
* legitimate keys are still shown verbatim. */
|
|
67
|
+
const SECRET_LIKE = /(api[_-]?key|apikey|authorization|bearer|access[_-]?token|client[_-]?secret|password|credential)/i;
|
|
68
|
+
|
|
69
|
+
export function formatValue(value: unknown): string {
|
|
70
|
+
if (value === undefined) return '(unset)';
|
|
71
|
+
if (typeof value === 'string') return value === '' ? '""' : value;
|
|
72
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
73
|
+
if (value === null) return 'null';
|
|
74
|
+
return JSON.stringify(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function redactValue(key: string, value: unknown): string {
|
|
78
|
+
if (SECRET_LIKE.test(key) && value !== undefined && value !== '') return '***';
|
|
79
|
+
return formatValue(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type ConfigSource = 'env' | 'default';
|
|
83
|
+
|
|
84
|
+
export interface ResolvedKnob {
|
|
85
|
+
readonly knob: ConfigKnob;
|
|
86
|
+
readonly value: string;
|
|
87
|
+
readonly source: ConfigSource;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolves the env plane: only catalog keys are ever read. */
|
|
91
|
+
export function resolveEnvKnobs(env: Record<string, string | undefined> = {}): ResolvedKnob[] {
|
|
92
|
+
return ENV_KNOBS.map((knob) => {
|
|
93
|
+
// Same rule as optionValue(): an empty value counts as unset.
|
|
94
|
+
const raw = env[knob.key];
|
|
95
|
+
if (raw === undefined || raw === '') {
|
|
96
|
+
return { knob, value: redactValue(knob.key, knob.defaultValue), source: 'default' as const };
|
|
97
|
+
}
|
|
98
|
+
return { knob, value: redactValue(knob.key, raw), source: 'env' as const };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CheckReport {
|
|
103
|
+
readonly plane: ConfigPlaneId;
|
|
104
|
+
readonly ok: boolean;
|
|
105
|
+
readonly issues: readonly string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function checkEnvKnobs(env: Record<string, string | undefined> = {}): string[] {
|
|
109
|
+
const issues: string[] = [];
|
|
110
|
+
for (const knob of ENV_KNOBS) {
|
|
111
|
+
if (knob.kind !== 'number') continue;
|
|
112
|
+
const raw = env[knob.key];
|
|
113
|
+
if (raw === undefined || raw === '') continue;
|
|
114
|
+
const parsed = Number.parseInt(raw, 10);
|
|
115
|
+
const min = knob.min ?? 0;
|
|
116
|
+
if (!Number.isFinite(parsed) || parsed < min) {
|
|
117
|
+
issues.push(`${knob.key}="${raw}" is not a valid integer >= ${min} (runtime falls back to ${formatValue(knob.defaultValue)})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sourceLabel(entry: ResolvedKnob): string {
|
|
124
|
+
return entry.source;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function summarizePlane(entries: readonly ResolvedKnob[]): string {
|
|
128
|
+
const changed = entries.filter((e) => e.source !== 'default');
|
|
129
|
+
if (changed.length === 0) return 'all defaults';
|
|
130
|
+
const bySource = new Map<ConfigSource, number>();
|
|
131
|
+
for (const e of changed) bySource.set(e.source, (bySource.get(e.source) ?? 0) + 1);
|
|
132
|
+
return `${changed.length} set (${[...bySource].map(([s, n]) => `${s} ${n}`).join(', ')})`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function renderIndex(lines: {
|
|
136
|
+
env: readonly ResolvedKnob[];
|
|
137
|
+
roleSummary: string;
|
|
138
|
+
}): string[] {
|
|
139
|
+
return [
|
|
140
|
+
'piher config — 2 planes, env > default (roles are per-file)',
|
|
141
|
+
` roles ${lines.roleSummary}`,
|
|
142
|
+
` env ${summarizePlane(lines.env)}`,
|
|
143
|
+
' show: /piher-config show <roles|env|all> check: /piher-config check report: /piher-config doc',
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function renderPlane(entries: readonly ResolvedKnob[]): string[] {
|
|
148
|
+
const out: string[] = [];
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const { knob } = entry;
|
|
151
|
+
out.push(` ${knob.key} = ${entry.value} [${sourceLabel(entry)}]`);
|
|
152
|
+
out.push(` ${knob.impact}`);
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function renderCheck(reports: readonly CheckReport[]): string[] {
|
|
158
|
+
const out: string[] = [];
|
|
159
|
+
for (const report of reports) {
|
|
160
|
+
out.push(` ${report.ok ? 'ok ' : 'FAIL'} ${report.plane}`);
|
|
161
|
+
for (const issue of report.issues) out.push(` - ${issue}`);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface ReportMeta {
|
|
167
|
+
readonly generatedAt: string;
|
|
168
|
+
readonly cwd: string;
|
|
169
|
+
readonly workspaceTrusted: boolean;
|
|
170
|
+
readonly piVersion?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function renderReport(entries: readonly ResolvedKnob[], meta: ReportMeta): string {
|
|
174
|
+
const lines = [
|
|
175
|
+
'# piher config report',
|
|
176
|
+
'',
|
|
177
|
+
`- generated: ${meta.generatedAt}`,
|
|
178
|
+
`- cwd: ${meta.cwd}`,
|
|
179
|
+
`- workspace trusted: ${meta.workspaceTrusted}`,
|
|
180
|
+
...(meta.piVersion ? [`- pi: ${meta.piVersion}`] : []),
|
|
181
|
+
'',
|
|
182
|
+
'Precedence: env > default.',
|
|
183
|
+
'',
|
|
184
|
+
];
|
|
185
|
+
const escape = (text: string): string => text.replace(/\|/g, '\\|');
|
|
186
|
+
for (const plane of CONFIG_PLANES) {
|
|
187
|
+
const planeEntries = entries.filter((e) => e.knob.plane === plane.id);
|
|
188
|
+
lines.push(`## ${plane.id} — ${plane.title}`, '', `Files: ${plane.files.map((f) => `\`${f}\``).join(' , ')}`, '', `> ${plane.editHint}`, '');
|
|
189
|
+
if (planeEntries.length === 0) {
|
|
190
|
+
lines.push('(no value-carrying knobs; per-file plane)', '');
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
lines.push('| key | value | source | impact |', '|---|---|---|---|');
|
|
194
|
+
for (const entry of planeEntries) {
|
|
195
|
+
lines.push(`| \`${entry.knob.key}\` | ${escape(entry.value)} | ${sourceLabel(entry)} | ${escape(entry.knob.impact)} |`);
|
|
196
|
+
}
|
|
197
|
+
lines.push('');
|
|
198
|
+
}
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D104 `/piher-config`: probes the 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 { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
|
+
import { VERSION as piVersion, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
14
|
+
import {
|
|
15
|
+
CONFIG_PLANES,
|
|
16
|
+
checkEnvKnobs,
|
|
17
|
+
renderCheck,
|
|
18
|
+
renderIndex,
|
|
19
|
+
renderPlane,
|
|
20
|
+
renderReport,
|
|
21
|
+
resolveEnvKnobs,
|
|
22
|
+
type CheckReport,
|
|
23
|
+
type ConfigPlaneId,
|
|
24
|
+
type ResolvedKnob,
|
|
25
|
+
} from './config-catalog-core.ts';
|
|
26
|
+
import { RESERVED_ROLE_NAMES, listRoleNames, loadRoleConfig, roleLayers } from './role-loader.ts';
|
|
27
|
+
import { formatOptionRows } from './options.ts';
|
|
28
|
+
import { formatSwallowedErrors } from './swallow.ts';
|
|
29
|
+
import { CORE_VERSION_PINNED } from './core-client.ts';
|
|
30
|
+
|
|
31
|
+
export const CONFIG_GUIDANCE_PROMPT = [
|
|
32
|
+
'[PIHER-CONFIG] The user wants to inspect or change piher configuration. Follow this workflow strictly:',
|
|
33
|
+
'',
|
|
34
|
+
'1. Ground yourself first: run `/piher-config show all` (or read the files) and use the reported EFFECTIVE VALUE and SOURCE',
|
|
35
|
+
' (env > default). Only entries whose source is `default` are worth changing.',
|
|
36
|
+
'2. Route by plane:',
|
|
37
|
+
' - roles: read `docs/sidebar-role-config.md` and `schemas/role-manifest.schema.json`; builtin role names cannot be overridden.',
|
|
38
|
+
' - env (PIHER_* / PIHER_*): affects new processes only; state that explicitly.',
|
|
39
|
+
' - pi model, thinking and compaction settings belong to pi itself: send the user to `/settings`, not to a piher file.',
|
|
40
|
+
'3. Explain before changing: 2-3 sentences on what the knob controls, its cost/benefit (tokens, latency, safety, blast radius),',
|
|
41
|
+
' and 2-3 recommended values for common scenarios. Then ask what the user actually wants to achieve.',
|
|
42
|
+
'4. Before writing: show a precise diff (file path, current -> proposed value) and the activation path',
|
|
43
|
+
' (hot / needs `/reload` / needs a new session or process restart). Wait for explicit confirmation. Change ONE plane at a time.',
|
|
44
|
+
'5. Apply with the normal `edit`/`write` tools (write locks apply), then run `/piher-config check` and report the result back.',
|
|
45
|
+
'6. If a change cannot take effect in the current process, say so and tell the user exactly what to restart.',
|
|
46
|
+
'',
|
|
47
|
+
'Forbidden: dumping `process.env`; writing secrets or tokens into any config file; editing multiple planes before confirmation;',
|
|
48
|
+
'touching `.piher/` of an untrusted project.',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
51
|
+
export interface ConfigPlaneFile {
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly label: string;
|
|
54
|
+
readonly exists: boolean;
|
|
55
|
+
readonly ignored?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ConfigGuideDeps {
|
|
59
|
+
cwd?: string;
|
|
60
|
+
env?: Record<string, string | undefined>;
|
|
61
|
+
isProjectTrusted?: boolean;
|
|
62
|
+
/** User-level roles dir override (defaults to ~/.pi/agent/piher/roles). */
|
|
63
|
+
rolesUserDir?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ConfigGuideSnapshot {
|
|
67
|
+
readonly entries: readonly ResolvedKnob[];
|
|
68
|
+
readonly reports: readonly CheckReport[];
|
|
69
|
+
readonly files: Readonly<Record<ConfigPlaneId, readonly ConfigPlaneFile[]>>;
|
|
70
|
+
readonly workspaceTrusted: boolean;
|
|
71
|
+
readonly roleSummary: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
function listJsonFiles(dir: string): string[] {
|
|
76
|
+
try {
|
|
77
|
+
return readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function collectConfigSnapshot(deps: ConfigGuideDeps = {}): ConfigGuideSnapshot {
|
|
84
|
+
const env = deps.env ?? process.env;
|
|
85
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
86
|
+
const workspaceTrusted = deps.isProjectTrusted ?? false;
|
|
87
|
+
|
|
88
|
+
const reports: CheckReport[] = [];
|
|
89
|
+
const files: Record<ConfigPlaneId, ConfigPlaneFile[]> = { roles: [], env: [] };
|
|
90
|
+
|
|
91
|
+
const envIssues = checkEnvKnobs(env);
|
|
92
|
+
files.env.push({ path: '(process environment)', label: 'env', exists: true });
|
|
93
|
+
|
|
94
|
+
/* roles: every layer's JSON must load; builtin names are intentionally shadow-proof */
|
|
95
|
+
const roleIssues: string[] = [];
|
|
96
|
+
const roleNames = new Set<string>();
|
|
97
|
+
const layerCounts: string[] = [];
|
|
98
|
+
for (const layer of roleLayers({ baseDir: cwd, userDir: deps.rolesUserDir })) {
|
|
99
|
+
const names = listJsonFiles(layer.dir);
|
|
100
|
+
const label: ConfigPlaneFile['label'] = layer.label.startsWith('workspace')
|
|
101
|
+
? 'workspace'
|
|
102
|
+
: layer.label.startsWith('user') ? 'user' : 'builtin';
|
|
103
|
+
files.roles.push({ path: layer.dir, label, exists: names.length > 0 });
|
|
104
|
+
layerCounts.push(`${label} ${names.length}`);
|
|
105
|
+
for (const file of names) roleNames.add(file.replace(/\.json$/, ''));
|
|
106
|
+
}
|
|
107
|
+
for (const name of [...roleNames].sort()) {
|
|
108
|
+
try {
|
|
109
|
+
loadRoleConfig(name, { baseDir: cwd });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const issues = (err as { issues?: readonly string[] }).issues ?? [];
|
|
112
|
+
roleIssues.push(`role "${name}": ${err instanceof Error ? err.message : String(err)}${issues.length ? ` — ${issues.join('; ')}` : ''}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const shadowed = listRoleNames(cwd, deps.rolesUserDir).filter((name) => RESERVED_ROLE_NAMES.includes(name));
|
|
116
|
+
if (shadowed.length > 0) {
|
|
117
|
+
roleIssues.push(`reserved role name(s) shadowed in a custom layer (ignored for built-ins): ${shadowed.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
const roleSummary = `${roleNames.size} role(s): ${layerCounts.join(' / ')}`;
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
reports.push(
|
|
123
|
+
{ plane: 'roles', ok: roleIssues.length === 0, issues: roleIssues },
|
|
124
|
+
{ plane: 'env', ok: envIssues.length === 0, issues: envIssues },
|
|
125
|
+
);
|
|
126
|
+
return { entries: resolveEnvKnobs(env), reports, files, workspaceTrusted, roleSummary };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
export type GuideView = 'index' | 'check' | ConfigPlaneId | 'all';
|
|
131
|
+
|
|
132
|
+
/** Renders one `/piher-config` view: the plane index, one/all plane listings, or the check report. */
|
|
133
|
+
export function renderGuide(snapshot: ConfigGuideSnapshot, view: GuideView): string[] {
|
|
134
|
+
if (view === 'index') {
|
|
135
|
+
const plane = (id: ConfigPlaneId): readonly ResolvedKnob[] => snapshot.entries.filter((e) => e.knob.plane === id);
|
|
136
|
+
return renderIndex({
|
|
137
|
+
env: plane('env'),
|
|
138
|
+
roleSummary: snapshot.roleSummary,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (view === 'check') {
|
|
142
|
+
const anyFail = snapshot.reports.some((r) => !r.ok);
|
|
143
|
+
return [
|
|
144
|
+
...renderCheck(snapshot.reports),
|
|
145
|
+
anyFail ? ' → fix the FAIL planes above (values are still shown by `show`)' : ' → all planes look consistent',
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
for (const meta of CONFIG_PLANES) {
|
|
151
|
+
if (view !== 'all' && meta.id !== view) continue;
|
|
152
|
+
out.push(`${meta.id} — ${meta.title}`);
|
|
153
|
+
for (const file of snapshot.files[meta.id]) {
|
|
154
|
+
const status = file.exists ? (file.ignored ? 'present (IGNORED: untrusted project)' : 'present') : 'absent';
|
|
155
|
+
out.push(` file [${file.label}] ${file.path} — ${status}`);
|
|
156
|
+
}
|
|
157
|
+
out.push(` hint: ${meta.editHint}`);
|
|
158
|
+
const entries = snapshot.entries.filter((e) => e.knob.plane === meta.id);
|
|
159
|
+
if (entries.length > 0) {
|
|
160
|
+
out.push(...renderPlane(entries));
|
|
161
|
+
} else {
|
|
162
|
+
const report = snapshot.reports.find((r) => r.plane === meta.id);
|
|
163
|
+
out.push(` (per-file plane: ${!report ? 'no data' : report.ok ? 'no issues' : `${report.issues.length} issue(s) — see /piher-config check`})`);
|
|
164
|
+
}
|
|
165
|
+
out.push('');
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function guideReportMarkdown(
|
|
171
|
+
snapshot: ConfigGuideSnapshot,
|
|
172
|
+
meta: { generatedAt: string; cwd: string; piVersion?: string },
|
|
173
|
+
): string {
|
|
174
|
+
const body = renderReport(snapshot.entries, {
|
|
175
|
+
generatedAt: meta.generatedAt,
|
|
176
|
+
cwd: meta.cwd,
|
|
177
|
+
workspaceTrusted: snapshot.workspaceTrusted,
|
|
178
|
+
piVersion: meta.piVersion,
|
|
179
|
+
});
|
|
180
|
+
const filesSection = ['', '## Files observed', ''];
|
|
181
|
+
for (const plane of CONFIG_PLANES) {
|
|
182
|
+
for (const file of snapshot.files[plane.id]) {
|
|
183
|
+
const status = file.exists ? (file.ignored ? 'present, ignored (untrusted project)' : 'present') : 'absent';
|
|
184
|
+
filesSection.push(`- [${plane.id}] ${file.label}: \`${file.path}\` — ${status}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const checkSection = ['', '## Checks', '', ...renderGuide(snapshot, 'check').map((l) => `- ${l.trim()}`)];
|
|
188
|
+
return `${body}\n${filesSection.join('\n')}\n${checkSection.join('\n')}\n`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface ConfigCommandDeps {
|
|
192
|
+
pi: ExtensionAPI;
|
|
193
|
+
/** Injectable for tests; defaults to collectConfigSnapshot. */
|
|
194
|
+
collect?: (deps: ConfigGuideDeps) => ConfigGuideSnapshot;
|
|
195
|
+
/** Overridable so tests can pin the report destination. */
|
|
196
|
+
reportDir?: (cwd: string) => string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const PLANE_IDS: readonly ConfigPlaneId[] = CONFIG_PLANES.map((p) => p.id);
|
|
200
|
+
const SUBCOMMANDS = ['show', 'check', 'doc', 'doctor'] as const;
|
|
201
|
+
|
|
202
|
+
function parseArgs(args: unknown): { sub: (typeof SUBCOMMANDS)[number] | 'index'; rest: string } {
|
|
203
|
+
const raw = typeof args === 'string'
|
|
204
|
+
? args.split(/\s+/).filter(Boolean)
|
|
205
|
+
: Array.isArray(args) ? args.map(String) : [];
|
|
206
|
+
const [first, ...rest] = raw;
|
|
207
|
+
const sub = (SUBCOMMANDS as readonly string[]).includes(first ?? '')
|
|
208
|
+
? (first as (typeof SUBCOMMANDS)[number])
|
|
209
|
+
: 'index';
|
|
210
|
+
// `show` accepts an optional plane; an unknown first token is treated as the plane selector for `show`.
|
|
211
|
+
const restJoined = sub === 'index' && first && first !== 'index' ? [first, ...rest].join(' ') : rest.join(' ');
|
|
212
|
+
return { sub, rest: restJoined };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Registers `/piher-config`. Safe to call once per extension load (pi replaces commands by name). */
|
|
216
|
+
export function installConfigCommand(deps: ConfigCommandDeps): void {
|
|
217
|
+
const { pi } = deps;
|
|
218
|
+
const collect = deps.collect ?? collectConfigSnapshot;
|
|
219
|
+
const reportDir = deps.reportDir ?? ((cwd: string) => join(cwd, '.piher'));
|
|
220
|
+
|
|
221
|
+
pi.registerCommand('piher-config', {
|
|
222
|
+
description:
|
|
223
|
+
'Show piher configuration (2 planes: roles, env) with effective values and sources; `check` validates them; `doctor` lists option values and swallowed errors; no argument hands a guided change to the agent',
|
|
224
|
+
getArgumentCompletions: (prefix: string) => {
|
|
225
|
+
const tokens = (prefix ?? '').split(/\s+/);
|
|
226
|
+
const head = tokens[0] ?? '';
|
|
227
|
+
if (tokens.length <= 1) {
|
|
228
|
+
return ['show', 'check', 'doc', 'doctor', 'all', ...PLANE_IDS]
|
|
229
|
+
.filter((c) => c.startsWith(head))
|
|
230
|
+
.map((c) => ({ value: c, label: c, description: c === 'doc' ? 'write a config report file' : undefined }));
|
|
231
|
+
}
|
|
232
|
+
if (head === 'show') {
|
|
233
|
+
const last = tokens[tokens.length - 1] ?? '';
|
|
234
|
+
return ['all', ...PLANE_IDS].filter((c) => c.startsWith(last)).map((c) => ({ value: c, label: c }));
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
},
|
|
238
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
239
|
+
const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
|
|
240
|
+
const emit = (text: string, level: 'info' | 'warning' | 'error' = 'info'): void => {
|
|
241
|
+
if (ui?.notify) ui.notify(text, level);
|
|
242
|
+
else console.log(text); // print/json and test modes have no UI surface
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
246
|
+
const isProjectTrusted =
|
|
247
|
+
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
|
|
248
|
+
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
249
|
+
: false;
|
|
250
|
+
|
|
251
|
+
let snapshot: ConfigGuideSnapshot;
|
|
252
|
+
try {
|
|
253
|
+
snapshot = collect({ cwd, isProjectTrusted });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
emit(`piher-config: failed to read configuration (${err instanceof Error ? err.message : String(err)})`, 'error');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const { sub, rest } = parseArgs(args);
|
|
260
|
+
|
|
261
|
+
if (sub === 'check') {
|
|
262
|
+
emit([`piher config check (workspace trusted: ${snapshot.workspaceTrusted})`, ...renderGuide(snapshot, 'check')].join('\n'));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (sub === 'doc') {
|
|
267
|
+
const target = rest ? (isAbsolute(rest) ? rest : resolve(cwd, rest)) : join(reportDir(cwd), 'config-report.md');
|
|
268
|
+
try {
|
|
269
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
270
|
+
writeFileSync(
|
|
271
|
+
target,
|
|
272
|
+
guideReportMarkdown(snapshot, { generatedAt: new Date().toISOString(), cwd, piVersion }),
|
|
273
|
+
'utf8',
|
|
274
|
+
);
|
|
275
|
+
emit(
|
|
276
|
+
`piher config report written: ${target}`
|
|
277
|
+
+ (rest ? '' : ' (add `.piher/config-report.md` to .gitignore if you do not want it tracked)'),
|
|
278
|
+
);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
emit(`piher-config: could not write report (${err instanceof Error ? err.message : String(err)})`, 'error');
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (sub === 'doctor') {
|
|
286
|
+
// B9/B10: one place for every piher option (canonical name, effective value, source) and the
|
|
287
|
+
// errors deliberately swallowed this session — otherwise a failing best-effort path stays invisible.
|
|
288
|
+
emit([
|
|
289
|
+
'piher doctor',
|
|
290
|
+
'',
|
|
291
|
+
`wire: herdr-core ${CORE_VERSION_PINNED} (pinned by test/fixtures/hc-contract.json)`,
|
|
292
|
+
'',
|
|
293
|
+
'options (PIHER_*):',
|
|
294
|
+
...formatOptionRows(),
|
|
295
|
+
'',
|
|
296
|
+
formatSwallowedErrors(),
|
|
297
|
+
].join('\n'));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (sub === 'show') {
|
|
302
|
+
const plane = rest.trim();
|
|
303
|
+
const selector = plane === '' || plane === 'all' ? 'all' : PLANE_IDS.find((id) => id === plane);
|
|
304
|
+
if (!selector) {
|
|
305
|
+
emit(`piher-config: unknown plane "${plane}" — expected one of ${PLANE_IDS.join(', ')} or all`, 'warning');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
emit(renderGuide(snapshot, selector).join('\n'));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
emit([...renderGuide(snapshot, 'index'), '', 'asking the agent to guide the change...'].join('\n'));
|
|
313
|
+
try {
|
|
314
|
+
// Call on the receiver: the ExtensionAPI method must not be detached from `pi`.
|
|
315
|
+
(pi as { sendMessage?: (msg: unknown, opts: unknown) => void }).sendMessage?.(
|
|
316
|
+
{ customType: 'piher.config-guide', content: CONFIG_GUIDANCE_PROMPT, display: false },
|
|
317
|
+
{ triggerTurn: true },
|
|
318
|
+
);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
emit(`piher-config: could not start the guided flow (${err instanceof Error ? err.message : String(err)})`, 'warning');
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { BgJobDeps } from './plugins/bg-job.ts';
|
|
12
|
+
import type { SubagentDeps } from './plugins/subagent.ts';
|
|
13
|
+
|
|
14
|
+
export interface PiherCordisServices {
|
|
15
|
+
'piher.ledger': DisposeLedger;
|
|
16
|
+
'piher.surface': PiSurface<object>;
|
|
17
|
+
'piher.bg-job-deps': BgJobDeps;
|
|
18
|
+
'piher.subagent-deps': SubagentDeps;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module '@deepseek-ai/cordis' {
|
|
22
|
+
interface Context extends PiherCordisServices {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cordisProvide<K extends keyof PiherCordisServices>(
|
|
26
|
+
ctx: Context,
|
|
27
|
+
key: K,
|
|
28
|
+
value: PiherCordisServices[K],
|
|
29
|
+
): void {
|
|
30
|
+
ctx.provide(key, value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Throws when the root has not provided `key`. `ctx.get` itself returns `T | undefined`. */
|
|
34
|
+
export function cordisGet<K extends keyof PiherCordisServices>(ctx: Context, key: K): PiherCordisServices[K] {
|
|
35
|
+
const value = ctx.get(key) as PiherCordisServices[K] | undefined;
|
|
36
|
+
if (value === undefined) throw new Error(`[piher] cordis service missing: ${key}`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|