@entrorove/skill 0.1.41 → 0.1.47

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/ide-install.mjs CHANGED
@@ -1,65 +1,233 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  import { ideDirectory, ideDigest, readIdeJson, writeIdeJson, ideError } from './ide-session.mjs';
5
5
  import { withStateLock } from './local-state-lock.mjs';
6
6
 
7
7
  const source = path.dirname(fileURLToPath(import.meta.url));
8
- const files = ['ide-hook.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs'];
9
- const events = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostCompact', 'Stop'];
8
+ const files = ['ide-hook.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs', 'local-stage-boundary.mjs'];
10
9
  const quote = text => `'${text.replace(/'/gu, "'\\''")}'`;
11
- function definition(node, file) {
10
+
11
+ // One shared host-agnostic hook binary; every host is described by its own
12
+ // adapter (config location, event names, entry shape). Codex and Claude Code
13
+ // are the verified adapters; adding a host that exposes the same callback
14
+ // contract is data, not a new code path.
15
+ export const ideAdapters = [
16
+ { id: 'codex', host: 'Codex', marker: 'CODEX_THREAD_ID', directory: '.codex', config: 'hooks.json',
17
+ record: 'installation.json', events: ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostCompact', 'Stop'],
18
+ entry: hook => ({ hooks: [hook] }) },
19
+ { id: 'claude', host: 'Claude Code', marker: 'CLAUDE_CODE_ENTRYPOINT', directory: '.claude', config: 'settings.json',
20
+ record: 'installation-claude.json', events: ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PreCompact', 'Stop'],
21
+ entry: hook => ({ matcher: '*', hooks: [hook] }) },
22
+ ];
23
+ export const verifiedIdeHosts = ideAdapters.map(adapter => adapter.host);
24
+
25
+ // Universal entry: any host can be wired by declaring an adapter module
26
+ // (ENTROROVE_IDE_ADAPTER=/absolute/module.mjs) whose default export describes
27
+ // where its guard config lives and how its entries are shaped. Verified hosts
28
+ // stay built in; a declared adapter is treated as verified for its own host.
29
+ export async function resolveAdapters({ environment = process.env, extra = [] } = {}) {
30
+ const declared = String(environment.ENTROROVE_IDE_ADAPTER ?? '').split(',').map(value => value.trim()).filter(Boolean);
31
+ const resolved = [...ideAdapters, ...extra];
32
+ for (const pathname of declared) {
33
+ if (!path.isAbsolute(pathname)) throw ideError('ENTROROVE_IDE_ADAPTER', 'A declared adapter module must use an absolute path');
34
+ const module = await import(pathToFileURL(pathname).href).catch(cause => { throw ideError('ENTROROVE_IDE_ADAPTER', `Declared adapter could not be loaded: ${cause.message}`); });
35
+ const adapter = module.default ?? module.adapter;
36
+ if (!adapter || typeof adapter !== 'object' || typeof adapter.host !== 'string' || !/^[\p{L}\p{N} .+_-]{2,48}$/u.test(adapter.host)
37
+ || typeof adapter.directory !== 'string' || !/^\.[a-z0-9-]{1,32}$/u.test(adapter.directory)
38
+ || typeof adapter.config !== 'string' || adapter.config.includes('/') || adapter.config.includes('..')
39
+ || !Array.isArray(adapter.events) || !adapter.events.includes('PreToolUse')
40
+ || !['hooks', 'matcher-hooks'].includes(adapter.entry)) {
41
+ throw ideError('ENTROROVE_IDE_ADAPTER', 'A declared adapter must describe its host, config directory and entry shape');
42
+ }
43
+ const id = adapter.id ?? adapter.host.toLowerCase().replace(/[^a-z0-9]+/gu, '-');
44
+ resolved.push({ id, host: adapter.host, declared: true, marker: adapter.marker ?? null, directory: adapter.directory, config: adapter.config,
45
+ record: `installation-${id}.json`, events: adapter.events, minimal: adapter.entry === 'matcher-hooks',
46
+ entry: adapter.entry === 'matcher-hooks' ? hook => ({ matcher: '*', hooks: [hook] }) : hook => ({ hooks: [hook] }) });
47
+ }
48
+ return resolved;
49
+ }
50
+
51
+ function definition(adapter, node, file) {
12
52
  const command = `${quote(node)} ${quote(file)}`;
13
- return { hooks: Object.fromEntries(events.map(event => [event, [{ hooks: [{ type: 'command', command,
53
+ const hooks = Object.fromEntries(adapter.events.map(event => [event, [adapter.entry({ type: 'command', command,
14
54
  timeout: 10, statusMessage: 'Entrorove · 检查任务',
15
- ...(['PostCompact','Stop'].includes(event) ? {} : {additionalContextLimit:1500}) }] }]])) };
55
+ ...(['PostCompact', 'PreCompact', 'Stop'].includes(event) ? {} : { additionalContextLimit: 1500 }) })]]));
56
+ return { hooks };
16
57
  }
17
- async function prepareConfigDirectory(home) {
18
- const directory = path.join(home, '.codex');
58
+ async function prepareConfigDirectory(home, adapter) {
59
+ const directory = path.join(home, adapter.directory);
19
60
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
20
61
  const stat = await fs.lstat(directory);
21
- if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32' && stat.uid !== process.getuid()) throw ideError('ENTROROVE_IDE_CONFIG', 'Codex config must be an owned real directory');
62
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32' && stat.uid !== process.getuid()) {
63
+ throw ideError('ENTROROVE_IDE_CONFIG', `${adapter.host} config must be an owned real directory`);
64
+ }
22
65
  return directory;
23
66
  }
24
- async function readHostConfig(file) {
25
- const raw = await fs.readFile(file, 'utf8').catch(cause => { if (cause.code === 'ENOENT') return null; throw cause; });
26
- if (raw === null) return null;
27
- try { return JSON.parse(raw); } catch { throw ideError('ENTROROVE_IDE_CONFIG', 'Existing Codex hooks config is not valid JSON; it was preserved'); }
28
- }
29
- function mergeHooks(existing, previous, current) {
67
+ function mergeHooks(existing, previous, current, adapter) {
30
68
  if (existing && (typeof existing !== 'object' || Array.isArray(existing) || existing.hooks && typeof existing.hooks !== 'object')) throw ideError('ENTROROVE_IDE_CONFIG', 'Existing hooks config is invalid; it was preserved');
31
69
  const next = { ...existing, hooks: { ...existing?.hooks } };
32
- for (const event of events) {
70
+ for (const event of new Set([...Object.keys(previous?.hooks ?? {}), ...adapter.events])) {
33
71
  const old = previous?.hooks?.[event]?.[0], entries = next.hooks[event] ?? [];
34
72
  if (!Array.isArray(entries)) throw ideError('ENTROROVE_IDE_CONFIG', 'Existing hook list is invalid; it was preserved');
35
73
  const ours = entries.filter(entry => old && JSON.stringify(entry) === JSON.stringify(old));
36
74
  if (previous && !ours.length && entries.some(entry => entry.hooks?.some(hook => hook.command === old?.hooks?.[0]?.command))) throw ideError('ENTROROVE_IDE_CONFIG_EDITED', 'A hand-edited Entrorove hook was preserved; review it before updating');
37
- next.hooks[event] = [...entries.filter(entry => !ours.includes(entry)), current.hooks[event][0]];
75
+ const retained = entries.filter(entry => !ours.includes(entry));
76
+ if (current.hooks[event]) next.hooks[event] = [...retained, current.hooks[event][0]];
77
+ else if (retained.length) next.hooks[event] = retained;
78
+ else delete next.hooks[event];
38
79
  }
39
80
  return next;
40
81
  }
41
- export async function installIdeHooks({ home, version, packageDirectory = source, node = process.execPath }) {
42
- const directory = await ideDirectory(home, true), configDirectory = await prepareConfigDirectory(home);
43
- const hooksPath = path.join(configDirectory, 'hooks.json'), recordPath = path.join(directory, 'installation.json');
44
- return withStateLock(hooksPath, async () => {
45
- const existing = await readHostConfig(hooksPath), previous = await readIdeJson(recordPath);
46
- const current = definition(node, path.join(directory, 'ide-hook.mjs'));
47
- const config = mergeHooks(existing, previous?.definition, current);
48
- const contents = await Promise.all(files.map(async name => [name, await fs.readFile(path.join(source, name))]));
49
- for (const [name, bytes] of contents) {
50
- const target = path.join(directory, name), stat = await fs.lstat(target).catch(cause => { if (cause.code !== 'ENOENT') throw cause; return null; });
51
- if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)) throw ideError('ENTROROVE_IDE_STORE', 'Managed hook target must be a private regular file');
52
- if (stat && previous?.files?.[name] && ideDigest((await fs.readFile(target)).toString('utf8')) !== previous.files[name]) throw ideError('ENTROROVE_IDE_HOOK_EDITED', 'Hand-edited hook code was preserved');
53
- await fs.writeFile(target, bytes, { mode: 0o600 });
54
- }
55
- const cli = path.join(packageDirectory, 'cli.mjs');
56
- await writeIdeJson(path.join(directory, 'runtime.json'), { schemaVersion: 'entrorove.ide-runtime/1.0', version, node, cli,
57
- apiOrigin: 'https://entrorove.com',
58
- cliDigest: ideDigest((await fs.readFile(cli)).toString('utf8')) });
59
- if (JSON.stringify(existing) !== JSON.stringify(config)) await writeIdeJson(hooksPath, config);
60
- await writeIdeJson(recordPath, { schemaVersion: 'entrorove.ide-installation/1.0', version, definition: current,
61
- files: Object.fromEntries(contents.map(([name, bytes]) => [name, ideDigest(bytes.toString('utf8'))])) });
62
- return { host: 'Codex', configured: true, enforcement: 'review-required', hooksPath,
63
- message: '已安装持续执行检查。请在 Codex /hooks 审查并信任 Entrorove;实际收到 PreToolUse 回调后才算拦截已生效。' };
82
+ // A host's own config is not ours to police: read it permissively (its file
83
+ // mode belongs to that host), while our own state files stay strictly private.
84
+ async function readConfigJson(file, previous) {
85
+ const raw = await fs.readFile(file, 'utf8').catch(cause => { if (cause.code === 'ENOENT') return null; throw cause; });
86
+ if (raw === null) return { value: null, recoveredEmpty: false };
87
+ if (!raw.trim() && previous?.schemaVersion === 'entrorove.ide-installation/1.0' && previous.definition?.hooks?.PreToolUse) {
88
+ return { value: previous.definition, recoveredEmpty: true };
89
+ }
90
+ try { return { value: JSON.parse(raw), recoveredEmpty: false }; }
91
+ catch { throw ideError('ENTROROVE_IDE_CONFIG', 'Existing host config is not valid JSON; it was preserved'); }
92
+ }
93
+ async function directoryExists(directory) {
94
+ const stat = await fs.lstat(directory).catch(cause => { if (cause.code === 'ENOENT') return null; throw cause; });
95
+ return Boolean(stat?.isDirectory());
96
+ }
97
+ export async function activeIdeAdapters({ home, environment = process.env } = {}) {
98
+ const directory = await ideDirectory(home).catch(cause => {
99
+ if (cause.code === 'ENOENT') return null;
100
+ throw cause;
64
101
  });
102
+ if (!directory) return [];
103
+ const active = [];
104
+ for (const adapter of await resolveAdapters({ environment })) {
105
+ const record = await readIdeJson(path.join(directory, adapter.record));
106
+ if (record?.schemaVersion !== 'entrorove.ide-installation/1.0'
107
+ || record.host && record.host !== adapter.host) continue;
108
+ const configPath = path.join(home, adapter.directory, adapter.config);
109
+ const stat = await fs.lstat(configPath).catch(cause => {
110
+ if (cause.code === 'ENOENT') return null;
111
+ throw cause;
112
+ });
113
+ if (!stat?.isFile() || stat.isSymbolicLink()) continue;
114
+ let config;
115
+ try { config = JSON.parse(await fs.readFile(configPath, 'utf8')); } catch { continue; }
116
+ const entry = record.definition?.hooks?.PreToolUse?.[0];
117
+ const entries = config?.hooks?.PreToolUse;
118
+ if (entry && Array.isArray(entries)
119
+ && entries.some(value => JSON.stringify(value) === JSON.stringify(entry))) active.push(adapter);
120
+ }
121
+ return active;
122
+ }
123
+ export async function installIdeHooks({ home, version, packageDirectory = source, node = process.execPath,
124
+ environment = process.env, adapters = null }) {
125
+ const directory = await ideDirectory(home, true);
126
+ // Hand-edited protection must consider every host's record, not just the
127
+ // primary one: a machine configured for another host still has our files.
128
+ const records = (await Promise.all([...new Set(['installation.json', ...ideAdapters.map(adapter => adapter.record)])]
129
+ .map(name => readIdeJson(path.join(directory, name))))).filter(Boolean);
130
+ const record = records[0] ?? null;
131
+ void record;
132
+ const previousDigest = name => records.map(item => item.files?.[name]).find(value => typeof value === 'string') ?? null;
133
+ const contents = await Promise.all(files.map(async name => [name, await fs.readFile(path.join(source, name))]));
134
+ for (const [name, bytes] of contents) {
135
+ const target = path.join(directory, name), stat = await fs.lstat(target).catch(cause => { if (cause.code !== 'ENOENT') throw cause; return null; });
136
+ if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)) throw ideError('ENTROROVE_IDE_STORE', 'Managed hook target must be a private regular file');
137
+ const expectedDigest = previousDigest(name);
138
+ if (stat && expectedDigest && ideDigest((await fs.readFile(target)).toString('utf8')) !== expectedDigest) throw ideError('ENTROROVE_IDE_HOOK_EDITED', 'Hand-edited hook code was preserved');
139
+ await fs.writeFile(target, bytes, { mode: 0o600 });
140
+ }
141
+ const cli = path.join(packageDirectory, 'cli.mjs');
142
+ await writeIdeJson(path.join(directory, 'runtime.json'), { schemaVersion: 'entrorove.ide-runtime/1.0', version, node, cli,
143
+ apiOrigin: 'https://entrorove.com', cliDigest: ideDigest((await fs.readFile(cli)).toString('utf8')) });
144
+ const installedFiles = Object.fromEntries(contents.map(([name, bytes]) => [name, ideDigest(bytes.toString('utf8'))]));
145
+ const targets = [];
146
+ for (const adapter of adapters ?? await resolveAdapters({ environment })) {
147
+ if (adapter.id === 'codex' || adapter.declared || environment[adapter.marker] !== undefined || await directoryExists(path.join(home, adapter.directory))) targets.push(adapter);
148
+ }
149
+ const installed = [];
150
+ for (const adapter of targets) {
151
+ const configDirectory = await prepareConfigDirectory(home, adapter);
152
+ const configPath = path.join(configDirectory, adapter.config), recordPath = path.join(directory, adapter.record);
153
+ installed.push(await withStateLock(configPath, async () => {
154
+ const previous = await readIdeJson(recordPath);
155
+ const { value: existing, recoveredEmpty } = await readConfigJson(configPath, previous);
156
+ const current = definition(adapter, node, path.join(directory, 'ide-hook.mjs'));
157
+ const config = mergeHooks(existing, previous?.definition, current, adapter);
158
+ if (recoveredEmpty || JSON.stringify(existing) !== JSON.stringify(config)) await writeIdeJson(configPath, config);
159
+ await writeIdeJson(recordPath, { schemaVersion: 'entrorove.ide-installation/1.0', version, host: adapter.host,
160
+ definition: current, files: installedFiles });
161
+ return { host: adapter.host, id: adapter.id, configured: true, enforcement: 'configured', hooksPath: configPath };
162
+ }));
163
+ }
164
+ return { host: installed[0]?.host ?? ideAdapters[0].host, hosts: installed.map(item => item.host), configured: true,
165
+ enforcement: 'configured', hooksPath: installed[0]?.hooksPath,
166
+ message: `已写入 Entrorove Hook 配置(${installed.map(item => item.host).join('、')});需由 IDE 信任并收到真实回调后才能确认生效。` };
167
+ }
168
+
169
+ export async function uninstallIdeHooks({ home }) {
170
+ const directory = await ideDirectory(home).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
171
+ if (!directory) return { removed: [], preserved: [] };
172
+ const removed = [], preserved = [], records = [];
173
+ const hookPath = path.join(directory, 'ide-hook.mjs');
174
+ for (const adapter of ideAdapters) {
175
+ const recordPath = path.join(directory, adapter.record), record = await readIdeJson(recordPath);
176
+ if (record && (record.schemaVersion !== 'entrorove.ide-installation/1.0' || record.host !== adapter.host)) {
177
+ throw ideError('ENTROROVE_IDE_STORE', `${adapter.host} installation record is invalid; it was preserved`);
178
+ }
179
+ if (record) records.push(record);
180
+ const configPath = path.join(home, adapter.directory, adapter.config);
181
+ await withStateLock(configPath, async () => {
182
+ const { value: existing } = await readConfigJson(configPath, record);
183
+ if (!existing) { if (record) await fs.rm(recordPath); return; }
184
+ if (!existing.hooks || typeof existing.hooks !== 'object' || Array.isArray(existing.hooks)) {
185
+ throw ideError('ENTROROVE_IDE_CONFIG', `${adapter.host} hook config is invalid; it was preserved`);
186
+ }
187
+ const commands = new Set(Object.values(record?.definition?.hooks ?? {}).flatMap(entries =>
188
+ entries.flatMap(entry => entry.hooks?.map(hook => hook.command) ?? [])).filter(command => typeof command === 'string'));
189
+ const hooks = {};
190
+ for (const [event, entries] of Object.entries(existing.hooks)) {
191
+ if (!Array.isArray(entries)) throw ideError('ENTROROVE_IDE_CONFIG', `${adapter.host} hook list is invalid; it was preserved`);
192
+ hooks[event] = entries.flatMap(entry => {
193
+ if (!Array.isArray(entry.hooks)) return [entry];
194
+ const retained = entry.hooks.filter(hook => !commands.has(hook.command)
195
+ && !hook.command?.endsWith(` ${quote(hookPath)}`));
196
+ return retained.length ? [{ ...entry, hooks: retained }] : [];
197
+ });
198
+ if (!hooks[event].length) delete hooks[event];
199
+ }
200
+ const next = { ...existing, hooks };
201
+ if (JSON.stringify(existing) !== JSON.stringify(next)) {
202
+ if (!Object.keys(hooks).length && Object.keys(next).length === 1) await fs.rm(configPath);
203
+ else await writeIdeJson(configPath, next);
204
+ removed.push(configPath);
205
+ }
206
+ if (record) await fs.rm(recordPath);
207
+ });
208
+ }
209
+ const digests = new Map();
210
+ for (const record of records) for (const [name, digest] of Object.entries(record.files ?? {})) {
211
+ if (!digests.has(name)) digests.set(name, new Set());
212
+ digests.get(name).add(digest);
213
+ }
214
+ for (const [name, known] of digests) {
215
+ if (!files.includes(name)) continue;
216
+ const target = path.join(directory, name), stat = await fs.lstat(target).catch(error => {
217
+ if (error.code === 'ENOENT') return null; throw error;
218
+ });
219
+ if (!stat) continue;
220
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1
221
+ || !known.has(ideDigest((await fs.readFile(target)).toString('utf8')))) { preserved.push(target); continue; }
222
+ await fs.rm(target); removed.push(target);
223
+ }
224
+ for (const name of files.filter(item => !digests.has(item))) {
225
+ const target = path.join(directory, name);
226
+ if (await fs.lstat(target).catch(error => { if (error.code === 'ENOENT') return null; throw error; })) preserved.push(target);
227
+ }
228
+ const runtimePath = path.join(directory, 'runtime.json'), runtime = await readIdeJson(runtimePath);
229
+ if (runtime?.schemaVersion === 'entrorove.ide-runtime/1.0' && runtime.cli?.endsWith('/cli.mjs')) {
230
+ await fs.rm(runtimePath); removed.push(runtimePath);
231
+ }
232
+ return { removed, preserved };
65
233
  }
package/ide-session.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import { constants, promises as fs } from 'node:fs';
2
+ import { pathToFileURL } from 'node:url';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { credentialDirectory, readCredential } from './local-credential.mjs';
6
7
  import { withStateLock } from './local-state-lock.mjs';
7
8
  import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
8
- import { breakGlassPath, breakGlassSchema, writeBreakGlass as writeOfflineBreakGlass } from './break-glass.mjs';
9
+ import { breakGlassPath, breakGlassSchema, breakGlassSessionDigest, writeBreakGlass as writeOfflineBreakGlass } from './break-glass.mjs';
9
10
 
10
11
  export const ideDigest = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
11
12
  export const ideError = (code, message) => Object.assign(new Error(message), { code });
@@ -34,10 +35,12 @@ export async function writeIdeJson(file, value) {
34
35
  try { await fs.rename(stage, file); } finally { await fs.rm(stage, { force: true }); }
35
36
  }
36
37
  export const writeBreakGlass = writeOfflineBreakGlass;
37
- export async function readBreakGlass(home = os.homedir()) {
38
- await ideDirectory(home, true);
38
+ export async function readBreakGlass(home = os.homedir(), sessionId) {
39
+ if (!sessionId) return null;
40
+ const directory = await ideDirectory(home, true);
39
41
  const value = await readIdeJson(breakGlassPath(home));
40
42
  if (!value || value.schemaVersion !== breakGlassSchema || value.mode !== 'management-only'
43
+ || value.sessionDigest !== breakGlassSessionDigest(sessionId)
41
44
  || typeof value.expiresAt !== 'string' || !Number.isFinite(Date.parse(value.expiresAt))
42
45
  || Date.parse(value.expiresAt) <= Date.now()) return null;
43
46
  return value;
@@ -47,8 +50,10 @@ export async function ideSession(event, action, home = os.homedir()) {
47
50
  const directory = await ideDirectory(home, true), file = path.join(directory, `session-${ideDigest(event.session_id)}.json`);
48
51
  return withStateLock(file, async () => {
49
52
  const root = await fs.realpath(event.cwd), prior = await readIdeJson(file);
50
- if (prior && (prior.schemaVersion !== 'entrorove.ide-session/1.0' || prior.sessionId !== event.session_id || prior.repositoryRoot !== root)) throw ideError('ENTROROVE_IDE_IDENTITY', 'A session cannot replace its original physical repository');
51
- const state = prior ?? { schemaVersion: 'entrorove.ide-session/1.0', sessionId: event.session_id, repositoryRoot: root, active: false, phase: 'entry', seen: [], blockStreak: 0 };
53
+ if (prior && (prior.schemaVersion !== 'entrorove.ide-session/1.0' || prior.sessionId !== event.session_id
54
+ || prior.repositoryRoot !== root && prior.active)) throw ideError('ENTROROVE_IDE_IDENTITY', 'An active task cannot replace its original physical repository');
55
+ const state = prior?.repositoryRoot === root ? prior
56
+ : { schemaVersion: 'entrorove.ide-session/1.0', sessionId: event.session_id, repositoryRoot: root, active: false, phase: 'entry', seen: [], blockStreak: 0 };
52
57
  state.stateFile = file;
53
58
  const result = await action(state);
54
59
  const { stateFile, ...persisted } = state;
@@ -79,20 +84,84 @@ export async function ideRuntime(home = os.homedir()) {
79
84
  return runtime;
80
85
  }
81
86
  export function ideContext(state) {
82
- return `Entrorove 已强制接管本任务。${state.taskNumber ? `云端任务 ${state.taskNumber}。` : '尚未获得云端任务编号,不得声称已进入云端。'}${state.flowId ? `继续原 flowId=${state.flowId},` : ''}${state.requestId ? `原 requestId=${state.requestId}。` : ''}当前阶段 ${state.phase}。必须读 SKILL.md 并执行返回的 nextStep;禁止在失败后替换执行器。源文件、数据及终端写入必须走原任务的受控入口。`;
87
+ const delivered = state.phase === 'delivered' && Number.isSafeInteger(state.deliveredStage)
88
+ ? `阶段 ${state.deliveredStage} 已按边界独立交付(终态,不等于失败或阻塞);续跑从下一个边界继续,不重新执行已交付阶段。` : '';
89
+ return `Entrorove 已强制接管本任务。${state.taskNumber ? `云端任务 ${state.taskNumber}。` : '尚未获得云端任务编号,不得声称已进入云端。'}${state.flowId ? `继续原 flowId=${state.flowId},` : ''}${state.requestId ? `原 requestId=${state.requestId}。` : ''}当前阶段 ${state.phase}。${delivered}必须读 SKILL.md 并执行返回的 nextStep;禁止在失败后替换执行器。源文件、数据及终端写入必须走原任务的受控入口。`;
90
+ }
91
+ // Verified hosts only: the guard is universal in shape (host label + session
92
+ // id), and each host proves itself through its own adapter callback.
93
+ export const verifiedIdeGuards = { 'Codex': 'CODEX_THREAD_ID', 'Claude Code': 'CLAUDE_SESSION_ID' };
94
+ export function ideGuardBinding(environment = process.env) {
95
+ const host = environment.ENTROROVE_IDE ?? (environment.CODEX_THREAD_ID ? 'Codex' : environment.CLAUDE_CODE_ENTRYPOINT ? 'Claude Code'
96
+ : environment.CURSOR_TRACE_ID ? 'Cursor' : null);
97
+ const sessionId = environment.ENTROROVE_SESSION ?? (host ? environment[verifiedIdeGuards[host] ?? ''] : undefined) ?? environment.CODEX_THREAD_ID;
98
+ return { host, sessionId: typeof sessionId === 'string' ? sessionId : null };
99
+ }
100
+ async function declaredHost(environment) {
101
+ const declared = String(environment.ENTROROVE_IDE_ADAPTER ?? '').split(',').map(value => value.trim()).filter(Boolean);
102
+ for (const pathname of declared) {
103
+ try {
104
+ const module = await import(pathToFileURL(pathname).href);
105
+ const host = (module.default ?? module.adapter)?.host;
106
+ if (typeof host === 'string') return host;
107
+ } catch { return null; }
108
+ }
109
+ return null;
83
110
  }
84
111
  export async function assertIdeExecution(args, input, { home = os.homedir(), environment = process.env } = {}) {
85
- const host = environment.ENTROROVE_IDE ?? (environment.CODEX_THREAD_ID ? 'Codex' : environment.CURSOR_TRACE_ID ? 'Cursor' : environment.CLAUDE_CODE_ENTRYPOINT ? 'Claude Code' : null);
112
+ const { host, sessionId } = ideGuardBinding(environment);
86
113
  if (!host || host === 'Entrorove CLI') return;
87
- if (host !== 'Codex' || !environment.CODEX_THREAD_ID) throw ideError('ENTROROVE_IDE_GUARD_UNSUPPORTED', 'This IDE has no verified tool guard adapter. Execution is blocked; never continue with another executor.');
88
- const directory = await ideDirectory(home), state = await readIdeJson(path.join(directory, `session-${ideDigest(environment.CODEX_THREAD_ID)}.json`));
114
+ const lifecycle = args[0] === 'local' ? args[1] : args[0] === 'invoke' ? input?.operation : null;
115
+ if (['flow-cancel', 'flow-abandon'].includes(lifecycle) && await readBreakGlass(home, sessionId)) {
116
+ const flowId = args[0] === 'invoke' ? input?.input?.flowId : input?.flowId;
117
+ const state = await readIdeJson(path.join(await ideDirectory(home), `session-${ideDigest(sessionId)}.json`));
118
+ if (state?.flowId && state.flowId !== flowId) throw ideError('ENTROROVE_IDE_RECOVERY_SCOPE', 'Recovery must settle the flow bound to this IDE session');
119
+ if (args[0] === 'local') {
120
+ const root = input?.repositoryRoot ?? args[2];
121
+ if (state?.repositoryRoot && root && await fs.realpath(root) !== state.repositoryRoot) {
122
+ throw ideError('ENTROROVE_IDE_RECOVERY_SCOPE', 'Recovery cannot switch the IDE session repository');
123
+ }
124
+ if (input?.home && await fs.realpath(input.home) !== await fs.realpath(home)) {
125
+ throw ideError('ENTROROVE_IDE_RECOVERY_SCOPE', 'Recovery cannot switch the IDE session home');
126
+ }
127
+ }
128
+ return;
129
+ }
130
+ const declared = verifiedIdeGuards[host] ? null : await declaredHost(environment);
131
+ if (!verifiedIdeGuards[host] && declared !== host || !sessionId) {
132
+ throw ideError('ENTROROVE_IDE_GUARD_UNSUPPORTED', `${host} has no verified tool guard adapter. Execution is blocked; never continue with another executor.`);
133
+ }
134
+ const directory = await ideDirectory(home), state = await readIdeJson(path.join(directory, `session-${ideDigest(sessionId)}.json`));
89
135
  const expected = ideDigest({ args, input });
90
136
  const approved = [state?.pendingCall, ...Object.values(state?.readCalls ?? {})].find(call => call?.inputDigest === expected);
91
- if (!state?.active || !approved || state.sessionId !== environment.CODEX_THREAD_ID
137
+ if (!state?.active || !approved || state.sessionId !== sessionId
92
138
  || Date.now() - Date.parse(state.observedAt) > 120_000) {
93
- throw ideError('ENTROROVE_IDE_GUARD_REQUIRED', 'Codex tool guard has not approved this exact call. Run install, review Entrorove in /hooks, and resume the original task.');
139
+ throw ideError('ENTROROVE_IDE_GUARD_REQUIRED', `${host} tool guard has not approved this exact call. Run install, accept the host hook, and resume the original task.`);
94
140
  }
95
141
  await bindIdeAccount(state, home);
96
142
  approvedResultContext = { home, sessionId: state.sessionId, repositoryRoot: state.repositoryRoot,
97
143
  accountDigest: state.accountDigest, toolUseId: approved.toolUseId, inputDigest: expected };
98
144
  }
145
+
146
+ export async function settleBreakGlassSession(args, input, result, { home = os.homedir(), environment = process.env } = {}) {
147
+ const operation = args[0] === 'local' ? args[1] : args[0] === 'invoke' ? input?.operation : null;
148
+ if (!['flow-cancel', 'flow-abandon'].includes(operation)) return false;
149
+ const value = result?.output ?? result;
150
+ const flowId = args[0] === 'invoke' ? input?.input?.flowId : input?.flowId;
151
+ if (value?.operation !== operation || value.flowId !== flowId || !['succeeded', 'reported'].includes(value.status)
152
+ || operation === 'flow-abandon' && value.unbound !== true) return false;
153
+ const { host, sessionId } = ideGuardBinding(environment);
154
+ if (!host || host === 'Entrorove CLI' || !await readBreakGlass(home, sessionId)) return false;
155
+ const file = path.join(await ideDirectory(home), `session-${ideDigest(sessionId)}.json`);
156
+ const current = await readIdeJson(file);
157
+ if (!current) return false;
158
+ return ideSession({ session_id: sessionId, cwd: current.repositoryRoot }, state => {
159
+ if (state.flowId !== flowId) throw ideError('ENTROROVE_IDE_RECOVERY_SCOPE', 'Recovered flow no longer matches the IDE session');
160
+ if (operation === 'flow-abandon') {
161
+ for (const key of ['flowId', 'taskId', 'taskNumber', 'requestId', 'remoteRequestId', 'nextStep', 'pendingCall', 'readCalls']) delete state[key];
162
+ state.feedbackAcknowledged = false;
163
+ if (state.phase !== 'user-exited') state.phase = 'entry';
164
+ } else state.phase = 'ended';
165
+ return true;
166
+ }, home);
167
+ }
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ function gitRoot(directory) {
7
+ try { return execFileSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'],
8
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
9
+ catch { return null; }
10
+ }
11
+
12
+ function tracked(root, directory) {
13
+ if (!root) return false;
14
+ const relative = path.relative(root, directory);
15
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
16
+ return execFileSync('git', ['-C', root, 'ls-files', '--cached', '--', relative],
17
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().length > 0;
18
+ }
19
+
20
+ async function removeDuplicate(directory, target, root, isManaged) {
21
+ const stat = await fs.lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
22
+ if (!stat) return 'absent';
23
+ if (tracked(root, directory)) return 'git-tracked';
24
+ if (stat.isSymbolicLink()) {
25
+ if (await fs.realpath(directory).catch(() => null) !== target) return 'unmanaged';
26
+ await fs.unlink(directory);
27
+ return 'removed';
28
+ }
29
+ if (!await isManaged(directory)) return 'unmanaged-or-edited';
30
+ const quarantine = `${directory}.entrorove-cleanup-${randomUUID()}`;
31
+ await fs.rename(directory, quarantine);
32
+ try {
33
+ if (!await isManaged(quarantine)) throw new Error('Managed duplicate changed during cleanup');
34
+ await fs.rm(quarantine, { recursive: true });
35
+ } catch (error) {
36
+ await fs.rename(quarantine, directory);
37
+ throw error;
38
+ }
39
+ return 'removed';
40
+ }
41
+
42
+ export async function cleanManagedDuplicates({ target, cwd, home, isManaged, includeProject = true }) {
43
+ const root = includeProject ? gitRoot(cwd) : null, homeRoot = gitRoot(home);
44
+ const candidates = [
45
+ ...(root ? ['.codex/skills/entrorove', '.agents/skills/entrorove', '.claude/skills/entrorove']
46
+ .map(relative => ({ directory: path.join(root, relative), label: relative, root })) : []),
47
+ ...['.agents/skills/entrorove', '.claude/skills/entrorove']
48
+ .map(relative => ({ directory: path.join(home, relative), label: `~/${relative}`, root: homeRoot })),
49
+ ];
50
+ const removed = [], preserved = [], seen = new Set([target]);
51
+ for (const candidate of candidates) {
52
+ if (seen.has(candidate.directory)) continue;
53
+ seen.add(candidate.directory);
54
+ const result = await removeDuplicate(candidate.directory, target, candidate.root, isManaged);
55
+ if (result === 'removed') removed.push(candidate.label);
56
+ else if (result !== 'absent') preserved.push({ path: candidate.label, reason: result });
57
+ }
58
+ return { removed, preserved };
59
+ }
package/local-budget.mjs CHANGED
@@ -38,7 +38,8 @@ export function budgetView(state, now = Date.now()) {
38
38
  ? Math.floor((elapsedMs - state.maxDurationMs) / auto.policy.intervalMs) + 1 : 0;
39
39
  const autoRenewEligible = Boolean(intervals && !state.closedReason && now < Date.parse(state.grant.expiresAt)
40
40
  && auto.renewalsUsed + intervals <= auto.policy.maxRenewals);
41
- return { ...state, elapsedMs, remainingFiles: state.enforced ? Math.max(0, state.maxFiles - state.files.length) : null,
41
+ return { ...state, elapsedMs, consumedRatio: state.enforced ? elapsedMs / state.maxDurationMs : null,
42
+ remainingFiles: state.enforced ? Math.max(0, state.maxFiles - state.files.length) : null,
42
43
  remainingTokenEstimate: state.enforced ? Math.max(0, state.maxTokenEstimate - state.tokenEstimate) : null,
43
44
  remainingDurationMs: state.enforced ? Math.max(0, state.maxDurationMs - elapsedMs) : null,
44
45
  enforcement: state.enforced ? 'cloud-sandbox' : 'continuous', autoRenewEligible,
@@ -4,11 +4,6 @@ import { inspectLocalChainResult } from './local-chain-plan.mjs';
4
4
 
5
5
  const STAGES = Object.freeze({ 'workflow-gate': 'aimlock', 'workflow-contract': 'aimlock',
6
6
  'workflow-plan': 'blueprint', 'workflow-dispatch': 'swarm', 'workflow-verdict': 'validator' });
7
- function nativeSchema(runtime, operation) {
8
- const schemas = runtime.operationSchemas;
9
- const selected = Array.isArray(schemas) ? schemas.find(value => value.operation === operation) : schemas?.[operation];
10
- return selected?.inputSchema ?? selected?.input ?? selected;
11
- }
12
7
  export async function captureChainCloud(plan, client) {
13
8
  if (!plan.skills.length) return null;
14
9
  const output = await client.invoke('capabilities', {}), capabilities = output.capabilities;
@@ -37,10 +32,16 @@ export function checkChainSkillDispatch(original, state, resumed, input) {
37
32
  }
38
33
  const slug = STAGES[original.operation] ?? resumed.route?.slug;
39
34
  if (slug !== original.skillId) fail('ENTROROVE_CHAIN_SKILL_BINDING', 'declared skill differs from the original native workflow stage');
40
- const runtime = state.cloud.runtimes.find(value => value.slug === original.skillId);
41
- const schema = state.cloud.workflowOperations?.operationSchemas?.[original.operation] ?? nativeSchema(runtime, original.operation);
35
+ // `continue` is a workflow operation. A native skill operation with the
36
+ // same name can have a different request body (for example, the workflow
37
+ // reads its persisted interaction while the native runtime requires one).
38
+ const schema = state.cloud.workflowOperations?.operationSchemas?.[original.operation];
42
39
  if (schema) {
43
- const validate = new Ajv({ strict: true, strictRequired: false, allErrors: true }).compile(schema);
40
+ // Pinned native schemas include legacy `optional` annotations and nested
41
+ // object branches without an explicit `type`. Keep validation of the
42
+ // actual JSON Schema constraints while accepting those published forms.
43
+ const validate = new Ajv({ strict: true, strictRequired: false, strictTypes: false, allErrors: true })
44
+ .addKeyword({ keyword: 'optional', schemaType: 'boolean', valid: true }).compile(schema);
44
45
  if (!validate(input)) fail('ENTROROVE_CHAIN_SKILL_INPUT', 'bound input violates its fixed original operation schema');
45
46
  }
46
47
  // Native operations without public nested schemas remain server-validated on
package/local-cli.mjs CHANGED
@@ -25,7 +25,7 @@ import { ARCHGUARD_LOCAL_OPERATIONS } from './local-archguard-operations.mjs';
25
25
  import { LOCAL_BUDGET_HANDOFF_OPERATIONS } from './local-budget-handoff.mjs';
26
26
  import { LOCAL_CHAIN_OPERATIONS } from './local-chain-operations.mjs';
27
27
  import { DISCIPLINE_LOCAL_OPERATIONS } from './local-discipline-operations.mjs';
28
- import { assertIdeExecution } from './ide-session.mjs';
28
+ import { assertIdeExecution, settleBreakGlassSession } from './ide-session.mjs';
29
29
  import { runManagedWorkflowLocalStep } from './workflow-local-client.mjs';
30
30
  import { localErrorEnvelope } from './local-error.mjs';
31
31
 
@@ -101,6 +101,7 @@ export async function runLocal(args = [], dependencies) {
101
101
  const validated = await checkedLocalInput(operation, input);
102
102
  const supplied = operation === 'workflow-run-step' || Object.hasOwn(LOCAL_CHAIN_OPERATIONS, operation) ? dependencies : undefined;
103
103
  const output = await withLocalInvocation(operation, validated, () => withBudgetReadContext(validated, () => handler(validated, supplied)));
104
+ await settleBreakGlassSession(['local', ...args], validated, output);
104
105
  process.stdout.write(`${JSON.stringify(output)}\n`);
105
106
  if (['pending', 'blocked', 'failed', 'incomplete'].includes(output?.status)) process.exitCode = 2;
106
107
  } catch (cause) {
@@ -115,5 +116,7 @@ export async function runLocalInput(operation, input, dependencies) {
115
116
  if (!handler) throw new Error(`Unsupported local operation: ${operation}`);
116
117
  const validated = await checkedLocalInput(operation, input);
117
118
  const supplied = operation === 'workflow-run-step' || Object.hasOwn(LOCAL_CHAIN_OPERATIONS, operation) ? dependencies : undefined;
118
- return withLocalInvocation(operation, validated, () => withBudgetReadContext(validated, () => handler(validated, supplied)));
119
+ const output = await withLocalInvocation(operation, validated, () => withBudgetReadContext(validated, () => handler(validated, supplied)));
120
+ await settleBreakGlassSession(['local', operation], validated, output);
121
+ return output;
119
122
  }