@jwilger/pi-development-system 0.10.6 → 0.12.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 +9 -1
- package/agents/advisor.md +40 -0
- package/agents/architect.md +66 -0
- package/agents/coder.md +57 -0
- package/agents/implementer.md +47 -0
- package/agents/lens-cagan.md +54 -0
- package/agents/lens-perri.md +54 -0
- package/agents/lens-pichler.md +54 -0
- package/agents/lens-rumelt.md +54 -0
- package/agents/lens-torres.md +54 -0
- package/agents/researcher.md +70 -0
- package/agents/reviewer.md +57 -0
- package/agents/tasker.md +58 -0
- package/agents/writer.md +39 -0
- package/extensions/development-system.ts +4 -0
- package/package.json +5 -3
- package/skills/delegation/SKILL.md +66 -0
- package/src/core/models.ts +18 -0
- package/src/core/routing.ts +67 -0
- package/src/core/spawn-overrides.ts +59 -0
- package/src/jev/questions/route.ts +93 -0
- package/src/state/config.ts +2 -4
- package/src/state/route-task-tool.ts +74 -0
- package/src/subagents/VENDORED.md +40 -0
- package/src/subagents/index.ts +544 -0
- package/src/subagents/orch/inherited-tools.ts +85 -0
- package/src/subagents/orch/mailbox.ts +262 -0
- package/src/subagents/orch/manager.ts +778 -0
- package/src/subagents/orch/paths.ts +74 -0
- package/src/subagents/orch/prompt.ts +25 -0
- package/src/subagents/orch/runtime.ts +791 -0
- package/src/subagents/orch/tools.ts +177 -0
- package/src/subagents/orch/transcript.ts +187 -0
- package/src/subagents/prefs/agent-import.ts +246 -0
- package/src/subagents/prefs/config.ts +1029 -0
- package/src/subagents/prefs/import-discovery.ts +644 -0
- package/src/subagents/prefs/import-instructions.ts +157 -0
- package/src/subagents/prefs/models.ts +85 -0
- package/src/subagents/prefs/settings.ts +371 -0
- package/src/subagents/types.ts +209 -0
- package/src/subagents/ui/agent-navigation-editor.ts +210 -0
- package/src/subagents/ui/dialog.ts +651 -0
- package/src/subagents/ui/import-picker.ts +232 -0
- package/src/subagents/ui/live-agent-view.ts +572 -0
- package/src/subagents/ui/model-picker.ts +452 -0
- package/src/subagents/ui/settings-ui.ts +329 -0
- package/src/subagents/ui/status-ui.ts +545 -0
- package/src/subagents/ui/thread-tree.ts +135 -0
- package/src/subagents/ui/ui.ts +1507 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
|
+
import { lstatSync, readdirSync, readFileSync, type Dirent, type Stats } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { parseDocument } from "yaml";
|
|
6
|
+
|
|
7
|
+
export interface ImportCandidate {
|
|
8
|
+
id: string;
|
|
9
|
+
path: string;
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
scope: "user" | "project";
|
|
13
|
+
settingsPaths: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DiscoverImportOptions {
|
|
17
|
+
cwd: string;
|
|
18
|
+
agentDir: string;
|
|
19
|
+
includeProject: boolean;
|
|
20
|
+
homeDir?: string;
|
|
21
|
+
extraAgentDirs?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EXTRA_AGENT_DIRS_ENV = "PI_SUBAGENT_EXTRA_AGENT_DIRS";
|
|
25
|
+
const SKIPPED_DIR_NAMES = new Set([".git", "node_modules"]);
|
|
26
|
+
|
|
27
|
+
function isSkillDirectoryName(name: string): boolean {
|
|
28
|
+
const normalized = name.toLowerCase();
|
|
29
|
+
return normalized === "skills" || normalized === ".skills";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isSkillMarkdownName(name: string): boolean {
|
|
33
|
+
return name.toLowerCase() === "skill.md";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when any path segment is a skills or .skills directory, in any case. */
|
|
37
|
+
function isSkillPath(filePath: string): boolean {
|
|
38
|
+
return normalize(filePath)
|
|
39
|
+
.split(sep)
|
|
40
|
+
.some((segment) => isSkillDirectoryName(segment));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Scope = ImportCandidate["scope"];
|
|
44
|
+
type Permissive = "agents" | "broad" | "strict";
|
|
45
|
+
|
|
46
|
+
interface ScanRoot {
|
|
47
|
+
directory: string;
|
|
48
|
+
scope: Scope;
|
|
49
|
+
permissive: Permissive;
|
|
50
|
+
settingsPaths: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface DisplayFields {
|
|
54
|
+
name?: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
packageName?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Listed =
|
|
60
|
+
| { kind: "absent" }
|
|
61
|
+
| { kind: "symlink" }
|
|
62
|
+
| { kind: "error"; message: string }
|
|
63
|
+
| { kind: "ok"; stat: Stats };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read-only discovery of definitions the user may import from tintinweb/pi-subagents
|
|
67
|
+
* and npm pi-subagents. File contents are data, not instructions: only name,
|
|
68
|
+
* description, and package are read, and only for display. Nothing is migrated
|
|
69
|
+
* or written. Package-provided and builtin agents are not discovery roots.
|
|
70
|
+
* Skill directories and SKILL.md files are not agent definitions: they are skipped
|
|
71
|
+
* before their contents are read, including when a configured root points at them.
|
|
72
|
+
*/
|
|
73
|
+
export function discoverImportCandidates(options: DiscoverImportOptions): {
|
|
74
|
+
candidates: ImportCandidate[];
|
|
75
|
+
diagnostics: string[];
|
|
76
|
+
} {
|
|
77
|
+
const diagnostics: string[] = [];
|
|
78
|
+
const candidates: ImportCandidate[] = [];
|
|
79
|
+
try {
|
|
80
|
+
collectCandidates(options, candidates, diagnostics);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
diagnostics.push(`Import discovery failed: ${detail(error)}`);
|
|
83
|
+
}
|
|
84
|
+
candidates.sort(
|
|
85
|
+
(left, right) => compare(left.name, right.name) || compare(left.path, right.path),
|
|
86
|
+
);
|
|
87
|
+
return { candidates, diagnostics: unique(diagnostics) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectCandidates(
|
|
91
|
+
options: DiscoverImportOptions,
|
|
92
|
+
candidates: ImportCandidate[],
|
|
93
|
+
diagnostics: string[],
|
|
94
|
+
): void {
|
|
95
|
+
const cwd = resolve(options.cwd);
|
|
96
|
+
const agentDir = resolve(options.agentDir);
|
|
97
|
+
const homeDir = resolve(options.homeDir ?? homedir());
|
|
98
|
+
const extraRaw = options.extraAgentDirs ?? process.env[EXTRA_AGENT_DIRS_ENV] ?? "";
|
|
99
|
+
const userSettingsPath = resolve(agentDir, "settings.json");
|
|
100
|
+
const userBase = realDirectory(agentDir, [], diagnostics);
|
|
101
|
+
const userSettings = userBase
|
|
102
|
+
? readScanSettings(userSettingsPath, diagnostics)
|
|
103
|
+
: { scanDirs: [], excludes: [] };
|
|
104
|
+
const userExcludes = userSettings.excludes.map((entry) =>
|
|
105
|
+
resolveLiteral(entry, agentDir, homeDir),
|
|
106
|
+
);
|
|
107
|
+
const managerDirs = [
|
|
108
|
+
resolve(agentDir, "subagent-manager", "agents"),
|
|
109
|
+
resolve(cwd, ".pi", "agent", "subagent-manager", "agents"),
|
|
110
|
+
];
|
|
111
|
+
const projectRoot = options.includeProject ? findNearestProjectRoot(cwd, homeDir) : undefined;
|
|
112
|
+
const projectSettingsDir = projectRoot
|
|
113
|
+
? realDirectory(projectRoot, [".pi"], diagnostics)
|
|
114
|
+
: undefined;
|
|
115
|
+
const projectSettingsPath = projectSettingsDir
|
|
116
|
+
? resolve(projectSettingsDir, "settings.json")
|
|
117
|
+
: undefined;
|
|
118
|
+
const projectSettings = projectSettingsPath
|
|
119
|
+
? readScanSettings(projectSettingsPath, diagnostics)
|
|
120
|
+
: { scanDirs: [], excludes: [] };
|
|
121
|
+
const projectExcludes = projectSettings.excludes.map((entry) =>
|
|
122
|
+
resolveLiteral(entry, projectSettingsPath ? dirname(projectSettingsPath) : cwd, homeDir),
|
|
123
|
+
);
|
|
124
|
+
const mergedProjectExcludes = [...userExcludes, ...projectExcludes];
|
|
125
|
+
const userSettingsPaths = userBase ? existingFiles([userSettingsPath], diagnostics) : [];
|
|
126
|
+
const projectSettingsPaths = existingFiles(
|
|
127
|
+
[...userSettingsPaths, ...(projectSettingsPath ? [projectSettingsPath] : [])],
|
|
128
|
+
diagnostics,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const roots: ScanRoot[] = [];
|
|
132
|
+
const scheduled = new Set<string>();
|
|
133
|
+
const schedule = (directory: string | undefined, scope: Scope, permissive: Permissive) => {
|
|
134
|
+
if (!directory) return;
|
|
135
|
+
const resolved = resolve(directory);
|
|
136
|
+
if (isSkillPath(resolved) || scheduled.has(resolved) || isManagerOwned(resolved, managerDirs)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const excludes = scope === "project" ? mergedProjectExcludes : userExcludes;
|
|
140
|
+
if (isExcluded(resolved, excludes)) return;
|
|
141
|
+
scheduled.add(resolved);
|
|
142
|
+
roots.push({
|
|
143
|
+
directory: resolved,
|
|
144
|
+
scope,
|
|
145
|
+
permissive,
|
|
146
|
+
settingsPaths: scope === "project" ? projectSettingsPaths : userSettingsPaths,
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
schedule(realDirectory(agentDir, ["agents"], diagnostics), "user", "agents");
|
|
151
|
+
schedule(realDirectory(homeDir, [".agents"], diagnostics), "user", "broad");
|
|
152
|
+
if (projectRoot) {
|
|
153
|
+
schedule(realDirectory(projectRoot, [".pi", "agents"], diagnostics), "project", "agents");
|
|
154
|
+
schedule(realDirectory(projectRoot, [".agents"], diagnostics), "project", "broad");
|
|
155
|
+
}
|
|
156
|
+
for (const directory of extraDirectories(extraRaw, cwd, homeDir, diagnostics)) {
|
|
157
|
+
schedule(directory, "user", "strict");
|
|
158
|
+
}
|
|
159
|
+
for (const directory of expandScanDirs(
|
|
160
|
+
userSettings.scanDirs,
|
|
161
|
+
agentDir,
|
|
162
|
+
homeDir,
|
|
163
|
+
userSettingsPath,
|
|
164
|
+
diagnostics,
|
|
165
|
+
)) {
|
|
166
|
+
schedule(directory, "user", "strict");
|
|
167
|
+
}
|
|
168
|
+
if (projectRoot && projectSettingsPath) {
|
|
169
|
+
for (const directory of expandScanDirs(
|
|
170
|
+
projectSettings.scanDirs,
|
|
171
|
+
dirname(projectSettingsPath),
|
|
172
|
+
homeDir,
|
|
173
|
+
projectSettingsPath,
|
|
174
|
+
diagnostics,
|
|
175
|
+
)) {
|
|
176
|
+
schedule(directory, "project", "strict");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const seen = new Set<string>();
|
|
181
|
+
for (const root of roots) {
|
|
182
|
+
const excludes = root.scope === "project" ? mergedProjectExcludes : userExcludes;
|
|
183
|
+
walk(root, excludes, managerDirs, seen, candidates, diagnostics);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function walk(
|
|
188
|
+
root: ScanRoot,
|
|
189
|
+
excludes: string[],
|
|
190
|
+
managerDirs: string[],
|
|
191
|
+
seen: Set<string>,
|
|
192
|
+
candidates: ImportCandidate[],
|
|
193
|
+
diagnostics: string[],
|
|
194
|
+
): void {
|
|
195
|
+
if (isSkillPath(root.directory)) return;
|
|
196
|
+
const skipHidden = basenameOf(root.directory) === ".agents";
|
|
197
|
+
const visit = (directory: string): void => {
|
|
198
|
+
if (
|
|
199
|
+
isSkillPath(directory) ||
|
|
200
|
+
isManagerOwned(directory, managerDirs) ||
|
|
201
|
+
isExcluded(directory, excludes)
|
|
202
|
+
) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
let entries: Dirent[];
|
|
206
|
+
try {
|
|
207
|
+
const stat = lstatSync(directory);
|
|
208
|
+
if (stat.isSymbolicLink()) return;
|
|
209
|
+
if (!stat.isDirectory()) {
|
|
210
|
+
diagnostics.push(`${directory}: Agent directory must be a directory`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (errorCode(error) === "ENOENT") return;
|
|
216
|
+
diagnostics.push(`${directory}: ${detail(error)}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const entry of [...entries].sort((left, right) => compare(left.name, right.name))) {
|
|
220
|
+
if (entry.isSymbolicLink()) continue;
|
|
221
|
+
const child = join(directory, entry.name);
|
|
222
|
+
if (entry.isDirectory()) {
|
|
223
|
+
if (SKIPPED_DIR_NAMES.has(entry.name) || isSkillDirectoryName(entry.name)) continue;
|
|
224
|
+
if (skipHidden && entry.name.startsWith(".")) continue;
|
|
225
|
+
if (isManagerOwned(child, managerDirs) || isExcluded(child, excludes)) continue;
|
|
226
|
+
visit(child);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (isSkillMarkdownName(entry.name)) continue;
|
|
230
|
+
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.endsWith(".chain.md")) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (isExcluded(child, excludes) || isManagerOwned(child, managerDirs)) continue;
|
|
234
|
+
considerFile(child, root, seen, candidates, diagnostics);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
visit(root.directory);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function considerFile(
|
|
241
|
+
filePath: string,
|
|
242
|
+
root: ScanRoot,
|
|
243
|
+
seen: Set<string>,
|
|
244
|
+
candidates: ImportCandidate[],
|
|
245
|
+
diagnostics: string[],
|
|
246
|
+
): void {
|
|
247
|
+
if (isSkillMarkdownName(basenameOf(filePath)) || isSkillPath(filePath)) return;
|
|
248
|
+
const resolved = resolve(filePath);
|
|
249
|
+
if (seen.has(resolved)) return;
|
|
250
|
+
seen.add(resolved);
|
|
251
|
+
let content = "";
|
|
252
|
+
try {
|
|
253
|
+
const stat = lstatSync(resolved);
|
|
254
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return;
|
|
255
|
+
content = readFileSync(resolved, "utf8");
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (errorCode(error) !== "ENOENT") diagnostics.push(`${resolved}: ${detail(error)}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let fields: DisplayFields;
|
|
261
|
+
try {
|
|
262
|
+
fields = readDisplayFields(content);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
diagnostics.push(`${resolved}: ${detail(error)}`);
|
|
265
|
+
const display = looseDisplayFields(content);
|
|
266
|
+
if (!display) return;
|
|
267
|
+
fields = display;
|
|
268
|
+
}
|
|
269
|
+
const permissive = allowsFilenameFallback(root, resolved);
|
|
270
|
+
const rawName = fields.name?.trim() ?? "";
|
|
271
|
+
const name = rawName || (permissive ? filenameName(resolved) : "");
|
|
272
|
+
const description = fields.description ?? (permissive ? "" : undefined);
|
|
273
|
+
if (!name || description === undefined || (!permissive && !description.trim())) return;
|
|
274
|
+
const packageName = fields.packageName?.trim() ?? "";
|
|
275
|
+
candidates.push({
|
|
276
|
+
id: resolved,
|
|
277
|
+
path: resolved,
|
|
278
|
+
name: packageName ? `${packageName}.${name}` : name,
|
|
279
|
+
description,
|
|
280
|
+
scope: root.scope,
|
|
281
|
+
settingsPaths: root.settingsPaths,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// npm's parser permits non-YAML values in unrelated fields (e.g. tools: *).
|
|
286
|
+
// This fallback extracts only unambiguous scalar display metadata, never converts an agent.
|
|
287
|
+
function looseDisplayFields(content: string): DisplayFields | undefined {
|
|
288
|
+
const extracted = frontmatter(content);
|
|
289
|
+
if (!extracted) return undefined;
|
|
290
|
+
const fields: Record<string, string> = {};
|
|
291
|
+
for (const key of ["name", "description", "package"]) {
|
|
292
|
+
const matches = [...extracted.yaml.matchAll(new RegExp(`^${key}:\\s*(.*)$`, "gm"))];
|
|
293
|
+
if (matches.length > 1) return undefined;
|
|
294
|
+
if (!matches.length) continue;
|
|
295
|
+
const value = matches[0]![1]!.trim();
|
|
296
|
+
if (!value || /^[&*[{>|]/.test(value)) continue;
|
|
297
|
+
try {
|
|
298
|
+
const scalar = parseDocument(value);
|
|
299
|
+
if (scalar.errors.length) continue;
|
|
300
|
+
const parsed = scalar.toJS({ maxAliasCount: 0 });
|
|
301
|
+
if (typeof parsed === "string") fields[key] = parsed;
|
|
302
|
+
} catch {
|
|
303
|
+
/* Ambiguous display metadata is not selectable. */
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!fields.name || !fields.description) return undefined;
|
|
307
|
+
return { name: fields.name, description: fields.description, packageName: fields.package };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Source text is data. Alias expansion is rejected when the document is materialized. */
|
|
311
|
+
function readDisplayFields(content: string): DisplayFields {
|
|
312
|
+
const extracted = frontmatter(content);
|
|
313
|
+
if (!extracted) return {};
|
|
314
|
+
const doc = parseDocument(extracted.yaml);
|
|
315
|
+
if (doc.errors.length) {
|
|
316
|
+
throw new Error(doc.errors.map((error) => error.message).join("; "));
|
|
317
|
+
}
|
|
318
|
+
const data = doc.toJS({ maxAliasCount: 0 });
|
|
319
|
+
if (data === null || data === undefined) return {};
|
|
320
|
+
if (typeof data !== "object" || Array.isArray(data)) {
|
|
321
|
+
throw new Error("Frontmatter must be a mapping");
|
|
322
|
+
}
|
|
323
|
+
const record = data as Record<string, unknown>;
|
|
324
|
+
return {
|
|
325
|
+
name: typeof record.name === "string" ? record.name : undefined,
|
|
326
|
+
description: typeof record.description === "string" ? record.description : undefined,
|
|
327
|
+
packageName: typeof record.package === "string" ? record.package : undefined,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function frontmatter(content: string): { yaml: string } | undefined {
|
|
332
|
+
const match = /^(?:\uFEFF)?---\r?\n([\s\S]*?)^---[ \t]*(?:\r?\n|$)/m.exec(content);
|
|
333
|
+
if (!match || match.index !== 0) return undefined;
|
|
334
|
+
return { yaml: match[1] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function allowsFilenameFallback(root: ScanRoot, filePath: string): boolean {
|
|
338
|
+
if (root.permissive === "agents") return true;
|
|
339
|
+
if (root.permissive !== "broad") return false;
|
|
340
|
+
const parts = relative(root.directory, filePath).split(sep);
|
|
341
|
+
return parts.length === 2 && parts[0] === "agents" && parts[1].endsWith(".md");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function filenameName(filePath: string): string {
|
|
345
|
+
const base = basenameOf(filePath);
|
|
346
|
+
return base.endsWith(".md") ? base.slice(0, -3) : base;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function findNearestProjectRoot(cwd: string, homeDir: string): string | undefined {
|
|
350
|
+
let current = resolve(cwd);
|
|
351
|
+
const home = resolve(homeDir);
|
|
352
|
+
while (current !== home) {
|
|
353
|
+
const stat = listed(current);
|
|
354
|
+
if (stat.kind === "ok" && stat.stat.isDirectory()) {
|
|
355
|
+
if (isRealDirectory(join(current, ".pi")) || isRealDirectory(join(current, ".agents"))) {
|
|
356
|
+
return current;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const parent = dirname(current);
|
|
360
|
+
if (parent === current) return undefined;
|
|
361
|
+
current = parent;
|
|
362
|
+
}
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isRealDirectory(path: string): boolean {
|
|
367
|
+
const stat = listed(path);
|
|
368
|
+
return stat.kind === "ok" && stat.stat.isDirectory();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function realDirectory(
|
|
372
|
+
base: string,
|
|
373
|
+
segments: string[],
|
|
374
|
+
diagnostics: string[],
|
|
375
|
+
): string | undefined {
|
|
376
|
+
let current = resolve(base);
|
|
377
|
+
const accept = (path: string): boolean => {
|
|
378
|
+
const stat = listed(path);
|
|
379
|
+
if (stat.kind === "absent" || stat.kind === "symlink") return false;
|
|
380
|
+
if (stat.kind === "error") {
|
|
381
|
+
diagnostics.push(`${path}: ${stat.message}`);
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
if (!stat.stat.isDirectory()) {
|
|
385
|
+
diagnostics.push(`${path}: Agent directory must be a directory`);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
return true;
|
|
389
|
+
};
|
|
390
|
+
if (!accept(current)) return undefined;
|
|
391
|
+
for (const segment of segments) {
|
|
392
|
+
current = join(current, segment);
|
|
393
|
+
if (!accept(current)) return undefined;
|
|
394
|
+
}
|
|
395
|
+
return current;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function extraDirectories(
|
|
399
|
+
raw: string,
|
|
400
|
+
cwd: string,
|
|
401
|
+
homeDir: string,
|
|
402
|
+
diagnostics: string[],
|
|
403
|
+
): string[] {
|
|
404
|
+
const directories: string[] = [];
|
|
405
|
+
for (const entry of raw.split(delimiter)) {
|
|
406
|
+
const trimmed = entry.trim();
|
|
407
|
+
if (!trimmed) continue;
|
|
408
|
+
const target = resolve(cwd, expandTilde(trimmed, homeDir));
|
|
409
|
+
if (isSkillPath(target) || hasSymlinkComponent(cwd, target)) continue;
|
|
410
|
+
const stat = listed(target);
|
|
411
|
+
if (stat.kind === "absent" || stat.kind === "symlink") continue;
|
|
412
|
+
if (stat.kind === "error") {
|
|
413
|
+
diagnostics.push(`${target}: ${stat.message}`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (!stat.stat.isDirectory()) {
|
|
417
|
+
diagnostics.push(`${target}: Agent directory must be a directory`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
directories.push(target);
|
|
421
|
+
}
|
|
422
|
+
return directories;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function expandScanDirs(
|
|
426
|
+
patterns: string[],
|
|
427
|
+
baseDir: string,
|
|
428
|
+
homeDir: string,
|
|
429
|
+
settingsPath: string,
|
|
430
|
+
diagnostics: string[],
|
|
431
|
+
): string[] {
|
|
432
|
+
const directories: string[] = [];
|
|
433
|
+
for (const pattern of patterns) {
|
|
434
|
+
const expanded = expandTilde(pattern, homeDir).replace(/[\\/]+/g, sep);
|
|
435
|
+
const absolute = isAbsolute(expanded) ? expanded : join(resolve(baseDir), expanded);
|
|
436
|
+
const normalized = normalize(absolute);
|
|
437
|
+
const parts = normalized.split(sep);
|
|
438
|
+
const starCount = [...normalized.matchAll(/\*/g)].length;
|
|
439
|
+
if (starCount === 0) {
|
|
440
|
+
if (isSkillPath(normalized)) continue;
|
|
441
|
+
const directory = acceptDirectory(normalized, baseDir, diagnostics);
|
|
442
|
+
if (directory) directories.push(directory);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const wildcardAt = parts.findIndex((part) => part.includes("*"));
|
|
446
|
+
if (starCount !== 1 || wildcardAt < 0 || parts[wildcardAt] !== "*") {
|
|
447
|
+
diagnostics.push(
|
|
448
|
+
`${settingsPath}: Invalid agentScanDirs pattern '${pattern}': expected one '*' path segment`,
|
|
449
|
+
);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const wildcardBase = wildcardDirectory(parts.slice(0, wildcardAt));
|
|
453
|
+
if (isSkillPath(wildcardBase) || !acceptDirectory(wildcardBase, baseDir, diagnostics)) continue;
|
|
454
|
+
let entries: Dirent[];
|
|
455
|
+
try {
|
|
456
|
+
entries = readdirSync(wildcardBase, { withFileTypes: true });
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (errorCode(error) !== "ENOENT") diagnostics.push(`${wildcardBase}: ${detail(error)}`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const rest = parts.slice(wildcardAt + 1);
|
|
462
|
+
for (const entry of [...entries].sort((left, right) => compare(left.name, right.name))) {
|
|
463
|
+
if (entry.isSymbolicLink() || !entry.isDirectory() || isSkillDirectoryName(entry.name)) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const target = join(wildcardBase, entry.name, ...rest);
|
|
467
|
+
if (isSkillPath(target)) continue;
|
|
468
|
+
const directory = acceptDirectory(target, wildcardBase, diagnostics);
|
|
469
|
+
if (directory) directories.push(directory);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return directories;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function wildcardDirectory(parts: string[]): string {
|
|
476
|
+
if (parts.length === 0) return sep;
|
|
477
|
+
const joined = parts.join(sep);
|
|
478
|
+
return joined || sep;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function acceptDirectory(target: string, base: string, diagnostics: string[]): string | undefined {
|
|
482
|
+
const resolved = resolve(target);
|
|
483
|
+
if (hasSymlinkComponent(base, resolved)) return undefined;
|
|
484
|
+
const stat = listed(resolved);
|
|
485
|
+
if (stat.kind === "absent" || stat.kind === "symlink") return undefined;
|
|
486
|
+
if (stat.kind === "error") {
|
|
487
|
+
diagnostics.push(`${resolved}: ${stat.message}`);
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
if (!stat.stat.isDirectory()) {
|
|
491
|
+
diagnostics.push(`${resolved}: Agent directory must be a directory`);
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
return resolved;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function readScanSettings(
|
|
498
|
+
filePath: string,
|
|
499
|
+
diagnostics: string[],
|
|
500
|
+
): { scanDirs: string[]; excludes: string[] } {
|
|
501
|
+
const empty = { scanDirs: [], excludes: [] };
|
|
502
|
+
const stat = listed(filePath);
|
|
503
|
+
if (stat.kind === "absent" || stat.kind === "symlink") return empty;
|
|
504
|
+
if (stat.kind === "error") {
|
|
505
|
+
diagnostics.push(`${filePath}: ${stat.message}`);
|
|
506
|
+
return empty;
|
|
507
|
+
}
|
|
508
|
+
if (!stat.stat.isFile()) {
|
|
509
|
+
diagnostics.push(`${filePath}: Settings must be a regular file`);
|
|
510
|
+
return empty;
|
|
511
|
+
}
|
|
512
|
+
let parsed: unknown;
|
|
513
|
+
try {
|
|
514
|
+
parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
515
|
+
} catch (error) {
|
|
516
|
+
diagnostics.push(`${filePath}: ${detail(error)}`);
|
|
517
|
+
return empty;
|
|
518
|
+
}
|
|
519
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
520
|
+
diagnostics.push(`${filePath}: Settings must be a JSON object`);
|
|
521
|
+
return empty;
|
|
522
|
+
}
|
|
523
|
+
const subagents = (parsed as Record<string, unknown>).subagents;
|
|
524
|
+
if (subagents === undefined) return empty;
|
|
525
|
+
if (subagents === null || typeof subagents !== "object" || Array.isArray(subagents)) {
|
|
526
|
+
diagnostics.push(`${filePath}: subagents must be a JSON object`);
|
|
527
|
+
return empty;
|
|
528
|
+
}
|
|
529
|
+
const record = subagents as Record<string, unknown>;
|
|
530
|
+
return {
|
|
531
|
+
scanDirs: stringList(record, "agentScanDirs", filePath, diagnostics),
|
|
532
|
+
excludes: stringList(record, "agentExcludeDirs", filePath, diagnostics),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function stringList(
|
|
537
|
+
record: Record<string, unknown>,
|
|
538
|
+
key: string,
|
|
539
|
+
filePath: string,
|
|
540
|
+
diagnostics: string[],
|
|
541
|
+
): string[] {
|
|
542
|
+
if (!Object.hasOwn(record, key)) return [];
|
|
543
|
+
const value = record[key];
|
|
544
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
|
|
545
|
+
diagnostics.push(`${filePath}: subagents.${key} must be an array of non-empty strings`);
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
return value.map((item: string) => item.trim());
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function existingFiles(paths: string[], diagnostics: string[]): string[] {
|
|
552
|
+
const files: string[] = [];
|
|
553
|
+
for (const filePath of paths) {
|
|
554
|
+
const resolved = resolve(filePath);
|
|
555
|
+
const stat = listed(resolved);
|
|
556
|
+
if (stat.kind === "absent" || stat.kind === "symlink") continue;
|
|
557
|
+
if (stat.kind === "error") {
|
|
558
|
+
diagnostics.push(`${resolved}: ${stat.message}`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!stat.stat.isFile()) {
|
|
562
|
+
diagnostics.push(`${resolved}: Settings must be a regular file`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
files.push(resolved);
|
|
566
|
+
}
|
|
567
|
+
return files;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Symlink checks start at the configured base, never at filesystem-root ancestors. */
|
|
571
|
+
function hasSymlinkComponent(base: string, target: string): boolean {
|
|
572
|
+
const resolvedBase = resolve(base);
|
|
573
|
+
const resolvedTarget = resolve(target);
|
|
574
|
+
const rel = relative(resolvedBase, resolvedTarget);
|
|
575
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
576
|
+
return listed(resolvedTarget).kind === "symlink";
|
|
577
|
+
}
|
|
578
|
+
let current = resolvedBase;
|
|
579
|
+
if (listed(current).kind === "symlink") return true;
|
|
580
|
+
for (const segment of rel.split(sep)) {
|
|
581
|
+
if (!segment || segment === ".") continue;
|
|
582
|
+
current = join(current, segment);
|
|
583
|
+
if (listed(current).kind === "symlink") return true;
|
|
584
|
+
}
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function isManagerOwned(target: string, managerDirs: string[]): boolean {
|
|
589
|
+
const resolved = resolve(target);
|
|
590
|
+
return managerDirs.some((directory) => isInside(directory, resolved));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isExcluded(target: string, excludes: string[]): boolean {
|
|
594
|
+
const resolved = resolve(target);
|
|
595
|
+
return excludes.some((directory) => isInside(directory, resolved));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function isInside(root: string, target: string): boolean {
|
|
599
|
+
const rel = relative(resolve(root), resolve(target));
|
|
600
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function resolveLiteral(entry: string, baseDir: string, homeDir: string): string {
|
|
604
|
+
return resolve(baseDir, expandTilde(entry.trim(), homeDir));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function expandTilde(value: string, homeDir: string): string {
|
|
608
|
+
if (value === "~") return homeDir;
|
|
609
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return join(homeDir, value.slice(2));
|
|
610
|
+
return value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function listed(path: string): Listed {
|
|
614
|
+
try {
|
|
615
|
+
const stat = lstatSync(path);
|
|
616
|
+
return stat.isSymbolicLink() ? { kind: "symlink" } : { kind: "ok", stat };
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (errorCode(error) === "ENOENT") return { kind: "absent" };
|
|
619
|
+
return { kind: "error", message: detail(error) };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function basenameOf(path: string): string {
|
|
624
|
+
const parts = path.split(sep);
|
|
625
|
+
return parts[parts.length - 1] ?? path;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function errorCode(error: unknown): string | undefined {
|
|
629
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
630
|
+
? String((error as { code?: unknown }).code)
|
|
631
|
+
: undefined;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function detail(error: unknown): string {
|
|
635
|
+
return error instanceof Error ? error.message : String(error);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function compare(left: string, right: string): number {
|
|
639
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function unique(values: string[]): string[] {
|
|
643
|
+
return [...new Set(values)];
|
|
644
|
+
}
|