@marcoscale98/pi-extensible-workflows 5.14.1-fork.1 → 5.17.0-fork.1
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/CHANGELOG.md +67 -0
- package/dist/src/agent-execution.js +36 -51
- package/dist/src/background-widget.d.ts +56 -0
- package/dist/src/background-widget.js +22 -15
- package/dist/src/decoders.js +2 -1
- package/dist/src/host-navigator.d.ts +18 -0
- package/dist/src/host-navigator.js +71 -13
- package/dist/src/host-view.d.ts +16 -1
- package/dist/src/host-view.js +109 -42
- package/dist/src/host.js +57 -24
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +867 -545
- package/dist/src/index.js.map +3 -3
- package/dist/src/registry.js +2 -1
- package/dist/src/roles.d.ts +70 -0
- package/dist/src/roles.js +325 -0
- package/dist/src/settings.d.ts +13 -0
- package/dist/src/settings.js +253 -0
- package/dist/src/trajectory.d.ts +29 -4
- package/dist/src/trajectory.js +58 -3
- package/dist/src/types.d.ts +0 -1
- package/dist/src/utils.d.ts +2 -1
- package/dist/src/utils.js +10 -0
- package/dist/src/validation.d.ts +5 -17
- package/dist/src/validation.js +9 -458
- package/dist/subagents/index.js +1132 -694
- package/dist/subagents/index.js.map +3 -3
- package/dist/subagents/src/contracts.d.ts +4 -0
- package/dist/subagents/src/index.d.ts +1 -1
- package/dist/subagents/src/index.js +2 -1
- package/dist/subagents/src/manager.js +29 -10
- package/dist/subagents/src/navigator.js +396 -247
- package/dist/subagents/src/view.d.ts +23 -2
- package/dist/subagents/src/view.js +96 -30
- package/dist/trajectory/assets/index.html +347 -231
- package/dist/trajectory/assets/prism.min.js +13 -0
- package/dist/trajectory/index.js +377 -257
- package/dist/trajectory/index.js.map +3 -3
- package/dist/trajectory/src/assets/index.html +347 -231
- package/dist/trajectory/src/assets/prism.min.js +13 -0
- package/dist/trajectory/src/export.js +4 -2
- package/dist/trajectory/src/index.d.ts +2 -0
- package/dist/trajectory/src/index.js +143 -42
- package/dist/trajectory/src/server.js +134 -9
- package/dist/trajectory/src/server.js.map +1 -1
- package/package.json +2 -1
- package/src/agent-execution.ts +36 -42
- package/src/background-widget.ts +25 -18
- package/src/decoders.ts +2 -1
- package/src/host-navigator.ts +58 -14
- package/src/host-view.ts +93 -37
- package/src/host.ts +51 -21
- package/src/index.ts +3 -1
- package/src/registry.ts +2 -1
- package/src/roles.ts +326 -0
- package/src/settings.ts +206 -0
- package/src/trajectory.ts +76 -7
- package/src/types.ts +1 -1
- package/src/utils.ts +8 -1
- package/src/validation.ts +12 -356
- package/subagents/README.md +1 -1
- package/subagents/src/contracts.ts +4 -0
- package/subagents/src/index.ts +3 -2
- package/subagents/src/manager.ts +28 -10
- package/subagents/src/navigator.ts +336 -180
- package/subagents/src/view.ts +97 -28
- package/trajectory/src/assets/index.html +347 -231
- package/trajectory/src/assets/prism.min.js +13 -0
- package/trajectory/src/export.ts +4 -2
- package/trajectory/src/index.ts +118 -38
- package/trajectory/src/server.ts +112 -12
package/src/roles.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
3
|
+
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { AgentDefinition, AgentResourceSelectorSources, AgentResourceSelectors, ContextFileScope, ModelSpec, WorkflowExtensionMetadata, WorkflowExtensionSettings, WorkflowRoleDirectoryRegistration } from "./types.js";
|
|
5
|
+
import { registeredWorkflowRoleDirectoryRegistrations } from "./registry.js";
|
|
6
|
+
import { resolveWorkflowSettings, validateContextFileScopes, validateSelectorList, validateWorkflowExtensionSettings, workflowSettingsPath } from "./settings.js";
|
|
7
|
+
import { assertModelThinking, deepFreeze, object, errorText, fail, isNodeError, modelAliasName, modelCapability, resourcePatternHasMagic, resolveModelReference, selectResourcesByLayers, unmatchedResourcePatterns } from "./utils.js";
|
|
8
|
+
import { canonicalPath } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
const ROLE_DIRECTORY = "pi-extensible-workflows";
|
|
11
|
+
// Preserve the role-file base when a discovered definition is resolved again through the public API.
|
|
12
|
+
const roleDefinitionPaths = new WeakMap<AgentDefinition, string>();
|
|
13
|
+
|
|
14
|
+
export function parseRoleMarkdown(content: string, strict = false, rolePath?: string): AgentDefinition {
|
|
15
|
+
if (!strict) {
|
|
16
|
+
if (!content.startsWith("---\n")) return { prompt: content };
|
|
17
|
+
const end = content.indexOf("\n---", 4);
|
|
18
|
+
if (end < 0) return { prompt: content };
|
|
19
|
+
const meta: Record<string, string> = {};
|
|
20
|
+
for (const line of content.slice(4, end).split("\n")) {
|
|
21
|
+
const match = /^(model|tools|skills|extensions|description|overrideSystemPrompt|override_system_prompt|is_system_prompt|contextFiles|extensionSettings|disabledAgentResources|thinking)\s*:\s*(.+)$/.exec(line.trim());
|
|
22
|
+
if (match?.[1] === "disabledAgentResources") fail("INVALID_METADATA", "disabledAgentResources is no longer supported; use skills, extensions, and tools selectors");
|
|
23
|
+
if (match?.[1] === "thinking") fail("INVALID_METADATA", "Role thinking is not supported; put it on model as provider/model:thinking");
|
|
24
|
+
if (match?.[1] && match[2]) meta[match[1]] = match[2].trim();
|
|
25
|
+
}
|
|
26
|
+
const unquote = (v: string) => v.replace(/^['"]|['"]$/g, "");
|
|
27
|
+
const parseList = (value: string | undefined): string[] | undefined => value === undefined ? undefined : value.replace(/^\[|\]$/g, "").split(",").map((entry) => unquote(entry.trim())).filter(Boolean);
|
|
28
|
+
const tools = parseList(meta.tools);
|
|
29
|
+
const skills = parseList(meta.skills);
|
|
30
|
+
const extensions = parseList(meta.extensions);
|
|
31
|
+
let extensionSettings: unknown;
|
|
32
|
+
if (meta.extensionSettings !== undefined) {
|
|
33
|
+
try { extensionSettings = JSON.parse(meta.extensionSettings) as unknown; }
|
|
34
|
+
catch (error) { fail("INVALID_METADATA", `Invalid role extensionSettings: ${errorText(error)}`); }
|
|
35
|
+
}
|
|
36
|
+
const rolePathValue = rolePath ?? "<role>";
|
|
37
|
+
const normalizedExtensionSettings = validateWorkflowExtensionSettings(extensionSettings, rolePathValue, "INVALID_METADATA");
|
|
38
|
+
const definition: AgentDefinition = { prompt: content.slice(end + 4).replace(/^\n/, "") };
|
|
39
|
+
if (meta.model) {
|
|
40
|
+
const model = unquote(meta.model);
|
|
41
|
+
assertModelThinking(model, "Role model");
|
|
42
|
+
definition.model = model;
|
|
43
|
+
}
|
|
44
|
+
if (meta.description) definition.description = unquote(meta.description);
|
|
45
|
+
if (tools) definition.tools = tools;
|
|
46
|
+
if (skills) definition.skills = skills;
|
|
47
|
+
if (extensions) definition.extensions = extensions;
|
|
48
|
+
if (normalizedExtensionSettings !== undefined) definition.extensionSettings = normalizedExtensionSettings;
|
|
49
|
+
const overrideSystemPrompt = meta.overrideSystemPrompt ?? meta.override_system_prompt ?? meta.is_system_prompt;
|
|
50
|
+
const contextFiles = meta.contextFiles ? meta.contextFiles.replace(/^\[|\]$/g, "").split(",").map((scope) => unquote(scope.trim())).filter(Boolean) : undefined;
|
|
51
|
+
const normalizedContextFiles = validateContextFileScopes(contextFiles, "role");
|
|
52
|
+
if (overrideSystemPrompt) definition.overrideSystemPrompt = overrideSystemPrompt === "true";
|
|
53
|
+
if (normalizedContextFiles) definition.contextFiles = normalizedContextFiles;
|
|
54
|
+
return definition;
|
|
55
|
+
}
|
|
56
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
57
|
+
if (normalized.startsWith("---\n") && normalized.indexOf("\n---", 3) < 0) fail("INVALID_METADATA", "Role frontmatter is missing its closing delimiter");
|
|
58
|
+
let parsed: ReturnType<typeof parseFrontmatter>;
|
|
59
|
+
try { parsed = parseFrontmatter(content); }
|
|
60
|
+
catch (error) { fail("INVALID_METADATA", `Invalid role frontmatter: ${errorText(error)}`); }
|
|
61
|
+
if (!object(parsed.frontmatter)) fail("INVALID_METADATA", "Role frontmatter must be an object");
|
|
62
|
+
const { model, tools, skills, extensions, extensionSettings, description, contextFiles } = parsed.frontmatter;
|
|
63
|
+
if (Object.prototype.hasOwnProperty.call(parsed.frontmatter, "disabledAgentResources")) fail("INVALID_METADATA", "disabledAgentResources is no longer supported; use skills, extensions, and tools selectors");
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(parsed.frontmatter, "thinking")) fail("INVALID_METADATA", "Role thinking is not supported; put it on model as provider/model:thinking");
|
|
65
|
+
const overrideSystemPrompt = parsed.frontmatter.overrideSystemPrompt ?? parsed.frontmatter.override_system_prompt ?? parsed.frontmatter.is_system_prompt;
|
|
66
|
+
if (model !== undefined && (typeof model !== "string" || model.trim() === "")) fail("INVALID_METADATA", "Role model must be a non-empty string");
|
|
67
|
+
if (description !== undefined && (typeof description !== "string" || description.trim() === "" || description.length > 1024 || /[\r\n]/.test(description))) fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
|
|
68
|
+
if (overrideSystemPrompt !== undefined && typeof overrideSystemPrompt !== "boolean") fail("INVALID_METADATA", "Role overrideSystemPrompt must be a boolean");
|
|
69
|
+
const normalizedContextFiles = validateContextFileScopes(contextFiles, rolePath ?? "<role>");
|
|
70
|
+
const rolePathValue = rolePath ?? "<role>";
|
|
71
|
+
const normalizedTools = validateSelectorList(tools, rolePathValue, "tools", "INVALID_METADATA");
|
|
72
|
+
const normalizedSkills = validateSelectorList(skills, rolePathValue, "skills", "INVALID_METADATA");
|
|
73
|
+
const normalizedExtensions = validateSelectorList(extensions, rolePathValue, "extensions", "INVALID_METADATA");
|
|
74
|
+
const normalizedExtensionSettings = validateWorkflowExtensionSettings(extensionSettings, rolePathValue, "INVALID_METADATA");
|
|
75
|
+
const normalizedDescription = typeof description === "string" ? description.trim() : undefined;
|
|
76
|
+
const normalizedModel = typeof model === "string" ? model.trim() : undefined;
|
|
77
|
+
if (normalizedModel !== undefined) assertModelThinking(normalizedModel, "Role model");
|
|
78
|
+
const definition: AgentDefinition = { prompt: parsed.body };
|
|
79
|
+
if (normalizedDescription !== undefined) definition.description = normalizedDescription;
|
|
80
|
+
if (normalizedModel !== undefined) definition.model = normalizedModel;
|
|
81
|
+
if (normalizedTools !== undefined) definition.tools = normalizedTools;
|
|
82
|
+
if (normalizedSkills !== undefined) definition.skills = normalizedSkills;
|
|
83
|
+
if (normalizedExtensions !== undefined) definition.extensions = normalizedExtensions;
|
|
84
|
+
if (normalizedExtensionSettings !== undefined) definition.extensionSettings = normalizedExtensionSettings;
|
|
85
|
+
if (typeof overrideSystemPrompt === "boolean") definition.overrideSystemPrompt = overrideSystemPrompt;
|
|
86
|
+
if (normalizedContextFiles !== undefined) definition.contextFiles = normalizedContextFiles;
|
|
87
|
+
return definition;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function workflowRoleDirectories(agentDir = getAgentDir()): readonly string[] {
|
|
91
|
+
return [join(agentDir, ROLE_DIRECTORY, "roles")];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function projectRoleDirectories(root: string): readonly string[] {
|
|
95
|
+
return [join(root, ROLE_DIRECTORY, "roles")];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type RoleDirectorySource = { path: string; extension?: WorkflowExtensionMetadata; builtin?: true };
|
|
99
|
+
export type WorkflowRoleDirectoryInput = string | WorkflowRoleDirectoryRegistration;
|
|
100
|
+
type RoleFile = { name: string; path: string; source: RoleDirectorySource };
|
|
101
|
+
function roleDirectorySources(dirs: readonly WorkflowRoleDirectoryInput[]): RoleDirectorySource[] {
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
return dirs.flatMap((value) => {
|
|
104
|
+
const source = typeof value === "string" ? { path: value } : { path: value.path, extension: value.extension, ...(value.builtin === true ? { builtin: true as const } : {}) };
|
|
105
|
+
if (seen.has(source.path)) return [];
|
|
106
|
+
seen.add(source.path);
|
|
107
|
+
return [source];
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function roleDirectoryLabel(source: RoleDirectorySource, extension = true): string {
|
|
111
|
+
return source.extension ? `Extension "${source.extension.headline}" (${source.extension.version})` : extension ? "Registered workflow extension" : "Standard workflow";
|
|
112
|
+
}
|
|
113
|
+
function isRoleFile(dir: string, entry: import("node:fs").Dirent): boolean {
|
|
114
|
+
if (extname(entry.name) !== ".md") return false;
|
|
115
|
+
if (entry.isFile()) return true;
|
|
116
|
+
if (!entry.isSymbolicLink()) return false;
|
|
117
|
+
try { return statSync(join(dir, entry.name)).isFile(); }
|
|
118
|
+
catch (error) { if (isNodeError(error, "ENOENT")) return false; throw error; }
|
|
119
|
+
}
|
|
120
|
+
function scanRoleFiles(dirs: readonly WorkflowRoleDirectoryInput[], extension: boolean): RoleFile[] {
|
|
121
|
+
const files: RoleFile[] = [];
|
|
122
|
+
for (const source of roleDirectorySources(dirs)) {
|
|
123
|
+
let entries: import("node:fs").Dirent[];
|
|
124
|
+
try { entries = readdirSync(source.path, { withFileTypes: true }); }
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (!extension && isNodeError(error, "ENOENT")) continue;
|
|
127
|
+
fail("INVALID_METADATA", `${roleDirectoryLabel(source, extension)} role directory "${source.path}" could not be scanned: ${errorText(error)}`);
|
|
128
|
+
}
|
|
129
|
+
for (const entry of entries) if (isRoleFile(source.path, entry)) files.push({ name: basename(entry.name, ".md"), path: join(source.path, entry.name), source });
|
|
130
|
+
}
|
|
131
|
+
return files.sort((left, right) => left.name.localeCompare(right.name) || left.path.localeCompare(right.path));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface RoleDiscoveryOptions {
|
|
135
|
+
cwd: string;
|
|
136
|
+
agentDir?: string;
|
|
137
|
+
projectTrusted?: boolean;
|
|
138
|
+
extensionRoleDirectories?: readonly WorkflowRoleDirectoryInput[];
|
|
139
|
+
}
|
|
140
|
+
export interface RoleResourceCandidates {
|
|
141
|
+
skills?: readonly string[];
|
|
142
|
+
extensions?: readonly string[];
|
|
143
|
+
tools?: readonly string[];
|
|
144
|
+
}
|
|
145
|
+
export interface RoleResolutionOptions extends RoleDiscoveryOptions {
|
|
146
|
+
definition?: AgentDefinition;
|
|
147
|
+
definitions?: Readonly<Record<string, AgentDefinition>>;
|
|
148
|
+
model?: string;
|
|
149
|
+
modelOverride?: ModelSpec;
|
|
150
|
+
rootModel?: ModelSpec;
|
|
151
|
+
rootTools?: ReadonlySet<string> | readonly string[];
|
|
152
|
+
inheritedTools?: readonly string[];
|
|
153
|
+
availableModels?: ReadonlySet<string>;
|
|
154
|
+
knownModels?: ReadonlySet<string>;
|
|
155
|
+
modelAliases?: Readonly<Record<string, string>>;
|
|
156
|
+
blockedAliases?: ReadonlySet<string>;
|
|
157
|
+
blockedAliasTargets?: Readonly<Record<string, string>>;
|
|
158
|
+
settingsPath?: string;
|
|
159
|
+
selectorSources?: AgentResourceSelectorSources;
|
|
160
|
+
resources?: RoleResourceCandidates;
|
|
161
|
+
tools?: readonly string[];
|
|
162
|
+
skills?: readonly string[];
|
|
163
|
+
extensions?: readonly string[];
|
|
164
|
+
contextFiles?: readonly ContextFileScope[];
|
|
165
|
+
effectiveTools?: readonly string[];
|
|
166
|
+
}
|
|
167
|
+
export interface ResolvedRole {
|
|
168
|
+
name?: string;
|
|
169
|
+
definition?: AgentDefinition;
|
|
170
|
+
model?: ModelSpec;
|
|
171
|
+
requestedModel?: string;
|
|
172
|
+
tools?: readonly string[];
|
|
173
|
+
prompt: string;
|
|
174
|
+
overrideSystemPrompt: boolean;
|
|
175
|
+
systemPrompt: { mode: "override" | "append"; text: string };
|
|
176
|
+
contextFiles?: readonly ContextFileScope[];
|
|
177
|
+
extensionSettings?: Readonly<WorkflowExtensionSettings>;
|
|
178
|
+
selectorSources: AgentResourceSelectorSources;
|
|
179
|
+
selectorLayers: { skills: readonly (readonly string[] | undefined)[]; extensions: readonly (readonly string[] | undefined)[]; tools: readonly (readonly string[] | undefined)[] };
|
|
180
|
+
selectedSkills?: readonly string[];
|
|
181
|
+
selectedExtensions?: readonly string[];
|
|
182
|
+
unmatchedSkills?: readonly string[];
|
|
183
|
+
unmatchedExtensions?: readonly string[];
|
|
184
|
+
unmatchedTools?: readonly string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
type RoleEntry = { name: string; path: string; definition: AgentDefinition; source: RoleDirectorySource };
|
|
188
|
+
function roleEntries(dirs: readonly WorkflowRoleDirectoryInput[], extension: boolean): Record<string, RoleEntry> {
|
|
189
|
+
const files = scanRoleFiles(dirs, extension);
|
|
190
|
+
const starterFiles = files.filter(({ source }) => source.builtin === true);
|
|
191
|
+
const regularFiles = files.filter(({ source }) => source.builtin !== true);
|
|
192
|
+
if (extension) {
|
|
193
|
+
const byName = new Map<string, RoleFile[]>();
|
|
194
|
+
for (const file of regularFiles) byName.set(file.name, [...(byName.get(file.name) ?? []), file]);
|
|
195
|
+
for (const [name, matches] of byName) if (matches.length > 1) fail("INVALID_METADATA", `Duplicate extension role "${name}": ${matches.map(({ path, source }) => `${roleDirectoryLabel(source)} role directory "${source.path}" (${path})`).join("; ")}`);
|
|
196
|
+
}
|
|
197
|
+
const read = (roleFiles: readonly RoleFile[]): Record<string, RoleEntry> => Object.fromEntries(roleFiles.map(({ name, path, source }) => {
|
|
198
|
+
try {
|
|
199
|
+
const definition = parseRoleMarkdown(readFileSync(path, "utf8"), true, path);
|
|
200
|
+
roleDefinitionPaths.set(definition, path);
|
|
201
|
+
return [name, { name, path, source, definition }];
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (extension) fail("INVALID_METADATA", `${roleDirectoryLabel(source)} role directory "${source.path}" contains invalid role "${name}" at "${path}": ${errorText(error)}`);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
return extension ? { ...read(starterFiles), ...read(regularFiles) } : read(files);
|
|
209
|
+
}
|
|
210
|
+
type ResolvedRoleDiscoveryOptions = { cwd: string; agentDir: string; projectTrusted: boolean; extensionRoleDirectories: readonly WorkflowRoleDirectoryInput[] };
|
|
211
|
+
function discoveryInput(input: RoleDiscoveryOptions): ResolvedRoleDiscoveryOptions {
|
|
212
|
+
return { cwd: input.cwd, agentDir: input.agentDir ?? getAgentDir(), projectTrusted: input.projectTrusted ?? true, extensionRoleDirectories: input.extensionRoleDirectories ?? registeredWorkflowRoleDirectoryRegistrations() };
|
|
213
|
+
}
|
|
214
|
+
function discoveredRoleEntries(input: RoleDiscoveryOptions): Record<string, RoleEntry> {
|
|
215
|
+
const resolved = discoveryInput(input);
|
|
216
|
+
return {
|
|
217
|
+
...roleEntries(resolved.extensionRoleDirectories, true),
|
|
218
|
+
...roleEntries(workflowRoleDirectories(resolved.agentDir), false),
|
|
219
|
+
...(resolved.projectTrusted ? roleEntries(projectRoleDirectories(join(resolved.cwd, ".pi")), false) : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
export function loadProjectAgentDefinitions(cwd: string): Readonly<Record<string, AgentDefinition>> {
|
|
223
|
+
return deepFreeze(Object.fromEntries(Object.entries(roleEntries(projectRoleDirectories(join(cwd, ".pi")), false)).map(([name, entry]) => [name, entry.definition])));
|
|
224
|
+
}
|
|
225
|
+
export function discoverRoles(input: RoleDiscoveryOptions): Readonly<Record<string, AgentDefinition>> {
|
|
226
|
+
return deepFreeze(Object.fromEntries(Object.entries(discoveredRoleEntries(input)).map(([name, entry]) => [name, entry.definition])));
|
|
227
|
+
}
|
|
228
|
+
export function loadRole(name: string, input: RoleDiscoveryOptions): AgentDefinition {
|
|
229
|
+
const entry = discoveredRoleEntries(input)[name];
|
|
230
|
+
if (!entry) fail("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${name}`);
|
|
231
|
+
return entry.definition;
|
|
232
|
+
}
|
|
233
|
+
export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), projectTrusted = true, extensionRoleDirectories: readonly WorkflowRoleDirectoryInput[] = registeredWorkflowRoleDirectoryRegistrations()): Readonly<Record<string, AgentDefinition>> {
|
|
234
|
+
return discoverRoles({ cwd, agentDir, projectTrusted, extensionRoleDirectories });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function canonicalExtensionSelector(selector: string, base = process.cwd()): string {
|
|
238
|
+
const negated = selector.startsWith("!");
|
|
239
|
+
const body = negated ? selector.slice(1) : selector;
|
|
240
|
+
if (body === "*" || body === "**" || body.startsWith("**/")) return selector;
|
|
241
|
+
const resolved = resolve(base, body);
|
|
242
|
+
if (resourcePatternHasMagic(body)) return `${negated ? "!" : ""}${resolved}`;
|
|
243
|
+
return `${negated ? "!" : ""}${canonicalPath(resolved)}`;
|
|
244
|
+
}
|
|
245
|
+
function selectorsFromSettings(settings: { skills?: readonly string[]; extensions?: readonly string[]; tools?: readonly string[] }): AgentResourceSelectors {
|
|
246
|
+
return { ...(settings.skills === undefined ? {} : { skills: settings.skills }), ...(settings.extensions === undefined ? {} : { extensions: settings.extensions }), ...(settings.tools === undefined ? {} : { tools: settings.tools }) };
|
|
247
|
+
}
|
|
248
|
+
function rootToolNames(value: ReadonlySet<string> | readonly string[] | undefined): readonly string[] | undefined {
|
|
249
|
+
return value === undefined ? undefined : value instanceof Set ? [...value] : [...value];
|
|
250
|
+
}
|
|
251
|
+
function roleDefinitionFor(name: string | undefined, options: RoleResolutionOptions): { definition?: AgentDefinition; path?: string } {
|
|
252
|
+
if (name === undefined) return {};
|
|
253
|
+
if (options.definition !== undefined) {
|
|
254
|
+
const path = roleDefinitionPaths.get(options.definition);
|
|
255
|
+
return { definition: options.definition, ...(path === undefined ? {} : { path }) };
|
|
256
|
+
}
|
|
257
|
+
if (options.definitions?.[name] !== undefined) {
|
|
258
|
+
const definition = options.definitions[name];
|
|
259
|
+
const path = roleDefinitionPaths.get(definition);
|
|
260
|
+
return { definition, ...(path === undefined ? {} : { path }) };
|
|
261
|
+
}
|
|
262
|
+
const entries = discoveredRoleEntries(options);
|
|
263
|
+
const entry = entries[name];
|
|
264
|
+
if (!entry) fail("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${name}`);
|
|
265
|
+
return entry;
|
|
266
|
+
}
|
|
267
|
+
/** Without a supplied root/tool/resource boundary, this API reports selector layers but does not infer available resources; tools and model remain undefined when no candidates are supplied. When selector sources are supplied, model aliases default to none and blocked aliases are not applied unless provided. */
|
|
268
|
+
export function resolveRole(name: string | undefined, options: RoleResolutionOptions): ResolvedRole {
|
|
269
|
+
const { definition, path } = roleDefinitionFor(name, options);
|
|
270
|
+
const settings = options.selectorSources === undefined ? resolveWorkflowSettings(options.cwd, options.projectTrusted ?? true, options.settingsPath ?? workflowSettingsPath(options.agentDir ?? getAgentDir())) : undefined;
|
|
271
|
+
const baseSources = options.selectorSources ?? { global: selectorsFromSettings(settings?.global ?? {}), project: selectorsFromSettings(settings?.project ?? {}) };
|
|
272
|
+
const canonicalSources = (sources: AgentResourceSelectorSources): AgentResourceSelectorSources => ({
|
|
273
|
+
...sources,
|
|
274
|
+
global: { ...sources.global, ...(sources.global.extensions === undefined ? {} : { extensions: sources.global.extensions.map((selector) => canonicalExtensionSelector(selector, options.cwd)) }) },
|
|
275
|
+
project: { ...sources.project, ...(sources.project.extensions === undefined ? {} : { extensions: sources.project.extensions.map((selector) => canonicalExtensionSelector(selector, options.cwd)) }) },
|
|
276
|
+
...(sources.role === undefined ? {} : { role: { ...sources.role, ...(sources.role.extensions === undefined ? {} : { extensions: sources.role.extensions.map((selector) => canonicalExtensionSelector(selector, options.cwd)) }) } }),
|
|
277
|
+
...(sources.call === undefined ? {} : { call: { ...sources.call, ...(sources.call.extensions === undefined ? {} : { extensions: sources.call.extensions.map((selector) => canonicalExtensionSelector(selector, options.cwd)) }) } }),
|
|
278
|
+
});
|
|
279
|
+
const roleLayer = definition?.skills !== undefined || definition?.extensions !== undefined || definition?.tools !== undefined ? { ...(definition.skills === undefined ? {} : { skills: definition.skills }), ...(definition.extensions === undefined ? {} : { extensions: definition.extensions.map((selector) => canonicalExtensionSelector(selector, path ? dirname(path) : options.cwd)) }), ...(definition.tools === undefined ? {} : { tools: definition.tools }) } : baseSources.role;
|
|
280
|
+
const callLayer = options.skills !== undefined || options.extensions !== undefined || options.tools !== undefined ? { ...(options.skills === undefined ? {} : { skills: options.skills }), ...(options.extensions === undefined ? {} : { extensions: options.extensions.map((selector) => canonicalExtensionSelector(selector, options.cwd)) }), ...(options.tools === undefined ? {} : { tools: options.tools }) } : baseSources.call;
|
|
281
|
+
const selectorSources = canonicalSources({ global: baseSources.global, project: baseSources.project, ...(roleLayer === undefined ? {} : { role: roleLayer }), ...(callLayer === undefined ? {} : { call: callLayer }) });
|
|
282
|
+
const callSelectors = selectorSources.call;
|
|
283
|
+
const roleSelectors = selectorSources.role;
|
|
284
|
+
const selectorLayers = {
|
|
285
|
+
skills: [selectorSources.global.skills, selectorSources.project.skills, roleSelectors?.skills, callSelectors?.skills],
|
|
286
|
+
extensions: [selectorSources.global.extensions, selectorSources.project.extensions, roleSelectors?.extensions, callSelectors?.extensions],
|
|
287
|
+
tools: [selectorSources.global.tools, selectorSources.project.tools, roleSelectors?.tools, callSelectors?.tools],
|
|
288
|
+
};
|
|
289
|
+
const resources = options.resources ?? {};
|
|
290
|
+
const candidateTools = options.inheritedTools ?? rootToolNames(options.rootTools) ?? resources.tools;
|
|
291
|
+
const tools = options.effectiveTools ?? (candidateTools === undefined ? undefined : selectResourcesByLayers(selectorLayers.tools, candidateTools));
|
|
292
|
+
const rootTools = rootToolNames(options.rootTools);
|
|
293
|
+
if (rootTools !== undefined) {
|
|
294
|
+
const outsideEffectiveTool = options.effectiveTools?.find((tool) => !candidateTools?.includes(tool));
|
|
295
|
+
if (outsideEffectiveTool) fail("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${outsideEffectiveTool}`);
|
|
296
|
+
const outsideCallTool = (options.tools ?? []).find((tool) => !tool.startsWith("!") && !resourcePatternHasMagic(tool) && !candidateTools?.includes(tool));
|
|
297
|
+
if (outsideCallTool) fail("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${outsideCallTool}`);
|
|
298
|
+
const outsideRootTool = tools?.find((tool) => !rootTools.includes(tool));
|
|
299
|
+
if (outsideRootTool) fail("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${outsideRootTool}`);
|
|
300
|
+
}
|
|
301
|
+
const selectedSkills = resources.skills === undefined ? undefined : selectResourcesByLayers(selectorLayers.skills, resources.skills);
|
|
302
|
+
const extensionResources = resources.extensions?.map((extension) => canonicalPath(extension));
|
|
303
|
+
const selectedExtensions = extensionResources === undefined ? undefined : selectResourcesByLayers(selectorLayers.extensions, extensionResources);
|
|
304
|
+
const unmatchedSkills = resources.skills === undefined ? undefined : unmatchedResourcePatterns(selectorLayers.skills.flatMap((layer) => layer ?? []), resources.skills);
|
|
305
|
+
const unmatchedExtensions = extensionResources === undefined ? undefined : unmatchedResourcePatterns(selectorLayers.extensions.flatMap((layer) => layer ?? []), extensionResources);
|
|
306
|
+
const unmatchedTools = candidateTools === undefined ? undefined : unmatchedResourcePatterns(selectorLayers.tools.flatMap((layer) => layer ?? []), candidateTools);
|
|
307
|
+
const aliases = options.modelAliases ?? settings?.effective.modelAliases ?? {};
|
|
308
|
+
const requestedModel = options.model ?? (definition?.model && definition.thinking && !definition.model.includes(":") ? `${definition.model}:${definition.thinking}` : definition?.model);
|
|
309
|
+
const blockedAlias = requestedModel?.split(":", 1)[0];
|
|
310
|
+
if (requestedModel !== undefined && blockedAlias && options.blockedAliases?.has(blockedAlias) && !modelAliasName(requestedModel, aliases)) {
|
|
311
|
+
const target = options.blockedAliasTargets?.[blockedAlias];
|
|
312
|
+
fail("UNKNOWN_MODEL", `Unknown model alias ${requestedModel}${target ? ` resolved to ${target}` : ""}${options.settingsPath ? ` (settings: ${options.settingsPath})` : ""}`);
|
|
313
|
+
}
|
|
314
|
+
let model = options.modelOverride;
|
|
315
|
+
if (model === undefined && requestedModel !== undefined) {
|
|
316
|
+
assertModelThinking(requestedModel);
|
|
317
|
+
model = resolveModelReference(requestedModel, aliases, options.knownModels ?? options.availableModels, options.settingsPath ?? settings?.sources.modelAliases);
|
|
318
|
+
}
|
|
319
|
+
if (model === undefined) model = options.rootModel;
|
|
320
|
+
const availableModels = options.knownModels ?? options.availableModels ?? (options.rootModel === undefined ? undefined : new Set([modelCapability(options.rootModel)]));
|
|
321
|
+
if (model !== undefined && availableModels !== undefined && !availableModels.has(modelCapability(model))) fail("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${options.settingsPath ? ` (settings: ${options.settingsPath})` : ""}`);
|
|
322
|
+
const prompt = definition?.prompt ?? "";
|
|
323
|
+
const overrideSystemPrompt = definition?.overrideSystemPrompt === true;
|
|
324
|
+
const contextFiles = options.contextFiles ?? definition?.contextFiles;
|
|
325
|
+
return { ...(name === undefined ? {} : { name }), ...(definition === undefined ? {} : { definition }), ...(model === undefined ? {} : { model }), ...(modelAliasName(requestedModel ?? "", aliases) && requestedModel ? { requestedModel } : {}), ...(tools === undefined ? {} : { tools }), prompt, overrideSystemPrompt, systemPrompt: { mode: overrideSystemPrompt ? "override" : "append", text: prompt }, ...(contextFiles === undefined ? {} : { contextFiles: [...contextFiles] }), ...(definition?.extensionSettings === undefined ? {} : { extensionSettings: definition.extensionSettings }), selectorSources, selectorLayers, ...(selectedSkills === undefined ? {} : { selectedSkills }), ...(selectedExtensions === undefined ? {} : { selectedExtensions }), ...(unmatchedSkills === undefined ? {} : { unmatchedSkills }), ...(unmatchedExtensions === undefined ? {} : { unmatchedExtensions }), ...(unmatchedTools === undefined ? {} : { unmatchedTools }) };
|
|
326
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { atomicWriteFile } from "./persistence.js";
|
|
2
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { AgentResourcePolicy, AgentResourceSelectors, AgentResourceSelectorSet, ContextFileScope, JsonValue, WorkflowExtensionSettings, WorkflowRetentionSettings, WorkflowSettings, WorkflowSettingsOverrides, WorkflowSettingsResolution, WorkflowSettingsSources } from "./types.js";
|
|
8
|
+
import { isContextFileScope } from "./types.js";
|
|
9
|
+
import { annotateModelAliasError, deepFreeze, errorText, fail, isNodeError, jsonValue, modelCapability, object, positiveInteger, resourcePatternHasMagic, unknownModel, validateModelAliases, validateResourcePattern, validWorkflowExtensionNamespace } from "./utils.js";
|
|
10
|
+
import { canonicalPath, workflowProjectSettingsPath } from "./paths.js";
|
|
11
|
+
export { workflowProjectSettingsPath } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
const ROLE_DIRECTORY = "pi-extensible-workflows";
|
|
14
|
+
export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ concurrency: 8, backgroundWidget: true });
|
|
15
|
+
export function workflowSettingsPath(agentDir = getAgentDir()): string { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
|
|
16
|
+
function normalizedResourcePath(value: string, settingsPath: string): string {
|
|
17
|
+
if (value === "*") return value;
|
|
18
|
+
let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
|
|
19
|
+
if (expanded.startsWith("file://")) expanded = fileURLToPath(expanded);
|
|
20
|
+
const resolved = resolve(dirname(settingsPath), expanded);
|
|
21
|
+
if (expanded === "**" || expanded.startsWith("**/") || expanded.startsWith("**\\")) return expanded;
|
|
22
|
+
if (resourcePatternHasMagic(expanded)) {
|
|
23
|
+
const magicIndex = resolved.search(/[*?\x5b\x5d{}()]/);
|
|
24
|
+
const separatorIndex = Math.max(resolved.lastIndexOf("/", magicIndex), resolved.lastIndexOf("\\", magicIndex));
|
|
25
|
+
const rootBoundary = separatorIndex === 0 || (separatorIndex === 2 && /^[A-Za-z]:[\\/]/.test(resolved));
|
|
26
|
+
const prefix = rootBoundary ? resolved.slice(0, separatorIndex + 1) : separatorIndex >= 0 ? resolved.slice(0, separatorIndex) : resolved;
|
|
27
|
+
const suffix = rootBoundary ? resolved.slice(separatorIndex + 1) : separatorIndex >= 0 ? resolved.slice(separatorIndex) : "";
|
|
28
|
+
return `${canonicalPath(prefix)}${suffix}`;
|
|
29
|
+
}
|
|
30
|
+
return canonicalPath(resolved);
|
|
31
|
+
}
|
|
32
|
+
export function validateSelectorList(value: unknown, path: string, kind: "skills" | "extensions" | "tools", errorCode: "INVALID_SETTINGS" | "INVALID_METADATA" = "INVALID_SETTINGS", normalizeExtensions = true): readonly string[] | undefined {
|
|
33
|
+
if (value === undefined) return undefined;
|
|
34
|
+
if (!Array.isArray(value)) fail(errorCode, `${path}.${kind} must be an array`);
|
|
35
|
+
const normalized: string[] = [];
|
|
36
|
+
for (const [index, entry] of value.entries()) {
|
|
37
|
+
if (typeof entry !== "string" || !entry.trim()) fail(errorCode, `${path}.${kind}[${String(index)}] must be a non-empty string`);
|
|
38
|
+
let selector = entry.trim();
|
|
39
|
+
if (kind === "extensions" && normalizeExtensions) {
|
|
40
|
+
const negated = selector.startsWith("!");
|
|
41
|
+
const body = negated ? selector.slice(1) : selector;
|
|
42
|
+
if (!body) fail(errorCode, `${path}.${kind}[${String(index)}] must be a valid minimatch pattern: Empty minimatch pattern ${JSON.stringify(selector)}`);
|
|
43
|
+
try { selector = `${negated ? "!" : ""}${normalizedResourcePath(body, path)}`; } catch (error) { fail(errorCode, `${path}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`); }
|
|
44
|
+
}
|
|
45
|
+
try { validateResourcePattern(selector); } catch (error) { fail(errorCode, `${path}.${kind}[${String(index)}] must be a valid minimatch pattern: ${errorText(error)}`); }
|
|
46
|
+
normalized.push(selector);
|
|
47
|
+
}
|
|
48
|
+
return Object.freeze(normalized);
|
|
49
|
+
}
|
|
50
|
+
function validateWorktreePostCreateCommand(value: unknown, settingsPath: string): readonly string[] | undefined {
|
|
51
|
+
if (value === undefined) return undefined;
|
|
52
|
+
if (!Array.isArray(value) || value.length === 0) fail("INVALID_SETTINGS", `${settingsPath}.worktreePostCreateCommand must be a non-empty array`);
|
|
53
|
+
return Object.freeze(value.map((entry, index) => {
|
|
54
|
+
if (typeof entry !== "string" || !entry.trim()) fail("INVALID_SETTINGS", `${settingsPath}.worktreePostCreateCommand[${String(index)}] must be a non-empty string`);
|
|
55
|
+
return entry;
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
function selectorsFromSettings(settings: Readonly<WorkflowSettings | WorkflowSettingsOverrides>): AgentResourceSelectors {
|
|
59
|
+
return {
|
|
60
|
+
...(settings.skills === undefined ? {} : { skills: settings.skills }),
|
|
61
|
+
...(settings.extensions === undefined ? {} : { extensions: settings.extensions }),
|
|
62
|
+
...(settings.tools === undefined ? {} : { tools: settings.tools }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function selectorSet(value: AgentResourceSelectors | undefined): AgentResourceSelectorSet { return { skills: [...(value?.skills ?? [])], extensions: [...(value?.extensions ?? [])], ...(value?.tools === undefined ? {} : { tools: [...value.tools] }) }; }
|
|
66
|
+
export function validateContextFileScopes(value: unknown, rolePath: string): readonly ContextFileScope[] | undefined {
|
|
67
|
+
if (value === undefined) return undefined;
|
|
68
|
+
if (!Array.isArray(value) || !value.every(isContextFileScope)) fail("INVALID_METADATA", `${rolePath}.contextFiles must be an array containing only global, project, or cwd`);
|
|
69
|
+
return [...value];
|
|
70
|
+
}
|
|
71
|
+
export function validateWorkflowExtensionSettings(value: unknown, settingsPath: string, errorCode: "INVALID_SETTINGS" | "INVALID_METADATA" = "INVALID_SETTINGS"): WorkflowExtensionSettings | undefined {
|
|
72
|
+
if (value === undefined) return undefined;
|
|
73
|
+
const base = `${settingsPath}.extensionSettings`;
|
|
74
|
+
if (!object(value)) fail(errorCode, `${base} must be an object`);
|
|
75
|
+
const normalized: Record<string, JsonValue> = {};
|
|
76
|
+
for (const [namespace, raw] of Object.entries(value)) {
|
|
77
|
+
if (!validWorkflowExtensionNamespace(namespace)) fail(errorCode, `${base} contains an invalid namespace: ${namespace}`);
|
|
78
|
+
if (namespace === "herdr") {
|
|
79
|
+
if (!object(raw)) fail(errorCode, `${base}.herdr must be an object`);
|
|
80
|
+
if (Object.keys(raw).some((key) => key !== "enableFullyInspectableMode")) fail(errorCode, `${base}.herdr contains an unsupported setting`);
|
|
81
|
+
if (raw.enableFullyInspectableMode !== undefined && typeof raw.enableFullyInspectableMode !== "boolean") fail(errorCode, `${base}.herdr.enableFullyInspectableMode must be a boolean`);
|
|
82
|
+
normalized.herdr = Object.freeze({ ...(raw.enableFullyInspectableMode === undefined ? {} : { enableFullyInspectableMode: raw.enableFullyInspectableMode }) });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (namespace === "trajectory") {
|
|
86
|
+
if (!object(raw)) fail(errorCode, `${base}.trajectory must be an object`);
|
|
87
|
+
if (Object.keys(raw).some((key) => key !== "port")) fail(errorCode, `${base}.trajectory contains an unsupported setting`);
|
|
88
|
+
if (raw.port !== undefined && (!positiveInteger(raw.port) || raw.port > 65535)) fail(errorCode, `${base}.trajectory.port must be an integer from 1 to 65535`);
|
|
89
|
+
normalized.trajectory = Object.freeze({ ...(raw.port === undefined ? {} : { port: raw.port }) });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!jsonValue(raw)) fail(errorCode, `${base}.${namespace} must be JSON-compatible`);
|
|
93
|
+
normalized[namespace] = structuredClone(raw);
|
|
94
|
+
}
|
|
95
|
+
return deepFreeze(normalized as WorkflowExtensionSettings);
|
|
96
|
+
}
|
|
97
|
+
function positiveRetentionInteger(value: unknown): value is number { return positiveInteger(value) && Number.isSafeInteger(value); }
|
|
98
|
+
function validateRetention(value: unknown, settingsPath: string): Readonly<WorkflowRetentionSettings> | undefined {
|
|
99
|
+
if (value === undefined) return undefined;
|
|
100
|
+
const base = `${settingsPath}.retention`;
|
|
101
|
+
if (!object(value)) fail("INVALID_SETTINGS", `${base} must be an object`);
|
|
102
|
+
const unknown = Object.keys(value).find((key) => key !== "olderThanDays" && key !== "maxTerminalRuns");
|
|
103
|
+
if (unknown) fail("INVALID_SETTINGS", `Unknown retention setting at ${base}: ${unknown}`);
|
|
104
|
+
if (value.olderThanDays !== undefined && !positiveRetentionInteger(value.olderThanDays)) fail("INVALID_SETTINGS", `${base}.olderThanDays must be a positive integer`);
|
|
105
|
+
if (value.maxTerminalRuns !== undefined && !positiveRetentionInteger(value.maxTerminalRuns)) fail("INVALID_SETTINGS", `${base}.maxTerminalRuns must be a positive integer`);
|
|
106
|
+
return Object.freeze({ ...(value.olderThanDays === undefined ? {} : { olderThanDays: value.olderThanDays }), ...(value.maxTerminalRuns === undefined ? {} : { maxTerminalRuns: value.maxTerminalRuns }) });
|
|
107
|
+
}
|
|
108
|
+
function parseSettings(path: string, partial: false): Readonly<WorkflowSettings>;
|
|
109
|
+
function parseSettings(path: string, partial: true): Readonly<WorkflowSettingsOverrides>;
|
|
110
|
+
function parseSettings(path: string, partial: boolean): Readonly<WorkflowSettings | WorkflowSettingsOverrides> {
|
|
111
|
+
let parsed: unknown;
|
|
112
|
+
try { parsed = JSON.parse(readFileSync(path, "utf8")); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (isNodeError(error, "ENOENT")) return partial ? Object.freeze({}) : DEFAULT_SETTINGS;
|
|
115
|
+
fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
|
|
116
|
+
}
|
|
117
|
+
if (!object(parsed)) fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
|
|
118
|
+
const allowed = new Set(["concurrency", "modelAliases", "skills", "extensions", "extensionSettings", "tools", "retention", "worktreePostCreateCommand", ...(partial ? [] : ["backgroundWidget"]) ]);
|
|
119
|
+
const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
|
|
120
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "disabledAgentResources")) fail("INVALID_SETTINGS", `disabledAgentResources is no longer supported; use skills, extensions, and tools selectors (settings: ${path})`);
|
|
121
|
+
if (unknown) fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
|
|
122
|
+
const concurrency = parsed.concurrency === undefined ? (partial ? undefined : DEFAULT_SETTINGS.concurrency) : parsed.concurrency;
|
|
123
|
+
if (concurrency !== undefined && (!positiveInteger(concurrency) || concurrency > 16)) fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
|
|
124
|
+
const backgroundWidget = parsed.backgroundWidget === undefined ? (partial ? undefined : DEFAULT_SETTINGS.backgroundWidget) : parsed.backgroundWidget;
|
|
125
|
+
if (backgroundWidget !== undefined && typeof backgroundWidget !== "boolean") fail("INVALID_SETTINGS", `${path}.backgroundWidget must be a boolean`);
|
|
126
|
+
const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
|
|
127
|
+
const skills = validateSelectorList(parsed.skills, path, "skills");
|
|
128
|
+
const tools = validateSelectorList(parsed.tools, path, "tools");
|
|
129
|
+
const extensions = validateSelectorList(parsed.extensions, path, "extensions");
|
|
130
|
+
const extensionSettings = parsed.extensionSettings === undefined ? undefined : validateWorkflowExtensionSettings(parsed.extensionSettings, path, "INVALID_SETTINGS");
|
|
131
|
+
const retention = validateRetention(parsed.retention, path);
|
|
132
|
+
const worktreePostCreateCommand = validateWorktreePostCreateCommand(parsed.worktreePostCreateCommand, path);
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
...(concurrency === undefined ? {} : { concurrency }), ...(backgroundWidget === undefined ? {} : { backgroundWidget }), ...(modelAliases === undefined ? {} : { modelAliases }),
|
|
135
|
+
...(skills === undefined ? {} : { skills }), ...(extensions === undefined ? {} : { extensions }),
|
|
136
|
+
...(extensionSettings === undefined ? {} : { extensionSettings }), ...(tools === undefined ? {} : { tools }), ...(retention === undefined ? {} : { retention }),
|
|
137
|
+
...(worktreePostCreateCommand === undefined ? {} : { worktreePostCreateCommand }),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
export function loadSettings(path = workflowSettingsPath()): Readonly<WorkflowSettings> { return parseSettings(path, false); }
|
|
141
|
+
export function loadSettingsOverrides(path: string): Readonly<WorkflowSettingsOverrides> { return parseSettings(path, true); }
|
|
142
|
+
export function resolveWorkflowSettings(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): WorkflowSettingsResolution {
|
|
143
|
+
const projectSettingsPath = workflowProjectSettingsPath(cwd);
|
|
144
|
+
const global = loadSettings(globalSettingsPath);
|
|
145
|
+
const project: Readonly<WorkflowSettingsOverrides> = projectTrusted ? loadSettingsOverrides(projectSettingsPath) : Object.freeze({});
|
|
146
|
+
const projectHas = (key: keyof WorkflowSettingsOverrides): boolean => Object.prototype.hasOwnProperty.call(project, key);
|
|
147
|
+
const sourceFor = (key: "skills" | "extensions" | "tools"): string => projectHas(key) ? projectSettingsPath : globalSettingsPath;
|
|
148
|
+
const globalSelectors = selectorsFromSettings(global);
|
|
149
|
+
const projectSelectors = selectorsFromSettings(project);
|
|
150
|
+
const effectiveSelectors = selectorSet({
|
|
151
|
+
skills: [...(globalSelectors.skills ?? []), ...(projectSelectors.skills ?? [])],
|
|
152
|
+
extensions: [...(globalSelectors.extensions ?? []), ...(projectSelectors.extensions ?? [])],
|
|
153
|
+
...(globalSelectors.tools === undefined && projectSelectors.tools === undefined ? {} : { tools: [...(globalSelectors.tools ?? []), ...(projectSelectors.tools ?? [])] }),
|
|
154
|
+
});
|
|
155
|
+
const hasExtensionSelectors = global.extensions !== undefined || project.extensions !== undefined;
|
|
156
|
+
const extensionSettings = projectHas("extensionSettings") ? project.extensionSettings : global.extensionSettings;
|
|
157
|
+
const sources: WorkflowSettingsSources = {
|
|
158
|
+
concurrency: projectHas("concurrency") ? projectSettingsPath : globalSettingsPath,
|
|
159
|
+
modelAliases: projectHas("modelAliases") ? projectSettingsPath : globalSettingsPath,
|
|
160
|
+
skills: sourceFor("skills"), extensions: sourceFor("extensions"), tools: sourceFor("tools"),
|
|
161
|
+
...(extensionSettings === undefined ? {} : { extensionSettings: projectHas("extensionSettings") ? projectSettingsPath : globalSettingsPath }),
|
|
162
|
+
...(project.retention === undefined && global.retention === undefined ? {} : { retention: project.retention === undefined ? globalSettingsPath : projectSettingsPath }),
|
|
163
|
+
...(projectHas("worktreePostCreateCommand") || global.worktreePostCreateCommand !== undefined ? { worktreePostCreateCommand: projectHas("worktreePostCreateCommand") ? projectSettingsPath : globalSettingsPath } : {}),
|
|
164
|
+
};
|
|
165
|
+
const effective = Object.freeze({
|
|
166
|
+
concurrency: project.concurrency ?? global.concurrency,
|
|
167
|
+
backgroundWidget: global.backgroundWidget ?? true,
|
|
168
|
+
...(projectHas("modelAliases") ? { modelAliases: project.modelAliases } : global.modelAliases === undefined ? {} : { modelAliases: global.modelAliases }),
|
|
169
|
+
...(effectiveSelectors.skills.length ? { skills: effectiveSelectors.skills } : global.skills !== undefined || project.skills !== undefined ? { skills: effectiveSelectors.skills } : {}),
|
|
170
|
+
...(hasExtensionSelectors ? { extensions: effectiveSelectors.extensions } : {}),
|
|
171
|
+
...(extensionSettings === undefined ? {} : { extensionSettings }),
|
|
172
|
+
...(effectiveSelectors.tools?.length ? { tools: effectiveSelectors.tools } : global.tools !== undefined || project.tools !== undefined ? { tools: effectiveSelectors.tools } : {}),
|
|
173
|
+
...((project.retention ?? global.retention) === undefined ? {} : { retention: project.retention ?? global.retention }),
|
|
174
|
+
...(projectHas("worktreePostCreateCommand") ? { worktreePostCreateCommand: project.worktreePostCreateCommand } : global.worktreePostCreateCommand === undefined ? {} : { worktreePostCreateCommand: global.worktreePostCreateCommand }),
|
|
175
|
+
});
|
|
176
|
+
return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, sources };
|
|
177
|
+
}
|
|
178
|
+
export function validateModelAliasAvailability(aliases: Readonly<Record<string, string>>, names: readonly string[], availableModels: ReadonlySet<string>, knownModels: ReadonlySet<string>, settingsPath?: string): void {
|
|
179
|
+
for (const name of names) {
|
|
180
|
+
try {
|
|
181
|
+
const target = modelCapability(name, aliases, knownModels, settingsPath);
|
|
182
|
+
if (!availableModels.has(target)) unknownModel(name, target, settingsPath);
|
|
183
|
+
} catch (error) { throw annotateModelAliasError(error, name); }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath = workflowSettingsPath()): AgentResourcePolicy {
|
|
187
|
+
const resolved = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
|
|
188
|
+
const global = selectorSet(selectorsFromSettings(resolved.global));
|
|
189
|
+
const project = selectorSet(selectorsFromSettings(resolved.project));
|
|
190
|
+
const effective = selectorSet(selectorsFromSettings(resolved.effective));
|
|
191
|
+
return { globalSettingsPath: resolved.globalSettingsPath, projectSettingsPath: resolved.projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [], unmatchedTools: [], selectorSources: { global: selectorsFromSettings(resolved.global), project: selectorsFromSettings(resolved.project) } };
|
|
192
|
+
}
|
|
193
|
+
export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonly<Record<string, string>> = {}): void {
|
|
194
|
+
const normalized = validateModelAliases(aliases, path);
|
|
195
|
+
let parsed: unknown = {};
|
|
196
|
+
try {
|
|
197
|
+
loadSettings(path);
|
|
198
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (!isNodeError(error, "ENOENT")) throw error;
|
|
201
|
+
}
|
|
202
|
+
if (!object(parsed)) fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
|
|
203
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
204
|
+
atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
|
|
205
|
+
}
|
|
206
|
+
|