pi-monofold 0.2.0 → 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 +14 -0
- 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
|
@@ -104,6 +104,12 @@ defaults:
|
|
|
104
104
|
created: "{{date}}"
|
|
105
105
|
source: "pi-monofold"
|
|
106
106
|
|
|
107
|
+
focusPresets:
|
|
108
|
+
- id: control
|
|
109
|
+
label: Control workspace focus
|
|
110
|
+
targets:
|
|
111
|
+
- targetTags: [control]
|
|
112
|
+
|
|
107
113
|
workspaces:
|
|
108
114
|
- name: "Product docs"
|
|
109
115
|
path: "../business"
|
|
@@ -133,6 +139,14 @@ workspaces:
|
|
|
133
139
|
contextFiles: [README.md, AGENTS.md]
|
|
134
140
|
```
|
|
135
141
|
|
|
142
|
+
## Focus presets
|
|
143
|
+
|
|
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.
|
|
149
|
+
|
|
136
150
|
## Commands
|
|
137
151
|
|
|
138
152
|
Human-facing commands accept natural-language arguments and hand off interpretation to the Pi agent:
|
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.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
|
+
}
|