devlyn-cli 4.2.25 → 4.2.26

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.
@@ -0,0 +1,129 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { verifyRelease, physicalRoot, sha256, manifestDigest, fail, options } = require('./release');
4
+
5
+ const MARKER = '.devlyn-harness-projection.json';
6
+ const SOURCE = 'native/codex/plugins/devlyn';
7
+ const marketplaceName = (release) => `devlyn-harness-v${release.version.replace(/\./g, '-')}`;
8
+ const projectionPath = (release, accountHome, paths = path) =>
9
+ paths.join(accountHome, 'plugins', 'cache', marketplaceName(release), 'devlyn', release.version);
10
+ const markerBytes = (release) => Buffer.from(JSON.stringify({ schemaVersion: 1, release, source: SOURCE }, null, 2) + '\n');
11
+
12
+ function conflict(file) { fail('name-collision', { path: file, kind: 'codex-projection' }, 3); }
13
+
14
+ // Account homes may have physical aliases (e.g. /var on macOS). Below that
15
+ // explicitly selected root, never follow a link or adopt a foreign cache entry.
16
+ function cacheParent(accountHome, release, create) {
17
+ let dir = physicalRoot(accountHome);
18
+ for (const part of ['plugins', 'cache', marketplaceName(release), 'devlyn']) {
19
+ dir = path.join(dir, part);
20
+ if (create) {
21
+ try { fs.mkdirSync(dir); }
22
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
23
+ }
24
+ const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
25
+ if (!stat && !create) return null;
26
+ if (!stat?.isDirectory()) conflict(dir);
27
+ }
28
+ return dir;
29
+ }
30
+
31
+ function projectionEntries(release) {
32
+ const manifest = JSON.parse(fs.readFileSync(path.join(release.root, 'release.json'), 'utf8'));
33
+ if (manifest.digest?.value !== release.digest || manifestDigest(manifest) !== release.digest) {
34
+ fail('release-source-changed', { path: release.root }, 3);
35
+ }
36
+ const entries = manifest.files.filter((entry) => entry.path.startsWith(`${SOURCE}/`))
37
+ .map((entry) => ({ ...entry, path: entry.path.slice(SOURCE.length + 1) }));
38
+ if (!entries.some((entry) => entry.path === '.codex-plugin/plugin.json')) conflict(release.root);
39
+ return entries;
40
+ }
41
+
42
+ function verifyProjection(root, release) {
43
+ const entries = projectionEntries(release);
44
+ const expected = new Map(entries.map((entry) => [entry.path, entry]));
45
+ expected.set(MARKER, { sha256: sha256(markerBytes(release)), executable: false });
46
+ const directories = new Set(['']);
47
+ for (const name of expected.keys()) {
48
+ const parts = name.split('/');
49
+ for (let i = 1; i < parts.length; i++) directories.add(parts.slice(0, i).join('/'));
50
+ }
51
+ const seen = new Set();
52
+ function visit(relative) {
53
+ const full = path.join(root, relative);
54
+ const stat = fs.lstatSync(full, { throwIfNoEntry: false });
55
+ if (stat?.isDirectory() && directories.has(relative)) {
56
+ for (const name of fs.readdirSync(full)) visit(relative ? `${relative}/${name}` : name);
57
+ } else {
58
+ const entry = expected.get(relative);
59
+ if (!entry || !stat?.isFile() || stat.nlink !== 1
60
+ || sha256(fs.readFileSync(full)) !== entry.sha256
61
+ || (process.platform !== 'win32' && Boolean(stat.mode & 0o111) !== entry.executable)) conflict(full);
62
+ seen.add(relative);
63
+ }
64
+ }
65
+ visit('');
66
+ if (seen.size !== expected.size) conflict(root);
67
+ return { projectionRoot: root, markerSha256: expected.get(MARKER).sha256 };
68
+ }
69
+
70
+ function inspectProjection(release, accountHome) {
71
+ const parent = cacheParent(accountHome, release, false);
72
+ if (!parent) return null;
73
+ const root = path.join(parent, release.version);
74
+ if (!fs.lstatSync(root, { throwIfNoEntry: false })) return null;
75
+ return verifyProjection(root, release);
76
+ }
77
+
78
+ function syncProjection(releaseRoot, accountHome) {
79
+ const release = verifyRelease(releaseRoot).release;
80
+ const account = physicalRoot(accountHome);
81
+ const relative = path.relative(release.root, account);
82
+ if (!relative || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
83
+ fail('invalid-arguments', { argument: 'account-home must be outside the release' }, 2);
84
+ }
85
+ const parent = cacheParent(account, release, true);
86
+ const target = path.join(parent, release.version);
87
+ const existing = () => ({ release, ...verifyProjection(target, release), created: false });
88
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) return existing();
89
+ // A short exclusive publication lock prevents two CLI publishers from
90
+ // replacing each other's cache. Interrupted locks are explicit conflicts;
91
+ // they are never guessed stale or reaped by age/PID.
92
+ const lock = path.join(parent, `.${release.version}.lock`);
93
+ let lockFd;
94
+ try { lockFd = fs.openSync(lock, 'wx', 0o600); }
95
+ catch (error) { if (error.code === 'EEXIST') conflict(lock); throw error; }
96
+ let temp;
97
+ try {
98
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) return existing();
99
+ temp = fs.mkdtempSync(path.join(parent, `.${release.version}-`));
100
+ for (const entry of projectionEntries(release)) {
101
+ const dest = path.join(temp, entry.path);
102
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
103
+ fs.copyFileSync(path.join(release.root, SOURCE, entry.path), dest, fs.constants.COPYFILE_EXCL);
104
+ fs.chmodSync(dest, entry.executable ? 0o755 : 0o644);
105
+ }
106
+ fs.writeFileSync(path.join(temp, MARKER), markerBytes(release), { flag: 'wx', mode: 0o644 });
107
+ verifyProjection(temp, release);
108
+ if (fs.lstatSync(target, { throwIfNoEntry: false })) return existing();
109
+ fs.renameSync(temp, target);
110
+ return { release, ...verifyProjection(target, release), created: true };
111
+ } finally {
112
+ if (temp) fs.rmSync(temp, { recursive: true, force: true });
113
+ fs.closeSync(lockFd);
114
+ fs.unlinkSync(lock);
115
+ }
116
+ }
117
+
118
+ function machineCommand(args) {
119
+ if (args[0] !== 'sync') fail('invalid-arguments', { argument: 'machine sync' }, 2);
120
+ const opts = options(args.slice(1), {
121
+ 'release-root': { type: 'string' }, engine: { type: 'string' }, 'account-home': { type: 'string' },
122
+ });
123
+ if (opts.engine !== 'codex' || !opts['release-root'] || !opts['account-home']) {
124
+ fail('invalid-arguments', { argument: 'release-root, engine=codex and account-home required' }, 2);
125
+ }
126
+ return syncProjection(opts['release-root'], opts['account-home']);
127
+ }
128
+
129
+ module.exports = { MARKER, marketplaceName, projectionPath, inspectProjection, verifyProjection, syncProjection, machineCommand };
package/bin/devlyn.js CHANGED
@@ -1350,6 +1350,18 @@ async function main() {
1350
1350
  const command = args[0];
1351
1351
 
1352
1352
  switch (command) {
1353
+ case 'activation':
1354
+ require('./release').runJson(() => require('./harness-activation').activationCommand(args.slice(1)));
1355
+ break;
1356
+ case 'machine':
1357
+ require('./release').runJson(() => require('./codex-projection').machineCommand(args.slice(1)));
1358
+ break;
1359
+ case 'release':
1360
+ require('./release').runJson(() => require('./release').releaseCommand(args.slice(1)));
1361
+ break;
1362
+ case 'status':
1363
+ require('./release').runJson(() => require('./harness-status').statusCommand(args.slice(1)));
1364
+ break;
1353
1365
  case '--help':
1354
1366
  case '-h':
1355
1367
  showHelp();
package/bin/engines.js ADDED
@@ -0,0 +1,35 @@
1
+ // Engine IDs from devlyn-os-v1/packages/contracts/src/engines.ts, in inventory order.
2
+ // CLI binary names (for example agy) are not engine IDs.
3
+ const ENGINE_IDS = Object.freeze([
4
+ 'claude', 'codex', 'grok', 'omp', 'antigravity', 'cursor', 'opencode', 'kimi', 'copilot', 'qwen',
5
+ ]);
6
+
7
+ // Examined versions, not historical feature-introduction claims. Pre-1.0
8
+ // engines are bounded to the examined minor; Cursor to the examined build.
9
+ const ENGINE_CAPABILITIES = Object.freeze({
10
+ claude: { minimum: '2.1.307', generation: '2', executor: true },
11
+ codex: { minimum: '0.163.0', generation: '0.163', executor: true },
12
+ grok: { minimum: '1.0.62', generation: '1', executor: false },
13
+ omp: { minimum: '16.5.0', generation: '16', executor: false },
14
+ antigravity: { minimum: '1.4.4', generation: '1', executor: false },
15
+ cursor: { minimum: '2025.09.12-4852336', generation: '2025.09.12-4852336', executor: false },
16
+ opencode: { minimum: '1.19.2', generation: '1', executor: false },
17
+ kimi: { minimum: '2.3.0', generation: '2', executor: false },
18
+ copilot: { minimum: '1.0.98', generation: '1', executor: false },
19
+ qwen: { minimum: '0.26.0', generation: '0.26', executor: false },
20
+ });
21
+
22
+ function supportedVersion(engine, version) {
23
+ const capability = ENGINE_CAPABILITIES[engine];
24
+ if (engine === 'cursor') return version === capability.minimum;
25
+ if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) return false;
26
+ if (!version.startsWith(`${capability.generation}.`)) return false;
27
+ const actual = version.split('.').map(Number);
28
+ const floor = capability.minimum.split('.').map(Number);
29
+ for (let i = 0; i < 3; i++) {
30
+ if (actual[i] !== floor[i]) return actual[i] > floor[i];
31
+ }
32
+ return true;
33
+ }
34
+
35
+ module.exports = { ENGINE_IDS, ENGINE_CAPABILITIES, supportedVersion };
@@ -0,0 +1,427 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const { ENGINE_IDS, ENGINE_CAPABILITIES, supportedVersion } = require('./engines');
5
+ const { verifyRelease, sha256, fail, options, HarnessError } = require('./release');
6
+ const { fingerprint } = require('./skill-ownership');
7
+ const history = require('./skill-history.json');
8
+ const { MARKER, marketplaceName, projectionPath, inspectProjection } = require('./codex-projection');
9
+
10
+ const SKILLS = ['devlyn-ideate', 'devlyn-engines'];
11
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ const textValue = (value) => typeof value === 'string' && !value.includes('\0');
13
+ const strings = (value) => Array.isArray(value) && value.every(textValue);
14
+ const reason = (code, params = {}) => ({ code, params });
15
+ const channel = (state = 'disabled', mechanism = null, reasons = []) => ({ state, mechanism, reasons });
16
+ const joinText = (...parts) => parts.filter((part) => part !== null && part !== undefined && part !== '').join('\n\n');
17
+
18
+ // TOML basic strings share JSON escaping except JSON's optional slash escape.
19
+ // Also escape DEL, forbidden in TOML strings. No shell quoting is involved.
20
+ function toml(value) {
21
+ if (typeof value === 'string') return JSON.stringify(value).replace(/\x7f/g, '\\u007f');
22
+ if (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) return String(value);
23
+ if (Array.isArray(value)) return `[${value.map(toml).join(',')}]`;
24
+ if (object(value)) return `{${Object.entries(value).map(([key, item]) => `${toml(key)}=${toml(item)}`).join(',')}}`;
25
+ fail('config-unreadable', { field: 'TOML override', detail: 'unsupported value' }, 2);
26
+ }
27
+
28
+ function validateContext(context) {
29
+ const allowed = ['schemaVersion', 'engine', 'version', 'launchMode', 'cwd', 'runDir', 'projectChoice',
30
+ 'committedBlockApplies', 'initialPrompt', 'accountRef', 'accountHome', 'effectiveConfig', 'skillRoots', 'recordedRelease'];
31
+ if (!object(context) || context.schemaVersion !== 1 || Object.keys(context).some((key) => !allowed.includes(key))
32
+ || !ENGINE_IDS.includes(context.engine) || !(context.version === null || textValue(context.version))
33
+ || !['fresh', 'resume', 'fork'].includes(context.launchMode)
34
+ || !['default', 'devlyn', 'none'].includes(context.projectChoice)
35
+ || typeof context.committedBlockApplies !== 'boolean'
36
+ || ![context.cwd, context.runDir].every((p) => textValue(p) && path.isAbsolute(p))
37
+ || !(context.initialPrompt === null || (object(context.initialPrompt) && textValue(context.initialPrompt.text)
38
+ && ['argv', 'stdin'].includes(context.initialPrompt.transport)
39
+ && Object.keys(context.initialPrompt).every((k) => ['text', 'transport'].includes(k))))
40
+ || (context.accountRef !== undefined && context.accountRef !== null && !textValue(context.accountRef))
41
+ || (context.accountHome !== undefined && (!textValue(context.accountHome) || !path.isAbsolute(context.accountHome)))
42
+ || (context.skillRoots !== undefined && (!strings(context.skillRoots) || !context.skillRoots.every(path.isAbsolute)))) {
43
+ fail('invalid-arguments', { argument: 'activation context' }, 2);
44
+ }
45
+ if (context.recordedRelease !== undefined && context.recordedRelease !== null) {
46
+ const ref = context.recordedRelease;
47
+ if (!object(ref) || ref.package !== 'devlyn-cli' || !textValue(ref.version)
48
+ || !/^[a-f0-9]{64}$/.test(ref.digest) || !textValue(ref.root) || !path.isAbsolute(ref.root)
49
+ || Object.keys(ref).some((key) => !['package', 'version', 'digest', 'root'].includes(key))) {
50
+ fail('invalid-arguments', { argument: 'recordedRelease' }, 2);
51
+ }
52
+ }
53
+ return context;
54
+ }
55
+
56
+ function readContext(file) {
57
+ if (file !== '-' && (!textValue(file) || !path.isAbsolute(file))) fail('invalid-arguments', { argument: 'context-file' }, 2);
58
+ try {
59
+ return validateContext(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(file === '-' ? 0 : file))));
60
+ } catch (error) {
61
+ if (error instanceof HarnessError) throw error;
62
+ fail('config-unreadable', { field: 'context-file', detail: error.message }, 2);
63
+ }
64
+ }
65
+
66
+ function statusFor(context) {
67
+ const supported = ENGINE_CAPABILITIES[context.engine].executor;
68
+ return {
69
+ engine: context.engine, version: context.version, accountRef: context.accountRef ?? null,
70
+ overall: 'disabled', principles: channel(), skills: channel(), hide: channel(), invocation: null,
71
+ executor: { supported, reasons: supported ? [] : [reason('executor-unqualified', { engine: context.engine })] },
72
+ inheritedDevlyn: [],
73
+ };
74
+ }
75
+
76
+ function finish(plan) {
77
+ const states = [plan.channels.principles.state, plan.channels.skills.state];
78
+ plan.channels.overall = states.every((s) => s === 'disabled') ? 'disabled'
79
+ : states.every((s) => s === 'configured') ? 'configured'
80
+ : states.every((s) => s === 'unavailable') ? 'unavailable' : 'partial';
81
+ if (plan.channels.overall === 'configured' && plan.channels.hide.state === 'partial') plan.channels.overall = 'partial';
82
+ return plan;
83
+ }
84
+
85
+ // The app supplies only effective values used by this recipe. Null means the
86
+ // model-free config read failed; {} means it succeeded with no values set.
87
+ function effectiveConfig(context) {
88
+ const config = context.effectiveConfig;
89
+ const keys = {
90
+ claude: ['appendSystemPrompt', 'pluginDirs'], codex: ['developer_instructions', 'skills', 'marketplaces', 'plugins'],
91
+ grok: ['rules'], omp: ['appendSystemPrompt', 'skills'], antigravity: [], cursor: [],
92
+ opencode: ['inlineConfig'], kimi: ['ownerAgent'], copilot: ['skillsDirs'], qwen: ['appendSystemPrompt'],
93
+ }[context.engine];
94
+ if (!object(config) || Object.keys(config).some((key) => !keys.includes(key))) return null;
95
+ for (const field of ['appendSystemPrompt', 'developer_instructions', 'rules', 'inlineConfig', 'skillsDirs']) {
96
+ if (config[field] !== undefined && config[field] !== null && !textValue(config[field])) return null;
97
+ }
98
+ if (context.engine === 'kimi' && typeof config.ownerAgent !== 'boolean') return null;
99
+ if (config.pluginDirs !== undefined && (!strings(config.pluginDirs) || !config.pluginDirs.every(path.isAbsolute))) return null;
100
+ if (context.engine === 'codex') {
101
+ for (const key of ['marketplaces', 'plugins', 'skills']) if (config[key] != null && !object(config[key])) return null;
102
+ if (config.skills?.config !== undefined && (!Array.isArray(config.skills.config)
103
+ || !config.skills.config.every((entry) => object(entry) && textValue(entry.path) && path.isAbsolute(entry.path)
104
+ && typeof entry.enabled === 'boolean' && Object.keys(entry).every((key) => ['path', 'enabled'].includes(key))))) return null;
105
+ for (const entry of Object.values(config.plugins || {})) {
106
+ if (!object(entry) || typeof entry.enabled !== 'boolean') return null;
107
+ }
108
+ for (const entry of Object.values(config.marketplaces || {})) {
109
+ if (!object(entry) || !textValue(entry.source_type) || !textValue(entry.source)) return null;
110
+ }
111
+ }
112
+ if (context.engine === 'omp') {
113
+ if (config.skills != null && !object(config.skills)) return null;
114
+ for (const key of ['customDirectories', 'ignoredSkills']) {
115
+ if (config.skills?.[key] !== undefined && !strings(config.skills[key])) return null;
116
+ }
117
+ if (config.skills?.enableSkillCommands !== undefined && typeof config.skills.enableSkillCommands !== 'boolean') return null;
118
+ }
119
+ return config;
120
+ }
121
+
122
+ function discoveryRoots(context, env, config) {
123
+ const home = env.HOME || env.USERPROFILE || os.homedir();
124
+ const roots = [...(context.skillRoots || [])];
125
+ const add = (base, ...parts) => { if (base) roots.push(path.resolve(base, ...parts)); };
126
+ const engine = context.engine;
127
+ const native = {
128
+ claude: [env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), '.claude'],
129
+ codex: [context.accountHome || env.CODEX_HOME || path.join(home, '.codex'), '.agents'],
130
+ grok: [env.GROK_HOME || path.join(home, '.grok'), '.grok'],
131
+ omp: [env.PI_CODING_AGENT_DIR || path.join(home, '.omp/agent'), '.omp'],
132
+ antigravity: [path.join(home, '.gemini/antigravity-cli'), '.agents'],
133
+ cursor: [null, null], opencode: [path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode'), '.opencode'],
134
+ kimi: [env.KIMI_CODE_HOME || path.join(home, '.kimi-code'), '.kimi-code'],
135
+ copilot: [env.COPILOT_HOME || path.join(home, '.copilot'), '.github'],
136
+ qwen: [env.QWEN_HOME || path.join(home, '.qwen'), '.qwen'],
137
+ }[engine];
138
+ if (engine !== 'codex') add(native[0], 'skills');
139
+ const copilotDefaultHome = !env.COPILOT_HOME || path.resolve(env.COPILOT_HOME) === path.join(home, '.copilot');
140
+ if (!['claude', 'cursor'].includes(engine) && (engine !== 'copilot' || copilotDefaultHome)) add(home, '.agents/skills');
141
+ if (['omp', 'opencode', 'grok'].includes(engine)) add(home, '.claude/skills');
142
+ if (engine === 'omp') {
143
+ add(home, '.codex/skills'); add(home, '.agent/skills');
144
+ for (const dir of config?.skills?.customDirectories || []) add(context.cwd, dir);
145
+ }
146
+ if (engine === 'codex' && process.platform !== 'win32') add('/etc/codex', 'skills');
147
+ if (engine === 'opencode') add(env.OPENCODE_CONFIG_DIR, 'skills');
148
+ if (engine === 'copilot') for (const dir of (config?.skillsDirs ?? env.COPILOT_SKILLS_DIRS ?? '').split(',').filter(Boolean)) add(context.cwd, dir);
149
+ for (let dir = path.resolve(context.cwd); ; dir = path.dirname(dir)) {
150
+ if (native[1]) add(dir, native[1], 'skills');
151
+ if (!['claude', 'cursor'].includes(engine)) add(dir, '.agents/skills');
152
+ if (['grok', 'omp', 'opencode', 'copilot'].includes(engine)) add(dir, '.claude/skills');
153
+ if (['omp', 'antigravity'].includes(engine)) add(dir, '.agent/skills');
154
+ if (fs.existsSync(path.join(dir, '.git')) || dir === path.dirname(dir)) break;
155
+ }
156
+ return [...new Set(roots)];
157
+ }
158
+
159
+ function inspectSkills(context, release, env, config) {
160
+ const found = [], errors = [], hashes = new Map();
161
+ for (const root of discoveryRoots(context, env, config)) {
162
+ for (const name of SKILLS) {
163
+ const dir = path.join(root, name);
164
+ try {
165
+ if (!fs.lstatSync(dir, { throwIfNoEntry: false })) continue;
166
+ const hash = fingerprint(dir);
167
+ if (!hashes.has(name)) hashes.set(name, fingerprint(path.join(release.root, 'config/skills', name)));
168
+ found.push({ name, path: path.join(dir, 'SKILL.md'), owned: hash === hashes.get(name) || (history.skills[name] || []).includes(hash) });
169
+ } catch (error) {
170
+ // An unreadable/linked candidate can collide, but never authorizes hide.
171
+ found.push({ name, path: path.join(dir, 'SKILL.md'), owned: false });
172
+ errors.push(reason('config-unreadable', { path: dir, detail: error.message }));
173
+ }
174
+ }
175
+ }
176
+ return { found, errors };
177
+ }
178
+
179
+ function skillCatalog(release, paths = path) {
180
+ const root = paths.join(release.root, 'config', 'skills');
181
+ return ['Devlyn session skill catalog (read the absolute file to invoke):',
182
+ ...SKILLS.map((name) => `${name}: ${paths.join(root, name, 'SKILL.md')}`),
183
+ `This session's _shared folder is ${paths.join(root, '_shared')}.`,
184
+ 'Use that absolute folder wherever the Devlyn defaults name _shared/, including the Quick Start references to .claude/skills/ or .agents/skills/.',
185
+ ].join('\n');
186
+ }
187
+
188
+ function codexRegistrations(config, accountHome) {
189
+ const owned = [], errors = [];
190
+ for (const [key, plugin] of Object.entries(config.plugins || {})) {
191
+ if (!plugin.enabled || !key.startsWith('devlyn@')) continue;
192
+ const market = key.slice('devlyn@'.length), source = config.marketplaces?.[market];
193
+ if (source?.source_type !== 'local' || !path.isAbsolute(source.source)) continue;
194
+ const root = path.resolve(source.source, '../..');
195
+ if (path.join(root, 'native/codex') !== source.source) continue;
196
+ try {
197
+ // Name and marketplace source alone never prove ownership. Require the
198
+ // resolver's marker, release verification and exact projection payload.
199
+ const manifest = path.join(root, 'release.json');
200
+ if (!fs.lstatSync(manifest, { throwIfNoEntry: false })) continue;
201
+ const release = verifyRelease(root).release;
202
+ if (marketplaceName(release) !== market) continue;
203
+ if (!fs.lstatSync(path.join(projectionPath(release, accountHome), MARKER), { throwIfNoEntry: false })) continue;
204
+ inspectProjection(release, accountHome);
205
+ owned.push(key);
206
+ } catch (error) {
207
+ errors.push(reason('config-unreadable', { field: 'plugins', name: key, detail: error.message }));
208
+ }
209
+ }
210
+ return { owned, errors };
211
+ }
212
+
213
+ function planActivation(releaseRoot, input, env = process.env) {
214
+ const context = validateContext(input);
215
+ const status = statusFor(context);
216
+ const plan = { schemaVersion: 1, release: null, engineArgs: [], envPatch: {}, prompt: null,
217
+ files: [], machineSync: null, channels: status };
218
+ const continuation = context.launchMode !== 'fresh';
219
+ if (continuation && context.recordedRelease === undefined) {
220
+ status.principles = channel('partial', null, [reason('principles-unavailable', { launchMode: context.launchMode, detail: 'recorded-binding-missing' })]);
221
+ status.skills = channel('partial', null, [reason('skills-unavailable', { launchMode: context.launchMode, detail: 'recorded-binding-missing' })]);
222
+ return finish(plan);
223
+ }
224
+ const off = continuation ? context.recordedRelease === null : context.projectChoice === 'none';
225
+ const release = verifyRelease(continuation && !off ? context.recordedRelease.root : releaseRoot).release;
226
+ if (continuation && !off && (release.digest !== context.recordedRelease.digest || release.version !== context.recordedRelease.version)) {
227
+ fail('release-version-conflict', { version: context.recordedRelease.version }, 3);
228
+ }
229
+ const config = effectiveConfig(context);
230
+ const { found, errors } = inspectSkills(context, release, env, config);
231
+ if (errors.length) status.hide = channel('partial', null, errors);
232
+ status.inheritedDevlyn = found.filter((entry) => entry.owned).map((entry) => entry.path);
233
+ const supported = supportedVersion(context.engine, context.version);
234
+ const c = (key, value) => {
235
+ const encoded = `${key}=${toml(value)}`;
236
+ const previous = plan.engineArgs.findIndex((arg) => arg.startsWith(`${key}=`));
237
+ if (previous < 0) plan.engineArgs.push('-c', encoded);
238
+ else plan.engineArgs[previous] = encoded;
239
+ };
240
+ const accountHome = context.accountHome || env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.codex');
241
+ let pluginOverrides = config?.plugins || {};
242
+ if (context.engine === 'codex' && config && supported) {
243
+ if (!path.isAbsolute(accountHome)) fail('config-unreadable', { field: 'CODEX_HOME' }, 2);
244
+ if (context.accountHome && env.CODEX_HOME && fs.realpathSync(context.accountHome) !== fs.realpathSync(env.CODEX_HOME)) {
245
+ fail('config-unreadable', { field: 'accountHome', detail: 'does not match finalized CODEX_HOME' }, 2);
246
+ }
247
+ const registrations = codexRegistrations(config, accountHome);
248
+ status.inheritedDevlyn.push(...registrations.owned);
249
+ pluginOverrides = { ...config.plugins };
250
+ for (const key of registrations.owned) pluginOverrides[key] = { ...pluginOverrides[key], enabled: false };
251
+ if (registrations.owned.length) {
252
+ c('plugins', pluginOverrides);
253
+ status.hide = channel('configured', 'plugins');
254
+ }
255
+ const hideErrors = [...errors, ...registrations.errors];
256
+ if (hideErrors.length) status.hide = channel('partial', null, hideErrors);
257
+ const owned = new Set(found.filter((entry) => entry.owned).map((entry) => entry.path));
258
+ if (owned.size) {
259
+ const entries = (config.skills?.config || []).map((entry) => ({ ...entry, ...(owned.has(entry.path) ? { enabled: false } : {}) }));
260
+ for (const file of owned) if (!entries.some((entry) => entry.path === file)) entries.push({ path: file, enabled: false });
261
+ c('skills.config', entries);
262
+ status.hide = channel(hideErrors.length ? 'partial' : 'configured', 'skills.config', hideErrors);
263
+ }
264
+ }
265
+ if (status.inheritedDevlyn.length && status.hide.state === 'disabled') {
266
+ status.hide = channel('partial', null, [reason('skills-unavailable', { channel: 'selective-hide' })]);
267
+ }
268
+ if (off) return finish(plan);
269
+ plan.release = release;
270
+ if (!supported) {
271
+ const reasons = [reason('engine-version-unsupported', { minimum: ENGINE_CAPABILITIES[context.engine].minimum,
272
+ generation: ENGINE_CAPABILITIES[context.engine].generation })];
273
+ status.principles = channel('unavailable', null, reasons);
274
+ status.skills = channel('unavailable', null, reasons);
275
+ return finish(plan);
276
+ }
277
+ if (!config) {
278
+ const reasons = [reason('config-unreadable', { field: 'effectiveConfig' })];
279
+ status.principles = channel('partial', null, reasons);
280
+ status.skills = channel('partial', null, reasons);
281
+ return finish(plan);
282
+ }
283
+ const defaults = context.committedBlockApplies ? '' : fs.readFileSync(path.join(release.root, 'config/skills/_shared/runtime-principles.md'), 'utf8');
284
+ const catalog = skillCatalog(release);
285
+ const instructions = joinText(defaults, catalog);
286
+ const file = (relativePath, utf8) => {
287
+ let parent = path.resolve(context.runDir);
288
+ const missing = [];
289
+ while (!fs.existsSync(parent)) { missing.unshift(path.basename(parent)); parent = path.dirname(parent); }
290
+ const physical = path.join(fs.realpathSync(parent), ...missing);
291
+ const relative = path.relative(release.root, physical);
292
+ if (!relative || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
293
+ fail('invalid-arguments', { argument: 'runDir must be outside the release' }, 2);
294
+ }
295
+ plan.files.push({ relativePath, utf8, sha256: sha256(utf8) });
296
+ return path.join(context.runDir, relativePath);
297
+ };
298
+ const principles = (mechanism) => { status.principles = channel('configured', context.committedBlockApplies ? 'native-project' : mechanism); };
299
+ const fallback = (reasons = []) => {
300
+ status.skills = channel('partial', 'absolute-catalog', [...reasons, reason('skills-catalog')]);
301
+ status.invocation = { kind: 'read-file', text: `Read ${path.join(release.root, 'config/skills/devlyn-ideate/SKILL.md')}` };
302
+ };
303
+ const native = (mechanism, kind, text) => { status.skills = channel('configured', mechanism); status.invocation = { kind, text }; };
304
+ const collisions = found.map((entry) => reason('name-collision', { name: entry.name, path: entry.path }));
305
+ switch (context.engine) {
306
+ case 'claude': {
307
+ const pluginDir = path.join(release.root, 'native/claude');
308
+ const conflicts = [];
309
+ let alreadySelected = false;
310
+ for (const dir of config.pluginDirs || []) {
311
+ try {
312
+ if (fs.realpathSync(dir) === pluginDir) { alreadySelected = true; continue; }
313
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, '.claude-plugin/plugin.json'), 'utf8'));
314
+ if (!object(manifest) || !textValue(manifest.name)) conflicts.push(reason('config-unreadable', { path: dir }));
315
+ else if (manifest.name === 'devlyn') conflicts.push(reason('name-collision', { name: 'devlyn', path: dir }));
316
+ } catch (error) {
317
+ conflicts.push(reason('config-unreadable', { path: dir, detail: error.message }));
318
+ }
319
+ }
320
+ if (!conflicts.length && !alreadySelected) plan.engineArgs.push('--plugin-dir', pluginDir);
321
+ plan.engineArgs.push('--append-system-prompt-file',
322
+ file('devlyn-instructions.md', joinText(config.appendSystemPrompt, instructions)));
323
+ principles('append-system-prompt-file');
324
+ if (conflicts.length) fallback(conflicts);
325
+ else native('plugin-dir', 'slash', '/devlyn:devlyn-ideate');
326
+ break;
327
+ }
328
+ case 'codex': {
329
+ const market = marketplaceName(release), key = `devlyn@${market}`;
330
+ const source = path.join(release.root, 'native/codex');
331
+ const existing = config.marketplaces?.[market];
332
+ let collision = (existing && (existing.source_type !== 'local' || existing.source !== source))
333
+ || (!existing && config.plugins?.[key] !== undefined);
334
+ try { inspectProjection(release, accountHome); }
335
+ catch (error) {
336
+ if (!(error instanceof HarnessError) || error.reason.code !== 'name-collision') throw error;
337
+ collision = true;
338
+ }
339
+ if (collision) fallback([reason('name-collision', { name: key, path: projectionPath(release, accountHome) })]);
340
+ else {
341
+ c('marketplaces', { ...config.marketplaces, [market]: { ...existing, source_type: 'local', source } });
342
+ c('plugins', { ...pluginOverrides, [key]: { ...config.plugins?.[key], enabled: true } });
343
+ plan.machineSync = { engine: 'codex', accountHome };
344
+ native('session-plugin', 'dollar', '$devlyn:devlyn-ideate');
345
+ }
346
+ c('developer_instructions', joinText(config.developer_instructions, instructions));
347
+ principles('developer_instructions');
348
+ break;
349
+ }
350
+ case 'omp': {
351
+ const skills = { ...config.skills,
352
+ customDirectories: [...new Set([...(config.skills?.customDirectories || []), path.join(release.root, 'config/skills')])] };
353
+ plan.engineArgs.push('--config', file('devlyn-omp.json', JSON.stringify({ skills }, null, 2) + '\n'),
354
+ '--append-system-prompt', joinText(config.appendSystemPrompt, instructions));
355
+ principles('append-system-prompt');
356
+ if (collisions.length || errors.length || skills.enableSkillCommands === false || skills.ignoredSkills?.length) fallback([...collisions, ...errors]);
357
+ else native('skills.customDirectories', 'slash', '/skill:devlyn-ideate');
358
+ break;
359
+ }
360
+ case 'grok':
361
+ plan.engineArgs.push('--rules', joinText(config.rules, instructions));
362
+ principles('rules'); fallback();
363
+ break;
364
+ case 'qwen':
365
+ plan.engineArgs.push('--append-system-prompt', joinText(config.appendSystemPrompt, instructions));
366
+ principles('append-system-prompt'); fallback();
367
+ break;
368
+ case 'opencode': {
369
+ let inline;
370
+ try { inline = config.inlineConfig == null ? {} : JSON.parse(config.inlineConfig); }
371
+ catch (error) { if (!(error instanceof SyntaxError)) throw error; inline = null; }
372
+ if (!object(inline) || (inline.instructions !== undefined && !strings(inline.instructions))) {
373
+ status.principles = channel('partial', null, [reason('config-unreadable', { field: 'inlineConfig.instructions' })]);
374
+ status.skills = channel('partial', null, [reason('config-unreadable', { field: 'inlineConfig.instructions' })]);
375
+ break;
376
+ }
377
+ plan.envPatch.OPENCODE_CONFIG_CONTENT = JSON.stringify({ ...inline,
378
+ instructions: [...(inline.instructions || []), file('devlyn-instructions.md', instructions)] });
379
+ principles('OPENCODE_CONFIG_CONTENT.instructions'); fallback();
380
+ break;
381
+ }
382
+ case 'copilot': {
383
+ status.principles = context.committedBlockApplies ? channel('configured', 'native-project')
384
+ : channel('unavailable', null, [reason('principles-unavailable')]);
385
+ const root = path.join(release.root, 'config/skills');
386
+ if (root.includes(',') || collisions.length || errors.length) {
387
+ status.skills = channel('partial', null, root.includes(',') ? [reason('skills-unavailable', { detail: 'comma-in-path' })] : [...collisions, ...errors]);
388
+ status.invocation = { kind: 'read-file', text: `Read ${path.join(root, 'devlyn-ideate/SKILL.md')}` };
389
+ } else {
390
+ const dirs = config.skillsDirs ?? env.COPILOT_SKILLS_DIRS ?? '';
391
+ plan.envPatch.COPILOT_SKILLS_DIRS = dirs.split(',').includes(root) ? dirs : (dirs ? `${dirs},${root}` : root);
392
+ native('COPILOT_SKILLS_DIRS', 'slash', '/devlyn-ideate');
393
+ }
394
+ break;
395
+ }
396
+ case 'kimi':
397
+ if (continuation || config.ownerAgent) {
398
+ status.principles = channel('partial', null, [reason('principles-unavailable', { launchMode: context.launchMode, ownerAgent: config.ownerAgent })]);
399
+ status.skills = channel('partial', null, [reason('skills-unavailable', { launchMode: context.launchMode, ownerAgent: config.ownerAgent })]);
400
+ } else {
401
+ plan.engineArgs.push('--agent-file', file('devlyn-kimi.md', joinText('${base_prompt}', instructions)));
402
+ principles('agent-file'); fallback();
403
+ }
404
+ break;
405
+ case 'antigravity':
406
+ case 'cursor':
407
+ status.principles = channel('partial', context.initialPrompt ? 'initial-task' : null, [reason('principles-task-only')]);
408
+ if (context.initialPrompt) {
409
+ plan.prompt = { ...context.initialPrompt, text: joinText(instructions, context.initialPrompt.text) };
410
+ fallback();
411
+ } else status.skills = channel('partial', null, [reason('skills-unavailable', { detail: 'no-initial-task' })]);
412
+ break;
413
+ }
414
+ if (context.committedBlockApplies) status.principles = channel('configured', 'native-project');
415
+ if (plan.engineArgs.length || Object.keys(plan.envPatch).length || plan.prompt) plan.envPatch.DEVLYN_HARNESS_ROOT = release.root;
416
+ return finish(plan);
417
+ }
418
+
419
+ function activationCommand(args) {
420
+ const opts = options(args, { plan: { type: 'boolean' }, 'release-root': { type: 'string' }, 'context-file': { type: 'string' } });
421
+ if (!opts.plan || !opts['release-root'] || !path.isAbsolute(opts['release-root']) || !opts['context-file']) {
422
+ fail('invalid-arguments', { argument: 'plan, absolute release-root and context-file required' }, 2);
423
+ }
424
+ return planActivation(opts['release-root'], readContext(opts['context-file']));
425
+ }
426
+
427
+ module.exports = { planActivation, activationCommand, readContext, toml, skillCatalog };