pi-monofold 0.11.1 → 0.12.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 (3) hide show
  1. package/README.md +1 -0
  2. package/index.ts +80 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -20,6 +20,7 @@ See [docs/usage.md](./docs/usage.md) for configuration, commands, agent tools, a
20
20
  ## Features
21
21
 
22
22
  - **Virtual monorepo manifest** — declare workspaces and project workspaces in `.pi/monofold.yaml`
23
+ - **Multi-runtime path overlays** — keep one logical workspace definition while swapping absolute repo roots per runtime
23
24
  - **Routed Markdown writes** — route PRDs, progress notes, and other doc types to configured folders
24
25
  - **Workspace-aware reads** — list, read, search, and tree views scoped to readable workspaces, with bounded previews by default
25
26
  - **Capability guard** — block or confirm `read` / `write` / `edit` / `grep` / `find` / `bash` based on workspace tags
package/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Type } from "typebox";
3
3
  import { execFile } from "node:child_process";
4
4
  import { access, copyFile, mkdir, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
5
+ import { userInfo } from "node:os";
5
6
  import path from "node:path";
6
7
  import YAML from "yaml";
7
8
  import {
@@ -69,9 +70,12 @@ type RouteConfig = {
69
70
  metadata?: Record<string, unknown>;
70
71
  };
71
72
 
73
+ type RuntimePathOverlayMap = Record<string, string>;
74
+
72
75
  type WorkspaceConfig = {
73
76
  name?: string;
74
77
  path: string;
78
+ pathOverlays?: RuntimePathOverlayMap;
75
79
  tags: string[];
76
80
  capabilities: CapabilityTag[];
77
81
  contextFiles?: string[];
@@ -111,6 +115,14 @@ type ResolvedWorkspace = WorkspaceConfig & {
111
115
  normalizedRoutes: Partial<Record<RouteType, RouteConfig>>;
112
116
  effectiveContextFiles: string[];
113
117
  commitScope?: string;
118
+ runtimePathSource: "base" | "overlay";
119
+ runtimeResolvedPath: string;
120
+ activeRuntimeId: string;
121
+ };
122
+
123
+ type RuntimeInfo = {
124
+ id: string;
125
+ source: "env" | "detected";
114
126
  };
115
127
 
116
128
  type LoadedConfig = {
@@ -118,6 +130,7 @@ type LoadedConfig = {
118
130
  root: string;
119
131
  raw: MultiWorkspaceConfig;
120
132
  workspaces: ResolvedWorkspace[];
133
+ activeRuntime: RuntimeInfo;
121
134
  };
122
135
 
123
136
  type TargetInput = {
@@ -189,9 +202,11 @@ const CODE_EXTENSIONS = new Set([
189
202
  ".scss",
190
203
  ".html",
191
204
  ]);
205
+ const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\/]/;
206
+ const UNC_ABSOLUTE_PATH_RE = /^\\[^\\]+\\[^\\]+/;
192
207
  const ROOT_KEYS = new Set(["version", "defaults", "focusPresets", "workspaces"]);
193
208
  const DEFAULT_KEYS = new Set(["contextFiles", "filenameTemplate", "metadata"]);
194
- const WORKSPACE_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes", "projects"]);
209
+ const WORKSPACE_KEYS = new Set(["name", "path", "pathOverlays", "tags", "capabilities", "contextFiles", "routes", "projects"]);
195
210
  const PROJECT_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes"]);
196
211
  const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
197
212
  const FOCUS_STATUS_ID = "monofold-focus";
@@ -218,6 +233,50 @@ function normalizeSlashes(value: string): string {
218
233
  return value.replace(/\\/g, "/");
219
234
  }
220
235
 
236
+ function normalizeRuntimeId(value: string): string {
237
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "runtime";
238
+ }
239
+
240
+ function isPortableAbsolutePath(value: string): boolean {
241
+ return path.isAbsolute(value) || WINDOWS_ABSOLUTE_PATH_RE.test(value) || UNC_ABSOLUTE_PATH_RE.test(value);
242
+ }
243
+
244
+ function resolvePortablePath(root: string, configuredPath: string): string {
245
+ return isPortableAbsolutePath(configuredPath) ? configuredPath : path.resolve(root, configuredPath);
246
+ }
247
+
248
+ function detectActiveRuntime(): RuntimeInfo {
249
+ const explicit = process.env.PI_MONOFOLD_RUNTIME ?? process.env.MONOFOLD_RUNTIME;
250
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
251
+ return { id: normalizeRuntimeId(explicit), source: "env" };
252
+ }
253
+ const platform = process.platform === "darwin" ? "mac" : process.platform === "win32" ? "win" : process.platform;
254
+ let username = "user";
255
+ try {
256
+ username = userInfo().username || username;
257
+ } catch {
258
+ // ignore userInfo failures and keep fallback username
259
+ }
260
+ return { id: `${platform}-${normalizeRuntimeId(username)}`, source: "detected" };
261
+ }
262
+
263
+ function asPathOverlayMap(label: string, value: unknown): RuntimePathOverlayMap | undefined {
264
+ if (value === undefined) return undefined;
265
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
266
+ const normalized: RuntimePathOverlayMap = {};
267
+ for (const [runtimeId, overlayPath] of Object.entries(value)) {
268
+ const normalizedRuntimeId = normalizeRuntimeId(runtimeId);
269
+ if (typeof overlayPath !== "string" || overlayPath.trim().length === 0) {
270
+ throw new Error(`${label}.${runtimeId} must be a non-empty string path`);
271
+ }
272
+ if (!isPortableAbsolutePath(overlayPath)) {
273
+ throw new Error(`${label}.${runtimeId} must be an absolute path: ${overlayPath}`);
274
+ }
275
+ normalized[normalizedRuntimeId] = overlayPath;
276
+ }
277
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
278
+ }
279
+
221
280
  function isInside(parent: string, child: string): boolean {
222
281
  const normalizedParent = normalizeGuardPath(parent);
223
282
  const normalizedChild = normalizeGuardPath(child);
@@ -370,6 +429,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
370
429
  const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
371
430
  const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
372
431
  const focusPresets = parseFocusPresets(parsed.focusPresets, "focusPresets");
432
+ const activeRuntime = detectActiveRuntime();
373
433
 
374
434
  const workspaces: ResolvedWorkspace[] = [];
375
435
  for (let index = 0; index < parsed.workspaces.length; index += 1) {
@@ -378,6 +438,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
378
438
  assertKnownKeys(`workspaces[${index}]`, item, WORKSPACE_KEYS);
379
439
  if (item.name !== undefined && typeof item.name !== "string") throw new Error(`workspaces[${index}].name must be string`);
380
440
  if (typeof item.path !== "string") throw new Error(`workspaces[${index}].path is required`);
441
+ const pathOverlays = asPathOverlayMap(`workspaces[${index}].pathOverlays`, item.pathOverlays);
381
442
  const tags = asStringArray(`workspaces[${index}].tags`, item.tags);
382
443
  const capabilities = asCapabilityArray(item.capabilities);
383
444
  const contextFiles = asStringArray(`workspaces[${index}].contextFiles`, item.contextFiles, false);
@@ -402,13 +463,19 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
402
463
  }
403
464
  }
404
465
 
405
- const resolvedPath = path.resolve(cwd, item.path);
406
- const realPath = await existingRealPath(`workspaces[${index}].path`, resolvedPath);
466
+ const runtimeOverlayPath = pathOverlays?.[activeRuntime.id];
467
+ const runtimePathSource = runtimeOverlayPath ? "overlay" : "base";
468
+ const resolvedPath = resolvePortablePath(cwd, runtimeOverlayPath ?? item.path);
469
+ const realPath = await existingRealPath(
470
+ runtimePathSource === "overlay" ? `workspaces[${index}].pathOverlays.${activeRuntime.id}` : `workspaces[${index}].path`,
471
+ resolvedPath,
472
+ );
407
473
  const workspace: ResolvedWorkspace = {
408
474
  kind: "workspace",
409
475
  targetId: `#${index}`,
410
476
  name: item.name as string | undefined,
411
477
  path: item.path,
478
+ pathOverlays,
412
479
  displayPath: item.path,
413
480
  tags: uniqueStrings(tags),
414
481
  capabilities,
@@ -419,6 +486,9 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
419
486
  realPath,
420
487
  normalizedRoutes,
421
488
  effectiveContextFiles: [...defaultContextFiles, ...contextFiles],
489
+ runtimePathSource,
490
+ runtimeResolvedPath: resolvedPath,
491
+ activeRuntimeId: activeRuntime.id,
422
492
  };
423
493
  workspaces.push(workspace);
424
494
 
@@ -481,6 +551,9 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
481
551
  normalizedRoutes: projectNormalizedRoutes,
482
552
  effectiveContextFiles: [...defaultContextFiles, ...contextFiles, ...projectContextFiles],
483
553
  commitScope: normalizeSlashes(path.relative(resolvedPath, projectResolvedPath)),
554
+ runtimePathSource,
555
+ runtimeResolvedPath: projectResolvedPath,
556
+ activeRuntimeId: activeRuntime.id,
484
557
  });
485
558
  }
486
559
  }
@@ -512,6 +585,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
512
585
  workspaces: workspaces.filter((workspace) => workspace.kind === "workspace"),
513
586
  },
514
587
  workspaces,
588
+ activeRuntime,
515
589
  };
516
590
  }
517
591
 
@@ -1171,11 +1245,13 @@ async function gitRoot(workspace: ResolvedWorkspace): Promise<string | undefined
1171
1245
 
1172
1246
  async function buildFullWorkspaceManifestEntry(workspace: ResolvedWorkspace, suffix = ""): Promise<string> {
1173
1247
  const git = await gitSummary(workspace).catch((error) => ({ isGit: false, status: `git status error: ${String(error)}` }));
1248
+ const runtimeSuffix = workspace.runtimePathSource === "overlay" ? " (overlay)" : " (base)";
1174
1249
  return (
1175
1250
  `- ${formatWorkspaceLabel(workspace)}${suffix}\n` +
1176
1251
  ` capabilities: ${workspace.capabilities.join(", ")}\n` +
1177
1252
  ` routes: ${Object.keys(workspace.normalizedRoutes).join(", ") || "none"}\n` +
1178
1253
  ` contextFiles: ${workspace.effectiveContextFiles.join(", ") || "none"}\n` +
1254
+ ` runtimePath: ${normalizeSlashes(workspace.runtimeResolvedPath)}${runtimeSuffix}\n` +
1179
1255
  ` git: ${git.isGit ? git.status : "not a git repository"}`
1180
1256
  );
1181
1257
  }
@@ -1186,6 +1262,7 @@ async function buildManifest(
1186
1262
  ): Promise<string> {
1187
1263
  await ensureActiveFocusReady(loaded);
1188
1264
  const lines = ["Pi Monofold Manifest:"];
1265
+ lines.push(`Active runtime: ${loaded.activeRuntime.id}${loaded.activeRuntime.source === "env" ? " [env]" : " [detected]"}`);
1189
1266
  const activePreset = getActiveFocusPreset(loaded);
1190
1267
  const position = getActiveFocusPresetPosition(loaded.raw.focusPresets);
1191
1268
  if (activePreset && position) {
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
14
14
  "type": "module",
15
- "version": "0.11.1",
15
+ "version": "0.12.0",
16
16
  "pi": {
17
17
  "extensions": [
18
18
  "./index.ts"