pi-monofold 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Pi Monofold
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/pi-monofold?color=cb3837&logo=npm)](https://www.npmjs.com/package/pi-monofold)
4
+ [![Publish to npm](https://github.com/eiei114/pi-monofold/actions/workflows/publish.yml/badge.svg)](https://github.com/eiei114/pi-monofold/actions/workflows/publish.yml)
5
+ [![Auto Release](https://github.com/eiei114/pi-monofold/actions/workflows/auto-release.yml/badge.svg)](https://github.com/eiei114/pi-monofold/actions/workflows/auto-release.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
7
+ [![Pi Package](https://img.shields.io/badge/Pi-package-6f42c1)](https://github.com/eiei114/pi-monofold)
8
+
3
9
  Pi Monofold (`pi-monofold`) is a Pi Coding Agent extension that folds multiple local repositories and folders into a guarded **Virtual Monorepo** for AI agents.
4
10
 
5
11
  It keeps repositories physically separate, while giving Pi a lightweight manifest, routed writes, workspace-aware reads, guarded commands, and explicit git flows.
@@ -98,11 +104,17 @@ defaults:
98
104
  created: "{{date}}"
99
105
  source: "pi-monofold"
100
106
 
107
+ focusPresets:
108
+ - id: control
109
+ label: Control workspace focus
110
+ targets:
111
+ - targetTags: [control]
112
+
101
113
  workspaces:
102
114
  - name: "Product docs"
103
115
  path: "../business"
104
116
  tags: [business, markdown, planning]
105
- capabilities: [read, writeDocs, gitCommit]
117
+ capabilities: [read, writeDocs, git]
106
118
  contextFiles: [README.md, CONTEXT.md]
107
119
  routes:
108
120
  default: "Notes"
@@ -123,44 +135,57 @@ workspaces:
123
135
  - name: "Application"
124
136
  path: "../app"
125
137
  tags: [development, app]
126
- capabilities: [read, editCode, runCommands, gitCommit, gitPush]
138
+ capabilities: [read, editCode, runCommands, git]
127
139
  contextFiles: [README.md, AGENTS.md]
128
140
  ```
129
141
 
130
- ## Tools
142
+ ## Focus presets
131
143
 
132
- - `monofold_list`: show manifest and git status summary.
133
- - `monofold_read`: read files, search text, or show a tree inside readable workspaces.
134
- - `monofold_write`: create routed Markdown outputs by `routeType`, `title`, and `body`.
135
- - `monofold_git`: run guarded workspace git `status`, `commit`, or `push`.
136
- - `monofold_init`: queue `/monofold:init`.
144
+ Optional `focusPresets` define tag-based focus targets for the control workspace. Preset `id` values must be unique. Each target uses `targetTags`, matching workspace tags the same way as other Monofold target selectors. Targets that match zero configured workspaces are allowed in config and emit a runtime warning when the active preset is applied.
145
+
146
+ Active focus (the selected preset id) lives in extension session memory only. It resets when Pi restarts. When `focusPresets` is non-empty, the first preset in YAML order becomes active at session start unless a later slice changes it.
147
+
148
+ The example above includes a generic seed preset `control`. Add matching workspace tags in your own config when you adopt it.
137
149
 
138
150
  ## Commands
139
151
 
140
- - `/monofold:list` or `/monofold_list`: show manifest and git status summary.
141
- - `/monofold:add <path> --name "Name" --tags tag1,tag2 --capabilities read,editCode,runCommands,gitCommit`: add a workspace to `.pi/monofold.yaml`.
142
- - `/monofold:project-add <path> --parent "Name" --tags project,slug`: add a Project Workspace under a parent workspace.
143
- - `/monofold:update [natural language request]`: migrate legacy config to `.pi/monofold.yaml`, normalize YAML, validate the manifest, and optionally hand a config-change request to the agent.
144
- - `/monofold:read file <path> --target #0.1`: read a file from a workspace or project target.
145
- - `/monofold:tree [path] --target #0.1 --depth 2`: show a target tree.
146
- - `/monofold:search <query> --target #0.1`: search a target.
147
- - `/monofold:write --route progress --title "Title" --body "Markdown body"`: write routed Markdown.
148
- - `/monofold:git status|commit|push --target #0.1`: run guarded target git.
152
+ Human-facing commands accept natural-language arguments and hand off interpretation to the Pi agent:
153
+
154
+ - `/monofold:explore [request]`: list, read, search, or inspect workspace trees.
155
+ - `/monofold:write [request]`: create routed Markdown outputs.
156
+ - `/monofold:config [request]`: add or change Workspaces and Project Workspaces.
157
+ - `/monofold:git [request]`: run git status, commit, push, or commit+push workflows.
158
+ - `/monofold:guide`: start an interactive guide for Explore, Write, Config, Git, init, and update flows.
159
+ - `/monofold:init`: create or update `.pi/monofold.yaml` with an interactive wizard.
160
+ - `/monofold:update [request]`: migrate/clean up legacy config and optionally hand a config-change request to the agent.
149
161
 
150
162
  Examples:
151
163
 
152
164
  ```text
153
- /monofold:add C:/Projects/app --name "Application" --tags development,app --capabilities read,editCode,runCommands,gitCommit --context README.md,AGENTS.md
154
- /monofold:add ../business --name "Product Docs" --tags business,docs --capabilities read,writeDocs,gitCommit --routes default=Notes,progress=Progress,research=Research
155
- /monofold:project-add Projects/Launch --parent "Product Docs" --tags project,launch --routes default=.,progress=Progress
156
- /monofold:update rename the Product Docs workspace to Business Notes and add tag docs
165
+ /monofold:explore show the project workspaces
166
+ /monofold:write write today's progress note for Pi Monofold
167
+ /monofold:config add 4_Project/NewApp as a Project Workspace under Obsidian Vault with tag project,newapp
168
+ /monofold:git commit and push the pi-monofold dev workspace
169
+ /monofold:guide
157
170
  ```
158
171
 
172
+ Fine-grained legacy commands such as `/monofold:list`, `/monofold:read`, `/monofold:search`, `/monofold:tree`, `/monofold:add`, `/monofold:project-add`, and underscore aliases are not part of the human command surface.
173
+
174
+ ## Agent API
175
+
176
+ Pi agents use strict `monofold_*` tools behind the natural-language command surface:
177
+
178
+ - `monofold_list`: show manifest and git status summary.
179
+ - `monofold_read`: read files, search text, or show a tree inside readable workspaces.
180
+ - `monofold_write`: create routed Markdown outputs by `routeType`, `title`, and `body`.
181
+ - `monofold_git`: run guarded workspace git `status`, `commit`, `push`, or `commitPush`.
182
+ - `monofold_init`: queue `/monofold:init`.
183
+
159
184
  Project Workspaces are listed under `workspaces[].projects`. Their `path` is relative to the parent workspace, `tags` are combined with parent tags, `capabilities` inherit unless explicitly replaced, and missing routes default to `default: "."` when the effective target has `writeDocs`.
160
185
 
161
186
  ## Updating configuration
162
187
 
163
- `.pi/monofold.yaml` is the canonical config file. Legacy `.pi/monofold.yml` is still readable, but `/monofold:update` migrates it to `.pi/monofold.yaml`, writes a timestamped backup such as `.pi/monofold.yml.bak-20260524-153012`, and removes the legacy file after a successful write. If both `.yaml` and `.yml` exist, Pi Monofold stops with a conflict error so you can choose the correct file manually.
188
+ `.pi/monofold.yaml` is the canonical config file. Legacy `.pi/monofold.yml` is still readable. Intent commands try to migrate a legacy-only config automatically, show a notice, and continue with the legacy config if migration fails. `/monofold:update` migrates or cleans up legacy config, writes timestamped backups such as `.pi/monofold.yml.bak-20260524-153012`, and removes the legacy file after a successful write. If both `.yaml` and `.yml` exist, normal intent commands prefer canonical `.yaml`; `/monofold:update` handles legacy cleanup.
164
189
 
165
190
  `/monofold:update` is a configuration migration command, not a Pi package updater. Use `pi update`, `pi update --extensions`, or `pi install ...@new-ref` for package updates.
166
191
 
@@ -178,4 +203,4 @@ When `.pi/monofold.yaml` or legacy `.pi/monofold.yml` exists, Pi Monofold guards
178
203
  - Docs write: requires `writeDocs`.
179
204
  - Code edit: requires `editCode`.
180
205
  - Bash: requires workspace cwd and `runCommands`.
181
- - Git commit/push via bash: blocked; use `monofold_git`.
206
+ - Git commit/push via bash: blocked; use `/monofold:git` or the `monofold_git` agent tool.
@@ -0,0 +1,133 @@
1
+ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
2
+
3
+ export type FocusPresetTarget = {
4
+ targetTags: string[];
5
+ };
6
+
7
+ export type FocusPreset = {
8
+ id: string;
9
+ label: string;
10
+ targets: FocusPresetTarget[];
11
+ };
12
+
13
+ export type FocusMatchableWorkspace = {
14
+ tags: string[];
15
+ };
16
+
17
+ const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets"]);
18
+ const FOCUS_PRESET_TARGET_KEYS = new Set(["targetTags"]);
19
+
20
+ /** Parses and validates focus preset configuration from YAML/JSON input. */
21
+ export function parseFocusPresets(value: unknown, label = "focusPresets"): FocusPreset[] {
22
+ if (value === undefined) return [];
23
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
24
+ const presets: FocusPreset[] = [];
25
+ const seenIds = new Set<string>();
26
+ for (let index = 0; index < value.length; index += 1) {
27
+ const item = value[index];
28
+ const itemLabel = `${label}[${index}]`;
29
+ if (!isRecord(item)) throw new Error(`${itemLabel} must be an object`);
30
+ assertKnownKeys(itemLabel, item, FOCUS_PRESET_KEYS);
31
+ if (typeof item.id !== "string" || item.id.trim() === "") {
32
+ throw new Error(`${itemLabel}.id must be a non-empty string`);
33
+ }
34
+ if (typeof item.label !== "string" || item.label.trim() === "") {
35
+ throw new Error(`${itemLabel}.label must be a non-empty string`);
36
+ }
37
+ if (!Array.isArray(item.targets) || item.targets.length === 0) {
38
+ throw new Error(`${itemLabel}.targets must be a non-empty array`);
39
+ }
40
+ const targets: FocusPresetTarget[] = [];
41
+ for (let targetIndex = 0; targetIndex < item.targets.length; targetIndex += 1) {
42
+ const target = item.targets[targetIndex];
43
+ const targetLabel = `${itemLabel}.targets[${targetIndex}]`;
44
+ if (!isRecord(target)) throw new Error(`${targetLabel} must be an object`);
45
+ assertKnownKeys(targetLabel, target, FOCUS_PRESET_TARGET_KEYS);
46
+ const targetTags = uniqueStrings(asStringArray(`${targetLabel}.targetTags`, target.targetTags));
47
+ if (targetTags.length === 0) {
48
+ throw new Error(`${targetLabel}.targetTags must contain at least one non-empty string`);
49
+ }
50
+ targets.push({ targetTags });
51
+ }
52
+ if (seenIds.has(item.id)) throw new Error(`${label} has duplicate preset id: ${item.id}`);
53
+ seenIds.add(item.id);
54
+ presets.push({ id: item.id, label: item.label, targets });
55
+ }
56
+ return presets;
57
+ }
58
+
59
+ /** Returns the first preset id, or null when no focus preset is available. */
60
+ export function pickDefaultFocusPresetId(focusPresets: FocusPreset[] | undefined): string | null {
61
+ if (!focusPresets || focusPresets.length === 0) return null;
62
+ return focusPresets[0]?.id ?? null;
63
+ }
64
+
65
+ /** Finds a focus preset by stable id. */
66
+ export function findFocusPresetById(focusPresets: FocusPreset[] | undefined, id: string): FocusPreset | undefined {
67
+ return focusPresets?.find((preset) => preset.id === id);
68
+ }
69
+
70
+ /** Returns true when every requested focus tag is present on a workspace. */
71
+ export function matchesFocusTarget(workspace: FocusMatchableWorkspace, targetTags: string[]): boolean {
72
+ if (targetTags.length === 0) return false;
73
+ return targetTags.every((tag) => workspace.tags.includes(tag));
74
+ }
75
+
76
+ /** Counts workspaces matched by a focus target's tag set. */
77
+ export function countMatchingWorkspaces(
78
+ workspaces: FocusMatchableWorkspace[],
79
+ targetTags: string[],
80
+ ): number {
81
+ return workspaces.filter((workspace) => matchesFocusTarget(workspace, targetTags)).length;
82
+ }
83
+
84
+ /** Emits warnings for a preset's targets that do not match any configured workspace. */
85
+ export function warnZeroTargetMatchesForPreset(
86
+ preset: FocusPreset,
87
+ workspaces: FocusMatchableWorkspace[],
88
+ warn: (message: string) => void,
89
+ ): void {
90
+ for (const target of preset.targets) {
91
+ if (countMatchingWorkspaces(workspaces, target.targetTags) === 0) {
92
+ warn(
93
+ `Focus preset "${preset.id}" target [${target.targetTags.join(", ")}] matches no configured workspace`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+
99
+ let activeFocusPresetId: string | null = null;
100
+ let activeFocusInitialized = false;
101
+
102
+ /** Initializes the active focus preset once per process/session. */
103
+ export function ensureActiveFocusInitialized(focusPresets: FocusPreset[] | undefined): void {
104
+ if (activeFocusInitialized) return;
105
+ activeFocusInitialized = true;
106
+ activeFocusPresetId = pickDefaultFocusPresetId(focusPresets);
107
+ }
108
+
109
+ /** Returns the current active focus preset id, if any. */
110
+ export function getActiveFocusPresetId(): string | null {
111
+ return activeFocusPresetId;
112
+ }
113
+
114
+ /** Sets the active focus preset after validating that the id exists. */
115
+ export function setActiveFocusPresetId(id: string, focusPresets: FocusPreset[] | undefined): void {
116
+ if (!findFocusPresetById(focusPresets, id)) {
117
+ throw new Error(`Unknown focus preset id: ${id}`);
118
+ }
119
+ activeFocusPresetId = id;
120
+ activeFocusInitialized = true;
121
+ }
122
+
123
+ /** Clears the active focus preset for the current process/session. */
124
+ export function clearActiveFocusPresetId(): void {
125
+ activeFocusPresetId = null;
126
+ activeFocusInitialized = true;
127
+ }
128
+
129
+ /** Resets in-memory session state (for tests and process restart). */
130
+ export function resetActiveFocusSessionState(): void {
131
+ activeFocusPresetId = null;
132
+ activeFocusInitialized = false;
133
+ }
package/index.ts CHANGED
@@ -4,8 +4,19 @@ import { execFile } from "node:child_process";
4
4
  import { access, copyFile, mkdir, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import YAML from "yaml";
7
-
8
- type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "gitCommit" | "gitPush";
7
+ import {
8
+ type FocusPreset,
9
+ ensureActiveFocusInitialized,
10
+ findFocusPresetById,
11
+ getActiveFocusPresetId,
12
+ parseFocusPresets,
13
+ warnZeroTargetMatchesForPreset,
14
+ } from "./focus-preset.js";
15
+ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
16
+
17
+ type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "git";
18
+ type LegacyCapabilityTag = CapabilityTag | "gitCommit" | "gitPush";
19
+ type IntentCategory = "Explore" | "Write" | "Config" | "Git";
9
20
  type RouteType = "default" | "prd" | "design" | "progress" | "issue" | "research" | "decision";
10
21
 
11
22
  type RouteConfig = {
@@ -40,6 +51,7 @@ type MultiWorkspaceConfig = {
40
51
  filenameTemplate?: string;
41
52
  metadata?: Record<string, unknown>;
42
53
  };
54
+ focusPresets?: FocusPreset[];
43
55
  workspaces: WorkspaceConfig[];
44
56
  };
45
57
 
@@ -93,6 +105,9 @@ type ConfigMigrationPlan = {
93
105
  targetRelativePath: string;
94
106
  backupPath?: string;
95
107
  backupRelativePath?: string;
108
+ cleanupLegacyPath?: string;
109
+ cleanupLegacyBackupPath?: string;
110
+ cleanupLegacyBackupRelativePath?: string;
96
111
  normalizedText: string;
97
112
  loaded: LoadedConfig;
98
113
  actions: string[];
@@ -101,7 +116,8 @@ type ConfigMigrationPlan = {
101
116
  const CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yaml");
102
117
  const LEGACY_CONFIG_RELATIVE_PATH = path.join(".pi", "monofold.yml");
103
118
  const ROUTE_TYPES: RouteType[] = ["default", "prd", "design", "progress", "issue", "research", "decision"];
104
- const CAPABILITIES: CapabilityTag[] = ["read", "writeDocs", "editCode", "runCommands", "gitCommit", "gitPush"];
119
+ const CAPABILITIES: CapabilityTag[] = ["read", "writeDocs", "editCode", "runCommands", "git"];
120
+ const LEGACY_CAPABILITIES: LegacyCapabilityTag[] = [...CAPABILITIES, "gitCommit", "gitPush"];
105
121
  const DOC_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]);
106
122
  const CODE_EXTENSIONS = new Set([
107
123
  ".ts",
@@ -129,26 +145,16 @@ const CODE_EXTENSIONS = new Set([
129
145
  ".scss",
130
146
  ".html",
131
147
  ]);
132
- const ROOT_KEYS = new Set(["version", "defaults", "workspaces"]);
148
+ const ROOT_KEYS = new Set(["version", "defaults", "focusPresets", "workspaces"]);
133
149
  const DEFAULT_KEYS = new Set(["contextFiles", "filenameTemplate", "metadata"]);
134
150
  const WORKSPACE_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes", "projects"]);
135
151
  const PROJECT_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes"]);
136
152
  const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
137
153
 
138
- function isRecord(value: unknown): value is Record<string, unknown> {
139
- return typeof value === "object" && value !== null && !Array.isArray(value);
140
- }
141
-
142
154
  function normalizeSlashes(value: string): string {
143
155
  return value.replace(/\\/g, "/");
144
156
  }
145
157
 
146
- function assertKnownKeys(label: string, value: Record<string, unknown>, allowed: Set<string>): void {
147
- for (const key of Object.keys(value)) {
148
- if (!allowed.has(key)) throw new Error(`${label} has unknown key: ${key}`);
149
- }
150
- }
151
-
152
158
  function isInside(parent: string, child: string): boolean {
153
159
  const relative = path.relative(parent, child);
154
160
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -168,26 +174,16 @@ function assertProjectPath(label: string, value: string): void {
168
174
  }
169
175
  }
170
176
 
171
- function uniqueStrings(items: string[]): string[] {
172
- return [...new Set(items.filter(Boolean))];
173
- }
174
-
175
- function asStringArray(label: string, value: unknown, required = true): string[] {
176
- if (value === undefined && !required) return [];
177
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
178
- throw new Error(`${label} must be an array of strings`);
179
- }
180
- return value;
181
- }
182
-
183
177
  function asCapabilityArray(value: unknown): CapabilityTag[] {
184
178
  const items = asStringArray("capabilities", value);
179
+ const normalized: CapabilityTag[] = [];
185
180
  for (const item of items) {
186
- if (!CAPABILITIES.includes(item as CapabilityTag)) {
181
+ if (!LEGACY_CAPABILITIES.includes(item as LegacyCapabilityTag)) {
187
182
  throw new Error(`Unknown capability: ${item}`);
188
183
  }
184
+ normalized.push(item === "gitCommit" || item === "gitPush" ? "git" : (item as CapabilityTag));
189
185
  }
190
- return items as CapabilityTag[];
186
+ return uniqueStrings(normalized) as CapabilityTag[];
191
187
  }
192
188
 
193
189
  function asOptionalCapabilityArray(value: unknown): CapabilityTag[] | undefined {
@@ -238,11 +234,16 @@ async function pathExists(targetPath: string): Promise<boolean> {
238
234
  }
239
235
  }
240
236
 
241
- async function resolveConfigFile(cwd: string, allowMissing = false): Promise<{ configPath: string; relativePath: string; kind: "canonical" | "legacy" | "missing" }> {
237
+ async function resolveConfigFile(
238
+ cwd: string,
239
+ allowMissing = false,
240
+ options: { preferCanonicalOnConflict?: boolean } = {},
241
+ ): Promise<{ configPath: string; relativePath: string; kind: "canonical" | "legacy" | "missing" }> {
242
242
  const canonicalPath = path.join(cwd, CONFIG_RELATIVE_PATH);
243
243
  const legacyPath = path.join(cwd, LEGACY_CONFIG_RELATIVE_PATH);
244
244
  const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
245
245
  if (hasCanonical && hasLegacy) {
246
+ if (options.preferCanonicalOnConflict) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "canonical" };
246
247
  throw new Error(`Configuration file conflict: both ${CONFIG_RELATIVE_PATH} and ${LEGACY_CONFIG_RELATIVE_PATH} exist. Remove one before continuing.`);
247
248
  }
248
249
  if (hasCanonical) return { configPath: canonicalPath, relativePath: CONFIG_RELATIVE_PATH, kind: "canonical" };
@@ -282,7 +283,7 @@ function runCommand(
282
283
  }
283
284
 
284
285
  async function loadConfig(cwd: string): Promise<LoadedConfig> {
285
- const { configPath } = await resolveConfigFile(cwd);
286
+ const { configPath } = await resolveConfigFile(cwd, false, { preferCanonicalOnConflict: true });
286
287
  const text = await readFile(configPath, "utf8");
287
288
  const parsed = YAML.parse(text, { uniqueKeys: true }) as unknown;
288
289
  return validateConfigObject(cwd, configPath, parsed);
@@ -301,6 +302,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
301
302
  const defaultContextFiles = defaults ? asStringArray("defaults.contextFiles", defaults.contextFiles, false) : [];
302
303
  const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
303
304
  const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
305
+ const focusPresets = parseFocusPresets(parsed.focusPresets, "focusPresets");
304
306
 
305
307
  const workspaces: ResolvedWorkspace[] = [];
306
308
  for (let index = 0; index < parsed.workspaces.length; index += 1) {
@@ -426,6 +428,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
426
428
  filenameTemplate: defaultFilenameTemplate,
427
429
  metadata: defaultMetadata,
428
430
  },
431
+ focusPresets: focusPresets.length > 0 ? focusPresets : undefined,
429
432
  workspaces: workspaces.filter((workspace) => workspace.kind === "workspace"),
430
433
  },
431
434
  workspaces,
@@ -436,6 +439,26 @@ function timestampSuffix(now = new Date()): string {
436
439
  return now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z").replace("T", "-").replace(/Z$/, "");
437
440
  }
438
441
 
442
+ function normalizeCapabilityValues(value: unknown): string[] | undefined {
443
+ if (value === undefined) return undefined;
444
+ return asCapabilityArray(value);
445
+ }
446
+
447
+ function normalizeConfigCapabilities(config: Record<string, unknown>): void {
448
+ const workspaces = Array.isArray(config.workspaces) ? (config.workspaces as unknown[]) : [];
449
+ for (const workspace of workspaces) {
450
+ if (!isRecord(workspace)) continue;
451
+ const capabilities = normalizeCapabilityValues(workspace.capabilities);
452
+ if (capabilities) workspace.capabilities = capabilities;
453
+ const projects = Array.isArray(workspace.projects) ? (workspace.projects as unknown[]) : [];
454
+ for (const project of projects) {
455
+ if (!isRecord(project)) continue;
456
+ const projectCapabilities = normalizeCapabilityValues(project.capabilities);
457
+ if (projectCapabilities) project.capabilities = projectCapabilities;
458
+ }
459
+ }
460
+ }
461
+
439
462
  function normalizeConfigForMigration(parsed: unknown): Record<string, unknown> {
440
463
  if (!isRecord(parsed)) throw new Error("monofold config must be a YAML object");
441
464
  assertKnownKeys("monofold config", parsed, ROOT_KEYS);
@@ -443,12 +466,17 @@ function normalizeConfigForMigration(parsed: unknown): Record<string, unknown> {
443
466
  if (version !== 1) throw new Error("monofold config requires version: 1");
444
467
  const normalized: Record<string, unknown> = { version: 1 };
445
468
  if (parsed.defaults !== undefined) normalized.defaults = parsed.defaults;
469
+ if (parsed.focusPresets !== undefined) normalized.focusPresets = parsed.focusPresets;
446
470
  normalized.workspaces = parsed.workspaces;
471
+ normalizeConfigCapabilities(normalized);
447
472
  return normalized;
448
473
  }
449
474
 
450
475
  async function buildConfigMigrationPlan(cwd: string): Promise<ConfigMigrationPlan> {
451
- const source = await resolveConfigFile(cwd);
476
+ const canonicalPath = path.join(cwd, CONFIG_RELATIVE_PATH);
477
+ const legacyPath = path.join(cwd, LEGACY_CONFIG_RELATIVE_PATH);
478
+ const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
479
+ const source = await resolveConfigFile(cwd, false, { preferCanonicalOnConflict: true });
452
480
  if (source.kind === "missing") throw new Error(`No Pi Monofold configuration found. Expected ${CONFIG_RELATIVE_PATH} or legacy ${LEGACY_CONFIG_RELATIVE_PATH}.`);
453
481
 
454
482
  const originalText = await readFile(source.configPath, "utf8");
@@ -457,13 +485,16 @@ async function buildConfigMigrationPlan(cwd: string): Promise<ConfigMigrationPla
457
485
  const targetPath = path.join(cwd, CONFIG_RELATIVE_PATH);
458
486
  const normalizedText = YAML.stringify(normalized).trimEnd() + "\n";
459
487
  const loaded = await validateConfigObject(cwd, targetPath, normalized);
460
- const changed = source.kind !== "canonical" || originalText !== normalizedText;
488
+ const cleanupLegacy = hasCanonical && hasLegacy;
489
+ const changed = source.kind !== "canonical" || originalText !== normalizedText || cleanupLegacy;
461
490
  const actions: string[] = [];
462
491
  if (source.kind === "legacy") actions.push(`Move legacy config ${LEGACY_CONFIG_RELATIVE_PATH} to canonical ${CONFIG_RELATIVE_PATH}`);
463
492
  if (originalText !== normalizedText) actions.push("Normalize YAML and ensure version: 1 is explicit");
464
- if (source.kind === "legacy") actions.push(`Remove legacy config ${LEGACY_CONFIG_RELATIVE_PATH} after writing ${CONFIG_RELATIVE_PATH}`);
493
+ if (source.kind === "legacy" || cleanupLegacy) actions.push(`Remove legacy config ${LEGACY_CONFIG_RELATIVE_PATH} after writing ${CONFIG_RELATIVE_PATH}`);
465
494
  if (!changed) actions.push(`Already current: ${CONFIG_RELATIVE_PATH} (version 1)`);
466
- const backupPath = changed ? `${source.configPath}.bak-${timestampSuffix()}` : undefined;
495
+ const suffix = timestampSuffix();
496
+ const backupPath = changed && (source.kind === "legacy" || originalText !== normalizedText) ? `${source.configPath}.bak-${suffix}` : undefined;
497
+ const cleanupLegacyBackupPath = cleanupLegacy ? `${legacyPath}.bak-${suffix}` : undefined;
467
498
  return {
468
499
  changed,
469
500
  sourcePath: source.configPath,
@@ -473,6 +504,9 @@ async function buildConfigMigrationPlan(cwd: string): Promise<ConfigMigrationPla
473
504
  targetRelativePath: CONFIG_RELATIVE_PATH,
474
505
  backupPath,
475
506
  backupRelativePath: backupPath ? normalizeSlashes(path.relative(cwd, backupPath)) : undefined,
507
+ cleanupLegacyPath: cleanupLegacy ? legacyPath : undefined,
508
+ cleanupLegacyBackupPath,
509
+ cleanupLegacyBackupRelativePath: cleanupLegacyBackupPath ? normalizeSlashes(path.relative(cwd, cleanupLegacyBackupPath)) : undefined,
476
510
  normalizedText,
477
511
  loaded,
478
512
  actions,
@@ -481,11 +515,35 @@ async function buildConfigMigrationPlan(cwd: string): Promise<ConfigMigrationPla
481
515
 
482
516
  async function applyConfigMigrationPlan(plan: ConfigMigrationPlan): Promise<void> {
483
517
  if (!plan.changed) return;
484
- if (!plan.backupPath) throw new Error("Migration backup path is required for changed configuration");
485
518
  await mkdir(path.dirname(plan.targetPath), { recursive: true });
486
- await copyFile(plan.sourcePath, plan.backupPath);
519
+ if (plan.backupPath) await copyFile(plan.sourcePath, plan.backupPath);
520
+ if (plan.cleanupLegacyPath && plan.cleanupLegacyBackupPath) await copyFile(plan.cleanupLegacyPath, plan.cleanupLegacyBackupPath);
487
521
  await writeFile(plan.targetPath, plan.normalizedText, "utf8");
488
522
  if (plan.sourceKind === "legacy") await unlink(plan.sourcePath);
523
+ if (plan.cleanupLegacyPath) await unlink(plan.cleanupLegacyPath);
524
+ }
525
+
526
+ async function prepareIntentConfiguration(ctx: ExtensionCommandContext): Promise<boolean> {
527
+ const canonicalPath = path.join(ctx.cwd, CONFIG_RELATIVE_PATH);
528
+ const legacyPath = path.join(ctx.cwd, LEGACY_CONFIG_RELATIVE_PATH);
529
+ const [hasCanonical, hasLegacy] = await Promise.all([pathExists(canonicalPath), pathExists(legacyPath)]);
530
+ if (!hasCanonical && !hasLegacy) {
531
+ ctx.ui.notify(`No ${CONFIG_RELATIVE_PATH} found. Queueing /monofold:init.`, "info");
532
+ return false;
533
+ }
534
+ if (!hasCanonical && hasLegacy) {
535
+ try {
536
+ const plan = await buildConfigMigrationPlan(ctx.cwd);
537
+ await applyConfigMigrationPlan(plan);
538
+ if (plan.changed) {
539
+ ctx.ui.notify(`Migrated ${LEGACY_CONFIG_RELATIVE_PATH} to ${CONFIG_RELATIVE_PATH}${plan.backupRelativePath ? ` (backup: ${plan.backupRelativePath})` : ""}.`, "info");
540
+ }
541
+ } catch (error) {
542
+ const message = error instanceof Error ? error.message : String(error);
543
+ ctx.ui.notify(`Legacy migration failed; continuing with ${LEGACY_CONFIG_RELATIVE_PATH}: ${message}`, "error");
544
+ }
545
+ }
546
+ return true;
489
547
  }
490
548
 
491
549
  function formatMigrationPlan(plan: ConfigMigrationPlan): string {
@@ -493,7 +551,8 @@ function formatMigrationPlan(plan: ConfigMigrationPlan): string {
493
551
  return [
494
552
  `Source: ${plan.sourceRelativePath}`,
495
553
  `Target: ${plan.targetRelativePath}`,
496
- `Backup: ${plan.backupRelativePath}`,
554
+ `Backup: ${plan.backupRelativePath ?? plan.cleanupLegacyBackupRelativePath ?? "none"}`,
555
+ ...(plan.backupRelativePath && plan.cleanupLegacyBackupRelativePath ? [`Legacy backup: ${plan.cleanupLegacyBackupRelativePath}`] : []),
497
556
  "",
498
557
  "Actions:",
499
558
  ...plan.actions.map((action) => `- ${action}`),
@@ -503,13 +562,62 @@ function formatMigrationPlan(plan: ConfigMigrationPlan): string {
503
562
  function buildConfigurationHandoffPrompt(request: string): string {
504
563
  return [
505
564
  "/monofold:update completed. Apply this requested Pi Monofold configuration change to `.pi/monofold.yaml`.",
506
- "Edit the canonical config directly when needed, preserve valid YAML, then run `/monofold:list` as manifest validation.",
565
+ "Edit the canonical config directly when needed, preserve valid YAML, then run `monofold_list` as manifest validation.",
507
566
  "",
508
567
  "User request:",
509
568
  request.trim(),
510
569
  ].join("\n");
511
570
  }
512
571
 
572
+ function buildIntentHandoffPrompt(intent: IntentCategory, request: string): string {
573
+ const trimmed = request.trim();
574
+ const emptyInstruction =
575
+ intent === "Explore"
576
+ ? "Ask what the user wants to list, read, search, or inspect."
577
+ : intent === "Write"
578
+ ? "Ask what document to create and where it should be saved."
579
+ : intent === "Config"
580
+ ? "Ask what Workspace or Project Workspace configuration change is needed."
581
+ : "Ask whether the user wants git status, commit, push, or commit+push.";
582
+ return [
583
+ `Pi Monofold ${intent} request. Interpret the user's natural-language input and continue as the agent.`,
584
+ "",
585
+ "Rules:",
586
+ "- Use strict `monofold_*` tools as the execution API; do not ask the user to write JSON or YAML unless needed.",
587
+ "- Select a Workspace Target automatically only when the manifest makes it unique; if multiple targets match, ask one clarifying question.",
588
+ "- Ask missing information incrementally, one question at a time.",
589
+ "- Explore intent: read/search/tree/list is read-only; execute immediately when target and path/query are clear.",
590
+ "- Write intent: infer route/title/body/filename when possible, but confirm Workspace, route, and filename before calling `monofold_write`.",
591
+ "- Config intent: edit `.pi/monofold.yaml` only after showing the YAML diff; validate afterward with `monofold_list`.",
592
+ "- Git intent: use `monofold_git`; for commit+push use action `commitPush` and one combined confirmation. If message is missing, propose one from the diff.",
593
+ "- If there is no `.pi/monofold.yaml`, guide the user to `/monofold:init`.",
594
+ "",
595
+ "Intent:",
596
+ intent,
597
+ "",
598
+ "User request:",
599
+ trimmed || `(empty input) ${emptyInstruction}`,
600
+ ].join("\n");
601
+ }
602
+
603
+ function buildGuideHandoffPrompt(request: string): string {
604
+ return [
605
+ "Pi Monofold guide request. Start a conversational helper flow for Pi Monofold.",
606
+ "",
607
+ "Guide the user through Explore, Write, Config, Git, init, or update. Do not dump a static help page.",
608
+ "Ask one question at a time, then route to the appropriate intent behavior:",
609
+ "- Explore: list/read/search/tree workspaces.",
610
+ "- Write: routed Markdown output.",
611
+ "- Config: Workspace or Project Workspace configuration changes with YAML diff confirmation.",
612
+ "- Git: status/commit/push/commit+push via `monofold_git`.",
613
+ "- Init: queue or instruct `/monofold:init`.",
614
+ "- Update: run or instruct `/monofold:update` for migration/cleanup.",
615
+ "",
616
+ "Initial user request:",
617
+ request.trim() || "(empty input) Ask what they want to do with Pi Monofold.",
618
+ ].join("\n");
619
+ }
620
+
513
621
  function matchesTarget(workspace: ResolvedWorkspace, target: TargetInput): boolean {
514
622
  if (target.targetId && workspace.targetId !== (target.targetId.startsWith("#") ? target.targetId : `#${target.targetId}`)) return false;
515
623
  if (target.workspaceIndex !== undefined && workspace.index !== target.workspaceIndex) return false;
@@ -884,6 +992,21 @@ function inferBashCwd(ctx: ExtensionContext, command: string): string {
884
992
  }
885
993
 
886
994
  export default function piMultiWorkspace(pi: ExtensionAPI) {
995
+ pi.on("session_start", async (_event, ctx) => {
996
+ try {
997
+ const loaded = await loadConfig(ctx.cwd);
998
+ ensureActiveFocusInitialized(loaded.raw.focusPresets);
999
+ const activeId = getActiveFocusPresetId();
1000
+ if (!activeId) return;
1001
+ const preset = findFocusPresetById(loaded.raw.focusPresets, activeId);
1002
+ if (!preset) return;
1003
+ if (!ctx.hasUI) return;
1004
+ warnZeroTargetMatchesForPreset(preset, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
1005
+ } catch {
1006
+ return;
1007
+ }
1008
+ });
1009
+
887
1010
  pi.on("before_agent_start", async (_event, ctx) => {
888
1011
  try {
889
1012
  const loaded = await loadConfig(ctx.cwd);
@@ -1028,9 +1151,9 @@ ${manifest}
1028
1151
  pi.registerTool({
1029
1152
  name: "monofold_git",
1030
1153
  label: "Workspace Git",
1031
- description: "Run guarded git status, commit, or push for one configured Git Workspace.",
1154
+ description: "Run guarded git status, commit, push, or commitPush for one configured Git Workspace.",
1032
1155
  parameters: Type.Object({
1033
- action: Type.String({ description: "status, commit, or push" }),
1156
+ action: Type.String({ description: "status, commit, push, or commitPush" }),
1034
1157
  message: Type.Optional(Type.String()),
1035
1158
  targetTags: Type.Optional(Type.Array(Type.String())),
1036
1159
  targetName: Type.Optional(Type.String()),
@@ -1038,7 +1161,7 @@ ${manifest}
1038
1161
  workspaceName: Type.Optional(Type.String()),
1039
1162
  }),
1040
1163
  async execute(_id, params, signal, _onUpdate, ctx) {
1041
- const required: CapabilityTag[] = params.action === "push" ? ["gitPush"] : params.action === "commit" ? ["gitCommit"] : [];
1164
+ const required: CapabilityTag[] = params.action === "status" ? ["read"] : ["git"];
1042
1165
  const loaded = await loadConfig(ctx.cwd);
1043
1166
  const workspace = await resolveWorkspace(ctx, loaded, {
1044
1167
  targetTags: params.targetTags,
@@ -1050,18 +1173,35 @@ ${manifest}
1050
1173
  const root = await gitRoot(workspace);
1051
1174
  if (!root) throw new Error(`Not a Git Workspace: ${formatWorkspaceLabel(workspace)}`);
1052
1175
  if (params.action === "status") {
1176
+ if (!workspace.capabilities.includes("read")) throw new Error(`Workspace lacks read capability: ${formatWorkspaceLabel(workspace)}`);
1053
1177
  const result = await runCommand("git", ["-C", root, "status", "--short", "--branch"], { signal, timeout: 10000 });
1054
1178
  return { content: [{ type: "text", text: result.stdout || "clean" }], details: { workspace } };
1055
1179
  }
1056
- if (params.action === "commit") {
1180
+ if (!workspace.capabilities.includes("git")) throw new Error(`Workspace lacks git capability: ${formatWorkspaceLabel(workspace)}`);
1181
+ if (params.action === "commit" || params.action === "commitPush") {
1057
1182
  const message = params.message ?? `Update ${workspace.name ?? (workspace.tags.join("-") || "workspace")}`;
1058
1183
  const status = await runCommand("git", ["-C", root, "status", "--short"], { signal, timeout: 10000 });
1059
1184
  const diffstat = await runCommand("git", ["-C", root, "diff", "--stat"], { signal, timeout: 10000 });
1060
1185
  const scope = workspace.kind === "project" && root !== workspace.resolvedPath ? normalizeSlashes(path.relative(root, workspace.resolvedPath)) : ".";
1061
- const ok = await confirm(ctx, "Workspace Commit", `${formatWorkspaceLabel(workspace)}\n\nStatus (repo full):\n${status.stdout || "clean"}\n\nDiffstat (repo full):\n${diffstat.stdout || "none"}\n\nCommit scope:\n${scope}\n\nCommit message:\n${message}\n\nStage scoped changes and commit?`);
1062
- if (!ok) return { content: [{ type: "text", text: "Commit cancelled" }], details: { cancelled: true } };
1186
+ let pushContext = "";
1187
+ if (params.action === "commitPush") {
1188
+ const branch = await runCommand("git", ["-C", root, "branch", "--show-current"], { signal, timeout: 10000 });
1189
+ const remote = await runCommand("git", ["-C", root, "remote", "-v"], { signal, timeout: 10000 });
1190
+ const log = await runCommand("git", ["-C", root, "log", "--oneline", "@{u}..HEAD"], {
1191
+ signal,
1192
+ timeout: 10000,
1193
+ allowExitCodes: [0, 128],
1194
+ });
1195
+ pushContext = `\n\nPush after commit:\nBranch: ${branch.stdout.trim()}\n\nRemote:\n${remote.stdout}\nCommits already ahead:\n${log.stdout || "none/unknown upstream"}`;
1196
+ }
1197
+ 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?"}`);
1198
+ if (!ok) return { content: [{ type: "text", text: params.action === "commitPush" ? "Commit+push cancelled" : "Commit cancelled" }], details: { cancelled: true } };
1063
1199
  await runCommand("git", ["-C", root, "add", "-A", "--", scope], { signal, timeout: 10000 });
1064
1200
  const commit = await runCommand("git", ["-C", root, "commit", "-m", message], { signal, timeout: 30000 });
1201
+ if (params.action === "commitPush") {
1202
+ const push = await runCommand("git", ["-C", root, "push"], { signal, timeout: 60000 });
1203
+ return { content: [{ type: "text", text: [commit.stdout || commit.stderr, push.stdout || push.stderr].filter(Boolean).join("\n") }], details: { workspace, message } };
1204
+ }
1065
1205
  return { content: [{ type: "text", text: commit.stdout || commit.stderr }], details: { workspace, message } };
1066
1206
  }
1067
1207
  if (params.action === "push") {
@@ -1206,7 +1346,7 @@ ${manifest}
1206
1346
  try {
1207
1347
  const parsed = parseCommandArgs(args);
1208
1348
  const action = parsed.positional[0] ?? "status";
1209
- const required: CapabilityTag[] = action === "push" ? ["gitPush"] : action === "commit" ? ["gitCommit"] : [];
1349
+ const required: CapabilityTag[] = action === "status" ? ["read"] : ["git"];
1210
1350
  const loaded = await loadConfig(ctx.cwd);
1211
1351
  const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, required));
1212
1352
  const root = await gitRoot(workspace);
@@ -1322,6 +1462,7 @@ ${manifest}
1322
1462
  changed: plan.changed,
1323
1463
  configPath: CONFIG_RELATIVE_PATH,
1324
1464
  backupPath: plan.backupRelativePath,
1465
+ legacyBackupPath: plan.cleanupLegacyBackupRelativePath,
1325
1466
  });
1326
1467
 
1327
1468
  let request = args.trim();
@@ -1336,20 +1477,26 @@ ${manifest}
1336
1477
  }
1337
1478
  };
1338
1479
 
1339
- pi.registerCommand("monofold:list", { description: "List configured Pi Monofold workspaces", handler: listCommand });
1340
- pi.registerCommand("monofold_list", { description: "Alias for /monofold:list", handler: listCommand });
1341
- pi.registerCommand("monofold:tree", { description: "Show a tree for a configured workspace", handler: readCommand });
1342
- pi.registerCommand("monofold:read", { description: "Read, tree, or search a configured workspace", handler: readCommand });
1343
- pi.registerCommand("monofold_read", { description: "Alias for /monofold:read", handler: readCommand });
1344
- pi.registerCommand("monofold:search", { description: "Search a configured workspace", handler: (args, ctx) => readCommand(`search ${args}`, ctx) });
1345
- pi.registerCommand("monofold:write", { description: "Write a routed Markdown document", handler: writeCommand });
1346
- pi.registerCommand("monofold_write", { description: "Alias for /monofold:write", handler: writeCommand });
1347
- pi.registerCommand("monofold:git", { description: "Run guarded workspace git status, commit, or push", handler: gitCommand });
1348
- pi.registerCommand("monofold_git", { description: "Alias for /monofold:git", handler: gitCommand });
1349
- pi.registerCommand("monofold:add", { description: `Add a workspace to ${CONFIG_RELATIVE_PATH}`, handler: addCommand });
1350
- pi.registerCommand("monofold_add", { description: "Alias for /monofold:add", handler: addCommand });
1351
- pi.registerCommand("monofold:project-add", { description: "Add a project workspace under a parent workspace", handler: projectAddCommand });
1352
- pi.registerCommand("monofold_project_add", { description: "Alias for /monofold:project-add", handler: projectAddCommand });
1480
+ const intentCommand = (intent: IntentCategory) => async (args: string, ctx: ExtensionCommandContext) => {
1481
+ const prepared = await prepareIntentConfiguration(ctx);
1482
+ if (!prepared) {
1483
+ pi.sendUserMessage("/monofold:init", { deliverAs: "followUp" });
1484
+ return;
1485
+ }
1486
+ pi.sendUserMessage(buildIntentHandoffPrompt(intent, args), { deliverAs: "followUp" });
1487
+ sendCommandOutput(pi, `monofold:${intent.toLowerCase()}`, `Queued ${intent} handoff.`, { intent, request: args.trim() });
1488
+ };
1489
+
1490
+ const guideCommand = async (args: string, _ctx: ExtensionCommandContext) => {
1491
+ pi.sendUserMessage(buildGuideHandoffPrompt(args), { deliverAs: "followUp" });
1492
+ sendCommandOutput(pi, "monofold:guide", "Queued Monofold guide.", { request: args.trim() });
1493
+ };
1494
+
1495
+ pi.registerCommand("monofold:explore", { description: "Explore configured workspaces via natural-language handoff", handler: intentCommand("Explore") });
1496
+ pi.registerCommand("monofold:write", { description: "Create routed Markdown via natural-language handoff", handler: intentCommand("Write") });
1497
+ pi.registerCommand("monofold:config", { description: "Change Workspace configuration via natural-language handoff", handler: intentCommand("Config") });
1498
+ pi.registerCommand("monofold:git", { description: "Run workspace git workflows via natural-language handoff", handler: intentCommand("Git") });
1499
+ pi.registerCommand("monofold:guide", { description: "Conversational guide for Pi Monofold workflows", handler: guideCommand });
1353
1500
  pi.registerCommand("monofold:update", { description: `Migrate and validate ${CONFIG_RELATIVE_PATH}`, handler: updateCommand });
1354
1501
 
1355
1502
  const initCommand = async (_args: string, ctx: ExtensionCommandContext) => {
@@ -1395,7 +1542,7 @@ ${manifest}
1395
1542
  const name = await ctx.ui.input("Optional workspace name", "");
1396
1543
  const tagsInput = await ctx.ui.input("Tags comma-separated", "business,markdown");
1397
1544
  if (!tagsInput) return;
1398
- const capsInput = await ctx.ui.input("Capabilities comma-separated", "read,writeDocs,gitCommit");
1545
+ const capsInput = await ctx.ui.input("Capabilities comma-separated", "read,writeDocs,git");
1399
1546
  if (!capsInput) return;
1400
1547
  const capabilities = capsInput.split(",").map((s) => s.trim()).filter(Boolean);
1401
1548
  const routePath = capabilities.includes("writeDocs") ? await ctx.ui.input("Default document route", "Notes") : undefined;
@@ -1422,10 +1569,6 @@ ${manifest}
1422
1569
  description: `Create or update ${CONFIG_RELATIVE_PATH} with an interactive wizard`,
1423
1570
  handler: initCommand,
1424
1571
  });
1425
- pi.registerCommand("monofold_init", {
1426
- description: "Alias for /monofold:init",
1427
- handler: initCommand,
1428
- });
1429
1572
 
1430
1573
  pi.registerTool({
1431
1574
  name: "monofold_init",
@@ -1475,4 +1618,4 @@ ${manifest}
1475
1618
  return undefined;
1476
1619
  });
1477
1620
  }
1478
-
1621
+
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "scripts": {
3
3
  "typecheck": "tsc --noEmit",
4
- "check": "npm run typecheck && npm pack --dry-run"
4
+ "test": "node --import tsx --test tests/**/*.test.ts",
5
+ "check": "npm run typecheck && npm test && npm pack --dry-run"
5
6
  },
6
7
  "peerDependencies": {
7
8
  "typebox": "*",
@@ -10,7 +11,7 @@
10
11
  },
11
12
  "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
12
13
  "type": "module",
13
- "version": "0.1.1",
14
+ "version": "0.3.0",
14
15
  "pi": {
15
16
  "extensions": [
16
17
  "./index.ts"
@@ -19,13 +20,16 @@
19
20
  "files": [
20
21
  "README.md",
21
22
  "LICENSE",
22
- "index.ts"
23
+ "index.ts",
24
+ "focus-preset.ts",
25
+ "validation.ts"
23
26
  ],
24
27
  "name": "pi-monofold",
25
28
  "devDependencies": {
26
29
  "typescript": "^6.0.3",
27
30
  "typebox": "^1.1.38",
28
31
  "@types/node": "^25.9.1",
32
+ "tsx": "^4.20.5",
29
33
  "@earendil-works/pi-coding-agent": "^0.75.4",
30
34
  "@earendil-works/pi-ai": "^0.75.4"
31
35
  },
package/validation.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Returns true when a value is a plain object record, excluding arrays and null. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+
6
+ /** Throws when an object contains keys outside the supplied allow-list. */
7
+ export function assertKnownKeys(label: string, value: Record<string, unknown>, allowed: Set<string>): void {
8
+ for (const key of Object.keys(value)) {
9
+ if (!allowed.has(key)) throw new Error(`${label} has unknown key: ${key}`);
10
+ }
11
+ }
12
+
13
+ /** Coerces a required or optional unknown value to a string array with strict item validation. */
14
+ export function asStringArray(label: string, value: unknown, required = true): string[] {
15
+ if (value === undefined && !required) return [];
16
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
17
+ throw new Error(`${label} must be an array of strings`);
18
+ }
19
+ return value;
20
+ }
21
+
22
+ /** Removes empty strings and duplicate entries while preserving first-seen order. */
23
+ export function uniqueStrings(items: string[]): string[] {
24
+ return [...new Set(items.filter(Boolean))];
25
+ }