pi-monofold 0.8.0 → 0.10.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
 
@@ -112,7 +113,7 @@ When Active Focus is set, Pi Monofold injects the active preset's `contextFiles`
112
113
  - Max **6,000** characters per file, with `… [truncated]` appended when a file is cut.
113
114
  - Max **12,000** injected file-content characters per turn; remaining files are skipped and a warning is surfaced once for that turn.
114
115
 
115
- Agent tools (`monofold_list`, `monofold_read`, `monofold_write`, `monofold_git`, `monofold_init`) sit behind these commands. Full reference: [docs/usage.md](./docs/usage.md).
116
+ Agent tools (`monofold_list`, `monofold_read`, `monofold_write`, `monofold_git`, `monofold_init`) sit behind these commands. Use `monofold_list` as the first-line Active Focus health check (preset, route override, unresolved targets, and warnings). Full reference: [docs/usage.md](./docs/usage.md).
116
117
 
117
118
  ## Safe read defaults
118
119
 
@@ -0,0 +1,122 @@
1
+ import { assertKnownKeys, asStringArray, isRecord, uniqueStrings } from "./validation.js";
2
+
3
+ type TagMatchableWorkspace = {
4
+ tags: string[];
5
+ };
6
+
7
+ export type FocusDecisionNoteDestination = {
8
+ targetTags: string[];
9
+ path: string;
10
+ };
11
+
12
+ export type FocusDecisionNoteValidationWorkspace = TagMatchableWorkspace & {
13
+ targetId: string;
14
+ name?: string;
15
+ capabilities: readonly string[];
16
+ };
17
+
18
+ const DECISION_NOTE_DESTINATION_KEYS = new Set(["targetTags", "path"]);
19
+
20
+ /** Parses and validates an optional decisionNoteDestination from preset config. */
21
+ export function parseDecisionNoteDestination(itemLabel: string, value: unknown): FocusDecisionNoteDestination | undefined {
22
+ if (value === undefined) return undefined;
23
+ const destLabel = `${itemLabel}.decisionNoteDestination`;
24
+ if (!isRecord(value)) throw new Error(`${destLabel} must be an object`);
25
+ assertKnownKeys(destLabel, value, DECISION_NOTE_DESTINATION_KEYS);
26
+ const targetTags = uniqueStrings(asStringArray(`${destLabel}.targetTags`, value.targetTags));
27
+ if (targetTags.length === 0) {
28
+ throw new Error(`${destLabel}.targetTags must contain at least one non-empty string`);
29
+ }
30
+ if (typeof value.path !== "string" || value.path.trim() === "") {
31
+ throw new Error(`${destLabel}.path must be a non-empty string`);
32
+ }
33
+ assertWorkspaceInternalRelative(`${destLabel}.path`, value.path.trim());
34
+ return { targetTags, path: value.path.trim() };
35
+ }
36
+
37
+ function assertWorkspaceInternalRelative(label: string, value: string): void {
38
+ const normalized = value.replace(/\\/g, "/");
39
+ if (normalized.startsWith("/") || normalized.split("/").includes("..")) {
40
+ throw new Error(`${label} must be a workspace-internal relative path: ${value}`);
41
+ }
42
+ }
43
+
44
+ /** Formats a decision-note destination for manifest and status output. */
45
+ export function formatDecisionNoteDestinationLabel(
46
+ workspaceLabel: string,
47
+ destination: FocusDecisionNoteDestination,
48
+ ): string {
49
+ return `${workspaceLabel}:${destination.path}`;
50
+ }
51
+
52
+ function matchesTargetTags(workspace: TagMatchableWorkspace, targetTags: string[]): boolean {
53
+ if (targetTags.length === 0) return false;
54
+ return targetTags.every((tag) => workspace.tags.includes(tag));
55
+ }
56
+
57
+ /** Validates a preset decision-note destination against configured workspaces. */
58
+ export function validateDecisionNoteDestinationAgainstWorkspaces(
59
+ presetLabel: string,
60
+ destination: FocusDecisionNoteDestination,
61
+ workspaces: FocusDecisionNoteValidationWorkspace[],
62
+ ): void {
63
+ const destLabel = `${presetLabel}.decisionNoteDestination`;
64
+ const matches = workspaces.filter((workspace) => matchesTargetTags(workspace, destination.targetTags));
65
+ if (matches.length === 0) {
66
+ throw new Error(
67
+ `${destLabel}.targetTags [${destination.targetTags.join(", ")}] matches no workspace target`,
68
+ );
69
+ }
70
+ if (matches.length > 1) {
71
+ const labels = matches
72
+ .map((workspace) => (workspace.name ? `${workspace.targetId} (${workspace.name})` : workspace.targetId))
73
+ .join(", ");
74
+ throw new Error(
75
+ `${destLabel}.targetTags [${destination.targetTags.join(", ")}] is ambiguous across workspaces: ${labels}`,
76
+ );
77
+ }
78
+ const workspace = matches[0]!;
79
+ if (!workspace.capabilities.includes("read")) {
80
+ throw new Error(
81
+ `${destLabel} requires read on ${workspace.targetId}, but matched capabilities [${workspace.capabilities.join(", ")}]`,
82
+ );
83
+ }
84
+ }
85
+
86
+ /** Finds the single workspace that matches a decision-note destination, if any. */
87
+ export function findDecisionNoteWorkspace<T extends FocusDecisionNoteValidationWorkspace>(
88
+ workspaces: T[],
89
+ destination: FocusDecisionNoteDestination,
90
+ ): T | undefined {
91
+ const matches = workspaces.filter((workspace) => matchesTargetTags(workspace, destination.targetTags));
92
+ return matches.length === 1 ? matches[0] : undefined;
93
+ }
94
+
95
+ let warnedDecisionNoteKey: string | null = null;
96
+
97
+ /** Clears per-activation decision-note warning deduplication (for tests and focus changes). */
98
+ export function resetDecisionNoteWarningState(): void {
99
+ warnedDecisionNoteKey = null;
100
+ }
101
+
102
+ /** Emits one actionable warning per focus activation for unavailable decision-note destinations. */
103
+ export function warnUnavailableDecisionNoteDestination(
104
+ presetId: string,
105
+ destination: FocusDecisionNoteDestination,
106
+ workspaceLabel: string,
107
+ reason: "missing-workspace" | "missing-file",
108
+ warn: (message: string) => void,
109
+ ): void {
110
+ const key = `${presetId}:${reason}:${destination.targetTags.join(",")}:${destination.path}`;
111
+ if (warnedDecisionNoteKey === key) return;
112
+ warnedDecisionNoteKey = key;
113
+ if (reason === "missing-workspace") {
114
+ warn(
115
+ `Focus preset "${presetId}" decisionNoteDestination [${destination.targetTags.join(", ")}] → ${destination.path} matches no configured workspace. Fix targetTags or add the workspace, then reload Pi.`,
116
+ );
117
+ return;
118
+ }
119
+ warn(
120
+ `Focus preset "${presetId}" decisionNoteDestination ${workspaceLabel}:${destination.path} is unavailable. Create the file or fix the path, then reload Pi.`,
121
+ );
122
+ }