@playcraft/cli 0.1.70 → 0.1.71

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.
Files changed (29) hide show
  1. package/README.md +20 -1
  2. package/dist/commands/skills.js +68 -4
  3. package/dist/project-skills/discovery.js +248 -0
  4. package/dist/project-skills/messages.js +80 -0
  5. package/dist/project-skills/remote-activation.js +500 -0
  6. package/dist/project-skills/remote-client.js +117 -0
  7. package/dist/project-skills/remote-commands.js +87 -0
  8. package/dist/project-skills/remote-prepare.js +223 -0
  9. package/dist/project-skills/search-command.js +47 -0
  10. package/dist/project-skills/search.js +96 -0
  11. package/dist/recording/project-digest.js +1 -1
  12. package/dist/workspace-runtime/batch/source-capture.js +1 -1
  13. package/dist/workspace-runtime/codex/app-server.js +25 -1
  14. package/dist/workspace-runtime/codex/model-broker.js +93 -6
  15. package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
  16. package/dist/workspace-runtime/serve.js +5 -0
  17. package/dist/workspace-runtime/server/dispatch.js +121 -28
  18. package/dist/workspace-runtime/server/http.js +14 -0
  19. package/dist/workspace-runtime/server/operation-record.js +3 -1
  20. package/dist/workspace-runtime/workspaces/files.js +20 -14
  21. package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
  22. package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
  23. package/dist/workspace-runtime/workspaces/skills.js +13 -176
  24. package/package.json +3 -3
  25. package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  26. package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  27. package/project-templates/empty/gitignore +1 -0
  28. package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  29. package/project-templates/threejs/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
@@ -0,0 +1,87 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { remoteSkillMatchResultSchema } from '@playcraft/common/project-skills';
5
+ import { RemoteSkillClient } from './remote-client.js';
6
+ import { prepareRemoteSkillSelection } from './remote-prepare.js';
7
+ import { findManagedSkillsRoot, normalizeSkillEngine } from './search.js';
8
+ import { printSkillSearchError } from './search-command.js';
9
+ import { projectSkillsMessage as t } from './messages.js';
10
+ export async function matchRemoteSkills(options) {
11
+ const limit = Number(options.limit ?? 20);
12
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
13
+ throw new Error(t('searchInvalidLimit'));
14
+ const engine = options.engine ? normalizeSkillEngine(options.engine) : undefined;
15
+ if (engine !== undefined && engine !== 'phaser' && engine !== 'threejs')
16
+ throw new Error(t('remoteInvalidEngine'));
17
+ const result = await new RemoteSkillClient({ cwd: resolve(options.dir), url: options.url }).match({ query: options.intent, engine, limit });
18
+ if (options.json)
19
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
20
+ else {
21
+ console.log(t('remoteSource'));
22
+ console.log(result.total ? `${t('searchSummary')} ${result.total}` : t('remoteNoMatches'));
23
+ for (const skill of result.items) {
24
+ console.log(` ${skill.skillRef}@${skill.version} ${skill.name}`);
25
+ if (skill.description)
26
+ console.log(` ${skill.description}`);
27
+ if (skill.matchedTerms.length)
28
+ console.log(` ${skill.matchedTerms.join(', ')}`);
29
+ }
30
+ console.log(t('remotePrepareHint'));
31
+ }
32
+ return result;
33
+ }
34
+ async function readMatchReceipt(path) {
35
+ const file = await open(resolve(path), constants.O_RDONLY | constants.O_NOFOLLOW);
36
+ try {
37
+ const stat = await file.stat();
38
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024)
39
+ throw new Error(t('remoteInvalidMatchFile'));
40
+ return remoteSkillMatchResultSchema.parse(JSON.parse(await file.readFile('utf8')));
41
+ }
42
+ finally {
43
+ await file.close();
44
+ }
45
+ }
46
+ export function registerRemoteSkillPrepareCommand(skills) {
47
+ skills.command('prepare').description(t('remotePrepare'))
48
+ .requiredOption('--from-match <path>', t('remoteMatchFile'))
49
+ .requiredOption('--skills <refs>', t('remoteSelectedRefs'))
50
+ .option('--dir <path>', t('directory'), '.')
51
+ .option('--url <url>', t('remoteUrl'))
52
+ .option('--prepare-only', t('remotePrepareOnly'))
53
+ .option('--json', t('json'))
54
+ .action(async (options) => {
55
+ try {
56
+ const match = await readMatchReceipt(options.fromMatch);
57
+ const refs = [...new Set(options.skills.split(',').map(ref => ref.trim()).filter(Boolean))];
58
+ if (!refs.length || refs.length > 50)
59
+ throw new Error(t('remoteInvalidSelection'));
60
+ const selected = refs.map(ref => {
61
+ const candidates = match.items.filter(skill => skill.skillRef === ref || `${skill.skillRef}@${skill.version}` === ref);
62
+ if (candidates.length !== 1)
63
+ throw new Error(`${t('remoteInvalidSelection')}: ${ref}`);
64
+ const skill = candidates[0];
65
+ return { skillRef: skill.skillRef, skillVersionId: skill.skillVersionId, version: skill.version, artifactHash: skill.artifactHash };
66
+ });
67
+ const engine = match.engine === 'phaser' || match.engine === 'threejs' ? match.engine : undefined;
68
+ const client = new RemoteSkillClient({ cwd: resolve(options.dir), url: options.url });
69
+ const selection = await client.select({ skills: selected, engine, catalogHash: match.catalogHash });
70
+ const root = client.hasExecutionBridge() ? await findManagedSkillsRoot(options.dir) ?? resolve(options.dir) : resolve(options.dir);
71
+ const prepared = await prepareRemoteSkillSelection(root, selection, entry => client.artifact(selection, entry));
72
+ const activation = options.prepareOnly ? undefined : await client.activate(selection.selectionId);
73
+ const result = activation ? { ...prepared, status: activation.status, activation } : prepared;
74
+ if (options.json)
75
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
76
+ else {
77
+ console.log(t('remotePrepared'));
78
+ console.log(result.directory);
79
+ console.log(t(activation?.status === 'activation_requested' ? 'remoteActivationRequested'
80
+ : activation?.status === 'already_active' ? 'remoteAlreadyActive' : 'remoteActivationRequired'));
81
+ }
82
+ }
83
+ catch (error) {
84
+ printSkillSearchError(error, options.json);
85
+ }
86
+ });
87
+ }
@@ -0,0 +1,223 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { link, lstat, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ import { canonicalProjectSkillsJson, parseRemoteSkillSelectionResult, projectSkillsDigest, } from '@playcraft/common/project-skills';
6
+ import { ensureProjectSkillDirectory, readLockedProjectSkillCatalog } from './discovery.js';
7
+ import { excludeFromGit } from '../workspace-runtime/workspaces/dependencies.js';
8
+ const MAX_RECEIPT_BYTES = 16 * 1024 * 1024;
9
+ const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
10
+ const receiptName = 'selection.json';
11
+ const PROCESS_STARTED_AT = Math.round(Date.now() - process.uptime() * 1000);
12
+ async function readPreparationLease(path) {
13
+ let handle;
14
+ try {
15
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT')
19
+ throw error;
20
+ throw new Error('remote_skill_selection_lease_invalid');
21
+ }
22
+ try {
23
+ const stat = await handle.stat();
24
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 2048)
25
+ throw new Error('remote_skill_selection_lease_invalid');
26
+ const bytes = await handle.readFile();
27
+ let record;
28
+ try {
29
+ record = JSON.parse(bytes.toString('utf8'));
30
+ }
31
+ catch {
32
+ throw new Error('remote_skill_selection_lease_invalid');
33
+ }
34
+ if (!record || record.version !== 1 || !/^[a-f0-9]{64}$/.test(record.selectionId)
35
+ || !Number.isSafeInteger(record.pid) || record.pid < 1 || record.pid > 2_147_483_647
36
+ || !Number.isSafeInteger(record.processStartedAt) || record.processStartedAt < 1
37
+ || typeof record.nonce !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(record.nonce))
38
+ throw new Error('remote_skill_selection_lease_invalid');
39
+ return { bytes, ino: stat.ino, dev: stat.dev, record };
40
+ }
41
+ finally {
42
+ await handle.close();
43
+ }
44
+ }
45
+ function preparationOwnerExited(pid) {
46
+ try {
47
+ process.kill(pid, 0);
48
+ return false;
49
+ }
50
+ catch (error) {
51
+ return error.code === 'ESRCH';
52
+ }
53
+ }
54
+ const samePreparationLease = (left, right) => left.ino === right.ino && left.dev === right.dev && left.bytes.equals(right.bytes);
55
+ async function ownsPreparationLease(path, expected) {
56
+ try {
57
+ return samePreparationLease(await readPreparationLease(path), expected);
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ async function retirePreparationLease(path, expected, mayRemove = () => true) {
64
+ if (!await ownsPreparationLease(path, expected))
65
+ return false;
66
+ const retired = `${path}.${randomUUID()}.retired`;
67
+ try {
68
+ await rename(path, retired);
69
+ }
70
+ catch (error) {
71
+ if (error.code === 'ENOENT')
72
+ return false;
73
+ throw error;
74
+ }
75
+ if (await ownsPreparationLease(retired, expected) && mayRemove()) {
76
+ await rm(retired);
77
+ return true;
78
+ }
79
+ // Exclusive restoration never overwrites a concurrently created lease. If another
80
+ // owner claimed the original path, retain these unknown bytes under the retired name.
81
+ try {
82
+ await link(retired, path);
83
+ await rm(retired);
84
+ }
85
+ catch (error) {
86
+ if (error.code !== 'EEXIST')
87
+ throw error;
88
+ }
89
+ return false;
90
+ }
91
+ async function acquirePreparationLease(path, selectionId) {
92
+ const create = async () => {
93
+ const handle = await open(path, 'wx', 0o600);
94
+ const record = { version: 1, selectionId, pid: process.pid,
95
+ processStartedAt: PROCESS_STARTED_AT, nonce: randomUUID() };
96
+ const bytes = Buffer.from(JSON.stringify(record));
97
+ try {
98
+ await handle.writeFile(bytes);
99
+ const stat = await handle.stat();
100
+ return { handle, bytes, record, ino: stat.ino, dev: stat.dev };
101
+ }
102
+ catch (error) {
103
+ await handle.close();
104
+ // A partially written lease has no proof of ownership and remains fail-closed.
105
+ throw error;
106
+ }
107
+ };
108
+ try {
109
+ return await create();
110
+ }
111
+ catch (error) {
112
+ if (error.code !== 'EEXIST')
113
+ throw error;
114
+ }
115
+ const previous = await readPreparationLease(path);
116
+ if (previous.record.selectionId !== selectionId)
117
+ throw new Error('remote_skill_selection_lease_invalid');
118
+ if (!preparationOwnerExited(previous.record.pid))
119
+ throw new Error('remote_skill_selection_busy');
120
+ if (!await retirePreparationLease(path, previous, () => preparationOwnerExited(previous.record.pid)))
121
+ throw new Error('remote_skill_selection_lease_changed');
122
+ try {
123
+ return await create();
124
+ }
125
+ catch (error) {
126
+ if (error.code === 'EEXIST')
127
+ throw new Error('remote_skill_selection_busy');
128
+ throw error;
129
+ }
130
+ }
131
+ async function exists(path) {
132
+ return lstat(path).then(stat => stat, (error) => {
133
+ if (error.code === 'ENOENT')
134
+ return undefined;
135
+ throw error;
136
+ });
137
+ }
138
+ function identity(selection) {
139
+ return projectSkillsDigest({ declaration: selection.declaration, lock: selection.lock,
140
+ skills: selection.skills.map(({ skillRef, skillVersionId, version, artifactHash }) => ({ skillRef, skillVersionId, version, artifactHash })).sort((a, b) => a.skillRef.localeCompare(b.skillRef)) });
141
+ }
142
+ async function readReceipt(directory) {
143
+ const stat = await lstat(directory);
144
+ if (!stat.isDirectory() || stat.isSymbolicLink())
145
+ throw new Error('remote_skill_selection_path_invalid');
146
+ const receipt = join(directory, receiptName);
147
+ const file = await lstat(receipt);
148
+ if (!file.isFile() || file.isSymbolicLink() || file.nlink !== 1 || file.size > MAX_RECEIPT_BYTES)
149
+ throw new Error('remote_skill_selection_path_invalid');
150
+ return parseRemoteSkillSelectionResult(JSON.parse(await readFile(receipt, 'utf8')));
151
+ }
152
+ async function prepared(directory, selection) {
153
+ const saved = await readReceipt(directory);
154
+ if (identity(saved) !== identity(selection))
155
+ throw new Error('remote_skill_selection_conflict');
156
+ const catalog = await readLockedProjectSkillCatalog(directory, join(directory, '.playcraft/skill-artifacts'));
157
+ if (catalog.lockHash !== selection.lockHash || catalog.skills.length !== selection.lock.entries.length)
158
+ throw new Error('remote_skill_selection_conflict');
159
+ return { status: 'prepared_requires_activation', executionReady: false,
160
+ selectionId: selection.selectionId, lockHash: selection.lockHash, directory,
161
+ skills: catalog.skills.map(({ entryKey, skillRef, revision, artifactHash, name, description, skillDir, path }) => ({ entryKey, skillRef, revision, artifactHash, name, description, skillDir, path })) };
162
+ }
163
+ /** Downloads a separate exact selection; never changes the project's active declaration or lock. */
164
+ export async function prepareRemoteSkillSelection(directory, input, loadArtifact) {
165
+ const selection = parseRemoteSkillSelectionResult(input);
166
+ if (!selection.lock.entries.length || selection.declaration.entries.some(entry => entry.source.kind !== 'registry' || !entry.enabled || entry.surface !== 'sandbox-agent'))
167
+ throw new Error('invalid_remote_skill_selection');
168
+ const selectedBytes = canonicalProjectSkillsJson(selection);
169
+ if (Buffer.byteLength(selectedBytes) > MAX_RECEIPT_BYTES)
170
+ throw new Error('remote_skill_selection_too_large');
171
+ const suppliedRoot = resolve(directory);
172
+ const stat = await lstat(suppliedRoot);
173
+ if (!stat.isDirectory() || stat.isSymbolicLink())
174
+ throw new Error('remote_skill_selection_path_invalid');
175
+ const root = await realpath(suppliedRoot);
176
+ await excludeFromGit(root, '/.playcraft/skill-selections/');
177
+ const parent = await ensureProjectSkillDirectory(root, '.playcraft/skill-selections', true);
178
+ const target = join(parent, selection.selectionId);
179
+ if (await exists(target))
180
+ return prepared(target, selection);
181
+ const leasePath = join(parent, `.${selection.selectionId}.prepare.lock`);
182
+ const lease = await acquirePreparationLease(leasePath, selection.selectionId);
183
+ const stageName = `.${selection.selectionId}.${randomUUID()}.tmp`;
184
+ const stage = join(parent, stageName);
185
+ let published = false;
186
+ try {
187
+ if (await exists(target))
188
+ return prepared(target, selection);
189
+ await ensureProjectSkillDirectory(parent, stageName);
190
+ const management = await ensureProjectSkillDirectory(stage, '.playcraft/skill-artifacts');
191
+ const snapshots = [
192
+ ['skills.json', selection.declaration], ['skills.lock.json', selection.lock],
193
+ ];
194
+ for (const [name, value] of snapshots) {
195
+ const bytes = canonicalProjectSkillsJson(value);
196
+ if (Buffer.byteLength(bytes) > 1024 * 1024)
197
+ throw new Error('remote_skill_selection_too_large');
198
+ await writeFile(join(stage, '.playcraft', name), bytes, { flag: 'wx', mode: 0o644 });
199
+ }
200
+ await readLockedProjectSkillCatalog(stage, management, async (entry) => {
201
+ const archive = await loadArtifact(entry);
202
+ if (!Buffer.isBuffer(archive) || archive.byteLength > MAX_ARTIFACT_BYTES
203
+ || createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
204
+ throw new Error('remote_skill_artifact_digest_mismatch');
205
+ return archive;
206
+ });
207
+ await writeFile(join(stage, receiptName), selectedBytes, { flag: 'wx', mode: 0o644 });
208
+ // Nothing under the deterministic selection name is visible until every bundle is verified.
209
+ if (!await ownsPreparationLease(leasePath, lease))
210
+ throw new Error('remote_skill_selection_lease_changed');
211
+ if (await exists(target))
212
+ throw new Error('remote_skill_selection_conflict');
213
+ await rename(stage, target);
214
+ published = true;
215
+ return await prepared(target, selection);
216
+ }
217
+ finally {
218
+ if (!published)
219
+ await rm(stage, { recursive: true, force: true });
220
+ await lease.handle.close();
221
+ await retirePreparationLease(leasePath, lease);
222
+ }
223
+ }
@@ -0,0 +1,47 @@
1
+ import { resolve } from 'node:path';
2
+ import { projectSkillsMessage as t } from './messages.js';
3
+ import { searchProjectSkills } from './search.js';
4
+ export function printLockedSkillSearch(skills, total) {
5
+ console.log(t('searchLockedSource'));
6
+ console.log(total ? `${t('searchSummary')} ${total}` : t('searchNoMatches'));
7
+ for (const skill of skills) {
8
+ console.log(` ${skill.skillRef}@${skill.revision} ${skill.name}`);
9
+ if (skill.description)
10
+ console.log(` ${skill.description}`);
11
+ console.log(` ${skill.path}`);
12
+ }
13
+ if (skills.length)
14
+ console.log(t('searchReadPath'));
15
+ }
16
+ export function printSkillSearchError(error, json) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ const code = error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : 'skill_search_failed';
19
+ if (json)
20
+ process.stdout.write(`${JSON.stringify({ error: { code, message } })}\n`);
21
+ else
22
+ console.error(`${t('searchFailed')}: ${message}`);
23
+ process.exitCode = 1;
24
+ }
25
+ export function registerSkillSearchCommand(skills) {
26
+ skills.command('search').description(t('search'))
27
+ .option('--query <text>', t('searchQuery'), '')
28
+ .option('--engine <name>', t('searchEngine'))
29
+ .option('--dir <path>', t('directory'), '.')
30
+ .option('--limit <count>', t('searchLimit'), '20')
31
+ .option('--json', t('json'))
32
+ .action(async (options) => {
33
+ try {
34
+ const limit = Number(options.limit);
35
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
36
+ throw new Error(t('searchInvalidLimit'));
37
+ const result = await searchProjectSkills(resolve(options.dir), { ...options, limit });
38
+ if (options.json)
39
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
40
+ else
41
+ printLockedSkillSearch(result.skills, result.total);
42
+ }
43
+ catch (error) {
44
+ printSkillSearchError(error, options.json);
45
+ }
46
+ });
47
+ }
@@ -0,0 +1,96 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { readLockedProjectSkillCatalog } from './discovery.js';
4
+ import { projectSkillsMessage as t } from './messages.js';
5
+ /** A partial configuration is still managed and must fail validation, never fall through to scanning. */
6
+ export async function findManagedSkillsRoot(directory) {
7
+ let root = resolve(directory);
8
+ const exists = async (file) => lstat(file).then(() => true, (error) => {
9
+ if (error.code === 'ENOENT')
10
+ return false;
11
+ throw error;
12
+ });
13
+ while (true) {
14
+ if (await exists(join(root, '.playcraft/skills.json')) || await exists(join(root, '.playcraft/skills.lock.json')))
15
+ return root;
16
+ if (await exists(join(root, '.git')) || dirname(root) === root)
17
+ return null;
18
+ root = dirname(root);
19
+ }
20
+ }
21
+ const normalize = (value) => value.normalize('NFKC').toLowerCase();
22
+ export const normalizeSkillEngine = (value) => normalize(value).replace(/[.\s-]/g, '');
23
+ export function skillSearchTerms(query) {
24
+ const terms = new Set(normalize(query).split(/[\s,,、;;]+/u).filter(Boolean));
25
+ // Include words within natural-language Chinese queries and compound English tags.
26
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
27
+ for (const { segment, isWordLike } of segmenter.segment(normalize(query))) {
28
+ if (isWordLike && segment.length > 1)
29
+ terms.add(segment);
30
+ }
31
+ return [...terms];
32
+ }
33
+ export function filterLockedSkillsByEngine(skills, target) {
34
+ if (!target)
35
+ return skills;
36
+ const engine = normalizeSkillEngine(target);
37
+ const byAtom = new Map(skills.filter(skill => skill.manifest).map(skill => [skill.manifest.atomId, skill]));
38
+ const rootEngine = (id) => /^(phaser|threejs)(?:\.aicomponent|-runtime)?$/.exec(id)?.[1];
39
+ const compatible = (skill, visited = new Set()) => {
40
+ if (visited.has(skill.entryKey))
41
+ return true;
42
+ visited.add(skill.entryKey);
43
+ const manifest = skill.manifest;
44
+ if (!manifest)
45
+ return true;
46
+ const ownEngine = rootEngine(manifest.atomId);
47
+ if (ownEngine && ownEngine !== engine)
48
+ return false;
49
+ const metadata = manifest;
50
+ const engines = [...(metadata.renderBackend ?? []), ...Object.keys(metadata.engineVariants ?? {})].map(normalize);
51
+ if (engines.length && !engines.includes(engine))
52
+ return false;
53
+ const pins = metadata.engineVariants?.[engine]?.imports ?? (manifest.imports ?? [])
54
+ .filter(item => item.edgeKind === 'requires' || item.slot === 'engine' || item.bindAs === 'engine')
55
+ .flatMap(item => item.pinAtomId ? [item.pinAtomId] : []);
56
+ return pins.every(id => {
57
+ const requiredEngine = rootEngine(id);
58
+ if (requiredEngine && requiredEngine !== engine)
59
+ return false;
60
+ const dependency = byAtom.get(id);
61
+ return !dependency || compatible(dependency, visited);
62
+ });
63
+ };
64
+ return skills.filter(skill => compatible(skill));
65
+ }
66
+ export function rankLockedProjectSkills(skills, options = {}) {
67
+ const terms = skillSearchTerms(options.query ?? '');
68
+ const ranked = filterLockedSkillsByEngine(skills, options.engine).flatMap((skill) => {
69
+ const manifest = skill.manifest;
70
+ const fields = [
71
+ [skill.name, 5], [skill.skillRef, 5],
72
+ [skill.triggers.join(' '), 4], [skill.tags.join(' '), 4], [skill.keywords.join(' '), 3],
73
+ [manifest?.label?.zh ?? '', 4], [manifest?.label?.en ?? '', 4], [skill.description, 1],
74
+ ];
75
+ let score = 0;
76
+ const matchedTerms = terms.filter(term => {
77
+ const weight = Math.max(0, ...fields.map(([value, boost]) => normalize(value).includes(term) ? boost : 0));
78
+ score += weight;
79
+ return weight > 0;
80
+ });
81
+ if (terms.length && !matchedTerms.length)
82
+ return [];
83
+ const { manifest: _manifest, ...metadata } = skill;
84
+ return [{ ...metadata, atomId: manifest?.atomId, score, matchedTerms }];
85
+ }).sort((a, b) => b.score - a.score || a.skillRef.localeCompare(b.skillRef));
86
+ return { total: ranked.length, skills: ranked.slice(0, options.limit ?? 20) };
87
+ }
88
+ export async function searchProjectSkills(directory, options = {}) {
89
+ const root = await findManagedSkillsRoot(directory);
90
+ if (!root)
91
+ throw new Error(t('searchRequiresLock'));
92
+ const catalog = await readLockedProjectSkillCatalog(root);
93
+ return { source: 'project-lock', lockHash: catalog.lockHash,
94
+ query: options.query ?? '', engine: options.engine ?? null,
95
+ ...rankLockedProjectSkills(catalog.skills, options) };
96
+ }
@@ -7,7 +7,7 @@ import { RecordingError } from './messages.js';
7
7
  const execute = promisify(execFile);
8
8
  const excludedNames = new Set(['.git', 'node_modules', '.DS_Store']);
9
9
  const excludedRoots = new Set(['.agents', '.claude', '.codex', '.next', '.vite', '.cache', '.tmp', 'tmp', 'temp', 'dist', 'build', 'coverage', '.playcraft-preview-out', '.playcraft-temp']);
10
- const excludedInternalPaths = ['.playcraft/video-replication', '.playcraft/skills', '.playcraft/skill-artifacts', '.playcraft/skills.json', '.playcraft/skills.lock.json', '.playcraft/workspace-context.json'];
10
+ const excludedInternalPaths = ['.playcraft/video-replication', '.playcraft/skills', '.playcraft/skill-artifacts', '.playcraft/skill-selections', '.playcraft/skills.json', '.playcraft/skills.lock.json', '.playcraft/workspace-context.json'];
11
11
  const inside = (root, path) => path === root || path.startsWith(`${root}${sep}`);
12
12
  const canonicalPath = async (path) => {
13
13
  const absolute = resolve(path);
@@ -17,7 +17,7 @@ function included(path, referenceExamples) {
17
17
  const parts = path.split('/');
18
18
  return !parts.some((name, index) => privateNames.has(name) || (/^\.env(?:$|\.)/.test(name) &&
19
19
  !(index === parts.length - 1 && referenceExamples.has(path))) || /\.(?:pem|key|p12|pfx)$/.test(name))
20
- && !path.startsWith('.playcraft/skills/');
20
+ && !path.startsWith('.playcraft/skills/') && !path.startsWith('.playcraft/skill-selections/');
21
21
  }
22
22
  /** Git keeps only the executable bit, so the digest must not depend on umask or group-write bits. */
23
23
  function snapshotMode(mode) {
@@ -348,7 +348,7 @@ export function createCodexAppServerAdapter(options) {
348
348
  'skills.include_instructions': false,
349
349
  'skills.config': listing.skills.map(skill => ({ path: skill.path, enabled: false })) };
350
350
  const developerInstructions = [
351
- `Available workspace skills are fixed by lock ${frozen.declarationHash}.`,
351
+ `Available workspace skills are fixed by lock ${frozen.lockHash}.`,
352
352
  'Use only the following locked skills. Read its SKILL.md when a skill applies, and resolve supporting files relative to that file. Disabled and unlisted skill directories are not available.',
353
353
  ...frozen.skills.map(skill => JSON.stringify(skill)),
354
354
  ...(frozen.skills.length ? [] : ['(No workspace skills are enabled.)']),
@@ -414,6 +414,26 @@ export function createCodexAppServerAdapter(options) {
414
414
  throw new NativePreparationError(unavailable);
415
415
  }
416
416
  }
417
+ async function injectPreparedSkillContext(threadId, prepared) {
418
+ const instructions = asRecord(prepared.params).developerInstructions;
419
+ if (!prepared.skillsHash || typeof instructions !== 'string')
420
+ return;
421
+ if (threads.get(threadId)?.turnActive || [...approvals.values()].some(pending => owningThreadId(pending.threadId) === threadId))
422
+ throw new NativePreparationError('skills_reload_pending');
423
+ // Resume configuration does not replace developer messages already in native history.
424
+ // Keep built-in collaboration policies and update only the platform-owned workspace context.
425
+ try {
426
+ await rpc.request('thread/inject_items', { threadId, items: [{ type: 'message', role: 'developer', content: [{
427
+ type: 'input_text', text: [
428
+ `Current platform workspace skill lock: ${prepared.skillsHash}. This replaces only earlier platform workspace skill lock and allowlist declarations. Keep all other instructions and permissions in force.`,
429
+ instructions,
430
+ ].join('\n\n'),
431
+ }] }] }, 30_000);
432
+ }
433
+ catch {
434
+ throw new NativePreparationError('workspace_skills_unavailable');
435
+ }
436
+ }
417
437
  async function resumePreparedThread(threadId, thread, prepared) {
418
438
  const resumed = await rpc.request('thread/resume', {
419
439
  threadId, ...prepared.params,
@@ -421,6 +441,7 @@ export function createCodexAppServerAdapter(options) {
421
441
  }, 30_000);
422
442
  if (resumed.thread?.id !== threadId)
423
443
  throw new NativePreparationError('workspace_skills_unavailable');
444
+ await injectPreparedSkillContext(threadId, prepared);
424
445
  thread.loaded = true;
425
446
  thread.readOnlyConfirmed = isReadOnlySnapshot(resumed);
426
447
  thread.skillsHash = prepared.skillsHash;
@@ -938,6 +959,7 @@ export function createCodexAppServerAdapter(options) {
938
959
  }
939
960
  if (resumedId !== input.threadId)
940
961
  return undefined;
962
+ await injectPreparedSkillContext(input.threadId, prepared);
941
963
  prepared.activate();
942
964
  threads.set(input.threadId, { sessionId: input.sessionId, cwd: input.cwd, skillsHash: prepared.skillsHash,
943
965
  discoveryHash: prepared.discoveryHash, mcpHash: prepared.mcpHash, contextHash: nativeContextHash(prepared.params), resolvedModel: 'model' in prepared.params ? prepared.params.model : model, modelConfigId: prepared.modelConfigId, reasoningEffort: input.reasoningEffort, permissionMode: input.permissionMode ?? 'auto', canEscalate: sessionCanEscalate(Boolean(options.trustedExecution?.(input.sessionId)), prepared.execution, Boolean(options.fixedExecution)), hasNativeHistory: true, loaded: true });
@@ -1024,6 +1046,8 @@ export function createCodexAppServerAdapter(options) {
1024
1046
  thread.turnActive = true;
1025
1047
  thread.scheduledTaskProposalBridge = input.scheduledTaskProposalBridge;
1026
1048
  thread.uiSnapshot = options.workspaceUi && input.uiSnapshot?.sessionId === input.sessionId ? input.uiSnapshot : undefined;
1049
+ if (thread.cwd)
1050
+ options.modelBroker?.markCodexDispatched?.(thread.cwd, input.sessionId, input.clientRequestId);
1027
1051
  const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [
1028
1052
  { type: 'text', text: input.text },
1029
1053
  ...(input.images ?? []).map((path) => ({ type: 'localImage', path })),