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.
@@ -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.9",
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
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './effective-state.js';
2
2
  export * from './collision.js';
3
+ export * from './exposure.js';
3
4
  export * from './overrides.js';
4
5
  export * from './project.js';
@@ -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',