@fro.bot/systematic 3.4.1 → 3.5.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/HARNESSES.md +9 -2
- package/agents/review/code-simplicity-reviewer.md +1 -1
- package/dist/cli.js +1420 -41
- package/dist/{index-0vm17gwv.js → index-1mb4baxr.js} +115 -4
- package/dist/index.js +165 -24
- package/dist/lib/config-schema.d.ts +66 -0
- package/dist/lib/config.d.ts +16 -2
- package/dist/lib/pi-subagents-export.d.ts +143 -0
- package/dist/lib/pi-subagents-personas.d.ts +64 -0
- package/dist/lib/workflow-guard.d.ts +14 -0
- package/dist/schemas/systematic-config.schema.json +168 -3
- package/package.json +5 -4
- package/skills/using-systematic/references/pi-profile.md +28 -2
package/dist/cli.js
CHANGED
|
@@ -6,18 +6,1219 @@ import {
|
|
|
6
6
|
findCommandsInDir,
|
|
7
7
|
findSkillsInDir,
|
|
8
8
|
getConfigPaths,
|
|
9
|
+
loadConfigWithSources,
|
|
9
10
|
modify,
|
|
10
11
|
parse,
|
|
12
|
+
parseFrontmatter,
|
|
11
13
|
parseTree
|
|
12
|
-
} from "./index-
|
|
14
|
+
} from "./index-1mb4baxr.js";
|
|
13
15
|
|
|
14
16
|
// src/cli.ts
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
+
import fs4 from "fs";
|
|
18
|
+
import path4 from "path";
|
|
19
|
+
|
|
20
|
+
// src/lib/pi-subagents-export.ts
|
|
21
|
+
import * as crypto2 from "crypto";
|
|
22
|
+
import * as fs2 from "fs";
|
|
23
|
+
import * as os from "os";
|
|
24
|
+
import * as path2 from "path";
|
|
25
|
+
import { fileURLToPath } from "url";
|
|
26
|
+
|
|
27
|
+
// src/lib/pi-subagents-personas.ts
|
|
28
|
+
import * as crypto from "crypto";
|
|
29
|
+
import * as fs from "fs";
|
|
30
|
+
import * as path from "path";
|
|
31
|
+
var CURATED_PERSONAS = [
|
|
32
|
+
{
|
|
33
|
+
relPath: "agents/research/best-practices-researcher.md",
|
|
34
|
+
rationale: "Self-contained research persona. INFO: mentions skills in prose as discovery hints, but is operationally independent \u2014 no load-skill directive, no parent-dispatch assumption."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
relPath: "agents/research/framework-docs-researcher.md",
|
|
38
|
+
rationale: "Self-contained framework documentation researcher. No Systematic-only coupling; uses standard tools (Context7, web search, file reads)."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
relPath: "agents/research/git-history-analyzer.md",
|
|
42
|
+
rationale: "Self-contained git archaeology persona. Uses only standard git CLI + file tools. No skill or orchestration dependencies."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
relPath: "agents/research/issue-intelligence-analyst.md",
|
|
46
|
+
rationale: "Self-contained GitHub issue analysis persona. Requires only gh CLI + standard file tools. No Systematic-only coupling."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
relPath: "agents/research/repo-research-analyst.md",
|
|
50
|
+
rationale: "Self-contained repository research persona. Uses standard file/search/glob tools. No Systematic-specific orchestration."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
relPath: "agents/review/adversarial-reviewer.md",
|
|
54
|
+
rationale: "Self-contained adversarial code reviewer. Fully portable \u2014 no Systematic tools or skills required."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
relPath: "agents/review/api-contract-reviewer.md",
|
|
58
|
+
rationale: "Self-contained API contract reviewer. No Systematic-only coupling."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
relPath: "agents/review/architecture-strategist.md",
|
|
62
|
+
rationale: "Self-contained architectural reviewer. No Systematic-only coupling."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
relPath: "agents/review/cli-readiness-reviewer.md",
|
|
66
|
+
rationale: "Self-contained CLI readiness reviewer. No Systematic-only coupling."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
relPath: "agents/review/code-simplicity-reviewer.md",
|
|
70
|
+
rationale: "Self-contained code simplicity reviewer. No Systematic-only coupling."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
relPath: "agents/review/correctness-reviewer.md",
|
|
74
|
+
rationale: "Self-contained correctness reviewer. No Systematic-only coupling."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
relPath: "agents/review/data-migrations-reviewer.md",
|
|
78
|
+
rationale: "Self-contained data migrations reviewer. No Systematic-only coupling."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
relPath: "agents/review/deployment-verification-agent.md",
|
|
82
|
+
rationale: "Self-contained deployment verification persona. No Systematic-only coupling."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
relPath: "agents/review/maintainability-reviewer.md",
|
|
86
|
+
rationale: "Self-contained maintainability reviewer. No Systematic-only coupling."
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
relPath: "agents/review/pattern-recognition-specialist.md",
|
|
90
|
+
rationale: "Self-contained pattern recognition reviewer. No Systematic-only coupling."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
relPath: "agents/review/performance-reviewer.md",
|
|
94
|
+
rationale: "Self-contained performance reviewer. No Systematic-only coupling."
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
relPath: "agents/review/previous-comments-reviewer.md",
|
|
98
|
+
rationale: "Self-contained previous-comments reviewer. Reviews prior feedback. No Systematic-only coupling."
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
relPath: "agents/review/reliability-reviewer.md",
|
|
102
|
+
rationale: "Self-contained reliability reviewer. No Systematic-only coupling."
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
relPath: "agents/review/security-reviewer.md",
|
|
106
|
+
rationale: "Self-contained security reviewer. No Systematic-only coupling."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
relPath: "agents/review/testing-reviewer.md",
|
|
110
|
+
rationale: "Self-contained testing reviewer. No Systematic-only coupling."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
relPath: "agents/document-review/adversarial-document-reviewer.md",
|
|
114
|
+
rationale: "Self-contained document reviewer. Challenges premises in plans/specs. No Systematic-only coupling."
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
relPath: "agents/document-review/coherence-reviewer.md",
|
|
118
|
+
rationale: "Self-contained document coherence reviewer. No Systematic-only coupling."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
relPath: "agents/document-review/design-lens-reviewer.md",
|
|
122
|
+
rationale: "Self-contained design lens reviewer. No Systematic-only coupling."
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
relPath: "agents/document-review/feasibility-reviewer.md",
|
|
126
|
+
rationale: "Self-contained feasibility reviewer. No Systematic-only coupling."
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
relPath: "agents/document-review/product-lens-reviewer.md",
|
|
130
|
+
rationale: "Self-contained product lens reviewer. No Systematic-only coupling."
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
relPath: "agents/document-review/scope-guardian-reviewer.md",
|
|
134
|
+
rationale: "Self-contained scope guardian reviewer. No Systematic-only coupling."
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
relPath: "agents/document-review/security-lens-reviewer.md",
|
|
138
|
+
rationale: "Self-contained security lens reviewer. No Systematic-only coupling."
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
relPath: "agents/workflow/spec-flow-analyzer.md",
|
|
142
|
+
rationale: "Self-contained spec flow analyzer. Analyzes specs for user flow completeness. No Systematic orchestration coupling."
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
relPath: "agents/workflow/bug-reproduction-validator.md",
|
|
146
|
+
rationale: "Self-contained bug reproduction specialist. WARNING: mentions agent-browser skill in prose as an optional tool hint, but does not require Systematic skill loading to operate."
|
|
147
|
+
}
|
|
148
|
+
];
|
|
149
|
+
function sanitizeName(name) {
|
|
150
|
+
return name.toLowerCase().replace(/[/\\]/g, "").replace(/\./g, "").replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
|
151
|
+
}
|
|
152
|
+
function foldIdentity(value) {
|
|
153
|
+
return value.normalize("NFC").toLowerCase();
|
|
154
|
+
}
|
|
155
|
+
var CRITICAL_PATTERNS = [
|
|
156
|
+
{
|
|
157
|
+
pattern: /dispatched by a parent/i,
|
|
158
|
+
reason: 'References parent-dispatch assumption ("dispatched by a parent") \u2014 this persona is designed to be orchestrated by Systematic.'
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
pattern: /report(?:ing)? back to the orchestrator/i,
|
|
162
|
+
reason: "References orchestrator-report assumption \u2014 this persona is designed to operate within Systematic's orchestration loop."
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
pattern: /\bload\s+(?:the\s+)?systematic:/i,
|
|
166
|
+
reason: 'Contains explicit Systematic skill load directive ("Load systematic:...") \u2014 requires Systematic skill loading infrastructure.'
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
pattern: /load\s+`systematic:/i,
|
|
170
|
+
reason: "Contains explicit Systematic skill load directive with backtick form \u2014 requires Systematic skill loading infrastructure."
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
pattern: /\.\.(\/|\\)\.\.(?:\/|\\)skills\//,
|
|
174
|
+
reason: "Directly references Systematic internal skill paths (../../skills/...) \u2014 not portable outside Systematic."
|
|
175
|
+
}
|
|
176
|
+
];
|
|
177
|
+
var WARNING_PATTERNS = [
|
|
178
|
+
{
|
|
179
|
+
pattern: /\bagent-browser\s+(?:CLI|skill)\b/i,
|
|
180
|
+
reason: "References agent-browser CLI/skill as an optional tool hint. Behavior may differ if agent-browser is not available, but persona remains operable without it."
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
pattern: /use\s+(?:the\s+)?agent-browser\s+skill/i,
|
|
184
|
+
reason: "Suggests using the agent-browser skill. Persona operates without it but visual verification steps would be skipped."
|
|
185
|
+
}
|
|
186
|
+
];
|
|
187
|
+
function classifyCompatibility(content) {
|
|
188
|
+
const reasons = [];
|
|
189
|
+
let severity = "info";
|
|
190
|
+
for (const { pattern, reason } of CRITICAL_PATTERNS) {
|
|
191
|
+
if (pattern.test(content)) {
|
|
192
|
+
reasons.push(reason);
|
|
193
|
+
severity = "critical";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (severity !== "critical") {
|
|
197
|
+
for (const { pattern, reason } of WARNING_PATTERNS) {
|
|
198
|
+
if (pattern.test(content)) {
|
|
199
|
+
reasons.push(reason);
|
|
200
|
+
severity = "warning";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { severity, reasons };
|
|
205
|
+
}
|
|
206
|
+
function generatePersonaContent(sourceRef, rawContent) {
|
|
207
|
+
const { data, body, hadFrontmatter } = parseFrontmatter(rawContent);
|
|
208
|
+
const rawName = data.name;
|
|
209
|
+
if (!hadFrontmatter || rawName === undefined || rawName === null) {
|
|
210
|
+
throw new Error(`Persona "${sourceRef}" is missing frontmatter "name" \u2014 cannot generate pi-subagents export.`);
|
|
211
|
+
}
|
|
212
|
+
if (typeof rawName !== "string") {
|
|
213
|
+
throw new Error(`Persona "${sourceRef}" has a frontmatter "name" of type ${typeof rawName} (expected a non-empty string).`);
|
|
214
|
+
}
|
|
215
|
+
const trimmedName = rawName.trim();
|
|
216
|
+
if (trimmedName === "") {
|
|
217
|
+
throw new Error(`Persona "${sourceRef}" has an empty frontmatter "name" \u2014 cannot generate pi-subagents export.`);
|
|
218
|
+
}
|
|
219
|
+
const sanitized = sanitizeName(trimmedName);
|
|
220
|
+
if (sanitized === "") {
|
|
221
|
+
throw new Error(`Persona "${sourceRef}" name "${trimmedName}" sanitizes to an empty string \u2014 cannot produce a valid filename. Rename the persona to use alphanumeric characters.`);
|
|
222
|
+
}
|
|
223
|
+
const compat = classifyCompatibility(rawContent);
|
|
224
|
+
if (compat.severity === "critical")
|
|
225
|
+
return null;
|
|
226
|
+
const description = typeof data.description === "string" ? data.description : undefined;
|
|
227
|
+
const emittedFrontmatter = {};
|
|
228
|
+
if (description !== undefined && description.trim() !== "") {
|
|
229
|
+
emittedFrontmatter.description = description;
|
|
230
|
+
}
|
|
231
|
+
let frontmatterBlock;
|
|
232
|
+
if (Object.keys(emittedFrontmatter).length === 0) {
|
|
233
|
+
frontmatterBlock = `---
|
|
234
|
+
---`;
|
|
235
|
+
} else {
|
|
236
|
+
const lines = Object.entries(emittedFrontmatter).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(`
|
|
237
|
+
`);
|
|
238
|
+
frontmatterBlock = `---
|
|
239
|
+
${lines}
|
|
240
|
+
---`;
|
|
241
|
+
}
|
|
242
|
+
const trimmedBody = body.replace(/^\n+/, "");
|
|
243
|
+
return `${frontmatterBlock}
|
|
244
|
+
|
|
245
|
+
${trimmedBody}`;
|
|
246
|
+
}
|
|
247
|
+
function generatePersonaManifest(sourceRelPath, rawContent, _repoRoot) {
|
|
248
|
+
const { data } = parseFrontmatter(rawContent);
|
|
249
|
+
const rawName = data.name;
|
|
250
|
+
if (typeof rawName !== "string" || rawName.trim() === "") {
|
|
251
|
+
throw new Error(`Persona "${sourceRelPath}" is missing or has an empty frontmatter "name".`);
|
|
252
|
+
}
|
|
253
|
+
const sanitized = sanitizeName(rawName.trim());
|
|
254
|
+
if (sanitized === "") {
|
|
255
|
+
throw new Error(`Persona "${sourceRelPath}" name "${rawName.trim()}" sanitizes to an empty string \u2014 cannot produce a valid filename. Rename the persona to use alphanumeric characters.`);
|
|
256
|
+
}
|
|
257
|
+
const filename = `systematic-${sanitized}.md`;
|
|
258
|
+
const compat = classifyCompatibility(rawContent);
|
|
259
|
+
if (compat.severity === "critical") {
|
|
260
|
+
return {
|
|
261
|
+
filename,
|
|
262
|
+
status: "excluded-critical",
|
|
263
|
+
sourceRelPath,
|
|
264
|
+
hash: "",
|
|
265
|
+
reason: compat.reasons.join("; ")
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const content = generatePersonaContent(sourceRelPath, rawContent);
|
|
269
|
+
if (content === null) {
|
|
270
|
+
return {
|
|
271
|
+
filename,
|
|
272
|
+
status: "excluded-critical",
|
|
273
|
+
sourceRelPath,
|
|
274
|
+
hash: "",
|
|
275
|
+
reason: "Compatibility screening rejected persona."
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const hash = crypto.createHash("sha256").update(content).digest("hex");
|
|
279
|
+
const status = compat.severity === "warning" ? "exported-with-warning" : "exported";
|
|
280
|
+
return {
|
|
281
|
+
filename,
|
|
282
|
+
status,
|
|
283
|
+
sourceRelPath,
|
|
284
|
+
hash,
|
|
285
|
+
content,
|
|
286
|
+
reason: compat.reasons.length > 0 ? compat.reasons.join("; ") : undefined
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function generateAll(repoRoot) {
|
|
290
|
+
const entries = [];
|
|
291
|
+
const byFoldedFilename = new Map;
|
|
292
|
+
for (const curatedEntry of CURATED_PERSONAS) {
|
|
293
|
+
const fullPath = path.join(repoRoot, curatedEntry.relPath);
|
|
294
|
+
let rawContent;
|
|
295
|
+
try {
|
|
296
|
+
rawContent = fs.readFileSync(fullPath, "utf-8");
|
|
297
|
+
} catch (err) {
|
|
298
|
+
throw new Error(`Failed to read ${curatedEntry.relPath}: ${err.message}`);
|
|
299
|
+
}
|
|
300
|
+
const entry = generatePersonaManifest(curatedEntry.relPath, rawContent, repoRoot);
|
|
301
|
+
entries.push(entry);
|
|
302
|
+
const folded = foldIdentity(entry.filename);
|
|
303
|
+
const existing = byFoldedFilename.get(folded) ?? [];
|
|
304
|
+
existing.push(curatedEntry.relPath);
|
|
305
|
+
byFoldedFilename.set(folded, existing);
|
|
306
|
+
}
|
|
307
|
+
const collisions = [...byFoldedFilename.entries()].filter(([, sources]) => sources.length > 1);
|
|
308
|
+
if (collisions.length > 0) {
|
|
309
|
+
const msg = collisions.map(([filename, sources]) => ` "${filename}": ${sources.join(", ")}`).join(`
|
|
310
|
+
`);
|
|
311
|
+
throw new Error(`Filename collision(s) detected in curated persona list:
|
|
312
|
+
${msg}
|
|
313
|
+
Update CURATED_PERSONAS to resolve conflicts.`);
|
|
314
|
+
}
|
|
315
|
+
const criticals = entries.filter((e) => e.status === "excluded-critical");
|
|
316
|
+
if (criticals.length > 0) {
|
|
317
|
+
const msg = criticals.map((e) => ` ${e.sourceRelPath}: ${e.reason}`).join(`
|
|
318
|
+
`);
|
|
319
|
+
throw new Error(`Critical compatibility coupling detected in curated personas:
|
|
320
|
+
${msg}
|
|
321
|
+
Remove these personas from CURATED_PERSONAS or fix the coupling.`);
|
|
322
|
+
}
|
|
323
|
+
return entries;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/lib/pi-subagents-export.ts
|
|
327
|
+
var MANIFEST_FILENAME = ".systematic-personas.json";
|
|
328
|
+
var LOCK_FILENAME = ".systematic-personas.lock";
|
|
329
|
+
var GENERATED_NAMESPACE_PATTERN = /^systematic-[a-z0-9-]+\.md$/;
|
|
330
|
+
function resolveAgentsRoot(scope, cwd) {
|
|
331
|
+
if (scope === "project")
|
|
332
|
+
return path2.join(cwd, ".pi", "agents");
|
|
333
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
334
|
+
const base = envDir ? envDir : path2.join(os.homedir(), ".pi", "agent");
|
|
335
|
+
return path2.join(base, "agents");
|
|
336
|
+
}
|
|
337
|
+
function resolveAnchor(scope, cwd) {
|
|
338
|
+
if (scope === "project")
|
|
339
|
+
return path2.resolve(cwd);
|
|
340
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
341
|
+
if (envDir)
|
|
342
|
+
return path2.resolve(path2.dirname(envDir));
|
|
343
|
+
return path2.resolve(os.homedir());
|
|
344
|
+
}
|
|
345
|
+
function anchorForScope(configOptions, agentsRootFallback) {
|
|
346
|
+
const resolvedAgentsRoot = path2.resolve(agentsRootFallback);
|
|
347
|
+
if (configOptions) {
|
|
348
|
+
return resolveAnchor(configOptions.scope, configOptions.cwd);
|
|
349
|
+
}
|
|
350
|
+
return path2.dirname(resolvedAgentsRoot);
|
|
351
|
+
}
|
|
352
|
+
function assertAnchoredPathSafe(anchor, targetPath) {
|
|
353
|
+
const resolvedAnchor = path2.resolve(anchor);
|
|
354
|
+
const resolvedTarget = path2.resolve(targetPath);
|
|
355
|
+
if (resolvedTarget !== resolvedAnchor && !resolvedTarget.startsWith(resolvedAnchor + path2.sep)) {
|
|
356
|
+
throw new Error(`Refusing to operate on ${resolvedTarget}: outside safety anchor ${resolvedAnchor}`);
|
|
357
|
+
}
|
|
358
|
+
const rel = path2.relative(resolvedAnchor, resolvedTarget);
|
|
359
|
+
const parts = rel === "" ? [] : rel.split(path2.sep);
|
|
360
|
+
let current = resolvedAnchor;
|
|
361
|
+
for (const part of parts) {
|
|
362
|
+
current = path2.join(current, part);
|
|
363
|
+
let stat;
|
|
364
|
+
try {
|
|
365
|
+
stat = fs2.lstatSync(current);
|
|
366
|
+
} catch {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (stat.isSymbolicLink())
|
|
370
|
+
throw new Error(`Refusing to operate through ${current}: symlink detected`);
|
|
371
|
+
if (!stat.isDirectory())
|
|
372
|
+
throw new Error(`Refusing to operate through ${current}: not a directory`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function assertCanonicalRootWithinAnchor(anchor, root) {
|
|
376
|
+
let canonicalAnchor;
|
|
377
|
+
try {
|
|
378
|
+
canonicalAnchor = fs2.realpathSync(anchor);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
throw new Error(`Cannot resolve safety anchor ${anchor}: ${err instanceof Error ? err.message : String(err)}`);
|
|
381
|
+
}
|
|
382
|
+
const canonicalRoot = fs2.realpathSync(root);
|
|
383
|
+
if (canonicalRoot !== canonicalAnchor && !canonicalRoot.startsWith(canonicalAnchor + path2.sep)) {
|
|
384
|
+
throw new Error(`Refusing to operate on ${canonicalRoot}: canonical path escapes safety anchor ` + `${canonicalAnchor} (raced-symlink ancestor detected after resolution)`);
|
|
385
|
+
}
|
|
386
|
+
return canonicalRoot;
|
|
387
|
+
}
|
|
388
|
+
function validateManifestEntry(entry, seenFilenames) {
|
|
389
|
+
if (typeof entry !== "object" || entry === null)
|
|
390
|
+
return "Manifest files entry must be an object";
|
|
391
|
+
const e = entry;
|
|
392
|
+
if (typeof e.filename !== "string" || typeof e.hash !== "string")
|
|
393
|
+
return "Manifest files entry missing required string fields";
|
|
394
|
+
if (seenFilenames.has(e.filename))
|
|
395
|
+
return `Manifest contains duplicate filename: "${e.filename}"`;
|
|
396
|
+
seenFilenames.add(e.filename);
|
|
397
|
+
if (path2.basename(e.filename) !== e.filename || path2.isAbsolute(e.filename))
|
|
398
|
+
return `Unsafe filename in manifest: "${e.filename}" \u2014 contains traversal or path separators (invalid filename)`;
|
|
399
|
+
if (!GENERATED_NAMESPACE_PATTERN.test(e.filename))
|
|
400
|
+
return `Manifest entry "${e.filename}" is not a generated namespace file (must match systematic-*.md; invalid filename)`;
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
function validateManifestObject(parsed) {
|
|
404
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
405
|
+
return "Manifest root must be an object";
|
|
406
|
+
const obj = parsed;
|
|
407
|
+
if (!Array.isArray(obj.files))
|
|
408
|
+
return 'Manifest "files" field must be an array';
|
|
409
|
+
const seenFilenames = new Set;
|
|
410
|
+
for (const entry of obj.files) {
|
|
411
|
+
const error = validateManifestEntry(entry, seenFilenames);
|
|
412
|
+
if (error !== null)
|
|
413
|
+
return error;
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
var HAS_O_NOFOLLOW = typeof fs2.constants.O_NOFOLLOW === "number";
|
|
418
|
+
function verifyManifestPathIdentity(manifestPath, openedStat) {
|
|
419
|
+
let linkStat;
|
|
420
|
+
try {
|
|
421
|
+
linkStat = fs2.lstatSync(manifestPath);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
return `Cannot verify manifest path after open: ${err instanceof Error ? err.message : String(err)}`;
|
|
424
|
+
}
|
|
425
|
+
if (linkStat.isSymbolicLink())
|
|
426
|
+
return `Refusing to read manifest through symlink: ${manifestPath}`;
|
|
427
|
+
if (linkStat.dev !== openedStat.dev || linkStat.ino !== openedStat.ino)
|
|
428
|
+
return `Manifest path identity changed during read (possible tamper): ${manifestPath}`;
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
function openManifestFd(manifestPath) {
|
|
432
|
+
const openFlags = fs2.constants.O_RDONLY | (HAS_O_NOFOLLOW ? fs2.constants.O_NOFOLLOW : 0);
|
|
433
|
+
try {
|
|
434
|
+
return { kind: "fd", fd: fs2.openSync(manifestPath, openFlags) };
|
|
435
|
+
} catch (err) {
|
|
436
|
+
if (err.code === "ENOENT")
|
|
437
|
+
return { kind: "result", result: { kind: "absent" } };
|
|
438
|
+
if (err.code === "ELOOP")
|
|
439
|
+
return {
|
|
440
|
+
kind: "result",
|
|
441
|
+
result: {
|
|
442
|
+
kind: "malformed",
|
|
443
|
+
error: `Refusing to read manifest through symlink: ${manifestPath}`
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
return {
|
|
447
|
+
kind: "result",
|
|
448
|
+
result: {
|
|
449
|
+
kind: "malformed",
|
|
450
|
+
error: `Cannot read manifest: ${err instanceof Error ? err.message : String(err)}`
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function parseManifestContents(raw) {
|
|
456
|
+
let parsed;
|
|
457
|
+
try {
|
|
458
|
+
parsed = JSON.parse(raw);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
return {
|
|
461
|
+
kind: "malformed",
|
|
462
|
+
error: `Manifest JSON parse error: ${err instanceof Error ? err.message : String(err)}`
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const validationError = validateManifestObject(parsed);
|
|
466
|
+
if (validationError !== null)
|
|
467
|
+
return { kind: "malformed", error: validationError };
|
|
468
|
+
return { kind: "ok", manifest: parsed };
|
|
469
|
+
}
|
|
470
|
+
function readManifestStrict(agentsRoot) {
|
|
471
|
+
const manifestPath = path2.join(agentsRoot, MANIFEST_FILENAME);
|
|
472
|
+
const opened = openManifestFd(manifestPath);
|
|
473
|
+
if (opened.kind === "result")
|
|
474
|
+
return opened.result;
|
|
475
|
+
const { fd } = opened;
|
|
476
|
+
try {
|
|
477
|
+
const stat = fs2.fstatSync(fd);
|
|
478
|
+
if (!stat.isFile())
|
|
479
|
+
return {
|
|
480
|
+
kind: "malformed",
|
|
481
|
+
error: `Manifest path exists but is not a regular file: ${manifestPath}`
|
|
482
|
+
};
|
|
483
|
+
if (!HAS_O_NOFOLLOW) {
|
|
484
|
+
const identityError = verifyManifestPathIdentity(manifestPath, stat);
|
|
485
|
+
if (identityError !== null)
|
|
486
|
+
return { kind: "malformed", error: identityError };
|
|
487
|
+
}
|
|
488
|
+
return parseManifestContents(fs2.readFileSync(fd, "utf-8"));
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return {
|
|
491
|
+
kind: "malformed",
|
|
492
|
+
error: `Cannot read manifest: ${err instanceof Error ? err.message : String(err)}`
|
|
493
|
+
};
|
|
494
|
+
} finally {
|
|
495
|
+
fs2.closeSync(fd);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function writeManifest(agentsRoot, manifest) {
|
|
499
|
+
atomicWriteString(path2.join(agentsRoot, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}
|
|
500
|
+
`);
|
|
501
|
+
}
|
|
502
|
+
function statOrNull(p) {
|
|
503
|
+
try {
|
|
504
|
+
return fs2.lstatSync(p);
|
|
505
|
+
} catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function assertRealAgentsRoot(agentsRoot) {
|
|
510
|
+
let stat = null;
|
|
511
|
+
try {
|
|
512
|
+
stat = fs2.lstatSync(agentsRoot);
|
|
513
|
+
} catch {}
|
|
514
|
+
if (stat !== null) {
|
|
515
|
+
if (stat.isSymbolicLink())
|
|
516
|
+
throw new Error(`Refusing to write under ${agentsRoot}: not a real directory (symlink detected)`);
|
|
517
|
+
if (!stat.isDirectory())
|
|
518
|
+
throw new Error(`Refusing to write under ${agentsRoot}: not a directory`);
|
|
519
|
+
}
|
|
520
|
+
return fs2.realpathSync(agentsRoot);
|
|
521
|
+
}
|
|
522
|
+
function safeFilePathOrThrow(agentsRoot, filename) {
|
|
523
|
+
if (path2.basename(filename) !== filename || path2.isAbsolute(filename)) {
|
|
524
|
+
throw new Error(`Unsafe filename in manifest: "${filename}" \u2014 contains traversal or path separators (invalid filename)`);
|
|
525
|
+
}
|
|
526
|
+
const resolved = path2.resolve(agentsRoot, filename);
|
|
527
|
+
if (!resolved.startsWith(agentsRoot + path2.sep) && resolved !== agentsRoot) {
|
|
528
|
+
throw new Error(`Unsafe filename in manifest: "${filename}" resolves outside agents root (traversal)`);
|
|
529
|
+
}
|
|
530
|
+
return resolved;
|
|
531
|
+
}
|
|
532
|
+
function safeFilePath(agentsRoot, filename) {
|
|
533
|
+
try {
|
|
534
|
+
return safeFilePathOrThrow(agentsRoot, filename);
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function acquireLock(agentsRoot) {
|
|
540
|
+
const lockPath = path2.join(agentsRoot, LOCK_FILENAME);
|
|
541
|
+
try {
|
|
542
|
+
return { ok: true, fd: fs2.openSync(lockPath, "wx") };
|
|
543
|
+
} catch (err) {
|
|
544
|
+
if (err.code === "EEXIST") {
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
contention: true,
|
|
548
|
+
error: `Another export/refresh/cleanup operation appears to be in progress ` + `(lock file exists: ${lockPath}). If no other operation is running ` + `(e.g. after a crash), verify manually and remove the lock file yourself.`
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
contention: false,
|
|
554
|
+
error: err instanceof Error ? err.message : String(err)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function releaseLock(agentsRoot, fd) {
|
|
559
|
+
try {
|
|
560
|
+
fs2.closeSync(fd);
|
|
561
|
+
} catch {}
|
|
562
|
+
try {
|
|
563
|
+
fs2.unlinkSync(path2.join(agentsRoot, LOCK_FILENAME));
|
|
564
|
+
} catch {}
|
|
565
|
+
}
|
|
566
|
+
function withLock(agentsRoot, fn) {
|
|
567
|
+
const acquired = acquireLock(agentsRoot);
|
|
568
|
+
if (!acquired.ok) {
|
|
569
|
+
return {
|
|
570
|
+
locked: false,
|
|
571
|
+
error: acquired.error,
|
|
572
|
+
isContention: acquired.contention
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
return { locked: true, result: fn() };
|
|
577
|
+
} finally {
|
|
578
|
+
releaseLock(agentsRoot, acquired.fd);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function atomicWriteString(destPath, content) {
|
|
582
|
+
const parentDir = path2.dirname(destPath);
|
|
583
|
+
const tmpPath = path2.join(parentDir, `.${path2.basename(destPath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
584
|
+
try {
|
|
585
|
+
fs2.writeFileSync(tmpPath, content, { flag: "w", mode: 420 });
|
|
586
|
+
fs2.renameSync(tmpPath, destPath);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
try {
|
|
589
|
+
fs2.unlinkSync(tmpPath);
|
|
590
|
+
} catch {}
|
|
591
|
+
throw err;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function snapshotFiles(filePaths) {
|
|
595
|
+
const backup = new Map;
|
|
596
|
+
for (const p of filePaths) {
|
|
597
|
+
try {
|
|
598
|
+
backup.set(p, fs2.readFileSync(p, "utf-8"));
|
|
599
|
+
} catch (err) {
|
|
600
|
+
if (err.code === "ENOENT") {
|
|
601
|
+
backup.set(p, undefined);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
error: `Cannot snapshot ${p}: ${err instanceof Error ? err.message : String(err)}`
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return { ok: true, backup };
|
|
611
|
+
}
|
|
612
|
+
function restoreFromBackup(backup) {
|
|
613
|
+
const failed = [];
|
|
614
|
+
for (const [p, content] of backup) {
|
|
615
|
+
try {
|
|
616
|
+
if (content === undefined) {
|
|
617
|
+
if (fs2.existsSync(p))
|
|
618
|
+
fs2.unlinkSync(p);
|
|
619
|
+
} else {
|
|
620
|
+
fs2.writeFileSync(p, content, "utf-8");
|
|
621
|
+
}
|
|
622
|
+
} catch {
|
|
623
|
+
failed.push(p);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return failed;
|
|
627
|
+
}
|
|
628
|
+
function rollbackMessage(failed) {
|
|
629
|
+
return failed.length === 0 ? "rolled back" : `partial rollback (restoration failed for: ${failed.map((p) => path2.basename(p)).join(", ")})`;
|
|
630
|
+
}
|
|
631
|
+
function runWithRollback(pathsToWatch, ops) {
|
|
632
|
+
const snapshot = snapshotFiles(pathsToWatch);
|
|
633
|
+
if (!snapshot.ok) {
|
|
634
|
+
return { ok: false, error: snapshot.error, rollbackFailed: [] };
|
|
635
|
+
}
|
|
636
|
+
const backup = snapshot.backup;
|
|
637
|
+
for (const op of ops) {
|
|
638
|
+
try {
|
|
639
|
+
op();
|
|
640
|
+
} catch (err) {
|
|
641
|
+
const rollbackFailed = restoreFromBackup(backup);
|
|
642
|
+
const rb = rollbackMessage(rollbackFailed);
|
|
643
|
+
return {
|
|
644
|
+
ok: false,
|
|
645
|
+
error: `${err instanceof Error ? err.message : String(err)} (${rb})`,
|
|
646
|
+
rollbackFailed
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return { ok: true, rollbackFailed: [] };
|
|
651
|
+
}
|
|
652
|
+
function ownedFilenames(manifest) {
|
|
653
|
+
if (manifest === null)
|
|
654
|
+
return new Set;
|
|
655
|
+
const s = new Set;
|
|
656
|
+
for (const entry of manifest.files) {
|
|
657
|
+
if (path2.basename(entry.filename) === entry.filename && !path2.isAbsolute(entry.filename))
|
|
658
|
+
s.add(entry.filename);
|
|
659
|
+
}
|
|
660
|
+
return s;
|
|
661
|
+
}
|
|
662
|
+
function parseSourceCategoryAndKey(sourceRelPath) {
|
|
663
|
+
const parts = sourceRelPath.split("/");
|
|
664
|
+
if (parts.length >= 3 && parts[0] === "agents") {
|
|
665
|
+
const fileName2 = parts.at(-1) ?? "";
|
|
666
|
+
return {
|
|
667
|
+
category: parts[1],
|
|
668
|
+
agentKey: fileName2.replace(/\.md$/, "")
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const fileName = parts.at(-1) ?? sourceRelPath;
|
|
672
|
+
return { category: undefined, agentKey: fileName.replace(/\.md$/, "") };
|
|
673
|
+
}
|
|
674
|
+
function lookupOverlay(map, category, agentKey) {
|
|
675
|
+
if (!map)
|
|
676
|
+
return;
|
|
677
|
+
if (category) {
|
|
678
|
+
const qualified = map[`${category}/${agentKey}`];
|
|
679
|
+
if (qualified)
|
|
680
|
+
return qualified;
|
|
681
|
+
}
|
|
682
|
+
return map[agentKey];
|
|
683
|
+
}
|
|
684
|
+
function resolveModel(config, category, agentKey) {
|
|
685
|
+
const agentOverlay = lookupOverlay(config.agents, category, agentKey);
|
|
686
|
+
if (agentOverlay && Object.hasOwn(agentOverlay, "model")) {
|
|
687
|
+
const value = agentOverlay.model;
|
|
688
|
+
return typeof value === "string" ? value : undefined;
|
|
689
|
+
}
|
|
690
|
+
const categoryOverlay = category ? config.categories?.[category] : undefined;
|
|
691
|
+
if (categoryOverlay && Object.hasOwn(categoryOverlay, "model")) {
|
|
692
|
+
const value = categoryOverlay.model;
|
|
693
|
+
return typeof value === "string" ? value : undefined;
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
var PI_SUBAGENTS_FRONTMATTER_FIELDS = [
|
|
698
|
+
"thinking",
|
|
699
|
+
"max_turns",
|
|
700
|
+
"tools",
|
|
701
|
+
"skills"
|
|
702
|
+
];
|
|
703
|
+
function resolvePiSubagentsFields(config, category, agentKey) {
|
|
704
|
+
const categoryOverlay = category ? config.pi_subagents?.categories?.[category] : undefined;
|
|
705
|
+
const agentOverlay = lookupOverlay(config.pi_subagents?.agents, category, agentKey);
|
|
706
|
+
const result = {};
|
|
707
|
+
for (const field of PI_SUBAGENTS_FRONTMATTER_FIELDS) {
|
|
708
|
+
if (agentOverlay && Object.hasOwn(agentOverlay, field)) {
|
|
709
|
+
result[field] = agentOverlay[field];
|
|
710
|
+
} else if (categoryOverlay && Object.hasOwn(categoryOverlay, field)) {
|
|
711
|
+
result[field] = categoryOverlay[field];
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return result;
|
|
715
|
+
}
|
|
716
|
+
function applyConfigToContent(content, config, category, agentKey) {
|
|
717
|
+
const { data, body } = parseFrontmatter(content);
|
|
718
|
+
const model = resolveModel(config, category, agentKey);
|
|
719
|
+
const piSubagentsFields = resolvePiSubagentsFields(config, category, agentKey);
|
|
720
|
+
const frontmatter = {};
|
|
721
|
+
if (typeof data.description === "string") {
|
|
722
|
+
frontmatter.description = data.description;
|
|
723
|
+
}
|
|
724
|
+
if (model !== undefined)
|
|
725
|
+
frontmatter.model = model;
|
|
726
|
+
for (const field of PI_SUBAGENTS_FRONTMATTER_FIELDS) {
|
|
727
|
+
if (Object.hasOwn(piSubagentsFields, field)) {
|
|
728
|
+
frontmatter[field] = piSubagentsFields[field];
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
let frontmatterBlock;
|
|
732
|
+
if (Object.keys(frontmatter).length === 0) {
|
|
733
|
+
frontmatterBlock = `---
|
|
734
|
+
---`;
|
|
735
|
+
} else {
|
|
736
|
+
const lines = Object.entries(frontmatter).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(`
|
|
737
|
+
`);
|
|
738
|
+
frontmatterBlock = `---
|
|
739
|
+
${lines}
|
|
740
|
+
---`;
|
|
741
|
+
}
|
|
742
|
+
const trimmedBody = body.replace(/^\n+/, "");
|
|
743
|
+
return `${frontmatterBlock}
|
|
744
|
+
|
|
745
|
+
${trimmedBody}`;
|
|
746
|
+
}
|
|
747
|
+
function applyConfigToEntries(entries, config) {
|
|
748
|
+
return entries.map((entry) => {
|
|
749
|
+
if (entry.status === "excluded-critical" || !entry.content)
|
|
750
|
+
return entry;
|
|
751
|
+
const { category, agentKey } = parseSourceCategoryAndKey(entry.sourceRelPath);
|
|
752
|
+
const content = applyConfigToContent(entry.content, config, category, agentKey);
|
|
753
|
+
const hash = crypto2.createHash("sha256").update(content).digest("hex");
|
|
754
|
+
return { ...entry, content, hash };
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
function generateEntries(repoRoot, configOptions) {
|
|
758
|
+
const entries = generateAll(repoRoot);
|
|
759
|
+
if (!configOptions)
|
|
760
|
+
return entries;
|
|
761
|
+
const config = loadConfigWithSources(configOptions.cwd, {
|
|
762
|
+
includeProject: configOptions.scope === "project"
|
|
763
|
+
}).config;
|
|
764
|
+
return applyConfigToEntries(entries, config);
|
|
765
|
+
}
|
|
766
|
+
function findPackageRoot() {
|
|
767
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
768
|
+
const dir = path2.resolve(path2.dirname(thisFile), "..", "..");
|
|
769
|
+
if (fs2.existsSync(path2.join(dir, "package.json")))
|
|
770
|
+
return dir;
|
|
771
|
+
let candidate = path2.dirname(thisFile);
|
|
772
|
+
for (let i = 0;i < 8; i++) {
|
|
773
|
+
if (fs2.existsSync(path2.join(candidate, "package.json")))
|
|
774
|
+
return candidate;
|
|
775
|
+
const parent = path2.dirname(candidate);
|
|
776
|
+
if (parent === candidate)
|
|
777
|
+
break;
|
|
778
|
+
candidate = parent;
|
|
779
|
+
}
|
|
780
|
+
throw new Error("Could not locate package root (no package.json found)");
|
|
781
|
+
}
|
|
782
|
+
function planExistingEntry(absRoot, filename, hash, owned) {
|
|
783
|
+
const diskContent = fs2.readFileSync(path2.join(absRoot, filename), "utf-8");
|
|
784
|
+
const diskHash = crypto2.createHash("sha256").update(diskContent).digest("hex");
|
|
785
|
+
if (owned.has(filename)) {
|
|
786
|
+
return diskHash !== hash ? { action: "update", filename } : { action: "skip", filename };
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
action: "refuse",
|
|
790
|
+
filename,
|
|
791
|
+
reason: "File exists but is not owned by a previous export"
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function planEntryActions(absRoot, entries, owned) {
|
|
795
|
+
const actions = [];
|
|
796
|
+
for (const entry of entries) {
|
|
797
|
+
if (entry.status === "excluded-critical" || !entry.content)
|
|
798
|
+
continue;
|
|
799
|
+
const exists = fs2.existsSync(path2.join(absRoot, entry.filename));
|
|
800
|
+
actions.push(exists ? planExistingEntry(absRoot, entry.filename, entry.hash, owned) : { action: "create", filename: entry.filename });
|
|
801
|
+
}
|
|
802
|
+
return actions;
|
|
803
|
+
}
|
|
804
|
+
function planRemoveActions(absRoot, manifest, currentFilenames) {
|
|
805
|
+
if (!manifest)
|
|
806
|
+
return [];
|
|
807
|
+
const removes = [];
|
|
808
|
+
for (const mEntry of manifest.files) {
|
|
809
|
+
if (currentFilenames.has(mEntry.filename))
|
|
810
|
+
continue;
|
|
811
|
+
const safe = safeFilePath(absRoot, mEntry.filename);
|
|
812
|
+
if (safe !== null && fs2.existsSync(safe))
|
|
813
|
+
removes.push({ action: "remove", filename: mEntry.filename });
|
|
814
|
+
}
|
|
815
|
+
return removes;
|
|
816
|
+
}
|
|
817
|
+
function preview(agentsRoot, configOptions) {
|
|
818
|
+
const absRoot = path2.resolve(agentsRoot);
|
|
819
|
+
const manifestResult = readManifestStrict(absRoot);
|
|
820
|
+
if (manifestResult.kind === "malformed") {
|
|
821
|
+
return {
|
|
822
|
+
status: "error",
|
|
823
|
+
error: `Malformed manifest: ${manifestResult.error}`,
|
|
824
|
+
agentsRoot: absRoot,
|
|
825
|
+
actions: []
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
const manifest = manifestResult.kind === "ok" ? manifestResult.manifest : null;
|
|
829
|
+
let entries;
|
|
830
|
+
try {
|
|
831
|
+
entries = generateEntries(findPackageRoot(), configOptions);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
return {
|
|
834
|
+
status: "error",
|
|
835
|
+
error: `Generator failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
836
|
+
agentsRoot: absRoot,
|
|
837
|
+
actions: []
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
const owned = ownedFilenames(manifest);
|
|
841
|
+
const currentFilenames = new Set(entries.filter((e) => e.status !== "excluded-critical" && e.content).map((e) => e.filename));
|
|
842
|
+
return {
|
|
843
|
+
status: "ok",
|
|
844
|
+
agentsRoot: absRoot,
|
|
845
|
+
actions: [
|
|
846
|
+
...planEntryActions(absRoot, entries, owned),
|
|
847
|
+
...planRemoveActions(absRoot, manifest, currentFilenames)
|
|
848
|
+
]
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
function classifyOneExportEntry(absRoot, entry, owned) {
|
|
852
|
+
const safePath = safeFilePath(absRoot, entry.filename);
|
|
853
|
+
if (safePath === null)
|
|
854
|
+
return { kind: "refuse", reason: "Filename would escape agents root" };
|
|
855
|
+
if (fs2.existsSync(safePath) && !owned.has(entry.filename))
|
|
856
|
+
return {
|
|
857
|
+
kind: "refuse",
|
|
858
|
+
reason: "File exists but is not owned by a previous export (user file)"
|
|
859
|
+
};
|
|
860
|
+
if (fs2.existsSync(safePath)) {
|
|
861
|
+
const diskHash = crypto2.createHash("sha256").update(fs2.readFileSync(safePath, "utf-8")).digest("hex");
|
|
862
|
+
if (diskHash === entry.hash)
|
|
863
|
+
return { kind: "skip", safePath };
|
|
864
|
+
}
|
|
865
|
+
return { kind: "write", safePath };
|
|
866
|
+
}
|
|
867
|
+
function classifyExportEntries(absRoot, entries, owned) {
|
|
868
|
+
const toWrite = [];
|
|
869
|
+
const toSkip = [];
|
|
870
|
+
const refused = [];
|
|
871
|
+
const currentFilenames = new Set;
|
|
872
|
+
for (const entry of entries) {
|
|
873
|
+
if (entry.status === "excluded-critical" || !entry.content)
|
|
874
|
+
continue;
|
|
875
|
+
currentFilenames.add(entry.filename);
|
|
876
|
+
const result = classifyOneExportEntry(absRoot, entry, owned);
|
|
877
|
+
if (result.kind === "refuse") {
|
|
878
|
+
refused.push({
|
|
879
|
+
filename: entry.filename,
|
|
880
|
+
reason: result.reason ?? "refused"
|
|
881
|
+
});
|
|
882
|
+
} else if (result.safePath !== undefined && result.kind === "skip") {
|
|
883
|
+
toSkip.push({ entry, safePath: result.safePath });
|
|
884
|
+
} else if (result.safePath !== undefined) {
|
|
885
|
+
toWrite.push({ entry, safePath: result.safePath });
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return { toWrite, toSkip, refused, currentFilenames };
|
|
889
|
+
}
|
|
890
|
+
function buildNewManifestFiles(toWrite, toSkip) {
|
|
891
|
+
const toFileEntry = (e) => ({
|
|
892
|
+
filename: e.filename,
|
|
893
|
+
hash: e.hash,
|
|
894
|
+
status: e.status === "exported-with-warning" ? "exported-with-warning" : "exported"
|
|
895
|
+
});
|
|
896
|
+
return [
|
|
897
|
+
...toSkip.map(({ entry }) => toFileEntry(entry)),
|
|
898
|
+
...toWrite.map(({ entry }) => toFileEntry(entry))
|
|
899
|
+
];
|
|
900
|
+
}
|
|
901
|
+
function makeExportError(error) {
|
|
902
|
+
return { status: "error", written: 0, skipped: 0, refused: [], error };
|
|
903
|
+
}
|
|
904
|
+
function prepareExportRoot(agentsRoot) {
|
|
905
|
+
let preStat = null;
|
|
906
|
+
try {
|
|
907
|
+
preStat = fs2.lstatSync(agentsRoot);
|
|
908
|
+
} catch {}
|
|
909
|
+
if (preStat?.isSymbolicLink())
|
|
910
|
+
return makeExportError(`Refusing to write under ${agentsRoot}: not a real directory (symlink detected)`);
|
|
911
|
+
try {
|
|
912
|
+
fs2.mkdirSync(agentsRoot, { recursive: true });
|
|
913
|
+
return assertRealAgentsRoot(agentsRoot);
|
|
914
|
+
} catch (err) {
|
|
915
|
+
return makeExportError(err instanceof Error ? err.message : String(err));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
function assertHashMatchesOrThrow(filePath, expectedHash, filename, verb) {
|
|
919
|
+
const diskContent = fs2.readFileSync(filePath, "utf-8");
|
|
920
|
+
const diskHash = crypto2.createHash("sha256").update(diskContent).digest("hex");
|
|
921
|
+
if (diskHash !== expectedHash) {
|
|
922
|
+
throw new Error(`Refusing to ${verb} "${filename}": content on disk does not match the ` + `manifest hash (drifted or tampered). Resolve manually or re-export/refresh first.`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function findStalePaths(absRoot, prevManifest, currentFilenames) {
|
|
926
|
+
if (!prevManifest)
|
|
927
|
+
return [];
|
|
928
|
+
const stale = [];
|
|
929
|
+
for (const mEntry of prevManifest.files) {
|
|
930
|
+
if (currentFilenames.has(mEntry.filename))
|
|
931
|
+
continue;
|
|
932
|
+
const safe = safeFilePath(absRoot, mEntry.filename);
|
|
933
|
+
if (safe === null || !fs2.existsSync(safe))
|
|
934
|
+
continue;
|
|
935
|
+
assertHashMatchesOrThrow(safe, mEntry.hash, mEntry.filename, "remove stale file");
|
|
936
|
+
stale.push({ path: safe, filename: mEntry.filename, hash: mEntry.hash });
|
|
937
|
+
}
|
|
938
|
+
return stale;
|
|
939
|
+
}
|
|
940
|
+
function exportPersonas(agentsRoot, configOptions) {
|
|
941
|
+
let entries;
|
|
942
|
+
try {
|
|
943
|
+
entries = generateEntries(findPackageRoot(), configOptions);
|
|
944
|
+
} catch (err) {
|
|
945
|
+
return makeExportError(`Failed to generate personas: ${err instanceof Error ? err.message : String(err)}`);
|
|
946
|
+
}
|
|
947
|
+
const anchor = anchorForScope(configOptions, agentsRoot);
|
|
948
|
+
try {
|
|
949
|
+
assertAnchoredPathSafe(anchor, path2.resolve(agentsRoot));
|
|
950
|
+
} catch (err) {
|
|
951
|
+
return makeExportError(err instanceof Error ? err.message : String(err));
|
|
952
|
+
}
|
|
953
|
+
const rootOrErr = prepareExportRoot(agentsRoot);
|
|
954
|
+
if (typeof rootOrErr !== "string")
|
|
955
|
+
return rootOrErr;
|
|
956
|
+
let absRoot;
|
|
957
|
+
try {
|
|
958
|
+
absRoot = assertCanonicalRootWithinAnchor(anchor, rootOrErr);
|
|
959
|
+
} catch (err) {
|
|
960
|
+
return makeExportError(err instanceof Error ? err.message : String(err));
|
|
961
|
+
}
|
|
962
|
+
const lockResult = withLock(absRoot, () => {
|
|
963
|
+
const manifestResult = readManifestStrict(absRoot);
|
|
964
|
+
if (manifestResult.kind === "malformed")
|
|
965
|
+
return makeExportError(`Malformed manifest \u2014 refusing to export: ${manifestResult.error}`);
|
|
966
|
+
const prevManifest = manifestResult.kind === "ok" ? manifestResult.manifest : null;
|
|
967
|
+
const owned = ownedFilenames(prevManifest);
|
|
968
|
+
const { toWrite, toSkip, refused, currentFilenames } = classifyExportEntries(absRoot, entries, owned);
|
|
969
|
+
let staleOwned;
|
|
970
|
+
try {
|
|
971
|
+
staleOwned = findStalePaths(absRoot, prevManifest, currentFilenames);
|
|
972
|
+
} catch (err) {
|
|
973
|
+
return makeExportError(err instanceof Error ? err.message : String(err));
|
|
974
|
+
}
|
|
975
|
+
const newManifest = () => ({
|
|
976
|
+
generatedAt: new Date().toISOString(),
|
|
977
|
+
agentsRoot: absRoot,
|
|
978
|
+
files: buildNewManifestFiles(toWrite, toSkip)
|
|
979
|
+
});
|
|
980
|
+
const tx = runWithRollback([
|
|
981
|
+
...toWrite.map((x) => x.safePath),
|
|
982
|
+
...staleOwned.map((x) => x.path),
|
|
983
|
+
path2.join(absRoot, MANIFEST_FILENAME)
|
|
984
|
+
], [
|
|
985
|
+
...toWrite.map(({ entry, safePath }) => () => atomicWriteString(safePath, entry.content ?? "")),
|
|
986
|
+
...staleOwned.map(({ path: p, filename, hash }) => () => {
|
|
987
|
+
if (fs2.existsSync(p)) {
|
|
988
|
+
assertHashMatchesOrThrow(p, hash, filename, "remove stale file");
|
|
989
|
+
fs2.unlinkSync(p);
|
|
990
|
+
}
|
|
991
|
+
}),
|
|
992
|
+
() => writeManifest(absRoot, newManifest())
|
|
993
|
+
]);
|
|
994
|
+
if (!tx.ok)
|
|
995
|
+
return {
|
|
996
|
+
status: "error",
|
|
997
|
+
written: 0,
|
|
998
|
+
skipped: toSkip.length,
|
|
999
|
+
refused,
|
|
1000
|
+
error: tx.error
|
|
1001
|
+
};
|
|
1002
|
+
return {
|
|
1003
|
+
status: "ok",
|
|
1004
|
+
written: toWrite.length,
|
|
1005
|
+
skipped: toSkip.length,
|
|
1006
|
+
refused
|
|
1007
|
+
};
|
|
1008
|
+
});
|
|
1009
|
+
if (!lockResult.locked)
|
|
1010
|
+
return makeExportError(lockResult.error);
|
|
1011
|
+
return lockResult.result;
|
|
1012
|
+
}
|
|
1013
|
+
function classifyOneRefreshEntry(absRoot, entry, owned) {
|
|
1014
|
+
const safePath = safeFilePath(absRoot, entry.filename);
|
|
1015
|
+
if (safePath === null)
|
|
1016
|
+
return { kind: "ignore" };
|
|
1017
|
+
const exists = fs2.existsSync(safePath);
|
|
1018
|
+
if (exists && !owned.has(entry.filename))
|
|
1019
|
+
return { kind: "skip-unowned", safePath };
|
|
1020
|
+
if (!exists && !owned.has(entry.filename))
|
|
1021
|
+
return { kind: "skip-unexported" };
|
|
1022
|
+
if (exists) {
|
|
1023
|
+
const diskHash = crypto2.createHash("sha256").update(fs2.readFileSync(safePath, "utf-8")).digest("hex");
|
|
1024
|
+
if (diskHash === entry.hash)
|
|
1025
|
+
return { kind: "keep", safePath };
|
|
1026
|
+
}
|
|
1027
|
+
return { kind: "update", safePath };
|
|
1028
|
+
}
|
|
1029
|
+
function classifyRefreshEntries(absRoot, entries, owned) {
|
|
1030
|
+
const toUpdate = [];
|
|
1031
|
+
const toKeep = [];
|
|
1032
|
+
let skippedUnowned = 0;
|
|
1033
|
+
for (const entry of entries) {
|
|
1034
|
+
if (entry.status === "excluded-critical" || !entry.content)
|
|
1035
|
+
continue;
|
|
1036
|
+
const result = classifyOneRefreshEntry(absRoot, entry, owned);
|
|
1037
|
+
if (result.kind === "skip-unowned") {
|
|
1038
|
+
skippedUnowned++;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (result.kind === "ignore" || result.kind === "skip-unexported")
|
|
1042
|
+
continue;
|
|
1043
|
+
if (result.safePath !== undefined && result.kind === "keep")
|
|
1044
|
+
toKeep.push({ entry, safePath: result.safePath });
|
|
1045
|
+
else if (result.safePath !== undefined)
|
|
1046
|
+
toUpdate.push({ entry, safePath: result.safePath });
|
|
1047
|
+
}
|
|
1048
|
+
return { toUpdate, toKeep, skippedUnowned };
|
|
1049
|
+
}
|
|
1050
|
+
function validateRefreshRoot(agentsRoot) {
|
|
1051
|
+
const absRoot = path2.resolve(agentsRoot);
|
|
1052
|
+
if (!fs2.existsSync(absRoot))
|
|
1053
|
+
return {
|
|
1054
|
+
status: "error",
|
|
1055
|
+
updated: 0,
|
|
1056
|
+
skippedUnowned: 0,
|
|
1057
|
+
error: `Agents root does not exist: ${absRoot}. Run export first.`
|
|
1058
|
+
};
|
|
1059
|
+
try {
|
|
1060
|
+
const stat = fs2.lstatSync(absRoot);
|
|
1061
|
+
if (stat.isSymbolicLink())
|
|
1062
|
+
return {
|
|
1063
|
+
status: "error",
|
|
1064
|
+
updated: 0,
|
|
1065
|
+
skippedUnowned: 0,
|
|
1066
|
+
error: `Refusing to write under ${absRoot}: not a real directory (symlink)`
|
|
1067
|
+
};
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
return {
|
|
1070
|
+
status: "error",
|
|
1071
|
+
updated: 0,
|
|
1072
|
+
skippedUnowned: 0,
|
|
1073
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
return absRoot;
|
|
1077
|
+
}
|
|
1078
|
+
function refresh(agentsRoot, configOptions) {
|
|
1079
|
+
let entries;
|
|
1080
|
+
try {
|
|
1081
|
+
entries = generateEntries(findPackageRoot(), configOptions);
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
return {
|
|
1084
|
+
status: "error",
|
|
1085
|
+
updated: 0,
|
|
1086
|
+
skippedUnowned: 0,
|
|
1087
|
+
error: `Failed to generate personas: ${err instanceof Error ? err.message : String(err)}`
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
const absRootCandidate = path2.resolve(agentsRoot);
|
|
1091
|
+
const anchor = anchorForScope(configOptions, absRootCandidate);
|
|
1092
|
+
try {
|
|
1093
|
+
assertAnchoredPathSafe(anchor, absRootCandidate);
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
return {
|
|
1096
|
+
status: "error",
|
|
1097
|
+
updated: 0,
|
|
1098
|
+
skippedUnowned: 0,
|
|
1099
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
const rootOrErr = validateRefreshRoot(agentsRoot);
|
|
1103
|
+
if (typeof rootOrErr !== "string")
|
|
1104
|
+
return rootOrErr;
|
|
1105
|
+
let absRoot;
|
|
1106
|
+
try {
|
|
1107
|
+
absRoot = assertCanonicalRootWithinAnchor(anchor, rootOrErr);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
return {
|
|
1110
|
+
status: "error",
|
|
1111
|
+
updated: 0,
|
|
1112
|
+
skippedUnowned: 0,
|
|
1113
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
const lockResult = withLock(absRoot, () => {
|
|
1117
|
+
const manifestResult = readManifestStrict(absRoot);
|
|
1118
|
+
if (manifestResult.kind === "malformed")
|
|
1119
|
+
return {
|
|
1120
|
+
status: "error",
|
|
1121
|
+
updated: 0,
|
|
1122
|
+
skippedUnowned: 0,
|
|
1123
|
+
error: `Malformed manifest \u2014 refusing to refresh: ${manifestResult.error}`
|
|
1124
|
+
};
|
|
1125
|
+
const manifest = manifestResult.kind === "ok" ? manifestResult.manifest : null;
|
|
1126
|
+
const { toUpdate, toKeep, skippedUnowned } = classifyRefreshEntries(absRoot, entries, ownedFilenames(manifest));
|
|
1127
|
+
const needsManifest = manifest !== null || toUpdate.length + toKeep.length > 0;
|
|
1128
|
+
const newManifest = () => ({
|
|
1129
|
+
generatedAt: new Date().toISOString(),
|
|
1130
|
+
agentsRoot: absRoot,
|
|
1131
|
+
files: buildNewManifestFiles(toUpdate, toKeep)
|
|
1132
|
+
});
|
|
1133
|
+
const tx = runWithRollback([
|
|
1134
|
+
...toUpdate.map((x) => x.safePath),
|
|
1135
|
+
path2.join(absRoot, MANIFEST_FILENAME)
|
|
1136
|
+
], [
|
|
1137
|
+
...toUpdate.map(({ entry, safePath }) => () => atomicWriteString(safePath, entry.content ?? "")),
|
|
1138
|
+
...needsManifest ? [() => writeManifest(absRoot, newManifest())] : []
|
|
1139
|
+
]);
|
|
1140
|
+
if (!tx.ok)
|
|
1141
|
+
return { status: "error", updated: 0, skippedUnowned, error: tx.error };
|
|
1142
|
+
return { status: "ok", updated: toUpdate.length, skippedUnowned };
|
|
1143
|
+
});
|
|
1144
|
+
if (!lockResult.locked)
|
|
1145
|
+
return {
|
|
1146
|
+
status: "error",
|
|
1147
|
+
updated: 0,
|
|
1148
|
+
skippedUnowned: 0,
|
|
1149
|
+
error: lockResult.error
|
|
1150
|
+
};
|
|
1151
|
+
return lockResult.result;
|
|
1152
|
+
}
|
|
1153
|
+
function collectCleanupPaths(absRoot, manifest) {
|
|
1154
|
+
const targets = [];
|
|
1155
|
+
for (const entry of manifest.files) {
|
|
1156
|
+
const safe = safeFilePath(absRoot, entry.filename);
|
|
1157
|
+
if (safe === null || !fs2.existsSync(safe))
|
|
1158
|
+
continue;
|
|
1159
|
+
assertHashMatchesOrThrow(safe, entry.hash, entry.filename, "delete");
|
|
1160
|
+
targets.push({ path: safe, filename: entry.filename, hash: entry.hash });
|
|
1161
|
+
}
|
|
1162
|
+
const manifestPath = path2.join(absRoot, MANIFEST_FILENAME);
|
|
1163
|
+
return {
|
|
1164
|
+
targets,
|
|
1165
|
+
manifestPath: fs2.existsSync(manifestPath) ? manifestPath : null
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function cleanup(agentsRoot, configOptions) {
|
|
1169
|
+
const absRootCandidate = path2.resolve(agentsRoot);
|
|
1170
|
+
const preStat = statOrNull(absRootCandidate);
|
|
1171
|
+
if (preStat?.isSymbolicLink())
|
|
1172
|
+
throw new Error(`Refusing to operate on ${absRootCandidate}: not a real directory (symlink detected)`);
|
|
1173
|
+
const anchor = anchorForScope(configOptions, absRootCandidate);
|
|
1174
|
+
assertAnchoredPathSafe(anchor, absRootCandidate);
|
|
1175
|
+
if (!fs2.existsSync(absRootCandidate))
|
|
1176
|
+
return { status: "ok" };
|
|
1177
|
+
const absRoot = assertCanonicalRootWithinAnchor(anchor, absRootCandidate);
|
|
1178
|
+
const lockResult = withLock(absRoot, () => {
|
|
1179
|
+
const manifestResult = readManifestStrict(absRoot);
|
|
1180
|
+
if (manifestResult.kind === "absent")
|
|
1181
|
+
return { kind: "absent" };
|
|
1182
|
+
if (manifestResult.kind === "malformed")
|
|
1183
|
+
throw new Error(`Malformed manifest \u2014 refusing to cleanup: ${manifestResult.error}`);
|
|
1184
|
+
const manifest = manifestResult.manifest;
|
|
1185
|
+
const { targets, manifestPath } = collectCleanupPaths(absRoot, manifest);
|
|
1186
|
+
const pathsToWatch = [
|
|
1187
|
+
...targets.map((t) => t.path),
|
|
1188
|
+
...manifestPath ? [manifestPath] : []
|
|
1189
|
+
];
|
|
1190
|
+
const tx2 = runWithRollback(pathsToWatch, [
|
|
1191
|
+
...targets.map(({ path: p, filename, hash }) => () => {
|
|
1192
|
+
if (fs2.existsSync(p)) {
|
|
1193
|
+
assertHashMatchesOrThrow(p, hash, filename, "delete");
|
|
1194
|
+
fs2.unlinkSync(p);
|
|
1195
|
+
}
|
|
1196
|
+
}),
|
|
1197
|
+
...manifestPath ? [
|
|
1198
|
+
() => {
|
|
1199
|
+
if (fs2.existsSync(manifestPath))
|
|
1200
|
+
fs2.unlinkSync(manifestPath);
|
|
1201
|
+
}
|
|
1202
|
+
] : []
|
|
1203
|
+
]);
|
|
1204
|
+
return { kind: "tx", tx: tx2 };
|
|
1205
|
+
});
|
|
1206
|
+
if (!lockResult.locked) {
|
|
1207
|
+
if (lockResult.isContention)
|
|
1208
|
+
throw new Error(lockResult.error);
|
|
1209
|
+
return { status: "error", error: lockResult.error };
|
|
1210
|
+
}
|
|
1211
|
+
if (lockResult.result.kind === "absent")
|
|
1212
|
+
return { status: "ok" };
|
|
1213
|
+
const tx = lockResult.result.tx;
|
|
1214
|
+
if (!tx.ok)
|
|
1215
|
+
return { status: "error", error: tx.error };
|
|
1216
|
+
return { status: "ok" };
|
|
1217
|
+
}
|
|
17
1218
|
|
|
18
1219
|
// src/lib/setup.ts
|
|
19
|
-
import
|
|
20
|
-
import
|
|
1220
|
+
import fs3 from "fs";
|
|
1221
|
+
import path3 from "path";
|
|
21
1222
|
var SYSTEMATIC_PACKAGE_NAME = "@fro.bot/systematic";
|
|
22
1223
|
var PI_PACKAGE_IDENTIFIER = `npm:${SYSTEMATIC_PACKAGE_NAME}`;
|
|
23
1224
|
var SETUP_ERROR_NAME = "SetupError";
|
|
@@ -27,10 +1228,10 @@ function createSetupError(message) {
|
|
|
27
1228
|
return error;
|
|
28
1229
|
}
|
|
29
1230
|
var DEFAULT_OPS = {
|
|
30
|
-
writeFileSync:
|
|
31
|
-
renameSync:
|
|
32
|
-
unlinkSync:
|
|
33
|
-
chmodSync:
|
|
1231
|
+
writeFileSync: fs3.writeFileSync,
|
|
1232
|
+
renameSync: fs3.renameSync,
|
|
1233
|
+
unlinkSync: fs3.unlinkSync,
|
|
1234
|
+
chmodSync: fs3.chmodSync
|
|
34
1235
|
};
|
|
35
1236
|
function isRecord(value) {
|
|
36
1237
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -52,7 +1253,7 @@ function isPiSystematicIdentifier(identifier) {
|
|
|
52
1253
|
}
|
|
53
1254
|
function lstatOrNull(targetPath) {
|
|
54
1255
|
try {
|
|
55
|
-
return
|
|
1256
|
+
return fs3.lstatSync(targetPath);
|
|
56
1257
|
} catch (error) {
|
|
57
1258
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
58
1259
|
return null;
|
|
@@ -61,10 +1262,10 @@ function lstatOrNull(targetPath) {
|
|
|
61
1262
|
}
|
|
62
1263
|
}
|
|
63
1264
|
function assertRealpathUnderCwd(dir, cwd) {
|
|
64
|
-
const realDir =
|
|
65
|
-
const realCwd =
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
1265
|
+
const realDir = fs3.realpathSync(dir);
|
|
1266
|
+
const realCwd = fs3.realpathSync(cwd);
|
|
1267
|
+
const relative2 = path3.relative(realCwd, realDir);
|
|
1268
|
+
if (relative2.startsWith("..") || path3.isAbsolute(relative2)) {
|
|
68
1269
|
throw createSetupError(`Refusing to write under ${dir}: resolved directory escapes the project root`);
|
|
69
1270
|
}
|
|
70
1271
|
}
|
|
@@ -77,7 +1278,7 @@ function assertParentTrusted(parentDir, cwd) {
|
|
|
77
1278
|
assertRealpathUnderCwd(parentDir, cwd);
|
|
78
1279
|
return;
|
|
79
1280
|
}
|
|
80
|
-
|
|
1281
|
+
fs3.mkdirSync(parentDir, { recursive: true });
|
|
81
1282
|
assertRealpathUnderCwd(parentDir, cwd);
|
|
82
1283
|
}
|
|
83
1284
|
var OPENCODE_TARGET_CANDIDATES = [
|
|
@@ -87,19 +1288,19 @@ var OPENCODE_TARGET_CANDIDATES = [
|
|
|
87
1288
|
"opencode.json"
|
|
88
1289
|
];
|
|
89
1290
|
function resolveOpenCodeTargetPath(cwd) {
|
|
90
|
-
const opencodeDirStat = lstatOrNull(
|
|
1291
|
+
const opencodeDirStat = lstatOrNull(path3.join(cwd, ".opencode"));
|
|
91
1292
|
if (opencodeDirStat?.isSymbolicLink()) {
|
|
92
|
-
return
|
|
1293
|
+
return path3.join(cwd, OPENCODE_TARGET_CANDIDATES[0]);
|
|
93
1294
|
}
|
|
94
1295
|
for (const candidate of OPENCODE_TARGET_CANDIDATES) {
|
|
95
|
-
const candidatePath =
|
|
1296
|
+
const candidatePath = path3.join(cwd, candidate);
|
|
96
1297
|
if (lstatOrNull(candidatePath) !== null)
|
|
97
1298
|
return candidatePath;
|
|
98
1299
|
}
|
|
99
|
-
return
|
|
1300
|
+
return path3.join(cwd, "opencode.jsonc");
|
|
100
1301
|
}
|
|
101
1302
|
var IS_WINDOWS = process.platform === "win32";
|
|
102
|
-
var OPEN_FLAGS = IS_WINDOWS ?
|
|
1303
|
+
var OPEN_FLAGS = IS_WINDOWS ? fs3.constants.O_RDONLY : fs3.constants.O_RDONLY | fs3.constants.O_NOFOLLOW | fs3.constants.O_NONBLOCK;
|
|
103
1304
|
function assertWindowsPreOpenTrust(targetPath) {
|
|
104
1305
|
const preStat = lstatOrNull(targetPath);
|
|
105
1306
|
if (preStat && (preStat.isSymbolicLink() || !preStat.isFile())) {
|
|
@@ -111,7 +1312,7 @@ function openTrustedExisting(targetPath) {
|
|
|
111
1312
|
assertWindowsPreOpenTrust(targetPath);
|
|
112
1313
|
let fd;
|
|
113
1314
|
try {
|
|
114
|
-
fd =
|
|
1315
|
+
fd = fs3.openSync(targetPath, OPEN_FLAGS);
|
|
115
1316
|
} catch (error) {
|
|
116
1317
|
if (error instanceof Error && "code" in error) {
|
|
117
1318
|
if (error.code === "ENOENT")
|
|
@@ -123,13 +1324,13 @@ function openTrustedExisting(targetPath) {
|
|
|
123
1324
|
throw error;
|
|
124
1325
|
}
|
|
125
1326
|
try {
|
|
126
|
-
const stat =
|
|
1327
|
+
const stat = fs3.fstatSync(fd);
|
|
127
1328
|
if (!stat.isFile()) {
|
|
128
1329
|
throw createSetupError(`Refusing to read ${targetPath}: not a regular file`);
|
|
129
1330
|
}
|
|
130
|
-
return { bytes:
|
|
1331
|
+
return { bytes: fs3.readFileSync(fd), mode: stat.mode & 511 };
|
|
131
1332
|
} finally {
|
|
132
|
-
|
|
1333
|
+
fs3.closeSync(fd);
|
|
133
1334
|
}
|
|
134
1335
|
}
|
|
135
1336
|
function atomicWrite(targetPath, content, originalBytes, mode, ops) {
|
|
@@ -144,8 +1345,8 @@ function atomicWrite(targetPath, content, originalBytes, mode, ops) {
|
|
|
144
1345
|
writeTempAndRename(targetPath, content, mode, preserveMode, ops);
|
|
145
1346
|
}
|
|
146
1347
|
function writeTempAndRename(destPath, content, mode, preserveExactMode, ops) {
|
|
147
|
-
const parentDir =
|
|
148
|
-
const tempPath = makeTempPath(parentDir,
|
|
1348
|
+
const parentDir = path3.dirname(destPath);
|
|
1349
|
+
const tempPath = makeTempPath(parentDir, path3.basename(destPath));
|
|
149
1350
|
try {
|
|
150
1351
|
ops.writeFileSync(tempPath, content, { flag: "wx", mode });
|
|
151
1352
|
if (preserveExactMode) {
|
|
@@ -157,11 +1358,11 @@ function writeTempAndRename(destPath, content, mode, preserveExactMode, ops) {
|
|
|
157
1358
|
throw error;
|
|
158
1359
|
}
|
|
159
1360
|
}
|
|
160
|
-
function makeTempPath(parentDir,
|
|
161
|
-
return
|
|
1361
|
+
function makeTempPath(parentDir, basename2) {
|
|
1362
|
+
return path3.join(parentDir, `.${basename2}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
162
1363
|
}
|
|
163
1364
|
function cleanupTemp(tempPath, ops) {
|
|
164
|
-
if (!
|
|
1365
|
+
if (!fs3.existsSync(tempPath))
|
|
165
1366
|
return;
|
|
166
1367
|
try {
|
|
167
1368
|
ops.unlinkSync(tempPath);
|
|
@@ -195,7 +1396,7 @@ function parseOpenCodeJsonc(rawText, targetPath) {
|
|
|
195
1396
|
}
|
|
196
1397
|
function setupOpenCode(cwd, ops) {
|
|
197
1398
|
const targetPath = resolveOpenCodeTargetPath(cwd);
|
|
198
|
-
assertParentTrusted(
|
|
1399
|
+
assertParentTrusted(path3.dirname(targetPath), cwd);
|
|
199
1400
|
const existing = openTrustedExisting(targetPath);
|
|
200
1401
|
if (existing === null) {
|
|
201
1402
|
const content = `${JSON.stringify({ plugin: [SYSTEMATIC_PACKAGE_NAME] }, null, 2)}
|
|
@@ -253,8 +1454,8 @@ function assertPiTaggedEntryUsable(entry, targetPath) {
|
|
|
253
1454
|
}
|
|
254
1455
|
}
|
|
255
1456
|
function setupPi(cwd, ops) {
|
|
256
|
-
const targetPath =
|
|
257
|
-
assertParentTrusted(
|
|
1457
|
+
const targetPath = path3.join(cwd, ".pi", "settings.json");
|
|
1458
|
+
assertParentTrusted(path3.dirname(targetPath), cwd);
|
|
258
1459
|
const existing = openTrustedExisting(targetPath);
|
|
259
1460
|
if (existing === null) {
|
|
260
1461
|
const content = `${JSON.stringify({ packages: [PI_PACKAGE_IDENTIFIER] }, null, 2)}
|
|
@@ -314,10 +1515,10 @@ function setupHarness(harness, cwd, opsOverride) {
|
|
|
314
1515
|
// src/cli.ts
|
|
315
1516
|
var getPackageVersion = () => {
|
|
316
1517
|
try {
|
|
317
|
-
const packageJsonPath =
|
|
318
|
-
if (!
|
|
1518
|
+
const packageJsonPath = path4.resolve(import.meta.dirname, "..", "package.json");
|
|
1519
|
+
if (!fs4.existsSync(packageJsonPath))
|
|
319
1520
|
return "unknown";
|
|
320
|
-
const content =
|
|
1521
|
+
const content = fs4.readFileSync(packageJsonPath, "utf8");
|
|
321
1522
|
const parsed = JSON.parse(content);
|
|
322
1523
|
return parsed.version ?? "unknown";
|
|
323
1524
|
} catch {
|
|
@@ -337,6 +1538,11 @@ Commands:
|
|
|
337
1538
|
show Show configuration
|
|
338
1539
|
path Print config file locations
|
|
339
1540
|
setup --harness opencode|pi Configure a harness to load Systematic (project-local only)
|
|
1541
|
+
pi-subagents <subcommand> Manage Systematic persona export for pi-subagents
|
|
1542
|
+
preview [--scope project|global] Show what export would do (no writes)
|
|
1543
|
+
export [--scope project|global] Write persona files to agents dir
|
|
1544
|
+
refresh [--scope project|global] Replace stale owned files from current source
|
|
1545
|
+
cleanup [--scope project|global] Remove all generated files and manifest
|
|
340
1546
|
|
|
341
1547
|
Options:
|
|
342
1548
|
-h, --help Show this help message
|
|
@@ -348,6 +1554,23 @@ Examples:
|
|
|
348
1554
|
systematic config show
|
|
349
1555
|
systematic setup --harness opencode
|
|
350
1556
|
systematic setup --harness pi
|
|
1557
|
+
systematic pi-subagents preview
|
|
1558
|
+
systematic pi-subagents export
|
|
1559
|
+
systematic pi-subagents export --scope global
|
|
1560
|
+
systematic pi-subagents refresh
|
|
1561
|
+
systematic pi-subagents cleanup
|
|
1562
|
+
`;
|
|
1563
|
+
var PI_SUBAGENTS_USAGE = `Usage: systematic pi-subagents <subcommand> [--scope project|global]
|
|
1564
|
+
|
|
1565
|
+
Subcommands:
|
|
1566
|
+
preview Show what export would do (no writes)
|
|
1567
|
+
export Write persona files to agents dir
|
|
1568
|
+
refresh Replace stale owned files from current source
|
|
1569
|
+
cleanup Remove all generated files and manifest
|
|
1570
|
+
|
|
1571
|
+
Scope:
|
|
1572
|
+
project <cwd>/.pi/agents (default)
|
|
1573
|
+
global $PI_CODING_AGENT_DIR/agents or ~/.pi/agent/agents
|
|
351
1574
|
`;
|
|
352
1575
|
function isHarness(value) {
|
|
353
1576
|
return value === "opencode" || value === "pi";
|
|
@@ -376,7 +1599,7 @@ function setupCommand(rest) {
|
|
|
376
1599
|
}
|
|
377
1600
|
}
|
|
378
1601
|
function listItems(type) {
|
|
379
|
-
const packageRoot =
|
|
1602
|
+
const packageRoot = path4.resolve(import.meta.dirname, "..");
|
|
380
1603
|
const bundledDir = packageRoot;
|
|
381
1604
|
let finder;
|
|
382
1605
|
let subdir;
|
|
@@ -397,7 +1620,7 @@ function listItems(type) {
|
|
|
397
1620
|
console.error(`Unknown type: ${type}. Use: skills, agents, commands`);
|
|
398
1621
|
process.exit(1);
|
|
399
1622
|
}
|
|
400
|
-
const items = finder(
|
|
1623
|
+
const items = finder(path4.join(bundledDir, subdir));
|
|
401
1624
|
if (items.length === 0) {
|
|
402
1625
|
console.log(`No ${type} found.`);
|
|
403
1626
|
return;
|
|
@@ -414,15 +1637,15 @@ function configShow() {
|
|
|
414
1637
|
`);
|
|
415
1638
|
console.log(` User config: ${paths.userConfig}`);
|
|
416
1639
|
console.log(` Project config: ${paths.projectConfig}`);
|
|
417
|
-
if (
|
|
1640
|
+
if (fs4.existsSync(paths.projectConfig)) {
|
|
418
1641
|
console.log(`
|
|
419
1642
|
Project configuration:`);
|
|
420
|
-
console.log(
|
|
1643
|
+
console.log(fs4.readFileSync(paths.projectConfig, "utf-8"));
|
|
421
1644
|
}
|
|
422
|
-
if (
|
|
1645
|
+
if (fs4.existsSync(paths.userConfig)) {
|
|
423
1646
|
console.log(`
|
|
424
1647
|
User configuration:`);
|
|
425
|
-
console.log(
|
|
1648
|
+
console.log(fs4.readFileSync(paths.userConfig, "utf-8"));
|
|
426
1649
|
}
|
|
427
1650
|
}
|
|
428
1651
|
function configPath() {
|
|
@@ -431,6 +1654,159 @@ function configPath() {
|
|
|
431
1654
|
console.log(` User: ${paths.userConfig}`);
|
|
432
1655
|
console.log(` Project: ${paths.projectConfig}`);
|
|
433
1656
|
}
|
|
1657
|
+
function parsePiSubagentsArgs(rest) {
|
|
1658
|
+
let subcommand;
|
|
1659
|
+
let scope = "project";
|
|
1660
|
+
let i = 0;
|
|
1661
|
+
while (i < rest.length) {
|
|
1662
|
+
const arg = rest[i];
|
|
1663
|
+
if (arg === "--scope") {
|
|
1664
|
+
i++;
|
|
1665
|
+
const val = rest[i];
|
|
1666
|
+
if (val !== "project" && val !== "global") {
|
|
1667
|
+
console.error(`Invalid --scope value: ${val ?? ""}. Use: project, global`);
|
|
1668
|
+
process.exit(1);
|
|
1669
|
+
}
|
|
1670
|
+
scope = val;
|
|
1671
|
+
} else if (subcommand === undefined && arg !== undefined && !arg.startsWith("-")) {
|
|
1672
|
+
subcommand = arg;
|
|
1673
|
+
} else if (arg !== undefined) {
|
|
1674
|
+
console.error(`Unknown argument: ${arg}`);
|
|
1675
|
+
console.error(PI_SUBAGENTS_USAGE);
|
|
1676
|
+
process.exit(1);
|
|
1677
|
+
}
|
|
1678
|
+
i++;
|
|
1679
|
+
}
|
|
1680
|
+
return { subcommand, scope };
|
|
1681
|
+
}
|
|
1682
|
+
function printActionLine(a) {
|
|
1683
|
+
if (a.action === "create")
|
|
1684
|
+
console.log(` + ${a.filename} (create)`);
|
|
1685
|
+
else if (a.action === "update")
|
|
1686
|
+
console.log(` ~ ${a.filename} (update)`);
|
|
1687
|
+
else if (a.action === "refuse")
|
|
1688
|
+
console.log(` ! ${a.filename} (refuse: ${a.reason})`);
|
|
1689
|
+
else if (a.action === "remove")
|
|
1690
|
+
console.log(` - ${a.filename} (remove stale)`);
|
|
1691
|
+
else
|
|
1692
|
+
console.log(` = ${a.filename} (up to date)`);
|
|
1693
|
+
}
|
|
1694
|
+
function runPreview(agentsRoot, scope) {
|
|
1695
|
+
const plan = preview(agentsRoot, { scope, cwd: process.cwd() });
|
|
1696
|
+
if (plan.status === "error") {
|
|
1697
|
+
console.error(`Preview failed: ${plan.error ?? "unknown error"}`);
|
|
1698
|
+
process.exit(1);
|
|
1699
|
+
}
|
|
1700
|
+
console.log(`Target: ${plan.agentsRoot}`);
|
|
1701
|
+
console.log("");
|
|
1702
|
+
if (plan.actions.length === 0) {
|
|
1703
|
+
console.log("No actions needed (nothing to export).");
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
const counts = { create: 0, update: 0, refuse: 0, remove: 0, skip: 0 };
|
|
1707
|
+
for (const a of plan.actions) {
|
|
1708
|
+
counts[a.action]++;
|
|
1709
|
+
printActionLine(a);
|
|
1710
|
+
}
|
|
1711
|
+
console.log("");
|
|
1712
|
+
console.log(`Summary: ${counts.create} create, ${counts.update} update, ` + `${counts.skip} skip, ${counts.refuse} refuse, ${counts.remove} remove`);
|
|
1713
|
+
}
|
|
1714
|
+
function runExport(agentsRoot, scope) {
|
|
1715
|
+
const configOptions = { scope, cwd: process.cwd() };
|
|
1716
|
+
const plan = preview(agentsRoot, configOptions);
|
|
1717
|
+
if (plan.status === "error") {
|
|
1718
|
+
console.error(`Export failed (preview step): ${plan.error ?? "unknown error"}`);
|
|
1719
|
+
process.exit(1);
|
|
1720
|
+
}
|
|
1721
|
+
console.log(`Target: ${plan.agentsRoot}`);
|
|
1722
|
+
const hasWork = plan.actions.some((a) => a.action === "create" || a.action === "update" || a.action === "refuse" || a.action === "remove");
|
|
1723
|
+
if (plan.actions.length > 0) {
|
|
1724
|
+
console.log("");
|
|
1725
|
+
const counts = { create: 0, update: 0, refuse: 0, remove: 0, skip: 0 };
|
|
1726
|
+
for (const a of plan.actions) {
|
|
1727
|
+
counts[a.action]++;
|
|
1728
|
+
printActionLine(a);
|
|
1729
|
+
}
|
|
1730
|
+
console.log("");
|
|
1731
|
+
console.log(`Summary: ${counts.create} create, ${counts.update} update, ` + `${counts.skip} skip, ${counts.refuse} refuse, ${counts.remove} remove`);
|
|
1732
|
+
}
|
|
1733
|
+
if (!hasWork) {
|
|
1734
|
+
console.log("All persona files are already up to date. No changes.");
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
const result = exportPersonas(agentsRoot, configOptions);
|
|
1738
|
+
if (result.status === "error") {
|
|
1739
|
+
console.error(`Export failed: ${result.error ?? "unknown error"}`);
|
|
1740
|
+
process.exit(1);
|
|
1741
|
+
}
|
|
1742
|
+
const hadRemovals = plan.actions.some((a) => a.action === "remove");
|
|
1743
|
+
if (result.written === 0 && result.refused.length === 0 && !hadRemovals) {
|
|
1744
|
+
console.log("All persona files are already up to date. No changes.");
|
|
1745
|
+
} else {
|
|
1746
|
+
console.log(`Exported ${result.written} file(s) to ${agentsRoot}/ (${result.skipped} already up to date).`);
|
|
1747
|
+
}
|
|
1748
|
+
if (result.refused.length > 0) {
|
|
1749
|
+
console.log(`
|
|
1750
|
+
Refused (pre-existing unowned files not overwritten):`);
|
|
1751
|
+
for (const r of result.refused)
|
|
1752
|
+
console.log(` ! ${r.filename}: ${r.reason}`);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
function runRefresh(agentsRoot, scope) {
|
|
1756
|
+
const result = refresh(agentsRoot, { scope, cwd: process.cwd() });
|
|
1757
|
+
if (result.status === "error") {
|
|
1758
|
+
console.error(`Refresh failed: ${result.error ?? "unknown error"}`);
|
|
1759
|
+
process.exit(1);
|
|
1760
|
+
}
|
|
1761
|
+
if (result.updated === 0) {
|
|
1762
|
+
console.log("All owned persona files are up to date. No changes.");
|
|
1763
|
+
} else {
|
|
1764
|
+
console.log(`Refreshed: updated ${result.updated} file(s) in ${agentsRoot}/.`);
|
|
1765
|
+
}
|
|
1766
|
+
if (result.skippedUnowned > 0)
|
|
1767
|
+
console.log(` Skipped ${result.skippedUnowned} unowned file(s) (not touched).`);
|
|
1768
|
+
}
|
|
1769
|
+
function runCleanup(agentsRoot, scope) {
|
|
1770
|
+
let result;
|
|
1771
|
+
try {
|
|
1772
|
+
result = cleanup(agentsRoot, { scope, cwd: process.cwd() });
|
|
1773
|
+
} catch (err) {
|
|
1774
|
+
console.error(`Cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1775
|
+
process.exit(1);
|
|
1776
|
+
}
|
|
1777
|
+
if (result.status === "error") {
|
|
1778
|
+
console.error(`Cleanup failed: ${result.error ?? "unknown error"}`);
|
|
1779
|
+
process.exit(1);
|
|
1780
|
+
}
|
|
1781
|
+
console.log(`Cleaned up generated persona files from ${agentsRoot}/. Manifest (${MANIFEST_FILENAME}) removed.`);
|
|
1782
|
+
}
|
|
1783
|
+
function piSubagentsCommand(rest) {
|
|
1784
|
+
const { subcommand, scope } = parsePiSubagentsArgs(rest);
|
|
1785
|
+
if (!subcommand) {
|
|
1786
|
+
console.error("Error: pi-subagents requires a subcommand.");
|
|
1787
|
+
console.error(PI_SUBAGENTS_USAGE);
|
|
1788
|
+
process.exit(1);
|
|
1789
|
+
}
|
|
1790
|
+
const agentsRoot = resolveAgentsRoot(scope, process.cwd());
|
|
1791
|
+
switch (subcommand) {
|
|
1792
|
+
case "preview":
|
|
1793
|
+
runPreview(agentsRoot, scope);
|
|
1794
|
+
break;
|
|
1795
|
+
case "export":
|
|
1796
|
+
runExport(agentsRoot, scope);
|
|
1797
|
+
break;
|
|
1798
|
+
case "refresh":
|
|
1799
|
+
runRefresh(agentsRoot, scope);
|
|
1800
|
+
break;
|
|
1801
|
+
case "cleanup":
|
|
1802
|
+
runCleanup(agentsRoot, scope);
|
|
1803
|
+
break;
|
|
1804
|
+
default:
|
|
1805
|
+
console.error(`Unknown pi-subagents subcommand: ${subcommand}`);
|
|
1806
|
+
console.error(PI_SUBAGENTS_USAGE);
|
|
1807
|
+
process.exit(1);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
434
1810
|
var args = process.argv.slice(2);
|
|
435
1811
|
var command = args[0];
|
|
436
1812
|
switch (command) {
|
|
@@ -440,6 +1816,9 @@ switch (command) {
|
|
|
440
1816
|
case "setup":
|
|
441
1817
|
setupCommand(args.slice(1));
|
|
442
1818
|
break;
|
|
1819
|
+
case "pi-subagents":
|
|
1820
|
+
piSubagentsCommand(args.slice(1));
|
|
1821
|
+
break;
|
|
443
1822
|
case "config":
|
|
444
1823
|
switch (args[1]) {
|
|
445
1824
|
case "show":
|