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 +48 -23
- package/focus-preset.ts +133 -0
- package/index.ts +208 -65
- package/package.json +7 -3
- package/validation.ts +25 -0
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Pi Monofold
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-monofold)
|
|
4
|
+
[](https://github.com/eiei114/pi-monofold/actions/workflows/publish.yml)
|
|
5
|
+
[](https://github.com/eiei114/pi-monofold/actions/workflows/auto-release.yml)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](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,
|
|
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,
|
|
138
|
+
capabilities: [read, editCode, runCommands, git]
|
|
127
139
|
contextFiles: [README.md, AGENTS.md]
|
|
128
140
|
```
|
|
129
141
|
|
|
130
|
-
##
|
|
142
|
+
## Focus presets
|
|
131
143
|
|
|
132
|
-
- `
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
-
|
|
141
|
-
|
|
142
|
-
- `/monofold:
|
|
143
|
-
- `/monofold:
|
|
144
|
-
- `/monofold:
|
|
145
|
-
- `/monofold:
|
|
146
|
-
- `/monofold:
|
|
147
|
-
- `/monofold:
|
|
148
|
-
- `/monofold:
|
|
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:
|
|
154
|
-
/monofold:
|
|
155
|
-
/monofold:
|
|
156
|
-
/monofold:
|
|
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,
|
|
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.
|
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,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
|
|
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", "
|
|
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 (!
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 === "
|
|
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 (
|
|
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
|
-
|
|
1062
|
-
if (
|
|
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 === "
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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,
|
|
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
|
-
"
|
|
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
|
+
}
|