pi-monofold 0.10.0 → 0.11.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 +1 -1
- package/focus-preset.ts +52 -3
- package/focus-session-state.ts +58 -0
- package/index.ts +64 -8
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ pi -e npm:pi-monofold
|
|
|
86
86
|
1. Install the extension (see [Install](#install)).
|
|
87
87
|
2. In your control repository, create `.pi/monofold.yaml` with at least one workspace entry (or run `/monofold:init`).
|
|
88
88
|
3. Start Pi in the control repository and run `/monofold:explore show the project workspaces`.
|
|
89
|
-
4. Use `/monofold:focus`, `ctrl+shift+m`, or `shift+ctrl+f` to switch focus presets when `focusPresets` are configured.
|
|
89
|
+
4. Use `/monofold:focus`, `ctrl+shift+m`, or `shift+ctrl+f` to switch focus presets when `focusPresets` are configured. Active Focus is restored automatically on the next Pi session in the same control repository.
|
|
90
90
|
5. Use `/monofold:write` for routed Markdown outputs and `/monofold:git` for guarded git workflows.
|
|
91
91
|
|
|
92
92
|
Example command flows: [docs/examples.md](./docs/examples.md).
|
package/focus-preset.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FocusSessionLoadResult } from "./focus-session-state.js";
|
|
1
2
|
import { type MonofoldRouteType, parseDefaultRouteOverride } from "./focus-route-override.js";
|
|
2
3
|
import {
|
|
3
4
|
parseDecisionNoteDestination,
|
|
@@ -213,14 +214,59 @@ export function warnZeroTargetMatchesForPreset(
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
export type ActiveFocusInitSource = "restored" | "default" | "stale" | "cleared" | "malformed";
|
|
218
|
+
|
|
216
219
|
let activeFocusPresetId: string | null = null;
|
|
217
220
|
let activeFocusInitialized = false;
|
|
221
|
+
let activeFocusInitSource: ActiveFocusInitSource | null = null;
|
|
218
222
|
|
|
219
|
-
/**
|
|
220
|
-
export function
|
|
223
|
+
/** Returns how the current session initialized Active Focus, when applicable. */
|
|
224
|
+
export function getActiveFocusInitSource(): ActiveFocusInitSource | null {
|
|
225
|
+
return activeFocusInitSource;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Returns true after Active Focus has been initialized for this Pi session. */
|
|
229
|
+
export function isActiveFocusSessionInitialized(): boolean {
|
|
230
|
+
return activeFocusInitialized;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function clearActiveFocusInitSource(): void {
|
|
234
|
+
activeFocusInitSource = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Applies persisted or default Active Focus once per Pi session. */
|
|
238
|
+
export function applyActiveFocusSession(
|
|
239
|
+
focusPresets: FocusPreset[] | undefined,
|
|
240
|
+
persisted: FocusSessionLoadResult = { kind: "missing" },
|
|
241
|
+
): void {
|
|
221
242
|
if (activeFocusInitialized) return;
|
|
222
243
|
activeFocusInitialized = true;
|
|
223
|
-
|
|
244
|
+
const presets = focusPresets ?? [];
|
|
245
|
+
|
|
246
|
+
if (persisted.kind === "loaded") {
|
|
247
|
+
const savedId = persisted.activeFocusPresetId;
|
|
248
|
+
if (savedId === null) {
|
|
249
|
+
activeFocusPresetId = null;
|
|
250
|
+
activeFocusInitSource = "cleared";
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (findFocusPresetById(presets, savedId)) {
|
|
254
|
+
activeFocusPresetId = savedId;
|
|
255
|
+
activeFocusInitSource = "restored";
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
activeFocusPresetId = pickDefaultFocusPresetId(presets);
|
|
259
|
+
activeFocusInitSource = "stale";
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
activeFocusPresetId = pickDefaultFocusPresetId(presets);
|
|
264
|
+
activeFocusInitSource = persisted.kind === "malformed" ? "malformed" : "default";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Initializes the active focus preset once per process/session. */
|
|
268
|
+
export function ensureActiveFocusInitialized(focusPresets: FocusPreset[] | undefined): void {
|
|
269
|
+
applyActiveFocusSession(focusPresets, { kind: "missing" });
|
|
224
270
|
}
|
|
225
271
|
|
|
226
272
|
/** Returns the current active focus preset id, if any. */
|
|
@@ -235,6 +281,7 @@ export function setActiveFocusPresetId(id: string, focusPresets: FocusPreset[] |
|
|
|
235
281
|
}
|
|
236
282
|
activeFocusPresetId = id;
|
|
237
283
|
activeFocusInitialized = true;
|
|
284
|
+
clearActiveFocusInitSource();
|
|
238
285
|
resetFocusSkillsWarningState();
|
|
239
286
|
resetDecisionNoteWarningState();
|
|
240
287
|
}
|
|
@@ -315,6 +362,7 @@ export function cycleActiveFocusPresetBackward(focusPresets: FocusPreset[] | und
|
|
|
315
362
|
export function clearActiveFocusPresetId(): void {
|
|
316
363
|
activeFocusPresetId = null;
|
|
317
364
|
activeFocusInitialized = true;
|
|
365
|
+
clearActiveFocusInitSource();
|
|
318
366
|
resetFocusSkillsWarningState();
|
|
319
367
|
resetDecisionNoteWarningState();
|
|
320
368
|
}
|
|
@@ -323,6 +371,7 @@ export function clearActiveFocusPresetId(): void {
|
|
|
323
371
|
export function resetActiveFocusSessionState(): void {
|
|
324
372
|
activeFocusPresetId = null;
|
|
325
373
|
activeFocusInitialized = false;
|
|
374
|
+
activeFocusInitSource = null;
|
|
326
375
|
resetFocusSkillsWarningState();
|
|
327
376
|
resetDecisionNoteWarningState();
|
|
328
377
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { normalizeGuardPath } from "./path-normalize.js";
|
|
4
|
+
|
|
5
|
+
export const FOCUS_SESSION_STATE_RELATIVE_PATH = path.join(".pi", "monofold-focus-session.json");
|
|
6
|
+
|
|
7
|
+
export type FocusSessionStateFile = {
|
|
8
|
+
version: 1;
|
|
9
|
+
activeFocusPresetId: string | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type FocusSessionLoadResult =
|
|
13
|
+
| { kind: "missing" }
|
|
14
|
+
| { kind: "malformed"; error: string }
|
|
15
|
+
| { kind: "loaded"; activeFocusPresetId: string | null };
|
|
16
|
+
|
|
17
|
+
function assertFocusSessionStateFile(value: unknown): asserts value is FocusSessionStateFile {
|
|
18
|
+
if (typeof value !== "object" || value === null) throw new Error("focus session state file must be an object");
|
|
19
|
+
if (!("version" in value) || (value as { version?: unknown }).version !== 1) {
|
|
20
|
+
throw new Error("focus session state file requires version: 1");
|
|
21
|
+
}
|
|
22
|
+
const activeFocusPresetId = (value as { activeFocusPresetId?: unknown }).activeFocusPresetId;
|
|
23
|
+
if (activeFocusPresetId !== null && typeof activeFocusPresetId !== "string") {
|
|
24
|
+
throw new Error("focus session state file requires string|null activeFocusPresetId");
|
|
25
|
+
}
|
|
26
|
+
if (typeof activeFocusPresetId === "string" && activeFocusPresetId.trim() === "") {
|
|
27
|
+
throw new Error("focus session state file activeFocusPresetId must be non-empty when set");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveFocusSessionStatePath(root: string): string {
|
|
32
|
+
return path.join(normalizeGuardPath(root), FOCUS_SESSION_STATE_RELATIVE_PATH);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function loadFocusSessionState(root: string): Promise<FocusSessionLoadResult> {
|
|
36
|
+
const filePath = resolveFocusSessionStatePath(root);
|
|
37
|
+
try {
|
|
38
|
+
const text = await readFile(filePath, "utf8");
|
|
39
|
+
const parsed = JSON.parse(text) as unknown;
|
|
40
|
+
assertFocusSessionStateFile(parsed);
|
|
41
|
+
return { kind: "loaded", activeFocusPresetId: parsed.activeFocusPresetId };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const code = typeof error === "object" && error && "code" in error ? (error as { code?: string }).code : undefined;
|
|
44
|
+
if (code === "ENOENT") return { kind: "missing" };
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
return { kind: "malformed", error: message };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function saveFocusSessionState(root: string, activeFocusPresetId: string | null): Promise<void> {
|
|
51
|
+
const filePath = resolveFocusSessionStatePath(root);
|
|
52
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
53
|
+
const payload: FocusSessionStateFile = {
|
|
54
|
+
version: 1,
|
|
55
|
+
activeFocusPresetId,
|
|
56
|
+
};
|
|
57
|
+
await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
58
|
+
}
|
package/index.ts
CHANGED
|
@@ -6,13 +6,15 @@ import path from "node:path";
|
|
|
6
6
|
import YAML from "yaml";
|
|
7
7
|
import {
|
|
8
8
|
type ActiveFocusPresetPosition,
|
|
9
|
+
applyActiveFocusSession,
|
|
9
10
|
cycleActiveFocusPresetBackward,
|
|
10
11
|
cycleActiveFocusPresetForward,
|
|
11
12
|
type FocusPreset,
|
|
12
|
-
ensureActiveFocusInitialized,
|
|
13
13
|
findFocusPresetById,
|
|
14
|
+
getActiveFocusInitSource,
|
|
14
15
|
getActiveFocusPresetId,
|
|
15
16
|
getActiveFocusPresetPosition,
|
|
17
|
+
isActiveFocusSessionInitialized,
|
|
16
18
|
biasMatchesTowardActiveFocus,
|
|
17
19
|
isTagBasedTargetInference,
|
|
18
20
|
matchesFocusTarget,
|
|
@@ -42,6 +44,11 @@ import {
|
|
|
42
44
|
readMonofoldFile,
|
|
43
45
|
runMonofoldSearch,
|
|
44
46
|
} from "./monofold-read-ops.js";
|
|
47
|
+
import {
|
|
48
|
+
FOCUS_SESSION_STATE_RELATIVE_PATH,
|
|
49
|
+
loadFocusSessionState,
|
|
50
|
+
saveFocusSessionState,
|
|
51
|
+
} from "./focus-session-state.js";
|
|
45
52
|
import {
|
|
46
53
|
clearUnknownPathAllows,
|
|
47
54
|
loadUnknownPathAllows,
|
|
@@ -919,8 +926,16 @@ function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usa
|
|
|
919
926
|
sendCommandOutput(pi, command, `Error: ${message}\n\nUsage:\n${usage}`, { error: message });
|
|
920
927
|
}
|
|
921
928
|
|
|
929
|
+
function formatActiveFocusInitSourceSuffix(): string {
|
|
930
|
+
const source = getActiveFocusInitSource();
|
|
931
|
+
if (source === "restored") return " [restored]";
|
|
932
|
+
if (source === "stale") return " [stale save, using default]";
|
|
933
|
+
if (source === "malformed") return " [using default]";
|
|
934
|
+
return "";
|
|
935
|
+
}
|
|
936
|
+
|
|
922
937
|
function formatFocusStatus(position: ActiveFocusPresetPosition): string {
|
|
923
|
-
const base = `focus: ${position.preset.label} (${position.index + 1}/${position.total}) ${FOCUS_CYCLE_SHORTCUT} / ${FOCUS_CYCLE_BACKWARD_SHORTCUT}`;
|
|
938
|
+
const base = `focus: ${position.preset.label} (${position.index + 1}/${position.total})${formatActiveFocusInitSourceSuffix()} ${FOCUS_CYCLE_SHORTCUT} / ${FOCUS_CYCLE_BACKWARD_SHORTCUT}`;
|
|
924
939
|
const route = position.preset.defaultRouteOverride ? ` route:${position.preset.defaultRouteOverride}` : "";
|
|
925
940
|
const note = position.preset.decisionNoteDestination
|
|
926
941
|
? ` note:${position.preset.decisionNoteDestination.path}`
|
|
@@ -928,6 +943,38 @@ function formatFocusStatus(position: ActiveFocusPresetPosition): string {
|
|
|
928
943
|
return `${base}${route}${note}`;
|
|
929
944
|
}
|
|
930
945
|
|
|
946
|
+
function notifyActiveFocusInitSource(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig): void {
|
|
947
|
+
if (!ctx.hasUI) return;
|
|
948
|
+
const source = getActiveFocusInitSource();
|
|
949
|
+
if (source === "malformed") {
|
|
950
|
+
ctx.ui.notify(
|
|
951
|
+
`Ignored malformed focus session state at ${FOCUS_SESSION_STATE_RELATIVE_PATH}; using default Active Focus.`,
|
|
952
|
+
"warning",
|
|
953
|
+
);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (source === "stale") {
|
|
957
|
+
ctx.ui.notify("Saved Active Focus preset is no longer valid; using default.", "warning");
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (source === "restored") {
|
|
961
|
+
const position = getActiveFocusPresetPosition(loaded.raw.focusPresets);
|
|
962
|
+
if (position) {
|
|
963
|
+
ctx.ui.notify(`Restored Active Focus: ${position.preset.label} (${position.index + 1}/${position.total})`, "info");
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function ensureActiveFocusReady(loaded: LoadedConfig): Promise<void> {
|
|
969
|
+
if (isActiveFocusSessionInitialized()) return;
|
|
970
|
+
const persisted = await loadFocusSessionState(loaded.root);
|
|
971
|
+
applyActiveFocusSession(loaded.raw.focusPresets, persisted);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function persistActiveFocusSession(root: string): Promise<void> {
|
|
975
|
+
await saveFocusSessionState(root, getActiveFocusPresetId());
|
|
976
|
+
}
|
|
977
|
+
|
|
931
978
|
function updateFocusStatus(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig): void {
|
|
932
979
|
if (!ctx.hasUI) return;
|
|
933
980
|
const position = getActiveFocusPresetPosition(loaded.raw.focusPresets);
|
|
@@ -969,7 +1016,7 @@ function biasWorkspaceMatchesWithActiveFocus(loaded: LoadedConfig, matches: Reso
|
|
|
969
1016
|
}
|
|
970
1017
|
|
|
971
1018
|
async function resolveWorkspace(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig, target: TargetInput): Promise<ResolvedWorkspace> {
|
|
972
|
-
|
|
1019
|
+
await ensureActiveFocusReady(loaded);
|
|
973
1020
|
let matches = loaded.workspaces.filter((workspace) => matchesTarget(workspace, target));
|
|
974
1021
|
if (matches.length === 0) throw new Error(`No workspace matches target: ${JSON.stringify(target)}`);
|
|
975
1022
|
if (matches.length > 1 && isTagBasedTargetInference(target)) {
|
|
@@ -1137,12 +1184,14 @@ async function buildManifest(
|
|
|
1137
1184
|
loaded: LoadedConfig,
|
|
1138
1185
|
options?: { skills?: import("@earendil-works/pi-coding-agent").Skill[] },
|
|
1139
1186
|
): Promise<string> {
|
|
1140
|
-
|
|
1187
|
+
await ensureActiveFocusReady(loaded);
|
|
1141
1188
|
const lines = ["Pi Monofold Manifest:"];
|
|
1142
1189
|
const activePreset = getActiveFocusPreset(loaded);
|
|
1143
1190
|
const position = getActiveFocusPresetPosition(loaded.raw.focusPresets);
|
|
1144
1191
|
if (activePreset && position) {
|
|
1145
|
-
lines.push(
|
|
1192
|
+
lines.push(
|
|
1193
|
+
`Active Focus: ${activePreset.label} (${activePreset.id}, ${position.index + 1}/${position.total})${formatActiveFocusInitSourceSuffix()}`,
|
|
1194
|
+
);
|
|
1146
1195
|
if (activePreset.defaultRouteOverride) {
|
|
1147
1196
|
lines.push(`Default write route override: ${activePreset.defaultRouteOverride} (explicit routeType/--route still wins)`);
|
|
1148
1197
|
}
|
|
@@ -1443,8 +1492,9 @@ export default function piMultiWorkspace(pi: ExtensionAPI) {
|
|
|
1443
1492
|
pi.on("session_start", async (_event, ctx) => {
|
|
1444
1493
|
try {
|
|
1445
1494
|
const loaded = await loadConfig(ctx.cwd);
|
|
1446
|
-
|
|
1495
|
+
await ensureActiveFocusReady(loaded);
|
|
1447
1496
|
updateFocusStatus(ctx, loaded);
|
|
1497
|
+
notifyActiveFocusInitSource(ctx, loaded);
|
|
1448
1498
|
const activeId = getActiveFocusPresetId();
|
|
1449
1499
|
if (!activeId) return;
|
|
1450
1500
|
const preset = findFocusPresetById(loaded.raw.focusPresets, activeId);
|
|
@@ -1459,7 +1509,7 @@ export default function piMultiWorkspace(pi: ExtensionAPI) {
|
|
|
1459
1509
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
1460
1510
|
try {
|
|
1461
1511
|
const loaded = await loadConfig(ctx.cwd);
|
|
1462
|
-
|
|
1512
|
+
await ensureActiveFocusReady(loaded);
|
|
1463
1513
|
const manifest = await buildManifest(loaded, { skills: _event.systemPromptOptions?.skills });
|
|
1464
1514
|
const activePreset = getActiveFocusPreset(loaded);
|
|
1465
1515
|
const focusInjection = activePreset ? await buildFocusContextInjection(loaded, activePreset) : undefined;
|
|
@@ -2067,7 +2117,7 @@ ${focusInjection.text}` : ""}
|
|
|
2067
2117
|
try {
|
|
2068
2118
|
const loaded = await loadConfig(ctx.cwd);
|
|
2069
2119
|
const focusPresets = loaded.raw.focusPresets ?? [];
|
|
2070
|
-
|
|
2120
|
+
await ensureActiveFocusReady(loaded);
|
|
2071
2121
|
updateFocusStatus(ctx, loaded);
|
|
2072
2122
|
|
|
2073
2123
|
if (focusPresets.length === 0) {
|
|
@@ -2085,6 +2135,7 @@ ${focusInjection.text}` : ""}
|
|
|
2085
2135
|
if (focusPresets.length === 1) {
|
|
2086
2136
|
const preset = focusPresets[0]!;
|
|
2087
2137
|
setActiveFocusPresetId(preset.id, focusPresets);
|
|
2138
|
+
await persistActiveFocusSession(ctx.cwd);
|
|
2088
2139
|
await notifyFocusApplied(ctx, loaded, { preset, index: 0, total: 1 }, "command");
|
|
2089
2140
|
return;
|
|
2090
2141
|
}
|
|
@@ -2095,6 +2146,7 @@ ${focusInjection.text}` : ""}
|
|
|
2095
2146
|
return;
|
|
2096
2147
|
}
|
|
2097
2148
|
const position = setActiveFocusPresetByLabel(choice, focusPresets);
|
|
2149
|
+
await persistActiveFocusSession(ctx.cwd);
|
|
2098
2150
|
await notifyFocusApplied(ctx, loaded, position, "command");
|
|
2099
2151
|
} catch (error) {
|
|
2100
2152
|
sendCommandError(pi, "monofold:focus", error, "/monofold:focus");
|
|
@@ -2109,8 +2161,10 @@ ${focusInjection.text}` : ""}
|
|
|
2109
2161
|
notifyNoFocusPresets(ctx);
|
|
2110
2162
|
return;
|
|
2111
2163
|
}
|
|
2164
|
+
await ensureActiveFocusReady(loaded);
|
|
2112
2165
|
const result = cycleActiveFocusPresetForward(focusPresets);
|
|
2113
2166
|
if (!result) return;
|
|
2167
|
+
await persistActiveFocusSession(ctx.cwd);
|
|
2114
2168
|
updateFocusStatus(ctx, loaded);
|
|
2115
2169
|
await warnActiveFocusPresetIssues(result.preset, loaded, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
|
|
2116
2170
|
if (focusPresets.length === 1) {
|
|
@@ -2132,8 +2186,10 @@ ${focusInjection.text}` : ""}
|
|
|
2132
2186
|
notifyNoFocusPresets(ctx);
|
|
2133
2187
|
return;
|
|
2134
2188
|
}
|
|
2189
|
+
await ensureActiveFocusReady(loaded);
|
|
2135
2190
|
const result = cycleActiveFocusPresetBackward(focusPresets);
|
|
2136
2191
|
if (!result) return;
|
|
2192
|
+
await persistActiveFocusSession(ctx.cwd);
|
|
2137
2193
|
updateFocusStatus(ctx, loaded);
|
|
2138
2194
|
await warnActiveFocusPresetIssues(result.preset, loaded, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
|
|
2139
2195
|
if (focusPresets.length === 1) {
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
|
|
14
14
|
"type": "module",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.11.0",
|
|
16
16
|
"pi": {
|
|
17
17
|
"extensions": [
|
|
18
18
|
"./index.ts"
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"focus-route-override.ts",
|
|
30
30
|
"focus-skills.ts",
|
|
31
31
|
"focus-decision-note.ts",
|
|
32
|
+
"focus-session-state.ts",
|
|
32
33
|
"monofold-read-ops.ts",
|
|
33
34
|
"read-caps.ts",
|
|
34
35
|
"validation.ts"
|