pi-profile-switch 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +32 -32
  2. package/README.zh-CN.md +32 -32
  3. package/bin/pi-profile.js +11 -0
  4. package/bin/pi-profile.ts +65 -0
  5. package/bin/postinstall.d.ts +13 -0
  6. package/bin/postinstall.js +88 -0
  7. package/defaults/profiles.json +18 -0
  8. package/examples/profiles.json +31 -5
  9. package/extensions/pi-profile/index.ts +451 -0
  10. package/package.json +10 -17
  11. package/schemas/profiles.schema.json +22 -24
  12. package/src/extension-discovery.ts +347 -0
  13. package/src/json-file.ts +1 -21
  14. package/src/launcher/args.ts +57 -0
  15. package/src/launcher/discovery.ts +64 -0
  16. package/src/launcher/initial-profile.ts +179 -0
  17. package/src/launcher/model-check.ts +52 -0
  18. package/src/launcher/runtime-cleanup.ts +85 -0
  19. package/src/launcher/spawn.ts +82 -0
  20. package/src/mcp-config.ts +37 -153
  21. package/src/mcp-coordination.ts +29 -10
  22. package/src/profile-catalog-store.ts +31 -12
  23. package/src/profile-catalog.ts +44 -63
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -35
  27. package/src/settings-generator.ts +541 -0
  28. package/src/skill-registry.ts +94 -0
  29. package/src/switching/apply-plan.ts +197 -0
  30. package/src/switching/customize.ts +62 -33
  31. package/src/switching/list-profiles.ts +9 -6
  32. package/src/switching/mcp-toggle.ts +21 -25
  33. package/src/switching/profile-crud.ts +31 -24
  34. package/src/switching/profile-wizard.ts +21 -49
  35. package/src/switching/status.ts +142 -72
  36. package/src/switching/switch-profile.ts +219 -0
  37. package/src/switching/tool-references.ts +40 -0
  38. package/src/workspace.ts +57 -0
  39. package/LICENSE +0 -21
  40. package/examples/profiles.example.json +0 -74
  41. package/extensions/pi-profile-switch/index.ts +0 -744
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/mcp-overlay-file.ts +0 -35
  44. package/src/mcp-overlay.ts +0 -122
  45. package/src/model-selection.ts +0 -64
  46. package/src/name-matching.ts +0 -50
  47. package/src/profile-badge.ts +0 -142
  48. package/src/profile-presets.ts +0 -61
  49. package/src/skill-selection.ts +0 -81
  50. package/src/startup-mcp-scope.ts +0 -262
  51. package/src/startup-selection.ts +0 -144
  52. package/src/switching/activate-profile.ts +0 -115
  53. package/src/switching/apply-profile.ts +0 -131
@@ -16,30 +16,34 @@
16
16
 
17
17
  import path from "node:path";
18
18
 
19
+ import { getGlobalProfilesPath } from "../workspace.ts";
20
+ import { readTrustInputs } from "../launcher/initial-profile.ts";
19
21
  import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
20
22
  import { ProfileCatalogStore } from "../profile-catalog-store.ts";
21
23
 
22
24
  export type CatalogScope = "global" | "project";
23
25
 
24
- /** The caller's trust decision (`ctx.isProjectTrusted()`) plus the two
25
- * directories scope files live in. */
26
- export interface CatalogInput {
27
- realAgentDir: string;
28
- cwd: string;
29
- projectTrusted: boolean;
30
- }
31
-
32
26
  /** The store for one scope's catalog file — the ONLY place scope-file
33
27
  * paths are constructed. Callers must still trust-gate project access
34
28
  * (`requireScope` / `readCatalogScope`). */
35
- export function catalogStore(input: CatalogInput, scope: CatalogScope): ProfileCatalogStore {
36
- return new ProfileCatalogStore(
37
- scope === "global" ? path.join(input.realAgentDir, "profiles.json") : path.join(input.cwd, ".pi", "profiles.json"),
38
- );
29
+ export function catalogStore(
30
+ input: { realAgentDir: string; cwd: string },
31
+ scope: CatalogScope,
32
+ ): ProfileCatalogStore {
33
+ if (scope === "global") {
34
+ return new ProfileCatalogStore(
35
+ getGlobalProfilesPath(),
36
+ path.join(input.realAgentDir, "profiles.json")
37
+ );
38
+ }
39
+ return new ProfileCatalogStore(path.join(input.cwd, ".pi", "profiles.json"));
39
40
  }
40
41
 
41
- function requireScope(input: CatalogInput, scope: CatalogScope): void {
42
- if (scope === "project" && !input.projectTrusted) {
42
+ async function requireScope(input: { realAgentDir: string; cwd: string }, scope: CatalogScope): Promise<void> {
43
+ if (
44
+ scope === "project" &&
45
+ !(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
46
+ ) {
43
47
  throw new CatalogError(`project catalog is unavailable: ${input.cwd} is not trusted`);
44
48
  }
45
49
  }
@@ -47,10 +51,13 @@ function requireScope(input: CatalogInput, scope: CatalogScope): void {
47
51
  /** Reads one scope's catalog with the trust gate applied — project reads
48
52
  * return empty when untrusted (never touching the file). */
49
53
  export async function readCatalogScope(
50
- input: CatalogInput,
54
+ input: { realAgentDir: string; cwd: string },
51
55
  scope: CatalogScope,
52
56
  ): Promise<Map<string, ProfileDefinition>> {
53
- if (scope === "project" && !input.projectTrusted) {
57
+ if (
58
+ scope === "project" &&
59
+ !(await readTrustInputs({ agentDir: input.realAgentDir, cwd: input.cwd })).projectTrusted
60
+ ) {
54
61
  return new Map();
55
62
  }
56
63
  return catalogStore(input, scope).readDefinitions();
@@ -58,12 +65,12 @@ export async function readCatalogScope(
58
65
 
59
66
  /** Creates a complete definition in the chosen scope. */
60
67
  export async function createProfile(
61
- input: CatalogInput,
68
+ input: { realAgentDir: string; cwd: string },
62
69
  scope: CatalogScope,
63
70
  name: string,
64
71
  definition: ProfileDefinition,
65
72
  ): Promise<void> {
66
- requireScope(input, scope);
73
+ await requireScope(input, scope);
67
74
  const store = catalogStore(input, scope);
68
75
  if ((await store.readDefinitions()).has(name)) {
69
76
  throw new CatalogError(`profile "${name}" already exists in the ${scope} catalog`);
@@ -73,12 +80,12 @@ export async function createProfile(
73
80
 
74
81
  /** Replaces a complete definition; the caller reloads iff it is active. */
75
82
  export async function editProfile(
76
- input: CatalogInput,
83
+ input: { realAgentDir: string; cwd: string },
77
84
  scope: CatalogScope,
78
85
  name: string,
79
86
  definition: ProfileDefinition,
80
87
  ): Promise<void> {
81
- requireScope(input, scope);
88
+ await requireScope(input, scope);
82
89
  if (name === DEFAULT_PROFILE_NAME) {
83
90
  throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be edited`);
84
91
  }
@@ -96,12 +103,12 @@ export async function editProfile(
96
103
  * deleted.
97
104
  */
98
105
  export async function deleteProfile(
99
- input: CatalogInput,
106
+ input: { realAgentDir: string; cwd: string },
100
107
  scope: CatalogScope,
101
108
  name: string,
102
109
  options: { activeProfile?: string; replacement?: string },
103
110
  ): Promise<void> {
104
- requireScope(input, scope);
111
+ await requireScope(input, scope);
105
112
  if (name === DEFAULT_PROFILE_NAME) {
106
113
  throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
107
114
  }
@@ -113,12 +120,12 @@ export async function deleteProfile(
113
120
 
114
121
  /** Copies a complete definition under a new, unused name. */
115
122
  export async function duplicateProfile(
116
- input: CatalogInput,
123
+ input: { realAgentDir: string; cwd: string },
117
124
  scope: CatalogScope,
118
125
  sourceName: string,
119
126
  newName: string,
120
127
  ): Promise<void> {
121
- requireScope(input, scope);
128
+ await requireScope(input, scope);
122
129
  const store = catalogStore(input, scope);
123
130
  const definitions = await store.readDefinitions();
124
131
  const source = definitions.get(sourceName);
@@ -8,14 +8,9 @@
8
8
  * keeps the current value (prefill via placeholder); there is no
9
9
  * field-clearing gesture (delete + create instead). Any cancelled step
10
10
  * aborts the wizard — nothing is written.
11
- *
12
- * Create can start from a shipped preset (src/profile-presets.ts). A preset is
13
- * prefilled form state only: the wizard copies its complete definition into the
14
- * chosen catalog, and the new profile belongs to the user from then on.
15
11
  */
16
12
 
17
13
  import type { ProfileDefinition } from "../profile-catalog.ts";
18
- import { PROFILE_PRESETS, type ProfilePreset } from "../profile-presets.ts";
19
14
  import type { CatalogScope } from "./profile-crud.ts";
20
15
 
21
16
  export interface ProfileWizardUi {
@@ -27,8 +22,6 @@ export interface ProfileWizardResult {
27
22
  scope: CatalogScope;
28
23
  name: string;
29
24
  definition: ProfileDefinition;
30
- /** Name of the preset this definition was copied from, when any. */
31
- preset?: string;
32
25
  }
33
26
 
34
27
  interface ExistingProfile {
@@ -45,7 +38,7 @@ function parseList(raw: string): string[] {
45
38
  }
46
39
 
47
40
  /** "provider/id[/thinkingLevel]" → ProfileModel; empty/undefined → none. */
48
- function parseModel(raw: string): ProfileDefinition["model"] | undefined {
41
+ function parseModel(raw: string): { defaultProvider: string; defaultModel: string; defaultThinkingLevel?: string } | undefined {
49
42
  const trimmed = raw.trim();
50
43
  if (trimmed.length === 0) return undefined;
51
44
  const [provider, id, thinkingLevel] = trimmed.split("/").map((part) => part.trim());
@@ -53,8 +46,8 @@ function parseModel(raw: string): ProfileDefinition["model"] | undefined {
53
46
  return undefined;
54
47
  }
55
48
  return thinkingLevel !== undefined && thinkingLevel.length > 0
56
- ? { provider, id, thinkingLevel }
57
- : { provider, id };
49
+ ? { defaultProvider: provider, defaultModel: id, defaultThinkingLevel: thinkingLevel }
50
+ : { defaultProvider: provider, defaultModel: id };
58
51
  }
59
52
 
60
53
  async function captureDefinition(
@@ -74,8 +67,9 @@ async function captureDefinition(
74
67
  else if (existing?.description !== undefined) definition.description = existing.description;
75
68
 
76
69
  const listFields = [
77
- ["skills", "skills (comma-separated names or globs, empty = all visible)"],
78
- ["mcp", "mcp servers (names or globs, empty = none)"],
70
+ ["skills", "skills (comma-separated names or globs, empty = none)"],
71
+ ["extensions", "extensions (names or globs, empty = none)"],
72
+ ["mcps", "mcp servers (names or globs, empty = none)"],
79
73
  ["tools", "tools (names or globs, empty = pi default set)"],
80
74
  ] as const;
81
75
  for (const [field, prompt] of listFields) {
@@ -100,64 +94,42 @@ async function captureDefinition(
100
94
  else if (existing?.instructions !== undefined) definition.instructions = existing.instructions;
101
95
 
102
96
  const currentModel =
103
- existing?.model !== undefined
104
- ? `${existing.model.provider}/${existing.model.id}${existing.model.thinkingLevel !== undefined ? `/${existing.model.thinkingLevel}` : ""}`
97
+ existing?.defaultProvider !== undefined && existing?.defaultModel !== undefined
98
+ ? `${existing.defaultProvider}/${existing.defaultModel}${existing.defaultThinkingLevel !== undefined ? `/${existing.defaultThinkingLevel}` : ""}`
105
99
  : undefined;
106
100
  const modelRaw = await ui.input("model provider/id[/thinking] (empty = none)", currentModel);
107
101
  if (modelRaw === undefined) return undefined;
108
102
  const model = parseModel(modelRaw);
109
103
  if (model !== undefined) {
110
- definition.model = model;
111
- } else if (existing?.model !== undefined && modelRaw.trim().length === 0) {
112
- definition.model = existing.model;
104
+ definition.defaultProvider = model.defaultProvider;
105
+ definition.defaultModel = model.defaultModel;
106
+ if (model.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = model.defaultThinkingLevel;
107
+ } else if (existing?.defaultProvider !== undefined && modelRaw.trim().length === 0) {
108
+ definition.defaultProvider = existing.defaultProvider;
109
+ definition.defaultModel = existing.defaultModel;
110
+ if (existing.defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = existing.defaultThinkingLevel;
113
111
  }
114
112
 
115
113
  return definition;
116
114
  }
117
115
 
118
- const BLANK_OPTION = "blank — start from an empty definition";
119
-
120
- /** One preset row. The caller identifies the preset by the option's INDEX, so
121
- * a preset named like another row's text cannot be misread. */
122
- function presetOption(preset: ProfilePreset): string {
123
- const hint = preset.definition.description ?? preset.definition.label;
124
- return hint !== undefined ? `${preset.name} — ${hint}` : preset.name;
125
- }
126
-
127
- /** Create: scope, optional preset, name, then fields. */
116
+ /** Create: scope first (project only when trusted), then name, then fields. */
128
117
  export async function runProfileCreateWizard(
129
118
  ui: ProfileWizardUi,
130
- input: { projectTrusted: boolean; presets?: readonly ProfilePreset[] },
119
+ input: { projectTrusted: boolean },
131
120
  ): Promise<ProfileWizardResult | undefined> {
132
121
  const scopeOptions = input.projectTrusted ? ["global", "project"] : ["global"];
133
122
  // A single available scope needs no dialog.
134
123
  const scope = scopeOptions.length === 1 ? scopeOptions[0] : await ui.select("write to which catalog?", scopeOptions);
135
124
  if (scope === undefined) return undefined;
136
125
 
137
- const presets = input.presets ?? PROFILE_PRESETS;
138
- let preset: ProfilePreset | undefined;
139
- if (presets.length > 0) {
140
- const options = [BLANK_OPTION, ...presets.map(presetOption)];
141
- const chosen = await ui.select("start from which preset?", options);
142
- if (chosen === undefined) return undefined;
143
- preset = presets[options.indexOf(chosen) - 1];
144
- }
145
-
146
- // An empty answer takes the preset's name, so Enter accepts the preset.
147
- const name = await ui.input("profile name", preset?.name);
148
- if (name === undefined) return undefined;
149
- const resolvedName = name.trim().length > 0 ? name.trim() : preset?.name;
150
- if (resolvedName === undefined) return undefined;
126
+ const name = await ui.input("profile name");
127
+ if (name === undefined || name.trim().length === 0) return undefined;
151
128
 
152
- const definition = await captureDefinition(ui, preset?.definition);
129
+ const definition = await captureDefinition(ui);
153
130
  if (definition === undefined) return undefined;
154
131
 
155
- return {
156
- scope: scope as CatalogScope,
157
- name: resolvedName,
158
- definition,
159
- ...(preset !== undefined ? { preset: preset.name } : {}),
160
- };
132
+ return { scope: scope as CatalogScope, name: name.trim(), definition };
161
133
  }
162
134
 
163
135
  /** Edit: fields prefilled from the existing complete definition. */
@@ -1,74 +1,139 @@
1
1
  /**
2
- * StatusReport: the `/profile status` observability surface.
2
+ * StatusReport: the `/profile status` observability surface (ticket 07).
3
3
  *
4
- * Pure report builder: combines the ACTIVE selection (what the runtime was
5
- * resolved to), the stored overlay, and fresh MCP discovery. Markdown
6
- * formatting is the only presentation; the extension ships it via
7
- * `pi.sendMessage`.
4
+ * Pure report builder: combines the ACTIVE launch plan (what the runtime
5
+ * was resolved to), the stored overlay, fresh MCP adapter discovery, and
6
+ * Pi's actual command registrations (the winner evidence for same-name
7
+ * conflicts). Markdown formatting is the only presentation; the extension
8
+ * ships it via `pi.sendMessage`.
9
+ *
10
+ * Conflict semantics: Pi's load order is first-wins by scope/file order,
11
+ * so the registered command IS the winner. A conflict is reported when the
12
+ * plan resolved a skill whose command name is registered from a DIFFERENT
13
+ * path (shadowed) or is absent (failed to load) — never blocked, always
14
+ * visible.
8
15
  */
9
16
 
10
- import type { ProfileSource } from "../profile-catalog.ts";
11
- import type { ResolvedSelection, UnresolvedRef } from "../profile-resolver.ts";
12
17
  import type { RuntimeOverlay } from "../runtime-state-store.ts";
13
- import { visibleSkillNames, type SkillsFilterOutcome } from "../skill-selection.ts";
18
+ import type { LaunchPlanFile } from "./apply-plan.ts";
19
+
20
+ export interface StatusConflict {
21
+ /** Command name as registered (e.g. `skill:review`). */
22
+ name: string;
23
+ /** The path the active plan resolved. */
24
+ expectedPath: string;
25
+ /** The path Pi actually registered (the winner), or "not loaded". */
26
+ winnerPath: string;
27
+ }
14
28
 
15
29
  export interface StatusReport {
16
30
  profile: string;
17
- source: ProfileSource;
31
+ source: string;
18
32
  overlay?: RuntimeOverlay;
19
- /** The skills the model can see (all loaded skills when unfiltered). */
20
- skills: {
21
- filtered: boolean;
22
- visible: Array<{ name: string; filePath: string }>;
23
- loaded: number;
24
- /** The last prompt-filter result; `no-filter` when the profile
25
- * selects nothing. */
26
- filterOutcome: SkillsFilterOutcome;
27
- };
28
- tools?: { active: string[]; pending: string[] };
29
- mcp: { enabled: string[]; discovered: string[]; missing: string[] };
30
- unresolved: { skills: UnresolvedRef[]; unmatched: string[] };
33
+ skills: Array<{ name: string; filePath: string }>;
34
+ extensions: Array<{ id: string; entry: string; origin?: string }>;
35
+ tools?: string[];
36
+ mcp: { enabled: string[]; disabled: string[]; missing: string[] };
37
+ /** Glob delta versus the previous activation (prefixed names). */
38
+ delta?: { added: string[]; removed: string[] };
39
+ /** Glob references that matched nothing at resolution (ADR-0006). */
40
+ unmatched?: string[];
41
+ conflicts: StatusConflict[];
42
+ }
43
+
44
+ interface RegisteredCommand {
45
+ name: string;
46
+ sourceInfo?: { path: string };
47
+ }
48
+
49
+ interface RegisteredTool {
50
+ name: string;
51
+ sourceInfo?: { path: string; source: string };
52
+ }
53
+
54
+ function currentNames(plan: LaunchPlanFile): string[] {
55
+ const names = [
56
+ ...(plan.resolved?.skills ?? []).map((skill) => `skill:${skill.name}`),
57
+ ...(plan.resolved?.extensions ?? []).map((entry) => `extension:${entry.id}`),
58
+ ...(plan.tools ?? []).map((tool) => `tool:${tool}`),
59
+ ...(plan.mcps ?? []).map((server) => `mcp:${server}`),
60
+ ];
61
+ return names.sort();
31
62
  }
32
63
 
33
64
  export function buildStatusReport(input: {
34
- selection: ResolvedSelection;
35
- allSkills: Array<{ name: string; filePath: string }>;
65
+ plan: LaunchPlanFile;
66
+ overlay?: RuntimeOverlay;
36
67
  discoveredMcpServers: string[];
37
- filterOutcome?: SkillsFilterOutcome;
68
+ commands: RegisteredCommand[];
69
+ tools: RegisteredTool[];
38
70
  }): StatusReport {
39
- const { selection } = input;
40
- const visibleNames = visibleSkillNames(input.allSkills, selection.skills);
41
- const visible =
42
- visibleNames === undefined
43
- ? input.allSkills
44
- : input.allSkills.filter((skill) => visibleNames.includes(skill.name));
71
+ const { plan } = input;
45
72
 
46
- const enabled = selection.mcp ?? [];
73
+ const enabled = plan.mcps ?? [];
47
74
  const discovered = new Set(input.discoveredMcpServers);
48
- const unmatched = [
49
- ...selection.warnings.skillsUnmatched,
50
- ...selection.warnings.mcpUnmatched,
51
- ...selection.warnings.toolsUnmatched,
52
- ];
75
+ const mcp = {
76
+ enabled,
77
+ disabled: input.discoveredMcpServers.filter((name) => !enabled.includes(name)),
78
+ missing: enabled.filter((name) => !discovered.has(name)),
79
+ };
80
+
81
+ let delta: StatusReport["delta"];
82
+ if (plan.previousResolved !== undefined) {
83
+ const before = new Set(
84
+ [
85
+ ...plan.previousResolved.skills.map((name) => `skill:${name}`),
86
+ ...plan.previousResolved.extensions.map((id) => `extension:${id}`),
87
+ ...(plan.previousResolved.tools ?? []).map((name) => `tool:${name}`),
88
+ ...(plan.previousResolved.mcps ?? []).map((name) => `mcp:${name}`),
89
+ ].sort(),
90
+ );
91
+ const after = new Set(currentNames(plan));
92
+ const added = [...after].filter((name) => !before.has(name));
93
+ const removed = [...before].filter((name) => !after.has(name));
94
+ if (added.length > 0 || removed.length > 0) {
95
+ delta = { added, removed };
96
+ }
97
+ }
98
+
99
+ const conflicts: StatusConflict[] = [];
100
+ for (const skill of plan.resolved?.skills ?? []) {
101
+ const command = input.commands.find((entry) => entry.name === `skill:${skill.name}`);
102
+ const winnerPath = command?.sourceInfo?.path;
103
+ if (winnerPath === undefined) {
104
+ conflicts.push({ name: `skill:${skill.name}`, expectedPath: skill.filePath, winnerPath: "not loaded" });
105
+ } else if (winnerPath !== skill.filePath) {
106
+ conflicts.push({ name: `skill:${skill.name}`, expectedPath: skill.filePath, winnerPath });
107
+ }
108
+ }
109
+
110
+ // Tool conflicts: a plan tool whose registered winner is neither a pi
111
+ // builtin nor a tool from one of the plan's selected extensions was
112
+ // shadowed by (or shadows) an unexpected source.
113
+ const extensionDirs = (plan.resolved?.extensions ?? []).map((entry) =>
114
+ entry.entry.endsWith(".ts") ? entry.entry.slice(0, entry.entry.lastIndexOf("/")) : entry.entry,
115
+ );
116
+ for (const toolName of plan.tools ?? []) {
117
+ const winner = input.tools.find((entry) => entry.name === toolName);
118
+ const info = winner?.sourceInfo;
119
+ if (info === undefined) continue; // unknown names are dropped by pi.setActiveTools
120
+ const expected = info.source === "builtin" || extensionDirs.some((dir) => info.path.startsWith(dir));
121
+ if (!expected) {
122
+ conflicts.push({ name: `tool:${toolName}`, expectedPath: "builtin or selected extension", winnerPath: info.path });
123
+ }
124
+ }
53
125
 
54
126
  return {
55
- profile: selection.name,
56
- source: selection.source,
57
- skills: {
58
- filtered: selection.skills !== undefined,
59
- visible,
60
- loaded: input.allSkills.length,
61
- filterOutcome: input.filterOutcome ?? (selection.skills === undefined ? "no-filter" : "filtered"),
62
- },
63
- ...(selection.tools !== undefined
64
- ? { tools: { active: selection.tools, pending: selection.pendingTools } }
65
- : {}),
66
- mcp: {
67
- enabled,
68
- discovered: input.discoveredMcpServers,
69
- missing: enabled.filter((name) => !discovered.has(name)),
70
- },
71
- unresolved: { skills: selection.warnings.skillsUnresolved, unmatched },
127
+ profile: plan.profile,
128
+ source: plan.source,
129
+ overlay: input.overlay,
130
+ skills: plan.resolved?.skills ?? [],
131
+ extensions: plan.resolved?.extensions ?? [],
132
+ ...(plan.tools !== undefined ? { tools: plan.tools } : {}),
133
+ mcp,
134
+ ...(delta !== undefined ? { delta } : {}),
135
+ ...(plan.unmatched !== undefined && plan.unmatched.length > 0 ? { unmatched: plan.unmatched } : {}),
136
+ conflicts,
72
137
  };
73
138
  }
74
139
 
@@ -78,36 +143,41 @@ export function formatStatusMarkdown(report: StatusReport): string {
78
143
  if (report.overlay !== undefined) {
79
144
  const parts = [
80
145
  ...(report.overlay.disabledSkills ?? []).map((name) => `-skill:${name}`),
81
- ...(report.overlay.disabledMcp ?? []).map((name) => `-mcp:${name}`),
146
+ ...(report.overlay.disabledExtensions ?? []).map((id) => `-extension:${id}`),
147
+ ...(report.overlay.disabledMcps ?? []).map((name) => `-mcp:${name}`),
82
148
  ...(report.overlay.tools !== undefined ? [`tools=[${report.overlay.tools.join(", ")}]`] : []),
83
149
  ];
84
150
  lines.push(`overlay: ${parts.length > 0 ? parts.join(" ") : "(empty)"}`);
85
151
  }
86
- const skillScope = report.skills.filtered
87
- ? `${report.skills.visible.length} of ${report.skills.loaded} loaded`
88
- : `all ${report.skills.loaded} loaded`;
89
- lines.push(`skills: ${skillScope}`);
90
- if (report.skills.filtered && report.skills.filterOutcome !== "filtered") {
91
- lines.push(`skills filter: not applied (${report.skills.filterOutcome})`);
152
+ if (report.skills.length > 0) {
153
+ lines.push("skills:");
154
+ for (const skill of report.skills) {
155
+ lines.push(` ${skill.name} → ${skill.filePath}`);
156
+ }
92
157
  }
93
- for (const skill of report.skills.visible) {
94
- lines.push(` ${skill.name} → ${skill.filePath}`);
158
+ if (report.extensions.length > 0) {
159
+ lines.push("extensions:");
160
+ for (const extension of report.extensions) {
161
+ const originTag = extension.origin !== undefined ? ` [${extension.origin}]` : "";
162
+ lines.push(` ${extension.id}${originTag} → ${extension.entry}`);
163
+ }
95
164
  }
96
165
  if (report.tools !== undefined) {
97
- lines.push(`tools: [${report.tools.active.join(", ")}]`);
98
- if (report.tools.pending.length > 0) {
99
- lines.push(`tools pending (not registered yet): [${report.tools.pending.join(", ")}]`);
100
- }
166
+ lines.push(`tools: [${report.tools.join(", ")}]`);
101
167
  }
102
168
  lines.push(
103
- `mcp: enabled=[${report.mcp.enabled.join(", ")}] discovered=[${report.mcp.discovered.join(", ")}] missing=[${report.mcp.missing.join(", ")}]`,
169
+ `mcp: enabled=[${report.mcp.enabled.join(", ")}] disabled=[${report.mcp.disabled.join(", ")}] missing=[${report.mcp.missing.join(", ")}]`,
104
170
  );
105
- for (const unresolved of report.unresolved.skills) {
106
- const hint = unresolved.suggestions.length > 0 ? ` — did you mean: ${unresolved.suggestions.join(", ")}?` : "";
107
- lines.push(`unresolved skill: ${unresolved.reference}${hint}`);
171
+ if (report.delta !== undefined) {
172
+ lines.push(`delta: +[${report.delta.added.join(", ")}] -[${report.delta.removed.join(", ")}]`);
173
+ }
174
+ if (report.unmatched !== undefined) {
175
+ lines.push(`unmatched (zero-match globs this resolution): [${report.unmatched.join(", ")}]`);
108
176
  }
109
- if (report.unresolved.unmatched.length > 0) {
110
- lines.push(`zero-match globs: [${report.unresolved.unmatched.join(", ")}]`);
177
+ for (const conflict of report.conflicts) {
178
+ lines.push(
179
+ `conflict: ${conflict.name} — plan resolved ${conflict.expectedPath}, Pi registered ${conflict.winnerPath} (Pi first-wins load order)`,
180
+ );
111
181
  }
112
182
  return lines.join("\n");
113
183
  }