pi-monofold 0.0.2 → 0.1.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 +33 -7
  2. package/index.ts +456 -51
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -84,7 +84,7 @@ pi -e .
84
84
  Place config in the control repository:
85
85
 
86
86
  ```text
87
- <control-repo>/.pi/monofold.yml
87
+ <control-repo>/.pi/monofold.yaml
88
88
  ```
89
89
 
90
90
  Example:
@@ -111,6 +111,14 @@ workspaces:
111
111
  filenameTemplate: "prd-{{slug}}.md"
112
112
  metadata:
113
113
  type: prd
114
+ projects:
115
+ - name: "Launch plan"
116
+ path: "Projects/Launch"
117
+ tags: [project, launch]
118
+ contextFiles: [CONTEXT.md]
119
+ routes:
120
+ default: "."
121
+ progress: "Progress"
114
122
 
115
123
  - name: "Application"
116
124
  path: "../app"
@@ -130,23 +138,41 @@ workspaces:
130
138
  ## Commands
131
139
 
132
140
  - `/monofold:list` or `/monofold_list`: show manifest and git status summary.
133
- - `/monofold:add <path> --name "Name" --tags tag1,tag2 --capabilities read,editCode,runCommands,gitCommit`: add a workspace to `.pi/monofold.yml`.
134
- - `/monofold:read file <path> --workspace #0`: read a file from a workspace.
135
- - `/monofold:tree [path] --workspace #0 --depth 2`: show a workspace tree.
136
- - `/monofold:search <query> --workspace #0`: search a workspace.
141
+ - `/monofold:add <path> --name "Name" --tags tag1,tag2 --capabilities read,editCode,runCommands,gitCommit`: add a workspace to `.pi/monofold.yaml`.
142
+ - `/monofold:project-add <path> --parent "Name" --tags project,slug`: add a Project Workspace under a parent workspace.
143
+ - `/monofold:update [natural language request]`: migrate legacy config to `.pi/monofold.yaml`, normalize YAML, validate the manifest, and optionally hand a config-change request to the agent.
144
+ - `/monofold:read file <path> --target #0.1`: read a file from a workspace or project target.
145
+ - `/monofold:tree [path] --target #0.1 --depth 2`: show a target tree.
146
+ - `/monofold:search <query> --target #0.1`: search a target.
137
147
  - `/monofold:write --route progress --title "Title" --body "Markdown body"`: write routed Markdown.
138
- - `/monofold:git status|commit|push --workspace #0`: run guarded workspace git.
148
+ - `/monofold:git status|commit|push --target #0.1`: run guarded target git.
139
149
 
140
150
  Examples:
141
151
 
142
152
  ```text
143
153
  /monofold:add C:/Projects/app --name "Application" --tags development,app --capabilities read,editCode,runCommands,gitCommit --context README.md,AGENTS.md
144
154
  /monofold:add ../business --name "Product Docs" --tags business,docs --capabilities read,writeDocs,gitCommit --routes default=Notes,progress=Progress,research=Research
155
+ /monofold:project-add Projects/Launch --parent "Product Docs" --tags project,launch --routes default=.,progress=Progress
156
+ /monofold:update rename the Product Docs workspace to Business Notes and add tag docs
157
+ ```
158
+
159
+ Project Workspaces are listed under `workspaces[].projects`. Their `path` is relative to the parent workspace, `tags` are combined with parent tags, `capabilities` inherit unless explicitly replaced, and missing routes default to `default: "."` when the effective target has `writeDocs`.
160
+
161
+ ## Updating configuration
162
+
163
+ `.pi/monofold.yaml` is the canonical config file. Legacy `.pi/monofold.yml` is still readable, but `/monofold:update` migrates it to `.pi/monofold.yaml`, writes a timestamped backup such as `.pi/monofold.yml.bak-20260524-153012`, and removes the legacy file after a successful write. If both `.yaml` and `.yml` exist, Pi Monofold stops with a conflict error so you can choose the correct file manually.
164
+
165
+ `/monofold:update` is a configuration migration command, not a Pi package updater. Use `pi update`, `pi update --extensions`, or `pi install ...@new-ref` for package updates.
166
+
167
+ After migration, you can provide a natural-language configuration change request. The command hands that request to the Pi agent, which edits `.pi/monofold.yaml` directly and validates the result through the manifest path:
168
+
169
+ ```text
170
+ /monofold:update add 4_Project/NewApp as a Project Workspace under Obsidian Vault with tags project,newapp and progress route Progress
145
171
  ```
146
172
 
147
173
  ## Guard
148
174
 
149
- When `.pi/monofold.yml` exists, Pi Monofold guards standard `read/write/edit/grep/find/bash` calls against workspace capabilities.
175
+ When `.pi/monofold.yaml` or legacy `.pi/monofold.yml` exists, Pi Monofold guards standard `read/write/edit/grep/find/bash` calls against workspace capabilities.
150
176
 
151
177
  - Unknown path: confirm in UI, block without UI.
152
178
  - Docs write: requires `writeDocs`.
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { execFile } from "node:child_process";
4
- import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
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
7
 
@@ -21,6 +21,16 @@ type WorkspaceConfig = {
21
21
  capabilities: CapabilityTag[];
22
22
  contextFiles?: string[];
23
23
  routes?: Partial<Record<RouteType, string | RouteConfig>>;
24
+ projects?: ProjectConfig[];
25
+ };
26
+
27
+ type ProjectConfig = {
28
+ name?: string;
29
+ path: string;
30
+ tags: string[];
31
+ capabilities?: CapabilityTag[];
32
+ contextFiles?: string[];
33
+ routes?: Partial<Record<RouteType, string | RouteConfig>>;
24
34
  };
25
35
 
26
36
  type MultiWorkspaceConfig = {
@@ -34,10 +44,17 @@ type MultiWorkspaceConfig = {
34
44
  };
35
45
 
36
46
  type ResolvedWorkspace = WorkspaceConfig & {
47
+ kind: "workspace" | "project";
48
+ targetId: string;
37
49
  index: number;
50
+ projectIndex?: number;
51
+ parent?: ResolvedWorkspace;
52
+ displayPath: string;
38
53
  resolvedPath: string;
54
+ realPath: string;
39
55
  normalizedRoutes: Partial<Record<RouteType, RouteConfig>>;
40
56
  effectiveContextFiles: string[];
57
+ commitScope?: string;
41
58
  };
42
59
 
43
60
  type LoadedConfig = {
@@ -49,6 +66,8 @@ type LoadedConfig = {
49
66
 
50
67
  type TargetInput = {
51
68
  targetTags?: string[];
69
+ targetName?: string;
70
+ targetId?: string;
52
71
  workspaceName?: string;
53
72
  workspaceIndex?: number;
54
73
  requireCapabilities?: CapabilityTag[];
@@ -65,7 +84,22 @@ type CommandResult = {
65
84
  exitCode: number | string | null | undefined;
66
85
  };
67
86
 
68
- const CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yml");
87
+ type ConfigMigrationPlan = {
88
+ changed: boolean;
89
+ sourcePath: string;
90
+ sourceRelativePath: string;
91
+ sourceKind: "canonical" | "legacy";
92
+ targetPath: string;
93
+ targetRelativePath: string;
94
+ backupPath?: string;
95
+ backupRelativePath?: string;
96
+ normalizedText: string;
97
+ loaded: LoadedConfig;
98
+ actions: string[];
99
+ };
100
+
101
+ const CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yaml");
102
+ const LEGACY_CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yml");
69
103
  const ROUTE_TYPES: RouteType[] = ["default", "prd", "design", "progress", "issue", "research", "decision"];
70
104
  const CAPABILITIES: CapabilityTag[] = ["read", "writeDocs", "editCode", "runCommands", "gitCommit", "gitPush"];
71
105
  const DOC_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]);
@@ -95,6 +129,11 @@ const CODE_EXTENSIONS = new Set([
95
129
  ".scss",
96
130
  ".html",
97
131
  ]);
132
+ const ROOT_KEYS = new Set(["version", "defaults", "workspaces"]);
133
+ const DEFAULT_KEYS = new Set(["contextFiles", "filenameTemplate", "metadata"]);
134
+ const WORKSPACE_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes", "projects"]);
135
+ const PROJECT_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes"]);
136
+ const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
98
137
 
99
138
  function isRecord(value: unknown): value is Record<string, unknown> {
100
139
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -104,6 +143,12 @@ function normalizeSlashes(value: string): string {
104
143
  return value.replace(/\\/g, "/");
105
144
  }
106
145
 
146
+ function assertKnownKeys(label: string, value: Record<string, unknown>, allowed: Set<string>): void {
147
+ for (const key of Object.keys(value)) {
148
+ if (!allowed.has(key)) throw new Error(`${label} has unknown key: ${key}`);
149
+ }
150
+ }
151
+
107
152
  function isInside(parent: string, child: string): boolean {
108
153
  const relative = path.relative(parent, child);
109
154
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -115,6 +160,18 @@ function assertWorkspaceInternalRelative(label: string, value: string): void {
115
160
  }
116
161
  }
117
162
 
163
+ function assertProjectPath(label: string, value: string): void {
164
+ assertWorkspaceInternalRelative(label, value);
165
+ const normalized = normalizeSlashes(value).replace(/^\.\//, "").replace(/\/$/, "");
166
+ if (!normalized || normalized === ".") {
167
+ throw new Error(`${label} must point below the parent workspace, not the parent root`);
168
+ }
169
+ }
170
+
171
+ function uniqueStrings(items: string[]): string[] {
172
+ return [...new Set(items.filter(Boolean))];
173
+ }
174
+
118
175
  function asStringArray(label: string, value: unknown, required = true): string[] {
119
176
  if (value === undefined && !required) return [];
120
177
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
@@ -133,6 +190,19 @@ function asCapabilityArray(value: unknown): CapabilityTag[] {
133
190
  return items as CapabilityTag[];
134
191
  }
135
192
 
193
+ function asOptionalCapabilityArray(value: unknown): CapabilityTag[] | undefined {
194
+ if (value === undefined) return undefined;
195
+ return asCapabilityArray(value);
196
+ }
197
+
198
+ async function existingRealPath(label: string, targetPath: string): Promise<string> {
199
+ try {
200
+ return await realpath(targetPath);
201
+ } catch {
202
+ throw new Error(`${label} does not exist: ${targetPath}`);
203
+ }
204
+ }
205
+
136
206
  function normalizeRoute(routeType: string, value: unknown): RouteConfig {
137
207
  if (!ROUTE_TYPES.includes(routeType as RouteType)) {
138
208
  throw new Error(`Unknown route type: ${routeType}`);
@@ -144,6 +214,7 @@ function normalizeRoute(routeType: string, value: unknown): RouteConfig {
144
214
  if (!isRecord(value) || typeof value.path !== "string") {
145
215
  throw new Error(`routes.${routeType} must be a string path or object with path`);
146
216
  }
217
+ assertKnownKeys(`routes.${routeType}`, value, ROUTE_KEYS);
147
218
  assertWorkspaceInternalRelative(`routes.${routeType}.path`, value.path);
148
219
  if (value.filenameTemplate !== undefined && typeof value.filenameTemplate !== "string") {
149
220
  throw new Error(`routes.${routeType}.filenameTemplate must be a string`);
@@ -167,6 +238,19 @@ async function pathExists(targetPath: string): Promise<boolean> {
167
238
  }
168
239
  }
169
240
 
241
+ async function resolveConfigFile(cwd: string, allowMissing = false): Promise<{ configPath: string; relativePath: string; kind: "canonical" | "legacy" | "missing" }> {
242
+ const canonicalPath = path.join(cwd, CONFIG_RELATIVE_PATH);
243
+ const legacyPath = path.join(cwd, LEGACY_CONFIG_RELATIVE_PATH);
244
+ const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
245
+ if (hasCanonical && hasLegacy) {
246
+ throw new Error(`Configuration file conflict: both ${CONFIG_RELATIVE_PATH} and ${LEGACY_CONFIG_RELATIVE_PATH} exist. Remove one before continuing.`);
247
+ }
248
+ if (hasCanonical) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "canonical" };
249
+ if (hasLegacy) return { configPath: legacyPath, relativePath: LEGACY_CONFIG_RELATIVE_PATH, kind: "legacy" };
250
+ if (allowMissing) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "missing" };
251
+ throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
252
+ }
253
+
170
254
  function runCommand(
171
255
  command: string,
172
256
  args: string[],
@@ -198,22 +282,31 @@ function runCommand(
198
282
  }
199
283
 
200
284
  async function loadConfig(cwd: string): Promise<LoadedConfig> {
201
- const configPath = path.join(cwd, CONFIG_RELATIVE_PATH);
285
+ const { configPath } = await resolveConfigFile(cwd);
202
286
  const text = await readFile(configPath, "utf8");
203
287
  const parsed = YAML.parse(text, { uniqueKeys: true }) as unknown;
288
+ return validateConfigObject(cwd, configPath, parsed);
289
+ }
290
+
291
+ async function validateConfigObject(cwd: string, configPath: string, parsed: unknown): Promise<LoadedConfig> {
204
292
  if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
293
+ assertKnownKeys("monofold config", parsed, ROOT_KEYS);
205
294
  if (parsed.version !== 1) throw new Error("monofold config requires version: 1");
206
295
  if (!Array.isArray(parsed.workspaces) || parsed.workspaces.length === 0) {
207
296
  throw new Error("monofold config requires non-empty workspaces array");
208
297
  }
209
298
 
210
299
  const defaults = isRecord(parsed.defaults) ? parsed.defaults : undefined;
300
+ if (defaults) assertKnownKeys("defaults", defaults, DEFAULT_KEYS);
211
301
  const defaultContextFiles = defaults ? asStringArray("defaults.contextFiles", defaults.contextFiles, false) : [];
212
302
  const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
213
303
  const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
214
304
 
215
- const workspaces = parsed.workspaces.map((item, index): ResolvedWorkspace => {
305
+ const workspaces: ResolvedWorkspace[] = [];
306
+ for (let index = 0; index < parsed.workspaces.length; index += 1) {
307
+ const item = parsed.workspaces[index];
216
308
  if (!isRecord(item)) throw new Error(`workspaces[${index}] must be an object`);
309
+ assertKnownKeys(`workspaces[${index}]`, item, WORKSPACE_KEYS);
217
310
  if (item.name !== undefined && typeof item.name !== "string") throw new Error(`workspaces[${index}].name must be string`);
218
311
  if (typeof item.path !== "string") throw new Error(`workspaces[${index}].path is required`);
219
312
  const tags = asStringArray(`workspaces[${index}].tags`, item.tags);
@@ -226,6 +319,9 @@ async function loadConfig(cwd: string): Promise<LoadedConfig> {
226
319
  const routes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(item.routes)
227
320
  ? (item.routes as Partial<Record<RouteType, string | RouteConfig>>)
228
321
  : undefined;
322
+ if (!capabilities.includes("writeDocs") && routes) {
323
+ throw new Error(`workspaces[${index}] has routes but lacks writeDocs capability`);
324
+ }
229
325
  if (capabilities.includes("writeDocs") && !routes) {
230
326
  throw new Error(`workspaces[${index}] has writeDocs but no routes`);
231
327
  }
@@ -238,19 +334,87 @@ async function loadConfig(cwd: string): Promise<LoadedConfig> {
238
334
  }
239
335
 
240
336
  const resolvedPath = path.resolve(cwd, item.path);
241
- return {
337
+ const realPath = await existingRealPath(`workspaces[${index}].path`, resolvedPath);
338
+ const workspace: ResolvedWorkspace = {
339
+ kind: "workspace",
340
+ targetId: `#${index}`,
242
341
  name: item.name as string | undefined,
243
342
  path: item.path,
244
- tags,
343
+ displayPath: item.path,
344
+ tags: uniqueStrings(tags),
245
345
  capabilities,
246
346
  contextFiles,
247
347
  routes,
248
348
  index,
249
349
  resolvedPath,
350
+ realPath,
250
351
  normalizedRoutes,
251
352
  effectiveContextFiles: [...defaultContextFiles, ...contextFiles],
252
353
  };
253
- });
354
+ workspaces.push(workspace);
355
+
356
+ const projects = Array.isArray(item.projects) ? item.projects : [];
357
+ if (item.projects !== undefined && !Array.isArray(item.projects)) {
358
+ throw new Error(`workspaces[${index}].projects must be an array`);
359
+ }
360
+ const seenProjectRealPaths = new Set<string>();
361
+ for (let projectOffset = 0; projectOffset < projects.length; projectOffset += 1) {
362
+ const project = projects[projectOffset];
363
+ const projectIndex = projectOffset + 1;
364
+ if (!isRecord(project)) throw new Error(`workspaces[${index}].projects[${projectOffset}] must be an object`);
365
+ assertKnownKeys(`workspaces[${index}].projects[${projectOffset}]`, project, PROJECT_KEYS);
366
+ if (project.name !== undefined && typeof project.name !== "string") throw new Error(`workspaces[${index}].projects[${projectOffset}].name must be string`);
367
+ if (typeof project.path !== "string") throw new Error(`workspaces[${index}].projects[${projectOffset}].path is required`);
368
+ assertProjectPath(`workspaces[${index}].projects[${projectOffset}].path`, project.path);
369
+ const projectTags = asStringArray(`workspaces[${index}].projects[${projectOffset}].tags`, project.tags);
370
+ if (projectTags.length === 0) throw new Error(`workspaces[${index}].projects[${projectOffset}].tags is required`);
371
+ const projectCapabilities = asOptionalCapabilityArray(project.capabilities) ?? capabilities;
372
+ const projectContextFiles = asStringArray(`workspaces[${index}].projects[${projectOffset}].contextFiles`, project.contextFiles, false);
373
+ for (const contextFile of projectContextFiles) {
374
+ assertWorkspaceInternalRelative(`workspaces[${index}].projects[${projectOffset}].contextFiles`, contextFile);
375
+ }
376
+ const projectRoutes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(project.routes)
377
+ ? (project.routes as Partial<Record<RouteType, string | RouteConfig>>)
378
+ : undefined;
379
+ if (!projectCapabilities.includes("writeDocs") && projectRoutes) {
380
+ throw new Error(`workspaces[${index}].projects[${projectOffset}] has routes but lacks writeDocs capability`);
381
+ }
382
+ const projectNormalizedRoutes: Partial<Record<RouteType, RouteConfig>> = {};
383
+ if (projectRoutes) {
384
+ for (const [routeType, routeValue] of Object.entries(projectRoutes)) {
385
+ projectNormalizedRoutes[routeType as RouteType] = normalizeRoute(routeType, routeValue);
386
+ }
387
+ } else if (projectCapabilities.includes("writeDocs")) {
388
+ projectNormalizedRoutes.default = { path: "." };
389
+ }
390
+ const projectResolvedPath = path.resolve(resolvedPath, project.path);
391
+ const projectRealPath = await existingRealPath(`workspaces[${index}].projects[${projectOffset}].path`, projectResolvedPath);
392
+ if (!isInside(realPath, projectRealPath) || projectRealPath === realPath) {
393
+ throw new Error(`workspaces[${index}].projects[${projectOffset}].path must stay below parent workspace: ${project.path}`);
394
+ }
395
+ if (seenProjectRealPaths.has(projectRealPath)) throw new Error(`Duplicate project path in workspaces[${index}]: ${project.path}`);
396
+ seenProjectRealPaths.add(projectRealPath);
397
+ workspaces.push({
398
+ kind: "project",
399
+ targetId: `#${index}.${projectIndex}`,
400
+ name: project.name as string | undefined,
401
+ path: project.path,
402
+ displayPath: normalizeSlashes(path.join(item.path, project.path)),
403
+ tags: uniqueStrings([...tags, ...projectTags]),
404
+ capabilities: projectCapabilities,
405
+ contextFiles: projectContextFiles,
406
+ routes: project.routes as ProjectConfig["routes"],
407
+ index,
408
+ projectIndex,
409
+ parent: workspace,
410
+ resolvedPath: projectResolvedPath,
411
+ realPath: projectRealPath,
412
+ normalizedRoutes: projectNormalizedRoutes,
413
+ effectiveContextFiles: [...defaultContextFiles, ...contextFiles, ...projectContextFiles],
414
+ commitScope: normalizeSlashes(path.relative(resolvedPath, projectResolvedPath)),
415
+ });
416
+ }
417
+ }
254
418
 
255
419
  return {
256
420
  configPath,
@@ -262,15 +426,95 @@ async function loadConfig(cwd: string): Promise<LoadedConfig> {
262
426
  filenameTemplate: defaultFilenameTemplate,
263
427
  metadata: defaultMetadata,
264
428
  },
265
- workspaces,
429
+ workspaces: workspaces.filter((workspace) => workspace.kind === "workspace"),
266
430
  },
267
431
  workspaces,
268
432
  };
269
433
  }
270
434
 
435
+ function timestampSuffix(now = new Date()): string {
436
+ return now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "-").replace(/Z$/, "");
437
+ }
438
+
439
+ function normalizeConfigForMigration(parsed: unknown): Record<string, unknown> {
440
+ if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
441
+ assertKnownKeys("monofold config", parsed, ROOT_KEYS);
442
+ const version = parsed.version ?? 1;
443
+ if (version !== 1) throw new Error("monofold config requires version: 1");
444
+ const normalized: Record<string, unknown> = { version: 1 };
445
+ if (parsed.defaults !== undefined) normalized.defaults = parsed.defaults;
446
+ normalized.workspaces = parsed.workspaces;
447
+ return normalized;
448
+ }
449
+
450
+ async function buildConfigMigrationPlan(cwd: string): Promise<ConfigMigrationPlan> {
451
+ const source = await resolveConfigFile(cwd);
452
+ if (source.kind === "missing") throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
453
+
454
+ const originalText = await readFile(source.configPath, "utf8");
455
+ const parsed = YAML.parse(originalText, { uniqueKeys: true }) as unknown;
456
+ const normalized = normalizeConfigForMigration(parsed);
457
+ const targetPath = path.join(cwd, CONFIG_RELATIVE_PATH);
458
+ const normalizedText = YAML.stringify(normalized).trimEnd() + "\n";
459
+ const loaded = await validateConfigObject(cwd, targetPath, normalized);
460
+ const changed = source.kind !== "canonical" || originalText !== normalizedText;
461
+ const actions: string[] = [];
462
+ if (source.kind === "legacy") actions.push(`Move legacy config ${LEGACY_CONFIG_RELATIVE_PATH} to canonical ${CONFIG_RELATIVE_PATH}`);
463
+ if (originalText !== normalizedText) actions.push("Normalize YAML and ensure version: 1 is explicit");
464
+ if (source.kind === "legacy") actions.push(`Remove legacy config ${LEGACY_CONFIG_RELATIVE_PATH} after writing ${CONFIG_RELATIVE_PATH}`);
465
+ if (!changed) actions.push(`Already current: ${CONFIG_RELATIVE_PATH} (version 1)`);
466
+ const backupPath = changed ? `${source.configPath}.bak-${timestampSuffix()}` : undefined;
467
+ return {
468
+ changed,
469
+ sourcePath: source.configPath,
470
+ sourceRelativePath: source.relativePath,
471
+ sourceKind: source.kind,
472
+ targetPath,
473
+ targetRelativePath: CONFIG_RELATIVE_PATH,
474
+ backupPath,
475
+ backupRelativePath: backupPath ? normalizeSlashes(path.relative(cwd, backupPath)) : undefined,
476
+ normalizedText,
477
+ loaded,
478
+ actions,
479
+ };
480
+ }
481
+
482
+ async function applyConfigMigrationPlan(plan: ConfigMigrationPlan): Promise<void> {
483
+ if (!plan.changed) return;
484
+ if (!plan.backupPath) throw new Error("Migration backup path is required for changed configuration");
485
+ await mkdir(path.dirname(plan.targetPath), { recursive: true });
486
+ await copyFile(plan.sourcePath, plan.backupPath);
487
+ await writeFile(plan.targetPath, plan.normalizedText, "utf8");
488
+ if (plan.sourceKind === "legacy") await unlink(plan.sourcePath);
489
+ }
490
+
491
+ function formatMigrationPlan(plan: ConfigMigrationPlan): string {
492
+ if (!plan.changed) return `Already current: ${plan.targetRelativePath} (version ${plan.loaded.raw.version})`;
493
+ return [
494
+ `Source: ${plan.sourceRelativePath}`,
495
+ `Target: ${plan.targetRelativePath}`,
496
+ `Backup: ${plan.backupRelativePath}`,
497
+ "",
498
+ "Actions:",
499
+ ...plan.actions.map((action) => `- ${action}`),
500
+ ].join("\n");
501
+ }
502
+
503
+ function buildConfigurationHandoffPrompt(request: string): string {
504
+ return [
505
+ "/monofold:update completed. Apply this requested Pi Monofold configuration change to `.pi/monofold.yaml`.",
506
+ "Edit the canonical config directly when needed, preserve valid YAML, then run `/monofold:list` as manifest validation.",
507
+ "",
508
+ "User request:",
509
+ request.trim(),
510
+ ].join("\n");
511
+ }
512
+
271
513
  function matchesTarget(workspace: ResolvedWorkspace, target: TargetInput): boolean {
514
+ if (target.targetId && workspace.targetId !== (target.targetId.startsWith("#") ? target.targetId : `#${target.targetId}`)) return false;
272
515
  if (target.workspaceIndex !== undefined && workspace.index !== target.workspaceIndex) return false;
273
- if (target.workspaceName && workspace.name !== target.workspaceName) return false;
516
+ const targetName = target.targetName ?? target.workspaceName;
517
+ if (targetName && workspace.name !== targetName) return false;
274
518
  if (target.targetTags?.length && !target.targetTags.every((tag) => workspace.tags.includes(tag))) return false;
275
519
  if (target.requireCapabilities?.length) {
276
520
  if (!target.requireCapabilities.every((cap) => workspace.capabilities.includes(cap))) return false;
@@ -325,14 +569,14 @@ function stringFlag(flags: Record<string, string | boolean>, ...names: string[])
325
569
  }
326
570
 
327
571
  function commandTarget(flags: Record<string, string | boolean>, requireCapabilities?: CapabilityTag[]): TargetInput {
328
- const workspace = stringFlag(flags, "workspace", "w");
572
+ const workspace = stringFlag(flags, "target", "workspace", "w");
329
573
  const tags = stringFlag(flags, "tags", "tag")
330
574
  ?.split(",")
331
575
  .map((item) => item.trim())
332
576
  .filter(Boolean);
333
- const workspaceIndex = workspace?.startsWith("#") ? Number.parseInt(workspace.slice(1), 10) : undefined;
577
+ const workspaceIndex = workspace?.startsWith("#") && !workspace.includes(".") ? Number.parseInt(workspace.slice(1), 10) : undefined;
334
578
  return {
335
- ...(workspace && workspaceIndex === undefined ? { workspaceName: workspace } : {}),
579
+ ...(workspace?.startsWith("#") ? { targetId: workspace } : workspace ? { targetName: workspace } : {}),
336
580
  ...(workspaceIndex !== undefined && Number.isFinite(workspaceIndex) ? { workspaceIndex } : {}),
337
581
  ...(tags?.length ? { targetTags: tags } : {}),
338
582
  requireCapabilities,
@@ -420,6 +664,49 @@ async function addWorkspaceToConfig(configPath: string, workspaceBlock: Workspac
420
664
  await writeFile(configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
421
665
  }
422
666
 
667
+ function buildProjectFromAddArgs(args: string): { project: ProjectConfig; parent: TargetInput } {
668
+ const parsed = parseCommandArgs(args);
669
+ const projectPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional[0];
670
+ if (!projectPath) throw new Error("project path is required");
671
+ const tags = commaListFlag(parsed.flags, "tags", "tag");
672
+ if (tags.length === 0) throw new Error("--tags is required");
673
+ const capabilities = commaListFlag(parsed.flags, "capabilities", "caps", "cap");
674
+ const parentName = stringFlag(parsed.flags, "parent");
675
+ const parentTags = commaListFlag(parsed.flags, "parent-tags", "parentTags");
676
+ if (!parentName && parentTags.length === 0) throw new Error("--parent or --parent-tags is required");
677
+ const project: ProjectConfig = {
678
+ ...(stringFlag(parsed.flags, "name", "n") ? { name: stringFlag(parsed.flags, "name", "n") } : {}),
679
+ path: projectPath,
680
+ tags,
681
+ ...(capabilities.length ? { capabilities: asCapabilityArray(capabilities) } : {}),
682
+ contextFiles: commaListFlag(parsed.flags, "context", "contexts", "contextFiles"),
683
+ ...(routesFlag(parsed.flags) ? { routes: routesFlag(parsed.flags) } : {}),
684
+ };
685
+ if (project.contextFiles?.length === 0) delete project.contextFiles;
686
+ return {
687
+ project,
688
+ parent: {
689
+ ...(parentName?.startsWith("#") ? { targetId: parentName } : parentName ? { targetName: parentName } : {}),
690
+ ...(parentTags.length ? { targetTags: parentTags } : {}),
691
+ },
692
+ };
693
+ }
694
+
695
+ async function addProjectToConfig(ctx: ExtensionCommandContext, loaded: LoadedConfig, project: ProjectConfig, parentInput: TargetInput): Promise<void> {
696
+ const parent = await resolveWorkspace(ctx, { ...loaded, workspaces: loaded.workspaces.filter((workspace) => workspace.kind === "workspace") }, parentInput);
697
+ assertProjectPath("project.path", project.path);
698
+ const projectRealPath = await existingRealPath("project.path", path.resolve(parent.resolvedPath, project.path));
699
+ if (!isInside(parent.realPath, projectRealPath) || projectRealPath === parent.realPath) throw new Error(`project path must stay below parent workspace: ${project.path}`);
700
+ if (loaded.workspaces.some((workspace) => workspace.parent === parent && workspace.realPath === projectRealPath)) throw new Error(`project path already exists under parent: ${project.path}`);
701
+ const parsed = YAML.parse(await readFile(loaded.configPath, "utf8"), { uniqueKeys: true }) as Record<string, unknown>;
702
+ const workspaces = parsed.workspaces as Record<string, unknown>[];
703
+ const rawParent = workspaces[parent.index];
704
+ const projects = Array.isArray(rawParent.projects) ? rawParent.projects : [];
705
+ rawParent.projects = projects;
706
+ projects.push(project);
707
+ await writeFile(loaded.configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
708
+ }
709
+
423
710
  function sendCommandOutput(pi: ExtensionAPI, title: string, text: string, details?: Record<string, unknown>) {
424
711
  pi.sendMessage({
425
712
  customType: "monofold-output",
@@ -434,7 +721,7 @@ function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usa
434
721
  sendCommandOutput(pi, command, `Error: ${message}\n\nUsage:\n${usage}`, { error: message });
435
722
  }
436
723
 
437
- async function resolveWorkspace(ctx: ExtensionContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
724
+ async function resolveWorkspace(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
438
725
  const matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
439
726
  if (matches.length === 0) throw new Error(`No workspace matches target: ${JSON.stringify(target)}`);
440
727
  if (matches.length === 1) return matches[0];
@@ -449,7 +736,8 @@ async function resolveWorkspace(ctx: ExtensionContext, loaded: LoadedConfig, tar
449
736
 
450
737
  function formatWorkspaceLabel(workspace: ResolvedWorkspace): string {
451
738
  const displayName = workspace.name ? `${workspace.name} ` : "";
452
- return `#${workspace.index} ${displayName}[${workspace.tags.join(", ")}] ${workspace.path}`;
739
+ const parent = workspace.kind === "project" && workspace.parent ? ` parent=${workspace.parent.name ?? workspace.parent.targetId}` : "";
740
+ return `${workspace.targetId} ${displayName}[${workspace.tags.join(", ")}] ${workspace.displayPath}${parent}`;
453
741
  }
454
742
 
455
743
  function relativePath(workspace: ResolvedWorkspace, inputPath: string): string {
@@ -458,11 +746,18 @@ function relativePath(workspace: ResolvedWorkspace, inputPath: string): string {
458
746
  }
459
747
 
460
748
  async function gitSummary(workspace: ResolvedWorkspace): Promise<{ isGit: boolean; status?: string }> {
461
- if (!(await pathExists(path.join(workspace.resolvedPath, ".git")))) return { isGit: false };
462
- const result = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short"], { timeout: 5000 });
749
+ const root = await gitRoot(workspace);
750
+ if (!root) return { isGit: false };
751
+ const result = await runCommand("git", ["-C", root, "status", "--short"], { timeout: 5000 });
463
752
  return { isGit: true, status: result.stdout.trim() || "clean" };
464
753
  }
465
754
 
755
+ async function gitRoot(workspace: ResolvedWorkspace): Promise<string | undefined> {
756
+ if (await pathExists(path.join(workspace.resolvedPath, ".git"))) return workspace.resolvedPath;
757
+ if (workspace.parent && (await pathExists(path.join(workspace.parent.resolvedPath, ".git")))) return workspace.parent.resolvedPath;
758
+ return undefined;
759
+ }
760
+
466
761
  async function buildManifest(loaded: LoadedConfig): Promise<string> {
467
762
  const lines = ["Pi Monofold Manifest:"];
468
763
  for (const workspace of loaded.workspaces) {
@@ -505,7 +800,7 @@ function slugify(title: string): string {
505
800
  }
506
801
 
507
802
  function renderTemplate(template: string, vars: Record<string, string>): string {
508
- return template.replace(/\{\{(date|datetime|title|slug|routeType|workspaceName|workspaceTags)\}\}/g, (_, key: string) => vars[key] ?? "");
803
+ return template.replace(/\{\{(date|datetime|title|slug|routeType|workspaceName|workspaceTags|targetName|targetTags|parentWorkspaceName|projectName)\}\}/g, (_, key: string) => vars[key] ?? "");
509
804
  }
510
805
 
511
806
  function renderMetadata(value: unknown, vars: Record<string, string>): unknown {
@@ -531,7 +826,7 @@ function classifyPath(targetPath: string): "docs" | "code" | "unknown" {
531
826
 
532
827
  function findWorkspaceForPath(loaded: LoadedConfig, targetPath: string): ResolvedWorkspace | undefined {
533
828
  const absolute = path.resolve(loaded.root, targetPath);
534
- return loaded.workspaces.find((workspace) => isInside(workspace.resolvedPath, absolute));
829
+ return [...loaded.workspaces].sort((a, b) => b.resolvedPath.length - a.resolvedPath.length).find((workspace) => isInside(workspace.resolvedPath, absolute));
535
830
  }
536
831
 
537
832
  async function confirm(ctx: ExtensionContext, title: string, body: string): Promise<boolean> {
@@ -630,6 +925,8 @@ ${manifest}
630
925
  query: Type.Optional(Type.String({ description: "Search query for mode=search" })),
631
926
  depth: Type.Optional(Type.Number({ description: "Tree depth, default 1" })),
632
927
  targetTags: Type.Optional(Type.Array(Type.String())),
928
+ targetName: Type.Optional(Type.String()),
929
+ targetId: Type.Optional(Type.String()),
633
930
  workspaceName: Type.Optional(Type.String()),
634
931
  requireCapabilities: Type.Optional(Type.Array(Type.String())),
635
932
  }),
@@ -637,6 +934,8 @@ ${manifest}
637
934
  const loaded = await loadConfig(ctx.cwd);
638
935
  const workspace = await resolveWorkspace(ctx, loaded, {
639
936
  targetTags: params.targetTags,
937
+ targetName: params.targetName,
938
+ targetId: params.targetId,
640
939
  workspaceName: params.workspaceName,
641
940
  requireCapabilities: ["read"],
642
941
  });
@@ -678,6 +977,8 @@ ${manifest}
678
977
  filename: Type.Optional(Type.String()),
679
978
  metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
680
979
  targetTags: Type.Optional(Type.Array(Type.String())),
980
+ targetName: Type.Optional(Type.String()),
981
+ targetId: Type.Optional(Type.String()),
681
982
  workspaceName: Type.Optional(Type.String()),
682
983
  }),
683
984
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -686,6 +987,8 @@ ${manifest}
686
987
  const loaded = await loadConfig(ctx.cwd);
687
988
  const workspace = await resolveWorkspace(ctx, loaded, {
688
989
  targetTags: params.targetTags,
990
+ targetName: params.targetName,
991
+ targetId: params.targetId,
689
992
  workspaceName: params.workspaceName,
690
993
  requireCapabilities: ["writeDocs"],
691
994
  });
@@ -701,6 +1004,10 @@ ${manifest}
701
1004
  routeType,
702
1005
  workspaceName: workspace.name ?? "",
703
1006
  workspaceTags: workspace.tags.join(","),
1007
+ targetName: workspace.name ?? "",
1008
+ targetTags: workspace.tags.join(","),
1009
+ parentWorkspaceName: workspace.parent?.name ?? "",
1010
+ projectName: workspace.kind === "project" ? workspace.name ?? "" : "",
704
1011
  };
705
1012
  const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
706
1013
  const filename = params.filename ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
@@ -726,6 +1033,8 @@ ${manifest}
726
1033
  action: Type.String({ description: "status, commit, or push" }),
727
1034
  message: Type.Optional(Type.String()),
728
1035
  targetTags: Type.Optional(Type.Array(Type.String())),
1036
+ targetName: Type.Optional(Type.String()),
1037
+ targetId: Type.Optional(Type.String()),
729
1038
  workspaceName: Type.Optional(Type.String()),
730
1039
  }),
731
1040
  async execute(_id, params, signal, _onUpdate, ctx) {
@@ -733,35 +1042,39 @@ ${manifest}
733
1042
  const loaded = await loadConfig(ctx.cwd);
734
1043
  const workspace = await resolveWorkspace(ctx, loaded, {
735
1044
  targetTags: params.targetTags,
1045
+ targetName: params.targetName,
1046
+ targetId: params.targetId,
736
1047
  workspaceName: params.workspaceName,
737
1048
  requireCapabilities: required,
738
1049
  });
739
- if (!(await pathExists(path.join(workspace.resolvedPath, ".git")))) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
1050
+ const root = await gitRoot(workspace);
1051
+ if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
740
1052
  if (params.action === "status") {
741
- const result = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short", "--branch"], { signal, timeout: 10000 });
1053
+ const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { signal, timeout: 10000 });
742
1054
  return { content: [{ type: "text", text: result.stdout || "clean" }], details: { workspace } };
743
1055
  }
744
1056
  if (params.action === "commit") {
745
1057
  const message = params.message ?? `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
746
- const status = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short"], { signal, timeout: 10000 });
747
- const diffstat = await runCommand("git", ["-C", workspace.resolvedPath, "diff", "--stat"], { signal, timeout: 10000 });
748
- const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus:\n${status.stdout || "clean"}\n\nDiffstat:\n${diffstat.stdout || "none"}\n\nCommit message:\n${message}\n\nStage all and commit?`);
1058
+ const status = await runCommand("git", ["-C", root, "status", "--short"], { signal, timeout: 10000 });
1059
+ const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { signal, timeout: 10000 });
1060
+ const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
1061
+ const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus (repo full):\n${status.stdout || "clean"}\n\nDiffstat (repo full):\n${diffstat.stdout || "none"}\n\nCommit scope:\n${scope}\n\nCommit message:\n${message}\n\nStage scoped changes and commit?`);
749
1062
  if (!ok) return { content: [{ type: "text", text: "Commit cancelled" }], details: { cancelled: true } };
750
- await runCommand("git", ["-C", workspace.resolvedPath, "add", "-A"], { signal, timeout: 10000 });
751
- const commit = await runCommand("git", ["-C", workspace.resolvedPath, "commit", "-m", message], { signal, timeout: 30000 });
1063
+ await runCommand("git", ["-C", root, "add", "-A", "--", scope], { signal, timeout: 10000 });
1064
+ const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { signal, timeout: 30000 });
752
1065
  return { content: [{ type: "text", text: commit.stdout || commit.stderr }], details: { workspace, message } };
753
1066
  }
754
1067
  if (params.action === "push") {
755
- const branch = await runCommand("git", ["-C", workspace.resolvedPath, "branch", "--show-current"], { signal, timeout: 10000 });
756
- const remote = await runCommand("git", ["-C", workspace.resolvedPath, "remote", "-v"], { signal, timeout: 10000 });
757
- const log = await runCommand("git", ["-C", workspace.resolvedPath, "log", "--oneline", "@{u}..HEAD"], {
1068
+ const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { signal, timeout: 10000 });
1069
+ const remote = await runCommand("git", ["-C", root, "remote", "-v"], { signal, timeout: 10000 });
1070
+ const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
758
1071
  signal,
759
1072
  timeout: 10000,
760
1073
  allowExitCodes: [0, 128],
761
1074
  });
762
- const ok = await confirm(ctx, "Confirmed Push", `${formatWorkspaceLabel(workspace)}\n\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\n\nCommits to push:\n${log.stdout || "none/unknown upstream"}\n\nPush now?`);
1075
+ const ok = await confirm(ctx, "Confirmed Push", `${formatWorkspaceLabel(workspace)}\n\nPush is repository/branch scoped.\n\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\n\nCommits to push:\n${log.stdout || "none/unknown upstream"}\n\nPush now?`);
763
1076
  if (!ok) return { content: [{ type: "text", text: "Push cancelled" }], details: { cancelled: true } };
764
- const push = await runCommand("git", ["-C", workspace.resolvedPath, "push"], { signal, timeout: 60000 });
1077
+ const push = await runCommand("git", ["-C", root, "push"], { signal, timeout: 60000 });
765
1078
  return { content: [{ type: "text", text: push.stdout || push.stderr }], details: { workspace } };
766
1079
  }
767
1080
  throw new Error(`Unknown monofold_git action: ${params.action}`);
@@ -863,6 +1176,10 @@ ${manifest}
863
1176
  routeType,
864
1177
  workspaceName: workspace.name ?? "",
865
1178
  workspaceTags: workspace.tags.join(","),
1179
+ targetName: workspace.name ?? "",
1180
+ targetTags: workspace.tags.join(","),
1181
+ parentWorkspaceName: workspace.parent?.name ?? "",
1182
+ projectName: workspace.kind === "project" ? workspace.name ?? "" : "",
866
1183
  };
867
1184
  const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
868
1185
  const filename = stringFlag(parsed.flags, "filename", "file", "f") ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
@@ -892,10 +1209,11 @@ ${manifest}
892
1209
  const required: CapabilityTag[] = action === "push" ? ["gitPush"] : action === "commit" ? ["gitCommit"] : [];
893
1210
  const loaded = await loadConfig(ctx.cwd);
894
1211
  const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, required));
895
- if (!(await pathExists(path.join(workspace.resolvedPath, ".git")))) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
1212
+ const root = await gitRoot(workspace);
1213
+ if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
896
1214
 
897
1215
  if (action === "status") {
898
- const result = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short", "--branch"], { timeout: 10000 });
1216
+ const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { timeout: 10000 });
899
1217
  sendCommandOutput(pi, `monofold:git status ${formatWorkspaceLabel(workspace)}`, result.stdout || "clean", { workspace });
900
1218
  return;
901
1219
  }
@@ -903,32 +1221,33 @@ ${manifest}
903
1221
  if (action === "commit") {
904
1222
  const parsedMessage = stringFlag(parsed.flags, "message", "m") ?? parsed.positional.slice(1).join(" ");
905
1223
  const message = parsedMessage || `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
906
- const status = await runCommand("git", ["-C", workspace.resolvedPath, "status", "--short"], { timeout: 10000 });
907
- const diffstat = await runCommand("git", ["-C", workspace.resolvedPath, "diff", "--stat"], { timeout: 10000 });
908
- const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus:\n${status.stdout || "clean"}\n\nDiffstat:\n${diffstat.stdout || "none"}\n\nCommit message:\n${message}\n\nStage all and commit?`);
1224
+ const status = await runCommand("git", ["-C", root, "status", "--short"], { timeout: 10000 });
1225
+ const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { timeout: 10000 });
1226
+ const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
1227
+ const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus (repo full):\n${status.stdout || "clean"}\n\nDiffstat (repo full):\n${diffstat.stdout || "none"}\n\nCommit scope:\n${scope}\n\nCommit message:\n${message}\n\nStage scoped changes and commit?`);
909
1228
  if (!ok) {
910
1229
  sendCommandOutput(pi, "monofold:git commit", "Commit cancelled", { cancelled: true });
911
1230
  return;
912
1231
  }
913
- await runCommand("git", ["-C", workspace.resolvedPath, "add", "-A"], { timeout: 10000 });
914
- const commit = await runCommand("git", ["-C", workspace.resolvedPath, "commit", "-m", message], { timeout: 30000 });
1232
+ await runCommand("git", ["-C", root, "add", "-A", "--", scope], { timeout: 10000 });
1233
+ const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { timeout: 30000 });
915
1234
  sendCommandOutput(pi, `monofold:git commit ${formatWorkspaceLabel(workspace)}`, commit.stdout || commit.stderr, { workspace, message });
916
1235
  return;
917
1236
  }
918
1237
 
919
1238
  if (action === "push") {
920
- const branch = await runCommand("git", ["-C", workspace.resolvedPath, "branch", "--show-current"], { timeout: 10000 });
921
- const remote = await runCommand("git", ["-C", workspace.resolvedPath, "remote", "-v"], { timeout: 10000 });
922
- const log = await runCommand("git", ["-C", workspace.resolvedPath, "log", "--oneline", "@{u}..HEAD"], {
1239
+ const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { timeout: 10000 });
1240
+ const remote = await runCommand("git", ["-C", root, "remote", "-v"], { timeout: 10000 });
1241
+ const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
923
1242
  timeout: 10000,
924
1243
  allowExitCodes: [0, 128],
925
1244
  });
926
- const ok = await confirm(ctx, "Confirmed Push", `${formatWorkspaceLabel(workspace)}\n\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\n\nCommits to push:\n${log.stdout || "none/unknown upstream"}\n\nPush now?`);
1245
+ const ok = await confirm(ctx, "Confirmed Push", `${formatWorkspaceLabel(workspace)}\n\nPush is repository/branch scoped.\n\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\n\nCommits to push:\n${log.stdout || "none/unknown upstream"}\n\nPush now?`);
927
1246
  if (!ok) {
928
1247
  sendCommandOutput(pi, "monofold:git push", "Push cancelled", { cancelled: true });
929
1248
  return;
930
1249
  }
931
- const push = await runCommand("git", ["-C", workspace.resolvedPath, "push"], { timeout: 60000 });
1250
+ const push = await runCommand("git", ["-C", root, "push"], { timeout: 60000 });
932
1251
  sendCommandOutput(pi, `monofold:git push ${formatWorkspaceLabel(workspace)}`, push.stdout || push.stderr, { workspace });
933
1252
  return;
934
1253
  }
@@ -950,8 +1269,8 @@ ${manifest}
950
1269
  const addCommand = async (args: string, ctx: ExtensionCommandContext) => {
951
1270
  try {
952
1271
  const workspaceBlock = buildWorkspaceFromAddArgs(args);
953
- const configPath = path.join(ctx.cwd, CONFIG_RELATIVE_PATH);
954
- await addWorkspaceToConfig(configPath, workspaceBlock);
1272
+ const configFile = await resolveConfigFile(ctx.cwd, true);
1273
+ await addWorkspaceToConfig(configFile.configPath, workspaceBlock);
955
1274
  const loaded = await loadConfig(ctx.cwd);
956
1275
  sendCommandOutput(pi, "monofold:add", `Added workspace:\n${YAML.stringify(workspaceBlock).trim()}\n\n${await buildManifest(loaded)}`, {
957
1276
  workspace: workspaceBlock,
@@ -961,6 +1280,62 @@ ${manifest}
961
1280
  }
962
1281
  };
963
1282
 
1283
+ const projectAddUsage = [
1284
+ "/monofold:project-add <path> --parent \"Workspace Name\" --tags project,slug",
1285
+ "Parent by tags: --parent-tags vault,docs",
1286
+ "Optional: --name \"Name\" --capabilities read,writeDocs --context CONTEXT.md --routes default=.,progress=Progress",
1287
+ "Alias: /monofold_project_add ...",
1288
+ ].join("\n");
1289
+
1290
+ const projectAddCommand = async (args: string, ctx: ExtensionCommandContext) => {
1291
+ try {
1292
+ const loaded = await loadConfig(ctx.cwd);
1293
+ const { project, parent } = buildProjectFromAddArgs(args);
1294
+ await addProjectToConfig(ctx, loaded, project, parent);
1295
+ const next = await loadConfig(ctx.cwd);
1296
+ sendCommandOutput(pi, "monofold:project-add", `Added project:\n${YAML.stringify(project).trim()}\n\n${await buildManifest(next)}`, { project });
1297
+ } catch (error) {
1298
+ sendCommandError(pi, "monofold:project-add", error, projectAddUsage);
1299
+ }
1300
+ };
1301
+
1302
+ const updateUsage = [
1303
+ "/monofold:update [natural language configuration change request]",
1304
+ `Migrates ${LEGACY_CONFIG_RELATIVE_PATH} to ${CONFIG_RELATIVE_PATH}, normalizes YAML, and validates the manifest.`,
1305
+ "If a request is provided, it is handed off to the Pi agent after successful migration.",
1306
+ ].join("\n");
1307
+
1308
+ const updateCommand = async (args: string, ctx: ExtensionCommandContext) => {
1309
+ try {
1310
+ const plan = await buildConfigMigrationPlan(ctx.cwd);
1311
+ if (plan.changed && ctx.hasUI) {
1312
+ const ok = await ctx.ui.confirm("Monofold Update", `${formatMigrationPlan(plan)}\n\nApply migration?`);
1313
+ if (!ok) {
1314
+ sendCommandOutput(pi, "monofold:update", "Update cancelled", { cancelled: true });
1315
+ return;
1316
+ }
1317
+ }
1318
+
1319
+ await applyConfigMigrationPlan(plan);
1320
+ const loaded = await loadConfig(ctx.cwd);
1321
+ sendCommandOutput(pi, "monofold:update", `${formatMigrationPlan(plan)}\n\nManifest validation: OK (${loaded.workspaces.length} targets)`, {
1322
+ changed: plan.changed,
1323
+ configPath: CONFIG_RELATIVE_PATH,
1324
+ backupPath: plan.backupRelativePath,
1325
+ });
1326
+
1327
+ let request = args.trim();
1328
+ if (!request && ctx.hasUI) {
1329
+ request = (await ctx.ui.input("Optional: describe workspace/project configuration changes to apply now", ""))?.trim() ?? "";
1330
+ }
1331
+ if (request) {
1332
+ pi.sendUserMessage(buildConfigurationHandoffPrompt(request), { deliverAs: "followUp" });
1333
+ }
1334
+ } catch (error) {
1335
+ sendCommandError(pi, "monofold:update", error, updateUsage);
1336
+ }
1337
+ };
1338
+
964
1339
  pi.registerCommand("monofold:list", { description: "List configured Pi Monofold workspaces", handler: listCommand });
965
1340
  pi.registerCommand("monofold_list", { description: "Alias for /monofold:list", handler: listCommand });
966
1341
  pi.registerCommand("monofold:tree", { description: "Show a tree for a configured workspace", handler: readCommand });
@@ -971,18 +1346,48 @@ ${manifest}
971
1346
  pi.registerCommand("monofold_write", { description: "Alias for /monofold:write", handler: writeCommand });
972
1347
  pi.registerCommand("monofold:git", { description: "Run guarded workspace git status, commit, or push", handler: gitCommand });
973
1348
  pi.registerCommand("monofold_git", { description: "Alias for /monofold:git", handler: gitCommand });
974
- pi.registerCommand("monofold:add", { description: "Add a workspace to .pi/monofold.yml", handler: addCommand });
1349
+ pi.registerCommand("monofold:add", { description: `Add a workspace to ${CONFIG_RELATIVE_PATH}`, handler: addCommand });
975
1350
  pi.registerCommand("monofold_add", { description: "Alias for /monofold:add", handler: addCommand });
1351
+ pi.registerCommand("monofold:project-add", { description: "Add a project workspace under a parent workspace", handler: projectAddCommand });
1352
+ pi.registerCommand("monofold_project_add", { description: "Alias for /monofold:project-add", handler: projectAddCommand });
1353
+ pi.registerCommand("monofold:update", { description: `Migrate and validate ${CONFIG_RELATIVE_PATH}`, handler: updateCommand });
976
1354
 
977
1355
  const initCommand = async (_args: string, ctx: ExtensionCommandContext) => {
978
1356
  if (!ctx.hasUI) {
979
1357
  ctx.ui.notify("monofold:init requires interactive UI", "error");
980
1358
  return;
981
1359
  }
982
- const configPath = path.join(ctx.cwd, CONFIG_RELATIVE_PATH);
983
- const exists = await pathExists(configPath);
1360
+ const configFile = await resolveConfigFile(ctx.cwd, true);
1361
+ const configPath = configFile.configPath;
1362
+ const exists = configFile.kind !== "missing";
1363
+ const addKind = exists ? await ctx.ui.select("What do you want to add?", ["Workspace", "Project Workspace"]) : "Workspace";
1364
+ if (!addKind) return;
1365
+ if (addKind === "Project Workspace") {
1366
+ const loaded = await loadConfig(ctx.cwd);
1367
+ const parentLabels = loaded.workspaces.filter((workspace) => workspace.kind === "workspace").map(formatWorkspaceLabel);
1368
+ const parentLabel = await ctx.ui.select("Parent workspace", parentLabels);
1369
+ if (!parentLabel) return;
1370
+ const parent = loaded.workspaces.filter((workspace) => workspace.kind === "workspace")[parentLabels.indexOf(parentLabel)];
1371
+ const projectPath = await ctx.ui.input("Project path relative to parent workspace", "4_Project/Example");
1372
+ if (!projectPath) return;
1373
+ const name = await ctx.ui.input("Optional project name", "");
1374
+ const tagsInput = await ctx.ui.input("Project tags comma-separated", "project,example");
1375
+ if (!tagsInput) return;
1376
+ const capsInput = await ctx.ui.input("Optional capabilities override comma-separated", "");
1377
+ const routesInput = await ctx.ui.input("Optional routes (default path or key=path list)", "");
1378
+ const project: ProjectConfig = {
1379
+ ...(name?.trim() ? { name: name.trim() } : {}),
1380
+ path: projectPath.trim(),
1381
+ tags: tagsInput.split(",").map((s) => s.trim()).filter(Boolean),
1382
+ ...(capsInput?.trim() ? { capabilities: asCapabilityArray(capsInput.split(",").map((s) => s.trim()).filter(Boolean)) } : {}),
1383
+ ...(routesInput?.trim() ? { routes: routesInput.includes("=") ? Object.fromEntries(routesInput.split(",").map((entry) => entry.split("=").map((part) => part.trim()))) as ProjectConfig["routes"] : { default: routesInput.trim() } } : {}),
1384
+ };
1385
+ await addProjectToConfig(ctx, loaded, project, { targetId: parent.targetId });
1386
+ ctx.ui.notify(`Updated ${normalizeSlashes(path.relative(ctx.cwd, loaded.configPath))}`, "info");
1387
+ return;
1388
+ }
984
1389
  if (exists) {
985
- const ok = await ctx.ui.confirm("Existing config", `${CONFIG_RELATIVE_PATH} exists. Append a new workspace?`);
1390
+ const ok = await ctx.ui.confirm("Existing config", `${configFile.relativePath} exists. Append a new workspace?`);
986
1391
  if (!ok) return;
987
1392
  }
988
1393
  const workspacePath = await ctx.ui.input("Workspace path", "../business");
@@ -1010,11 +1415,11 @@ ${manifest}
1010
1415
  const next = exists ? `${current.trimEnd()}\n${addition}\n` : `version: 1\n\nworkspaces:\n${addition}\n`;
1011
1416
  await mkdir(path.dirname(configPath), { recursive: true });
1012
1417
  await writeFile(configPath, next, "utf8");
1013
- ctx.ui.notify(`Updated ${CONFIG_RELATIVE_PATH}`, "info");
1418
+ ctx.ui.notify(`Updated ${configFile.relativePath}`, "info");
1014
1419
  };
1015
1420
 
1016
1421
  pi.registerCommand("monofold:init", {
1017
- description: "Create or update .pi/monofold.yml with an interactive wizard",
1422
+ description: `Create or update ${CONFIG_RELATIVE_PATH} with an interactive wizard`,
1018
1423
  handler: initCommand,
1019
1424
  });
1020
1425
  pi.registerCommand("monofold_init", {
@@ -1025,7 +1430,7 @@ ${manifest}
1025
1430
  pi.registerTool({
1026
1431
  name: "monofold_init",
1027
1432
  label: "Workspace Init",
1028
- description: "Queue the interactive /monofold:init command to create or update .pi/monofold.yml.",
1433
+ description: `Queue the interactive /monofold:init command to create or update ${CONFIG_RELATIVE_PATH}.`,
1029
1434
  parameters: Type.Object({}),
1030
1435
  async execute() {
1031
1436
  pi.sendUserMessage("/monofold:init", { deliverAs: "followUp" });
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
11
11
  "type": "module",
12
- "version": "0.0.2",
12
+ "version": "0.1.0",
13
13
  "pi": {
14
14
  "extensions": [
15
15
  "./index.ts"