pi-monofold 0.6.2 → 0.8.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/focus-preset.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type MonofoldRouteType, parseDefaultRouteOverride } from "./focus-route-override.js";
2
+ import { parseFocusSkills, resetFocusSkillsWarningState } from "./focus-skills.js";
1
3
  import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
2
4
 
3
5
  export type FocusPresetTarget = {
@@ -8,13 +10,15 @@ export type FocusPreset = {
8
10
  id: string;
9
11
  label: string;
10
12
  targets: FocusPresetTarget[];
13
+ focusSkills?: string[];
14
+ defaultRouteOverride?: MonofoldRouteType;
11
15
  };
12
16
 
13
17
  export type FocusMatchableWorkspace = {
14
18
  tags: string[];
15
19
  };
16
20
 
17
- const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets"]);
21
+ const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets", "focusSkills", "defaultRouteOverride"]);
18
22
  const FOCUS_PRESET_TARGET_KEYS = new Set(["targetTags"]);
19
23
 
20
24
  /** Parses and validates focus preset configuration from YAML/JSON input. */
@@ -49,9 +53,17 @@ export function parseFocusPresets(value: unknown, label = "focusPresets"): Focus
49
53
  }
50
54
  targets.push({ targetTags });
51
55
  }
56
+ const focusSkills = parseFocusSkills(itemLabel, item.focusSkills);
57
+ const defaultRouteOverride = parseDefaultRouteOverride(itemLabel, item.defaultRouteOverride);
52
58
  if (seenIds.has(item.id)) throw new Error(`${label} has duplicate preset id: ${item.id}`);
53
59
  seenIds.add(item.id);
54
- presets.push({ id: item.id, label: item.label, targets });
60
+ presets.push({
61
+ id: item.id,
62
+ label: item.label,
63
+ targets,
64
+ ...(focusSkills !== undefined ? { focusSkills } : {}),
65
+ ...(defaultRouteOverride !== undefined ? { defaultRouteOverride } : {}),
66
+ });
55
67
  }
56
68
  return presets;
57
69
  }
@@ -118,6 +130,7 @@ export function setActiveFocusPresetId(id: string, focusPresets: FocusPreset[] |
118
130
  }
119
131
  activeFocusPresetId = id;
120
132
  activeFocusInitialized = true;
133
+ resetFocusSkillsWarningState();
121
134
  }
122
135
 
123
136
  export type ActiveFocusPresetPosition = {
@@ -177,12 +190,14 @@ export function cycleActiveFocusPresetForward(focusPresets: FocusPreset[] | unde
177
190
  export function clearActiveFocusPresetId(): void {
178
191
  activeFocusPresetId = null;
179
192
  activeFocusInitialized = true;
193
+ resetFocusSkillsWarningState();
180
194
  }
181
195
 
182
196
  /** Resets in-memory session state (for tests and process restart). */
183
197
  export function resetActiveFocusSessionState(): void {
184
198
  activeFocusPresetId = null;
185
199
  activeFocusInitialized = false;
200
+ resetFocusSkillsWarningState();
186
201
  }
187
202
 
188
203
  export type TagBasedTargetInput = {
@@ -0,0 +1,44 @@
1
+ /** Monofold route types available for routed Markdown writes. */
2
+ export const MONOFOLD_ROUTE_TYPES = [
3
+ "default",
4
+ "prd",
5
+ "design",
6
+ "progress",
7
+ "issue",
8
+ "research",
9
+ "decision",
10
+ ] as const;
11
+
12
+ export type MonofoldRouteType = (typeof MONOFOLD_ROUTE_TYPES)[number];
13
+
14
+ const ROUTE_TYPE_SET = new Set<string>(MONOFOLD_ROUTE_TYPES);
15
+
16
+ /** Validates a route type string and returns the typed value. */
17
+ export function assertMonofoldRouteType(label: string, value: string): MonofoldRouteType {
18
+ if (!ROUTE_TYPE_SET.has(value)) {
19
+ throw new Error(
20
+ `${label} must be one of: ${MONOFOLD_ROUTE_TYPES.join(", ")} (got: ${value})`,
21
+ );
22
+ }
23
+ return value as MonofoldRouteType;
24
+ }
25
+
26
+ /** Parses an optional defaultRouteOverride from preset config. */
27
+ export function parseDefaultRouteOverride(itemLabel: string, value: unknown): MonofoldRouteType | undefined {
28
+ if (value === undefined) return undefined;
29
+ if (typeof value !== "string" || value.trim() === "") {
30
+ throw new Error(`${itemLabel}.defaultRouteOverride must be a non-empty string`);
31
+ }
32
+ return assertMonofoldRouteType(`${itemLabel}.defaultRouteOverride`, value.trim());
33
+ }
34
+
35
+ /** Resolves the effective write route: explicit selection wins over Focus default, then "default". */
36
+ export function resolveWriteRouteType(
37
+ explicitRoute: string | undefined,
38
+ defaultRouteOverride: MonofoldRouteType | undefined,
39
+ ): MonofoldRouteType {
40
+ if (explicitRoute !== undefined && explicitRoute !== "") {
41
+ return assertMonofoldRouteType("routeType", explicitRoute);
42
+ }
43
+ return defaultRouteOverride ?? "default";
44
+ }
@@ -0,0 +1,106 @@
1
+ import { formatSkillsForPrompt, type Skill } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Max skills a single focus preset may auto-load (matches focus context file cap). */
4
+ export const FOCUS_SKILLS_MAX_COUNT = 6;
5
+
6
+ const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ const SKILLS_SECTION_PATTERN = /\n\nThe following skills provide[\s\S]*?<\/available_skills>/;
8
+
9
+ /** Validates a focusSkills entry per Agent Skills name rules. */
10
+ export function assertValidFocusSkillName(label: string, name: string): void {
11
+ if (name.trim() === "") {
12
+ throw new Error(`${label} must not contain empty skill names`);
13
+ }
14
+ if (name.length > 64) {
15
+ throw new Error(`${label} skill name exceeds 64 characters: ${name}`);
16
+ }
17
+ if (!SKILL_NAME_PATTERN.test(name)) {
18
+ throw new Error(`${label} skill name must use lowercase letters, digits, and hyphens only: ${name}`);
19
+ }
20
+ }
21
+
22
+ /** Parses and validates an optional focusSkills list from preset config. */
23
+ export function parseFocusSkills(itemLabel: string, value: unknown): string[] | undefined {
24
+ if (value === undefined) return undefined;
25
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
26
+ throw new Error(`${itemLabel}.focusSkills must be an array of strings`);
27
+ }
28
+ const names: string[] = [];
29
+ const seen = new Set<string>();
30
+ for (let index = 0; index < value.length; index += 1) {
31
+ const raw = value[index]!;
32
+ const nameLabel = `${itemLabel}.focusSkills[${index}]`;
33
+ assertValidFocusSkillName(nameLabel, raw);
34
+ if (seen.has(raw)) {
35
+ throw new Error(`${itemLabel}.focusSkills has duplicate skill name: ${raw}`);
36
+ }
37
+ seen.add(raw);
38
+ names.push(raw);
39
+ }
40
+ if (names.length > FOCUS_SKILLS_MAX_COUNT) {
41
+ throw new Error(`${itemLabel}.focusSkills must contain at most ${FOCUS_SKILLS_MAX_COUNT} skill names`);
42
+ }
43
+ return names;
44
+ }
45
+
46
+ /** Removes Pi's default available-skills block from a system prompt, if present. */
47
+ export function stripSkillsSectionFromSystemPrompt(systemPrompt: string): string {
48
+ return systemPrompt.replace(SKILLS_SECTION_PATTERN, "");
49
+ }
50
+
51
+ /** Resolves declared focus skill names against Pi's discovered skill inventory. */
52
+ export function resolveFocusSkills(allSkills: Skill[] | undefined, focusSkillNames: string[] | undefined): Skill[] {
53
+ if (!focusSkillNames) return [];
54
+ const byName = new Map((allSkills ?? []).map((skill) => [skill.name, skill]));
55
+ return focusSkillNames.flatMap((name) => {
56
+ const skill = byName.get(name);
57
+ return skill ? [skill] : [];
58
+ });
59
+ }
60
+
61
+ /** Returns focusSkills names that are not present in the discovered inventory. */
62
+ export function findMissingFocusSkills(focusSkillNames: string[] | undefined, allSkills: Skill[] | undefined): string[] {
63
+ if (!focusSkillNames?.length) return [];
64
+ const available = new Set((allSkills ?? []).map((skill) => skill.name));
65
+ return focusSkillNames.filter((name) => !available.has(name));
66
+ }
67
+
68
+ /** Replaces the default skills section with only focus-declared skills when configured. */
69
+ export function applyFocusSkillsToSystemPrompt(
70
+ systemPrompt: string,
71
+ allSkills: Skill[] | undefined,
72
+ focusSkillNames: string[] | undefined,
73
+ ): string {
74
+ if (focusSkillNames === undefined) return systemPrompt;
75
+ const stripped = stripSkillsSectionFromSystemPrompt(systemPrompt);
76
+ const resolved = resolveFocusSkills(allSkills, focusSkillNames);
77
+ if (resolved.length === 0) return stripped;
78
+ return stripped + formatSkillsForPrompt(resolved);
79
+ }
80
+
81
+ let warnedFocusSkillsKey: string | null = null;
82
+
83
+ /** Clears per-activation focus skill warning deduplication (for tests and focus changes). */
84
+ export function resetFocusSkillsWarningState(): void {
85
+ warnedFocusSkillsKey = null;
86
+ }
87
+
88
+ /** Emits one actionable warning per focus activation for missing focusSkills names. */
89
+ export function warnMissingFocusSkills(
90
+ presetId: string,
91
+ focusSkillNames: string[] | undefined,
92
+ allSkills: Skill[] | undefined,
93
+ warn: (message: string) => void,
94
+ ): void {
95
+ if (!focusSkillNames?.length) return;
96
+ const missing = findMissingFocusSkills(focusSkillNames, allSkills);
97
+ if (missing.length === 0) return;
98
+ const key = `${presetId}:${missing.join(",")}`;
99
+ if (warnedFocusSkillsKey === key) return;
100
+ warnedFocusSkillsKey = key;
101
+ for (const name of missing) {
102
+ warn(
103
+ `Focus preset "${presetId}" focusSkills: skill "${name}" was not found in Pi's discovered inventory. Check the skill name or install the package, then reload Pi.`,
104
+ );
105
+ }
106
+ }
package/index.ts CHANGED
@@ -20,6 +20,14 @@ import {
20
20
  setActiveFocusPresetId,
21
21
  warnZeroTargetMatchesForPreset,
22
22
  } from "./focus-preset.js";
23
+ import {
24
+ resolveWriteRouteType,
25
+ type MonofoldRouteType,
26
+ } from "./focus-route-override.js";
27
+ import {
28
+ applyFocusSkillsToSystemPrompt,
29
+ warnMissingFocusSkills,
30
+ } from "./focus-skills.js";
23
31
  import {
24
32
  buildMonofoldTree,
25
33
  readMonofoldFile,
@@ -37,7 +45,7 @@ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./valid
37
45
  type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "git";
38
46
  type LegacyCapabilityTag = CapabilityTag | "gitCommit" | "gitPush";
39
47
  type IntentCategory = "Explore" | "Write" | "Config" | "Git";
40
- type RouteType = "default" | "prd" | "design" | "progress" | "issue" | "research" | "decision";
48
+ type RouteType = MonofoldRouteType;
41
49
 
42
50
  type RouteConfig = {
43
51
  path: string;
@@ -888,7 +896,11 @@ function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usa
888
896
  }
889
897
 
890
898
  function formatFocusStatus(position: ActiveFocusPresetPosition): string {
891
- return `focus: ${position.preset.label} (${position.index + 1}/${position.total}) ${FOCUS_CYCLE_SHORTCUT}`;
899
+ const base = `focus: ${position.preset.label} (${position.index + 1}/${position.total}) ${FOCUS_CYCLE_SHORTCUT}`;
900
+ if (position.preset.defaultRouteOverride) {
901
+ return `${base} route:${position.preset.defaultRouteOverride}`;
902
+ }
903
+ return base;
892
904
  }
893
905
 
894
906
  function updateFocusStatus(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig): void {
@@ -1022,6 +1034,9 @@ async function buildManifest(loaded: LoadedConfig): Promise<string> {
1022
1034
  const activeTargetIds = new Set(activeWorkspaces.map(({ workspace }) => workspace.targetId));
1023
1035
  if (activeWorkspaces.length > 0) {
1024
1036
  lines.push(`Active Focus: ${activePreset.label} (${activePreset.id})`);
1037
+ if (activePreset.defaultRouteOverride) {
1038
+ lines.push(`Default write route override: ${activePreset.defaultRouteOverride} (explicit routeType/--route still wins)`);
1039
+ }
1025
1040
  for (const { workspace } of activeWorkspaces) {
1026
1041
  lines.push(await buildFullWorkspaceManifestEntry(workspace, " (active)"));
1027
1042
  }
@@ -1042,6 +1057,9 @@ async function buildManifest(loaded: LoadedConfig): Promise<string> {
1042
1057
  async function buildFocusContextInjection(loaded: LoadedConfig, preset: FocusPreset): Promise<FocusContextInjection> {
1043
1058
  const activeWorkspaces = getActiveFocusWorkspaces(loaded, preset);
1044
1059
  const lines = ["## Focus Context Injection", "", `Active Focus: ${preset.label} (${preset.id})`];
1060
+ if (preset.defaultRouteOverride) {
1061
+ lines.push(`Default write route override: ${preset.defaultRouteOverride} (explicit routeType/--route still wins)`);
1062
+ }
1045
1063
  const notices: string[] = [];
1046
1064
  const seenFiles = new Set<string>();
1047
1065
  let injectedFileCount = 0;
@@ -1235,9 +1253,22 @@ export default function piMultiWorkspace(pi: ExtensionAPI) {
1235
1253
  "warning",
1236
1254
  );
1237
1255
  }
1256
+ if (activePreset && ctx.hasUI) {
1257
+ warnMissingFocusSkills(
1258
+ activePreset.id,
1259
+ activePreset.focusSkills,
1260
+ _event.systemPromptOptions?.skills,
1261
+ (message) => ctx.ui.notify(message, "warning"),
1262
+ );
1263
+ }
1264
+ const baseSystemPrompt = applyFocusSkillsToSystemPrompt(
1265
+ _event.systemPrompt,
1266
+ _event.systemPromptOptions?.skills,
1267
+ activePreset?.focusSkills,
1268
+ );
1238
1269
  return {
1239
1270
  systemPrompt:
1240
- _event.systemPrompt +
1271
+ baseSystemPrompt +
1241
1272
  `
1242
1273
 
1243
1274
  ## Pi Monofold
@@ -1369,7 +1400,7 @@ ${focusInjection.text}` : ""}
1369
1400
  label: "Workspace Write",
1370
1401
  description: "Write a Markdown document to a routed Workspace destination using routeType, title, body, filename, and metadata.",
1371
1402
  parameters: Type.Object({
1372
- routeType: Type.String({ description: "default, prd, design, progress, issue, research, or decision" }),
1403
+ routeType: Type.Optional(Type.String({ description: "default, prd, design, progress, issue, research, or decision; omitted uses active Focus defaultRouteOverride or default" })),
1373
1404
  title: Type.String(),
1374
1405
  body: Type.String(),
1375
1406
  filename: Type.Optional(Type.String()),
@@ -1380,9 +1411,9 @@ ${focusInjection.text}` : ""}
1380
1411
  workspaceName: Type.Optional(Type.String()),
1381
1412
  }),
1382
1413
  async execute(_id, params, _signal, _onUpdate, ctx) {
1383
- const routeType = params.routeType as RouteType;
1384
- if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${params.routeType}`);
1385
1414
  const loaded = await loadConfig(ctx.cwd);
1415
+ const activePreset = getActiveFocusPreset(loaded);
1416
+ const routeType = resolveWriteRouteType(params.routeType, activePreset?.defaultRouteOverride);
1386
1417
  const workspace = await resolveWorkspace(ctx, loaded, {
1387
1418
  targetTags: params.targetTags,
1388
1419
  targetName: params.targetName,
@@ -1596,14 +1627,15 @@ ${focusInjection.text}` : ""}
1596
1627
  const writeCommand = async (args: string, ctx: ExtensionCommandContext) => {
1597
1628
  try {
1598
1629
  const parsed = parseCommandArgs(args);
1599
- const routeType = (stringFlag(parsed.flags, "route", "r") ?? parsed.positional[0] ?? "default") as RouteType;
1600
- if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${routeType}`);
1601
1630
  const title = stringFlag(parsed.flags, "title", "t");
1602
1631
  const body = stringFlag(parsed.flags, "body", "b");
1603
1632
  if (!title) throw new Error("--title is required");
1604
1633
  if (!body) throw new Error("--body is required");
1605
1634
 
1606
1635
  const loaded = await loadConfig(ctx.cwd);
1636
+ const activePreset = getActiveFocusPreset(loaded);
1637
+ const explicitRoute = stringFlag(parsed.flags, "route", "r") ?? parsed.positional[0];
1638
+ const routeType = resolveWriteRouteType(explicitRoute, activePreset?.defaultRouteOverride);
1607
1639
  const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, ["writeDocs"]));
1608
1640
  const route = workspace.normalizedRoutes[routeType] ?? workspace.normalizedRoutes.default;
1609
1641
  if (!route) throw new Error(`Workspace has no route for ${routeType} and no default route`);
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
13
13
  "type": "module",
14
- "version": "0.6.2",
14
+ "version": "0.8.0",
15
15
  "pi": {
16
16
  "extensions": [
17
17
  "./index.ts"
@@ -25,6 +25,8 @@
25
25
  "unknown-path-allows.ts",
26
26
  "file-read-preview.ts",
27
27
  "focus-preset.ts",
28
+ "focus-route-override.ts",
29
+ "focus-skills.ts",
28
30
  "monofold-read-ops.ts",
29
31
  "read-caps.ts",
30
32
  "validation.ts"