pi-monofold 0.2.0 → 0.3.1

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,18 +1,31 @@
1
- # Pi Monofold
1
+ # pi-monofold
2
2
 
3
+ [![CI](https://github.com/eiei114/pi-monofold/actions/workflows/ci.yml/badge.svg)](https://github.com/eiei114/pi-monofold/actions/workflows/ci.yml)
4
+ [![Publish](https://github.com/eiei114/pi-monofold/actions/workflows/publish.yml/badge.svg)](https://github.com/eiei114/pi-monofold/actions/workflows/publish.yml)
3
5
  [![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
+ [![npm downloads](https://img.shields.io/npm/dw/pi-monofold)](https://www.npmjs.com/package/pi-monofold)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
7
8
  [![Pi Package](https://img.shields.io/badge/Pi-package-6f42c1)](https://github.com/eiei114/pi-monofold)
9
+ [![Trusted Publishing](https://img.shields.io/badge/npm-provenance-yellow)](https://docs.npmjs.com/generating-provenance-statements)
8
10
 
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.
11
+ Pi extension that folds multiple local repositories and folders into a guarded **Virtual Monorepo** for AI agents.
10
12
 
11
- It keeps repositories physically separate, while giving Pi a lightweight manifest, routed writes, workspace-aware reads, guarded commands, and explicit git flows.
13
+ ## What this is
12
14
 
13
- ## Why
15
+ Pi Monofold (`pi-monofold`) keeps repositories physically separate while giving Pi a lightweight manifest, routed writes, workspace-aware reads, guarded commands, and explicit git flows. Documentation, rules, product context, and implementation code can appear as one connected system without migrating everything into a single git repository.
14
16
 
15
- AI coding agents work best when documentation, rules, product context, and implementation code are visible as one connected system. Physical monorepos are not always practical. Pi Monofold gives Pi a logical monorepo boundary without forcing repository migration.
17
+ See [docs/usage.md](./docs/usage.md) for configuration, commands, agent tools, and guard behavior.
18
+
19
+ ## Features
20
+
21
+ - **Virtual monorepo manifest** — declare workspaces and project workspaces in `.pi/monofold.yaml`
22
+ - **Routed Markdown writes** — route PRDs, progress notes, and other doc types to configured folders
23
+ - **Workspace-aware reads** — list, read, search, and tree views scoped to readable workspaces
24
+ - **Capability guard** — block or confirm `read` / `write` / `edit` / `grep` / `find` / `bash` based on workspace tags
25
+ - **Focus presets** — tag-based focus targets for the control workspace
26
+ - **Natural-language commands** — `/monofold:explore`, `/monofold:write`, `/monofold:config`, `/monofold:git`, and more
27
+ - **Strict agent tools** — `monofold_*` tools for programmatic access behind the command surface
28
+ - **Config migration** — upgrade legacy `.pi/monofold.yml` with backups and validation
16
29
 
17
30
  ## Install
18
31
 
@@ -26,16 +39,16 @@ Pi Monofold is a Pi package. Install it with Pi's package installer from git or
26
39
  pi install git:github.com/eiei114/pi-monofold
27
40
  ```
28
41
 
29
- Install into the current project settings instead of user settings:
42
+ Project-local install:
30
43
 
31
44
  ```powershell
32
45
  pi install -l git:github.com/eiei114/pi-monofold
33
46
  ```
34
47
 
35
- Pin a version/ref:
48
+ Pin a version:
36
49
 
37
50
  ```powershell
38
- pi install git:github.com/eiei114/pi-monofold@v0.1.0
51
+ pi install git:github.com/eiei114/pi-monofold@v0.3.1
39
52
  ```
40
53
 
41
54
  Try without installing:
@@ -46,13 +59,11 @@ pi -e git:github.com/eiei114/pi-monofold
46
59
 
47
60
  ### From npm
48
61
 
49
- After the package is published to npm:
50
-
51
62
  ```powershell
52
63
  pi install npm:pi-monofold
53
64
  ```
54
65
 
55
- Install into the current project settings instead of user settings:
66
+ Project-local install:
56
67
 
57
68
  ```powershell
58
69
  pi install -l npm:pi-monofold
@@ -61,7 +72,7 @@ pi install -l npm:pi-monofold
61
72
  Pin a version:
62
73
 
63
74
  ```powershell
64
- pi install npm:pi-monofold@0.1.0
75
+ pi install npm:pi-monofold@0.3.1
65
76
  ```
66
77
 
67
78
  Try without installing:
@@ -70,123 +81,92 @@ Try without installing:
70
81
  pi -e npm:pi-monofold
71
82
  ```
72
83
 
73
- ### Local development
84
+ ## Quick start
74
85
 
75
- ```powershell
76
- git clone https://github.com/eiei114/pi-monofold.git
77
- cd pi-monofold
78
- npm install
79
- npm run typecheck
80
- ```
86
+ 1. Install the extension (see [Install](#install)).
87
+ 2. In your control repository, create `.pi/monofold.yaml` with at least one workspace entry (or run `/monofold:init`).
88
+ 3. Start Pi in the control repository and run `/monofold:explore show the project workspaces`.
89
+ 4. Use `/monofold:write` for routed Markdown outputs and `/monofold:git` for guarded git workflows.
81
90
 
82
- Try the local checkout without installing:
91
+ Example command flows: [docs/examples.md](./docs/examples.md).
83
92
 
84
- ```powershell
85
- pi -e .
86
- ```
93
+ ## Usage summary
87
94
 
88
- ## Config
95
+ | Surface | Purpose |
96
+ |---------|---------|
97
+ | `/monofold:explore` | List, read, search, or inspect workspace trees |
98
+ | `/monofold:write` | Create routed Markdown outputs |
99
+ | `/monofold:config` | Add or change workspaces and project workspaces |
100
+ | `/monofold:git` | Run guarded git status, commit, push, or commit+push |
101
+ | `/monofold:guide` | Interactive guide for common flows |
102
+ | `/monofold:init` | Create or update `.pi/monofold.yaml` |
103
+ | `/monofold:update` | Migrate legacy config and optionally request config edits |
89
104
 
90
- Place config in the control repository:
105
+ Agent tools (`monofold_list`, `monofold_read`, `monofold_write`, `monofold_git`, `monofold_init`) sit behind these commands. Full reference: [docs/usage.md](./docs/usage.md).
91
106
 
92
- ```text
93
- <control-repo>/.pi/monofold.yaml
94
- ```
107
+ ## Package contents
95
108
 
96
- Example:
97
-
98
- ```yaml
99
- version: 1
100
-
101
- defaults:
102
- filenameTemplate: "{{date}}-{{slug}}.md"
103
- metadata:
104
- created: "{{date}}"
105
- source: "pi-monofold"
106
-
107
- workspaces:
108
- - name: "Product docs"
109
- path: "../business"
110
- tags: [business, markdown, planning]
111
- capabilities: [read, writeDocs, git]
112
- contextFiles: [README.md, CONTEXT.md]
113
- routes:
114
- default: "Notes"
115
- prd:
116
- path: "Docs/PRD"
117
- filenameTemplate: "prd-{{slug}}.md"
118
- metadata:
119
- type: prd
120
- projects:
121
- - name: "Launch plan"
122
- path: "Projects/Launch"
123
- tags: [project, launch]
124
- contextFiles: [CONTEXT.md]
125
- routes:
126
- default: "."
127
- progress: "Progress"
128
-
129
- - name: "Application"
130
- path: "../app"
131
- tags: [development, app]
132
- capabilities: [read, editCode, runCommands, git]
133
- contextFiles: [README.md, AGENTS.md]
109
+ ```text
110
+ pi-monofold/
111
+ ├── .github/workflows/
112
+ │ ├── auto-release.yml # Auto-tag + release on merge to main
113
+ │ ├── ci.yml # Validate on PR / push
114
+ │ └── publish.yml # Publish to npm (Trusted Publishing)
115
+ ├── docs/
116
+ │ ├── usage.md # Config, commands, agent API, guard
117
+ │ ├── examples.md # Command examples
118
+ │ └── release.md # Release and publish flow
119
+ ├── tests/
120
+ │ └── focus-preset.test.ts
121
+ ├── CHANGELOG.md
122
+ ├── SECURITY.md
123
+ ├── focus-preset.ts
124
+ ├── index.ts
125
+ ├── LICENSE
126
+ ├── package.json
127
+ ├── README.md
128
+ ├── validation.ts
129
+ └── tsconfig.json
134
130
  ```
135
131
 
136
- ## Commands
137
-
138
- Human-facing commands accept natural-language arguments and hand off interpretation to the Pi agent:
132
+ ## Development
139
133
 
140
- - `/monofold:explore [request]`: list, read, search, or inspect workspace trees.
141
- - `/monofold:write [request]`: create routed Markdown outputs.
142
- - `/monofold:config [request]`: add or change Workspaces and Project Workspaces.
143
- - `/monofold:git [request]`: run git status, commit, push, or commit+push workflows.
144
- - `/monofold:guide`: start an interactive guide for Explore, Write, Config, Git, init, and update flows.
145
- - `/monofold:init`: create or update `.pi/monofold.yaml` with an interactive wizard.
146
- - `/monofold:update [request]`: migrate/clean up legacy config and optionally hand a config-change request to the agent.
134
+ Clone and validate:
147
135
 
148
- Examples:
149
-
150
- ```text
151
- /monofold:explore show the project workspaces
152
- /monofold:write write today's progress note for Pi Monofold
153
- /monofold:config add 4_Project/NewApp as a Project Workspace under Obsidian Vault with tag project,newapp
154
- /monofold:git commit and push the pi-monofold dev workspace
155
- /monofold:guide
136
+ ```powershell
137
+ git clone https://github.com/eiei114/pi-monofold.git
138
+ cd pi-monofold
139
+ npm install
140
+ npm run check
156
141
  ```
157
142
 
158
- 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.
159
-
160
- ## Agent API
143
+ Try the local checkout without installing:
161
144
 
162
- Pi agents use strict `monofold_*` tools behind the natural-language command surface:
145
+ ```powershell
146
+ pi -e .
147
+ ```
163
148
 
164
- - `monofold_list`: show manifest and git status summary.
165
- - `monofold_read`: read files, search text, or show a tree inside readable workspaces.
166
- - `monofold_write`: create routed Markdown outputs by `routeType`, `title`, and `body`.
167
- - `monofold_git`: run guarded workspace git `status`, `commit`, `push`, or `commitPush`.
168
- - `monofold_init`: queue `/monofold:init`.
149
+ ## Release
169
150
 
170
- 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`.
151
+ Releases are automated. See [docs/release.md](./docs/release.md) for details.
171
152
 
172
- ## Updating configuration
153
+ 1. Bump `version` in `package.json` and update `CHANGELOG.md`.
154
+ 2. Merge to `main`.
155
+ 3. **Auto Release** tags `v<version>` and creates a GitHub release when the tag is new.
156
+ 4. The tag triggers **Publish**, which publishes to npm with OIDC provenance.
173
157
 
174
- `.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.
158
+ ## Security
175
159
 
176
- `/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.
160
+ Pi Monofold intercepts standard Pi tool calls when monofold config is present. Writes and shell commands are allowed only when the resolved workspace grants the matching capability. Git commit/push via raw `bash` is blocked; use `/monofold:git` or `monofold_git` instead.
177
161
 
178
- After migration, you can provide a natural-language configuration change request. The command hands that request to the Pi agent, which edits `.pi/monofold.yaml` directly and validates the result through the manifest path:
162
+ Report vulnerabilities per [SECURITY.md](./SECURITY.md).
179
163
 
180
- ```text
181
- /monofold:update add 4_Project/NewApp as a Project Workspace under Obsidian Vault with tags project,newapp and progress route Progress
182
- ```
164
+ ## Links
183
165
 
184
- ## Guard
166
+ - **Repository**: <https://github.com/eiei114/pi-monofold>
167
+ - **npm**: <https://www.npmjs.com/package/pi-monofold>
168
+ - **Issues**: <https://github.com/eiei114/pi-monofold/issues>
185
169
 
186
- When `.pi/monofold.yaml` or legacy `.pi/monofold.yml` exists, Pi Monofold guards standard `read/write/edit/grep/find/bash` calls against workspace capabilities.
170
+ ## License
187
171
 
188
- - Unknown path: confirm in UI, block without UI.
189
- - Docs write: requires `writeDocs`.
190
- - Code edit: requires `editCode`.
191
- - Bash: requires workspace cwd and `runCommands`.
192
- - Git commit/push via bash: blocked; use `/monofold:git` or the `monofold_git` agent tool.
172
+ [MIT](LICENSE)
@@ -0,0 +1,133 @@
1
+ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
2
+
3
+ export type FocusPresetTarget = {
4
+ targetTags: string[];
5
+ };
6
+
7
+ export type FocusPreset = {
8
+ id: string;
9
+ label: string;
10
+ targets: FocusPresetTarget[];
11
+ };
12
+
13
+ export type FocusMatchableWorkspace = {
14
+ tags: string[];
15
+ };
16
+
17
+ const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets"]);
18
+ const FOCUS_PRESET_TARGET_KEYS = new Set(["targetTags"]);
19
+
20
+ /** Parses and validates focus preset configuration from YAML/JSON input. */
21
+ export function parseFocusPresets(value: unknown, label = "focusPresets"): FocusPreset[] {
22
+ if (value === undefined) return [];
23
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
24
+ const presets: FocusPreset[] = [];
25
+ const seenIds = new Set<string>();
26
+ for (let index = 0; index < value.length; index += 1) {
27
+ const item = value[index];
28
+ const itemLabel = `${label}[${index}]`;
29
+ if (!isRecord(item)) throw new Error(`${itemLabel} must be an object`);
30
+ assertKnownKeys(itemLabel, item, FOCUS_PRESET_KEYS);
31
+ if (typeof item.id !== "string" || item.id.trim() === "") {
32
+ throw new Error(`${itemLabel}.id must be a non-empty string`);
33
+ }
34
+ if (typeof item.label !== "string" || item.label.trim() === "") {
35
+ throw new Error(`${itemLabel}.label must be a non-empty string`);
36
+ }
37
+ if (!Array.isArray(item.targets) || item.targets.length === 0) {
38
+ throw new Error(`${itemLabel}.targets must be a non-empty array`);
39
+ }
40
+ const targets: FocusPresetTarget[] = [];
41
+ for (let targetIndex = 0; targetIndex < item.targets.length; targetIndex += 1) {
42
+ const target = item.targets[targetIndex];
43
+ const targetLabel = `${itemLabel}.targets[${targetIndex}]`;
44
+ if (!isRecord(target)) throw new Error(`${targetLabel} must be an object`);
45
+ assertKnownKeys(targetLabel, target, FOCUS_PRESET_TARGET_KEYS);
46
+ const targetTags = uniqueStrings(asStringArray(`${targetLabel}.targetTags`, target.targetTags));
47
+ if (targetTags.length === 0) {
48
+ throw new Error(`${targetLabel}.targetTags must contain at least one non-empty string`);
49
+ }
50
+ targets.push({ targetTags });
51
+ }
52
+ if (seenIds.has(item.id)) throw new Error(`${label} has duplicate preset id: ${item.id}`);
53
+ seenIds.add(item.id);
54
+ presets.push({ id: item.id, label: item.label, targets });
55
+ }
56
+ return presets;
57
+ }
58
+
59
+ /** Returns the first preset id, or null when no focus preset is available. */
60
+ export function pickDefaultFocusPresetId(focusPresets: FocusPreset[] | undefined): string | null {
61
+ if (!focusPresets || focusPresets.length === 0) return null;
62
+ return focusPresets[0]?.id ?? null;
63
+ }
64
+
65
+ /** Finds a focus preset by stable id. */
66
+ export function findFocusPresetById(focusPresets: FocusPreset[] | undefined, id: string): FocusPreset | undefined {
67
+ return focusPresets?.find((preset) => preset.id === id);
68
+ }
69
+
70
+ /** Returns true when every requested focus tag is present on a workspace. */
71
+ export function matchesFocusTarget(workspace: FocusMatchableWorkspace, targetTags: string[]): boolean {
72
+ if (targetTags.length === 0) return false;
73
+ return targetTags.every((tag) => workspace.tags.includes(tag));
74
+ }
75
+
76
+ /** Counts workspaces matched by a focus target's tag set. */
77
+ export function countMatchingWorkspaces(
78
+ workspaces: FocusMatchableWorkspace[],
79
+ targetTags: string[],
80
+ ): number {
81
+ return workspaces.filter((workspace) => matchesFocusTarget(workspace, targetTags)).length;
82
+ }
83
+
84
+ /** Emits warnings for a preset's targets that do not match any configured workspace. */
85
+ export function warnZeroTargetMatchesForPreset(
86
+ preset: FocusPreset,
87
+ workspaces: FocusMatchableWorkspace[],
88
+ warn: (message: string) => void,
89
+ ): void {
90
+ for (const target of preset.targets) {
91
+ if (countMatchingWorkspaces(workspaces, target.targetTags) === 0) {
92
+ warn(
93
+ `Focus preset "${preset.id}" target [${target.targetTags.join(", ")}] matches no configured workspace`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+
99
+ let activeFocusPresetId: string | null = null;
100
+ let activeFocusInitialized = false;
101
+
102
+ /** Initializes the active focus preset once per process/session. */
103
+ export function ensureActiveFocusInitialized(focusPresets: FocusPreset[] | undefined): void {
104
+ if (activeFocusInitialized) return;
105
+ activeFocusInitialized = true;
106
+ activeFocusPresetId = pickDefaultFocusPresetId(focusPresets);
107
+ }
108
+
109
+ /** Returns the current active focus preset id, if any. */
110
+ export function getActiveFocusPresetId(): string | null {
111
+ return activeFocusPresetId;
112
+ }
113
+
114
+ /** Sets the active focus preset after validating that the id exists. */
115
+ export function setActiveFocusPresetId(id: string, focusPresets: FocusPreset[] | undefined): void {
116
+ if (!findFocusPresetById(focusPresets, id)) {
117
+ throw new Error(`Unknown focus preset id: ${id}`);
118
+ }
119
+ activeFocusPresetId = id;
120
+ activeFocusInitialized = true;
121
+ }
122
+
123
+ /** Clears the active focus preset for the current process/session. */
124
+ export function clearActiveFocusPresetId(): void {
125
+ activeFocusPresetId = null;
126
+ activeFocusInitialized = true;
127
+ }
128
+
129
+ /** Resets in-memory session state (for tests and process restart). */
130
+ export function resetActiveFocusSessionState(): void {
131
+ activeFocusPresetId = null;
132
+ activeFocusInitialized = false;
133
+ }
package/index.ts CHANGED
@@ -4,6 +4,15 @@ import { execFile } from "node:child_process";
4
4
  import { access, copyFile, mkdir, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import YAML from "yaml";
7
+ import {
8
+ type FocusPreset,
9
+ ensureActiveFocusInitialized,
10
+ findFocusPresetById,
11
+ getActiveFocusPresetId,
12
+ parseFocusPresets,
13
+ warnZeroTargetMatchesForPreset,
14
+ } from "./focus-preset.js";
15
+ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
7
16
 
8
17
  type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "git";
9
18
  type LegacyCapabilityTag = CapabilityTag | "gitCommit" | "gitPush";
@@ -42,6 +51,7 @@ type MultiWorkspaceConfig = {
42
51
  filenameTemplate?: string;
43
52
  metadata?: Record<string, unknown>;
44
53
  };
54
+ focusPresets?: FocusPreset[];
45
55
  workspaces: WorkspaceConfig[];
46
56
  };
47
57
 
@@ -135,26 +145,16 @@ const CODE_EXTENSIONS = new Set([
135
145
  ".scss",
136
146
  ".html",
137
147
  ]);
138
- const ROOT_KEYS = new Set(["version", "defaults", "workspaces"]);
148
+ const ROOT_KEYS = new Set(["version", "defaults", "focusPresets", "workspaces"]);
139
149
  const DEFAULT_KEYS = new Set(["contextFiles", "filenameTemplate", "metadata"]);
140
150
  const WORKSPACE_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes", "projects"]);
141
151
  const PROJECT_KEYS = new Set(["name", "path", "tags", "capabilities", "contextFiles", "routes"]);
142
152
  const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
143
153
 
144
- function isRecord(value: unknown): value is Record<string, unknown> {
145
- return typeof value === "object" && value !== null && !Array.isArray(value);
146
- }
147
-
148
154
  function normalizeSlashes(value: string): string {
149
155
  return value.replace(/\\/g, "/");
150
156
  }
151
157
 
152
- function assertKnownKeys(label: string, value: Record<string, unknown>, allowed: Set<string>): void {
153
- for (const key of Object.keys(value)) {
154
- if (!allowed.has(key)) throw new Error(`${label} has unknown key: ${key}`);
155
- }
156
- }
157
-
158
158
  function isInside(parent: string, child: string): boolean {
159
159
  const relative = path.relative(parent, child);
160
160
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -174,18 +174,6 @@ function assertProjectPath(label: string, value: string): void {
174
174
  }
175
175
  }
176
176
 
177
- function uniqueStrings(items: string[]): string[] {
178
- return [...new Set(items.filter(Boolean))];
179
- }
180
-
181
- function asStringArray(label: string, value: unknown, required = true): string[] {
182
- if (value === undefined && !required) return [];
183
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
184
- throw new Error(`${label} must be an array of strings`);
185
- }
186
- return value;
187
- }
188
-
189
177
  function asCapabilityArray(value: unknown): CapabilityTag[] {
190
178
  const items = asStringArray("capabilities", value);
191
179
  const normalized: CapabilityTag[] = [];
@@ -314,6 +302,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
314
302
  const defaultContextFiles = defaults ? asStringArray("defaults.contextFiles", defaults.contextFiles, false) : [];
315
303
  const defaultFilenameTemplate = typeof defaults?.filenameTemplate === "string" ? defaults.filenameTemplate : undefined;
316
304
  const defaultMetadata = isRecord(defaults?.metadata) ? (defaults.metadata as Record<string, unknown>) : undefined;
305
+ const focusPresets = parseFocusPresets(parsed.focusPresets, "focusPresets");
317
306
 
318
307
  const workspaces: ResolvedWorkspace[] = [];
319
308
  for (let index = 0; index < parsed.workspaces.length; index += 1) {
@@ -439,6 +428,7 @@ async function validateConfigObject(cwd: string, configPath: string, parsed: unk
439
428
  filenameTemplate: defaultFilenameTemplate,
440
429
  metadata: defaultMetadata,
441
430
  },
431
+ focusPresets: focusPresets.length > 0 ? focusPresets : undefined,
442
432
  workspaces: workspaces.filter((workspace) => workspace.kind === "workspace"),
443
433
  },
444
434
  workspaces,
@@ -476,6 +466,7 @@ function normalizeConfigForMigration(parsed: unknown): Record<string, unknown> {
476
466
  if (version !== 1) throw new Error("monofold config requires version: 1");
477
467
  const normalized: Record<string, unknown> = { version: 1 };
478
468
  if (parsed.defaults !== undefined) normalized.defaults = parsed.defaults;
469
+ if (parsed.focusPresets !== undefined) normalized.focusPresets = parsed.focusPresets;
479
470
  normalized.workspaces = parsed.workspaces;
480
471
  normalizeConfigCapabilities(normalized);
481
472
  return normalized;
@@ -1001,6 +992,21 @@ function inferBashCwd(ctx: ExtensionContext, command: string): string {
1001
992
  }
1002
993
 
1003
994
  export default function piMultiWorkspace(pi: ExtensionAPI) {
995
+ pi.on("session_start", async (_event, ctx) => {
996
+ try {
997
+ const loaded = await loadConfig(ctx.cwd);
998
+ ensureActiveFocusInitialized(loaded.raw.focusPresets);
999
+ const activeId = getActiveFocusPresetId();
1000
+ if (!activeId) return;
1001
+ const preset = findFocusPresetById(loaded.raw.focusPresets, activeId);
1002
+ if (!preset) return;
1003
+ if (!ctx.hasUI) return;
1004
+ warnZeroTargetMatchesForPreset(preset, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
1005
+ } catch {
1006
+ return;
1007
+ }
1008
+ });
1009
+
1004
1010
  pi.on("before_agent_start", async (_event, ctx) => {
1005
1011
  try {
1006
1012
  const loaded = await loadConfig(ctx.cwd);
@@ -1612,4 +1618,4 @@ ${manifest}
1612
1618
  return undefined;
1613
1619
  });
1614
1620
  }
1615
-
1621
+
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "scripts": {
3
3
  "typecheck": "tsc --noEmit",
4
- "check": "npm run typecheck && npm pack --dry-run"
4
+ "test": "node --import tsx --test tests/**/*.test.ts",
5
+ "check": "npm run typecheck && npm test && npm pack --dry-run"
5
6
  },
6
7
  "peerDependencies": {
7
8
  "typebox": "*",
@@ -10,7 +11,7 @@
10
11
  },
11
12
  "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
12
13
  "type": "module",
13
- "version": "0.2.0",
14
+ "version": "0.3.1",
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
+ }