pi-monofold 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,1729 +1,1874 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Type } from "typebox";
3
- import { execFile } from "node:child_process";
4
- import { access, copyFile, mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from "node:fs/promises";
5
- import path from "node:path";
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 { buildFileReadResponse } from "./file-read-preview.js";
16
- import {
17
- capSearchOutput,
18
- capTreeLines,
19
- resolveSearchCaps,
20
- resolveTreeCaps,
21
- } from "./read-caps.js";
22
- import { normalizeGuardPath } from "./path-normalize.js";
23
- import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
24
-
25
- type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "git";
26
- type LegacyCapabilityTag = CapabilityTag | "gitCommit" | "gitPush";
27
- type IntentCategory = "Explore" | "Write" | "Config" | "Git";
28
- type RouteType = "default" | "prd" | "design" | "progress" | "issue" | "research" | "decision";
29
-
30
- type RouteConfig = {
31
- path: string;
32
- filenameTemplate?: string;
33
- metadata?: Record<string, unknown>;
34
- };
35
-
36
- type WorkspaceConfig = {
37
- name?: string;
38
- path: string;
39
- tags: string[];
40
- capabilities: CapabilityTag[];
41
- contextFiles?: string[];
42
- routes?: Partial<Record<RouteType, string | RouteConfig>>;
43
- projects?: ProjectConfig[];
44
- };
45
-
46
- type ProjectConfig = {
47
- name?: string;
48
- path: string;
49
- tags: string[];
50
- capabilities?: CapabilityTag[];
51
- contextFiles?: string[];
52
- routes?: Partial<Record<RouteType, string | RouteConfig>>;
53
- };
54
-
55
- type MultiWorkspaceConfig = {
56
- version: 1;
57
- defaults?: {
58
- contextFiles?: string[];
59
- filenameTemplate?: string;
60
- metadata?: Record<string, unknown>;
61
- };
62
- focusPresets?: FocusPreset[];
63
- workspaces: WorkspaceConfig[];
64
- };
65
-
66
- type ResolvedWorkspace = WorkspaceConfig & {
67
- kind: "workspace" | "project";
68
- targetId: string;
69
- index: number;
70
- projectIndex?: number;
71
- parent?: ResolvedWorkspace;
72
- displayPath: string;
73
- resolvedPath: string;
74
- realPath: string;
75
- normalizedRoutes: Partial<Record<RouteType, RouteConfig>>;
76
- effectiveContextFiles: string[];
77
- commitScope?: string;
78
- };
79
-
80
- type LoadedConfig = {
81
- configPath: string;
82
- root: string;
83
- raw: MultiWorkspaceConfig;
84
- workspaces: ResolvedWorkspace[];
85
- };
86
-
87
- type TargetInput = {
88
- targetTags?: string[];
89
- targetName?: string;
90
- targetId?: string;
91
- workspaceName?: string;
92
- workspaceIndex?: number;
93
- requireCapabilities?: CapabilityTag[];
94
- };
95
-
96
- type ParsedCommandArgs = {
97
- positional: string[];
98
- flags: Record<string, string | boolean>;
99
- };
100
-
101
- type CommandResult = {
102
- stdout: string;
103
- stderr: string;
104
- exitCode: number | string | null | undefined;
105
- };
106
-
107
- type ConfigMigrationPlan = {
108
- changed: boolean;
109
- sourcePath: string;
110
- sourceRelativePath: string;
111
- sourceKind: "canonical" | "legacy";
112
- targetPath: string;
113
- targetRelativePath: string;
114
- backupPath?: string;
115
- backupRelativePath?: string;
116
- cleanupLegacyPath?: string;
117
- cleanupLegacyBackupPath?: string;
118
- cleanupLegacyBackupRelativePath?: string;
119
- normalizedText: string;
120
- loaded: LoadedConfig;
121
- actions: string[];
122
- };
123
-
124
- const CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yaml");
125
- const LEGACY_CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yml");
126
- const ROUTE_TYPES: RouteType[] = ["default", "prd", "design", "progress", "issue", "research", "decision"];
127
- const CAPABILITIES: CapabilityTag[] = ["read", "writeDocs", "editCode", "runCommands", "git"];
128
- const LEGACY_CAPABILITIES: LegacyCapabilityTag[] = [...CAPABILITIES, "gitCommit", "gitPush"];
129
- const DOC_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]);
130
- const CODE_EXTENSIONS = new Set([
131
- ".ts",
132
- ".tsx",
133
- ".js",
134
- ".jsx",
135
- ".mjs",
136
- ".cjs",
137
- ".json",
138
- ".yml",
139
- ".yaml",
140
- ".toml",
141
- ".rs",
142
- ".go",
143
- ".py",
144
- ".rb",
145
- ".java",
146
- ".kt",
147
- ".swift",
148
- ".cs",
149
- ".cpp",
150
- ".c",
151
- ".h",
152
- ".css",
153
- ".scss",
154
- ".html",
155
- ]);
156
- const ROOT_KEYS = new Set(["version", "defaults", "focusPresets", "workspaces"]);
157
- const DEFAULT_KEYS = new Set(["contextFiles", "filenameTemplate", "metadata"]);
158
- const WORKSPACE_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes", "projects"]);
159
- const PROJECT_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes"]);
160
- const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
161
-
162
- function normalizeSlashes(value: string): string {
163
- return value.replace(/\\/g, "/");
164
- }
165
-
166
- function isInside(parent: string, child: string): boolean {
167
- const normalizedParent = normalizeGuardPath(parent);
168
- const normalizedChild = normalizeGuardPath(child);
169
- const relative = path.relative(normalizedParent, normalizedChild);
170
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
171
- }
172
-
173
- function assertWorkspaceInternalRelative(label: string, value: string): void {
174
- if (path.isAbsolute(value) || normalizeSlashes(value).split("/").includes("..")) {
175
- throw new Error(`${label} must be a workspace-internal relative path: ${value}`);
176
- }
177
- }
178
-
179
- function assertProjectPath(label: string, value: string): void {
180
- assertWorkspaceInternalRelative(label, value);
181
- const normalized = normalizeSlashes(value).replace(/^\.\//, "").replace(/\/$/, "");
182
- if (!normalized || normalized === ".") {
183
- throw new Error(`${label} must point below the parent workspace, not the parent root`);
184
- }
185
- }
186
-
187
- function asCapabilityArray(value: unknown): CapabilityTag[] {
188
- const items = asStringArray("capabilities", value);
189
- const normalized: CapabilityTag[] = [];
190
- for (const item of items) {
191
- if (!LEGACY_CAPABILITIES.includes(item as LegacyCapabilityTag)) {
192
- throw new Error(`Unknown capability: ${item}`);
193
- }
194
- normalized.push(item === "gitCommit" || item === "gitPush" ? "git" : (item as CapabilityTag));
195
- }
196
- return uniqueStrings(normalized) as CapabilityTag[];
197
- }
198
-
199
- function asOptionalCapabilityArray(value: unknown): CapabilityTag[] | undefined {
200
- if (value === undefined) return undefined;
201
- return asCapabilityArray(value);
202
- }
203
-
204
- async function existingRealPath(label: string, targetPath: string): Promise<string> {
205
- try {
206
- return await realpath(targetPath);
207
- } catch {
208
- throw new Error(`${label} does not exist: ${targetPath}`);
209
- }
210
- }
211
-
212
- function normalizeRoute(routeType: string, value: unknown): RouteConfig {
213
- if (!ROUTE_TYPES.includes(routeType as RouteType)) {
214
- throw new Error(`Unknown route type: ${routeType}`);
215
- }
216
- if (typeof value === "string") {
217
- assertWorkspaceInternalRelative(`routes.${routeType}`, value);
218
- return { path: value };
219
- }
220
- if (!isRecord(value) || typeof value.path !== "string") {
221
- throw new Error(`routes.${routeType} must be a string path or object with path`);
222
- }
223
- assertKnownKeys(`routes.${routeType}`, value, ROUTE_KEYS);
224
- assertWorkspaceInternalRelative(`routes.${routeType}.path`, value.path);
225
- if (value.filenameTemplate !== undefined && typeof value.filenameTemplate !== "string") {
226
- throw new Error(`routes.${routeType}.filenameTemplate must be a string`);
227
- }
228
- if (value.metadata !== undefined && !isRecord(value.metadata)) {
229
- throw new Error(`routes.${routeType}.metadata must be an object`);
230
- }
231
- return {
232
- path: value.path,
233
- filenameTemplate: value.filenameTemplate,
234
- metadata: value.metadata as Record<string, unknown> | undefined,
235
- };
236
- }
237
-
238
- async function pathExists(targetPath: string): Promise<boolean> {
239
- try {
240
- await access(targetPath);
241
- return true;
242
- } catch {
243
- return false;
244
- }
245
- }
246
-
247
- async function resolveConfigFile(
248
- cwd: string,
249
- allowMissing = false,
250
- options: { preferCanonicalOnConflict?: boolean } = {},
251
- ): Promise<{ configPath: string; relativePath: string; kind: "canonical" | "legacy" | "missing" }> {
252
- const normalizedCwd = normalizeGuardPath(cwd);
253
- const canonicalPath = path.join(normalizedCwd, CONFIG_RELATIVE_PATH);
254
- const legacyPath = path.join(normalizedCwd, LEGACY_CONFIG_RELATIVE_PATH);
255
- const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
256
- if (hasCanonical && hasLegacy) {
257
- if (options.preferCanonicalOnConflict) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "canonical" };
258
- throw new Error(`Configuration file conflict: both ${CONFIG_RELATIVE_PATH} and ${LEGACY_CONFIG_RELATIVE_PATH} exist. Remove one before continuing.`);
259
- }
260
- if (hasCanonical) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "canonical" };
261
- if (hasLegacy) return { configPath: legacyPath, relativePath: LEGACY_CONFIG_RELATIVE_PATH, kind: "legacy" };
262
- if (allowMissing) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "missing" };
263
- throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
264
- }
265
-
266
- function runCommand(
267
- command: string,
268
- args: string[],
269
- options: { cwd?: string; timeout?: number; signal?: AbortSignal; allowExitCodes?: Array<number | string> } = {},
270
- ): Promise<CommandResult> {
271
- const allowExitCodes = new Set<number | string>(options.allowExitCodes ?? [0]);
272
- return new Promise((resolve, reject) => {
273
- execFile(
274
- command,
275
- args,
276
- {
277
- cwd: options.cwd,
278
- timeout: options.timeout ?? 10000,
279
- signal: options.signal,
280
- windowsHide: true,
281
- maxBuffer: 1024 * 1024 * 4,
282
- },
283
- (error, stdout, stderr) => {
284
- const exitCode = typeof error === "object" && error && "code" in error ? (error as { code?: number | string }).code : 0;
285
- const result = { stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), exitCode };
286
- if (!error || allowExitCodes.has(exitCode ?? 0)) {
287
- resolve(result);
288
- return;
289
- }
290
- reject(new Error(`${command} ${args.join(" ")} failed (${String(exitCode)}): ${result.stderr || result.stdout}`));
291
- },
292
- );
293
- });
294
- }
295
-
296
- async function loadConfig(cwd: string): Promise<LoadedConfig> {
297
- const normalizedCwd = normalizeGuardPath(cwd);
298
- const { configPath } = await resolveConfigFile(normalizedCwd, false, { preferCanonicalOnConflict: true });
299
- const text = await readFile(configPath, "utf8");
300
- const parsed = YAML.parse(text, { uniqueKeys: true }) as unknown;
301
- return validateConfigObject(normalizedCwd, configPath, parsed);
302
- }
303
-
304
- async function validateConfigObject(cwd: string, configPath: string, parsed: unknown): Promise<LoadedConfig> {
305
- if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
306
- assertKnownKeys("monofold config", parsed, ROOT_KEYS);
307
- if (parsed.version !== 1) throw new Error("monofold config requires version: 1");
308
- if (!Array.isArray(parsed.workspaces) || parsed.workspaces.length === 0) {
309
- throw new Error("monofold config requires non-empty workspaces array");
310
- }
311
-
312
- const defaults = isRecord(parsed.defaults) ? parsed.defaults : undefined;
313
- if (defaults) assertKnownKeys("defaults", defaults, DEFAULT_KEYS);
314
- const defaultContextFiles = defaults ? asStringArray("defaults.contextFiles", defaults.contextFiles, false) : [];
315
- const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
316
- const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
317
- const focusPresets = parseFocusPresets(parsed.focusPresets, "focusPresets");
318
-
319
- const workspaces: ResolvedWorkspace[] = [];
320
- for (let index = 0; index < parsed.workspaces.length; index += 1) {
321
- const item = parsed.workspaces[index];
322
- if (!isRecord(item)) throw new Error(`workspaces[${index}] must be an object`);
323
- assertKnownKeys(`workspaces[${index}]`, item, WORKSPACE_KEYS);
324
- if (item.name !== undefined && typeof item.name !== "string") throw new Error(`workspaces[${index}].name must be string`);
325
- if (typeof item.path !== "string") throw new Error(`workspaces[${index}].path is required`);
326
- const tags = asStringArray(`workspaces[${index}].tags`, item.tags);
327
- const capabilities = asCapabilityArray(item.capabilities);
328
- const contextFiles = asStringArray(`workspaces[${index}].contextFiles`, item.contextFiles, false);
329
- for (const contextFile of [...defaultContextFiles, ...contextFiles]) {
330
- assertWorkspaceInternalRelative(`workspaces[${index}].contextFiles`, contextFile);
331
- }
332
-
333
- const routes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(item.routes)
334
- ? (item.routes as Partial<Record<RouteType, string | RouteConfig>>)
335
- : undefined;
336
- if (!capabilities.includes("writeDocs") && routes) {
337
- throw new Error(`workspaces[${index}] has routes but lacks writeDocs capability`);
338
- }
339
- if (capabilities.includes("writeDocs") && !routes) {
340
- throw new Error(`workspaces[${index}] has writeDocs but no routes`);
341
- }
342
-
343
- const normalizedRoutes: Partial<Record<RouteType, RouteConfig>> = {};
344
- if (routes) {
345
- for (const [routeType, routeValue] of Object.entries(routes)) {
346
- normalizedRoutes[routeType as RouteType] = normalizeRoute(routeType, routeValue);
347
- }
348
- }
349
-
350
- const resolvedPath = path.resolve(cwd, item.path);
351
- const realPath = await existingRealPath(`workspaces[${index}].path`, resolvedPath);
352
- const workspace: ResolvedWorkspace = {
353
- kind: "workspace",
354
- targetId: `#${index}`,
355
- name: item.name as string | undefined,
356
- path: item.path,
357
- displayPath: item.path,
358
- tags: uniqueStrings(tags),
359
- capabilities,
360
- contextFiles,
361
- routes,
362
- index,
363
- resolvedPath,
364
- realPath,
365
- normalizedRoutes,
366
- effectiveContextFiles: [...defaultContextFiles, ...contextFiles],
367
- };
368
- workspaces.push(workspace);
369
-
370
- const projects = Array.isArray(item.projects) ? item.projects : [];
371
- if (item.projects !== undefined && !Array.isArray(item.projects)) {
372
- throw new Error(`workspaces[${index}].projects must be an array`);
373
- }
374
- const seenProjectRealPaths = new Set<string>();
375
- for (let projectOffset = 0; projectOffset < projects.length; projectOffset += 1) {
376
- const project = projects[projectOffset];
377
- const projectIndex = projectOffset + 1;
378
- if (!isRecord(project)) throw new Error(`workspaces[${index}].projects[${projectOffset}] must be an object`);
379
- assertKnownKeys(`workspaces[${index}].projects[${projectOffset}]`, project, PROJECT_KEYS);
380
- if (project.name !== undefined && typeof project.name !== "string") throw new Error(`workspaces[${index}].projects[${projectOffset}].name must be string`);
381
- if (typeof project.path !== "string") throw new Error(`workspaces[${index}].projects[${projectOffset}].path is required`);
382
- assertProjectPath(`workspaces[${index}].projects[${projectOffset}].path`, project.path);
383
- const projectTags = asStringArray(`workspaces[${index}].projects[${projectOffset}].tags`, project.tags);
384
- if (projectTags.length === 0) throw new Error(`workspaces[${index}].projects[${projectOffset}].tags is required`);
385
- const projectCapabilities = asOptionalCapabilityArray(project.capabilities) ?? capabilities;
386
- const projectContextFiles = asStringArray(`workspaces[${index}].projects[${projectOffset}].contextFiles`, project.contextFiles, false);
387
- for (const contextFile of projectContextFiles) {
388
- assertWorkspaceInternalRelative(`workspaces[${index}].projects[${projectOffset}].contextFiles`, contextFile);
389
- }
390
- const projectRoutes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(project.routes)
391
- ? (project.routes as Partial<Record<RouteType, string | RouteConfig>>)
392
- : undefined;
393
- if (!projectCapabilities.includes("writeDocs") && projectRoutes) {
394
- throw new Error(`workspaces[${index}].projects[${projectOffset}] has routes but lacks writeDocs capability`);
395
- }
396
- const projectNormalizedRoutes: Partial<Record<RouteType, RouteConfig>> = {};
397
- if (projectRoutes) {
398
- for (const [routeType, routeValue] of Object.entries(projectRoutes)) {
399
- projectNormalizedRoutes[routeType as RouteType] = normalizeRoute(routeType, routeValue);
400
- }
401
- } else if (projectCapabilities.includes("writeDocs")) {
402
- projectNormalizedRoutes.default = { path: "." };
403
- }
404
- const projectResolvedPath = path.resolve(resolvedPath, project.path);
405
- const projectRealPath = await existingRealPath(`workspaces[${index}].projects[${projectOffset}].path`, projectResolvedPath);
406
- if (!isInside(realPath, projectRealPath) || projectRealPath === realPath) {
407
- throw new Error(`workspaces[${index}].projects[${projectOffset}].path must stay below parent workspace: ${project.path}`);
408
- }
409
- if (seenProjectRealPaths.has(projectRealPath)) throw new Error(`Duplicate project path in workspaces[${index}]: ${project.path}`);
410
- seenProjectRealPaths.add(projectRealPath);
411
- workspaces.push({
412
- kind: "project",
413
- targetId: `#${index}.${projectIndex}`,
414
- name: project.name as string | undefined,
415
- path: project.path,
416
- displayPath: normalizeSlashes(path.join(item.path, project.path)),
417
- tags: uniqueStrings([...tags, ...projectTags]),
418
- capabilities: projectCapabilities,
419
- contextFiles: projectContextFiles,
420
- routes: project.routes as ProjectConfig["routes"],
421
- index,
422
- projectIndex,
423
- parent: workspace,
424
- resolvedPath: projectResolvedPath,
425
- realPath: projectRealPath,
426
- normalizedRoutes: projectNormalizedRoutes,
427
- effectiveContextFiles: [...defaultContextFiles, ...contextFiles, ...projectContextFiles],
428
- commitScope: normalizeSlashes(path.relative(resolvedPath, projectResolvedPath)),
429
- });
430
- }
431
- }
432
-
433
- return {
434
- configPath,
435
- root: cwd,
436
- raw: {
437
- version: 1,
438
- defaults: {
439
- contextFiles: defaultContextFiles,
440
- filenameTemplate: defaultFilenameTemplate,
441
- metadata: defaultMetadata,
442
- },
443
- focusPresets: focusPresets.length > 0 ? focusPresets : undefined,
444
- workspaces: workspaces.filter((workspace) => workspace.kind === "workspace"),
445
- },
446
- workspaces,
447
- };
448
- }
449
-
450
- function timestampSuffix(now = new Date()): string {
451
- return now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "-").replace(/Z$/, "");
452
- }
453
-
454
- function normalizeCapabilityValues(value: unknown): string[] | undefined {
455
- if (value === undefined) return undefined;
456
- return asCapabilityArray(value);
457
- }
458
-
459
- function normalizeConfigCapabilities(config: Record<string, unknown>): void {
460
- const workspaces = Array.isArray(config.workspaces) ? (config.workspaces as unknown[]) : [];
461
- for (const workspace of workspaces) {
462
- if (!isRecord(workspace)) continue;
463
- const capabilities = normalizeCapabilityValues(workspace.capabilities);
464
- if (capabilities) workspace.capabilities = capabilities;
465
- const projects = Array.isArray(workspace.projects) ? (workspace.projects as unknown[]) : [];
466
- for (const project of projects) {
467
- if (!isRecord(project)) continue;
468
- const projectCapabilities = normalizeCapabilityValues(project.capabilities);
469
- if (projectCapabilities) project.capabilities = projectCapabilities;
470
- }
471
- }
472
- }
473
-
474
- function normalizeConfigForMigration(parsed: unknown): Record<string, unknown> {
475
- if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
476
- assertKnownKeys("monofold config", parsed, ROOT_KEYS);
477
- const version = parsed.version ?? 1;
478
- if (version !== 1) throw new Error("monofold config requires version: 1");
479
- const normalized: Record<string, unknown> = { version: 1 };
480
- if (parsed.defaults !== undefined) normalized.defaults = parsed.defaults;
481
- if (parsed.focusPresets !== undefined) normalized.focusPresets = parsed.focusPresets;
482
- normalized.workspaces = parsed.workspaces;
483
- normalizeConfigCapabilities(normalized);
484
- return normalized;
485
- }
486
-
487
- async function buildConfigMigrationPlan(cwd: string): Promise<ConfigMigrationPlan> {
488
- const canonicalPath = path.join(cwd, CONFIG_RELATIVE_PATH);
489
- const legacyPath = path.join(cwd, LEGACY_CONFIG_RELATIVE_PATH);
490
- const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
491
- const source = await resolveConfigFile(cwd, false, { preferCanonicalOnConflict: true });
492
- if (source.kind === "missing") throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
493
-
494
- const originalText = await readFile(source.configPath, "utf8");
495
- const parsed = YAML.parse(originalText, { uniqueKeys: true }) as unknown;
496
- const normalized = normalizeConfigForMigration(parsed);
497
- const targetPath = path.join(cwd, CONFIG_RELATIVE_PATH);
498
- const normalizedText = YAML.stringify(normalized).trimEnd() + "\n";
499
- const loaded = await validateConfigObject(cwd, targetPath, normalized);
500
- const cleanupLegacy = hasCanonical && hasLegacy;
501
- const changed = source.kind !== "canonical" || originalText !== normalizedText || cleanupLegacy;
502
- const actions: string[] = [];
503
- if (source.kind === "legacy") actions.push(`Move legacy config ${LEGACY_CONFIG_RELATIVE_PATH} to canonical ${CONFIG_RELATIVE_PATH}`);
504
- if (originalText !== normalizedText) actions.push("Normalize YAML and ensure version: 1 is explicit");
505
- if (source.kind === "legacy" || cleanupLegacy) actions.push(`Remove legacy config ${LEGACY_CONFIG_RELATIVE_PATH} after writing ${CONFIG_RELATIVE_PATH}`);
506
- if (!changed) actions.push(`Already current: ${CONFIG_RELATIVE_PATH} (version 1)`);
507
- const suffix = timestampSuffix();
508
- const backupPath = changed && (source.kind === "legacy" || originalText !== normalizedText) ? `${source.configPath}.bak-${suffix}` : undefined;
509
- const cleanupLegacyBackupPath = cleanupLegacy ? `${legacyPath}.bak-${suffix}` : undefined;
510
- return {
511
- changed,
512
- sourcePath: source.configPath,
513
- sourceRelativePath: source.relativePath,
514
- sourceKind: source.kind,
515
- targetPath,
516
- targetRelativePath: CONFIG_RELATIVE_PATH,
517
- backupPath,
518
- backupRelativePath: backupPath ? normalizeSlashes(path.relative(cwd, backupPath)) : undefined,
519
- cleanupLegacyPath: cleanupLegacy ? legacyPath : undefined,
520
- cleanupLegacyBackupPath,
521
- cleanupLegacyBackupRelativePath: cleanupLegacyBackupPath ? normalizeSlashes(path.relative(cwd, cleanupLegacyBackupPath)) : undefined,
522
- normalizedText,
523
- loaded,
524
- actions,
525
- };
526
- }
527
-
528
- async function applyConfigMigrationPlan(plan: ConfigMigrationPlan): Promise<void> {
529
- if (!plan.changed) return;
530
- await mkdir(path.dirname(plan.targetPath), { recursive: true });
531
- if (plan.backupPath) await copyFile(plan.sourcePath, plan.backupPath);
532
- if (plan.cleanupLegacyPath && plan.cleanupLegacyBackupPath) await copyFile(plan.cleanupLegacyPath, plan.cleanupLegacyBackupPath);
533
- await writeFile(plan.targetPath, plan.normalizedText, "utf8");
534
- if (plan.sourceKind === "legacy") await unlink(plan.sourcePath);
535
- if (plan.cleanupLegacyPath) await unlink(plan.cleanupLegacyPath);
536
- }
537
-
538
- async function prepareIntentConfiguration(ctx: ExtensionCommandContext): Promise<boolean> {
539
- const canonicalPath = path.join(ctx.cwd, CONFIG_RELATIVE_PATH);
540
- const legacyPath = path.join(ctx.cwd, LEGACY_CONFIG_RELATIVE_PATH);
541
- const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
542
- if (!hasCanonical && !hasLegacy) {
543
- ctx.ui.notify(`No ${CONFIG_RELATIVE_PATH} found. Queueing /monofold:init.`, "info");
544
- return false;
545
- }
546
- if (!hasCanonical && hasLegacy) {
547
- try {
548
- const plan = await buildConfigMigrationPlan(ctx.cwd);
549
- await applyConfigMigrationPlan(plan);
550
- if (plan.changed) {
551
- ctx.ui.notify(`Migrated ${LEGACY_CONFIG_RELATIVE_PATH} to ${CONFIG_RELATIVE_PATH}${plan.backupRelativePath ? ` (backup: ${plan.backupRelativePath})` : ""}.`, "info");
552
- }
553
- } catch (error) {
554
- const message = error instanceof Error ? error.message : String(error);
555
- ctx.ui.notify(`Legacy migration failed; continuing with ${LEGACY_CONFIG_RELATIVE_PATH}: ${message}`, "error");
556
- }
557
- }
558
- return true;
559
- }
560
-
561
- function formatMigrationPlan(plan: ConfigMigrationPlan): string {
562
- if (!plan.changed) return `Already current: ${plan.targetRelativePath} (version ${plan.loaded.raw.version})`;
563
- return [
564
- `Source: ${plan.sourceRelativePath}`,
565
- `Target: ${plan.targetRelativePath}`,
566
- `Backup: ${plan.backupRelativePath ?? plan.cleanupLegacyBackupRelativePath ?? "none"}`,
567
- ...(plan.backupRelativePath && plan.cleanupLegacyBackupRelativePath ? [`Legacy backup: ${plan.cleanupLegacyBackupRelativePath}`] : []),
568
- "",
569
- "Actions:",
570
- ...plan.actions.map((action) => `- ${action}`),
571
- ].join("\n");
572
- }
573
-
574
- function buildConfigurationHandoffPrompt(request: string): string {
575
- return [
576
- "/monofold:update completed. Apply this requested Pi Monofold configuration change to `.pi/monofold.yaml`.",
577
- "Edit the canonical config directly when needed, preserve valid YAML, then run `monofold_list` as manifest validation.",
578
- "",
579
- "User request:",
580
- request.trim(),
581
- ].join("\n");
582
- }
583
-
584
- function buildIntentHandoffPrompt(intent: IntentCategory, request: string): string {
585
- const trimmed = request.trim();
586
- const emptyInstruction =
587
- intent === "Explore"
588
- ? "Ask what the user wants to list, read, search, or inspect."
589
- : intent === "Write"
590
- ? "Ask what document to create and where it should be saved."
591
- : intent === "Config"
592
- ? "Ask what Workspace or Project Workspace configuration change is needed."
593
- : "Ask whether the user wants git status, commit, push, or commit+push.";
594
- return [
595
- `Pi Monofold ${intent} request. Interpret the user's natural-language input and continue as the agent.`,
596
- "",
597
- "Rules:",
598
- "- Use strict `monofold_*` tools as the execution API; do not ask the user to write JSON or YAML unless needed.",
599
- "- Select a Workspace Target automatically only when the manifest makes it unique; if multiple targets match, ask one clarifying question.",
600
- "- Ask missing information incrementally, one question at a time.",
601
- "- Explore intent: read/search/tree/list is read-only; execute immediately when target and path/query are clear.",
602
- "- Write intent: infer route/title/body/filename when possible, but confirm Workspace, route, and filename before calling `monofold_write`.",
603
- "- Config intent: edit `.pi/monofold.yaml` only after showing the YAML diff; validate afterward with `monofold_list`.",
604
- "- Git intent: use `monofold_git`; for commit+push use action `commitPush` and one combined confirmation. If message is missing, propose one from the diff.",
605
- "- If there is no `.pi/monofold.yaml`, guide the user to `/monofold:init`.",
606
- "",
607
- "Intent:",
608
- intent,
609
- "",
610
- "User request:",
611
- trimmed || `(empty input) ${emptyInstruction}`,
612
- ].join("\n");
613
- }
614
-
615
- function buildGuideHandoffPrompt(request: string): string {
616
- return [
617
- "Pi Monofold guide request. Start a conversational helper flow for Pi Monofold.",
618
- "",
619
- "Guide the user through Explore, Write, Config, Git, init, or update. Do not dump a static help page.",
620
- "Ask one question at a time, then route to the appropriate intent behavior:",
621
- "- Explore: list/read/search/tree workspaces.",
622
- "- Write: routed Markdown output.",
623
- "- Config: Workspace or Project Workspace configuration changes with YAML diff confirmation.",
624
- "- Git: status/commit/push/commit+push via `monofold_git`.",
625
- "- Init: queue or instruct `/monofold:init`.",
626
- "- Update: run or instruct `/monofold:update` for migration/cleanup.",
627
- "",
628
- "Initial user request:",
629
- request.trim() || "(empty input) Ask what they want to do with Pi Monofold.",
630
- ].join("\n");
631
- }
632
-
633
- function matchesTarget(workspace: ResolvedWorkspace, target: TargetInput): boolean {
634
- if (target.targetId && workspace.targetId !== (target.targetId.startsWith("#") ? target.targetId : `#${target.targetId}`)) return false;
635
- if (target.workspaceIndex !== undefined && workspace.index !== target.workspaceIndex) return false;
636
- const targetName = target.targetName ?? target.workspaceName;
637
- if (targetName && workspace.name !== targetName) return false;
638
- if (target.targetTags?.length && !target.targetTags.every((tag) => workspace.tags.includes(tag))) return false;
639
- if (target.requireCapabilities?.length) {
640
- if (!target.requireCapabilities.every((cap) => workspace.capabilities.includes(cap))) return false;
641
- }
642
- return true;
643
- }
644
-
645
- function splitCommandArgs(input: string): string[] {
646
- const tokens: string[] = [];
647
- const pattern = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g;
648
- let match: RegExpExecArray | null;
649
- while ((match = pattern.exec(input))) {
650
- const token = match[1] ?? match[2] ?? match[3] ?? "";
651
- tokens.push(token.replace(/\\(["\\])/g, "$1"));
652
- }
653
- return tokens;
654
- }
655
-
656
- function parseCommandArgs(input: string): ParsedCommandArgs {
657
- const positional: string[] = [];
658
- const flags: Record<string, string | boolean> = {};
659
- const tokens = splitCommandArgs(input);
660
- for (let index = 0; index < tokens.length; index += 1) {
661
- const token = tokens[index];
662
- if (!token.startsWith("--") || token === "--") {
663
- positional.push(token);
664
- continue;
665
- }
666
- const raw = token.slice(2);
667
- const equalsIndex = raw.indexOf("=");
668
- if (equalsIndex >= 0) {
669
- flags[raw.slice(0, equalsIndex)] = raw.slice(equalsIndex + 1);
670
- continue;
671
- }
672
- const next = tokens[index + 1];
673
- if (next && !next.startsWith("--")) {
674
- flags[raw] = next;
675
- index += 1;
676
- } else {
677
- flags[raw] = true;
678
- }
679
- }
680
- return { positional, flags };
681
- }
682
-
683
- function stringFlag(flags: Record<string, string | boolean>, ...names: string[]): string | undefined {
684
- for (const name of names) {
685
- const value = flags[name];
686
- if (typeof value === "string" && value.trim()) return value.trim();
687
- }
688
- return undefined;
689
- }
690
-
691
- function commandTarget(flags: Record<string, string | boolean>, requireCapabilities?: CapabilityTag[]): TargetInput {
692
- const workspace = stringFlag(flags, "target", "workspace", "w");
693
- const tags = stringFlag(flags, "tags", "tag")
694
- ?.split(",")
695
- .map((item) => item.trim())
696
- .filter(Boolean);
697
- const workspaceIndex = workspace?.startsWith("#") && !workspace.includes(".") ? Number.parseInt(workspace.slice(1), 10) : undefined;
698
- return {
699
- ...(workspace?.startsWith("#") ? { targetId: workspace } : workspace ? { targetName: workspace } : {}),
700
- ...(workspaceIndex !== undefined && Number.isFinite(workspaceIndex) ? { workspaceIndex } : {}),
701
- ...(tags?.length ? { targetTags: tags } : {}),
702
- requireCapabilities,
703
- };
704
- }
705
-
706
- function metadataFlag(flags: Record<string, string | boolean>): Record<string, string> {
707
- const raw = stringFlag(flags, "meta", "metadata");
708
- if (!raw) return {};
709
- return Object.fromEntries(
710
- raw
711
- .split(",")
712
- .map((item) => item.trim())
713
- .filter(Boolean)
714
- .map((item) => {
715
- const equalsIndex = item.indexOf("=");
716
- if (equalsIndex < 0) return [item, ""];
717
- return [item.slice(0, equalsIndex).trim(), item.slice(equalsIndex + 1).trim()];
718
- })
719
- .filter(([key]) => key),
720
- );
721
- }
722
-
723
- function commaListFlag(flags: Record<string, string | boolean>, ...names: string[]): string[] {
724
- const raw = stringFlag(flags, ...names);
725
- if (!raw) return [];
726
- return raw
727
- .split(",")
728
- .map((item) => item.trim())
729
- .filter(Boolean);
730
- }
731
-
732
- function routesFlag(flags: Record<string, string | boolean>): WorkspaceConfig["routes"] | undefined {
733
- const raw = stringFlag(flags, "routes", "route");
734
- if (!raw) return undefined;
735
- if (!raw.includes("=")) return { default: raw };
736
- const routes: Partial<Record<RouteType, string>> = {};
737
- for (const item of raw.split(",").map((part) => part.trim()).filter(Boolean)) {
738
- const equalsIndex = item.indexOf("=");
739
- if (equalsIndex < 0) throw new Error(`Route entry must be routeType=path: ${item}`);
740
- const routeType = item.slice(0, equalsIndex).trim() as RouteType;
741
- const routePath = item.slice(equalsIndex + 1).trim();
742
- if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown route type: ${routeType}`);
743
- if (!routePath) throw new Error(`Route path is empty for ${routeType}`);
744
- routes[routeType] = routePath;
745
- }
746
- return routes;
747
- }
748
-
749
- function buildWorkspaceFromAddArgs(args: string): WorkspaceConfig {
750
- const parsed = parseCommandArgs(args);
751
- const workspacePath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional[0];
752
- if (!workspacePath) throw new Error("workspace path is required");
753
- const capabilities = commaListFlag(parsed.flags, "capabilities", "caps", "cap");
754
- if (capabilities.length === 0) throw new Error("--capabilities is required");
755
- const workspaceBlock: WorkspaceConfig = {
756
- ...(stringFlag(parsed.flags, "name", "n") ? { name: stringFlag(parsed.flags, "name", "n") } : {}),
757
- path: workspacePath,
758
- tags: commaListFlag(parsed.flags, "tags", "tag"),
759
- capabilities: asCapabilityArray(capabilities),
760
- contextFiles: commaListFlag(parsed.flags, "context", "contexts", "contextFiles"),
761
- ...(routesFlag(parsed.flags) ? { routes: routesFlag(parsed.flags) } : {}),
762
- };
763
- if (workspaceBlock.tags.length === 0) throw new Error("--tags is required");
764
- if (workspaceBlock.contextFiles?.length === 0) delete workspaceBlock.contextFiles;
765
- if (workspaceBlock.capabilities.includes("writeDocs") && !workspaceBlock.routes) {
766
- throw new Error("workspaces with writeDocs require --route or --routes");
767
- }
768
- return workspaceBlock;
769
- }
770
-
771
- async function addWorkspaceToConfig(configPath: string, workspaceBlock: WorkspaceConfig): Promise<void> {
772
- const exists = await pathExists(configPath);
773
- const parsed = exists ? (YAML.parse(await readFile(configPath, "utf8"), { uniqueKeys: true }) as unknown) : { version: 1, workspaces: [] };
774
- if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
775
- if (parsed.version === undefined) parsed.version = 1;
776
- if (parsed.version !== 1) throw new Error("monofold config requires version: 1");
777
- const workspaces = Array.isArray(parsed.workspaces) ? parsed.workspaces : [];
778
- parsed.workspaces = workspaces;
779
- if (workspaces.some((item: unknown) => isRecord(item) && item.path === workspaceBlock.path)) {
780
- throw new Error(`workspace path already exists: ${workspaceBlock.path}`);
781
- }
782
- workspaces.push(workspaceBlock);
783
- await mkdir(path.dirname(configPath), { recursive: true });
784
- await writeFile(configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
785
- }
786
-
787
- function buildProjectFromAddArgs(args: string): { project: ProjectConfig; parent: TargetInput } {
788
- const parsed = parseCommandArgs(args);
789
- const projectPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional[0];
790
- if (!projectPath) throw new Error("project path is required");
791
- const tags = commaListFlag(parsed.flags, "tags", "tag");
792
- if (tags.length === 0) throw new Error("--tags is required");
793
- const capabilities = commaListFlag(parsed.flags, "capabilities", "caps", "cap");
794
- const parentName = stringFlag(parsed.flags, "parent");
795
- const parentTags = commaListFlag(parsed.flags, "parent-tags", "parentTags");
796
- if (!parentName && parentTags.length === 0) throw new Error("--parent or --parent-tags is required");
797
- const project: ProjectConfig = {
798
- ...(stringFlag(parsed.flags, "name", "n") ? { name: stringFlag(parsed.flags, "name", "n") } : {}),
799
- path: projectPath,
800
- tags,
801
- ...(capabilities.length ? { capabilities: asCapabilityArray(capabilities) } : {}),
802
- contextFiles: commaListFlag(parsed.flags, "context", "contexts", "contextFiles"),
803
- ...(routesFlag(parsed.flags) ? { routes: routesFlag(parsed.flags) } : {}),
804
- };
805
- if (project.contextFiles?.length === 0) delete project.contextFiles;
806
- return {
807
- project,
808
- parent: {
809
- ...(parentName?.startsWith("#") ? { targetId: parentName } : parentName ? { targetName: parentName } : {}),
810
- ...(parentTags.length ? { targetTags: parentTags } : {}),
811
- },
812
- };
813
- }
814
-
815
- async function addProjectToConfig(ctx: ExtensionCommandContext, loaded: LoadedConfig, project: ProjectConfig, parentInput: TargetInput): Promise<void> {
816
- const parent = await resolveWorkspace(ctx, { ...loaded, workspaces: loaded.workspaces.filter((workspace) => workspace.kind === "workspace") }, parentInput);
817
- assertProjectPath("project.path", project.path);
818
- const projectRealPath = await existingRealPath("project.path", path.resolve(parent.resolvedPath, project.path));
819
- if (!isInside(parent.realPath, projectRealPath) || projectRealPath === parent.realPath) throw new Error(`project path must stay below parent workspace: ${project.path}`);
820
- if (loaded.workspaces.some((workspace) => workspace.parent === parent && workspace.realPath === projectRealPath)) throw new Error(`project path already exists under parent: ${project.path}`);
821
- const parsed = YAML.parse(await readFile(loaded.configPath, "utf8"), { uniqueKeys: true }) as Record<string, unknown>;
822
- const workspaces = parsed.workspaces as Record<string, unknown>[];
823
- const rawParent = workspaces[parent.index];
824
- const projects = Array.isArray(rawParent.projects) ? rawParent.projects : [];
825
- rawParent.projects = projects;
826
- projects.push(project);
827
- await writeFile(loaded.configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
828
- }
829
-
830
- function sendCommandOutput(pi: ExtensionAPI, title: string, text: string, details?: Record<string, unknown>) {
831
- pi.sendMessage({
832
- customType: "monofold-output",
833
- content: `## ${title}\n\n${text}`,
834
- display: true,
835
- details: details ?? {},
836
- });
837
- }
838
-
839
- function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usage: string) {
840
- const message = error instanceof Error ? error.message : String(error);
841
- sendCommandOutput(pi, command, `Error: ${message}\n\nUsage:\n${usage}`, { error: message });
842
- }
843
-
844
- async function resolveWorkspace(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
845
- const matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
846
- if (matches.length === 0) throw new Error(`No workspace matches target: ${JSON.stringify(target)}`);
847
- if (matches.length === 1) return matches[0];
848
- if (!ctx.hasUI) {
849
- throw new Error(`Multiple workspaces match target in non-interactive mode: ${matches.map(formatWorkspaceLabel).join(", ")}`);
850
- }
851
- const labels = matches.map(formatWorkspaceLabel);
852
- const choice = await ctx.ui.select("Select workspace", labels);
853
- if (!choice) throw new Error("Workspace selection cancelled");
854
- return matches[labels.indexOf(choice)];
855
- }
856
-
857
- function formatWorkspaceLabel(workspace: ResolvedWorkspace): string {
858
- const displayName = workspace.name ? `${workspace.name} ` : "";
859
- const parent = workspace.kind === "project" && workspace.parent ? ` parent=${workspace.parent.name ?? workspace.parent.targetId}` : "";
860
- return `${workspace.targetId} ${displayName}[${workspace.tags.join(", ")}] ${workspace.displayPath}${parent}`;
861
- }
862
-
863
- function relativePath(workspace: ResolvedWorkspace, inputPath: string): string {
864
- assertWorkspaceInternalRelative("path", inputPath);
865
- return path.join(workspace.resolvedPath, inputPath);
866
- }
867
-
868
- async function gitSummary(workspace: ResolvedWorkspace): Promise<{ isGit: boolean; status?: string }> {
869
- const root = await gitRoot(workspace);
870
- if (!root) return { isGit: false };
871
- const result = await runCommand("git", ["-C", root, "status", "--short"], { timeout: 5000 });
872
- return { isGit: true, status: result.stdout.trim() || "clean" };
873
- }
874
-
875
- async function gitRoot(workspace: ResolvedWorkspace): Promise<string | undefined> {
876
- if (await pathExists(path.join(workspace.resolvedPath, ".git"))) return workspace.resolvedPath;
877
- if (workspace.parent && (await pathExists(path.join(workspace.parent.resolvedPath, ".git")))) return workspace.parent.resolvedPath;
878
- return undefined;
879
- }
880
-
881
- async function buildManifest(loaded: LoadedConfig): Promise<string> {
882
- const lines = ["Pi Monofold Manifest:"];
883
- for (const workspace of loaded.workspaces) {
884
- const git = await gitSummary(workspace).catch((error) => ({ isGit: false, status: `git status error: ${String(error)}` }));
885
- lines.push(
886
- `- ${formatWorkspaceLabel(workspace)}\n` +
887
- ` capabilities: ${workspace.capabilities.join(", ")}\n` +
888
- ` routes: ${Object.keys(workspace.normalizedRoutes).join(", ") || "none"}\n` +
889
- ` contextFiles: ${workspace.effectiveContextFiles.join(", ") || "none"}\n` +
890
- ` git: ${git.isGit ? git.status : "not a git repository"}`,
891
- );
892
- }
893
- lines.push("Use monofold_* tools for cross-workspace operations. Do not guess output paths when a route exists.");
894
- return lines.join("\n");
895
- }
896
-
897
- type ShallowTreeBudget = {
898
- remaining: number;
899
- truncated: boolean;
900
- };
901
-
902
- async function shallowTree(
903
- root: string,
904
- depth: number,
905
- prefix = "",
906
- budget?: ShallowTreeBudget,
907
- ): Promise<string[]> {
908
- if (depth < 0) return [];
909
- if (budget && budget.remaining <= 0) {
910
- budget.truncated = true;
911
- return [];
912
- }
913
- const entries = await readdir(path.join(root, prefix), { withFileTypes: true });
914
- const lines: string[] = [];
915
- for (const entry of entries.filter((e) => !e.name.startsWith(".git") && e.name !== "node_modules")) {
916
- if (budget && budget.remaining <= 0) {
917
- budget.truncated = true;
918
- break;
919
- }
920
- const rel = normalizeSlashes(path.join(prefix, entry.name));
921
- lines.push(entry.isDirectory() ? `${rel}/` : rel);
922
- if (budget) budget.remaining -= 1;
923
- if (entry.isDirectory() && depth > 0) {
924
- lines.push(...(await shallowTree(root, depth - 1, rel, budget)));
925
- }
926
- }
927
- return lines;
928
- }
929
-
930
- function slugify(title: string): string {
931
- const normalized = title
932
- .trim()
933
- .toLowerCase()
934
- .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, "")
935
- .replace(/\s+/g, "-")
936
- .replace(/-+/g, "-")
937
- .replace(/^-|-$/g, "");
938
- return normalized || "note";
939
- }
940
-
941
- function renderTemplate(template: string, vars: Record<string, string>): string {
942
- return template.replace(/\{\{(date|datetime|title|slug|routeType|workspaceName|workspaceTags|targetName|targetTags|parentWorkspaceName|projectName)\}\}/g, (_, key: string) => vars[key] ?? "");
943
- }
944
-
945
- function renderMetadata(value: unknown, vars: Record<string, string>): unknown {
946
- if (typeof value === "string") return renderTemplate(value, vars);
947
- if (Array.isArray(value)) return value.map((item) => renderMetadata(item, vars));
948
- if (isRecord(value)) {
949
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderMetadata(item, vars)]));
950
- }
951
- return value;
952
- }
953
-
954
- function frontmatter(metadata: Record<string, unknown>): string {
955
- if (Object.keys(metadata).length === 0) return "";
956
- return `---\n${YAML.stringify(metadata).trim()}\n---\n\n`;
957
- }
958
-
959
- function classifyPath(targetPath: string): "docs" | "code" | "unknown" {
960
- const ext = path.extname(targetPath).toLowerCase();
961
- if (DOC_EXTENSIONS.has(ext)) return "docs";
962
- if (CODE_EXTENSIONS.has(ext)) return "code";
963
- return "unknown";
964
- }
965
-
966
- function findWorkspaceForPath(loaded: LoadedConfig, targetPath: string): ResolvedWorkspace | undefined {
967
- const absolute = normalizeGuardPath(path.isAbsolute(targetPath) ? targetPath : path.resolve(loaded.root, targetPath));
968
- return [...loaded.workspaces].sort((a, b) => b.resolvedPath.length - a.resolvedPath.length).find((workspace) => isInside(workspace.resolvedPath, absolute));
969
- }
970
-
971
- async function confirm(ctx: ExtensionContext, title: string, body: string): Promise<boolean> {
972
- if (!ctx.hasUI) return false;
973
- return ctx.ui.confirm(title, body);
974
- }
975
-
976
- async function maybeBlockUnknown(ctx: ExtensionContext, loaded: LoadedConfig, targetPath: string, action: string) {
977
- const workspace = findWorkspaceForPath(loaded, targetPath);
978
- if (workspace) return undefined;
979
- const ok = await confirm(ctx, "Unknown Path", `${action} targets an unknown path:\n${targetPath}\nAllow this operation?`);
980
- if (!ok) return { block: true, reason: `Unknown Path requires confirmation: ${targetPath}` };
981
- return undefined;
982
- }
983
-
984
- async function guardPathOperation(ctx: ExtensionContext, loaded: LoadedConfig, targetPath: string, action: "read" | "write" | "edit") {
985
- const workspace = findWorkspaceForPath(loaded, targetPath);
986
- if (!workspace) return maybeBlockUnknown(ctx, loaded, targetPath, action);
987
- if (action === "read") {
988
- if (!workspace.capabilities.includes("read")) return { block: true, reason: `Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}` };
989
- return undefined;
990
- }
991
- const kind = classifyPath(targetPath);
992
- if (kind === "docs" && workspace.capabilities.includes("writeDocs")) return undefined;
993
- if (kind === "code" && workspace.capabilities.includes("editCode")) return undefined;
994
- if (kind === "unknown") {
995
- const ok = await confirm(
996
- ctx,
997
- "Unclassified file write",
998
- `${action} targets an unclassified file in ${formatWorkspaceLabel(workspace)}:\n${targetPath}\nAllow?`,
999
- );
1000
- if (ok) return undefined;
1001
- }
1002
- return { block: true, reason: `Workspace lacks capability for ${kind} ${action}: ${formatWorkspaceLabel(workspace)}` };
1003
- }
1004
-
1005
- function bashLooksDangerous(command: string): string | undefined {
1006
- const normalized = command.toLowerCase();
1007
- if (/rm\s+(-[^\n;]*r[^\n;]*f|-rf|-fr)/.test(normalized)) return "rm -rf";
1008
- if (/git\s+reset\s+--hard/.test(normalized)) return "git reset --hard";
1009
- if (/git\s+clean\b/.test(normalized)) return "git clean";
1010
- if (/chmod\s+-r/.test(normalized)) return "chmod -R";
1011
- return undefined;
1012
- }
1013
-
1014
- function bashContainsGitCommitOrPush(command: string): boolean {
1015
- return /(^|[;&|]\s*)git\s+(commit|push)\b/i.test(command);
1016
- }
1017
-
1018
- function inferBashCwd(ctx: ExtensionContext, command: string): string {
1019
- const baseCwd = normalizeGuardPath(ctx.cwd);
1020
- const match = command.match(/(?:^|[;&|]\s*)cd\s+([^;&|\n]+)/);
1021
- if (!match) return baseCwd;
1022
- const raw = match[1].trim().replace(/^['"]|['"]$/g, "");
1023
- return normalizeGuardPath(path.resolve(baseCwd, raw));
1024
- }
1025
-
1026
- export default function piMultiWorkspace(pi: ExtensionAPI) {
1027
- pi.on("session_start", async (_event, ctx) => {
1028
- try {
1029
- const loaded = await loadConfig(ctx.cwd);
1030
- ensureActiveFocusInitialized(loaded.raw.focusPresets);
1031
- const activeId = getActiveFocusPresetId();
1032
- if (!activeId) return;
1033
- const preset = findFocusPresetById(loaded.raw.focusPresets, activeId);
1034
- if (!preset) return;
1035
- if (!ctx.hasUI) return;
1036
- warnZeroTargetMatchesForPreset(preset, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
1037
- } catch {
1038
- return;
1039
- }
1040
- });
1041
-
1042
- pi.on("before_agent_start", async (_event, ctx) => {
1043
- try {
1044
- const loaded = await loadConfig(ctx.cwd);
1045
- const manifest = await buildManifest(loaded);
1046
- return {
1047
- systemPrompt:
1048
- _event.systemPrompt +
1049
- `
1050
-
1051
- ## Pi Monofold
1052
-
1053
- ${manifest}
1054
- `,
1055
- };
1056
- } catch {
1057
- return undefined;
1058
- }
1059
- });
1060
-
1061
- pi.registerTool({
1062
- name: "monofold_list",
1063
- label: "Workspace List",
1064
- description: "List configured Pi Monofold workspaces with tags, capabilities, routes, context files, and git status.",
1065
- parameters: Type.Object({}),
1066
- async execute(_id, _params, _signal, _onUpdate, ctx) {
1067
- const loaded = await loadConfig(ctx.cwd);
1068
- const manifest = await buildManifest(loaded);
1069
- return { content: [{ type: "text", text: manifest }], details: { workspaces: loaded.workspaces } };
1070
- },
1071
- });
1072
-
1073
- pi.registerTool({
1074
- name: "monofold_read",
1075
- label: "Workspace Read",
1076
- description: "Read, search, or list files inside a configured Workspace. Requires read capability.",
1077
- parameters: Type.Object({
1078
- mode: Type.String({ description: "file, search, or tree" }),
1079
- path: Type.Optional(Type.String({ description: "Workspace-relative path for file/tree" })),
1080
- query: Type.Optional(Type.String({ description: "Search query for mode=search" })),
1081
- depth: Type.Optional(Type.Number({ description: "Tree depth, default 1" })),
1082
- includeContent: Type.Optional(
1083
- Type.Boolean({ description: "mode=file only: return full file content instead of a bounded preview" }),
1084
- ),
1085
- maxChars: Type.Optional(
1086
- Type.Number({ description: "mode=file: max preview characters; mode=search: max output characters before truncation (default 8000)" }),
1087
- ),
1088
- head: Type.Optional(
1089
- Type.Number({ description: "mode=file only: include the first N lines when building a bounded preview" }),
1090
- ),
1091
- tail: Type.Optional(
1092
- Type.Number({ description: "mode=file only: include the last N lines when building a bounded preview" }),
1093
- ),
1094
- maxMatches: Type.Optional(Type.Integer({ minimum: 1, description: "Search: max match lines before truncation (default 50)" })),
1095
- maxEntries: Type.Optional(Type.Integer({ minimum: 1, description: "Tree: max entries before truncation (default 200)" })),
1096
- targetTags: Type.Optional(Type.Array(Type.String())),
1097
- targetName: Type.Optional(Type.String()),
1098
- targetId: Type.Optional(Type.String()),
1099
- workspaceName: Type.Optional(Type.String()),
1100
- requireCapabilities: Type.Optional(Type.Array(Type.String())),
1101
- }),
1102
- async execute(_id, params, signal, _onUpdate, ctx) {
1103
- const loaded = await loadConfig(ctx.cwd);
1104
- const workspace = await resolveWorkspace(ctx, loaded, {
1105
- targetTags: params.targetTags,
1106
- targetName: params.targetName,
1107
- targetId: params.targetId,
1108
- workspaceName: params.workspaceName,
1109
- requireCapabilities: ["read"],
1110
- });
1111
- if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
1112
- if (params.mode === "file") {
1113
- if (!params.path) throw new Error("monofold_read mode=file requires path");
1114
- const filePath = relativePath(workspace, params.path);
1115
- const [content, fileStat] = await Promise.all([
1116
- readFile(filePath, "utf8"),
1117
- stat(filePath),
1118
- ]);
1119
- const preview = buildFileReadResponse(
1120
- content,
1121
- {
1122
- includeContent: params.includeContent,
1123
- maxChars: params.maxChars,
1124
- head: params.head,
1125
- tail: params.tail,
1126
- },
1127
- { size: fileStat.size, mtime: fileStat.mtime },
1128
- { relativePath: params.path },
1129
- );
1130
- return {
1131
- content: [{ type: "text", text: preview.text }],
1132
- details: {
1133
- workspace: formatWorkspaceLabel(workspace),
1134
- path: params.path,
1135
- ...preview.details,
1136
- },
1137
- };
1138
- }
1139
- if (params.mode === "tree") {
1140
- const root = params.path ? relativePath(workspace, params.path) : workspace.resolvedPath;
1141
- const depth = Math.max(0, Math.min(5, params.depth ?? 1));
1142
- const treeCaps = resolveTreeCaps({ maxEntries: params.maxEntries });
1143
- const treeBudget: ShallowTreeBudget = { remaining: treeCaps.maxEntries, truncated: false };
1144
- const rawLines = await shallowTree(root, depth, "", treeBudget);
1145
- const capped = capTreeLines(rawLines, treeCaps, treeBudget.truncated);
1146
- return {
1147
- content: [{ type: "text", text: capped.text }],
1148
- details: {
1149
- workspace: formatWorkspaceLabel(workspace),
1150
- path: params.path ?? ".",
1151
- entryCount: capped.entryCount,
1152
- returnedEntryCount: capped.returnedEntryCount,
1153
- maxEntries: capped.maxEntries,
1154
- truncated: capped.truncated,
1155
- ...(capped.hint ? { hint: capped.hint } : {}),
1156
- },
1157
- };
1158
- }
1159
- if (params.mode === "search") {
1160
- if (!params.query) throw new Error("monofold_read mode=search requires query");
1161
- const searchCaps = resolveSearchCaps({ maxMatches: params.maxMatches, maxChars: params.maxChars });
1162
- const result = await runCommand("rg", ["--line-number", "--hidden", "--glob", "!.git/**", params.query, params.path ?? "."], {
1163
- cwd: workspace.resolvedPath,
1164
- signal,
1165
- timeout: 10000,
1166
- allowExitCodes: [0, 1],
1167
- });
1168
- const rawOutput =
1169
- result.stdout.trim() ||
1170
- (result.exitCode !== 0 && result.exitCode !== 1 ? result.stderr.trim() : "");
1171
- const capped = capSearchOutput(rawOutput, searchCaps);
1172
- return {
1173
- content: [{ type: "text", text: capped.text }],
1174
- details: {
1175
- workspace: formatWorkspaceLabel(workspace),
1176
- query: params.query,
1177
- matchCount: capped.matchCount,
1178
- returnedMatchCount: capped.returnedMatchCount,
1179
- maxMatches: capped.maxMatches,
1180
- maxChars: capped.maxChars,
1181
- truncated: capped.truncated,
1182
- ...(capped.hint ? { hint: capped.hint } : {}),
1183
- },
1184
- };
1185
- }
1186
- throw new Error(`Unknown monofold_read mode: ${params.mode}`);
1187
- },
1188
- });
1189
-
1190
- pi.registerTool({
1191
- name: "monofold_write",
1192
- label: "Workspace Write",
1193
- description: "Write a Markdown document to a routed Workspace destination using routeType, title, body, filename, and metadata.",
1194
- parameters: Type.Object({
1195
- routeType: Type.String({ description: "default, prd, design, progress, issue, research, or decision" }),
1196
- title: Type.String(),
1197
- body: Type.String(),
1198
- filename: Type.Optional(Type.String()),
1199
- metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
1200
- targetTags: Type.Optional(Type.Array(Type.String())),
1201
- targetName: Type.Optional(Type.String()),
1202
- targetId: Type.Optional(Type.String()),
1203
- workspaceName: Type.Optional(Type.String()),
1204
- }),
1205
- async execute(_id, params, _signal, _onUpdate, ctx) {
1206
- const routeType = params.routeType as RouteType;
1207
- if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${params.routeType}`);
1208
- const loaded = await loadConfig(ctx.cwd);
1209
- const workspace = await resolveWorkspace(ctx, loaded, {
1210
- targetTags: params.targetTags,
1211
- targetName: params.targetName,
1212
- targetId: params.targetId,
1213
- workspaceName: params.workspaceName,
1214
- requireCapabilities: ["writeDocs"],
1215
- });
1216
- const route = workspace.normalizedRoutes[routeType] ?? workspace.normalizedRoutes.default;
1217
- if (!route) throw new Error(`Workspace has no route for ${routeType} and no default route`);
1218
- const now = new Date();
1219
- const date = now.toISOString().slice(0, 10);
1220
- const vars = {
1221
- date,
1222
- datetime: now.toISOString(),
1223
- title: params.title,
1224
- slug: slugify(params.title),
1225
- routeType,
1226
- workspaceName: workspace.name ?? "",
1227
- workspaceTags: workspace.tags.join(","),
1228
- targetName: workspace.name ?? "",
1229
- targetTags: workspace.tags.join(","),
1230
- parentWorkspaceName: workspace.parent?.name ?? "",
1231
- projectName: workspace.kind === "project" ? workspace.name ?? "" : "",
1232
- };
1233
- const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
1234
- const filename = params.filename ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
1235
- assertWorkspaceInternalRelative("filename", filename);
1236
- const dir = relativePath(workspace, route.path);
1237
- const outputPath = path.join(dir, filename);
1238
- const defaultMetadata = loaded.raw.defaults?.metadata ?? {};
1239
- const routeMetadata = route.metadata ?? {};
1240
- const metadata = renderMetadata({ ...defaultMetadata, ...routeMetadata, ...(params.metadata ?? {}) }, vars) as Record<string, unknown>;
1241
- const text = `${frontmatter(metadata)}# ${params.title}\n\n${params.body.trim()}\n`;
1242
- await mkdir(path.dirname(outputPath), { recursive: true });
1243
- await writeFile(outputPath, text, "utf8");
1244
- const rel = normalizeSlashes(path.relative(workspace.resolvedPath, outputPath));
1245
- return { content: [{ type: "text", text: `Wrote ${formatWorkspaceLabel(workspace)}:${rel}` }], details: { workspace, path: rel } };
1246
- },
1247
- });
1248
-
1249
- pi.registerTool({
1250
- name: "monofold_git",
1251
- label: "Workspace Git",
1252
- description: "Run guarded git status, commit, push, or commitPush for one configured Git Workspace.",
1253
- parameters: Type.Object({
1254
- action: Type.String({ description: "status, commit, push, or commitPush" }),
1255
- message: Type.Optional(Type.String()),
1256
- targetTags: Type.Optional(Type.Array(Type.String())),
1257
- targetName: Type.Optional(Type.String()),
1258
- targetId: Type.Optional(Type.String()),
1259
- workspaceName: Type.Optional(Type.String()),
1260
- }),
1261
- async execute(_id, params, signal, _onUpdate, ctx) {
1262
- const required: CapabilityTag[] = params.action === "status" ? ["read"] : ["git"];
1263
- const loaded = await loadConfig(ctx.cwd);
1264
- const workspace = await resolveWorkspace(ctx, loaded, {
1265
- targetTags: params.targetTags,
1266
- targetName: params.targetName,
1267
- targetId: params.targetId,
1268
- workspaceName: params.workspaceName,
1269
- requireCapabilities: required,
1270
- });
1271
- const root = await gitRoot(workspace);
1272
- if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
1273
- if (params.action === "status") {
1274
- if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
1275
- const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { signal, timeout: 10000 });
1276
- return { content: [{ type: "text", text: result.stdout || "clean" }], details: { workspace } };
1277
- }
1278
- if (!workspace.capabilities.includes("git")) throw new Error(`Workspace lacks git capability: ${formatWorkspaceLabel(workspace)}`);
1279
- if (params.action === "commit" || params.action === "commitPush") {
1280
- const message = params.message ?? `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
1281
- const status = await runCommand("git", ["-C", root, "status", "--short"], { signal, timeout: 10000 });
1282
- const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { signal, timeout: 10000 });
1283
- const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
1284
- let pushContext = "";
1285
- if (params.action === "commitPush") {
1286
- const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { signal, timeout: 10000 });
1287
- const remote = await runCommand("git", ["-C", root, "remote", "-v"], { signal, timeout: 10000 });
1288
- const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
1289
- signal,
1290
- timeout: 10000,
1291
- allowExitCodes: [0, 128],
1292
- });
1293
- pushContext = `\n\nPush after commit:\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\nCommits already ahead:\n${log.stdout || "none/unknown upstream"}`;
1294
- }
1295
- const ok = await confirm(ctx, params.action === "commitPush" ? "Workspace Commit + Push" : "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}${pushContext}\n\n${params.action === "commitPush" ? "Stage scoped changes, commit, then push?" : "Stage scoped changes and commit?"}`);
1296
- if (!ok) return { content: [{ type: "text", text: params.action === "commitPush" ? "Commit+push cancelled" : "Commit cancelled" }], details: { cancelled: true } };
1297
- await runCommand("git", ["-C", root, "add", "-A", "--", scope], { signal, timeout: 10000 });
1298
- const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { signal, timeout: 30000 });
1299
- if (params.action === "commitPush") {
1300
- const push = await runCommand("git", ["-C", root, "push"], { signal, timeout: 60000 });
1301
- return { content: [{ type: "text", text: [commit.stdout || commit.stderr, push.stdout || push.stderr].filter(Boolean).join("\n") }], details: { workspace, message } };
1302
- }
1303
- return { content: [{ type: "text", text: commit.stdout || commit.stderr }], details: { workspace, message } };
1304
- }
1305
- if (params.action === "push") {
1306
- const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { signal, timeout: 10000 });
1307
- const remote = await runCommand("git", ["-C", root, "remote", "-v"], { signal, timeout: 10000 });
1308
- const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
1309
- signal,
1310
- timeout: 10000,
1311
- allowExitCodes: [0, 128],
1312
- });
1313
- 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?`);
1314
- if (!ok) return { content: [{ type: "text", text: "Push cancelled" }], details: { cancelled: true } };
1315
- const push = await runCommand("git", ["-C", root, "push"], { signal, timeout: 60000 });
1316
- return { content: [{ type: "text", text: push.stdout || push.stderr }], details: { workspace } };
1317
- }
1318
- throw new Error(`Unknown monofold_git action: ${params.action}`);
1319
- },
1320
- });
1321
-
1322
- const listCommand = async (_args: string, ctx: ExtensionCommandContext) => {
1323
- try {
1324
- const loaded = await loadConfig(ctx.cwd);
1325
- const manifest = await buildManifest(loaded);
1326
- sendCommandOutput(pi, "monofold:list", manifest, { workspaces: loaded.workspaces });
1327
- } catch (error) {
1328
- sendCommandError(pi, "monofold:list", error, "/monofold:list");
1329
- }
1330
- };
1331
-
1332
- const readUsage = [
1333
- "/monofold:tree [path] [--workspace \"Name\"|--workspace #0] [--depth 2]",
1334
- "/monofold:read file <path> [--workspace \"Name\"|--workspace #0]",
1335
- "/monofold:search <query> [--workspace \"Name\"|--workspace #0]",
1336
- "Aliases: /monofold_read tree|file|search ...",
1337
- ].join("\n");
1338
-
1339
- const readCommand = async (args: string, ctx: ExtensionCommandContext) => {
1340
- try {
1341
- const parsed = parseCommandArgs(args);
1342
- const mode = parsed.positional[0] ?? "tree";
1343
- const loaded = await loadConfig(ctx.cwd);
1344
- const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, ["read"]));
1345
- if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
1346
-
1347
- if (mode === "file" || mode === "read") {
1348
- const inputPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional.slice(1).join(" ");
1349
- if (!inputPath) throw new Error("file mode requires path");
1350
- const filePath = relativePath(workspace, inputPath);
1351
- const text = await readFile(filePath, "utf8");
1352
- sendCommandOutput(pi, `monofold:read ${formatWorkspaceLabel(workspace)}:${inputPath}`, text, { workspace, path: inputPath });
1353
- return;
1354
- }
1355
-
1356
- if (mode === "tree" || mode === "ls") {
1357
- const inputPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional.slice(1).join(" ");
1358
- const depth = Number.parseInt(stringFlag(parsed.flags, "depth", "d") ?? "1", 10);
1359
- const root = inputPath ? relativePath(workspace, inputPath) : workspace.resolvedPath;
1360
- const treeDepth = Math.max(0, Math.min(5, Number.isFinite(depth) ? depth : 1));
1361
- const treeCaps = resolveTreeCaps();
1362
- const treeBudget: ShallowTreeBudget = { remaining: treeCaps.maxEntries, truncated: false };
1363
- const rawLines = await shallowTree(root, treeDepth, "", treeBudget);
1364
- const capped = capTreeLines(rawLines, treeCaps, treeBudget.truncated);
1365
- sendCommandOutput(pi, `monofold:tree ${formatWorkspaceLabel(workspace)}:${inputPath || "."}`, capped.text, {
1366
- workspace,
1367
- path: inputPath || ".",
1368
- });
1369
- return;
1370
- }
1371
-
1372
- if (mode === "search" || mode === "grep") {
1373
- const query = stringFlag(parsed.flags, "query", "q") ?? parsed.positional.slice(1).join(" ");
1374
- if (!query) throw new Error("search mode requires query");
1375
- const result = await runCommand("rg", ["--line-number", "--hidden", "--glob", "!.git/**", query, "."], {
1376
- cwd: workspace.resolvedPath,
1377
- timeout: 10000,
1378
- allowExitCodes: [0, 1],
1379
- });
1380
- const rawOutput =
1381
- result.stdout.trim() ||
1382
- (result.exitCode !== 0 && result.exitCode !== 1 ? result.stderr.trim() : "");
1383
- const capped = capSearchOutput(rawOutput, resolveSearchCaps());
1384
- sendCommandOutput(pi, `monofold:search ${formatWorkspaceLabel(workspace)}:${query}`, capped.text, {
1385
- workspace,
1386
- query,
1387
- });
1388
- return;
1389
- }
1390
-
1391
- throw new Error(`Unknown read mode: ${mode}`);
1392
- } catch (error) {
1393
- sendCommandError(pi, "monofold:read", error, readUsage);
1394
- }
1395
- };
1396
-
1397
- const writeUsage = [
1398
- "/monofold:write --route progress --title \"Title\" --body \"Markdown body\" [--workspace \"Name\"|--workspace #0]",
1399
- "Optional: --filename file.md --meta key=value,other=value",
1400
- "Alias: /monofold_write ...",
1401
- ].join("\n");
1402
-
1403
- const writeCommand = async (args: string, ctx: ExtensionCommandContext) => {
1404
- try {
1405
- const parsed = parseCommandArgs(args);
1406
- const routeType = (stringFlag(parsed.flags, "route", "r") ?? parsed.positional[0] ?? "default") as RouteType;
1407
- if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${routeType}`);
1408
- const title = stringFlag(parsed.flags, "title", "t");
1409
- const body = stringFlag(parsed.flags, "body", "b");
1410
- if (!title) throw new Error("--title is required");
1411
- if (!body) throw new Error("--body is required");
1412
-
1413
- const loaded = await loadConfig(ctx.cwd);
1414
- const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, ["writeDocs"]));
1415
- const route = workspace.normalizedRoutes[routeType] ?? workspace.normalizedRoutes.default;
1416
- if (!route) throw new Error(`Workspace has no route for ${routeType} and no default route`);
1417
- const now = new Date();
1418
- const date = now.toISOString().slice(0, 10);
1419
- const vars = {
1420
- date,
1421
- datetime: now.toISOString(),
1422
- title,
1423
- slug: slugify(title),
1424
- routeType,
1425
- workspaceName: workspace.name ?? "",
1426
- workspaceTags: workspace.tags.join(","),
1427
- targetName: workspace.name ?? "",
1428
- targetTags: workspace.tags.join(","),
1429
- parentWorkspaceName: workspace.parent?.name ?? "",
1430
- projectName: workspace.kind === "project" ? workspace.name ?? "" : "",
1431
- };
1432
- const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
1433
- const filename = stringFlag(parsed.flags, "filename", "file", "f") ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
1434
- assertWorkspaceInternalRelative("filename", filename);
1435
- const dir = relativePath(workspace, route.path);
1436
- const outputPath = path.join(dir, filename);
1437
- const metadata = renderMetadata(
1438
- { ...(loaded.raw.defaults?.metadata ?? {}), ...(route.metadata ?? {}), ...metadataFlag(parsed.flags) },
1439
- vars,
1440
- ) as Record<string, unknown>;
1441
- const text = `${frontmatter(metadata)}# ${title}\n\n${body.trim()}\n`;
1442
- await mkdir(path.dirname(outputPath), { recursive: true });
1443
- await writeFile(outputPath, text, "utf8");
1444
- const rel = normalizeSlashes(path.relative(workspace.resolvedPath, outputPath));
1445
- sendCommandOutput(pi, "monofold:write", `Wrote ${formatWorkspaceLabel(workspace)}:${rel}`, { workspace, path: rel });
1446
- } catch (error) {
1447
- sendCommandError(pi, "monofold:write", error, writeUsage);
1448
- }
1449
- };
1450
-
1451
- const gitUsage = "/monofold:git status|commit|push [--workspace \"Name\"|--workspace #0] [--message \"Commit message\"]\nAlias: /monofold_git ...";
1452
-
1453
- const gitCommand = async (args: string, ctx: ExtensionCommandContext) => {
1454
- try {
1455
- const parsed = parseCommandArgs(args);
1456
- const action = parsed.positional[0] ?? "status";
1457
- const required: CapabilityTag[] = action === "status" ? ["read"] : ["git"];
1458
- const loaded = await loadConfig(ctx.cwd);
1459
- const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, required));
1460
- const root = await gitRoot(workspace);
1461
- if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
1462
-
1463
- if (action === "status") {
1464
- const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { timeout: 10000 });
1465
- sendCommandOutput(pi, `monofold:git status ${formatWorkspaceLabel(workspace)}`, result.stdout || "clean", { workspace });
1466
- return;
1467
- }
1468
-
1469
- if (action === "commit") {
1470
- const parsedMessage = stringFlag(parsed.flags, "message", "m") ?? parsed.positional.slice(1).join(" ");
1471
- const message = parsedMessage || `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
1472
- const status = await runCommand("git", ["-C", root, "status", "--short"], { timeout: 10000 });
1473
- const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { timeout: 10000 });
1474
- const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
1475
- 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?`);
1476
- if (!ok) {
1477
- sendCommandOutput(pi, "monofold:git commit", "Commit cancelled", { cancelled: true });
1478
- return;
1479
- }
1480
- await runCommand("git", ["-C", root, "add", "-A", "--", scope], { timeout: 10000 });
1481
- const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { timeout: 30000 });
1482
- sendCommandOutput(pi, `monofold:git commit ${formatWorkspaceLabel(workspace)}`, commit.stdout || commit.stderr, { workspace, message });
1483
- return;
1484
- }
1485
-
1486
- if (action === "push") {
1487
- const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { timeout: 10000 });
1488
- const remote = await runCommand("git", ["-C", root, "remote", "-v"], { timeout: 10000 });
1489
- const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
1490
- timeout: 10000,
1491
- allowExitCodes: [0, 128],
1492
- });
1493
- 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?`);
1494
- if (!ok) {
1495
- sendCommandOutput(pi, "monofold:git push", "Push cancelled", { cancelled: true });
1496
- return;
1497
- }
1498
- const push = await runCommand("git", ["-C", root, "push"], { timeout: 60000 });
1499
- sendCommandOutput(pi, `monofold:git push ${formatWorkspaceLabel(workspace)}`, push.stdout || push.stderr, { workspace });
1500
- return;
1501
- }
1502
-
1503
- throw new Error(`Unknown git action: ${action}`);
1504
- } catch (error) {
1505
- sendCommandError(pi, "monofold:git", error, gitUsage);
1506
- }
1507
- };
1508
-
1509
- const addUsage = [
1510
- "/monofold:add <path> --name \"Name\" --tags tag1,tag2 --capabilities read,editCode,runCommands,gitCommit",
1511
- "Optional: --context README.md,AGENTS.md",
1512
- "Docs workspace: --capabilities read,writeDocs,gitCommit --route Notes",
1513
- "Multi-route docs: --routes default=Notes,progress=Progress,research=Research",
1514
- "Alias: /monofold_add ...",
1515
- ].join("\n");
1516
-
1517
- const addCommand = async (args: string, ctx: ExtensionCommandContext) => {
1518
- try {
1519
- const workspaceBlock = buildWorkspaceFromAddArgs(args);
1520
- const configFile = await resolveConfigFile(ctx.cwd, true);
1521
- await addWorkspaceToConfig(configFile.configPath, workspaceBlock);
1522
- const loaded = await loadConfig(ctx.cwd);
1523
- sendCommandOutput(pi, "monofold:add", `Added workspace:\n${YAML.stringify(workspaceBlock).trim()}\n\n${await buildManifest(loaded)}`, {
1524
- workspace: workspaceBlock,
1525
- });
1526
- } catch (error) {
1527
- sendCommandError(pi, "monofold:add", error, addUsage);
1528
- }
1529
- };
1530
-
1531
- const projectAddUsage = [
1532
- "/monofold:project-add <path> --parent \"Workspace Name\" --tags project,slug",
1533
- "Parent by tags: --parent-tags vault,docs",
1534
- "Optional: --name \"Name\" --capabilities read,writeDocs --context CONTEXT.md --routes default=.,progress=Progress",
1535
- "Alias: /monofold_project_add ...",
1536
- ].join("\n");
1537
-
1538
- const projectAddCommand = async (args: string, ctx: ExtensionCommandContext) => {
1539
- try {
1540
- const loaded = await loadConfig(ctx.cwd);
1541
- const { project, parent } = buildProjectFromAddArgs(args);
1542
- await addProjectToConfig(ctx, loaded, project, parent);
1543
- const next = await loadConfig(ctx.cwd);
1544
- sendCommandOutput(pi, "monofold:project-add", `Added project:\n${YAML.stringify(project).trim()}\n\n${await buildManifest(next)}`, { project });
1545
- } catch (error) {
1546
- sendCommandError(pi, "monofold:project-add", error, projectAddUsage);
1547
- }
1548
- };
1549
-
1550
- const updateUsage = [
1551
- "/monofold:update [natural language configuration change request]",
1552
- `Migrates ${LEGACY_CONFIG_RELATIVE_PATH} to ${CONFIG_RELATIVE_PATH}, normalizes YAML, and validates the manifest.`,
1553
- "If a request is provided, it is handed off to the Pi agent after successful migration.",
1554
- ].join("\n");
1555
-
1556
- const updateCommand = async (args: string, ctx: ExtensionCommandContext) => {
1557
- try {
1558
- const plan = await buildConfigMigrationPlan(ctx.cwd);
1559
- if (plan.changed && ctx.hasUI) {
1560
- const ok = await ctx.ui.confirm("Monofold Update", `${formatMigrationPlan(plan)}\n\nApply migration?`);
1561
- if (!ok) {
1562
- sendCommandOutput(pi, "monofold:update", "Update cancelled", { cancelled: true });
1563
- return;
1564
- }
1565
- }
1566
-
1567
- await applyConfigMigrationPlan(plan);
1568
- const loaded = await loadConfig(ctx.cwd);
1569
- sendCommandOutput(pi, "monofold:update", `${formatMigrationPlan(plan)}\n\nManifest validation: OK (${loaded.workspaces.length} targets)`, {
1570
- changed: plan.changed,
1571
- configPath: CONFIG_RELATIVE_PATH,
1572
- backupPath: plan.backupRelativePath,
1573
- legacyBackupPath: plan.cleanupLegacyBackupRelativePath,
1574
- });
1575
-
1576
- let request = args.trim();
1577
- if (!request && ctx.hasUI) {
1578
- request = (await ctx.ui.input("Optional: describe workspace/project configuration changes to apply now", ""))?.trim() ?? "";
1579
- }
1580
- if (request) {
1581
- pi.sendUserMessage(buildConfigurationHandoffPrompt(request), { deliverAs: "followUp" });
1582
- }
1583
- } catch (error) {
1584
- sendCommandError(pi, "monofold:update", error, updateUsage);
1585
- }
1586
- };
1587
-
1588
- const intentCommand = (intent: IntentCategory) => async (args: string, ctx: ExtensionCommandContext) => {
1589
- const prepared = await prepareIntentConfiguration(ctx);
1590
- if (!prepared) {
1591
- pi.sendUserMessage("/monofold:init", { deliverAs: "followUp" });
1592
- return;
1593
- }
1594
- pi.sendUserMessage(buildIntentHandoffPrompt(intent, args), { deliverAs: "followUp" });
1595
- sendCommandOutput(pi, `monofold:${intent.toLowerCase()}`, `Queued ${intent} handoff.`, { intent, request: args.trim() });
1596
- };
1597
-
1598
- const guideCommand = async (args: string, _ctx: ExtensionCommandContext) => {
1599
- pi.sendUserMessage(buildGuideHandoffPrompt(args), { deliverAs: "followUp" });
1600
- sendCommandOutput(pi, "monofold:guide", "Queued Monofold guide.", { request: args.trim() });
1601
- };
1602
-
1603
- pi.registerCommand("monofold:explore", { description: "Explore configured workspaces via natural-language handoff", handler: intentCommand("Explore") });
1604
- pi.registerCommand("monofold:write", { description: "Create routed Markdown via natural-language handoff", handler: intentCommand("Write") });
1605
- pi.registerCommand("monofold:config", { description: "Change Workspace configuration via natural-language handoff", handler: intentCommand("Config") });
1606
- pi.registerCommand("monofold:git", { description: "Run workspace git workflows via natural-language handoff", handler: intentCommand("Git") });
1607
- pi.registerCommand("monofold:guide", { description: "Conversational guide for Pi Monofold workflows", handler: guideCommand });
1608
- pi.registerCommand("monofold:update", { description: `Migrate and validate ${CONFIG_RELATIVE_PATH}`, handler: updateCommand });
1609
-
1610
- const initCommand = async (_args: string, ctx: ExtensionCommandContext) => {
1611
- if (!ctx.hasUI) {
1612
- ctx.ui.notify("monofold:init requires interactive UI", "error");
1613
- return;
1614
- }
1615
- const configFile = await resolveConfigFile(ctx.cwd, true);
1616
- const configPath = configFile.configPath;
1617
- const exists = configFile.kind !== "missing";
1618
- const addKind = exists ? await ctx.ui.select("What do you want to add?", ["Workspace", "Project Workspace"]) : "Workspace";
1619
- if (!addKind) return;
1620
- if (addKind === "Project Workspace") {
1621
- const loaded = await loadConfig(ctx.cwd);
1622
- const parentLabels = loaded.workspaces.filter((workspace) => workspace.kind === "workspace").map(formatWorkspaceLabel);
1623
- const parentLabel = await ctx.ui.select("Parent workspace", parentLabels);
1624
- if (!parentLabel) return;
1625
- const parent = loaded.workspaces.filter((workspace) => workspace.kind === "workspace")[parentLabels.indexOf(parentLabel)];
1626
- const projectPath = await ctx.ui.input("Project path relative to parent workspace", "4_Project/Example");
1627
- if (!projectPath) return;
1628
- const name = await ctx.ui.input("Optional project name", "");
1629
- const tagsInput = await ctx.ui.input("Project tags comma-separated", "project,example");
1630
- if (!tagsInput) return;
1631
- const capsInput = await ctx.ui.input("Optional capabilities override comma-separated", "");
1632
- const routesInput = await ctx.ui.input("Optional routes (default path or key=path list)", "");
1633
- const project: ProjectConfig = {
1634
- ...(name?.trim() ? { name: name.trim() } : {}),
1635
- path: projectPath.trim(),
1636
- tags: tagsInput.split(",").map((s) => s.trim()).filter(Boolean),
1637
- ...(capsInput?.trim() ? { capabilities: asCapabilityArray(capsInput.split(",").map((s) => s.trim()).filter(Boolean)) } : {}),
1638
- ...(routesInput?.trim() ? { routes: routesInput.includes("=") ? Object.fromEntries(routesInput.split(",").map((entry) => entry.split("=").map((part) => part.trim()))) as ProjectConfig["routes"] : { default: routesInput.trim() } } : {}),
1639
- };
1640
- await addProjectToConfig(ctx, loaded, project, { targetId: parent.targetId });
1641
- ctx.ui.notify(`Updated ${normalizeSlashes(path.relative(ctx.cwd, loaded.configPath))}`, "info");
1642
- return;
1643
- }
1644
- if (exists) {
1645
- const ok = await ctx.ui.confirm("Existing config", `${configFile.relativePath} exists. Append a new workspace?`);
1646
- if (!ok) return;
1647
- }
1648
- const workspacePath = await ctx.ui.input("Workspace path", "../business");
1649
- if (!workspacePath) return;
1650
- const name = await ctx.ui.input("Optional workspace name", "");
1651
- const tagsInput = await ctx.ui.input("Tags comma-separated", "business,markdown");
1652
- if (!tagsInput) return;
1653
- const capsInput = await ctx.ui.input("Capabilities comma-separated", "read,writeDocs,git");
1654
- if (!capsInput) return;
1655
- const capabilities = capsInput.split(",").map((s) => s.trim()).filter(Boolean);
1656
- const routePath = capabilities.includes("writeDocs") ? await ctx.ui.input("Default document route", "Notes") : undefined;
1657
- const workspaceBlock: WorkspaceConfig = {
1658
- ...(name?.trim() ? { name: name.trim() } : {}),
1659
- path: workspacePath.trim(),
1660
- tags: tagsInput.split(",").map((s) => s.trim()).filter(Boolean),
1661
- capabilities: capabilities as CapabilityTag[],
1662
- ...(routePath ? { routes: { default: routePath.trim() } } : {}),
1663
- };
1664
- const current = exists ? await readFile(configPath, "utf8") : "version: 1\n\nworkspaces:\n";
1665
- const addition = YAML.stringify([workspaceBlock])
1666
- .split("\n")
1667
- .filter(Boolean)
1668
- .map((line) => ` ${line}`)
1669
- .join("\n");
1670
- const next = exists ? `${current.trimEnd()}\n${addition}\n` : `version: 1\n\nworkspaces:\n${addition}\n`;
1671
- await mkdir(path.dirname(configPath), { recursive: true });
1672
- await writeFile(configPath, next, "utf8");
1673
- ctx.ui.notify(`Updated ${configFile.relativePath}`, "info");
1674
- };
1675
-
1676
- pi.registerCommand("monofold:init", {
1677
- description: `Create or update ${CONFIG_RELATIVE_PATH} with an interactive wizard`,
1678
- handler: initCommand,
1679
- });
1680
-
1681
- pi.registerTool({
1682
- name: "monofold_init",
1683
- label: "Workspace Init",
1684
- description: `Queue the interactive /monofold:init command to create or update ${CONFIG_RELATIVE_PATH}.`,
1685
- parameters: Type.Object({}),
1686
- async execute() {
1687
- pi.sendUserMessage("/monofold:init", { deliverAs: "followUp" });
1688
- return { content: [{ type: "text", text: "Queued /monofold:init" }], details: {} };
1689
- },
1690
- });
1691
-
1692
- pi.on("tool_call", async (event, ctx) => {
1693
- let loaded: LoadedConfig;
1694
- try {
1695
- loaded = await loadConfig(ctx.cwd);
1696
- } catch {
1697
- return undefined;
1698
- }
1699
-
1700
- if ((event.toolName === "read" || event.toolName === "write" || event.toolName === "edit") && typeof event.input.path === "string") {
1701
- return guardPathOperation(ctx, loaded, event.input.path, event.toolName as "read" | "write" | "edit");
1702
- }
1703
-
1704
- if ((event.toolName === "grep" || event.toolName === "find") && typeof event.input.path === "string") {
1705
- return guardPathOperation(ctx, loaded, event.input.path, "read");
1706
- }
1707
-
1708
- if (event.toolName === "bash" && typeof event.input.command === "string") {
1709
- const command = event.input.command;
1710
- if (bashContainsGitCommitOrPush(command)) {
1711
- return { block: true, reason: "Use monofold_git for git commit/push so confirmation flow is enforced." };
1712
- }
1713
- const danger = bashLooksDangerous(command);
1714
- if (danger) {
1715
- const ok = await confirm(ctx, "Dangerous command", `Command contains ${danger}:\n${command}\nAllow?`);
1716
- if (!ok) return { block: true, reason: `Dangerous command requires confirmation: ${danger}` };
1717
- }
1718
- const cwd = inferBashCwd(ctx, command);
1719
- const workspace = findWorkspaceForPath(loaded, cwd);
1720
- if (!workspace) return maybeBlockUnknown(ctx, loaded, cwd, "bash");
1721
- if (!workspace.capabilities.includes("runCommands")) {
1722
- return { block: true, reason: `Workspace lacks runCommands capability: ${formatWorkspaceLabel(workspace)}` };
1723
- }
1724
- }
1725
-
1726
- return undefined;
1727
- });
1728
- }
1729
-
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { execFile } from "node:child_process";
4
+ import { access, copyFile, mkdir, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import YAML from "yaml";
7
+ import {
8
+ type ActiveFocusPresetPosition,
9
+ cycleActiveFocusPresetForward,
10
+ type FocusPreset,
11
+ ensureActiveFocusInitialized,
12
+ findFocusPresetById,
13
+ getActiveFocusPresetId,
14
+ getActiveFocusPresetPosition,
15
+ parseFocusPresets,
16
+ setActiveFocusPresetByLabel,
17
+ setActiveFocusPresetId,
18
+ warnZeroTargetMatchesForPreset,
19
+ } from "./focus-preset.js";
20
+ import {
21
+ buildMonofoldTree,
22
+ readMonofoldFile,
23
+ runMonofoldSearch,
24
+ } from "./monofold-read-ops.js";
25
+ import {
26
+ clearUnknownPathAllows,
27
+ loadUnknownPathAllows,
28
+ rememberUnknownPathAllow,
29
+ UNKNOWN_PATH_ALLOWS_RELATIVE_PATH,
30
+ } from "./unknown-path-allows.js";
31
+ import { normalizeGuardPath } from "./path-normalize.js";
32
+ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
33
+
34
+ type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "git";
35
+ type LegacyCapabilityTag = CapabilityTag | "gitCommit" | "gitPush";
36
+ type IntentCategory = "Explore" | "Write" | "Config" | "Git";
37
+ type RouteType = "default" | "prd" | "design" | "progress" | "issue" | "research" | "decision";
38
+
39
+ type RouteConfig = {
40
+ path: string;
41
+ filenameTemplate?: string;
42
+ metadata?: Record<string, unknown>;
43
+ };
44
+
45
+ type WorkspaceConfig = {
46
+ name?: string;
47
+ path: string;
48
+ tags: string[];
49
+ capabilities: CapabilityTag[];
50
+ contextFiles?: string[];
51
+ routes?: Partial<Record<RouteType, string | RouteConfig>>;
52
+ projects?: ProjectConfig[];
53
+ };
54
+
55
+ type ProjectConfig = {
56
+ name?: string;
57
+ path: string;
58
+ tags: string[];
59
+ capabilities?: CapabilityTag[];
60
+ contextFiles?: string[];
61
+ routes?: Partial<Record<RouteType, string | RouteConfig>>;
62
+ };
63
+
64
+ type MultiWorkspaceConfig = {
65
+ version: 1;
66
+ defaults?: {
67
+ contextFiles?: string[];
68
+ filenameTemplate?: string;
69
+ metadata?: Record<string, unknown>;
70
+ };
71
+ focusPresets?: FocusPreset[];
72
+ workspaces: WorkspaceConfig[];
73
+ };
74
+
75
+ type ResolvedWorkspace = WorkspaceConfig & {
76
+ kind: "workspace" | "project";
77
+ targetId: string;
78
+ index: number;
79
+ projectIndex?: number;
80
+ parent?: ResolvedWorkspace;
81
+ displayPath: string;
82
+ resolvedPath: string;
83
+ realPath: string;
84
+ normalizedRoutes: Partial<Record<RouteType, RouteConfig>>;
85
+ effectiveContextFiles: string[];
86
+ commitScope?: string;
87
+ };
88
+
89
+ type LoadedConfig = {
90
+ configPath: string;
91
+ root: string;
92
+ raw: MultiWorkspaceConfig;
93
+ workspaces: ResolvedWorkspace[];
94
+ };
95
+
96
+ type TargetInput = {
97
+ targetTags?: string[];
98
+ targetName?: string;
99
+ targetId?: string;
100
+ workspaceName?: string;
101
+ workspaceIndex?: number;
102
+ requireCapabilities?: CapabilityTag[];
103
+ };
104
+
105
+ type ParsedCommandArgs = {
106
+ positional: string[];
107
+ flags: Record<string, string | boolean>;
108
+ };
109
+
110
+ type CommandResult = {
111
+ stdout: string;
112
+ stderr: string;
113
+ exitCode: number | string | null | undefined;
114
+ };
115
+
116
+ type ConfigMigrationPlan = {
117
+ changed: boolean;
118
+ sourcePath: string;
119
+ sourceRelativePath: string;
120
+ sourceKind: "canonical" | "legacy";
121
+ targetPath: string;
122
+ targetRelativePath: string;
123
+ backupPath?: string;
124
+ backupRelativePath?: string;
125
+ cleanupLegacyPath?: string;
126
+ cleanupLegacyBackupPath?: string;
127
+ cleanupLegacyBackupRelativePath?: string;
128
+ normalizedText: string;
129
+ loaded: LoadedConfig;
130
+ actions: string[];
131
+ };
132
+
133
+ const CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yaml");
134
+ const LEGACY_CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yml");
135
+ const ROUTE_TYPES: RouteType[] = ["default", "prd", "design", "progress", "issue", "research", "decision"];
136
+ const CAPABILITIES: CapabilityTag[] = ["read", "writeDocs", "editCode", "runCommands", "git"];
137
+ const LEGACY_CAPABILITIES: LegacyCapabilityTag[] = [...CAPABILITIES, "gitCommit", "gitPush"];
138
+ const DOC_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]);
139
+ const CODE_EXTENSIONS = new Set([
140
+ ".ts",
141
+ ".tsx",
142
+ ".js",
143
+ ".jsx",
144
+ ".mjs",
145
+ ".cjs",
146
+ ".json",
147
+ ".yml",
148
+ ".yaml",
149
+ ".toml",
150
+ ".rs",
151
+ ".go",
152
+ ".py",
153
+ ".rb",
154
+ ".java",
155
+ ".kt",
156
+ ".swift",
157
+ ".cs",
158
+ ".cpp",
159
+ ".c",
160
+ ".h",
161
+ ".css",
162
+ ".scss",
163
+ ".html",
164
+ ]);
165
+ const ROOT_KEYS = new Set(["version", "defaults", "focusPresets", "workspaces"]);
166
+ const DEFAULT_KEYS = new Set(["contextFiles", "filenameTemplate", "metadata"]);
167
+ const WORKSPACE_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes", "projects"]);
168
+ const PROJECT_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes"]);
169
+ const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
170
+ const FOCUS_STATUS_ID = "monofold-focus";
171
+ const FOCUS_CYCLE_SHORTCUT = "ctrl+shift+m";
172
+ const FOCUS_CYCLE_ACTION_ID = "app.monofold.focus.cycleForward";
173
+
174
+ function normalizeSlashes(value: string): string {
175
+ return value.replace(/\\/g, "/");
176
+ }
177
+
178
+ function isInside(parent: string, child: string): boolean {
179
+ const normalizedParent = normalizeGuardPath(parent);
180
+ const normalizedChild = normalizeGuardPath(child);
181
+ const relative = path.relative(normalizedParent, normalizedChild);
182
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
183
+ }
184
+
185
+ function assertWorkspaceInternalRelative(label: string, value: string): void {
186
+ if (path.isAbsolute(value) || normalizeSlashes(value).split("/").includes("..")) {
187
+ throw new Error(`${label} must be a workspace-internal relative path: ${value}`);
188
+ }
189
+ }
190
+
191
+ function assertProjectPath(label: string, value: string): void {
192
+ assertWorkspaceInternalRelative(label, value);
193
+ const normalized = normalizeSlashes(value).replace(/^\.\//, "").replace(/\/$/, "");
194
+ if (!normalized || normalized === ".") {
195
+ throw new Error(`${label} must point below the parent workspace, not the parent root`);
196
+ }
197
+ }
198
+
199
+ function asCapabilityArray(value: unknown): CapabilityTag[] {
200
+ const items = asStringArray("capabilities", value);
201
+ const normalized: CapabilityTag[] = [];
202
+ for (const item of items) {
203
+ if (!LEGACY_CAPABILITIES.includes(item as LegacyCapabilityTag)) {
204
+ throw new Error(`Unknown capability: ${item}`);
205
+ }
206
+ normalized.push(item === "gitCommit" || item === "gitPush" ? "git" : (item as CapabilityTag));
207
+ }
208
+ return uniqueStrings(normalized) as CapabilityTag[];
209
+ }
210
+
211
+ function asOptionalCapabilityArray(value: unknown): CapabilityTag[] | undefined {
212
+ if (value === undefined) return undefined;
213
+ return asCapabilityArray(value);
214
+ }
215
+
216
+ async function existingRealPath(label: string, targetPath: string): Promise<string> {
217
+ try {
218
+ return await realpath(targetPath);
219
+ } catch {
220
+ throw new Error(`${label} does not exist: ${targetPath}`);
221
+ }
222
+ }
223
+
224
+ function normalizeRoute(routeType: string, value: unknown): RouteConfig {
225
+ if (!ROUTE_TYPES.includes(routeType as RouteType)) {
226
+ throw new Error(`Unknown route type: ${routeType}`);
227
+ }
228
+ if (typeof value === "string") {
229
+ assertWorkspaceInternalRelative(`routes.${routeType}`, value);
230
+ return { path: value };
231
+ }
232
+ if (!isRecord(value) || typeof value.path !== "string") {
233
+ throw new Error(`routes.${routeType} must be a string path or object with path`);
234
+ }
235
+ assertKnownKeys(`routes.${routeType}`, value, ROUTE_KEYS);
236
+ assertWorkspaceInternalRelative(`routes.${routeType}.path`, value.path);
237
+ if (value.filenameTemplate !== undefined && typeof value.filenameTemplate !== "string") {
238
+ throw new Error(`routes.${routeType}.filenameTemplate must be a string`);
239
+ }
240
+ if (value.metadata !== undefined && !isRecord(value.metadata)) {
241
+ throw new Error(`routes.${routeType}.metadata must be an object`);
242
+ }
243
+ return {
244
+ path: value.path,
245
+ filenameTemplate: value.filenameTemplate,
246
+ metadata: value.metadata as Record<string, unknown> | undefined,
247
+ };
248
+ }
249
+
250
+ async function pathExists(targetPath: string): Promise<boolean> {
251
+ try {
252
+ await access(targetPath);
253
+ return true;
254
+ } catch {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ async function resolveConfigFile(
260
+ cwd: string,
261
+ allowMissing = false,
262
+ options: { preferCanonicalOnConflict?: boolean } = {},
263
+ ): Promise<{ configPath: string; relativePath: string; kind: "canonical" | "legacy" | "missing" }> {
264
+ const normalizedCwd = normalizeGuardPath(cwd);
265
+ const canonicalPath = path.join(normalizedCwd, CONFIG_RELATIVE_PATH);
266
+ const legacyPath = path.join(normalizedCwd, LEGACY_CONFIG_RELATIVE_PATH);
267
+ const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
268
+ if (hasCanonical && hasLegacy) {
269
+ if (options.preferCanonicalOnConflict) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "canonical" };
270
+ throw new Error(`Configuration file conflict: both ${CONFIG_RELATIVE_PATH} and ${LEGACY_CONFIG_RELATIVE_PATH} exist. Remove one before continuing.`);
271
+ }
272
+ if (hasCanonical) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "canonical" };
273
+ if (hasLegacy) return { configPath: legacyPath, relativePath: LEGACY_CONFIG_RELATIVE_PATH, kind: "legacy" };
274
+ if (allowMissing) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "missing" };
275
+ throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
276
+ }
277
+
278
+ function runCommand(
279
+ command: string,
280
+ args: string[],
281
+ options: { cwd?: string; timeout?: number; signal?: AbortSignal; allowExitCodes?: Array<number | string> } = {},
282
+ ): Promise<CommandResult> {
283
+ const allowExitCodes = new Set<number | string>(options.allowExitCodes ?? [0]);
284
+ return new Promise((resolve, reject) => {
285
+ execFile(
286
+ command,
287
+ args,
288
+ {
289
+ cwd: options.cwd,
290
+ timeout: options.timeout ?? 10000,
291
+ signal: options.signal,
292
+ windowsHide: true,
293
+ maxBuffer: 1024 * 1024 * 4,
294
+ },
295
+ (error, stdout, stderr) => {
296
+ const exitCode = typeof error === "object" && error && "code" in error ? (error as { code?: number | string }).code : 0;
297
+ const result = { stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), exitCode };
298
+ if (!error || allowExitCodes.has(exitCode ?? 0)) {
299
+ resolve(result);
300
+ return;
301
+ }
302
+ reject(new Error(`${command} ${args.join(" ")} failed (${String(exitCode)}): ${result.stderr || result.stdout}`));
303
+ },
304
+ );
305
+ });
306
+ }
307
+
308
+ async function loadConfig(cwd: string): Promise<LoadedConfig> {
309
+ const normalizedCwd = normalizeGuardPath(cwd);
310
+ const { configPath } = await resolveConfigFile(normalizedCwd, false, { preferCanonicalOnConflict: true });
311
+ const text = await readFile(configPath, "utf8");
312
+ const parsed = YAML.parse(text, { uniqueKeys: true }) as unknown;
313
+ return validateConfigObject(normalizedCwd, configPath, parsed);
314
+ }
315
+
316
+ async function validateConfigObject(cwd: string, configPath: string, parsed: unknown): Promise<LoadedConfig> {
317
+ if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
318
+ assertKnownKeys("monofold config", parsed, ROOT_KEYS);
319
+ if (parsed.version !== 1) throw new Error("monofold config requires version: 1");
320
+ if (!Array.isArray(parsed.workspaces) || parsed.workspaces.length === 0) {
321
+ throw new Error("monofold config requires non-empty workspaces array");
322
+ }
323
+
324
+ const defaults = isRecord(parsed.defaults) ? parsed.defaults : undefined;
325
+ if (defaults) assertKnownKeys("defaults", defaults, DEFAULT_KEYS);
326
+ const defaultContextFiles = defaults ? asStringArray("defaults.contextFiles", defaults.contextFiles, false) : [];
327
+ const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
328
+ const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
329
+ const focusPresets = parseFocusPresets(parsed.focusPresets, "focusPresets");
330
+
331
+ const workspaces: ResolvedWorkspace[] = [];
332
+ for (let index = 0; index < parsed.workspaces.length; index += 1) {
333
+ const item = parsed.workspaces[index];
334
+ if (!isRecord(item)) throw new Error(`workspaces[${index}] must be an object`);
335
+ assertKnownKeys(`workspaces[${index}]`, item, WORKSPACE_KEYS);
336
+ if (item.name !== undefined && typeof item.name !== "string") throw new Error(`workspaces[${index}].name must be string`);
337
+ if (typeof item.path !== "string") throw new Error(`workspaces[${index}].path is required`);
338
+ const tags = asStringArray(`workspaces[${index}].tags`, item.tags);
339
+ const capabilities = asCapabilityArray(item.capabilities);
340
+ const contextFiles = asStringArray(`workspaces[${index}].contextFiles`, item.contextFiles, false);
341
+ for (const contextFile of [...defaultContextFiles, ...contextFiles]) {
342
+ assertWorkspaceInternalRelative(`workspaces[${index}].contextFiles`, contextFile);
343
+ }
344
+
345
+ const routes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(item.routes)
346
+ ? (item.routes as Partial<Record<RouteType, string | RouteConfig>>)
347
+ : undefined;
348
+ if (!capabilities.includes("writeDocs") && routes) {
349
+ throw new Error(`workspaces[${index}] has routes but lacks writeDocs capability`);
350
+ }
351
+ if (capabilities.includes("writeDocs") && !routes) {
352
+ throw new Error(`workspaces[${index}] has writeDocs but no routes`);
353
+ }
354
+
355
+ const normalizedRoutes: Partial<Record<RouteType, RouteConfig>> = {};
356
+ if (routes) {
357
+ for (const [routeType, routeValue] of Object.entries(routes)) {
358
+ normalizedRoutes[routeType as RouteType] = normalizeRoute(routeType, routeValue);
359
+ }
360
+ }
361
+
362
+ const resolvedPath = path.resolve(cwd, item.path);
363
+ const realPath = await existingRealPath(`workspaces[${index}].path`, resolvedPath);
364
+ const workspace: ResolvedWorkspace = {
365
+ kind: "workspace",
366
+ targetId: `#${index}`,
367
+ name: item.name as string | undefined,
368
+ path: item.path,
369
+ displayPath: item.path,
370
+ tags: uniqueStrings(tags),
371
+ capabilities,
372
+ contextFiles,
373
+ routes,
374
+ index,
375
+ resolvedPath,
376
+ realPath,
377
+ normalizedRoutes,
378
+ effectiveContextFiles: [...defaultContextFiles, ...contextFiles],
379
+ };
380
+ workspaces.push(workspace);
381
+
382
+ const projects = Array.isArray(item.projects) ? item.projects : [];
383
+ if (item.projects !== undefined && !Array.isArray(item.projects)) {
384
+ throw new Error(`workspaces[${index}].projects must be an array`);
385
+ }
386
+ const seenProjectRealPaths = new Set<string>();
387
+ for (let projectOffset = 0; projectOffset < projects.length; projectOffset += 1) {
388
+ const project = projects[projectOffset];
389
+ const projectIndex = projectOffset + 1;
390
+ if (!isRecord(project)) throw new Error(`workspaces[${index}].projects[${projectOffset}] must be an object`);
391
+ assertKnownKeys(`workspaces[${index}].projects[${projectOffset}]`, project, PROJECT_KEYS);
392
+ if (project.name !== undefined && typeof project.name !== "string") throw new Error(`workspaces[${index}].projects[${projectOffset}].name must be string`);
393
+ if (typeof project.path !== "string") throw new Error(`workspaces[${index}].projects[${projectOffset}].path is required`);
394
+ assertProjectPath(`workspaces[${index}].projects[${projectOffset}].path`, project.path);
395
+ const projectTags = asStringArray(`workspaces[${index}].projects[${projectOffset}].tags`, project.tags);
396
+ if (projectTags.length === 0) throw new Error(`workspaces[${index}].projects[${projectOffset}].tags is required`);
397
+ const projectCapabilities = asOptionalCapabilityArray(project.capabilities) ?? capabilities;
398
+ const projectContextFiles = asStringArray(`workspaces[${index}].projects[${projectOffset}].contextFiles`, project.contextFiles, false);
399
+ for (const contextFile of projectContextFiles) {
400
+ assertWorkspaceInternalRelative(`workspaces[${index}].projects[${projectOffset}].contextFiles`, contextFile);
401
+ }
402
+ const projectRoutes: Partial<Record<RouteType, string | RouteConfig>> | undefined = isRecord(project.routes)
403
+ ? (project.routes as Partial<Record<RouteType, string | RouteConfig>>)
404
+ : undefined;
405
+ if (!projectCapabilities.includes("writeDocs") && projectRoutes) {
406
+ throw new Error(`workspaces[${index}].projects[${projectOffset}] has routes but lacks writeDocs capability`);
407
+ }
408
+ const projectNormalizedRoutes: Partial<Record<RouteType, RouteConfig>> = {};
409
+ if (projectRoutes) {
410
+ for (const [routeType, routeValue] of Object.entries(projectRoutes)) {
411
+ projectNormalizedRoutes[routeType as RouteType] = normalizeRoute(routeType, routeValue);
412
+ }
413
+ } else if (projectCapabilities.includes("writeDocs")) {
414
+ projectNormalizedRoutes.default = { path: "." };
415
+ }
416
+ const projectResolvedPath = path.resolve(resolvedPath, project.path);
417
+ const projectRealPath = await existingRealPath(`workspaces[${index}].projects[${projectOffset}].path`, projectResolvedPath);
418
+ if (!isInside(realPath, projectRealPath) || projectRealPath === realPath) {
419
+ throw new Error(`workspaces[${index}].projects[${projectOffset}].path must stay below parent workspace: ${project.path}`);
420
+ }
421
+ if (seenProjectRealPaths.has(projectRealPath)) throw new Error(`Duplicate project path in workspaces[${index}]: ${project.path}`);
422
+ seenProjectRealPaths.add(projectRealPath);
423
+ workspaces.push({
424
+ kind: "project",
425
+ targetId: `#${index}.${projectIndex}`,
426
+ name: project.name as string | undefined,
427
+ path: project.path,
428
+ displayPath: normalizeSlashes(path.join(item.path, project.path)),
429
+ tags: uniqueStrings([...tags, ...projectTags]),
430
+ capabilities: projectCapabilities,
431
+ contextFiles: projectContextFiles,
432
+ routes: project.routes as ProjectConfig["routes"],
433
+ index,
434
+ projectIndex,
435
+ parent: workspace,
436
+ resolvedPath: projectResolvedPath,
437
+ realPath: projectRealPath,
438
+ normalizedRoutes: projectNormalizedRoutes,
439
+ effectiveContextFiles: [...defaultContextFiles, ...contextFiles, ...projectContextFiles],
440
+ commitScope: normalizeSlashes(path.relative(resolvedPath, projectResolvedPath)),
441
+ });
442
+ }
443
+ }
444
+
445
+ return {
446
+ configPath,
447
+ root: cwd,
448
+ raw: {
449
+ version: 1,
450
+ defaults: {
451
+ contextFiles: defaultContextFiles,
452
+ filenameTemplate: defaultFilenameTemplate,
453
+ metadata: defaultMetadata,
454
+ },
455
+ focusPresets: focusPresets.length > 0 ? focusPresets : undefined,
456
+ workspaces: workspaces.filter((workspace) => workspace.kind === "workspace"),
457
+ },
458
+ workspaces,
459
+ };
460
+ }
461
+
462
+ function timestampSuffix(now = new Date()): string {
463
+ return now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "-").replace(/Z$/, "");
464
+ }
465
+
466
+ function normalizeCapabilityValues(value: unknown): string[] | undefined {
467
+ if (value === undefined) return undefined;
468
+ return asCapabilityArray(value);
469
+ }
470
+
471
+ function normalizeConfigCapabilities(config: Record<string, unknown>): void {
472
+ const workspaces = Array.isArray(config.workspaces) ? (config.workspaces as unknown[]) : [];
473
+ for (const workspace of workspaces) {
474
+ if (!isRecord(workspace)) continue;
475
+ const capabilities = normalizeCapabilityValues(workspace.capabilities);
476
+ if (capabilities) workspace.capabilities = capabilities;
477
+ const projects = Array.isArray(workspace.projects) ? (workspace.projects as unknown[]) : [];
478
+ for (const project of projects) {
479
+ if (!isRecord(project)) continue;
480
+ const projectCapabilities = normalizeCapabilityValues(project.capabilities);
481
+ if (projectCapabilities) project.capabilities = projectCapabilities;
482
+ }
483
+ }
484
+ }
485
+
486
+ function normalizeConfigForMigration(parsed: unknown): Record<string, unknown> {
487
+ if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
488
+ assertKnownKeys("monofold config", parsed, ROOT_KEYS);
489
+ const version = parsed.version ?? 1;
490
+ if (version !== 1) throw new Error("monofold config requires version: 1");
491
+ const normalized: Record<string, unknown> = { version: 1 };
492
+ if (parsed.defaults !== undefined) normalized.defaults = parsed.defaults;
493
+ if (parsed.focusPresets !== undefined) normalized.focusPresets = parsed.focusPresets;
494
+ normalized.workspaces = parsed.workspaces;
495
+ normalizeConfigCapabilities(normalized);
496
+ return normalized;
497
+ }
498
+
499
+ async function buildConfigMigrationPlan(cwd: string): Promise<ConfigMigrationPlan> {
500
+ const canonicalPath = path.join(cwd, CONFIG_RELATIVE_PATH);
501
+ const legacyPath = path.join(cwd, LEGACY_CONFIG_RELATIVE_PATH);
502
+ const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
503
+ const source = await resolveConfigFile(cwd, false, { preferCanonicalOnConflict: true });
504
+ if (source.kind === "missing") throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
505
+
506
+ const originalText = await readFile(source.configPath, "utf8");
507
+ const parsed = YAML.parse(originalText, { uniqueKeys: true }) as unknown;
508
+ const normalized = normalizeConfigForMigration(parsed);
509
+ const targetPath = path.join(cwd, CONFIG_RELATIVE_PATH);
510
+ const normalizedText = YAML.stringify(normalized).trimEnd() + "\n";
511
+ const loaded = await validateConfigObject(cwd, targetPath, normalized);
512
+ const cleanupLegacy = hasCanonical && hasLegacy;
513
+ const changed = source.kind !== "canonical" || originalText !== normalizedText || cleanupLegacy;
514
+ const actions: string[] = [];
515
+ if (source.kind === "legacy") actions.push(`Move legacy config ${LEGACY_CONFIG_RELATIVE_PATH} to canonical ${CONFIG_RELATIVE_PATH}`);
516
+ if (originalText !== normalizedText) actions.push("Normalize YAML and ensure version: 1 is explicit");
517
+ if (source.kind === "legacy" || cleanupLegacy) actions.push(`Remove legacy config ${LEGACY_CONFIG_RELATIVE_PATH} after writing ${CONFIG_RELATIVE_PATH}`);
518
+ if (!changed) actions.push(`Already current: ${CONFIG_RELATIVE_PATH} (version 1)`);
519
+ const suffix = timestampSuffix();
520
+ const backupPath = changed && (source.kind === "legacy" || originalText !== normalizedText) ? `${source.configPath}.bak-${suffix}` : undefined;
521
+ const cleanupLegacyBackupPath = cleanupLegacy ? `${legacyPath}.bak-${suffix}` : undefined;
522
+ return {
523
+ changed,
524
+ sourcePath: source.configPath,
525
+ sourceRelativePath: source.relativePath,
526
+ sourceKind: source.kind,
527
+ targetPath,
528
+ targetRelativePath: CONFIG_RELATIVE_PATH,
529
+ backupPath,
530
+ backupRelativePath: backupPath ? normalizeSlashes(path.relative(cwd, backupPath)) : undefined,
531
+ cleanupLegacyPath: cleanupLegacy ? legacyPath : undefined,
532
+ cleanupLegacyBackupPath,
533
+ cleanupLegacyBackupRelativePath: cleanupLegacyBackupPath ? normalizeSlashes(path.relative(cwd, cleanupLegacyBackupPath)) : undefined,
534
+ normalizedText,
535
+ loaded,
536
+ actions,
537
+ };
538
+ }
539
+
540
+ async function applyConfigMigrationPlan(plan: ConfigMigrationPlan): Promise<void> {
541
+ if (!plan.changed) return;
542
+ await mkdir(path.dirname(plan.targetPath), { recursive: true });
543
+ if (plan.backupPath) await copyFile(plan.sourcePath, plan.backupPath);
544
+ if (plan.cleanupLegacyPath && plan.cleanupLegacyBackupPath) await copyFile(plan.cleanupLegacyPath, plan.cleanupLegacyBackupPath);
545
+ await writeFile(plan.targetPath, plan.normalizedText, "utf8");
546
+ if (plan.sourceKind === "legacy") await unlink(plan.sourcePath);
547
+ if (plan.cleanupLegacyPath) await unlink(plan.cleanupLegacyPath);
548
+ }
549
+
550
+ async function prepareIntentConfiguration(ctx: ExtensionCommandContext): Promise<boolean> {
551
+ const canonicalPath = path.join(ctx.cwd, CONFIG_RELATIVE_PATH);
552
+ const legacyPath = path.join(ctx.cwd, LEGACY_CONFIG_RELATIVE_PATH);
553
+ const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
554
+ if (!hasCanonical && !hasLegacy) {
555
+ ctx.ui.notify(`No ${CONFIG_RELATIVE_PATH} found. Queueing /monofold:init.`, "info");
556
+ return false;
557
+ }
558
+ if (!hasCanonical && hasLegacy) {
559
+ try {
560
+ const plan = await buildConfigMigrationPlan(ctx.cwd);
561
+ await applyConfigMigrationPlan(plan);
562
+ if (plan.changed) {
563
+ ctx.ui.notify(`Migrated ${LEGACY_CONFIG_RELATIVE_PATH} to ${CONFIG_RELATIVE_PATH}${plan.backupRelativePath ? ` (backup: ${plan.backupRelativePath})` : ""}.`, "info");
564
+ }
565
+ } catch (error) {
566
+ const message = error instanceof Error ? error.message : String(error);
567
+ ctx.ui.notify(`Legacy migration failed; continuing with ${LEGACY_CONFIG_RELATIVE_PATH}: ${message}`, "error");
568
+ }
569
+ }
570
+ return true;
571
+ }
572
+
573
+ function formatMigrationPlan(plan: ConfigMigrationPlan): string {
574
+ if (!plan.changed) return `Already current: ${plan.targetRelativePath} (version ${plan.loaded.raw.version})`;
575
+ return [
576
+ `Source: ${plan.sourceRelativePath}`,
577
+ `Target: ${plan.targetRelativePath}`,
578
+ `Backup: ${plan.backupRelativePath ?? plan.cleanupLegacyBackupRelativePath ?? "none"}`,
579
+ ...(plan.backupRelativePath && plan.cleanupLegacyBackupRelativePath ? [`Legacy backup: ${plan.cleanupLegacyBackupRelativePath}`] : []),
580
+ "",
581
+ "Actions:",
582
+ ...plan.actions.map((action) => `- ${action}`),
583
+ ].join("\n");
584
+ }
585
+
586
+ function buildConfigurationHandoffPrompt(request: string): string {
587
+ return [
588
+ "/monofold:update completed. Apply this requested Pi Monofold configuration change to `.pi/monofold.yaml`.",
589
+ "Edit the canonical config directly when needed, preserve valid YAML, then run `monofold_list` as manifest validation.",
590
+ "",
591
+ "User request:",
592
+ request.trim(),
593
+ ].join("\n");
594
+ }
595
+
596
+ function buildIntentHandoffPrompt(intent: IntentCategory, request: string): string {
597
+ const trimmed = request.trim();
598
+ const emptyInstruction =
599
+ intent === "Explore"
600
+ ? "Ask what the user wants to list, read, search, or inspect."
601
+ : intent === "Write"
602
+ ? "Ask what document to create and where it should be saved."
603
+ : intent === "Config"
604
+ ? "Ask what Workspace or Project Workspace configuration change is needed."
605
+ : "Ask whether the user wants git status, commit, push, or commit+push.";
606
+ return [
607
+ `Pi Monofold ${intent} request. Interpret the user's natural-language input and continue as the agent.`,
608
+ "",
609
+ "Rules:",
610
+ "- Use strict `monofold_*` tools as the execution API; do not ask the user to write JSON or YAML unless needed.",
611
+ "- Select a Workspace Target automatically only when the manifest makes it unique; if multiple targets match, ask one clarifying question.",
612
+ "- Ask missing information incrementally, one question at a time.",
613
+ "- Explore intent: read/search/tree/list is read-only; execute immediately when target and path/query are clear.",
614
+ "- Write intent: infer route/title/body/filename when possible, but confirm Workspace, route, and filename before calling `monofold_write`.",
615
+ "- Config intent: edit `.pi/monofold.yaml` only after showing the YAML diff; validate afterward with `monofold_list`.",
616
+ "- Git intent: use `monofold_git`; for commit+push use action `commitPush` and one combined confirmation. If message is missing, propose one from the diff.",
617
+ "- If there is no `.pi/monofold.yaml`, guide the user to `/monofold:init`.",
618
+ "",
619
+ "Intent:",
620
+ intent,
621
+ "",
622
+ "User request:",
623
+ trimmed || `(empty input) ${emptyInstruction}`,
624
+ ].join("\n");
625
+ }
626
+
627
+ function buildGuideHandoffPrompt(request: string): string {
628
+ return [
629
+ "Pi Monofold guide request. Start a conversational helper flow for Pi Monofold.",
630
+ "",
631
+ "Guide the user through Explore, Write, Config, Git, init, or update. Do not dump a static help page.",
632
+ "Ask one question at a time, then route to the appropriate intent behavior:",
633
+ "- Explore: list/read/search/tree workspaces.",
634
+ "- Write: routed Markdown output.",
635
+ "- Config: Workspace or Project Workspace configuration changes with YAML diff confirmation.",
636
+ "- Git: status/commit/push/commit+push via `monofold_git`.",
637
+ "- Init: queue or instruct `/monofold:init`.",
638
+ "- Update: run or instruct `/monofold:update` for migration/cleanup.",
639
+ "",
640
+ "Initial user request:",
641
+ request.trim() || "(empty input) Ask what they want to do with Pi Monofold.",
642
+ ].join("\n");
643
+ }
644
+
645
+ function matchesTarget(workspace: ResolvedWorkspace, target: TargetInput): boolean {
646
+ if (target.targetId && workspace.targetId !== (target.targetId.startsWith("#") ? target.targetId : `#${target.targetId}`)) return false;
647
+ if (target.workspaceIndex !== undefined && workspace.index !== target.workspaceIndex) return false;
648
+ const targetName = target.targetName ?? target.workspaceName;
649
+ if (targetName && workspace.name !== targetName) return false;
650
+ if (target.targetTags?.length && !target.targetTags.every((tag) => workspace.tags.includes(tag))) return false;
651
+ if (target.requireCapabilities?.length) {
652
+ if (!target.requireCapabilities.every((cap) => workspace.capabilities.includes(cap))) return false;
653
+ }
654
+ return true;
655
+ }
656
+
657
+ function splitCommandArgs(input: string): string[] {
658
+ const tokens: string[] = [];
659
+ const pattern = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g;
660
+ let match: RegExpExecArray | null;
661
+ while ((match = pattern.exec(input))) {
662
+ const token = match[1] ?? match[2] ?? match[3] ?? "";
663
+ tokens.push(token.replace(/\\(["\\])/g, "$1"));
664
+ }
665
+ return tokens;
666
+ }
667
+
668
+ function parseCommandArgs(input: string): ParsedCommandArgs {
669
+ const positional: string[] = [];
670
+ const flags: Record<string, string | boolean> = {};
671
+ const tokens = splitCommandArgs(input);
672
+ for (let index = 0; index < tokens.length; index += 1) {
673
+ const token = tokens[index];
674
+ if (!token.startsWith("--") || token === "--") {
675
+ positional.push(token);
676
+ continue;
677
+ }
678
+ const raw = token.slice(2);
679
+ const equalsIndex = raw.indexOf("=");
680
+ if (equalsIndex >= 0) {
681
+ flags[raw.slice(0, equalsIndex)] = raw.slice(equalsIndex + 1);
682
+ continue;
683
+ }
684
+ const next = tokens[index + 1];
685
+ if (next && !next.startsWith("--")) {
686
+ flags[raw] = next;
687
+ index += 1;
688
+ } else {
689
+ flags[raw] = true;
690
+ }
691
+ }
692
+ return { positional, flags };
693
+ }
694
+
695
+ function stringFlag(flags: Record<string, string | boolean>, ...names: string[]): string | undefined {
696
+ for (const name of names) {
697
+ const value = flags[name];
698
+ if (typeof value === "string" && value.trim()) return value.trim();
699
+ }
700
+ return undefined;
701
+ }
702
+
703
+ function booleanFlag(flags: Record<string, string | boolean>, ...names: string[]): boolean {
704
+ for (const name of names) {
705
+ const value = flags[name];
706
+ if (value === true || value === "true" || value === "1") return true;
707
+ }
708
+ return false;
709
+ }
710
+
711
+ function numberFlag(flags: Record<string, string | boolean>, label: string, ...names: string[]): number | undefined {
712
+ const raw = stringFlag(flags, ...names);
713
+ if (!raw) return undefined;
714
+ if (!/^[+-]?\d+$/.test(raw)) throw new Error(`${label} must be a number`);
715
+ const parsed = Number.parseInt(raw, 10);
716
+ if (!Number.isFinite(parsed)) throw new Error(`${label} must be a number`);
717
+ return parsed;
718
+ }
719
+
720
+ function commandTarget(flags: Record<string, string | boolean>, requireCapabilities?: CapabilityTag[]): TargetInput {
721
+ const workspace = stringFlag(flags, "target", "workspace", "w");
722
+ const tags = stringFlag(flags, "tags", "tag")
723
+ ?.split(",")
724
+ .map((item) => item.trim())
725
+ .filter(Boolean);
726
+ const workspaceIndex = workspace?.startsWith("#") && !workspace.includes(".") ? Number.parseInt(workspace.slice(1), 10) : undefined;
727
+ return {
728
+ ...(workspace?.startsWith("#") ? { targetId: workspace } : workspace ? { targetName: workspace } : {}),
729
+ ...(workspaceIndex !== undefined && Number.isFinite(workspaceIndex) ? { workspaceIndex } : {}),
730
+ ...(tags?.length ? { targetTags: tags } : {}),
731
+ requireCapabilities,
732
+ };
733
+ }
734
+
735
+ function metadataFlag(flags: Record<string, string | boolean>): Record<string, string> {
736
+ const raw = stringFlag(flags, "meta", "metadata");
737
+ if (!raw) return {};
738
+ return Object.fromEntries(
739
+ raw
740
+ .split(",")
741
+ .map((item) => item.trim())
742
+ .filter(Boolean)
743
+ .map((item) => {
744
+ const equalsIndex = item.indexOf("=");
745
+ if (equalsIndex < 0) return [item, ""];
746
+ return [item.slice(0, equalsIndex).trim(), item.slice(equalsIndex + 1).trim()];
747
+ })
748
+ .filter(([key]) => key),
749
+ );
750
+ }
751
+
752
+ function commaListFlag(flags: Record<string, string | boolean>, ...names: string[]): string[] {
753
+ const raw = stringFlag(flags, ...names);
754
+ if (!raw) return [];
755
+ return raw
756
+ .split(",")
757
+ .map((item) => item.trim())
758
+ .filter(Boolean);
759
+ }
760
+
761
+ function routesFlag(flags: Record<string, string | boolean>): WorkspaceConfig["routes"] | undefined {
762
+ const raw = stringFlag(flags, "routes", "route");
763
+ if (!raw) return undefined;
764
+ if (!raw.includes("=")) return { default: raw };
765
+ const routes: Partial<Record<RouteType, string>> = {};
766
+ for (const item of raw.split(",").map((part) => part.trim()).filter(Boolean)) {
767
+ const equalsIndex = item.indexOf("=");
768
+ if (equalsIndex < 0) throw new Error(`Route entry must be routeType=path: ${item}`);
769
+ const routeType = item.slice(0, equalsIndex).trim() as RouteType;
770
+ const routePath = item.slice(equalsIndex + 1).trim();
771
+ if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown route type: ${routeType}`);
772
+ if (!routePath) throw new Error(`Route path is empty for ${routeType}`);
773
+ routes[routeType] = routePath;
774
+ }
775
+ return routes;
776
+ }
777
+
778
+ function buildWorkspaceFromAddArgs(args: string): WorkspaceConfig {
779
+ const parsed = parseCommandArgs(args);
780
+ const workspacePath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional[0];
781
+ if (!workspacePath) throw new Error("workspace path is required");
782
+ const capabilities = commaListFlag(parsed.flags, "capabilities", "caps", "cap");
783
+ if (capabilities.length === 0) throw new Error("--capabilities is required");
784
+ const workspaceBlock: WorkspaceConfig = {
785
+ ...(stringFlag(parsed.flags, "name", "n") ? { name: stringFlag(parsed.flags, "name", "n") } : {}),
786
+ path: workspacePath,
787
+ tags: commaListFlag(parsed.flags, "tags", "tag"),
788
+ capabilities: asCapabilityArray(capabilities),
789
+ contextFiles: commaListFlag(parsed.flags, "context", "contexts", "contextFiles"),
790
+ ...(routesFlag(parsed.flags) ? { routes: routesFlag(parsed.flags) } : {}),
791
+ };
792
+ if (workspaceBlock.tags.length === 0) throw new Error("--tags is required");
793
+ if (workspaceBlock.contextFiles?.length === 0) delete workspaceBlock.contextFiles;
794
+ if (workspaceBlock.capabilities.includes("writeDocs") && !workspaceBlock.routes) {
795
+ throw new Error("workspaces with writeDocs require --route or --routes");
796
+ }
797
+ return workspaceBlock;
798
+ }
799
+
800
+ async function addWorkspaceToConfig(configPath: string, workspaceBlock: WorkspaceConfig): Promise<void> {
801
+ const exists = await pathExists(configPath);
802
+ const parsed = exists ? (YAML.parse(await readFile(configPath, "utf8"), { uniqueKeys: true }) as unknown) : { version: 1, workspaces: [] };
803
+ if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
804
+ if (parsed.version === undefined) parsed.version = 1;
805
+ if (parsed.version !== 1) throw new Error("monofold config requires version: 1");
806
+ const workspaces = Array.isArray(parsed.workspaces) ? parsed.workspaces : [];
807
+ parsed.workspaces = workspaces;
808
+ if (workspaces.some((item: unknown) => isRecord(item) && item.path === workspaceBlock.path)) {
809
+ throw new Error(`workspace path already exists: ${workspaceBlock.path}`);
810
+ }
811
+ workspaces.push(workspaceBlock);
812
+ await mkdir(path.dirname(configPath), { recursive: true });
813
+ await writeFile(configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
814
+ }
815
+
816
+ function buildProjectFromAddArgs(args: string): { project: ProjectConfig; parent: TargetInput } {
817
+ const parsed = parseCommandArgs(args);
818
+ const projectPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional[0];
819
+ if (!projectPath) throw new Error("project path is required");
820
+ const tags = commaListFlag(parsed.flags, "tags", "tag");
821
+ if (tags.length === 0) throw new Error("--tags is required");
822
+ const capabilities = commaListFlag(parsed.flags, "capabilities", "caps", "cap");
823
+ const parentName = stringFlag(parsed.flags, "parent");
824
+ const parentTags = commaListFlag(parsed.flags, "parent-tags", "parentTags");
825
+ if (!parentName && parentTags.length === 0) throw new Error("--parent or --parent-tags is required");
826
+ const project: ProjectConfig = {
827
+ ...(stringFlag(parsed.flags, "name", "n") ? { name: stringFlag(parsed.flags, "name", "n") } : {}),
828
+ path: projectPath,
829
+ tags,
830
+ ...(capabilities.length ? { capabilities: asCapabilityArray(capabilities) } : {}),
831
+ contextFiles: commaListFlag(parsed.flags, "context", "contexts", "contextFiles"),
832
+ ...(routesFlag(parsed.flags) ? { routes: routesFlag(parsed.flags) } : {}),
833
+ };
834
+ if (project.contextFiles?.length === 0) delete project.contextFiles;
835
+ return {
836
+ project,
837
+ parent: {
838
+ ...(parentName?.startsWith("#") ? { targetId: parentName } : parentName ? { targetName: parentName } : {}),
839
+ ...(parentTags.length ? { targetTags: parentTags } : {}),
840
+ },
841
+ };
842
+ }
843
+
844
+ async function addProjectToConfig(ctx: ExtensionCommandContext, loaded: LoadedConfig, project: ProjectConfig, parentInput: TargetInput): Promise<void> {
845
+ const parent = await resolveWorkspace(ctx, { ...loaded, workspaces: loaded.workspaces.filter((workspace) => workspace.kind === "workspace") }, parentInput);
846
+ assertProjectPath("project.path", project.path);
847
+ const projectRealPath = await existingRealPath("project.path", path.resolve(parent.resolvedPath, project.path));
848
+ if (!isInside(parent.realPath, projectRealPath) || projectRealPath === parent.realPath) throw new Error(`project path must stay below parent workspace: ${project.path}`);
849
+ if (loaded.workspaces.some((workspace) => workspace.parent === parent && workspace.realPath === projectRealPath)) throw new Error(`project path already exists under parent: ${project.path}`);
850
+ const parsed = YAML.parse(await readFile(loaded.configPath, "utf8"), { uniqueKeys: true }) as Record<string, unknown>;
851
+ const workspaces = parsed.workspaces as Record<string, unknown>[];
852
+ const rawParent = workspaces[parent.index];
853
+ const projects = Array.isArray(rawParent.projects) ? rawParent.projects : [];
854
+ rawParent.projects = projects;
855
+ projects.push(project);
856
+ await writeFile(loaded.configPath, YAML.stringify(parsed).trimEnd() + "\n", "utf8");
857
+ }
858
+
859
+ function sendCommandOutput(pi: ExtensionAPI, title: string, text: string, details?: Record<string, unknown>) {
860
+ pi.sendMessage({
861
+ customType: "monofold-output",
862
+ content: `## ${title}\n\n${text}`,
863
+ display: true,
864
+ details: details ?? {},
865
+ });
866
+ }
867
+
868
+ function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usage: string) {
869
+ const message = error instanceof Error ? error.message : String(error);
870
+ sendCommandOutput(pi, command, `Error: ${message}\n\nUsage:\n${usage}`, { error: message });
871
+ }
872
+
873
+ function formatFocusStatus(position: ActiveFocusPresetPosition): string {
874
+ return `focus: ${position.preset.label} (${position.index + 1}/${position.total}) ${FOCUS_CYCLE_SHORTCUT}`;
875
+ }
876
+
877
+ function updateFocusStatus(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig): void {
878
+ if (!ctx.hasUI) return;
879
+ const position = getActiveFocusPresetPosition(loaded.raw.focusPresets);
880
+ ctx.ui.setStatus(FOCUS_STATUS_ID, position ? formatFocusStatus(position) : undefined);
881
+ }
882
+
883
+ function notifyFocusApplied(
884
+ ctx: ExtensionContext | ExtensionCommandContext,
885
+ loaded: LoadedConfig,
886
+ position: ActiveFocusPresetPosition,
887
+ source: "command" | "shortcut",
888
+ ): void {
889
+ updateFocusStatus(ctx, loaded);
890
+ warnZeroTargetMatchesForPreset(position.preset, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
891
+ ctx.ui.notify(
892
+ source === "shortcut"
893
+ ? `Active Focus: ${position.preset.label} (${position.index + 1}/${position.total})`
894
+ : `Active Focus set to ${position.preset.label} (${position.index + 1}/${position.total})`,
895
+ "info",
896
+ );
897
+ }
898
+
899
+ function notifyNoFocusPresets(ctx: ExtensionContext | ExtensionCommandContext, pi?: ExtensionAPI): void {
900
+ const message = `No focusPresets configured. Add focusPresets to ${CONFIG_RELATIVE_PATH}, then reload Pi.`;
901
+ ctx.ui.notify(message, "warning");
902
+ if (pi) sendCommandOutput(pi, "monofold:focus", message, { focusPresets: 0 });
903
+ }
904
+
905
+ async function resolveWorkspace(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
906
+ const matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
907
+ if (matches.length === 0) throw new Error(`No workspace matches target: ${JSON.stringify(target)}`);
908
+ if (matches.length === 1) return matches[0];
909
+ if (!ctx.hasUI) {
910
+ throw new Error(`Multiple workspaces match target in non-interactive mode: ${matches.map(formatWorkspaceLabel).join(", ")}`);
911
+ }
912
+ const labels = matches.map(formatWorkspaceLabel);
913
+ const choice = await ctx.ui.select("Select workspace", labels);
914
+ if (!choice) throw new Error("Workspace selection cancelled");
915
+ return matches[labels.indexOf(choice)];
916
+ }
917
+
918
+ function formatWorkspaceLabel(workspace: ResolvedWorkspace): string {
919
+ const displayName = workspace.name ? `${workspace.name} ` : "";
920
+ const parent = workspace.kind === "project" && workspace.parent ? ` parent=${workspace.parent.name ?? workspace.parent.targetId}` : "";
921
+ return `${workspace.targetId} ${displayName}[${workspace.tags.join(", ")}] ${workspace.displayPath}${parent}`;
922
+ }
923
+
924
+ function relativePath(workspace: ResolvedWorkspace, inputPath: string): string {
925
+ assertWorkspaceInternalRelative("path", inputPath);
926
+ return path.join(workspace.resolvedPath, inputPath);
927
+ }
928
+
929
+ async function gitSummary(workspace: ResolvedWorkspace): Promise<{ isGit: boolean; status?: string }> {
930
+ const root = await gitRoot(workspace);
931
+ if (!root) return { isGit: false };
932
+ const result = await runCommand("git", ["-C", root, "status", "--short"], { timeout: 5000 });
933
+ return { isGit: true, status: result.stdout.trim() || "clean" };
934
+ }
935
+
936
+ async function gitRoot(workspace: ResolvedWorkspace): Promise<string | undefined> {
937
+ if (await pathExists(path.join(workspace.resolvedPath, ".git"))) return workspace.resolvedPath;
938
+ if (workspace.parent && (await pathExists(path.join(workspace.parent.resolvedPath, ".git")))) return workspace.parent.resolvedPath;
939
+ return undefined;
940
+ }
941
+
942
+ async function buildManifest(loaded: LoadedConfig): Promise<string> {
943
+ const lines = ["Pi Monofold Manifest:"];
944
+ for (const workspace of loaded.workspaces) {
945
+ const git = await gitSummary(workspace).catch((error) => ({ isGit: false, status: `git status error: ${String(error)}` }));
946
+ lines.push(
947
+ `- ${formatWorkspaceLabel(workspace)}\n` +
948
+ ` capabilities: ${workspace.capabilities.join(", ")}\n` +
949
+ ` routes: ${Object.keys(workspace.normalizedRoutes).join(", ") || "none"}\n` +
950
+ ` contextFiles: ${workspace.effectiveContextFiles.join(", ") || "none"}\n` +
951
+ ` git: ${git.isGit ? git.status : "not a git repository"}`,
952
+ );
953
+ }
954
+ lines.push("Use monofold_* tools for cross-workspace operations. Do not guess output paths when a route exists.");
955
+ return lines.join("\n");
956
+ }
957
+
958
+ function slugify(title: string): string {
959
+ const normalized = title
960
+ .trim()
961
+ .toLowerCase()
962
+ .replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, "")
963
+ .replace(/\s+/g, "-")
964
+ .replace(/-+/g, "-")
965
+ .replace(/^-|-$/g, "");
966
+ return normalized || "note";
967
+ }
968
+
969
+ function renderTemplate(template: string, vars: Record<string, string>): string {
970
+ return template.replace(/\{\{(date|datetime|title|slug|routeType|workspaceName|workspaceTags|targetName|targetTags|parentWorkspaceName|projectName)\}\}/g, (_, key: string) => vars[key] ?? "");
971
+ }
972
+
973
+ function renderMetadata(value: unknown, vars: Record<string, string>): unknown {
974
+ if (typeof value === "string") return renderTemplate(value, vars);
975
+ if (Array.isArray(value)) return value.map((item) => renderMetadata(item, vars));
976
+ if (isRecord(value)) {
977
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderMetadata(item, vars)]));
978
+ }
979
+ return value;
980
+ }
981
+
982
+ function frontmatter(metadata: Record<string, unknown>): string {
983
+ if (Object.keys(metadata).length === 0) return "";
984
+ return `---\n${YAML.stringify(metadata).trim()}\n---\n\n`;
985
+ }
986
+
987
+ function classifyPath(targetPath: string): "docs" | "code" | "unknown" {
988
+ const ext = path.extname(targetPath).toLowerCase();
989
+ if (DOC_EXTENSIONS.has(ext)) return "docs";
990
+ if (CODE_EXTENSIONS.has(ext)) return "code";
991
+ return "unknown";
992
+ }
993
+
994
+ function findWorkspaceForPath(loaded: LoadedConfig, targetPath: string): ResolvedWorkspace | undefined {
995
+ const absolute = normalizeGuardPath(path.isAbsolute(targetPath) ? targetPath : path.resolve(loaded.root, targetPath));
996
+ return [...loaded.workspaces].sort((a, b) => b.resolvedPath.length - a.resolvedPath.length).find((workspace) => isInside(workspace.resolvedPath, absolute));
997
+ }
998
+
999
+ async function confirm(ctx: ExtensionContext, title: string, body: string): Promise<boolean> {
1000
+ if (!ctx.hasUI) return false;
1001
+ return ctx.ui.confirm(title, body);
1002
+ }
1003
+
1004
+ async function maybeBlockUnknown(ctx: ExtensionContext, loaded: LoadedConfig, targetPath: string, action: string) {
1005
+ const workspace = findWorkspaceForPath(loaded, targetPath);
1006
+ if (workspace) return undefined;
1007
+ const normalized = normalizeGuardPath(path.isAbsolute(targetPath) ? targetPath : path.resolve(loaded.root, targetPath));
1008
+ const storedAllows = await loadUnknownPathAllows(loaded.root);
1009
+ if (storedAllows.has(normalized)) return undefined;
1010
+ if (!ctx.hasUI) return { block: true, reason: `Unknown Path requires confirmation: ${targetPath}` };
1011
+ const choice = await ctx.ui.select(
1012
+ `Unknown Path — ${normalized}`,
1013
+ ["Yes (remember across sessions)", "Yes (just this once)", "No"],
1014
+ );
1015
+ if (!choice || choice === "No") return { block: true, reason: `Unknown Path requires confirmation: ${targetPath}` };
1016
+ if (choice === "Yes (remember across sessions)") await rememberUnknownPathAllow(loaded.root, normalized);
1017
+ return undefined;
1018
+ }
1019
+
1020
+ async function guardPathOperation(ctx: ExtensionContext, loaded: LoadedConfig, targetPath: string, action: "read" | "write" | "edit") {
1021
+ const workspace = findWorkspaceForPath(loaded, targetPath);
1022
+ if (!workspace) return maybeBlockUnknown(ctx, loaded, targetPath, action);
1023
+ if (action === "read") {
1024
+ if (!workspace.capabilities.includes("read")) return { block: true, reason: `Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}` };
1025
+ return undefined;
1026
+ }
1027
+ const kind = classifyPath(targetPath);
1028
+ if (kind === "docs" && workspace.capabilities.includes("writeDocs")) return undefined;
1029
+ if (kind === "code" && workspace.capabilities.includes("editCode")) return undefined;
1030
+ if (kind === "unknown") {
1031
+ const ok = await confirm(
1032
+ ctx,
1033
+ "Unclassified file write",
1034
+ `${action} targets an unclassified file in ${formatWorkspaceLabel(workspace)}:\n${targetPath}\nAllow?`,
1035
+ );
1036
+ if (ok) return undefined;
1037
+ }
1038
+ return { block: true, reason: `Workspace lacks capability for ${kind} ${action}: ${formatWorkspaceLabel(workspace)}` };
1039
+ }
1040
+
1041
+ function bashLooksDangerous(command: string): string | undefined {
1042
+ const normalized = command.toLowerCase();
1043
+ if (/rm\s+(-[^\n;]*r[^\n;]*f|-rf|-fr)/.test(normalized)) return "rm -rf";
1044
+ if (/git\s+reset\s+--hard/.test(normalized)) return "git reset --hard";
1045
+ if (/git\s+clean\b/.test(normalized)) return "git clean";
1046
+ if (/chmod\s+-r/.test(normalized)) return "chmod -R";
1047
+ return undefined;
1048
+ }
1049
+
1050
+ function bashContainsGitCommitOrPush(command: string): boolean {
1051
+ return /(^|[;&|]\s*)git\s+(commit|push)\b/i.test(command);
1052
+ }
1053
+
1054
+ function inferBashCwd(ctx: ExtensionContext, command: string): string {
1055
+ const baseCwd = normalizeGuardPath(ctx.cwd);
1056
+ const match = command.match(/(?:^|[;&|]\s*)cd\s+([^;&|\n]+)/);
1057
+ if (!match) return baseCwd;
1058
+ const raw = match[1].trim().replace(/^['"]|['"]$/g, "");
1059
+ return normalizeGuardPath(path.resolve(baseCwd, raw));
1060
+ }
1061
+
1062
+ export default function piMultiWorkspace(pi: ExtensionAPI) {
1063
+ pi.on("session_start", async (_event, ctx) => {
1064
+ try {
1065
+ const loaded = await loadConfig(ctx.cwd);
1066
+ ensureActiveFocusInitialized(loaded.raw.focusPresets);
1067
+ updateFocusStatus(ctx, loaded);
1068
+ const activeId = getActiveFocusPresetId();
1069
+ if (!activeId) return;
1070
+ const preset = findFocusPresetById(loaded.raw.focusPresets, activeId);
1071
+ if (!preset) return;
1072
+ if (!ctx.hasUI) return;
1073
+ warnZeroTargetMatchesForPreset(preset, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
1074
+ } catch {
1075
+ return;
1076
+ }
1077
+ });
1078
+
1079
+ pi.on("before_agent_start", async (_event, ctx) => {
1080
+ try {
1081
+ const loaded = await loadConfig(ctx.cwd);
1082
+ const manifest = await buildManifest(loaded);
1083
+ return {
1084
+ systemPrompt:
1085
+ _event.systemPrompt +
1086
+ `
1087
+
1088
+ ## Pi Monofold
1089
+
1090
+ ${manifest}
1091
+ `,
1092
+ };
1093
+ } catch {
1094
+ return undefined;
1095
+ }
1096
+ });
1097
+
1098
+ pi.registerTool({
1099
+ name: "monofold_list",
1100
+ label: "Workspace List",
1101
+ description: "List configured Pi Monofold workspaces with tags, capabilities, routes, context files, and git status.",
1102
+ parameters: Type.Object({}),
1103
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
1104
+ const loaded = await loadConfig(ctx.cwd);
1105
+ const manifest = await buildManifest(loaded);
1106
+ return { content: [{ type: "text", text: manifest }], details: { workspaces: loaded.workspaces } };
1107
+ },
1108
+ });
1109
+
1110
+ pi.registerTool({
1111
+ name: "monofold_read",
1112
+ label: "Workspace Read",
1113
+ description: "Read, search, or list files inside a configured Workspace. Requires read capability.",
1114
+ parameters: Type.Object({
1115
+ mode: Type.String({ description: "file, search, or tree" }),
1116
+ path: Type.Optional(Type.String({ description: "Workspace-relative path for file/tree" })),
1117
+ query: Type.Optional(Type.String({ description: "Search query for mode=search" })),
1118
+ depth: Type.Optional(Type.Number({ description: "Tree depth, default 1" })),
1119
+ includeContent: Type.Optional(
1120
+ Type.Boolean({ description: "mode=file only: return full file content instead of a bounded preview" }),
1121
+ ),
1122
+ maxChars: Type.Optional(
1123
+ Type.Number({ description: "mode=file: max preview characters; mode=search: max output characters before truncation (default 8000)" }),
1124
+ ),
1125
+ head: Type.Optional(
1126
+ Type.Number({ description: "mode=file only: include the first N lines when building a bounded preview" }),
1127
+ ),
1128
+ tail: Type.Optional(
1129
+ Type.Number({ description: "mode=file only: include the last N lines when building a bounded preview" }),
1130
+ ),
1131
+ maxMatches: Type.Optional(Type.Integer({ minimum: 1, description: "Search: max match lines before truncation (default 50)" })),
1132
+ maxEntries: Type.Optional(Type.Integer({ minimum: 1, description: "Tree: max entries before truncation (default 200)" })),
1133
+ targetTags: Type.Optional(Type.Array(Type.String())),
1134
+ targetName: Type.Optional(Type.String()),
1135
+ targetId: Type.Optional(Type.String()),
1136
+ workspaceName: Type.Optional(Type.String()),
1137
+ requireCapabilities: Type.Optional(Type.Array(Type.String())),
1138
+ }),
1139
+ async execute(_id, params, signal, _onUpdate, ctx) {
1140
+ const loaded = await loadConfig(ctx.cwd);
1141
+ const workspace = await resolveWorkspace(ctx, loaded, {
1142
+ targetTags: params.targetTags,
1143
+ targetName: params.targetName,
1144
+ targetId: params.targetId,
1145
+ workspaceName: params.workspaceName,
1146
+ requireCapabilities: ["read"],
1147
+ });
1148
+ if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
1149
+ if (params.mode === "file") {
1150
+ if (!params.path) throw new Error("monofold_read mode=file requires path");
1151
+ const filePath = relativePath(workspace, params.path);
1152
+ const preview = await readMonofoldFile(filePath, params.path, {
1153
+ includeContent: params.includeContent,
1154
+ maxChars: params.maxChars,
1155
+ head: params.head,
1156
+ tail: params.tail,
1157
+ });
1158
+ return {
1159
+ content: [{ type: "text", text: preview.text }],
1160
+ details: {
1161
+ workspace: formatWorkspaceLabel(workspace),
1162
+ path: params.path,
1163
+ ...preview.details,
1164
+ },
1165
+ };
1166
+ }
1167
+ if (params.mode === "tree") {
1168
+ const root = params.path ? relativePath(workspace, params.path) : workspace.resolvedPath;
1169
+ const depth = Math.max(0, Math.min(5, params.depth ?? 1));
1170
+ const capped = await buildMonofoldTree(root, depth, { maxEntries: params.maxEntries });
1171
+ return {
1172
+ content: [{ type: "text", text: capped.text }],
1173
+ details: {
1174
+ workspace: formatWorkspaceLabel(workspace),
1175
+ path: params.path ?? ".",
1176
+ entryCount: capped.entryCount,
1177
+ returnedEntryCount: capped.returnedEntryCount,
1178
+ maxEntries: capped.maxEntries,
1179
+ truncated: capped.truncated,
1180
+ ...(capped.hint ? { hint: capped.hint } : {}),
1181
+ },
1182
+ };
1183
+ }
1184
+ if (params.mode === "search") {
1185
+ if (!params.query) throw new Error("monofold_read mode=search requires query");
1186
+ const capped = await runMonofoldSearch(runCommand, workspace.resolvedPath, params.query, params.path ?? ".", {
1187
+ signal,
1188
+ maxMatches: params.maxMatches,
1189
+ maxChars: params.maxChars,
1190
+ });
1191
+ return {
1192
+ content: [{ type: "text", text: capped.text }],
1193
+ details: {
1194
+ workspace: formatWorkspaceLabel(workspace),
1195
+ query: params.query,
1196
+ matchCount: capped.matchCount,
1197
+ returnedMatchCount: capped.returnedMatchCount,
1198
+ maxMatches: capped.maxMatches,
1199
+ maxChars: capped.maxChars,
1200
+ truncated: capped.truncated,
1201
+ ...(capped.hint ? { hint: capped.hint } : {}),
1202
+ },
1203
+ };
1204
+ }
1205
+ throw new Error(`Unknown monofold_read mode: ${params.mode}`);
1206
+ },
1207
+ });
1208
+
1209
+ pi.registerTool({
1210
+ name: "monofold_write",
1211
+ label: "Workspace Write",
1212
+ description: "Write a Markdown document to a routed Workspace destination using routeType, title, body, filename, and metadata.",
1213
+ parameters: Type.Object({
1214
+ routeType: Type.String({ description: "default, prd, design, progress, issue, research, or decision" }),
1215
+ title: Type.String(),
1216
+ body: Type.String(),
1217
+ filename: Type.Optional(Type.String()),
1218
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Any())),
1219
+ targetTags: Type.Optional(Type.Array(Type.String())),
1220
+ targetName: Type.Optional(Type.String()),
1221
+ targetId: Type.Optional(Type.String()),
1222
+ workspaceName: Type.Optional(Type.String()),
1223
+ }),
1224
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1225
+ const routeType = params.routeType as RouteType;
1226
+ if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${params.routeType}`);
1227
+ const loaded = await loadConfig(ctx.cwd);
1228
+ const workspace = await resolveWorkspace(ctx, loaded, {
1229
+ targetTags: params.targetTags,
1230
+ targetName: params.targetName,
1231
+ targetId: params.targetId,
1232
+ workspaceName: params.workspaceName,
1233
+ requireCapabilities: ["writeDocs"],
1234
+ });
1235
+ const route = workspace.normalizedRoutes[routeType] ?? workspace.normalizedRoutes.default;
1236
+ if (!route) throw new Error(`Workspace has no route for ${routeType} and no default route`);
1237
+ const now = new Date();
1238
+ const date = now.toISOString().slice(0, 10);
1239
+ const vars = {
1240
+ date,
1241
+ datetime: now.toISOString(),
1242
+ title: params.title,
1243
+ slug: slugify(params.title),
1244
+ routeType,
1245
+ workspaceName: workspace.name ?? "",
1246
+ workspaceTags: workspace.tags.join(","),
1247
+ targetName: workspace.name ?? "",
1248
+ targetTags: workspace.tags.join(","),
1249
+ parentWorkspaceName: workspace.parent?.name ?? "",
1250
+ projectName: workspace.kind === "project" ? workspace.name ?? "" : "",
1251
+ };
1252
+ const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
1253
+ const filename = params.filename ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
1254
+ assertWorkspaceInternalRelative("filename", filename);
1255
+ const dir = relativePath(workspace, route.path);
1256
+ const outputPath = path.join(dir, filename);
1257
+ const defaultMetadata = loaded.raw.defaults?.metadata ?? {};
1258
+ const routeMetadata = route.metadata ?? {};
1259
+ const metadata = renderMetadata({ ...defaultMetadata, ...routeMetadata, ...(params.metadata ?? {}) }, vars) as Record<string, unknown>;
1260
+ const text = `${frontmatter(metadata)}# ${params.title}\n\n${params.body.trim()}\n`;
1261
+ await mkdir(path.dirname(outputPath), { recursive: true });
1262
+ await writeFile(outputPath, text, "utf8");
1263
+ const rel = normalizeSlashes(path.relative(workspace.resolvedPath, outputPath));
1264
+ return { content: [{ type: "text", text: `Wrote ${formatWorkspaceLabel(workspace)}:${rel}` }], details: { workspace, path: rel } };
1265
+ },
1266
+ });
1267
+
1268
+ pi.registerTool({
1269
+ name: "monofold_git",
1270
+ label: "Workspace Git",
1271
+ description: "Run guarded git status, commit, push, or commitPush for one configured Git Workspace.",
1272
+ parameters: Type.Object({
1273
+ action: Type.String({ description: "status, commit, push, or commitPush" }),
1274
+ message: Type.Optional(Type.String()),
1275
+ targetTags: Type.Optional(Type.Array(Type.String())),
1276
+ targetName: Type.Optional(Type.String()),
1277
+ targetId: Type.Optional(Type.String()),
1278
+ workspaceName: Type.Optional(Type.String()),
1279
+ }),
1280
+ async execute(_id, params, signal, _onUpdate, ctx) {
1281
+ const required: CapabilityTag[] = params.action === "status" ? ["read"] : ["git"];
1282
+ const loaded = await loadConfig(ctx.cwd);
1283
+ const workspace = await resolveWorkspace(ctx, loaded, {
1284
+ targetTags: params.targetTags,
1285
+ targetName: params.targetName,
1286
+ targetId: params.targetId,
1287
+ workspaceName: params.workspaceName,
1288
+ requireCapabilities: required,
1289
+ });
1290
+ const root = await gitRoot(workspace);
1291
+ if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
1292
+ if (params.action === "status") {
1293
+ if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
1294
+ const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { signal, timeout: 10000 });
1295
+ return { content: [{ type: "text", text: result.stdout || "clean" }], details: { workspace } };
1296
+ }
1297
+ if (!workspace.capabilities.includes("git")) throw new Error(`Workspace lacks git capability: ${formatWorkspaceLabel(workspace)}`);
1298
+ if (params.action === "commit" || params.action === "commitPush") {
1299
+ const message = params.message ?? `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
1300
+ const status = await runCommand("git", ["-C", root, "status", "--short"], { signal, timeout: 10000 });
1301
+ const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { signal, timeout: 10000 });
1302
+ const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
1303
+ let pushContext = "";
1304
+ if (params.action === "commitPush") {
1305
+ const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { signal, timeout: 10000 });
1306
+ const remote = await runCommand("git", ["-C", root, "remote", "-v"], { signal, timeout: 10000 });
1307
+ const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
1308
+ signal,
1309
+ timeout: 10000,
1310
+ allowExitCodes: [0, 128],
1311
+ });
1312
+ pushContext = `\n\nPush after commit:\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\nCommits already ahead:\n${log.stdout || "none/unknown upstream"}`;
1313
+ }
1314
+ const ok = await confirm(ctx, params.action === "commitPush" ? "Workspace Commit + Push" : "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}${pushContext}\n\n${params.action === "commitPush" ? "Stage scoped changes, commit, then push?" : "Stage scoped changes and commit?"}`);
1315
+ if (!ok) return { content: [{ type: "text", text: params.action === "commitPush" ? "Commit+push cancelled" : "Commit cancelled" }], details: { cancelled: true } };
1316
+ await runCommand("git", ["-C", root, "add", "-A", "--", scope], { signal, timeout: 10000 });
1317
+ const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { signal, timeout: 30000 });
1318
+ if (params.action === "commitPush") {
1319
+ const push = await runCommand("git", ["-C", root, "push"], { signal, timeout: 60000 });
1320
+ return { content: [{ type: "text", text: [commit.stdout || commit.stderr, push.stdout || push.stderr].filter(Boolean).join("\n") }], details: { workspace, message } };
1321
+ }
1322
+ return { content: [{ type: "text", text: commit.stdout || commit.stderr }], details: { workspace, message } };
1323
+ }
1324
+ if (params.action === "push") {
1325
+ const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { signal, timeout: 10000 });
1326
+ const remote = await runCommand("git", ["-C", root, "remote", "-v"], { signal, timeout: 10000 });
1327
+ const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
1328
+ signal,
1329
+ timeout: 10000,
1330
+ allowExitCodes: [0, 128],
1331
+ });
1332
+ 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?`);
1333
+ if (!ok) return { content: [{ type: "text", text: "Push cancelled" }], details: { cancelled: true } };
1334
+ const push = await runCommand("git", ["-C", root, "push"], { signal, timeout: 60000 });
1335
+ return { content: [{ type: "text", text: push.stdout || push.stderr }], details: { workspace } };
1336
+ }
1337
+ throw new Error(`Unknown monofold_git action: ${params.action}`);
1338
+ },
1339
+ });
1340
+
1341
+ const listCommand = async (_args: string, ctx: ExtensionCommandContext) => {
1342
+ try {
1343
+ const loaded = await loadConfig(ctx.cwd);
1344
+ const manifest = await buildManifest(loaded);
1345
+ sendCommandOutput(pi, "monofold:list", manifest, { workspaces: loaded.workspaces });
1346
+ } catch (error) {
1347
+ sendCommandError(pi, "monofold:list", error, "/monofold:list");
1348
+ }
1349
+ };
1350
+
1351
+ const readUsage = [
1352
+ "/monofold:read file <path> [--workspace \"Name\"|--workspace #0] [--include-content] [--max-chars N] [--head N] [--tail N]",
1353
+ "/monofold:tree [path] [--workspace \"Name\"|--workspace #0] [--depth 2] [--max-entries N]",
1354
+ "/monofold:search <query> [--workspace \"Name\"|--workspace #0] [--path subdir] [--max-matches N] [--max-chars N]",
1355
+ "Legacy read/search/tree commands return bounded previews by default. Pass --include-content or larger caps intentionally.",
1356
+ "Aliases: /monofold_read tree|file|search ...",
1357
+ ].join("\n");
1358
+
1359
+ const readCommand = async (args: string, ctx: ExtensionCommandContext) => {
1360
+ try {
1361
+ const parsed = parseCommandArgs(args);
1362
+ const mode = parsed.positional[0] ?? "tree";
1363
+ const loaded = await loadConfig(ctx.cwd);
1364
+ const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, ["read"]));
1365
+ if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
1366
+
1367
+ if (mode === "file" || mode === "read") {
1368
+ const inputPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional.slice(1).join(" ");
1369
+ if (!inputPath) throw new Error("file mode requires path");
1370
+ const filePath = relativePath(workspace, inputPath);
1371
+ const preview = await readMonofoldFile(filePath, inputPath, {
1372
+ includeContent: booleanFlag(parsed.flags, "include-content", "includeContent"),
1373
+ maxChars: numberFlag(parsed.flags, "max-chars", "maxChars", "max-chars"),
1374
+ head: numberFlag(parsed.flags, "head", "head"),
1375
+ tail: numberFlag(parsed.flags, "tail", "tail"),
1376
+ });
1377
+ sendCommandOutput(pi, `monofold:read ${formatWorkspaceLabel(workspace)}:${inputPath}`, preview.text, {
1378
+ workspace,
1379
+ path: inputPath,
1380
+ ...preview.details,
1381
+ });
1382
+ return;
1383
+ }
1384
+
1385
+ if (mode === "tree" || mode === "ls") {
1386
+ const inputPath = stringFlag(parsed.flags, "path", "p") ?? parsed.positional.slice(1).join(" ");
1387
+ const depth = Number.parseInt(stringFlag(parsed.flags, "depth", "d") ?? "1", 10);
1388
+ const root = inputPath ? relativePath(workspace, inputPath) : workspace.resolvedPath;
1389
+ const treeDepth = Math.max(0, Math.min(5, Number.isFinite(depth) ? depth : 1));
1390
+ const capped = await buildMonofoldTree(root, treeDepth, {
1391
+ maxEntries: numberFlag(parsed.flags, "max-entries", "maxEntries", "max-entries"),
1392
+ });
1393
+ sendCommandOutput(pi, `monofold:tree ${formatWorkspaceLabel(workspace)}:${inputPath || "."}`, capped.text, {
1394
+ workspace,
1395
+ path: inputPath || ".",
1396
+ entryCount: capped.entryCount,
1397
+ returnedEntryCount: capped.returnedEntryCount,
1398
+ maxEntries: capped.maxEntries,
1399
+ truncated: capped.truncated,
1400
+ ...(capped.hint ? { hint: capped.hint } : {}),
1401
+ });
1402
+ return;
1403
+ }
1404
+
1405
+ if (mode === "search" || mode === "grep") {
1406
+ const query = stringFlag(parsed.flags, "query", "q") ?? parsed.positional.slice(1).join(" ");
1407
+ if (!query) throw new Error("search mode requires query");
1408
+ const searchPath = stringFlag(parsed.flags, "path", "p") ?? ".";
1409
+ const capped = await runMonofoldSearch(runCommand, workspace.resolvedPath, query, searchPath, {
1410
+ maxMatches: numberFlag(parsed.flags, "max-matches", "maxMatches", "max-matches"),
1411
+ maxChars: numberFlag(parsed.flags, "max-chars", "maxChars", "max-chars"),
1412
+ });
1413
+ sendCommandOutput(pi, `monofold:search ${formatWorkspaceLabel(workspace)}:${query}`, capped.text, {
1414
+ workspace,
1415
+ query,
1416
+ matchCount: capped.matchCount,
1417
+ returnedMatchCount: capped.returnedMatchCount,
1418
+ maxMatches: capped.maxMatches,
1419
+ maxChars: capped.maxChars,
1420
+ truncated: capped.truncated,
1421
+ ...(capped.hint ? { hint: capped.hint } : {}),
1422
+ });
1423
+ return;
1424
+ }
1425
+
1426
+ throw new Error(`Unknown read mode: ${mode}`);
1427
+ } catch (error) {
1428
+ sendCommandError(pi, "monofold:read", error, readUsage);
1429
+ }
1430
+ };
1431
+
1432
+ const writeUsage = [
1433
+ "/monofold:write --route progress --title \"Title\" --body \"Markdown body\" [--workspace \"Name\"|--workspace #0]",
1434
+ "Optional: --filename file.md --meta key=value,other=value",
1435
+ "Alias: /monofold_write ...",
1436
+ ].join("\n");
1437
+
1438
+ const writeCommand = async (args: string, ctx: ExtensionCommandContext) => {
1439
+ try {
1440
+ const parsed = parseCommandArgs(args);
1441
+ const routeType = (stringFlag(parsed.flags, "route", "r") ?? parsed.positional[0] ?? "default") as RouteType;
1442
+ if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${routeType}`);
1443
+ const title = stringFlag(parsed.flags, "title", "t");
1444
+ const body = stringFlag(parsed.flags, "body", "b");
1445
+ if (!title) throw new Error("--title is required");
1446
+ if (!body) throw new Error("--body is required");
1447
+
1448
+ const loaded = await loadConfig(ctx.cwd);
1449
+ const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, ["writeDocs"]));
1450
+ const route = workspace.normalizedRoutes[routeType] ?? workspace.normalizedRoutes.default;
1451
+ if (!route) throw new Error(`Workspace has no route for ${routeType} and no default route`);
1452
+ const now = new Date();
1453
+ const date = now.toISOString().slice(0, 10);
1454
+ const vars = {
1455
+ date,
1456
+ datetime: now.toISOString(),
1457
+ title,
1458
+ slug: slugify(title),
1459
+ routeType,
1460
+ workspaceName: workspace.name ?? "",
1461
+ workspaceTags: workspace.tags.join(","),
1462
+ targetName: workspace.name ?? "",
1463
+ targetTags: workspace.tags.join(","),
1464
+ parentWorkspaceName: workspace.parent?.name ?? "",
1465
+ projectName: workspace.kind === "project" ? workspace.name ?? "" : "",
1466
+ };
1467
+ const defaultTemplate = loaded.raw.defaults?.filenameTemplate ?? "{{date}}-{{slug}}.md";
1468
+ const filename = stringFlag(parsed.flags, "filename", "file", "f") ?? renderTemplate(route.filenameTemplate ?? defaultTemplate, vars);
1469
+ assertWorkspaceInternalRelative("filename", filename);
1470
+ const dir = relativePath(workspace, route.path);
1471
+ const outputPath = path.join(dir, filename);
1472
+ const metadata = renderMetadata(
1473
+ { ...(loaded.raw.defaults?.metadata ?? {}), ...(route.metadata ?? {}), ...metadataFlag(parsed.flags) },
1474
+ vars,
1475
+ ) as Record<string, unknown>;
1476
+ const text = `${frontmatter(metadata)}# ${title}\n\n${body.trim()}\n`;
1477
+ await mkdir(path.dirname(outputPath), { recursive: true });
1478
+ await writeFile(outputPath, text, "utf8");
1479
+ const rel = normalizeSlashes(path.relative(workspace.resolvedPath, outputPath));
1480
+ sendCommandOutput(pi, "monofold:write", `Wrote ${formatWorkspaceLabel(workspace)}:${rel}`, { workspace, path: rel });
1481
+ } catch (error) {
1482
+ sendCommandError(pi, "monofold:write", error, writeUsage);
1483
+ }
1484
+ };
1485
+
1486
+ const gitUsage = "/monofold:git status|commit|push [--workspace \"Name\"|--workspace #0] [--message \"Commit message\"]\nAlias: /monofold_git ...";
1487
+
1488
+ const gitCommand = async (args: string, ctx: ExtensionCommandContext) => {
1489
+ try {
1490
+ const parsed = parseCommandArgs(args);
1491
+ const action = parsed.positional[0] ?? "status";
1492
+ const required: CapabilityTag[] = action === "status" ? ["read"] : ["git"];
1493
+ const loaded = await loadConfig(ctx.cwd);
1494
+ const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, required));
1495
+ const root = await gitRoot(workspace);
1496
+ if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
1497
+
1498
+ if (action === "status") {
1499
+ const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { timeout: 10000 });
1500
+ sendCommandOutput(pi, `monofold:git status ${formatWorkspaceLabel(workspace)}`, result.stdout || "clean", { workspace });
1501
+ return;
1502
+ }
1503
+
1504
+ if (action === "commit") {
1505
+ const parsedMessage = stringFlag(parsed.flags, "message", "m") ?? parsed.positional.slice(1).join(" ");
1506
+ const message = parsedMessage || `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
1507
+ const status = await runCommand("git", ["-C", root, "status", "--short"], { timeout: 10000 });
1508
+ const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { timeout: 10000 });
1509
+ const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
1510
+ 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?`);
1511
+ if (!ok) {
1512
+ sendCommandOutput(pi, "monofold:git commit", "Commit cancelled", { cancelled: true });
1513
+ return;
1514
+ }
1515
+ await runCommand("git", ["-C", root, "add", "-A", "--", scope], { timeout: 10000 });
1516
+ const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { timeout: 30000 });
1517
+ sendCommandOutput(pi, `monofold:git commit ${formatWorkspaceLabel(workspace)}`, commit.stdout || commit.stderr, { workspace, message });
1518
+ return;
1519
+ }
1520
+
1521
+ if (action === "push") {
1522
+ const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { timeout: 10000 });
1523
+ const remote = await runCommand("git", ["-C", root, "remote", "-v"], { timeout: 10000 });
1524
+ const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
1525
+ timeout: 10000,
1526
+ allowExitCodes: [0, 128],
1527
+ });
1528
+ 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?`);
1529
+ if (!ok) {
1530
+ sendCommandOutput(pi, "monofold:git push", "Push cancelled", { cancelled: true });
1531
+ return;
1532
+ }
1533
+ const push = await runCommand("git", ["-C", root, "push"], { timeout: 60000 });
1534
+ sendCommandOutput(pi, `monofold:git push ${formatWorkspaceLabel(workspace)}`, push.stdout || push.stderr, { workspace });
1535
+ return;
1536
+ }
1537
+
1538
+ throw new Error(`Unknown git action: ${action}`);
1539
+ } catch (error) {
1540
+ sendCommandError(pi, "monofold:git", error, gitUsage);
1541
+ }
1542
+ };
1543
+
1544
+ const addUsage = [
1545
+ "/monofold:add <path> --name \"Name\" --tags tag1,tag2 --capabilities read,editCode,runCommands,gitCommit",
1546
+ "Optional: --context README.md,AGENTS.md",
1547
+ "Docs workspace: --capabilities read,writeDocs,gitCommit --route Notes",
1548
+ "Multi-route docs: --routes default=Notes,progress=Progress,research=Research",
1549
+ "Alias: /monofold_add ...",
1550
+ ].join("\n");
1551
+
1552
+ const addCommand = async (args: string, ctx: ExtensionCommandContext) => {
1553
+ try {
1554
+ const workspaceBlock = buildWorkspaceFromAddArgs(args);
1555
+ const configFile = await resolveConfigFile(ctx.cwd, true);
1556
+ await addWorkspaceToConfig(configFile.configPath, workspaceBlock);
1557
+ const loaded = await loadConfig(ctx.cwd);
1558
+ sendCommandOutput(pi, "monofold:add", `Added workspace:\n${YAML.stringify(workspaceBlock).trim()}\n\n${await buildManifest(loaded)}`, {
1559
+ workspace: workspaceBlock,
1560
+ });
1561
+ } catch (error) {
1562
+ sendCommandError(pi, "monofold:add", error, addUsage);
1563
+ }
1564
+ };
1565
+
1566
+ const projectAddUsage = [
1567
+ "/monofold:project-add <path> --parent \"Workspace Name\" --tags project,slug",
1568
+ "Parent by tags: --parent-tags vault,docs",
1569
+ "Optional: --name \"Name\" --capabilities read,writeDocs --context CONTEXT.md --routes default=.,progress=Progress",
1570
+ "Alias: /monofold_project_add ...",
1571
+ ].join("\n");
1572
+
1573
+ const projectAddCommand = async (args: string, ctx: ExtensionCommandContext) => {
1574
+ try {
1575
+ const loaded = await loadConfig(ctx.cwd);
1576
+ const { project, parent } = buildProjectFromAddArgs(args);
1577
+ await addProjectToConfig(ctx, loaded, project, parent);
1578
+ const next = await loadConfig(ctx.cwd);
1579
+ sendCommandOutput(pi, "monofold:project-add", `Added project:\n${YAML.stringify(project).trim()}\n\n${await buildManifest(next)}`, { project });
1580
+ } catch (error) {
1581
+ sendCommandError(pi, "monofold:project-add", error, projectAddUsage);
1582
+ }
1583
+ };
1584
+
1585
+ const updateUsage = [
1586
+ "/monofold:update [natural language configuration change request]",
1587
+ `Migrates ${LEGACY_CONFIG_RELATIVE_PATH} to ${CONFIG_RELATIVE_PATH}, normalizes YAML, and validates the manifest.`,
1588
+ "If a request is provided, it is handed off to the Pi agent after successful migration.",
1589
+ ].join("\n");
1590
+
1591
+ const updateCommand = async (args: string, ctx: ExtensionCommandContext) => {
1592
+ try {
1593
+ const plan = await buildConfigMigrationPlan(ctx.cwd);
1594
+ if (plan.changed && ctx.hasUI) {
1595
+ const ok = await ctx.ui.confirm("Monofold Update", `${formatMigrationPlan(plan)}\n\nApply migration?`);
1596
+ if (!ok) {
1597
+ sendCommandOutput(pi, "monofold:update", "Update cancelled", { cancelled: true });
1598
+ return;
1599
+ }
1600
+ }
1601
+
1602
+ await applyConfigMigrationPlan(plan);
1603
+ const loaded = await loadConfig(ctx.cwd);
1604
+ sendCommandOutput(pi, "monofold:update", `${formatMigrationPlan(plan)}\n\nManifest validation: OK (${loaded.workspaces.length} targets)`, {
1605
+ changed: plan.changed,
1606
+ configPath: CONFIG_RELATIVE_PATH,
1607
+ backupPath: plan.backupRelativePath,
1608
+ legacyBackupPath: plan.cleanupLegacyBackupRelativePath,
1609
+ });
1610
+
1611
+ let request = args.trim();
1612
+ if (!request && ctx.hasUI) {
1613
+ request = (await ctx.ui.input("Optional: describe workspace/project configuration changes to apply now", ""))?.trim() ?? "";
1614
+ }
1615
+ if (request) {
1616
+ pi.sendUserMessage(buildConfigurationHandoffPrompt(request), { deliverAs: "followUp" });
1617
+ }
1618
+ } catch (error) {
1619
+ sendCommandError(pi, "monofold:update", error, updateUsage);
1620
+ }
1621
+ };
1622
+
1623
+ const clearUnknownPathAllowsCommand = async (_args: string, ctx: ExtensionCommandContext) => {
1624
+ try {
1625
+ const count = await clearUnknownPathAllows(ctx.cwd);
1626
+ sendCommandOutput(
1627
+ pi,
1628
+ "monofold:clear-unknown-path-allows",
1629
+ count > 0
1630
+ ? `Cleared ${count} remembered unknown path allow${count === 1 ? "" : "s"} from ${UNKNOWN_PATH_ALLOWS_RELATIVE_PATH}`
1631
+ : `No remembered unknown path allows found at ${UNKNOWN_PATH_ALLOWS_RELATIVE_PATH}`,
1632
+ { count, path: UNKNOWN_PATH_ALLOWS_RELATIVE_PATH },
1633
+ );
1634
+ } catch (error) {
1635
+ sendCommandError(pi, "monofold:clear-unknown-path-allows", error, "/monofold:clear-unknown-path-allows");
1636
+ }
1637
+ };
1638
+
1639
+ const intentCommand = (intent: IntentCategory) => async (args: string, ctx: ExtensionCommandContext) => {
1640
+ const prepared = await prepareIntentConfiguration(ctx);
1641
+ if (!prepared) {
1642
+ pi.sendUserMessage("/monofold:init", { deliverAs: "followUp" });
1643
+ return;
1644
+ }
1645
+ pi.sendUserMessage(buildIntentHandoffPrompt(intent, args), { deliverAs: "followUp" });
1646
+ sendCommandOutput(pi, `monofold:${intent.toLowerCase()}`, `Queued ${intent} handoff.`, { intent, request: args.trim() });
1647
+ };
1648
+
1649
+ const guideCommand = async (args: string, _ctx: ExtensionCommandContext) => {
1650
+ pi.sendUserMessage(buildGuideHandoffPrompt(args), { deliverAs: "followUp" });
1651
+ sendCommandOutput(pi, "monofold:guide", "Queued Monofold guide.", { request: args.trim() });
1652
+ };
1653
+
1654
+ const focusCommand = async (_args: string, ctx: ExtensionCommandContext) => {
1655
+ try {
1656
+ const loaded = await loadConfig(ctx.cwd);
1657
+ const focusPresets = loaded.raw.focusPresets ?? [];
1658
+ ensureActiveFocusInitialized(focusPresets);
1659
+ updateFocusStatus(ctx, loaded);
1660
+
1661
+ if (focusPresets.length === 0) {
1662
+ notifyNoFocusPresets(ctx, pi);
1663
+ return;
1664
+ }
1665
+
1666
+ if (!ctx.hasUI) {
1667
+ const message = "/monofold:focus requires the Pi TUI to choose a focus preset.";
1668
+ ctx.ui.notify(message, "warning");
1669
+ sendCommandOutput(pi, "monofold:focus", message, { requiresTui: true });
1670
+ return;
1671
+ }
1672
+
1673
+ if (focusPresets.length === 1) {
1674
+ const preset = focusPresets[0]!;
1675
+ setActiveFocusPresetId(preset.id, focusPresets);
1676
+ notifyFocusApplied(ctx, loaded, { preset, index: 0, total: 1 }, "command");
1677
+ return;
1678
+ }
1679
+
1680
+ const choice = await ctx.ui.select("Select Monofold Focus", focusPresets.map((preset) => preset.label));
1681
+ if (!choice) {
1682
+ ctx.ui.notify("Focus selection cancelled", "info");
1683
+ return;
1684
+ }
1685
+ const position = setActiveFocusPresetByLabel(choice, focusPresets);
1686
+ notifyFocusApplied(ctx, loaded, position, "command");
1687
+ } catch (error) {
1688
+ sendCommandError(pi, "monofold:focus", error, "/monofold:focus");
1689
+ }
1690
+ };
1691
+
1692
+ const cycleFocusForward = async (ctx: ExtensionContext) => {
1693
+ try {
1694
+ const loaded = await loadConfig(ctx.cwd);
1695
+ const focusPresets = loaded.raw.focusPresets ?? [];
1696
+ if (focusPresets.length === 0) {
1697
+ notifyNoFocusPresets(ctx);
1698
+ return;
1699
+ }
1700
+ const result = cycleActiveFocusPresetForward(focusPresets);
1701
+ if (!result) return;
1702
+ updateFocusStatus(ctx, loaded);
1703
+ warnZeroTargetMatchesForPreset(result.preset, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
1704
+ if (focusPresets.length === 1) {
1705
+ ctx.ui.notify(`Active Focus unchanged: ${result.preset.label} (1/1)`, "info");
1706
+ return;
1707
+ }
1708
+ ctx.ui.notify(`Active Focus: ${result.preset.label} (${result.index + 1}/${result.total})`, "info");
1709
+ } catch (error) {
1710
+ const message = error instanceof Error ? error.message : String(error);
1711
+ ctx.ui.notify(`Monofold focus cycle failed: ${message}`, "error");
1712
+ }
1713
+ };
1714
+
1715
+ pi.registerCommand("monofold:explore", { description: "Explore configured workspaces via natural-language handoff", handler: intentCommand("Explore") });
1716
+ pi.registerCommand("monofold:write", { description: "Create routed Markdown via natural-language handoff", handler: intentCommand("Write") });
1717
+ pi.registerCommand("monofold:config", { description: "Change Workspace configuration via natural-language handoff", handler: intentCommand("Config") });
1718
+ pi.registerCommand("monofold:git", { description: "Run workspace git workflows via natural-language handoff", handler: intentCommand("Git") });
1719
+ pi.registerCommand("monofold:focus", { description: "Select the active Monofold focus preset from a TUI list", handler: focusCommand });
1720
+ pi.registerCommand("monofold:guide", { description: "Conversational guide for Pi Monofold workflows", handler: guideCommand });
1721
+ pi.registerCommand("monofold:update", { description: `Migrate and validate ${CONFIG_RELATIVE_PATH}`, handler: updateCommand });
1722
+ pi.registerCommand("monofold:clear-unknown-path-allows", {
1723
+ description: `Clear remembered unknown-path allows stored in ${UNKNOWN_PATH_ALLOWS_RELATIVE_PATH}`,
1724
+ handler: clearUnknownPathAllowsCommand,
1725
+ });
1726
+
1727
+ pi.registerShortcut(FOCUS_CYCLE_SHORTCUT, {
1728
+ description: `${FOCUS_CYCLE_ACTION_ID}: cycle active Monofold focus preset forward`,
1729
+ handler: cycleFocusForward,
1730
+ });
1731
+
1732
+
1733
+ pi.registerCommand("monofold:list", { description: "List configured Pi Monofold workspaces (legacy)", handler: listCommand });
1734
+
1735
+ pi.registerCommand("monofold:tree", {
1736
+ description: "Show a bounded tree for a configured workspace (legacy)",
1737
+ handler: (args, ctx) => readCommand(`tree ${args}`, ctx),
1738
+ });
1739
+
1740
+ pi.registerCommand("monofold:read", { description: "Read, tree, or search a configured workspace with safe defaults (legacy)", handler: readCommand });
1741
+
1742
+ pi.registerCommand("monofold:search", {
1743
+ description: "Search a configured workspace with safe defaults (legacy)",
1744
+ handler: (args, ctx) => readCommand(`search ${args}`, ctx),
1745
+ });
1746
+
1747
+ pi.registerCommand("monofold:add", { description: `Add a workspace to ${CONFIG_RELATIVE_PATH} (legacy)`, handler: addCommand });
1748
+
1749
+ pi.registerCommand("monofold:project-add", {
1750
+ description: "Add a project workspace under a parent workspace (legacy)",
1751
+ handler: projectAddCommand,
1752
+ });
1753
+
1754
+
1755
+ const initCommand = async (_args: string, ctx: ExtensionCommandContext) => {
1756
+ if (!ctx.hasUI) {
1757
+ ctx.ui.notify("monofold:init requires interactive UI", "error");
1758
+ return;
1759
+ }
1760
+ const configFile = await resolveConfigFile(ctx.cwd, true);
1761
+ const configPath = configFile.configPath;
1762
+ const exists = configFile.kind !== "missing";
1763
+ const addKind = exists ? await ctx.ui.select("What do you want to add?", ["Workspace", "Project Workspace"]) : "Workspace";
1764
+ if (!addKind) return;
1765
+ if (addKind === "Project Workspace") {
1766
+ const loaded = await loadConfig(ctx.cwd);
1767
+ const parentLabels = loaded.workspaces.filter((workspace) => workspace.kind === "workspace").map(formatWorkspaceLabel);
1768
+ const parentLabel = await ctx.ui.select("Parent workspace", parentLabels);
1769
+ if (!parentLabel) return;
1770
+ const parent = loaded.workspaces.filter((workspace) => workspace.kind === "workspace")[parentLabels.indexOf(parentLabel)];
1771
+ const projectPath = await ctx.ui.input("Project path relative to parent workspace", "4_Project/Example");
1772
+ if (!projectPath) return;
1773
+ const name = await ctx.ui.input("Optional project name", "");
1774
+ const tagsInput = await ctx.ui.input("Project tags comma-separated", "project,example");
1775
+ if (!tagsInput) return;
1776
+ const capsInput = await ctx.ui.input("Optional capabilities override comma-separated", "");
1777
+ const routesInput = await ctx.ui.input("Optional routes (default path or key=path list)", "");
1778
+ const project: ProjectConfig = {
1779
+ ...(name?.trim() ? { name: name.trim() } : {}),
1780
+ path: projectPath.trim(),
1781
+ tags: tagsInput.split(",").map((s) => s.trim()).filter(Boolean),
1782
+ ...(capsInput?.trim() ? { capabilities: asCapabilityArray(capsInput.split(",").map((s) => s.trim()).filter(Boolean)) } : {}),
1783
+ ...(routesInput?.trim() ? { routes: routesInput.includes("=") ? Object.fromEntries(routesInput.split(",").map((entry) => entry.split("=").map((part) => part.trim()))) as ProjectConfig["routes"] : { default: routesInput.trim() } } : {}),
1784
+ };
1785
+ await addProjectToConfig(ctx, loaded, project, { targetId: parent.targetId });
1786
+ ctx.ui.notify(`Updated ${normalizeSlashes(path.relative(ctx.cwd, loaded.configPath))}`, "info");
1787
+ return;
1788
+ }
1789
+ if (exists) {
1790
+ const ok = await ctx.ui.confirm("Existing config", `${configFile.relativePath} exists. Append a new workspace?`);
1791
+ if (!ok) return;
1792
+ }
1793
+ const workspacePath = await ctx.ui.input("Workspace path", "../business");
1794
+ if (!workspacePath) return;
1795
+ const name = await ctx.ui.input("Optional workspace name", "");
1796
+ const tagsInput = await ctx.ui.input("Tags comma-separated", "business,markdown");
1797
+ if (!tagsInput) return;
1798
+ const capsInput = await ctx.ui.input("Capabilities comma-separated", "read,writeDocs,git");
1799
+ if (!capsInput) return;
1800
+ const capabilities = capsInput.split(",").map((s) => s.trim()).filter(Boolean);
1801
+ const routePath = capabilities.includes("writeDocs") ? await ctx.ui.input("Default document route", "Notes") : undefined;
1802
+ const workspaceBlock: WorkspaceConfig = {
1803
+ ...(name?.trim() ? { name: name.trim() } : {}),
1804
+ path: workspacePath.trim(),
1805
+ tags: tagsInput.split(",").map((s) => s.trim()).filter(Boolean),
1806
+ capabilities: capabilities as CapabilityTag[],
1807
+ ...(routePath ? { routes: { default: routePath.trim() } } : {}),
1808
+ };
1809
+ const current = exists ? await readFile(configPath, "utf8") : "version: 1\n\nworkspaces:\n";
1810
+ const addition = YAML.stringify([workspaceBlock])
1811
+ .split("\n")
1812
+ .filter(Boolean)
1813
+ .map((line) => ` ${line}`)
1814
+ .join("\n");
1815
+ const next = exists ? `${current.trimEnd()}\n${addition}\n` : `version: 1\n\nworkspaces:\n${addition}\n`;
1816
+ await mkdir(path.dirname(configPath), { recursive: true });
1817
+ await writeFile(configPath, next, "utf8");
1818
+ ctx.ui.notify(`Updated ${configFile.relativePath}`, "info");
1819
+ };
1820
+
1821
+ pi.registerCommand("monofold:init", {
1822
+ description: `Create or update ${CONFIG_RELATIVE_PATH} with an interactive wizard`,
1823
+ handler: initCommand,
1824
+ });
1825
+
1826
+ pi.registerTool({
1827
+ name: "monofold_init",
1828
+ label: "Workspace Init",
1829
+ description: `Queue the interactive /monofold:init command to create or update ${CONFIG_RELATIVE_PATH}.`,
1830
+ parameters: Type.Object({}),
1831
+ async execute() {
1832
+ pi.sendUserMessage("/monofold:init", { deliverAs: "followUp" });
1833
+ return { content: [{ type: "text", text: "Queued /monofold:init" }], details: {} };
1834
+ },
1835
+ });
1836
+
1837
+ pi.on("tool_call", async (event, ctx) => {
1838
+ let loaded: LoadedConfig;
1839
+ try {
1840
+ loaded = await loadConfig(ctx.cwd);
1841
+ } catch {
1842
+ return undefined;
1843
+ }
1844
+
1845
+ if ((event.toolName === "read" || event.toolName === "write" || event.toolName === "edit") && typeof event.input.path === "string") {
1846
+ return guardPathOperation(ctx, loaded, event.input.path, event.toolName as "read" | "write" | "edit");
1847
+ }
1848
+
1849
+ if ((event.toolName === "grep" || event.toolName === "find") && typeof event.input.path === "string") {
1850
+ return guardPathOperation(ctx, loaded, event.input.path, "read");
1851
+ }
1852
+
1853
+ if (event.toolName === "bash" && typeof event.input.command === "string") {
1854
+ const command = event.input.command;
1855
+ if (bashContainsGitCommitOrPush(command)) {
1856
+ return { block: true, reason: "Use monofold_git for git commit/push so confirmation flow is enforced." };
1857
+ }
1858
+ const danger = bashLooksDangerous(command);
1859
+ if (danger) {
1860
+ const ok = await confirm(ctx, "Dangerous command", `Command contains ${danger}:\n${command}\nAllow?`);
1861
+ if (!ok) return { block: true, reason: `Dangerous command requires confirmation: ${danger}` };
1862
+ }
1863
+ const cwd = inferBashCwd(ctx, command);
1864
+ const workspace = findWorkspaceForPath(loaded, cwd);
1865
+ if (!workspace) return maybeBlockUnknown(ctx, loaded, cwd, "bash");
1866
+ if (!workspace.capabilities.includes("runCommands")) {
1867
+ return { block: true, reason: `Workspace lacks runCommands capability: ${formatWorkspaceLabel(workspace)}` };
1868
+ }
1869
+ }
1870
+
1871
+ return undefined;
1872
+ });
1873
+ }
1874
+