pi-monofold 0.7.0 → 0.9.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 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` or `ctrl+shift+m` 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.
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).
@@ -100,11 +100,12 @@ Example command flows: [docs/examples.md](./docs/examples.md).
100
100
  | `/monofold:config` | Add or change workspaces and project workspaces |
101
101
  | `/monofold:git` | Run guarded git status, commit, push, or commit+push |
102
102
  | `/monofold:focus` | Select the active focus preset from a TUI list |
103
+ | `/monofold:focus-prev` | Cycle Active Focus backward through `focusPresets` YAML order |
103
104
  | `/monofold:guide` | Interactive guide for common flows |
104
105
  | `/monofold:init` | Create or update `.pi/monofold.yaml` |
105
106
  | `/monofold:update` | Migrate legacy config and optionally request config edits |
106
107
 
107
- Default focus shortcut: `ctrl+shift+m` cycles Active Focus forward through `focusPresets` YAML order. No backward focus shortcut ships in the MVP.
108
+ Default focus shortcuts: `ctrl+shift+m` cycles Active Focus forward and `shift+ctrl+f` cycles backward through `focusPresets` YAML order. Both wrap at the start/end of the list.
108
109
 
109
110
  When Active Focus is set, Pi Monofold injects the active preset's `contextFiles` into each agent turn under **Focus Context Injection** and recomposes the manifest so active Workspace Targets are shown first while non-active targets are collapsed to one-line summaries. Tag-based target inference in `monofold_read`, `monofold_write`, and `monofold_git` also prefers Workspace Targets that belong to the active preset when a tag query would otherwise match multiple candidates; explicit `targetId` / workspace name selectors and uniquely matching targets are unchanged. If multiple in-focus targets still tie, the existing workspace selection flow applies. The MVP uses provisional context-injection caps that are intentionally temporary and exposed as constants for future tuning:
110
111
 
package/focus-preset.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type MonofoldRouteType, parseDefaultRouteOverride } from "./focus-route-override.js";
1
2
  import { parseFocusSkills, resetFocusSkillsWarningState } from "./focus-skills.js";
2
3
  import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
3
4
 
@@ -10,13 +11,14 @@ export type FocusPreset = {
10
11
  label: string;
11
12
  targets: FocusPresetTarget[];
12
13
  focusSkills?: string[];
14
+ defaultRouteOverride?: MonofoldRouteType;
13
15
  };
14
16
 
15
17
  export type FocusMatchableWorkspace = {
16
18
  tags: string[];
17
19
  };
18
20
 
19
- const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets", "focusSkills"]);
21
+ const FOCUS_PRESET_KEYS = new Set(["id", "label", "targets", "focusSkills", "defaultRouteOverride"]);
20
22
  const FOCUS_PRESET_TARGET_KEYS = new Set(["targetTags"]);
21
23
 
22
24
  /** Parses and validates focus preset configuration from YAML/JSON input. */
@@ -52,6 +54,7 @@ export function parseFocusPresets(value: unknown, label = "focusPresets"): Focus
52
54
  targets.push({ targetTags });
53
55
  }
54
56
  const focusSkills = parseFocusSkills(itemLabel, item.focusSkills);
57
+ const defaultRouteOverride = parseDefaultRouteOverride(itemLabel, item.defaultRouteOverride);
55
58
  if (seenIds.has(item.id)) throw new Error(`${label} has duplicate preset id: ${item.id}`);
56
59
  seenIds.add(item.id);
57
60
  presets.push({
@@ -59,6 +62,7 @@ export function parseFocusPresets(value: unknown, label = "focusPresets"): Focus
59
62
  label: item.label,
60
63
  targets,
61
64
  ...(focusSkills !== undefined ? { focusSkills } : {}),
65
+ ...(defaultRouteOverride !== undefined ? { defaultRouteOverride } : {}),
62
66
  });
63
67
  }
64
68
  return presets;
@@ -182,6 +186,25 @@ export function cycleActiveFocusPresetForward(focusPresets: FocusPreset[] | unde
182
186
  return { preset, index: nextIndex, total: presets.length, changed };
183
187
  }
184
188
 
189
+ /** Cycles the active focus preset backward in YAML order. */
190
+ export function cycleActiveFocusPresetBackward(focusPresets: FocusPreset[] | undefined): FocusCycleResult | null {
191
+ const presets = focusPresets ?? [];
192
+ if (presets.length === 0) {
193
+ clearActiveFocusPresetId();
194
+ return null;
195
+ }
196
+
197
+ ensureActiveFocusInitialized(presets);
198
+ const currentId = getActiveFocusPresetId();
199
+ const currentIndex = currentId ? presets.findIndex((preset) => preset.id === currentId) : -1;
200
+ const nextIndex =
201
+ currentIndex < 0 ? presets.length - 1 : (currentIndex - 1 + presets.length) % presets.length;
202
+ const preset = presets[nextIndex]!;
203
+ const changed = presets.length > 1 && preset.id !== currentId;
204
+ setActiveFocusPresetId(preset.id, presets);
205
+ return { preset, index: nextIndex, total: presets.length, changed };
206
+ }
207
+
185
208
  /** Clears the active focus preset for the current process/session. */
186
209
  export function clearActiveFocusPresetId(): void {
187
210
  activeFocusPresetId = null;
@@ -0,0 +1,44 @@
1
+ /** Monofold route types available for routed Markdown writes. */
2
+ export const MONOFOLD_ROUTE_TYPES = [
3
+ "default",
4
+ "prd",
5
+ "design",
6
+ "progress",
7
+ "issue",
8
+ "research",
9
+ "decision",
10
+ ] as const;
11
+
12
+ export type MonofoldRouteType = (typeof MONOFOLD_ROUTE_TYPES)[number];
13
+
14
+ const ROUTE_TYPE_SET = new Set<string>(MONOFOLD_ROUTE_TYPES);
15
+
16
+ /** Validates a route type string and returns the typed value. */
17
+ export function assertMonofoldRouteType(label: string, value: string): MonofoldRouteType {
18
+ if (!ROUTE_TYPE_SET.has(value)) {
19
+ throw new Error(
20
+ `${label} must be one of: ${MONOFOLD_ROUTE_TYPES.join(", ")} (got: ${value})`,
21
+ );
22
+ }
23
+ return value as MonofoldRouteType;
24
+ }
25
+
26
+ /** Parses an optional defaultRouteOverride from preset config. */
27
+ export function parseDefaultRouteOverride(itemLabel: string, value: unknown): MonofoldRouteType | undefined {
28
+ if (value === undefined) return undefined;
29
+ if (typeof value !== "string" || value.trim() === "") {
30
+ throw new Error(`${itemLabel}.defaultRouteOverride must be a non-empty string`);
31
+ }
32
+ return assertMonofoldRouteType(`${itemLabel}.defaultRouteOverride`, value.trim());
33
+ }
34
+
35
+ /** Resolves the effective write route: explicit selection wins over Focus default, then "default". */
36
+ export function resolveWriteRouteType(
37
+ explicitRoute: string | undefined,
38
+ defaultRouteOverride: MonofoldRouteType | undefined,
39
+ ): MonofoldRouteType {
40
+ if (explicitRoute !== undefined && explicitRoute !== "") {
41
+ return assertMonofoldRouteType("routeType", explicitRoute);
42
+ }
43
+ return defaultRouteOverride ?? "default";
44
+ }
package/index.ts CHANGED
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import YAML from "yaml";
7
7
  import {
8
8
  type ActiveFocusPresetPosition,
9
+ cycleActiveFocusPresetBackward,
9
10
  cycleActiveFocusPresetForward,
10
11
  type FocusPreset,
11
12
  ensureActiveFocusInitialized,
@@ -20,6 +21,10 @@ import {
20
21
  setActiveFocusPresetId,
21
22
  warnZeroTargetMatchesForPreset,
22
23
  } from "./focus-preset.js";
24
+ import {
25
+ resolveWriteRouteType,
26
+ type MonofoldRouteType,
27
+ } from "./focus-route-override.js";
23
28
  import {
24
29
  applyFocusSkillsToSystemPrompt,
25
30
  warnMissingFocusSkills,
@@ -41,7 +46,7 @@ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./valid
41
46
  type CapabilityTag = "read" | "writeDocs" | "editCode" | "runCommands" | "git";
42
47
  type LegacyCapabilityTag = CapabilityTag | "gitCommit" | "gitPush";
43
48
  type IntentCategory = "Explore" | "Write" | "Config" | "Git";
44
- type RouteType = "default" | "prd" | "design" | "progress" | "issue" | "research" | "decision";
49
+ type RouteType = MonofoldRouteType;
45
50
 
46
51
  type RouteConfig = {
47
52
  path: string;
@@ -177,6 +182,8 @@ const ROUTE_KEYS = new Set(["path", "filenameTemplate", "metadata"]);
177
182
  const FOCUS_STATUS_ID = "monofold-focus";
178
183
  const FOCUS_CYCLE_SHORTCUT = "ctrl+shift+m";
179
184
  const FOCUS_CYCLE_ACTION_ID = "app.monofold.focus.cycleForward";
185
+ const FOCUS_CYCLE_BACKWARD_SHORTCUT = "shift+ctrl+f";
186
+ const FOCUS_CYCLE_BACKWARD_ACTION_ID = "app.monofold.focus.cycleBackward";
180
187
  export const FOCUS_CONTEXT_MAX_FILES = 6;
181
188
  export const FOCUS_CONTEXT_MAX_CHARS_PER_FILE = 6_000;
182
189
  export const FOCUS_CONTEXT_MAX_TOTAL_CHARS = 12_000;
@@ -892,7 +899,11 @@ function sendCommandError(pi: ExtensionAPI, command: string, error: unknown, usa
892
899
  }
893
900
 
894
901
  function formatFocusStatus(position: ActiveFocusPresetPosition): string {
895
- return `focus: ${position.preset.label} (${position.index + 1}/${position.total}) ${FOCUS_CYCLE_SHORTCUT}`;
902
+ const base = `focus: ${position.preset.label} (${position.index + 1}/${position.total}) ${FOCUS_CYCLE_SHORTCUT} / ${FOCUS_CYCLE_BACKWARD_SHORTCUT}`;
903
+ if (position.preset.defaultRouteOverride) {
904
+ return `${base} route:${position.preset.defaultRouteOverride}`;
905
+ }
906
+ return base;
896
907
  }
897
908
 
898
909
  function updateFocusStatus(ctx: ExtensionContext | ExtensionCommandContext, loaded: LoadedConfig): void {
@@ -1026,6 +1037,9 @@ async function buildManifest(loaded: LoadedConfig): Promise<string> {
1026
1037
  const activeTargetIds = new Set(activeWorkspaces.map(({ workspace }) => workspace.targetId));
1027
1038
  if (activeWorkspaces.length > 0) {
1028
1039
  lines.push(`Active Focus: ${activePreset.label} (${activePreset.id})`);
1040
+ if (activePreset.defaultRouteOverride) {
1041
+ lines.push(`Default write route override: ${activePreset.defaultRouteOverride} (explicit routeType/--route still wins)`);
1042
+ }
1029
1043
  for (const { workspace } of activeWorkspaces) {
1030
1044
  lines.push(await buildFullWorkspaceManifestEntry(workspace, " (active)"));
1031
1045
  }
@@ -1046,6 +1060,9 @@ async function buildManifest(loaded: LoadedConfig): Promise<string> {
1046
1060
  async function buildFocusContextInjection(loaded: LoadedConfig, preset: FocusPreset): Promise<FocusContextInjection> {
1047
1061
  const activeWorkspaces = getActiveFocusWorkspaces(loaded, preset);
1048
1062
  const lines = ["## Focus Context Injection", "", `Active Focus: ${preset.label} (${preset.id})`];
1063
+ if (preset.defaultRouteOverride) {
1064
+ lines.push(`Default write route override: ${preset.defaultRouteOverride} (explicit routeType/--route still wins)`);
1065
+ }
1049
1066
  const notices: string[] = [];
1050
1067
  const seenFiles = new Set<string>();
1051
1068
  let injectedFileCount = 0;
@@ -1386,7 +1403,7 @@ ${focusInjection.text}` : ""}
1386
1403
  label: "Workspace Write",
1387
1404
  description: "Write a Markdown document to a routed Workspace destination using routeType, title, body, filename, and metadata.",
1388
1405
  parameters: Type.Object({
1389
- routeType: Type.String({ description: "default, prd, design, progress, issue, research, or decision" }),
1406
+ routeType: Type.Optional(Type.String({ description: "default, prd, design, progress, issue, research, or decision; omitted uses active Focus defaultRouteOverride or default" })),
1390
1407
  title: Type.String(),
1391
1408
  body: Type.String(),
1392
1409
  filename: Type.Optional(Type.String()),
@@ -1397,9 +1414,9 @@ ${focusInjection.text}` : ""}
1397
1414
  workspaceName: Type.Optional(Type.String()),
1398
1415
  }),
1399
1416
  async execute(_id, params, _signal, _onUpdate, ctx) {
1400
- const routeType = params.routeType as RouteType;
1401
- if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${params.routeType}`);
1402
1417
  const loaded = await loadConfig(ctx.cwd);
1418
+ const activePreset = getActiveFocusPreset(loaded);
1419
+ const routeType = resolveWriteRouteType(params.routeType, activePreset?.defaultRouteOverride);
1403
1420
  const workspace = await resolveWorkspace(ctx, loaded, {
1404
1421
  targetTags: params.targetTags,
1405
1422
  targetName: params.targetName,
@@ -1613,14 +1630,15 @@ ${focusInjection.text}` : ""}
1613
1630
  const writeCommand = async (args: string, ctx: ExtensionCommandContext) => {
1614
1631
  try {
1615
1632
  const parsed = parseCommandArgs(args);
1616
- const routeType = (stringFlag(parsed.flags, "route", "r") ?? parsed.positional[0] ?? "default") as RouteType;
1617
- if (!ROUTE_TYPES.includes(routeType)) throw new Error(`Unknown routeType: ${routeType}`);
1618
1633
  const title = stringFlag(parsed.flags, "title", "t");
1619
1634
  const body = stringFlag(parsed.flags, "body", "b");
1620
1635
  if (!title) throw new Error("--title is required");
1621
1636
  if (!body) throw new Error("--body is required");
1622
1637
 
1623
1638
  const loaded = await loadConfig(ctx.cwd);
1639
+ const activePreset = getActiveFocusPreset(loaded);
1640
+ const explicitRoute = stringFlag(parsed.flags, "route", "r") ?? parsed.positional[0];
1641
+ const routeType = resolveWriteRouteType(explicitRoute, activePreset?.defaultRouteOverride);
1624
1642
  const workspace = await resolveWorkspace(ctx, loaded, commandTarget(parsed.flags, ["writeDocs"]));
1625
1643
  const route = workspace.normalizedRoutes[routeType] ?? workspace.normalizedRoutes.default;
1626
1644
  if (!route) throw new Error(`Workspace has no route for ${routeType} and no default route`);
@@ -1887,11 +1905,38 @@ ${focusInjection.text}` : ""}
1887
1905
  }
1888
1906
  };
1889
1907
 
1908
+ const cycleFocusBackward = async (ctx: ExtensionContext) => {
1909
+ try {
1910
+ const loaded = await loadConfig(ctx.cwd);
1911
+ const focusPresets = loaded.raw.focusPresets ?? [];
1912
+ if (focusPresets.length === 0) {
1913
+ notifyNoFocusPresets(ctx);
1914
+ return;
1915
+ }
1916
+ const result = cycleActiveFocusPresetBackward(focusPresets);
1917
+ if (!result) return;
1918
+ updateFocusStatus(ctx, loaded);
1919
+ warnZeroTargetMatchesForPreset(result.preset, loaded.workspaces, (message) => ctx.ui.notify(message, "warning"));
1920
+ if (focusPresets.length === 1) {
1921
+ ctx.ui.notify(`Active Focus unchanged: ${result.preset.label} (1/1)`, "info");
1922
+ return;
1923
+ }
1924
+ ctx.ui.notify(`Active Focus: ${result.preset.label} (${result.index + 1}/${result.total})`, "info");
1925
+ } catch (error) {
1926
+ const message = error instanceof Error ? error.message : String(error);
1927
+ ctx.ui.notify(`Monofold focus cycle failed: ${message}`, "error");
1928
+ }
1929
+ };
1930
+
1890
1931
  pi.registerCommand("monofold:explore", { description: "Explore configured workspaces via natural-language handoff", handler: intentCommand("Explore") });
1891
1932
  pi.registerCommand("monofold:write", { description: "Create routed Markdown via natural-language handoff", handler: intentCommand("Write") });
1892
1933
  pi.registerCommand("monofold:config", { description: "Change Workspace configuration via natural-language handoff", handler: intentCommand("Config") });
1893
1934
  pi.registerCommand("monofold:git", { description: "Run workspace git workflows via natural-language handoff", handler: intentCommand("Git") });
1894
1935
  pi.registerCommand("monofold:focus", { description: "Select the active Monofold focus preset from a TUI list", handler: focusCommand });
1936
+ pi.registerCommand("monofold:focus-prev", {
1937
+ description: "Cycle the active Monofold focus preset backward through focusPresets YAML order",
1938
+ handler: (_args, ctx) => cycleFocusBackward(ctx),
1939
+ });
1895
1940
  pi.registerCommand("monofold:guide", { description: "Conversational guide for Pi Monofold workflows", handler: guideCommand });
1896
1941
  pi.registerCommand("monofold:update", { description: `Migrate and validate ${CONFIG_RELATIVE_PATH}`, handler: updateCommand });
1897
1942
  pi.registerCommand("monofold:clear-unknown-path-allows", {
@@ -1904,6 +1949,11 @@ ${focusInjection.text}` : ""}
1904
1949
  handler: cycleFocusForward,
1905
1950
  });
1906
1951
 
1952
+ pi.registerShortcut(FOCUS_CYCLE_BACKWARD_SHORTCUT, {
1953
+ description: `${FOCUS_CYCLE_BACKWARD_ACTION_ID}: cycle active Monofold focus preset backward`,
1954
+ handler: cycleFocusBackward,
1955
+ });
1956
+
1907
1957
 
1908
1958
  pi.registerCommand("monofold:list", { description: "List configured Pi Monofold workspaces (legacy)", handler: listCommand });
1909
1959
 
package/package.json CHANGED
@@ -1,64 +1,66 @@
1
- {
2
- "scripts": {
3
- "typecheck": "tsc --noEmit",
4
- "test": "node --import tsx --test tests/**/*.test.ts",
5
- "check": "npm run typecheck && npm test && npm pack --dry-run"
6
- },
7
- "peerDependencies": {
8
- "typebox": "*",
9
- "@earendil-works/pi-coding-agent": "*",
10
- "@earendil-works/pi-ai": "*"
11
- },
12
- "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
13
- "type": "module",
14
- "version": "0.7.0",
15
- "pi": {
16
- "extensions": [
17
- "./index.ts"
18
- ]
19
- },
20
- "files": [
21
- "README.md",
22
- "LICENSE",
23
- "index.ts",
24
- "path-normalize.ts",
25
- "unknown-path-allows.ts",
26
- "file-read-preview.ts",
27
- "focus-preset.ts",
28
- "focus-skills.ts",
29
- "monofold-read-ops.ts",
30
- "read-caps.ts",
31
- "validation.ts"
32
- ],
33
- "name": "pi-monofold",
34
- "devDependencies": {
35
- "typescript": "^6.0.3",
36
- "typebox": "^1.1.38",
37
- "@types/node": "^25.9.1",
38
- "tsx": "^4.20.5",
39
- "@earendil-works/pi-coding-agent": "^0.75.4",
40
- "@earendil-works/pi-ai": "^0.75.4"
41
- },
42
- "dependencies": {
43
- "yaml": "^2.8.1"
44
- },
45
- "keywords": [
46
- "pi-package",
47
- "pi-extension",
48
- "ai-coding",
49
- "virtual-monorepo",
50
- "multi-repo"
51
- ],
52
- "repository": {
53
- "type": "git",
54
- "url": "git+https://github.com/eiei114/pi-monofold.git"
55
- },
56
- "bugs": {
57
- "url": "https://github.com/eiei114/pi-monofold/issues"
58
- },
59
- "homepage": "https://github.com/eiei114/pi-monofold#readme",
60
- "publishConfig": {
61
- "access": "public"
62
- },
63
- "license": "MIT"
64
- }
1
+ {
2
+ "scripts": {
3
+ "typecheck": "tsc --noEmit",
4
+ "test": "node --import tsx --test tests/**/*.test.ts",
5
+ "check": "npm run typecheck && npm test && npm pack --dry-run",
6
+ "version:check": "node scripts/check-version-bump.mjs"
7
+ },
8
+ "peerDependencies": {
9
+ "typebox": "*",
10
+ "@earendil-works/pi-coding-agent": "*",
11
+ "@earendil-works/pi-ai": "*"
12
+ },
13
+ "description": "Pi extension that folds multiple repositories and folders into a guarded virtual monorepo for AI agents.",
14
+ "type": "module",
15
+ "version": "0.9.0",
16
+ "pi": {
17
+ "extensions": [
18
+ "./index.ts"
19
+ ]
20
+ },
21
+ "files": [
22
+ "README.md",
23
+ "LICENSE",
24
+ "index.ts",
25
+ "path-normalize.ts",
26
+ "unknown-path-allows.ts",
27
+ "file-read-preview.ts",
28
+ "focus-preset.ts",
29
+ "focus-route-override.ts",
30
+ "focus-skills.ts",
31
+ "monofold-read-ops.ts",
32
+ "read-caps.ts",
33
+ "validation.ts"
34
+ ],
35
+ "name": "pi-monofold",
36
+ "devDependencies": {
37
+ "typescript": "^6.0.3",
38
+ "typebox": "^1.1.38",
39
+ "@types/node": "^25.9.1",
40
+ "tsx": "^4.20.5",
41
+ "@earendil-works/pi-coding-agent": "^0.75.4",
42
+ "@earendil-works/pi-ai": "^0.75.4"
43
+ },
44
+ "dependencies": {
45
+ "yaml": "^2.8.1"
46
+ },
47
+ "keywords": [
48
+ "pi-package",
49
+ "pi-extension",
50
+ "ai-coding",
51
+ "virtual-monorepo",
52
+ "multi-repo"
53
+ ],
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/eiei114/pi-monofold.git"
57
+ },
58
+ "bugs": {
59
+ "url": "https://github.com/eiei114/pi-monofold/issues"
60
+ },
61
+ "homepage": "https://github.com/eiei114/pi-monofold#readme",
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "license": "MIT"
66
+ }