skillspub 0.1.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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/catalog.js +352 -0
- package/dist/cli.js +1562 -0
- package/dist/core.js +31 -0
- package/dist/explain.js +307 -0
- package/dist/harnesses/claude.js +95 -0
- package/dist/harnesses/grok.js +746 -0
- package/dist/harnesses/pi.js +1380 -0
- package/dist/harnesses/registry.js +30 -0
- package/dist/harnesses/target.js +5 -0
- package/dist/harnesses/types.js +1 -0
- package/dist/inventory.js +1437 -0
- package/dist/npx-skills.js +273 -0
- package/dist/reconcile.js +1014 -0
- package/dist/shared.js +1411 -0
- package/dist/source-verification.js +297 -0
- package/dist/targets/shared.js +13 -0
- package/dist/tui.js +2373 -0
- package/dist/view.js +321 -0
- package/package.json +51 -0
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { hashDirectory, normalizeSlotName, readStateFile, scanGlobalInventory, scanProjectInventory, } from "../inventory.js";
|
|
6
|
+
import { resolveHarnessTarget } from "./target.js";
|
|
7
|
+
const EVIDENCE = [
|
|
8
|
+
{
|
|
9
|
+
url: 'https://github.com/earendil-works/pi/blob/v0.85.1/packages/coding-agent/docs/skills.md',
|
|
10
|
+
verifiedVersion: '0.85.1',
|
|
11
|
+
detail: 'Pi discovers global, project, and Shared Agent Skills directories.',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
url: 'https://github.com/earendil-works/pi/releases/tag/v0.85.1',
|
|
15
|
+
verifiedVersion: '0.85.1',
|
|
16
|
+
detail: 'Real-machine release baseline accepted by SkillsPub issue #127.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
url: 'https://github.com/earendil-works/pi/commit/d981de1229ef899957bbe968bc8dcda02a21f477',
|
|
20
|
+
verifiedVersion: '0.85.1',
|
|
21
|
+
detail: 'Release source revalidated with root-specific Shared exclusions.',
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
function hash(value) {
|
|
25
|
+
return crypto.createHash('sha256').update(value ?? '').digest('hex');
|
|
26
|
+
}
|
|
27
|
+
function toPosix(value) {
|
|
28
|
+
return value.split(path.sep).join('/');
|
|
29
|
+
}
|
|
30
|
+
function piHome(target) {
|
|
31
|
+
return path.dirname(path.dirname(target.discoveryRoot));
|
|
32
|
+
}
|
|
33
|
+
function settingsFile(pi) {
|
|
34
|
+
return path.join(piHome(pi), 'agent', 'settings.json');
|
|
35
|
+
}
|
|
36
|
+
function stateFile(home) {
|
|
37
|
+
return path.join(home.configDir, 'state.json');
|
|
38
|
+
}
|
|
39
|
+
function readSnapshot(file, label) {
|
|
40
|
+
try {
|
|
41
|
+
const stat = fs.lstatSync(file);
|
|
42
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
43
|
+
throw new Error(`${label} path must be a regular file: ${file}`);
|
|
44
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
45
|
+
return { file, exists: true, raw, hash: hash(raw) };
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT')
|
|
49
|
+
return { file, exists: false, hash: hash(undefined) };
|
|
50
|
+
if (error.message.includes('path must be a regular file'))
|
|
51
|
+
throw error;
|
|
52
|
+
throw new Error(`cannot read ${label} at ${file}: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function parseRecord(snapshot, label) {
|
|
56
|
+
if (!snapshot.exists)
|
|
57
|
+
return {};
|
|
58
|
+
try {
|
|
59
|
+
const value = JSON.parse(snapshot.raw ?? '');
|
|
60
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
61
|
+
throw new Error('must be an object');
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`cannot read ${label} at ${snapshot.file}: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function validateMatcherEntry(entry, file) {
|
|
69
|
+
if (!entry || entry.includes('\0'))
|
|
70
|
+
throw new Error(`Pi settings at ${file} contain an invalid skills entry`);
|
|
71
|
+
const prefix = entry[0];
|
|
72
|
+
if (!['!', '+', '-'].includes(prefix ?? ''))
|
|
73
|
+
return;
|
|
74
|
+
const pattern = entry.slice(1);
|
|
75
|
+
if (!pattern || /[[\]{}()\\]/.test(pattern))
|
|
76
|
+
throw new Error(`Pi settings at ${file} contain unsupported matcher semantics: ${entry}`);
|
|
77
|
+
}
|
|
78
|
+
function readSettings(file) {
|
|
79
|
+
const snapshot = readSnapshot(file, 'Pi settings');
|
|
80
|
+
const value = parseRecord(snapshot, 'Pi settings');
|
|
81
|
+
const skills = value.skills;
|
|
82
|
+
if (skills !== undefined && (!Array.isArray(skills) || skills.some((item) => typeof item !== 'string')))
|
|
83
|
+
throw new Error(`Pi settings at ${file} have unsupported skills configuration`);
|
|
84
|
+
const result = (skills ?? []);
|
|
85
|
+
for (const entry of result)
|
|
86
|
+
validateMatcherEntry(entry, file);
|
|
87
|
+
return { ...snapshot, value, skills: result };
|
|
88
|
+
}
|
|
89
|
+
function resolveMatcherPath(value, baseDir) {
|
|
90
|
+
if (value === '~' || value.startsWith('~/'))
|
|
91
|
+
return undefined;
|
|
92
|
+
return path.resolve(baseDir, value);
|
|
93
|
+
}
|
|
94
|
+
function regexEscape(value) {
|
|
95
|
+
return value.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
96
|
+
}
|
|
97
|
+
function globMatches(value, pattern) {
|
|
98
|
+
let expression = '';
|
|
99
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
100
|
+
const character = pattern[index];
|
|
101
|
+
if (character === '*') {
|
|
102
|
+
if (pattern[index + 1] === '*') {
|
|
103
|
+
expression += '.*';
|
|
104
|
+
index++;
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
expression += '[^/]*';
|
|
108
|
+
}
|
|
109
|
+
else if (character === '?')
|
|
110
|
+
expression += '[^/]';
|
|
111
|
+
else
|
|
112
|
+
expression += regexEscape(character ?? '');
|
|
113
|
+
}
|
|
114
|
+
return new RegExp(`^${expression}$`).test(value);
|
|
115
|
+
}
|
|
116
|
+
function patternCandidates(filePath, baseDir) {
|
|
117
|
+
const absolute = toPosix(path.resolve(filePath));
|
|
118
|
+
const relative = toPosix(path.relative(baseDir, filePath));
|
|
119
|
+
const name = path.basename(filePath);
|
|
120
|
+
const isSkillFile = name === 'SKILL.md';
|
|
121
|
+
if (!isSkillFile)
|
|
122
|
+
return [relative, name, absolute];
|
|
123
|
+
const parent = path.dirname(filePath);
|
|
124
|
+
return [
|
|
125
|
+
relative,
|
|
126
|
+
name,
|
|
127
|
+
absolute,
|
|
128
|
+
toPosix(path.relative(baseDir, parent)),
|
|
129
|
+
path.basename(parent),
|
|
130
|
+
toPosix(path.resolve(parent)),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
function matchesPattern(filePath, pattern, baseDir) {
|
|
134
|
+
return patternCandidates(filePath, baseDir).some((candidate) => globMatches(candidate, toPosix(pattern)));
|
|
135
|
+
}
|
|
136
|
+
function normalizeExactPattern(pattern) {
|
|
137
|
+
return pattern.startsWith('./') || pattern.startsWith('.\\') ? pattern.slice(2) : pattern;
|
|
138
|
+
}
|
|
139
|
+
function matchesExact(filePath, pattern, baseDir) {
|
|
140
|
+
const normalized = normalizeExactPattern(toPosix(pattern));
|
|
141
|
+
const candidates = patternCandidates(filePath, baseDir);
|
|
142
|
+
return candidates.some((candidate, index) => index !== 1 && candidate === normalized);
|
|
143
|
+
}
|
|
144
|
+
function enabledByMatcher(filePath, skills, baseDir) {
|
|
145
|
+
const overrides = skills.filter((entry) => ['!', '+', '-'].includes(entry[0] ?? ''));
|
|
146
|
+
let enabled = !overrides
|
|
147
|
+
.filter((entry) => entry.startsWith('!'))
|
|
148
|
+
.some((entry) => matchesPattern(filePath, entry.slice(1), baseDir));
|
|
149
|
+
if (overrides.filter((entry) => entry.startsWith('+'))
|
|
150
|
+
.some((entry) => matchesExact(filePath, entry.slice(1), baseDir)))
|
|
151
|
+
enabled = true;
|
|
152
|
+
if (overrides.filter((entry) => entry.startsWith('-'))
|
|
153
|
+
.some((entry) => matchesExact(filePath, entry.slice(1), baseDir)))
|
|
154
|
+
enabled = false;
|
|
155
|
+
return enabled;
|
|
156
|
+
}
|
|
157
|
+
function rootSpecificExclusion(entry, root, baseDir) {
|
|
158
|
+
if (!entry.startsWith('!') || !entry.endsWith('/**'))
|
|
159
|
+
return false;
|
|
160
|
+
const rootPattern = entry.slice(1, -3);
|
|
161
|
+
if (/[*?]/.test(rootPattern))
|
|
162
|
+
return false;
|
|
163
|
+
const resolved = resolveMatcherPath(rootPattern, baseDir);
|
|
164
|
+
return resolved !== undefined && path.normalize(resolved) === path.normalize(path.resolve(root));
|
|
165
|
+
}
|
|
166
|
+
function isInside(root, candidate) {
|
|
167
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
168
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
169
|
+
}
|
|
170
|
+
function lexicalSkillFiles(root) {
|
|
171
|
+
try {
|
|
172
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
173
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
174
|
+
.map((entry) => path.join(root, entry.name, 'SKILL.md'))
|
|
175
|
+
.filter((file) => fs.existsSync(file));
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code === 'ENOENT')
|
|
179
|
+
return [];
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function projectCollision(settings, piRoot, sharedRoots) {
|
|
184
|
+
const baseDir = path.dirname(settings.file);
|
|
185
|
+
const bySlot = new Map();
|
|
186
|
+
const canonical = new Set();
|
|
187
|
+
for (const root of [piRoot, ...sharedRoots]) {
|
|
188
|
+
for (const file of lexicalSkillFiles(root)) {
|
|
189
|
+
if (!enabledByMatcher(file, settings.skills, baseDir))
|
|
190
|
+
continue;
|
|
191
|
+
const realPath = fs.realpathSync(file);
|
|
192
|
+
if (canonical.has(realPath))
|
|
193
|
+
continue;
|
|
194
|
+
canonical.add(realPath);
|
|
195
|
+
const slot = normalizeSlotName(path.basename(path.dirname(file)));
|
|
196
|
+
const previous = bySlot.get(slot);
|
|
197
|
+
if (previous && previous.realPath !== realPath)
|
|
198
|
+
return `Pi Skill collision for ${slot}: ${previous.lexicalPath} conflicts with ${file}`;
|
|
199
|
+
bySlot.set(slot, { lexicalPath: file, realPath });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
function inspectMatcher(skills, settingsPath, piRoot, sharedRoot) {
|
|
205
|
+
const baseDir = path.dirname(settingsPath);
|
|
206
|
+
const equivalentExclusion = skills.find((entry) => rootSpecificExclusion(entry, sharedRoot, baseDir));
|
|
207
|
+
const forceIncludes = skills.filter((entry) => entry.startsWith('+')).map((entry) => entry.slice(1));
|
|
208
|
+
const forceExcludes = skills.filter((entry) => entry.startsWith('-')).map((entry) => entry.slice(1));
|
|
209
|
+
const sharedFiles = lexicalSkillFiles(sharedRoot);
|
|
210
|
+
const forceInclude = forceIncludes.find((exact) => {
|
|
211
|
+
if (/[*?]/.test(exact))
|
|
212
|
+
return false;
|
|
213
|
+
const resolved = resolveMatcherPath(exact, baseDir);
|
|
214
|
+
const targetsSharedRoot = resolved !== undefined && isInside(sharedRoot, resolved);
|
|
215
|
+
const includedFile = sharedFiles.find((file) => matchesExact(file, exact, baseDir));
|
|
216
|
+
if (!targetsSharedRoot && !includedFile)
|
|
217
|
+
return false;
|
|
218
|
+
const probe = includedFile ?? path.join(resolved, 'SKILL.md');
|
|
219
|
+
return !forceExcludes.some((entry) => matchesExact(probe, entry, baseDir));
|
|
220
|
+
});
|
|
221
|
+
const piProbe = path.join(piRoot, '__skillspub_probe__', 'SKILL.md');
|
|
222
|
+
const sharedProbe = path.join(sharedRoot, '__skillspub_probe__', 'SKILL.md');
|
|
223
|
+
const piTargetConflict = enabledByMatcher(piProbe, skills, baseDir)
|
|
224
|
+
? undefined
|
|
225
|
+
: skills.find((entry) => entry.startsWith('!') || entry.startsWith('-'));
|
|
226
|
+
return {
|
|
227
|
+
sharedExcluded: (Boolean(equivalentExclusion) || !enabledByMatcher(sharedProbe, skills, baseDir)) && !forceInclude,
|
|
228
|
+
equivalentExclusion,
|
|
229
|
+
piTargetConflict,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function canonicalExclusionRoot(value) {
|
|
233
|
+
const root = path.resolve(value);
|
|
234
|
+
if (/[*?[\]{}()]/.test(root))
|
|
235
|
+
throw new Error(`Shared root cannot be represented safely in Pi matcher: ${root}`);
|
|
236
|
+
return `!${toPosix(root)}/**`;
|
|
237
|
+
}
|
|
238
|
+
function canonicalExclusion(shared) {
|
|
239
|
+
return canonicalExclusionRoot(shared.discoveryRoot);
|
|
240
|
+
}
|
|
241
|
+
function resolvePiTarget(targets) {
|
|
242
|
+
return resolveHarnessTarget(targets, 'pi', () => piAdapter.targetDefinition());
|
|
243
|
+
}
|
|
244
|
+
function resolveSharedTarget(targets) {
|
|
245
|
+
return resolveHarnessTarget(targets, 'shared', () => ({
|
|
246
|
+
key: 'shared',
|
|
247
|
+
kind: 'shared',
|
|
248
|
+
discoveryRoot: path.join(os.homedir(), '.agents', 'skills'),
|
|
249
|
+
parkingRoot: path.join(os.homedir(), '.agents', '.skillspub-off', 'skills'),
|
|
250
|
+
projectPath: '.agents/skills',
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
function canonicalProjectPath(selectedPath) {
|
|
254
|
+
const project = fs.realpathSync(selectedPath);
|
|
255
|
+
if (!fs.statSync(project).isDirectory())
|
|
256
|
+
throw new Error(`Project path is not a directory: ${selectedPath}`);
|
|
257
|
+
return project;
|
|
258
|
+
}
|
|
259
|
+
function projectBoundary(project) {
|
|
260
|
+
for (let current = project;; current = path.dirname(current)) {
|
|
261
|
+
if (fs.existsSync(path.join(current, '.git')))
|
|
262
|
+
return current;
|
|
263
|
+
const parent = path.dirname(current);
|
|
264
|
+
if (parent === current)
|
|
265
|
+
return current;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function projectSharedRoots(project, globalSharedRoot) {
|
|
269
|
+
const boundary = projectBoundary(project);
|
|
270
|
+
const globalRoot = path.normalize(path.resolve(globalSharedRoot));
|
|
271
|
+
const roots = [];
|
|
272
|
+
for (let current = project;; current = path.dirname(current)) {
|
|
273
|
+
const candidate = path.join(current, '.agents', 'skills');
|
|
274
|
+
if (path.normalize(path.resolve(candidate)) !== globalRoot)
|
|
275
|
+
roots.push(candidate);
|
|
276
|
+
if (current === boundary)
|
|
277
|
+
return roots;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function readProjectTrust(pi, project) {
|
|
281
|
+
const trustPath = path.join(piHome(pi), 'agent', 'trust.json');
|
|
282
|
+
try {
|
|
283
|
+
const trust = parseRecord(readSnapshot(trustPath, 'Pi trust store'), 'Pi trust store');
|
|
284
|
+
for (let current = project;; current = path.dirname(current)) {
|
|
285
|
+
const decision = trust[current];
|
|
286
|
+
if (decision === true || decision === false)
|
|
287
|
+
return { trusted: decision, detail: `Pi trust decision ${decision ? 'trusts' : 'does not trust'} ${current}.` };
|
|
288
|
+
if (decision !== undefined && decision !== null)
|
|
289
|
+
throw new Error(`value for ${current} must be true, false, or null`);
|
|
290
|
+
const parent = path.dirname(current);
|
|
291
|
+
if (parent === current)
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
const global = readSettings(settingsFile(pi)).value.defaultProjectTrust;
|
|
295
|
+
if (global !== undefined && !['ask', 'always', 'never'].includes(String(global)))
|
|
296
|
+
throw new Error('defaultProjectTrust must be ask, always, or never');
|
|
297
|
+
if (global === 'always')
|
|
298
|
+
return { trusted: true, detail: 'Global defaultProjectTrust always trusts this Project.' };
|
|
299
|
+
if (global === 'never')
|
|
300
|
+
return { trusted: false, detail: 'Global defaultProjectTrust never trusts this Project.' };
|
|
301
|
+
return { detail: 'Project trust is unresolved (defaultProjectTrust is ask).' };
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
return { detail: `Project trust cannot be confirmed: ${error.message}` };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function isPiClaim(value) {
|
|
308
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
309
|
+
return false;
|
|
310
|
+
const claim = value;
|
|
311
|
+
if (claim.version !== 1 || typeof claim.file !== 'string' || typeof claim.settingsHash !== 'string')
|
|
312
|
+
return false;
|
|
313
|
+
if (claim.scope === 'global')
|
|
314
|
+
return typeof claim.sharedRoot === 'string' && typeof claim.exclusion === 'string';
|
|
315
|
+
return claim.scope === 'project' && typeof claim.projectPath === 'string' &&
|
|
316
|
+
Array.isArray(claim.sharedRoots) && claim.sharedRoots.every((item) => typeof item === 'string') &&
|
|
317
|
+
Array.isArray(claim.exclusions) && claim.exclusions.every((item) => typeof item === 'string');
|
|
318
|
+
}
|
|
319
|
+
function ownership(value, file, sharedRoot, exclusion) {
|
|
320
|
+
if (value === undefined)
|
|
321
|
+
return { status: 'unowned' };
|
|
322
|
+
if (isPiClaim(value)) {
|
|
323
|
+
return value.scope === 'global' && value.file === file &&
|
|
324
|
+
path.resolve(value.sharedRoot) === path.resolve(sharedRoot) && value.exclusion === exclusion
|
|
325
|
+
? { status: 'owned', claim: value }
|
|
326
|
+
: { status: 'drift', claim: value };
|
|
327
|
+
}
|
|
328
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
329
|
+
const legacy = value;
|
|
330
|
+
if (legacy.file === file && typeof legacy.exclusion === 'string')
|
|
331
|
+
return { status: 'drift', legacyExclusion: legacy.exclusion };
|
|
332
|
+
}
|
|
333
|
+
throw new Error('Global SkillsPub state has unknown Pi isolation ownership');
|
|
334
|
+
}
|
|
335
|
+
function inspectOwnership(home, file, sharedRoot, exclusion) {
|
|
336
|
+
try {
|
|
337
|
+
const value = readStateFile(stateFile(home)).piIsolation;
|
|
338
|
+
return ownership(value, file, sharedRoot, exclusion);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
return error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function isolation(ownershipResult, exclusion, settings, excluded) {
|
|
345
|
+
if (ownershipResult instanceof Error)
|
|
346
|
+
return { status: 'unknown', detail: ownershipResult.message };
|
|
347
|
+
if (ownershipResult.status === 'unowned')
|
|
348
|
+
return { status: 'unmanaged', detail: excluded
|
|
349
|
+
? 'An equivalent safe Shared exclusion is active but is not owned by SkillsPub.'
|
|
350
|
+
: 'Shared exclusion is not managed by SkillsPub.' };
|
|
351
|
+
return ownershipResult.status === 'owned' && excluded && settings.skills.includes(exclusion) &&
|
|
352
|
+
ownershipResult.claim?.settingsHash === settings.hash
|
|
353
|
+
? { status: 'managed', detail: 'SkillsPub-owned Global Shared exclusion is active.' }
|
|
354
|
+
: { status: 'drift', detail: 'SkillsPub-owned Global Shared exclusion is missing or changed; run explicit reconcile.' };
|
|
355
|
+
}
|
|
356
|
+
function inspectProjectIsolation(pi, shared, project) {
|
|
357
|
+
if (pi.projectPath !== '.pi/skills')
|
|
358
|
+
return { status: 'drift', detail: 'Stale Pi Project Target override requires a separate migration to canonical .pi/skills.' };
|
|
359
|
+
const trust = readProjectTrust(pi, project);
|
|
360
|
+
if (trust.trusted !== true)
|
|
361
|
+
return { status: 'unknown', detail: trust.detail };
|
|
362
|
+
try {
|
|
363
|
+
const settings = readSettings(path.join(project, '.pi', 'settings.json'));
|
|
364
|
+
const roots = projectSharedRoots(project, shared.discoveryRoot);
|
|
365
|
+
const exclusions = roots.map(canonicalExclusionRoot);
|
|
366
|
+
const state = readStateFile(path.join(project, '.skillspub', 'state.json'));
|
|
367
|
+
const owned = projectOwnership(state.piIsolation, settings.file, project, roots, exclusions);
|
|
368
|
+
const piRoot = path.join(project, '.pi', 'skills');
|
|
369
|
+
const excluded = roots.every((root) => inspectMatcher(settings.skills, settings.file, piRoot, root).sharedExcluded);
|
|
370
|
+
const collision = projectCollision(settings, piRoot, roots);
|
|
371
|
+
if (collision)
|
|
372
|
+
return { status: 'unknown', detail: collision };
|
|
373
|
+
if (owned.status === 'unowned')
|
|
374
|
+
return { status: 'unmanaged', detail: excluded
|
|
375
|
+
? 'Equivalent safe Project Shared exclusions are active but not owned by SkillsPub.'
|
|
376
|
+
: 'Project Shared exclusions are not managed by SkillsPub.' };
|
|
377
|
+
return owned.status === 'owned' && excluded && owned.claim?.settingsHash === settings.hash
|
|
378
|
+
? { status: 'managed', detail: 'SkillsPub-owned exact-Project Shared exclusions are active.' }
|
|
379
|
+
: { status: 'drift', detail: 'SkillsPub-owned exact-Project Shared exclusions are missing or changed; run explicit reconcile.' };
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
return { status: 'unknown', detail: error.message };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function rootConsumption(result) {
|
|
386
|
+
if (result.detail)
|
|
387
|
+
return 'unknown';
|
|
388
|
+
return result.excluded ? 'excluded' : 'consumed';
|
|
389
|
+
}
|
|
390
|
+
function inspectSharedRoot(settingsPath, piRoot, sharedRoot) {
|
|
391
|
+
try {
|
|
392
|
+
const settings = readSettings(settingsPath);
|
|
393
|
+
const matcher = inspectMatcher(settings.skills, settingsPath, piRoot, sharedRoot);
|
|
394
|
+
return { excluded: matcher.sharedExcluded, piTargetConflict: matcher.piTargetConflict, skills: settings.skills };
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
return { excluded: false, detail: error.message, skills: [] };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function inspectShared(pi, shared, projectPath) {
|
|
401
|
+
const global = inspectSharedRoot(settingsFile(pi), path.resolve(pi.discoveryRoot), path.resolve(shared.discoveryRoot));
|
|
402
|
+
const entries = [{ scope: 'global', discoveryRoot: path.resolve(shared.discoveryRoot), result: global }];
|
|
403
|
+
const projectResults = [];
|
|
404
|
+
let trust;
|
|
405
|
+
if (projectPath) {
|
|
406
|
+
trust = readProjectTrust(pi, projectPath);
|
|
407
|
+
const roots = projectSharedRoots(projectPath, shared.discoveryRoot);
|
|
408
|
+
for (const [index, discoveryRoot] of roots.entries()) {
|
|
409
|
+
const result = trust.trusted === true
|
|
410
|
+
? inspectSharedRoot(path.join(projectPath, '.pi', 'settings.json'), path.join(projectPath, '.pi', 'skills'), discoveryRoot)
|
|
411
|
+
: { excluded: false, detail: trust.detail, skills: [] };
|
|
412
|
+
projectResults.push(result);
|
|
413
|
+
entries.push({ scope: index === 0 ? 'project' : 'parent', discoveryRoot, result, trustDetail: trust.detail });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const unknown = entries.find(({ result }) => result.detail);
|
|
417
|
+
const excluded = entries.filter(({ result }) => result.excluded);
|
|
418
|
+
let summary;
|
|
419
|
+
if (unknown)
|
|
420
|
+
summary = { status: 'unknown', detail: unknown.result.detail };
|
|
421
|
+
else if (excluded.length === entries.length) {
|
|
422
|
+
summary = { status: 'excluded', detail: `Pi Shared skills are excluded: ${entries.map(({ discoveryRoot }) => discoveryRoot).join(', ')}` };
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
summary = { status: 'enabled', detail: `Pi discovers Shared skills at ${entries.filter(({ result }) => !result.excluded).map(({ discoveryRoot }) => discoveryRoot).join(', ')}` };
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
summary,
|
|
429
|
+
global,
|
|
430
|
+
project: projectResults,
|
|
431
|
+
trust,
|
|
432
|
+
roots: entries.map(({ scope, discoveryRoot, result, trustDetail }) => ({
|
|
433
|
+
kind: 'shared',
|
|
434
|
+
targetKey: 'shared',
|
|
435
|
+
scope,
|
|
436
|
+
discoveryRoot,
|
|
437
|
+
consumption: rootConsumption(result),
|
|
438
|
+
reason: result.detail ?? (result.excluded
|
|
439
|
+
? 'Pi settings exclude this lexical Shared root before canonical dedupe.'
|
|
440
|
+
: `Pi settings allow this Shared root. ${trustDetail ?? ''}`.trim()),
|
|
441
|
+
})),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function atomicWrite(file, raw) {
|
|
445
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
446
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
447
|
+
let descriptor;
|
|
448
|
+
try {
|
|
449
|
+
descriptor = fs.openSync(temporary, 'wx', 0o600);
|
|
450
|
+
fs.writeFileSync(descriptor, raw);
|
|
451
|
+
fs.fsyncSync(descriptor);
|
|
452
|
+
fs.closeSync(descriptor);
|
|
453
|
+
descriptor = undefined;
|
|
454
|
+
fs.renameSync(temporary, file);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (descriptor !== undefined)
|
|
458
|
+
fs.closeSync(descriptor);
|
|
459
|
+
fs.rmSync(temporary, { force: true });
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function nearestExistingParent(file) {
|
|
464
|
+
let current = path.dirname(file);
|
|
465
|
+
while (!fs.existsSync(current)) {
|
|
466
|
+
const parent = path.dirname(current);
|
|
467
|
+
if (parent === current)
|
|
468
|
+
break;
|
|
469
|
+
current = parent;
|
|
470
|
+
}
|
|
471
|
+
return current;
|
|
472
|
+
}
|
|
473
|
+
function assertWritableFile(file, label) {
|
|
474
|
+
try {
|
|
475
|
+
if (fs.existsSync(file)) {
|
|
476
|
+
const stat = fs.lstatSync(file);
|
|
477
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
478
|
+
throw new Error(`${label} path must be a regular file: ${file}`);
|
|
479
|
+
fs.accessSync(file, fs.constants.R_OK | fs.constants.W_OK);
|
|
480
|
+
fs.accessSync(path.dirname(file), fs.constants.W_OK);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
const parent = nearestExistingParent(file);
|
|
484
|
+
if (!fs.statSync(parent).isDirectory())
|
|
485
|
+
throw new Error(`${label} parent is not a directory: ${parent}`);
|
|
486
|
+
fs.accessSync(parent, fs.constants.W_OK);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
throw new Error(`cannot safely write ${label} at ${file}: ${error.message}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function relationshipGroups(report) {
|
|
494
|
+
const effects = report.relationships
|
|
495
|
+
.filter(({ targetId, targetKey }) => targetId === 'global:pi' && targetKey === 'pi')
|
|
496
|
+
.map((relationship) => ({
|
|
497
|
+
scope: 'global',
|
|
498
|
+
targetId: relationship.targetId,
|
|
499
|
+
targetKey: relationship.targetKey,
|
|
500
|
+
resourceId: relationship.resourceId ?? relationship.realPath ?? relationship.target ?? relationship.path,
|
|
501
|
+
name: relationship.name,
|
|
502
|
+
slot: relationship.slot,
|
|
503
|
+
form: relationship.form,
|
|
504
|
+
activation: relationship.activation,
|
|
505
|
+
sourcePath: relationship.realPath ?? relationship.target ?? relationship.path,
|
|
506
|
+
targetPath: relationship.path,
|
|
507
|
+
plannedAction: 'retain',
|
|
508
|
+
sourcePreserved: true,
|
|
509
|
+
}))
|
|
510
|
+
.sort((left, right) => left.targetPath.localeCompare(right.targetPath));
|
|
511
|
+
return effects.length ? [{ scope: 'global', targetId: 'global:pi', targetKey: 'pi', relationships: effects }] : [];
|
|
512
|
+
}
|
|
513
|
+
function assertSafeRelationships(report, groups, settings, ignoredEntry) {
|
|
514
|
+
const piSlots = report.slots.filter(({ targetId }) => targetId === 'global:pi');
|
|
515
|
+
const collision = piSlots.find(({ relationships }) => relationships.length > 1);
|
|
516
|
+
if (collision)
|
|
517
|
+
throw new Error(`Pi Target Slot collision blocks isolation: ${collision.id}`);
|
|
518
|
+
const broken = report.relationships.find(({ targetId, inspectionError }) => targetId === 'global:pi' && inspectionError);
|
|
519
|
+
if (broken)
|
|
520
|
+
throw new Error(`Pi Relationship cannot be inspected: ${broken.path}`);
|
|
521
|
+
const entries = ignoredEntry ? settings.skills.filter((entry) => entry !== ignoredEntry) : settings.skills;
|
|
522
|
+
const baseDir = path.dirname(settings.file);
|
|
523
|
+
const conflict = groups.flatMap(({ relationships }) => relationships)
|
|
524
|
+
.find(({ targetPath }) => !enabledByMatcher(path.join(targetPath, 'SKILL.md'), entries, baseDir));
|
|
525
|
+
if (conflict)
|
|
526
|
+
throw new Error(`Pi matcher conflicts with Pi Target Relationship: ${conflict.targetPath}`);
|
|
527
|
+
}
|
|
528
|
+
function stableGroups(groups) {
|
|
529
|
+
return JSON.stringify(groups);
|
|
530
|
+
}
|
|
531
|
+
function concurrentModification(message) {
|
|
532
|
+
return Object.assign(new Error(message), { code: 'concurrent_modification' });
|
|
533
|
+
}
|
|
534
|
+
function recoveryCommand(label, snapshot, backup, appliedHash) {
|
|
535
|
+
const currentHash = `test "$(shasum -a 256 '${snapshot.file}' | awk '{print $1}')" = "${appliedHash}"`;
|
|
536
|
+
const backupHash = `test "$(shasum -a 256 '${backup}' | awk '{print $1}')" = "${snapshot.hash}"`;
|
|
537
|
+
return snapshot.exists
|
|
538
|
+
? `Restore ${label}: ${currentHash} && ${backupHash} && cp '${backup}' '${snapshot.file}' && test "$(shasum -a 256 '${snapshot.file}' | awk '{print $1}')" = "${snapshot.hash}"`
|
|
539
|
+
: `Restore absent ${label}: ${currentHash} && ${backupHash} && rm '${snapshot.file}' && test ! -e '${snapshot.file}'`;
|
|
540
|
+
}
|
|
541
|
+
function recoveryInstructions(plan) {
|
|
542
|
+
return [
|
|
543
|
+
recoveryCommand('Global Pi settings', plan.settings, plan.settingsBackupFile, plan.updatedSettingsHash),
|
|
544
|
+
recoveryCommand('Global SkillsPub state', plan.state, plan.stateBackupFile, plan.updatedStateHash),
|
|
545
|
+
`Hash-check recovery with the affected-path manifest and SHA-256 values: ${plan.manifestFile}`,
|
|
546
|
+
'Start a fresh Pi process to observe next-load visibility; this operation does not reload a running process.',
|
|
547
|
+
];
|
|
548
|
+
}
|
|
549
|
+
function relationshipImpact(plan) {
|
|
550
|
+
const effects = plan.groups.flatMap(({ relationships }) => relationships);
|
|
551
|
+
const preserved = new Set(effects.map(({ resourceId }) => resourceId));
|
|
552
|
+
let actualIsolation = 'unmanaged';
|
|
553
|
+
if (plan.ownership.status === 'owned')
|
|
554
|
+
actualIsolation = 'managed';
|
|
555
|
+
else if (plan.ownership.status === 'drift')
|
|
556
|
+
actualIsolation = 'drift';
|
|
557
|
+
const desiredIsolation = plan.managedAfter ? 'managed' : 'unmanaged';
|
|
558
|
+
const instructions = recoveryInstructions(plan);
|
|
559
|
+
return {
|
|
560
|
+
summary: {
|
|
561
|
+
affectedRelationships: effects.length,
|
|
562
|
+
unlinkedRelationships: 0,
|
|
563
|
+
retainedRelationships: effects.length,
|
|
564
|
+
preservedSourceResources: preserved.size,
|
|
565
|
+
},
|
|
566
|
+
actual: {
|
|
567
|
+
relationshipCount: effects.length,
|
|
568
|
+
isolation: actualIsolation,
|
|
569
|
+
},
|
|
570
|
+
desired: { relationshipCount: effects.length, isolation: desiredIsolation },
|
|
571
|
+
drift: { relationships: [], isolation: plan.change || plan.stateChange },
|
|
572
|
+
groups: plan.groups,
|
|
573
|
+
configuration: {
|
|
574
|
+
path: plan.settings.file,
|
|
575
|
+
plannedAction: plan.change ? 'write' : 'retain',
|
|
576
|
+
originalHash: plan.settings.hash,
|
|
577
|
+
backupPath: plan.settingsBackupFile,
|
|
578
|
+
},
|
|
579
|
+
ownershipState: {
|
|
580
|
+
path: plan.state.file,
|
|
581
|
+
status: plan.ownership.status,
|
|
582
|
+
plannedAction: plan.stateChange ? 'write' : 'retain',
|
|
583
|
+
originalHash: plan.state.hash,
|
|
584
|
+
backupPath: plan.stateBackupFile,
|
|
585
|
+
},
|
|
586
|
+
expectedTruth: {
|
|
587
|
+
sharedConsumption: 'excluded',
|
|
588
|
+
targetRelationships: 'retained',
|
|
589
|
+
effectiveVisibility: 'unknown',
|
|
590
|
+
},
|
|
591
|
+
recovery: { manifestPath: plan.manifestFile, instructions },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function buildPlan(home, targets, operation) {
|
|
595
|
+
const piTarget = resolvePiTarget(targets);
|
|
596
|
+
const sharedTarget = resolveSharedTarget(targets);
|
|
597
|
+
const file = settingsFile(piTarget);
|
|
598
|
+
const globalStateFile = stateFile(home);
|
|
599
|
+
const exclusion = canonicalExclusion(sharedTarget);
|
|
600
|
+
const settings = readSettings(file);
|
|
601
|
+
const state = readSnapshot(globalStateFile, 'Global SkillsPub state');
|
|
602
|
+
const stateValue = parseRecord(state, 'Global SkillsPub state');
|
|
603
|
+
let currentOwnership = ownership(stateValue.piIsolation, file, sharedTarget.discoveryRoot, exclusion);
|
|
604
|
+
if (currentOwnership.status === 'drift' && currentOwnership.claim)
|
|
605
|
+
throw new Error('Global SkillsPub Pi ownership conflicts with the resolved Global settings or Shared path');
|
|
606
|
+
if (currentOwnership.legacyExclusion &&
|
|
607
|
+
!['!skills/**', exclusion].includes(currentOwnership.legacyExclusion))
|
|
608
|
+
throw new Error('Global SkillsPub Pi ownership contains an unsupported legacy exclusion');
|
|
609
|
+
const removeLegacy = currentOwnership.legacyExclusion === '!skills/**'
|
|
610
|
+
? currentOwnership.legacyExclusion
|
|
611
|
+
: undefined;
|
|
612
|
+
const matcherEntries = removeLegacy
|
|
613
|
+
? settings.skills.filter((entry) => entry !== removeLegacy)
|
|
614
|
+
: settings.skills;
|
|
615
|
+
const matcher = inspectMatcher(matcherEntries, file, piTarget.discoveryRoot, sharedTarget.discoveryRoot);
|
|
616
|
+
if (matcher.piTargetConflict)
|
|
617
|
+
throw new Error(`Pi matcher conflicts with the Global Pi Target: ${matcher.piTargetConflict}`);
|
|
618
|
+
if (matcher.equivalentExclusion && !matcher.sharedExcluded)
|
|
619
|
+
throw new Error('Pi force-include conflicts with Global Shared isolation');
|
|
620
|
+
if (currentOwnership.status === 'owned' &&
|
|
621
|
+
(!settings.skills.includes(exclusion) || currentOwnership.claim?.settingsHash !== settings.hash)) {
|
|
622
|
+
currentOwnership = { ...currentOwnership, status: 'drift' };
|
|
623
|
+
}
|
|
624
|
+
if (operation === 'setup' && currentOwnership.status === 'drift')
|
|
625
|
+
throw new Error('Pi Shared isolation has drift; use explicit reconcile.');
|
|
626
|
+
const releaseClaim = currentOwnership.status !== 'unowned' && matcher.sharedExcluded &&
|
|
627
|
+
!matcherEntries.includes(exclusion) && !removeLegacy;
|
|
628
|
+
const change = Boolean(removeLegacy) || !matcher.sharedExcluded;
|
|
629
|
+
const updatedSkills = matcher.sharedExcluded
|
|
630
|
+
? matcherEntries
|
|
631
|
+
: [...matcherEntries, exclusion];
|
|
632
|
+
const updatedSettings = change
|
|
633
|
+
? `${JSON.stringify({ ...settings.value, skills: updatedSkills }, null, 2)}\n`
|
|
634
|
+
: settings.raw ?? '';
|
|
635
|
+
const updatedSettingsHash = hash(updatedSettings);
|
|
636
|
+
const id = `${Date.now()}-${crypto.randomUUID()}`;
|
|
637
|
+
const recoveryDir = path.join(home.configDir, 'pi-recovery');
|
|
638
|
+
const settingsBackupFile = path.join(recoveryDir, `${id}.settings.json`);
|
|
639
|
+
const stateBackupFile = path.join(recoveryDir, `${id}.state.json`);
|
|
640
|
+
const manifestFile = path.join(recoveryDir, `${id}.paths.json`);
|
|
641
|
+
const claimValue = {
|
|
642
|
+
version: 1,
|
|
643
|
+
scope: 'global',
|
|
644
|
+
file,
|
|
645
|
+
sharedRoot: path.resolve(sharedTarget.discoveryRoot),
|
|
646
|
+
exclusion,
|
|
647
|
+
settingsHash: updatedSettingsHash,
|
|
648
|
+
};
|
|
649
|
+
const managedAfter = !releaseClaim && (currentOwnership.status !== 'unowned' || !matcher.sharedExcluded);
|
|
650
|
+
const claim = managedAfter && (currentOwnership.status !== 'owned' || change ||
|
|
651
|
+
currentOwnership.claim?.settingsHash !== updatedSettingsHash);
|
|
652
|
+
const stateChange = claim || releaseClaim;
|
|
653
|
+
const nextState = { ...stateValue };
|
|
654
|
+
if (releaseClaim)
|
|
655
|
+
delete nextState.piIsolation;
|
|
656
|
+
else if (claim)
|
|
657
|
+
nextState.piIsolation = claimValue;
|
|
658
|
+
const updatedState = `${JSON.stringify(nextState, null, 2)}\n`;
|
|
659
|
+
const updatedStateHash = hash(updatedState);
|
|
660
|
+
const report = scanGlobalInventory(home, targets, { persist: false });
|
|
661
|
+
const groups = relationshipGroups(report);
|
|
662
|
+
assertSafeRelationships(report, groups, settings, removeLegacy);
|
|
663
|
+
const draft = {
|
|
664
|
+
settings,
|
|
665
|
+
state,
|
|
666
|
+
piRoot: path.resolve(piTarget.discoveryRoot),
|
|
667
|
+
sharedRoot: path.resolve(sharedTarget.discoveryRoot),
|
|
668
|
+
exclusion,
|
|
669
|
+
ownership: currentOwnership,
|
|
670
|
+
change,
|
|
671
|
+
claim,
|
|
672
|
+
stateChange,
|
|
673
|
+
managedAfter,
|
|
674
|
+
updatedSettings,
|
|
675
|
+
updatedSettingsHash,
|
|
676
|
+
updatedState,
|
|
677
|
+
updatedStateHash,
|
|
678
|
+
settingsBackupFile,
|
|
679
|
+
stateBackupFile,
|
|
680
|
+
manifestFile,
|
|
681
|
+
groups,
|
|
682
|
+
};
|
|
683
|
+
return { ...draft, relationshipImpact: relationshipImpact(draft) };
|
|
684
|
+
}
|
|
685
|
+
function preflightApply(home, targets, plan) {
|
|
686
|
+
const currentSettings = readSettings(plan.settings.file);
|
|
687
|
+
if (currentSettings.exists !== plan.settings.exists || currentSettings.hash !== plan.settings.hash ||
|
|
688
|
+
currentSettings.raw !== plan.settings.raw)
|
|
689
|
+
throw concurrentModification(`Pi settings changed after preview: ${plan.settings.file}`);
|
|
690
|
+
const currentState = readSnapshot(plan.state.file, 'Global SkillsPub state');
|
|
691
|
+
if (currentState.exists !== plan.state.exists || currentState.hash !== plan.state.hash || currentState.raw !== plan.state.raw)
|
|
692
|
+
throw concurrentModification(`Global SkillsPub state changed after preview: ${plan.state.file}`);
|
|
693
|
+
parseRecord(currentState, 'Global SkillsPub state');
|
|
694
|
+
const matcher = inspectMatcher(currentSettings.skills, currentSettings.file, plan.piRoot, plan.sharedRoot);
|
|
695
|
+
const ignored = plan.ownership.legacyExclusion === '!skills/**' ? plan.ownership.legacyExclusion : undefined;
|
|
696
|
+
if (matcher.piTargetConflict && !ignored)
|
|
697
|
+
throw concurrentModification(`Pi matcher now conflicts with the Global Pi Target: ${matcher.piTargetConflict}`);
|
|
698
|
+
const report = scanGlobalInventory(home, targets, { persist: false });
|
|
699
|
+
const groups = relationshipGroups(report);
|
|
700
|
+
try {
|
|
701
|
+
assertSafeRelationships(report, groups, currentSettings, ignored);
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
throw concurrentModification(error.message);
|
|
705
|
+
}
|
|
706
|
+
if (stableGroups(groups) !== stableGroups(plan.groups))
|
|
707
|
+
throw concurrentModification('Pi Relationships changed after preview');
|
|
708
|
+
if (plan.change || plan.stateChange) {
|
|
709
|
+
assertWritableFile(plan.settings.file, 'Global Pi settings');
|
|
710
|
+
assertWritableFile(plan.state.file, 'Global SkillsPub state');
|
|
711
|
+
assertWritableFile(plan.manifestFile, 'Pi recovery manifest');
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function writeRecovery(plan) {
|
|
715
|
+
fs.mkdirSync(path.dirname(plan.manifestFile), { recursive: true });
|
|
716
|
+
fs.writeFileSync(plan.settingsBackupFile, plan.settings.raw ?? '', { flag: 'wx', mode: 0o600 });
|
|
717
|
+
fs.writeFileSync(plan.stateBackupFile, plan.state.raw ?? '', { flag: 'wx', mode: 0o600 });
|
|
718
|
+
fs.writeFileSync(plan.manifestFile, `${JSON.stringify({
|
|
719
|
+
version: 1,
|
|
720
|
+
scope: 'global',
|
|
721
|
+
settings: {
|
|
722
|
+
path: plan.settings.file,
|
|
723
|
+
existed: plan.settings.exists,
|
|
724
|
+
originalHash: plan.settings.hash,
|
|
725
|
+
expectedAppliedHash: plan.updatedSettingsHash,
|
|
726
|
+
backupPath: plan.settingsBackupFile,
|
|
727
|
+
backupHash: plan.settings.hash,
|
|
728
|
+
},
|
|
729
|
+
state: {
|
|
730
|
+
path: plan.state.file,
|
|
731
|
+
existed: plan.state.exists,
|
|
732
|
+
originalHash: plan.state.hash,
|
|
733
|
+
expectedAppliedHash: plan.updatedStateHash,
|
|
734
|
+
backupPath: plan.stateBackupFile,
|
|
735
|
+
backupHash: plan.state.hash,
|
|
736
|
+
},
|
|
737
|
+
resolvedRoots: { pi: plan.piRoot, shared: plan.sharedRoot },
|
|
738
|
+
exactExclusion: plan.exclusion,
|
|
739
|
+
relationships: plan.groups,
|
|
740
|
+
}, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
741
|
+
}
|
|
742
|
+
function applyPlan(home, targets, plan) {
|
|
743
|
+
preflightApply(home, targets, plan);
|
|
744
|
+
if (!plan.change && !plan.stateChange)
|
|
745
|
+
return;
|
|
746
|
+
writeRecovery(plan);
|
|
747
|
+
let mutationStarted = false;
|
|
748
|
+
try {
|
|
749
|
+
if (plan.stateChange) {
|
|
750
|
+
atomicWrite(plan.state.file, plan.updatedState);
|
|
751
|
+
mutationStarted = true;
|
|
752
|
+
}
|
|
753
|
+
if (plan.change) {
|
|
754
|
+
atomicWrite(plan.settings.file, plan.updatedSettings);
|
|
755
|
+
mutationStarted = true;
|
|
756
|
+
}
|
|
757
|
+
const verified = readSettings(plan.settings.file);
|
|
758
|
+
const matcher = inspectMatcher(verified.skills, verified.file, plan.piRoot, plan.sharedRoot);
|
|
759
|
+
if (!matcher.sharedExcluded || matcher.piTargetConflict)
|
|
760
|
+
throw new Error(`Pi isolation was written but semantic verification failed: ${plan.settings.file}`);
|
|
761
|
+
const report = scanGlobalInventory(home, targets, { persist: false });
|
|
762
|
+
const groups = relationshipGroups(report);
|
|
763
|
+
if (stableGroups(groups) !== stableGroups(plan.groups))
|
|
764
|
+
throw new Error('Pi isolation was written but Relationships changed during verification');
|
|
765
|
+
const currentState = readSnapshot(plan.state.file, 'Global SkillsPub state');
|
|
766
|
+
if (currentState.hash !== plan.updatedStateHash || currentState.raw !== plan.updatedState)
|
|
767
|
+
throw concurrentModification(`Global SkillsPub state changed during apply: ${plan.state.file}`);
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
if (mutationStarted && error && typeof error === 'object')
|
|
771
|
+
Object.assign(error, { partialEffects: 'present' });
|
|
772
|
+
throw error;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function renderRelationshipImpact(plan) {
|
|
776
|
+
const impact = plan.relationshipImpact;
|
|
777
|
+
return [
|
|
778
|
+
'scope\tGlobal only; exact-Project Pi settings and state are not read or changed',
|
|
779
|
+
`resolved Pi root\t${plan.piRoot}`,
|
|
780
|
+
`resolved Shared root\t${plan.sharedRoot}`,
|
|
781
|
+
`exact exclusion\t${plan.exclusion}`,
|
|
782
|
+
`settings SHA-256\t${plan.settings.hash}`,
|
|
783
|
+
`Global ownership state SHA-256\t${plan.state.hash}`,
|
|
784
|
+
`ownership\t${plan.ownership.status}`,
|
|
785
|
+
`expected truth\tShared excluded; ${impact.summary.retainedRelationships} Pi Target Relationships retained on next load`,
|
|
786
|
+
`Actual\t${impact.actual.relationshipCount} Relationships; isolation ${impact.actual.isolation}`,
|
|
787
|
+
`Desired\t${impact.desired.relationshipCount} Relationships; isolation ${impact.desired.isolation}`,
|
|
788
|
+
`Drift\t${impact.drift.relationships.length} Relationship actions; isolation ${impact.drift.isolation ? 'yes' : 'no'}`,
|
|
789
|
+
`settings backup\t${plan.settingsBackupFile}`,
|
|
790
|
+
`state backup\t${plan.stateBackupFile}`,
|
|
791
|
+
`affected-path manifest\t${plan.manifestFile}`,
|
|
792
|
+
...impact.groups.flatMap((group) => [
|
|
793
|
+
`scope ${group.scope}\tSkill Target ${group.targetKey} (${group.targetId})`,
|
|
794
|
+
...group.relationships.map((effect) => `Retain\tresource=${effect.resourceId}\tname=${effect.name}\tform=${effect.form}\t` +
|
|
795
|
+
`Activation=${effect.activation}\tsource=${effect.sourcePath}\ttarget=${effect.targetPath}\taction=retain`),
|
|
796
|
+
]),
|
|
797
|
+
'Effective Visibility\tunknown until resource-specific explain; no running process reload is claimed',
|
|
798
|
+
];
|
|
799
|
+
}
|
|
800
|
+
function operationResult(home, targets, plan, inspection) {
|
|
801
|
+
const groups = relationshipGroups(scanGlobalInventory(home, targets, { persist: false }));
|
|
802
|
+
const present = new Set(groups.flatMap(({ relationships }) => relationships)
|
|
803
|
+
.map(({ targetId, targetPath }) => `${targetId}\0${targetPath}`));
|
|
804
|
+
const effects = plan.groups.flatMap(({ relationships }) => relationships);
|
|
805
|
+
const relationshipEffects = effects.map((effect) => ({
|
|
806
|
+
...effect,
|
|
807
|
+
outcome: present.has(`${effect.targetId}\0${effect.targetPath}`) ? 'retained' : 'drift',
|
|
808
|
+
}));
|
|
809
|
+
const sources = new Set(effects.map(({ sourcePath }) => sourcePath));
|
|
810
|
+
const actual = {
|
|
811
|
+
unlinkedRelationships: 0,
|
|
812
|
+
retainedRelationships: relationshipEffects.filter(({ outcome }) => outcome === 'retained').length,
|
|
813
|
+
preservedSourceResources: [...sources].filter((sourcePath) => fs.existsSync(sourcePath)).length,
|
|
814
|
+
};
|
|
815
|
+
const desired = {
|
|
816
|
+
unlinkedRelationships: 0,
|
|
817
|
+
retainedRelationships: effects.length,
|
|
818
|
+
preservedSourceResources: sources.size,
|
|
819
|
+
};
|
|
820
|
+
const driftRelationships = relationshipEffects
|
|
821
|
+
.filter(({ outcome }) => outcome === 'drift')
|
|
822
|
+
.map(({ outcome: _outcome, ...effect }) => effect);
|
|
823
|
+
const visibility = driftRelationships.length
|
|
824
|
+
? { status: 'conflicted', detail: 'A planned Pi Target Relationship is missing after apply.' }
|
|
825
|
+
: {
|
|
826
|
+
status: 'unknown',
|
|
827
|
+
detail: 'Use resource-specific explain for Effective Visibility; running Pi processes were not reloaded.',
|
|
828
|
+
};
|
|
829
|
+
return {
|
|
830
|
+
inspection,
|
|
831
|
+
actual,
|
|
832
|
+
desired,
|
|
833
|
+
drift: {
|
|
834
|
+
relationships: driftRelationships,
|
|
835
|
+
isolation: inspection.sharedConsumption.status !== 'excluded',
|
|
836
|
+
},
|
|
837
|
+
isolation: inspection.isolation,
|
|
838
|
+
relationshipEffects,
|
|
839
|
+
recovery: {
|
|
840
|
+
...plan.relationshipImpact.recovery,
|
|
841
|
+
configBackupPreserved: !plan.change || fs.existsSync(plan.settingsBackupFile),
|
|
842
|
+
stateBackupPreserved: !plan.stateChange || fs.existsSync(plan.stateBackupFile),
|
|
843
|
+
manifestPreserved: (!plan.change && !plan.stateChange) || fs.existsSync(plan.manifestFile),
|
|
844
|
+
},
|
|
845
|
+
sharedConsumption: inspection.sharedConsumption,
|
|
846
|
+
effectiveVisibility: visibility,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function projectRelationshipGroups(report, project) {
|
|
850
|
+
const targetId = `project:${project}:pi`;
|
|
851
|
+
const relationships = report.relationships.filter((entry) => entry.targetId === targetId).map((entry) => ({
|
|
852
|
+
scope: 'project', targetId, targetKey: 'pi',
|
|
853
|
+
resourceId: entry.resourceId ?? entry.realPath ?? entry.target ?? entry.path,
|
|
854
|
+
name: entry.name, slot: entry.slot, form: entry.form, activation: entry.activation,
|
|
855
|
+
sourcePath: entry.realPath ?? entry.target ?? entry.path, targetPath: entry.path,
|
|
856
|
+
plannedAction: 'retain', sourcePreserved: true,
|
|
857
|
+
})).sort((left, right) => left.targetPath.localeCompare(right.targetPath));
|
|
858
|
+
return relationships.length ? [{ scope: 'project', targetId, targetKey: 'pi', relationships }] : [];
|
|
859
|
+
}
|
|
860
|
+
function projectOwnership(value, file, project, roots, exclusions) {
|
|
861
|
+
if (value === undefined)
|
|
862
|
+
return { status: 'unowned' };
|
|
863
|
+
if (!isPiClaim(value))
|
|
864
|
+
throw new Error('Project SkillsPub state has unknown Pi isolation ownership');
|
|
865
|
+
const exact = value.scope === 'project' && value.file === file && value.projectPath === project &&
|
|
866
|
+
JSON.stringify(value.sharedRoots?.map((root) => path.resolve(root))) === JSON.stringify(roots.map((root) => path.resolve(root))) &&
|
|
867
|
+
JSON.stringify(value.exclusions) === JSON.stringify(exclusions);
|
|
868
|
+
return exact ? { status: 'owned', claim: value } : { status: 'drift', claim: value };
|
|
869
|
+
}
|
|
870
|
+
function buildProjectPlan(home, targets, operation, selectedPath) {
|
|
871
|
+
const project = canonicalProjectPath(selectedPath);
|
|
872
|
+
const pi = resolvePiTarget(targets);
|
|
873
|
+
if (pi.projectPath !== '.pi/skills')
|
|
874
|
+
throw new Error('Stale Pi Project Target override blocks isolation; approve the separate Pi migrate operation first.');
|
|
875
|
+
const trust = readProjectTrust(pi, project);
|
|
876
|
+
if (trust.trusted !== true)
|
|
877
|
+
throw new Error(`Pi Project isolation requires confirmed trust: ${trust.detail}`);
|
|
878
|
+
const settings = readSettings(path.join(project, '.pi', 'settings.json'));
|
|
879
|
+
const state = readSnapshot(path.join(project, '.skillspub', 'state.json'), 'Project SkillsPub state');
|
|
880
|
+
const stateValue = parseRecord(state, 'Project SkillsPub state');
|
|
881
|
+
const piRoot = path.join(project, '.pi', 'skills');
|
|
882
|
+
const sharedRoots = projectSharedRoots(project, resolveSharedTarget(targets).discoveryRoot);
|
|
883
|
+
const exclusions = sharedRoots.map(canonicalExclusionRoot);
|
|
884
|
+
let currentOwnership = projectOwnership(stateValue.piIsolation, settings.file, project, sharedRoots, exclusions);
|
|
885
|
+
const inspections = sharedRoots.map((root) => inspectMatcher(settings.skills, settings.file, piRoot, root));
|
|
886
|
+
const canonicalConflict = projectCollision(settings, piRoot, sharedRoots);
|
|
887
|
+
if (canonicalConflict)
|
|
888
|
+
throw new Error(`${canonicalConflict}; isolation is blocked until the competing Variant is resolved.`);
|
|
889
|
+
const conflict = inspections.find(({ piTargetConflict }) => piTargetConflict)?.piTargetConflict;
|
|
890
|
+
if (conflict)
|
|
891
|
+
throw new Error(`Pi matcher conflicts with the Project Pi Target: ${conflict}`);
|
|
892
|
+
if (inspections.some(({ equivalentExclusion, sharedExcluded }) => equivalentExclusion && !sharedExcluded))
|
|
893
|
+
throw new Error('Pi force-include conflicts with Project Shared isolation');
|
|
894
|
+
if (currentOwnership.status === 'owned' && (currentOwnership.claim?.settingsHash !== settings.hash ||
|
|
895
|
+
exclusions.some((entry) => !settings.skills.includes(entry))))
|
|
896
|
+
currentOwnership = { ...currentOwnership, status: 'drift' };
|
|
897
|
+
if (operation === 'setup' && currentOwnership.status === 'drift')
|
|
898
|
+
throw new Error('Pi Project isolation has drift; use explicit reconcile.');
|
|
899
|
+
const missing = exclusions.filter((_entry, index) => !inspections[index]?.sharedExcluded);
|
|
900
|
+
const change = missing.length > 0;
|
|
901
|
+
const updatedSettings = change
|
|
902
|
+
? `${JSON.stringify({ ...settings.value, skills: [...settings.skills, ...missing] }, null, 2)}\n`
|
|
903
|
+
: settings.raw ?? '';
|
|
904
|
+
const updatedSettingsHash = hash(updatedSettings);
|
|
905
|
+
const equivalentUnowned = currentOwnership.status === 'unowned' && !change;
|
|
906
|
+
const stateChange = !equivalentUnowned && (currentOwnership.status !== 'owned' || currentOwnership.claim?.settingsHash !== updatedSettingsHash);
|
|
907
|
+
const nextState = { ...stateValue };
|
|
908
|
+
if (equivalentUnowned)
|
|
909
|
+
delete nextState.piIsolation;
|
|
910
|
+
else
|
|
911
|
+
nextState.piIsolation = {
|
|
912
|
+
version: 1, scope: 'project', file: settings.file, projectPath: project,
|
|
913
|
+
sharedRoots, exclusions, settingsHash: updatedSettingsHash,
|
|
914
|
+
};
|
|
915
|
+
const updatedState = `${JSON.stringify(nextState, null, 2)}\n`;
|
|
916
|
+
const updatedStateHash = hash(updatedState);
|
|
917
|
+
const report = scanProjectInventory(home, project, targets, { persist: false });
|
|
918
|
+
const groups = projectRelationshipGroups(report, project);
|
|
919
|
+
const targetId = `project:${project}:pi`;
|
|
920
|
+
const collision = report.slots.find((slot) => slot.targetId === targetId && slot.relationships.length > 1);
|
|
921
|
+
if (collision)
|
|
922
|
+
throw new Error(`Pi Target Slot collision blocks isolation: ${collision.id}`);
|
|
923
|
+
const broken = report.relationships.find((entry) => entry.targetId === targetId && entry.inspectionError);
|
|
924
|
+
if (broken)
|
|
925
|
+
throw new Error(`Pi Relationship cannot be inspected: ${broken.path}`);
|
|
926
|
+
const matcherConflict = groups.flatMap((group) => group.relationships)
|
|
927
|
+
.find((entry) => !enabledByMatcher(path.join(entry.targetPath, 'SKILL.md'), settings.skills, path.dirname(settings.file)));
|
|
928
|
+
if (matcherConflict)
|
|
929
|
+
throw new Error(`Pi matcher conflicts with Pi Target Relationship: ${matcherConflict.targetPath}`);
|
|
930
|
+
const id = `${Date.now()}-${crypto.randomUUID()}`;
|
|
931
|
+
const recoveryDir = path.join(project, '.skillspub', 'pi-recovery');
|
|
932
|
+
const settingsBackupFile = path.join(recoveryDir, `${id}.settings.json`);
|
|
933
|
+
const stateBackupFile = path.join(recoveryDir, `${id}.state.json`);
|
|
934
|
+
const manifestFile = path.join(recoveryDir, `${id}.paths.json`);
|
|
935
|
+
const effects = groups.flatMap((group) => group.relationships);
|
|
936
|
+
const recovery = [
|
|
937
|
+
recoveryCommand('Project Pi settings', settings, settingsBackupFile, updatedSettingsHash),
|
|
938
|
+
recoveryCommand('Project SkillsPub state', state, stateBackupFile, updatedStateHash),
|
|
939
|
+
`Hash-check recovery with the affected-path manifest and SHA-256 values: ${manifestFile}`,
|
|
940
|
+
];
|
|
941
|
+
let actualIsolation = 'unmanaged';
|
|
942
|
+
if (currentOwnership.status === 'owned')
|
|
943
|
+
actualIsolation = 'managed';
|
|
944
|
+
else if (currentOwnership.status === 'drift')
|
|
945
|
+
actualIsolation = 'drift';
|
|
946
|
+
const relationshipImpact = {
|
|
947
|
+
summary: { affectedRelationships: effects.length, unlinkedRelationships: 0, retainedRelationships: effects.length, preservedSourceResources: new Set(effects.map((entry) => entry.resourceId)).size },
|
|
948
|
+
actual: { relationshipCount: effects.length, isolation: actualIsolation },
|
|
949
|
+
desired: { relationshipCount: effects.length, isolation: equivalentUnowned ? 'unmanaged' : 'managed' },
|
|
950
|
+
drift: { relationships: [], isolation: change || stateChange }, groups,
|
|
951
|
+
configuration: { path: settings.file, plannedAction: change ? 'write' : 'retain', originalHash: settings.hash, backupPath: settingsBackupFile },
|
|
952
|
+
ownershipState: { path: state.file, status: currentOwnership.status, plannedAction: stateChange ? 'write' : 'retain', originalHash: state.hash, backupPath: stateBackupFile },
|
|
953
|
+
expectedTruth: { sharedConsumption: 'excluded', targetRelationships: 'retained', effectiveVisibility: 'unknown' },
|
|
954
|
+
recovery: { manifestPath: manifestFile, instructions: recovery },
|
|
955
|
+
};
|
|
956
|
+
return { project, settings, state, piRoot, sharedRoots, exclusions, ownership: currentOwnership,
|
|
957
|
+
change, stateChange, updatedSettings, updatedSettingsHash, updatedState, updatedStateHash,
|
|
958
|
+
settingsBackupFile, stateBackupFile, manifestFile, groups, relationshipImpact };
|
|
959
|
+
}
|
|
960
|
+
function applyProjectPlan(home, targets, plan) {
|
|
961
|
+
const trust = readProjectTrust(resolvePiTarget(targets), plan.project);
|
|
962
|
+
if (trust.trusted !== true)
|
|
963
|
+
throw concurrentModification(`Pi Project trust changed after preview: ${trust.detail}`);
|
|
964
|
+
const currentRoots = projectSharedRoots(plan.project, resolveSharedTarget(targets).discoveryRoot);
|
|
965
|
+
if (JSON.stringify(currentRoots) !== JSON.stringify(plan.sharedRoots))
|
|
966
|
+
throw concurrentModification('Pi Project Git/filesystem boundary changed after preview');
|
|
967
|
+
const settings = readSettings(plan.settings.file);
|
|
968
|
+
const state = readSnapshot(plan.state.file, 'Project SkillsPub state');
|
|
969
|
+
if (settings.hash !== plan.settings.hash || settings.raw !== plan.settings.raw)
|
|
970
|
+
throw concurrentModification(`Pi settings changed after preview: ${plan.settings.file}`);
|
|
971
|
+
if (state.hash !== plan.state.hash || state.raw !== plan.state.raw)
|
|
972
|
+
throw concurrentModification(`Project SkillsPub state changed after preview: ${plan.state.file}`);
|
|
973
|
+
const groups = projectRelationshipGroups(scanProjectInventory(home, plan.project, targets, { persist: false }), plan.project);
|
|
974
|
+
if (stableGroups(groups) !== stableGroups(plan.groups))
|
|
975
|
+
throw concurrentModification('Pi Relationships changed after preview');
|
|
976
|
+
const collision = projectCollision(settings, plan.piRoot, plan.sharedRoots);
|
|
977
|
+
if (collision)
|
|
978
|
+
throw concurrentModification(`${collision}; Project Skill candidates changed after preview`);
|
|
979
|
+
if (!plan.change && !plan.stateChange)
|
|
980
|
+
return;
|
|
981
|
+
assertWritableFile(plan.settings.file, 'Project Pi settings');
|
|
982
|
+
assertWritableFile(plan.state.file, 'Project SkillsPub state');
|
|
983
|
+
assertWritableFile(plan.manifestFile, 'Pi recovery manifest');
|
|
984
|
+
fs.mkdirSync(path.dirname(plan.manifestFile), { recursive: true });
|
|
985
|
+
fs.writeFileSync(plan.settingsBackupFile, plan.settings.raw ?? '', { flag: 'wx', mode: 0o600 });
|
|
986
|
+
fs.writeFileSync(plan.stateBackupFile, plan.state.raw ?? '', { flag: 'wx', mode: 0o600 });
|
|
987
|
+
if (readSnapshot(plan.settingsBackupFile, 'Project Pi settings backup').hash !== plan.settings.hash ||
|
|
988
|
+
readSnapshot(plan.stateBackupFile, 'Project SkillsPub state backup').hash !== plan.state.hash)
|
|
989
|
+
throw new Error('Pi Project isolation backup verification failed');
|
|
990
|
+
fs.writeFileSync(plan.manifestFile, `${JSON.stringify({ version: 1, scope: 'project', projectPath: plan.project,
|
|
991
|
+
settings: { path: plan.settings.file, existed: plan.settings.exists, originalHash: plan.settings.hash, expectedAppliedHash: plan.updatedSettingsHash, backupPath: plan.settingsBackupFile },
|
|
992
|
+
state: { path: plan.state.file, existed: plan.state.exists, originalHash: plan.state.hash, expectedAppliedHash: plan.updatedStateHash, backupPath: plan.stateBackupFile },
|
|
993
|
+
resolvedRoots: { pi: plan.piRoot, shared: plan.sharedRoots }, exactExclusions: plan.exclusions, relationships: plan.groups }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
994
|
+
let mutationStarted = false;
|
|
995
|
+
try {
|
|
996
|
+
if (plan.stateChange) {
|
|
997
|
+
atomicWrite(plan.state.file, plan.updatedState);
|
|
998
|
+
mutationStarted = true;
|
|
999
|
+
}
|
|
1000
|
+
if (plan.change) {
|
|
1001
|
+
atomicWrite(plan.settings.file, plan.updatedSettings);
|
|
1002
|
+
mutationStarted = true;
|
|
1003
|
+
}
|
|
1004
|
+
const verified = readSettings(plan.settings.file);
|
|
1005
|
+
if (plan.sharedRoots.some((root) => { const result = inspectMatcher(verified.skills, verified.file, plan.piRoot, root); return !result.sharedExcluded || result.piTargetConflict; }))
|
|
1006
|
+
throw new Error(`Pi Project isolation semantic verification failed: ${plan.settings.file}`);
|
|
1007
|
+
if (stableGroups(projectRelationshipGroups(scanProjectInventory(home, plan.project, targets, { persist: false }), plan.project)) !== stableGroups(plan.groups))
|
|
1008
|
+
throw new Error('Pi Project Relationships changed during verification');
|
|
1009
|
+
const verifiedState = readSnapshot(plan.state.file, 'Project SkillsPub state');
|
|
1010
|
+
if (verifiedState.hash !== plan.updatedStateHash || verifiedState.raw !== plan.updatedState)
|
|
1011
|
+
throw concurrentModification(`Project SkillsPub state changed during apply: ${plan.state.file}`);
|
|
1012
|
+
}
|
|
1013
|
+
catch (error) {
|
|
1014
|
+
if (mutationStarted && error && typeof error === 'object')
|
|
1015
|
+
Object.assign(error, { partialEffects: 'present' });
|
|
1016
|
+
throw error;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function projectOperation(home, targets, operation, selectedPath) {
|
|
1020
|
+
const plan = buildProjectPlan(home, targets, operation, selectedPath);
|
|
1021
|
+
return {
|
|
1022
|
+
title: 'Pi exact-Project isolation plan:',
|
|
1023
|
+
lines: [
|
|
1024
|
+
`scope\tProject only: ${plan.project}; Global Pi settings, state, and Relationships remain read-only`,
|
|
1025
|
+
`resolved Pi root\t${plan.piRoot}`,
|
|
1026
|
+
...plan.sharedRoots.map((root, index) => `resolved ${index ? 'ancestor ' : ''}Shared root\t${root}\texclusion=${plan.exclusions[index]}`),
|
|
1027
|
+
`${plan.change ? 'write' : 'retain'}\t${plan.settings.file}`,
|
|
1028
|
+
`ownership\t${plan.ownership.status}`,
|
|
1029
|
+
`settings SHA-256\t${plan.settings.hash}`,
|
|
1030
|
+
`Project state SHA-256\t${plan.state.hash}`,
|
|
1031
|
+
`settings backup\t${plan.settingsBackupFile}`,
|
|
1032
|
+
`state backup\t${plan.stateBackupFile}`,
|
|
1033
|
+
`affected-path manifest\t${plan.manifestFile}`,
|
|
1034
|
+
`expected truth\tall applicable Project Shared roots excluded; ${plan.groups.flatMap((g) => g.relationships).length} canonical Project Pi Relationships retained`,
|
|
1035
|
+
],
|
|
1036
|
+
recovery: plan.relationshipImpact.recovery.instructions,
|
|
1037
|
+
relationshipImpact: plan.relationshipImpact,
|
|
1038
|
+
apply: () => applyProjectPlan(home, targets, plan),
|
|
1039
|
+
verify() {
|
|
1040
|
+
const inspection = piAdapter.inspect(home, targets, plan.project);
|
|
1041
|
+
const projectRoots = inspection.roots.filter((root) => root.kind === 'shared' && root.scope !== 'global');
|
|
1042
|
+
const projectPi = inspection.roots.find((root) => root.kind === 'harness' && root.scope === 'project');
|
|
1043
|
+
if (!projectRoots.length || projectRoots.some((root) => root.consumption !== 'excluded') || projectPi?.consumption !== 'consumed')
|
|
1044
|
+
throw new Error(`Pi Project isolation verification failed: ${inspection.sharedConsumption.detail}`);
|
|
1045
|
+
return inspection;
|
|
1046
|
+
},
|
|
1047
|
+
result: (inspection) => {
|
|
1048
|
+
const effects = plan.groups.flatMap((group) => group.relationships);
|
|
1049
|
+
return {
|
|
1050
|
+
inspection,
|
|
1051
|
+
actual: { unlinkedRelationships: 0, retainedRelationships: effects.length, preservedSourceResources: new Set(effects.map((entry) => entry.resourceId)).size },
|
|
1052
|
+
desired: { unlinkedRelationships: 0, retainedRelationships: effects.length, preservedSourceResources: new Set(effects.map((entry) => entry.resourceId)).size },
|
|
1053
|
+
drift: { relationships: [], isolation: false }, isolation: inspection.isolation,
|
|
1054
|
+
relationshipEffects: effects.map((entry) => ({ ...entry, outcome: 'retained' })),
|
|
1055
|
+
recovery: { ...plan.relationshipImpact.recovery, configBackupPreserved: !plan.change || fs.existsSync(plan.settingsBackupFile), stateBackupPreserved: !plan.stateChange || fs.existsSync(plan.stateBackupFile), manifestPreserved: (!plan.change && !plan.stateChange) || fs.existsSync(plan.manifestFile) },
|
|
1056
|
+
sharedConsumption: inspection.sharedConsumption,
|
|
1057
|
+
effectiveVisibility: { status: 'unknown', detail: 'Use resource-specific explain; no running Pi process was reloaded.' },
|
|
1058
|
+
};
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function buildMigrationPlan(home, targets, selectedPath) {
|
|
1063
|
+
const project = canonicalProjectPath(selectedPath);
|
|
1064
|
+
const pi = resolvePiTarget(targets);
|
|
1065
|
+
if (pi.projectPath !== '.pi/agent/skills')
|
|
1066
|
+
throw new Error('Pi Project Target is already canonical; no migration is required.');
|
|
1067
|
+
const registry = readSnapshot(path.join(home.configDir, 'targets.json'), 'Target registry');
|
|
1068
|
+
const value = parseRecord(registry, 'Target registry');
|
|
1069
|
+
if (value.version !== 1 || !Array.isArray(value.overrides) || !Array.isArray(value.genericTargets))
|
|
1070
|
+
throw new Error(`invalid Target registry: ${registry.file}`);
|
|
1071
|
+
const overrides = value.overrides.map((entry) => {
|
|
1072
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
1073
|
+
throw new Error(`invalid Target Definition override in ${registry.file}`);
|
|
1074
|
+
return { ...entry };
|
|
1075
|
+
});
|
|
1076
|
+
const piOverride = overrides.find((entry) => entry.key === 'pi');
|
|
1077
|
+
if (!piOverride || piOverride.projectPath !== '.pi/agent/skills')
|
|
1078
|
+
throw new Error('Resolved stale Pi Project Target has no matching registry override; migration is blocked.');
|
|
1079
|
+
const nextPi = { ...piOverride };
|
|
1080
|
+
delete nextPi.projectPath;
|
|
1081
|
+
const updatedOverrides = overrides.flatMap((entry) => {
|
|
1082
|
+
if (entry !== piOverride)
|
|
1083
|
+
return [entry];
|
|
1084
|
+
return Object.keys(nextPi).length === 1 ? [] : [nextPi];
|
|
1085
|
+
});
|
|
1086
|
+
const updatedRegistry = `${JSON.stringify({ ...value, overrides: updatedOverrides }, null, 2)}\n`;
|
|
1087
|
+
const source = path.join(project, '.pi', 'agent', 'skills');
|
|
1088
|
+
const destination = path.join(project, '.pi', 'skills');
|
|
1089
|
+
const sourceExists = fs.existsSync(source);
|
|
1090
|
+
if (sourceExists) {
|
|
1091
|
+
const stat = fs.lstatSync(source);
|
|
1092
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
1093
|
+
throw new Error(`Stale Pi Project Target must be a directory: ${source}`);
|
|
1094
|
+
if (fs.existsSync(destination))
|
|
1095
|
+
throw new Error(`Canonical Pi Project Target destination conflict: ${destination}`);
|
|
1096
|
+
}
|
|
1097
|
+
const state = readSnapshot(path.join(project, '.skillspub', 'state.json'), 'Project SkillsPub state');
|
|
1098
|
+
const stateValue = parseRecord(state, 'Project SkillsPub state');
|
|
1099
|
+
let ownershipStatus = 'unowned';
|
|
1100
|
+
if (stateValue.piIsolation !== undefined) {
|
|
1101
|
+
if (!isPiClaim(stateValue.piIsolation) || stateValue.piIsolation.scope !== 'project' ||
|
|
1102
|
+
stateValue.piIsolation.projectPath !== project)
|
|
1103
|
+
throw new Error('Project SkillsPub state has conflicting Pi isolation ownership');
|
|
1104
|
+
ownershipStatus = 'owned';
|
|
1105
|
+
}
|
|
1106
|
+
const groups = projectRelationshipGroups(scanProjectInventory(home, project, targets, { persist: false }), project);
|
|
1107
|
+
const id = `${Date.now()}-${crypto.randomUUID()}`;
|
|
1108
|
+
const recoveryDir = path.join(project, '.skillspub', 'pi-recovery');
|
|
1109
|
+
const canonicalTargets = targets.map((target) => target.key === 'pi' ? { ...target, projectPath: '.pi/skills' } : target);
|
|
1110
|
+
return {
|
|
1111
|
+
project, registry, state, source, destination, sourceExists,
|
|
1112
|
+
sourceHash: sourceExists ? hashDirectory(source) : hash(undefined),
|
|
1113
|
+
updatedRegistry, updatedRegistryHash: hash(updatedRegistry),
|
|
1114
|
+
registryBackup: path.join(recoveryDir, `${id}.targets.json`),
|
|
1115
|
+
stateBackup: path.join(recoveryDir, `${id}.state.json`),
|
|
1116
|
+
contentBackup: path.join(recoveryDir, `${id}.skills`),
|
|
1117
|
+
manifestFile: path.join(recoveryDir, `${id}.migration.json`),
|
|
1118
|
+
recoveryFile: path.join(recoveryDir, `${id}.recover.mjs`),
|
|
1119
|
+
ownershipStatus,
|
|
1120
|
+
groups, canonicalTargets,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
function migrationRecoveryScript(plan, manifestHash) {
|
|
1124
|
+
const evidence = JSON.stringify({
|
|
1125
|
+
registry: plan.registry.file,
|
|
1126
|
+
registryBackup: plan.registryBackup,
|
|
1127
|
+
registryOriginalHash: plan.registry.hash,
|
|
1128
|
+
registryAppliedHash: plan.updatedRegistryHash,
|
|
1129
|
+
state: plan.state.file,
|
|
1130
|
+
stateBackup: plan.stateBackup,
|
|
1131
|
+
stateHash: plan.state.hash,
|
|
1132
|
+
stateExisted: plan.state.exists,
|
|
1133
|
+
source: plan.source,
|
|
1134
|
+
destination: plan.destination,
|
|
1135
|
+
contentBackup: plan.contentBackup,
|
|
1136
|
+
sourceExisted: plan.sourceExists,
|
|
1137
|
+
sourceHash: plan.sourceHash,
|
|
1138
|
+
manifest: plan.manifestFile,
|
|
1139
|
+
manifestHash,
|
|
1140
|
+
});
|
|
1141
|
+
return `import crypto from 'node:crypto';\nimport fs from 'node:fs';\nimport path from 'node:path';\n` +
|
|
1142
|
+
`const evidence = ${evidence};\n` +
|
|
1143
|
+
`const sha = (file) => crypto.createHash('sha256').update(fs.existsSync(file) ? fs.readFileSync(file) : '').digest('hex');\n` +
|
|
1144
|
+
`const directoryHash = (root) => { const digest = crypto.createHash('sha256'); const update = (value) => { const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value); const length = Buffer.allocUnsafe(8); length.writeBigUInt64BE(BigInt(bytes.length)); digest.update(length); digest.update(bytes); }; const visit = (dir, prefix) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { if (entry.name === '.git' || entry.name === 'node_modules') continue; const relative = path.join(prefix, entry.name); const file = path.join(dir, entry.name); const kind = entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'link' : 'file'; update(kind); update(relative); if (entry.isDirectory()) visit(file, relative); else if (entry.isSymbolicLink()) update(fs.readlinkSync(file)); else if (entry.isFile()) update(fs.readFileSync(file)); } }; visit(root, ''); return digest.digest('hex'); };\n` +
|
|
1145
|
+
`const check = (condition, message) => { if (!condition) throw new Error(message); };\n` +
|
|
1146
|
+
`check(sha(evidence.manifest) === evidence.manifestHash, 'Migration manifest hash mismatch');\n` +
|
|
1147
|
+
`check(sha(evidence.registry) === evidence.registryAppliedHash, 'Target registry changed since migration');\n` +
|
|
1148
|
+
`check(sha(evidence.registryBackup) === evidence.registryOriginalHash, 'Target registry backup hash mismatch');\n` +
|
|
1149
|
+
`check(fs.existsSync(evidence.state) === evidence.stateExisted && sha(evidence.state) === evidence.stateHash, 'Project state changed since migration');\n` +
|
|
1150
|
+
`check(sha(evidence.stateBackup) === evidence.stateHash, 'Project state backup hash mismatch');\n` +
|
|
1151
|
+
`if (evidence.sourceExisted) { check(!fs.existsSync(evidence.source), 'Stale Target source already exists'); check(fs.existsSync(evidence.contentBackup) && directoryHash(evidence.contentBackup) === evidence.sourceHash, 'Target content backup hash mismatch'); if (fs.existsSync(evidence.destination)) { const currentHash = directoryHash(evidence.destination); if (currentHash === evidence.sourceHash) fs.rmSync(evidence.destination, { recursive: true }); else { const conflict = evidence.destination + '.recovery-conflict'; check(!fs.existsSync(conflict), 'Recovery conflict path already exists'); fs.renameSync(evidence.destination, conflict); } } fs.mkdirSync(path.dirname(evidence.source), { recursive: true }); fs.cpSync(evidence.contentBackup, evidence.source, { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true }); check(directoryHash(evidence.source) === evidence.sourceHash, 'Restored Target content hash mismatch'); }\n` +
|
|
1152
|
+
`const temporary = evidence.registry + '.recovery-' + process.pid; fs.copyFileSync(evidence.registryBackup, temporary); fs.renameSync(temporary, evidence.registry);\n` +
|
|
1153
|
+
`check(sha(evidence.registry) === evidence.registryOriginalHash, 'Restored registry hash mismatch'); check(sha(evidence.state) === evidence.stateHash, 'Restored Project state hash mismatch'); console.log('Pi Project Target recovery verified');\n`;
|
|
1154
|
+
}
|
|
1155
|
+
function applyMigrationPlan(home, targets, plan) {
|
|
1156
|
+
const registry = readSnapshot(plan.registry.file, 'Target registry');
|
|
1157
|
+
const state = readSnapshot(plan.state.file, 'Project SkillsPub state');
|
|
1158
|
+
if (registry.hash !== plan.registry.hash || registry.raw !== plan.registry.raw)
|
|
1159
|
+
throw concurrentModification(`Target registry changed after preview: ${plan.registry.file}`);
|
|
1160
|
+
if (state.hash !== plan.state.hash || state.raw !== plan.state.raw)
|
|
1161
|
+
throw concurrentModification(`Project SkillsPub state changed after preview: ${plan.state.file}`);
|
|
1162
|
+
if (fs.existsSync(plan.source) !== plan.sourceExists || (plan.sourceExists && hashDirectory(plan.source) !== plan.sourceHash))
|
|
1163
|
+
throw concurrentModification(`Stale Pi Project Target changed after preview: ${plan.source}`);
|
|
1164
|
+
if (plan.sourceExists && fs.existsSync(plan.destination))
|
|
1165
|
+
throw concurrentModification(`Canonical Pi Project Target destination appeared after preview: ${plan.destination}`);
|
|
1166
|
+
const groups = projectRelationshipGroups(scanProjectInventory(home, plan.project, targets, { persist: false }), plan.project);
|
|
1167
|
+
if (stableGroups(groups) !== stableGroups(plan.groups))
|
|
1168
|
+
throw concurrentModification('Pi Project Relationships changed after migration preview');
|
|
1169
|
+
assertWritableFile(plan.registry.file, 'Target registry');
|
|
1170
|
+
assertWritableFile(plan.manifestFile, 'Pi migration manifest');
|
|
1171
|
+
fs.mkdirSync(path.dirname(plan.manifestFile), { recursive: true });
|
|
1172
|
+
fs.writeFileSync(plan.registryBackup, plan.registry.raw ?? '', { flag: 'wx', mode: 0o600 });
|
|
1173
|
+
fs.writeFileSync(plan.stateBackup, plan.state.raw ?? '', { flag: 'wx', mode: 0o600 });
|
|
1174
|
+
if (plan.sourceExists)
|
|
1175
|
+
fs.cpSync(plan.source, plan.contentBackup, {
|
|
1176
|
+
recursive: true,
|
|
1177
|
+
errorOnExist: true,
|
|
1178
|
+
force: false,
|
|
1179
|
+
preserveTimestamps: true,
|
|
1180
|
+
verbatimSymlinks: true,
|
|
1181
|
+
});
|
|
1182
|
+
if (readSnapshot(plan.registryBackup, 'Target registry backup').hash !== plan.registry.hash ||
|
|
1183
|
+
readSnapshot(plan.stateBackup, 'Project SkillsPub state backup').hash !== plan.state.hash ||
|
|
1184
|
+
(plan.sourceExists && hashDirectory(plan.contentBackup) !== plan.sourceHash))
|
|
1185
|
+
throw new Error('Pi Target migration backup verification failed');
|
|
1186
|
+
const manifestRaw = `${JSON.stringify({ version: 1, scope: 'project', projectPath: plan.project,
|
|
1187
|
+
registry: { path: plan.registry.file, originalHash: plan.registry.hash, expectedAppliedHash: plan.updatedRegistryHash, backupPath: plan.registryBackup },
|
|
1188
|
+
state: { path: plan.state.file, hash: plan.state.hash, backupPath: plan.stateBackup },
|
|
1189
|
+
content: { source: plan.source, destination: plan.destination, existed: plan.sourceExists, hash: plan.sourceHash, backupPath: plan.contentBackup },
|
|
1190
|
+
recoveryScript: { path: plan.recoveryFile },
|
|
1191
|
+
relationships: plan.groups, expectedTruth: { projectTarget: plan.destination, relationships: 'retained', activationAndDesiredState: 'preserved' } }, null, 2)}\n`;
|
|
1192
|
+
fs.writeFileSync(plan.manifestFile, manifestRaw, { flag: 'wx', mode: 0o600 });
|
|
1193
|
+
fs.writeFileSync(plan.recoveryFile, migrationRecoveryScript(plan, hash(manifestRaw)), { flag: 'wx', mode: 0o700 });
|
|
1194
|
+
let mutationStarted = false;
|
|
1195
|
+
try {
|
|
1196
|
+
if (plan.sourceExists) {
|
|
1197
|
+
fs.mkdirSync(path.dirname(plan.destination), { recursive: true });
|
|
1198
|
+
fs.renameSync(plan.source, plan.destination);
|
|
1199
|
+
mutationStarted = true;
|
|
1200
|
+
}
|
|
1201
|
+
atomicWrite(plan.registry.file, plan.updatedRegistry);
|
|
1202
|
+
mutationStarted = true;
|
|
1203
|
+
if (readSnapshot(plan.registry.file, 'Target registry').hash !== plan.updatedRegistryHash)
|
|
1204
|
+
throw new Error('Pi Target migration registry verification failed');
|
|
1205
|
+
if (plan.sourceExists && (!fs.existsSync(plan.destination) || hashDirectory(plan.destination) !== plan.sourceHash || fs.existsSync(plan.source)))
|
|
1206
|
+
throw new Error('Pi Target migration content verification failed');
|
|
1207
|
+
if (readSnapshot(plan.state.file, 'Project SkillsPub state').hash !== plan.state.hash)
|
|
1208
|
+
throw new Error('Pi Target migration changed Project SkillsPub state');
|
|
1209
|
+
const after = projectRelationshipGroups(scanProjectInventory(home, plan.project, plan.canonicalTargets, { persist: false }), plan.project);
|
|
1210
|
+
const relationshipIdentity = (entry) => `${entry.slot}\0${entry.form === 'local' ? '<moved-local>' : entry.resourceId}\0${entry.form}\0${entry.activation}`;
|
|
1211
|
+
const beforeResources = plan.groups.flatMap((group) => group.relationships).map(relationshipIdentity).sort();
|
|
1212
|
+
const afterResources = after.flatMap((group) => group.relationships).map(relationshipIdentity).sort();
|
|
1213
|
+
if (JSON.stringify(beforeResources) !== JSON.stringify(afterResources))
|
|
1214
|
+
throw new Error('Pi Target migration did not preserve Relationships');
|
|
1215
|
+
}
|
|
1216
|
+
catch (error) {
|
|
1217
|
+
if (mutationStarted && error && typeof error === 'object')
|
|
1218
|
+
Object.assign(error, { partialEffects: 'present' });
|
|
1219
|
+
throw error;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
function migrationOperation(home, targets, selectedPath) {
|
|
1223
|
+
const plan = buildMigrationPlan(home, targets, selectedPath);
|
|
1224
|
+
const effects = plan.groups.flatMap((group) => group.relationships);
|
|
1225
|
+
const recovery = [
|
|
1226
|
+
`Run hash-checked recovery: node '${plan.recoveryFile}'`,
|
|
1227
|
+
`Verify original registry SHA-256 ${plan.registry.hash}, Project state SHA-256 ${plan.state.hash}, and content SHA-256 ${plan.sourceHash} using ${plan.manifestFile}`,
|
|
1228
|
+
];
|
|
1229
|
+
const impact = {
|
|
1230
|
+
summary: { affectedRelationships: effects.length, unlinkedRelationships: 0, retainedRelationships: effects.length, preservedSourceResources: new Set(effects.map((entry) => entry.resourceId)).size },
|
|
1231
|
+
actual: { relationshipCount: effects.length, isolation: 'drift' },
|
|
1232
|
+
desired: { relationshipCount: effects.length, isolation: 'unmanaged' },
|
|
1233
|
+
drift: { relationships: [], isolation: false }, groups: plan.groups,
|
|
1234
|
+
configuration: { path: plan.registry.file, plannedAction: 'write', originalHash: plan.registry.hash, backupPath: plan.registryBackup },
|
|
1235
|
+
ownershipState: { path: plan.state.file, status: plan.ownershipStatus, plannedAction: 'retain', originalHash: plan.state.hash, backupPath: plan.stateBackup },
|
|
1236
|
+
expectedTruth: { sharedConsumption: 'unknown', targetRelationships: 'retained', effectiveVisibility: 'unknown' },
|
|
1237
|
+
recovery: { manifestPath: plan.manifestFile, instructions: recovery },
|
|
1238
|
+
};
|
|
1239
|
+
return {
|
|
1240
|
+
title: 'Pi exact-Project Target migration plan:',
|
|
1241
|
+
lines: [
|
|
1242
|
+
`scope\tProject migration only: ${plan.project}; Global Pi settings and Global Pi Target remain unchanged`,
|
|
1243
|
+
`source\t${plan.source}`,
|
|
1244
|
+
`destination\t${plan.destination}`,
|
|
1245
|
+
`source content SHA-256\t${plan.sourceHash}`,
|
|
1246
|
+
`Target registry SHA-256\t${plan.registry.hash}`,
|
|
1247
|
+
`Project state SHA-256\t${plan.state.hash}`,
|
|
1248
|
+
`ownership\t${plan.ownershipStatus}; retained byte-for-byte`,
|
|
1249
|
+
`content backup\t${plan.contentBackup}`,
|
|
1250
|
+
`registry backup\t${plan.registryBackup}`,
|
|
1251
|
+
`state backup\t${plan.stateBackup}`,
|
|
1252
|
+
`executable recovery\t${plan.recoveryFile}`,
|
|
1253
|
+
`affected-path manifest\t${plan.manifestFile}`,
|
|
1254
|
+
`expected truth\tcanonical .pi/skills; ${effects.length} Relationships, Activation, Desired state, ownership, and resources preserved`,
|
|
1255
|
+
], recovery, relationshipImpact: impact,
|
|
1256
|
+
apply: () => applyMigrationPlan(home, targets, plan),
|
|
1257
|
+
verify: () => piAdapter.inspect(home, plan.canonicalTargets, plan.project),
|
|
1258
|
+
result: (inspection) => ({
|
|
1259
|
+
inspection,
|
|
1260
|
+
actual: { unlinkedRelationships: 0, retainedRelationships: effects.length, preservedSourceResources: new Set(effects.map((entry) => entry.resourceId)).size },
|
|
1261
|
+
desired: { unlinkedRelationships: 0, retainedRelationships: effects.length, preservedSourceResources: new Set(effects.map((entry) => entry.resourceId)).size },
|
|
1262
|
+
drift: { relationships: [], isolation: inspection.isolation.status === 'drift' }, isolation: inspection.isolation,
|
|
1263
|
+
relationshipEffects: effects.map((entry) => ({ ...entry, outcome: 'retained' })),
|
|
1264
|
+
recovery: { ...impact.recovery, configBackupPreserved: fs.existsSync(plan.registryBackup), stateBackupPreserved: fs.existsSync(plan.stateBackup), manifestPreserved: fs.existsSync(plan.manifestFile) },
|
|
1265
|
+
sharedConsumption: inspection.sharedConsumption,
|
|
1266
|
+
effectiveVisibility: { status: 'unknown', detail: 'Migration preserves filesystem truth; resource-specific explain determines next-load visibility.' },
|
|
1267
|
+
}),
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
function planOperation(home, targets, operation, projectPath) {
|
|
1271
|
+
if (projectPath)
|
|
1272
|
+
return operation === 'migrate'
|
|
1273
|
+
? migrationOperation(home, targets, canonicalProjectPath(projectPath))
|
|
1274
|
+
: projectOperation(home, targets, operation, canonicalProjectPath(projectPath));
|
|
1275
|
+
if (operation === 'migrate')
|
|
1276
|
+
throw new Error('Pi Target migration is exact-Project only.');
|
|
1277
|
+
const plan = buildPlan(home, targets, operation);
|
|
1278
|
+
return {
|
|
1279
|
+
title: 'Pi Global isolation plan:',
|
|
1280
|
+
lines: [
|
|
1281
|
+
`${plan.change ? 'write' : 'retain'}\t${plan.settings.file}`,
|
|
1282
|
+
`${plan.change ? 'add exclusion (root-specific)' : 'already satisfied'}\tstop consuming Shared without suppressing Pi Targets`,
|
|
1283
|
+
...renderRelationshipImpact(plan),
|
|
1284
|
+
],
|
|
1285
|
+
recovery: plan.relationshipImpact.recovery.instructions,
|
|
1286
|
+
relationshipImpact: plan.relationshipImpact,
|
|
1287
|
+
apply: () => applyPlan(home, targets, plan),
|
|
1288
|
+
verify() {
|
|
1289
|
+
const inspection = piAdapter.inspect(home, targets);
|
|
1290
|
+
const globalShared = inspection.roots.find(({ kind, scope }) => kind === 'shared' && scope === 'global');
|
|
1291
|
+
const globalPi = inspection.roots.find(({ kind, scope }) => kind === 'harness' && scope === 'global');
|
|
1292
|
+
if (globalShared?.consumption !== 'excluded' || globalPi?.consumption !== 'consumed')
|
|
1293
|
+
throw new Error(`Pi Global isolation verification failed: ${inspection.sharedConsumption.detail}`);
|
|
1294
|
+
return inspection;
|
|
1295
|
+
},
|
|
1296
|
+
result: (inspection) => operationResult(home, targets, plan, inspection),
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
export const piAdapter = {
|
|
1300
|
+
key: 'pi',
|
|
1301
|
+
name: 'Pi',
|
|
1302
|
+
targetDefinition() {
|
|
1303
|
+
const home = os.homedir();
|
|
1304
|
+
return {
|
|
1305
|
+
key: 'pi',
|
|
1306
|
+
kind: 'harness',
|
|
1307
|
+
discoveryRoot: path.join(home, '.pi', 'agent', 'skills'),
|
|
1308
|
+
parkingRoot: path.join(home, '.pi', 'agent', '.skillspub-off', 'skills'),
|
|
1309
|
+
projectPath: '.pi/skills',
|
|
1310
|
+
relationship: { support: 'managed', link: 'supported' },
|
|
1311
|
+
};
|
|
1312
|
+
},
|
|
1313
|
+
inspect(home, targets, projectPath) {
|
|
1314
|
+
const piTarget = resolvePiTarget(targets);
|
|
1315
|
+
const sharedTarget = resolveSharedTarget(targets);
|
|
1316
|
+
const projectRoot = projectPath ? canonicalProjectPath(projectPath) : undefined;
|
|
1317
|
+
const file = settingsFile(piTarget);
|
|
1318
|
+
const exclusion = canonicalExclusion(sharedTarget);
|
|
1319
|
+
const shared = inspectShared(piTarget, sharedTarget, projectRoot);
|
|
1320
|
+
const settings = (() => {
|
|
1321
|
+
try {
|
|
1322
|
+
return readSettings(file);
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
return { file, exists: fs.existsSync(file), hash: '', value: {}, skills: [] };
|
|
1326
|
+
}
|
|
1327
|
+
})();
|
|
1328
|
+
const ownershipResult = inspectOwnership(home, file, sharedTarget.discoveryRoot, exclusion);
|
|
1329
|
+
const configurationKnown = !shared.global.detail;
|
|
1330
|
+
const globalPiKnown = configurationKnown && !shared.global.piTargetConflict;
|
|
1331
|
+
const projectResult = shared.project[0];
|
|
1332
|
+
const projectPiKnown = shared.trust?.trusted === true && projectResult && !projectResult.detail && !projectResult.piTargetConflict;
|
|
1333
|
+
const detected = fs.existsSync(piTarget.discoveryRoot) || fs.existsSync(file) ||
|
|
1334
|
+
fs.existsSync(piHome(piTarget)) || Boolean(projectRoot && fs.existsSync(path.join(projectRoot, '.pi')));
|
|
1335
|
+
return {
|
|
1336
|
+
key: 'pi',
|
|
1337
|
+
name: 'Pi',
|
|
1338
|
+
detected,
|
|
1339
|
+
support: 'managed',
|
|
1340
|
+
evidence: EVIDENCE,
|
|
1341
|
+
targets: [
|
|
1342
|
+
{ scope: 'global', discoveryRoot: path.resolve(piTarget.discoveryRoot) },
|
|
1343
|
+
...(projectRoot ? [{ scope: 'project', discoveryRoot: path.join(projectRoot, '.pi', 'skills') }] : []),
|
|
1344
|
+
],
|
|
1345
|
+
roots: [
|
|
1346
|
+
{
|
|
1347
|
+
kind: 'harness',
|
|
1348
|
+
targetKey: 'pi',
|
|
1349
|
+
scope: 'global',
|
|
1350
|
+
discoveryRoot: path.resolve(piTarget.discoveryRoot),
|
|
1351
|
+
consumption: globalPiKnown ? 'consumed' : 'unknown',
|
|
1352
|
+
reason: shared.global.detail ?? (shared.global.piTargetConflict
|
|
1353
|
+
? `Pi matcher may suppress its Global Target: ${shared.global.piTargetConflict}`
|
|
1354
|
+
: 'Pi discovers its Global Skill Target independently before canonical dedupe.'),
|
|
1355
|
+
},
|
|
1356
|
+
...(projectRoot ? [{
|
|
1357
|
+
kind: 'harness',
|
|
1358
|
+
targetKey: 'pi',
|
|
1359
|
+
scope: 'project',
|
|
1360
|
+
discoveryRoot: path.join(projectRoot, '.pi', 'skills'),
|
|
1361
|
+
consumption: projectPiKnown ? 'consumed' : 'unknown',
|
|
1362
|
+
reason: projectResult?.detail ?? (projectResult?.piTargetConflict
|
|
1363
|
+
? `Pi matcher may suppress its exact Project Target: ${projectResult.piTargetConflict}`
|
|
1364
|
+
: 'Pi discovers the canonical exact Project Skill Target after trust.'),
|
|
1365
|
+
}] : []),
|
|
1366
|
+
...shared.roots,
|
|
1367
|
+
],
|
|
1368
|
+
sharedConsumption: shared.summary,
|
|
1369
|
+
isolation: projectRoot
|
|
1370
|
+
? inspectProjectIsolation(piTarget, sharedTarget, projectRoot)
|
|
1371
|
+
: isolation(ownershipResult, exclusion, settings, shared.global.excluded),
|
|
1372
|
+
link: { supported: true },
|
|
1373
|
+
};
|
|
1374
|
+
},
|
|
1375
|
+
operations: {
|
|
1376
|
+
setup: (home, targets, projectPath) => planOperation(home, targets, 'setup', projectPath),
|
|
1377
|
+
reconcile: (home, targets, projectPath) => planOperation(home, targets, 'reconcile', projectPath),
|
|
1378
|
+
migrate: (home, targets, projectPath) => planOperation(home, targets, 'migrate', projectPath),
|
|
1379
|
+
},
|
|
1380
|
+
};
|