pi-codex-marketplace 0.1.9 → 0.1.10
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/extensions/pi/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { stripTerminalSequences } from '@earendil-works/pi-tui';
|
|
|
16
16
|
|
|
17
17
|
import { readBridgeStateSync } from '../../src/bridge-state/store.js';
|
|
18
18
|
import type { ReadResult } from '../../src/bridge-state/types.js';
|
|
19
|
+
import { discoverProjectedSkillPaths } from '../../src/projection/exposure.js';
|
|
19
20
|
import { runStartupReconciliation } from '../../src/reconciliation/startup.js';
|
|
20
21
|
import type { AttemptReceipt } from '../../src/registration/receipt.js';
|
|
21
22
|
import { checkGlobalPendingBarrier } from '../../src/barrier/global-barrier.js';
|
|
@@ -307,6 +308,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
307
308
|
}
|
|
308
309
|
});
|
|
309
310
|
|
|
311
|
+
// Runtime Skill Exposure (ADR 0001): contribute Projected Skills through Pi's
|
|
312
|
+
// resource-discovery seam at every startup and reload. Passive existence inspection over the
|
|
313
|
+
// current Effective State only — no fingerprint validation, no Bridge State mutation, and no
|
|
314
|
+
// Attempt Receipt. Missing snapshot material is skipped individually; discovery never fails
|
|
315
|
+
// the host's resource pass.
|
|
316
|
+
pi.on('resources_discover', async (_event, ctx) => {
|
|
317
|
+
try {
|
|
318
|
+
return {
|
|
319
|
+
skillPaths: discoverProjectedSkillPaths({
|
|
320
|
+
cwd: ctx.cwd,
|
|
321
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
322
|
+
}).skillPaths,
|
|
323
|
+
};
|
|
324
|
+
} catch {
|
|
325
|
+
return {};
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
310
329
|
pi.registerCommand('codex-marketplace', {
|
|
311
330
|
description: uiText('cmd.description'),
|
|
312
331
|
handler: async (args, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-marketplace",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Bridge Package for Codex Marketplace compatibility in Pi — Global/Project Bridge State, atomic persistence, and /codex-marketplace TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -594,6 +594,21 @@ export function classifyPlugin(root: string, opts: ClassificationOptions): Class
|
|
|
594
594
|
const agentProfile = loadAgentProfile(root, skillDirectory, entry.name, opts, capture);
|
|
595
595
|
findings.push(...agentProfile.findings);
|
|
596
596
|
const descriptorPolicy = descriptorInvocationPolicy(descriptor.frontmatter?.['disable-model-invocation']);
|
|
597
|
+
// Advisory (COMP-W02): an Agent Profile may declare explicit-only Invocation Policy that the
|
|
598
|
+
// Pi runtime cannot honour because the Skill Descriptor lacks `disable-model-invocation`.
|
|
599
|
+
// This is a non-blocking Validation Warning; the Effective policy computation below and the
|
|
600
|
+
// whole-Plugin classification remain unchanged.
|
|
601
|
+
if (agentProfile.invocationPolicy === 'explicit' && !Object.hasOwn(descriptor.frontmatter!, 'disable-model-invocation')) {
|
|
602
|
+
findings.push(warning({
|
|
603
|
+
code: CODE.UNENFORCEABLE_INVOCATION_POLICY,
|
|
604
|
+
rule: 'COMP-W02',
|
|
605
|
+
target: 'skill',
|
|
606
|
+
pointer: `skills/${entry.name}/agents/openai.yaml#/policy/allow_implicit_invocation`,
|
|
607
|
+
outcome: 'Skill Agent Profile declares explicit-only Invocation Policy that remains unenforceable because the Skill Descriptor does not declare disable-model-invocation; declare disable-model-invocation: true in the Skill Descriptor frontmatter to make it enforceable',
|
|
608
|
+
scope: opts.scope,
|
|
609
|
+
phase: 'validation',
|
|
610
|
+
}));
|
|
611
|
+
}
|
|
597
612
|
if (descriptorPolicy && agentProfile.invocationPolicy && descriptorPolicy !== agentProfile.invocationPolicy) {
|
|
598
613
|
findings.push(finding(
|
|
599
614
|
opts,
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Skill Exposure — read-time discovery of Projected Skill directories contributed to Pi
|
|
3
|
+
* through the host resource-discovery seam (`resources_discover` → `skillPaths`).
|
|
4
|
+
* See CONTEXT.md: Runtime Skill Exposure, Projected Skill, Projected Plugin, Effective State,
|
|
5
|
+
* Runtime Skill Collision, Source Cache, Skill Availability.
|
|
6
|
+
*
|
|
7
|
+
* Exposure derives entirely from the current Effective State (enabled Installations minus Scope
|
|
8
|
+
* Overrides and Project Trust exclusions) and its collision survivors. It performs passive
|
|
9
|
+
* existence inspection only: no fingerprint recomputation, no Bridge State mutation, and no
|
|
10
|
+
* Attempt Receipt — snapshot-bound validation stays bound to Lifecycle Operations and Runtime
|
|
11
|
+
* Applications. Missing cache entries or unreadable snapshot material are skipped individually;
|
|
12
|
+
* discovery always completes. Exposure never establishes Skill Availability.
|
|
13
|
+
*
|
|
14
|
+
* Path resolution follows the retained Validation Snapshot locations:
|
|
15
|
+
* - Git Registrations resolve inside the Source Cache entry pinned by their recorded base-tree
|
|
16
|
+
* fingerprint (the same addressing Marketplace Refresh and inspection reuse; both the
|
|
17
|
+
* Registration's and Installation's snapshots are state-pinned against eviction).
|
|
18
|
+
* - Local Registrations resolve inside their live Marketplace Root at its canonical real path,
|
|
19
|
+
* mirroring read-time inspection; drift remains a Lifecycle Operation concern, not discovery's.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
23
|
+
import { basename, join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { parseFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
26
|
+
|
|
27
|
+
import { getCacheEntriesDir, getCacheDir } from '../cache/paths.js';
|
|
28
|
+
import { readBridgeStateSync } from '../bridge-state/store.js';
|
|
29
|
+
import type { BridgeState } from '../bridge-state/types.js';
|
|
30
|
+
import { parseCatalog, type Catalog } from '../registration/catalog.js';
|
|
31
|
+
import { BUDGET } from '../registration/budget.js';
|
|
32
|
+
import { resolveContained } from '../registration/contained.js';
|
|
33
|
+
import {
|
|
34
|
+
computeEffectiveState,
|
|
35
|
+
type EffectiveInstallation,
|
|
36
|
+
type EffectiveState,
|
|
37
|
+
} from './effective-state.js';
|
|
38
|
+
import { resolveRuntimeSkillCollisions, type SkillCandidate } from './collision.js';
|
|
39
|
+
|
|
40
|
+
export interface RuntimeSkillExposureOptions {
|
|
41
|
+
/** Working directory identifying the project scope document. */
|
|
42
|
+
cwd?: string;
|
|
43
|
+
/** Pi agent dir; defaults to getAgentDir(). */
|
|
44
|
+
agentDir?: string;
|
|
45
|
+
/** Pi host-owned Project Trust. Defaults to false: project additions stay excluded. */
|
|
46
|
+
projectTrusted?: boolean;
|
|
47
|
+
/** Exact names already claimed by pre-existing Pi skills (host-owned namespace layer). */
|
|
48
|
+
piSkillNames?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One Projected Skill contributed at its original snapshot location (no copy). */
|
|
52
|
+
export interface ExposedSkill {
|
|
53
|
+
name: string;
|
|
54
|
+
skillId: string;
|
|
55
|
+
pluginId: string;
|
|
56
|
+
installationId: string;
|
|
57
|
+
sourceScope: 'global' | 'project';
|
|
58
|
+
/** Absolute skill directory containing SKILL.md. */
|
|
59
|
+
skillDir: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SkippedInstallationReason =
|
|
63
|
+
| 'missing-snapshot'
|
|
64
|
+
| 'missing-cache-entry'
|
|
65
|
+
| 'catalog-unreadable'
|
|
66
|
+
| 'entry-not-found'
|
|
67
|
+
| 'no-skills';
|
|
68
|
+
|
|
69
|
+
export interface SkippedInstallation {
|
|
70
|
+
installationId: string;
|
|
71
|
+
reason: SkippedInstallationReason;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ExposureResult {
|
|
75
|
+
/** Deterministically ordered skill directories for `resources_discover` → `skillPaths`. */
|
|
76
|
+
skillPaths: string[];
|
|
77
|
+
exposed: ExposedSkill[];
|
|
78
|
+
skipped: SkippedInstallation[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeFingerprint(fp: string | undefined): fp is string {
|
|
82
|
+
return typeof fp === 'string' && /^[0-9a-f]{64}$/.test(fp);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emptyScopeState(): BridgeState {
|
|
86
|
+
return { schemaVersion: 1, stateRevision: '0', registrations: [], installations: [], scopeOverrides: [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Passive closed read: corrupted or incompatible documents contribute nothing and never throw. */
|
|
90
|
+
function readScopeOrEmpty(scope: 'global' | 'project', opts: RuntimeSkillExposureOptions): BridgeState {
|
|
91
|
+
const read = readBridgeStateSync(scope, { cwd: opts.cwd, agentDir: opts.agentDir });
|
|
92
|
+
return read.status === 'ok' || read.status === 'missing' ? read.state! : emptyScopeState();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Locate one Installation's plugin directory inside a snapshot root by resolving its
|
|
97
|
+
* Marketplace Entry ID through the retained catalog, then verify containment.
|
|
98
|
+
*/
|
|
99
|
+
function resolvePluginDirInSnapshot(snapshotRoot: string, installation: EffectiveInstallation, scope: 'global' | 'project'): string | undefined {
|
|
100
|
+
const catalogPath = join(snapshotRoot, '.agents', 'plugins', 'marketplace.json');
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(catalogPath) || statSync(catalogPath).size > BUDGET.maxCatalogBytes) return undefined;
|
|
103
|
+
const parsed: unknown = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
|
104
|
+
const result = parseCatalog(parsed, { scope });
|
|
105
|
+
if (!result.catalog) return undefined;
|
|
106
|
+
return resolveEntryPluginDir(snapshotRoot, result.catalog, installation);
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveEntryPluginDir(snapshotRoot: string, catalog: Catalog, installation: EffectiveInstallation): string | undefined {
|
|
113
|
+
// A malformed Marketplace Entry ID without the "/plugins/" marker yields an undefined pointer
|
|
114
|
+
// so resolution falls back to manifestName instead of degrading to a bogus tail slice.
|
|
115
|
+
const markerIndex = installation.marketplaceEntryId?.indexOf('/plugins/') ?? -1;
|
|
116
|
+
const pointer = installation.marketplaceEntryId && markerIndex >= 0
|
|
117
|
+
? installation.marketplaceEntryId.slice(markerIndex)
|
|
118
|
+
: undefined;
|
|
119
|
+
let entry = pointer !== undefined
|
|
120
|
+
? catalog.entries.find((item) => item.entryId === pointer && item.type === 'local' && item.available && item.path)
|
|
121
|
+
: undefined;
|
|
122
|
+
if (!entry && installation.manifestName) {
|
|
123
|
+
entry = catalog.entries.find((item) => item.name === installation.manifestName && item.type === 'local' && item.available && item.path);
|
|
124
|
+
}
|
|
125
|
+
if (!entry) return undefined;
|
|
126
|
+
const contained = resolveContained(snapshotRoot, entry.path!, 'directory');
|
|
127
|
+
return contained.outcome.kind === 'ok' ? contained.outcome.canonicalPath : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read each skills/<dir>/SKILL.md descriptor name exactly as Pi resolves it (frontmatter name, else directory name). */
|
|
131
|
+
function skillCandidates(pluginDir: string): Array<{ name: string; skillDir: string }> {
|
|
132
|
+
const skillsDir = join(pluginDir, 'skills');
|
|
133
|
+
if (!existsSync(skillsDir) || !statSync(skillsDir).isDirectory()) return [];
|
|
134
|
+
const found: Array<{ name: string; skillDir: string }> = [];
|
|
135
|
+
let entries;
|
|
136
|
+
try {
|
|
137
|
+
entries = readdirSync(skillsDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (!entry.isDirectory()) continue;
|
|
143
|
+
const skillDir = join(skillsDir, entry.name);
|
|
144
|
+
const descriptor = join(skillDir, 'SKILL.md');
|
|
145
|
+
if (!existsSync(descriptor)) continue;
|
|
146
|
+
const name = descriptorSkillName(skillDir, descriptor);
|
|
147
|
+
if (name) found.push({ name, skillDir });
|
|
148
|
+
}
|
|
149
|
+
return found;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function descriptorSkillName(skillDir: string, descriptorPath: string): string | undefined {
|
|
153
|
+
try {
|
|
154
|
+
// Pi drops skills whose descriptor cannot be parsed or lacks a description; mirror that here.
|
|
155
|
+
const text = readFileSync(descriptorPath, 'utf-8');
|
|
156
|
+
const { frontmatter } = parseFrontmatter<Record<string, unknown>>(text);
|
|
157
|
+
const description = frontmatter?.description;
|
|
158
|
+
if (typeof description !== 'string' || description.trim().length === 0) return undefined;
|
|
159
|
+
const declared = frontmatter?.name;
|
|
160
|
+
return typeof declared === 'string' && declared.trim().length > 0 ? declared.trim() : basename(skillDir);
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Compute the Runtime Skill Exposure for the current Effective State. Pure read-time behavior:
|
|
168
|
+
* never mutates either Bridge State document, never writes an Attempt Receipt, never throws on
|
|
169
|
+
* missing material.
|
|
170
|
+
*/
|
|
171
|
+
export function discoverProjectedSkillPaths(opts: RuntimeSkillExposureOptions = {}): ExposureResult {
|
|
172
|
+
const globalState = readScopeOrEmpty('global', opts);
|
|
173
|
+
const projectState = readScopeOrEmpty('project', opts);
|
|
174
|
+
const effective: EffectiveState = computeEffectiveState(globalState, projectState, { projectTrusted: opts.projectTrusted === true });
|
|
175
|
+
|
|
176
|
+
const registrationsById = new Map(effective.registrations.map((registration) => [`${registration.sourceScope}:${registration.id}`, registration]));
|
|
177
|
+
const entriesRoot = getCacheEntriesDir(getCacheDir(opts.agentDir));
|
|
178
|
+
|
|
179
|
+
interface Candidate extends SkillCandidate {
|
|
180
|
+
sourceScope: 'global' | 'project';
|
|
181
|
+
installationId: string;
|
|
182
|
+
skillDir: string;
|
|
183
|
+
}
|
|
184
|
+
const candidates: Candidate[] = [];
|
|
185
|
+
const skipped: SkippedInstallation[] = [];
|
|
186
|
+
|
|
187
|
+
for (const installation of effective.installations) {
|
|
188
|
+
const registration = registrationsById.get(`${installation.sourceScope}:${installation.registrationId}`);
|
|
189
|
+
if (!registration || !installation.marketplaceEntryId) {
|
|
190
|
+
skipped.push({ installationId: installation.id, reason: 'entry-not-found' });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let snapshotRoot: string | undefined;
|
|
195
|
+
if (registration.sourceKind === 'local') {
|
|
196
|
+
// Live registered Marketplace Root at its canonical real path (mirrors inspection).
|
|
197
|
+
try {
|
|
198
|
+
snapshotRoot = registration.source && existsSync(registration.source)
|
|
199
|
+
? realpathSync.native(registration.source)
|
|
200
|
+
: undefined;
|
|
201
|
+
} catch {
|
|
202
|
+
snapshotRoot = undefined;
|
|
203
|
+
}
|
|
204
|
+
} else if (registration.sourceKind === 'git') {
|
|
205
|
+
if (!safeFingerprint(registration.validationSnapshot)) {
|
|
206
|
+
skipped.push({ installationId: installation.id, reason: 'missing-snapshot' });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// Source Cache addressing matches inspection reuse: the Registration's retained
|
|
210
|
+
// base-tree fingerprint pins the entry against eviction.
|
|
211
|
+
const entryDir = join(entriesRoot, registration.validationSnapshot);
|
|
212
|
+
if (!existsSync(entryDir)) {
|
|
213
|
+
skipped.push({ installationId: installation.id, reason: 'missing-cache-entry' });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
snapshotRoot = entryDir;
|
|
217
|
+
} else {
|
|
218
|
+
skipped.push({ installationId: installation.id, reason: 'missing-snapshot' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!snapshotRoot) {
|
|
222
|
+
skipped.push({ installationId: installation.id, reason: 'catalog-unreadable' });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const pluginDir = resolvePluginDirInSnapshot(snapshotRoot, installation, installation.sourceScope);
|
|
227
|
+
if (!pluginDir) {
|
|
228
|
+
skipped.push({
|
|
229
|
+
installationId: installation.id,
|
|
230
|
+
reason: existsSync(join(snapshotRoot, '.agents', 'plugins', 'marketplace.json')) ? 'entry-not-found' : 'catalog-unreadable',
|
|
231
|
+
});
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const skills = skillCandidates(pluginDir);
|
|
235
|
+
if (skills.length === 0) {
|
|
236
|
+
skipped.push({ installationId: installation.id, reason: 'no-skills' });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
for (const skill of skills) {
|
|
240
|
+
candidates.push({
|
|
241
|
+
layer: installation.sourceScope,
|
|
242
|
+
name: skill.name,
|
|
243
|
+
skillId: `${installation.pluginId}/${skill.name}`,
|
|
244
|
+
pluginId: installation.pluginId,
|
|
245
|
+
sourceScope: installation.sourceScope,
|
|
246
|
+
installationId: installation.id,
|
|
247
|
+
skillDir: skill.skillDir,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Only collision survivors are contributed; layering is Pi → Project Scope → Global Scope.
|
|
253
|
+
const bridgeCandidates: SkillCandidate[] = opts.piSkillNames?.length
|
|
254
|
+
? [
|
|
255
|
+
...opts.piSkillNames.map((name): SkillCandidate => ({ layer: 'pi', name, skillId: `pi/${name}`, pluginId: '(pi)' })),
|
|
256
|
+
...candidates.map(({ layer, name, skillId, pluginId }) => ({ layer, name, skillId, pluginId })),
|
|
257
|
+
]
|
|
258
|
+
: candidates.map(({ layer, name, skillId, pluginId }) => ({ layer, name, skillId, pluginId }));
|
|
259
|
+
const survivors = new Set(resolveRuntimeSkillCollisions(bridgeCandidates).survivors.map((s) => s.skillId));
|
|
260
|
+
|
|
261
|
+
const exposed = candidates
|
|
262
|
+
.filter((candidate) => survivors.has(candidate.skillId))
|
|
263
|
+
.sort((a, b) => a.skillDir.localeCompare(b.skillDir))
|
|
264
|
+
.map((candidate): ExposedSkill => ({
|
|
265
|
+
name: candidate.name,
|
|
266
|
+
skillId: candidate.skillId,
|
|
267
|
+
pluginId: candidate.pluginId,
|
|
268
|
+
installationId: candidate.installationId,
|
|
269
|
+
sourceScope: candidate.sourceScope,
|
|
270
|
+
skillDir: candidate.skillDir,
|
|
271
|
+
}));
|
|
272
|
+
|
|
273
|
+
return { skillPaths: exposed.map((skill) => skill.skillDir), exposed, skipped };
|
|
274
|
+
}
|
package/src/projection/index.ts
CHANGED
|
@@ -132,6 +132,7 @@ export const CODE = {
|
|
|
132
132
|
SKILL_DESCRIPTOR_INVALID: 'SKILL_DESCRIPTOR_INVALID',
|
|
133
133
|
UNSUPPORTED_ACTIVE_COMPONENT: 'UNSUPPORTED_ACTIVE_COMPONENT',
|
|
134
134
|
INERT_METADATA_IGNORED: 'INERT_METADATA_IGNORED',
|
|
135
|
+
UNENFORCEABLE_INVOCATION_POLICY: 'UNENFORCEABLE_INVOCATION_POLICY',
|
|
135
136
|
PLUGIN_ID_COLLISION: 'PLUGIN_ID_COLLISION',
|
|
136
137
|
SKILL_AGENT_PROFILE_INVALID: 'SKILL_AGENT_PROFILE_INVALID',
|
|
137
138
|
REGISTRATION_NOT_FOUND: 'REGISTRATION_NOT_FOUND',
|