pi-monofold 0.2.0 → 0.3.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 CHANGED
@@ -104,6 +104,12 @@ defaults:
104
104
  created: "{{date}}"
105
105
  source: "pi-monofold"
106
106
 
107
+ focusPresets:
108
+ - id: control
109
+ label: Control workspace focus
110
+ targets:
111
+ - targetTags: [control]
112
+
107
113
  workspaces:
108
114
  - name: "Product docs"
109
115
  path: "../business"
@@ -133,6 +139,14 @@ workspaces:
133
139
  contextFiles: [README.md, AGENTS.md]
134
140
  ```
135
141
 
142
+ ## Focus presets
143
+
144
+ Optional `focusPresets` define tag-based focus targets for the control workspace. Preset `id` values must be unique. Each target uses `targetTags`, matching workspace tags the same way as other Monofold target selectors. Targets that match zero configured workspaces are allowed in config and emit a runtime warning when the active preset is applied.
145
+
146
+ Active focus (the selected preset id) lives in extension session memory only. It resets when Pi restarts. When `focusPresets` is non-empty, the first preset in YAML order becomes active at session start unless a later slice changes it.
147
+
148
+ The example above includes a generic seed preset `control`. Add matching workspace tags in your own config when you adopt it.
149
+
136
150
  ## Commands
137
151
 
138
152
  Human-facing commands accept natural-language arguments and hand off interpretation to the Pi agent:
@@ -0,0 +1,133 @@
1
+ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
2
+
3
+ export type FocusPresetTarget = {
4
+ targetTags: string[];
5
+ };
6
+
7
+ export type FocusPreset = {
8
+ id: string;
9
+ label: string;
10
+ targets: FocusPresetTarget[];
11
+ };
12
+
13
+ export type FocusMatchableWorkspace = {
14
+ tags: string[];
15
+ };
16
+
17
+ const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets"]);
18
+ const FOCUS_PRESET_TARGET_KEYS = new Set(["targetTags"]);
19
+
20
+ /** Parses and validates focus preset configuration from YAML/JSON input. */
21
+ export function parseFocusPresets(value: unknown, label = "focusPresets"): FocusPreset[] {
22
+ if (value === undefined) return [];
23
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
24
+ const presets: FocusPreset[] = [];
25
+ const seenIds = new Set<string>();
26
+ for (let index = 0; index < value.length; index += 1) {
27
+ const item = value[index];
28
+ const itemLabel = `${label}[${index}]`;
29
+ if (!isRecord(item)) throw new Error(`${itemLabel} must be an object`);
30
+ assertKnownKeys(itemLabel, item, FOCUS_PRESET_KEYS);
31
+ if (typeof item.id !== "string" || item.id.trim() === "") {
32
+ throw new Error(`${itemLabel}.id must be a non-empty string`);
33
+ }
34
+ if (typeof item.label !== "string" || item.label.trim() === "") {
35
+ throw new Error(`${itemLabel}.label must be a non-empty string`);
36
+ }
37
+ if (!Array.isArray(item.targets) || item.targets.length === 0) {
38
+ throw new Error(`${itemLabel}.targets must be a non-empty array`);
39
+ }
40
+ const targets: FocusPresetTarget[] = [];
41
+ for (let targetIndex = 0; targetIndex < item.targets.length; targetIndex += 1) {
42
+ const target = item.targets[targetIndex];
43
+ const targetLabel = `${itemLabel}.targets[${targetIndex}]`;
44
+ if (!isRecord(target)) throw new Error(`${targetLabel} must be an object`);
45
+ assertKnownKeys(targetLabel, target, FOCUS_PRESET_TARGET_KEYS);
46
+ const targetTags = uniqueStrings(asStringArray(`${targetLabel}.targetTags`, target.targetTags));
47
+ if (targetTags.length === 0) {
48
+ throw new Error(`${targetLabel}.targetTags must contain at least one non-empty string`);
49
+ }
50
+ targets.push({ targetTags });
51
+ }
52
+ if (seenIds.has(item.id)) throw new Error(`${label} has duplicate preset id: ${item.id}`);
53
+ seenIds.add(item.id);
54
+ presets.push({ id: item.id, label: item.label, targets });
55
+ }
56
+ return presets;
57
+ }
58
+
59
+ /** Returns the first preset id, or null when no focus preset is available. */
60
+ export function pickDefaultFocusPresetId(focusPresets: FocusPreset[] | undefined): string | null {
61
+ if (!focusPresets || focusPresets.length === 0) return null;
62
+ return focusPresets[0]?.id ?? null;
63
+ }
64
+
65
+ /** Finds a focus preset by stable id. */
66
+ export function findFocusPresetById(focusPresets: FocusPreset[] | undefined, id: string): FocusPreset | undefined {
67
+ return focusPresets?.find((preset) => preset.id === id);
68
+ }
69
+
70
+ /** Returns true when every requested focus tag is present on a workspace. */
71
+ export function matchesFocusTarget(workspace: FocusMatchableWorkspace, targetTags: string[]): boolean {
72
+ if (targetTags.length === 0) return false;
73
+ return targetTags.every((tag) => workspace.tags.includes(tag));
74
+ }
75
+
76
+ /** Counts workspaces matched by a focus target's tag set. */
77
+ export function countMatchingWorkspaces(
78
+ workspaces: FocusMatchableWorkspace[],
79
+ targetTags: string[],
80
+ ): number {
81
+ return workspaces.filter((workspace) => matchesFocusTarget(workspace, targetTags)).length;
82
+ }
83
+
84
+ /** Emits warnings for a preset's targets that do not match any configured workspace. */
85
+ export function warnZeroTargetMatchesForPreset(
86
+ preset: FocusPreset,
87
+ workspaces: FocusMatchableWorkspace[],
88
+ warn: (message: string) => void,
89
+ ): void {
90
+ for (const target of preset.targets) {
91
+ if (countMatchingWorkspaces(workspaces, target.targetTags) === 0) {
92
+ warn(
93
+ `Focus preset "${preset.id}" target [${target.targetTags.join(", ")}] matches no configured workspace`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+
99
+ let activeFocusPresetId: string | null = null;
100
+ let activeFocusInitialized = false;
101
+
102
+ /** Initializes the active focus preset once per process/session. */
103
+ export function ensureActiveFocusInitialized(focusPresets: FocusPreset[] | undefined): void {
104
+ if (activeFocusInitialized) return;
105
+ activeFocusInitialized = true;
106
+ activeFocusPresetId = pickDefaultFocusPresetId(focusPresets);
107
+ }
108
+
109
+ /** Returns the current active focus preset id, if any. */
110
+ export function getActiveFocusPresetId(): string | null {
111
+ return activeFocusPresetId;
112
+ }
113
+
114
+ /** Sets the active focus preset after validating that the id exists. */
115
+ export function setActiveFocusPresetId(id: string, focusPresets: FocusPreset[] | undefined): void {
116
+ if (!findFocusPresetById(focusPresets, id)) {
117
+ throw new Error(`Unknown focus preset id: ${id}`);
118
+ }
119
+ activeFocusPresetId = id;
120
+ activeFocusInitialized = true;
121
+ }
122
+
123
+ /** Clears the active focus preset for the current process/session. */
124
+ export function clearActiveFocusPresetId(): void {
125
+ activeFocusPresetId = null;
126
+ activeFocusInitialized = true;
127
+ }
128
+
129
+ /** Resets in-memory session state (for tests and process restart). */
130
+ export function resetActiveFocusSessionState(): void {
131
+ activeFocusPresetId = null;
132
+ activeFocusInitialized = false;
133
+ }
package/index.ts CHANGED
@@ -4,6 +4,15 @@ import { execFile } from "node:child_process";
4
4
  import { access, copyFile, mkdir, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import YAML from "yaml";
7
+ import {
8
+ type FocusPreset,
9
+ ensureActiveFocusInitialized,
10
+ findFocusPresetById,
11
+ getActiveFocusPresetId,
12
+ parseFocusPresets,
13
+ warnZeroTargetMatchesForPreset,
14
+ } from "./focus-preset.js";
15
+ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
7
16
 
8
17
  type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "git";
9
18
  type LegacyCapabilityTag = CapabilityTag | "gitCommit" | "gitPush";
@@ -42,6 +51,7 @@ type MultiWorkspaceConfig = {
42
51
  filenameTemplate?: string;
43
52
  metadata?: Record<string, unknown>;
44
53
  };
54
+ focusPresets?: FocusPreset[];
45
55
  workspaces: WorkspaceConfig[];
46
56
  };
47
57
 
@@ -135,26 +145,16 @@ const CODE_EXTENSIONS = new Set([
135
145
  ".scss",
136
146
  ".html",
137
147
  ]);
138
- const ROOT_KEYS = new Set(["version", "defaults", "workspaces"]);
148
+ const ROOT_KEYS = new Set(["version", "defaults", "focusPresets", "workspaces"]);
139
149
  const DEFAULT_KEYS = new Set(["contextFiles", "filenameTemplate", "metadata"]);
140
150
  const WORKSPACE_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes", "projects"]);
141
151
  const PROJECT_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes"]);
142
152
  const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
143
153
 
144
- function isRecord(value: unknown): value is Record<string, unknown> {
145
- return typeof value === "object" && value !== null && !Array.isArray(value);
146
- }
147
-
148
154
  function normalizeSlashes(value: string): string {
149
155
  return value.replace(/\\/g, "/");
150
156
  }
151
157
 
152
- function assertKnownKeys(label: string, value: Record<string, unknown>, allowed: Set<string>): void {
153
- for (const key of Object.keys(value)) {
154
- if (!allowed.has(key)) throw new Error(`${label} has unknown key: ${key}`);
155
- }
156
- }
157
-
158
158
  function isInside(parent: string, child: string): boolean {
159
159
  const relative = path.relative(parent, child);
160
160
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -174,18 +174,6 @@ function assertProjectPath(label: string, value: string): void {
174
174
  }
175
175
  }
176
176
 
177
- function uniqueStrings(items: string[]): string[] {
178
- return [...new Set(items.filter(Boolean))];
179
- }
180
-
181
- function asStringArray(label: string, value: unknown, required = true): string[] {
182
- if (value === undefined && !required) return [];
183
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
184
- throw new Error(`${label} must be an array of strings`);
185
- }
186
- return value;
187
- }
188
-
189
177
  function asCapabilityArray(value: unknown): CapabilityTag[] {
190
178
  const items = asStringArray("capabilities", value);
191
179
  const normalized: CapabilityTag[] = [];
@@ -314,6 +302,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
314
302
  const defaultContextFiles = defaults ? asStringArray("defaults.contextFiles", defaults.contextFiles, false) : [];
315
303
  const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
316
304
  const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
305
+ const focusPresets = parseFocusPresets(parsed.focusPresets, "focusPresets");
317
306
 
318
307
  const workspaces: ResolvedWorkspace[] = [];
319
308
  for (let index = 0; index < parsed.workspaces.length; index += 1) {
@@ -439,6 +428,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
439
428
  filenameTemplate: defaultFilenameTemplate,
440
429
  metadata: defaultMetadata,
441
430
  },
431
+ focusPresets: focusPresets.length > 0 ? focusPresets : undefined,
442
432
  workspaces: workspaces.filter((workspace) => workspace.kind === "workspace"),
443
433
  },
444
434
  workspaces,
@@ -476,6 +466,7 @@ function normalizeConfigForMigration(parsed: unknown): Record<string, unknown> {
476
466
  if (version !== 1) throw new Error("monofold config requires version: 1");
477
467
  const normalized: Record<string, unknown> = { version: 1 };
478
468
  if (parsed.defaults !== undefined) normalized.defaults = parsed.defaults;
469
+ if (parsed.focusPresets !== undefined) normalized.focusPresets = parsed.focusPresets;
479
470
  normalized.workspaces = parsed.workspaces;
480
471
  normalizeConfigCapabilities(normalized);
481
472
  return normalized;
@@ -1001,6 +992,21 @@ function inferBashCwd(ctx: ExtensionContext, command: string): string {
1001
992
  }
1002
993
 
1003
994
  export default function piMultiWorkspace(pi: ExtensionAPI) {
995
+ pi.on("session_start", async (_event, ctx) => {
996
+ try {
997
+ const loaded = await loadConfig(ctx.cwd);
998
+ ensureActiveFocusInitialized(loaded.raw.focusPresets);
999
+ const activeId = getActiveFocusPresetId();
1000
+ if (!activeId) return;
1001
+ const preset = findFocusPresetById(loaded.raw.focusPresets, activeId);
1002
+ if (!preset) return;
1003
+ if (!ctx.hasUI) return;
1004
+ warnZeroTargetMatchesForPreset(preset, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
1005
+ } catch {
1006
+ return;
1007
+ }
1008
+ });
1009
+
1004
1010
  pi.on("before_agent_start", async (_event, ctx) => {
1005
1011
  try {
1006
1012
  const loaded = await loadConfig(ctx.cwd);
@@ -1612,4 +1618,4 @@ ${manifest}
1612
1618
  return undefined;
1613
1619
  });
1614
1620
  }
1615
-
1621
+
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "scripts": {
3
3
  "typecheck": "tsc --noEmit",
4
- "check": "npm run typecheck && npm pack --dry-run"
4
+ "test": "node --import tsx --test tests/**/*.test.ts",
5
+ "check": "npm run typecheck && npm test && npm pack --dry-run"
5
6
  },
6
7
  "peerDependencies": {
7
8
  "typebox": "*",
@@ -10,7 +11,7 @@
10
11
  },
11
12
  "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
12
13
  "type": "module",
13
- "version": "0.2.0",
14
+ "version": "0.3.0",
14
15
  "pi": {
15
16
  "extensions": [
16
17
  "./index.ts"
@@ -19,13 +20,16 @@
19
20
  "files": [
20
21
  "README.md",
21
22
  "LICENSE",
22
- "index.ts"
23
+ "index.ts",
24
+ "focus-preset.ts",
25
+ "validation.ts"
23
26
  ],
24
27
  "name": "pi-monofold",
25
28
  "devDependencies": {
26
29
  "typescript": "^6.0.3",
27
30
  "typebox": "^1.1.38",
28
31
  "@types/node": "^25.9.1",
32
+ "tsx": "^4.20.5",
29
33
  "@earendil-works/pi-coding-agent": "^0.75.4",
30
34
  "@earendil-works/pi-ai": "^0.75.4"
31
35
  },
package/validation.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Returns true when a value is a plain object record, excluding arrays and null. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+
6
+ /** Throws when an object contains keys outside the supplied allow-list. */
7
+ export function assertKnownKeys(label: string, value: Record<string, unknown>, allowed: Set<string>): void {
8
+ for (const key of Object.keys(value)) {
9
+ if (!allowed.has(key)) throw new Error(`${label} has unknown key: ${key}`);
10
+ }
11
+ }
12
+
13
+ /** Coerces a required or optional unknown value to a string array with strict item validation. */
14
+ export function asStringArray(label: string, value: unknown, required = true): string[] {
15
+ if (value === undefined && !required) return [];
16
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
17
+ throw new Error(`${label} must be an array of strings`);
18
+ }
19
+ return value;
20
+ }
21
+
22
+ /** Removes empty strings and duplicate entries while preserving first-seen order. */
23
+ export function uniqueStrings(items: string[]): string[] {
24
+ return [...new Set(items.filter(Boolean))];
25
+ }