pi-monofold 0.9.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
@@ -113,7 +113,7 @@ When Active Focus is set, Pi Monofold injects the active preset's `contextFiles`
113
113
  - Max **6,000** characters per file, with `… [truncated]` appended when a file is cut.
114
114
  - Max **12,000** injected file-content characters per turn; remaining files are skipped and a warning is surfaced once for that turn.
115
115
 
116
- 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).
117
117
 
118
118
  ## Safe read defaults
119
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
+ }