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 +90 -110
- package/focus-preset.ts +133 -0
- package/index.ts +30 -24
- package/package.json +7 -3
- package/validation.ts +25 -0
package/README.md
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
# pi-monofold
|
|
2
2
|
|
|
3
|
+
[](https://github.com/eiei114/pi-monofold/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/eiei114/pi-monofold/actions/workflows/publish.yml)
|
|
3
5
|
[](https://www.npmjs.com/package/pi-monofold)
|
|
4
|
-
[](https://github.com/eiei114/pi-monofold/actions/workflows/auto-release.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/pi-monofold)
|
|
6
7
|
[](./LICENSE)
|
|
7
8
|
[](https://github.com/eiei114/pi-monofold)
|
|
9
|
+
[](https://docs.npmjs.com/generating-provenance-statements)
|
|
8
10
|
|
|
9
|
-
Pi
|
|
11
|
+
Pi extension that folds multiple local repositories and folders into a guarded **Virtual Monorepo** for AI agents.
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
## What this is
|
|
12
14
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
48
|
+
Pin a version:
|
|
36
49
|
|
|
37
50
|
```powershell
|
|
38
|
-
pi install git:github.com/eiei114/pi-monofold@v0.1
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
84
|
+
## Quick start
|
|
74
85
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
91
|
+
Example command flows: [docs/examples.md](./docs/examples.md).
|
|
83
92
|
|
|
84
|
-
|
|
85
|
-
pi -e .
|
|
86
|
-
```
|
|
93
|
+
## Usage summary
|
|
87
94
|
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
<control-repo>/.pi/monofold.yaml
|
|
94
|
-
```
|
|
107
|
+
## Package contents
|
|
95
108
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
##
|
|
137
|
-
|
|
138
|
-
Human-facing commands accept natural-language arguments and hand off interpretation to the Pi agent:
|
|
132
|
+
## Development
|
|
139
133
|
|
|
140
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
## Agent API
|
|
143
|
+
Try the local checkout without installing:
|
|
161
144
|
|
|
162
|
-
|
|
145
|
+
```powershell
|
|
146
|
+
pi -e .
|
|
147
|
+
```
|
|
163
148
|
|
|
164
|
-
|
|
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
|
-
|
|
151
|
+
Releases are automated. See [docs/release.md](./docs/release.md) for details.
|
|
171
152
|
|
|
172
|
-
|
|
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
|
-
|
|
158
|
+
## Security
|
|
175
159
|
|
|
176
|
-
|
|
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
|
-
|
|
162
|
+
Report vulnerabilities per [SECURITY.md](./SECURITY.md).
|
|
179
163
|
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
+
## License
|
|
187
171
|
|
|
188
|
-
|
|
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)
|
package/focus-preset.ts
ADDED
|
@@ -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
|
-
"
|
|
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.
|
|
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
|
+
}
|