@entrorove/skill 0.1.40 → 0.1.43
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/CHANGELOG.md +15 -0
- package/DELIVERY-BOUNDARIES.md +30 -0
- package/IDE-EXECUTION.md +10 -2
- package/SKILL.md +10 -6
- package/cli.mjs +63 -77
- package/ide-code-mode.mjs +6 -0
- package/ide-command.mjs +13 -6
- package/ide-hook.mjs +37 -9
- package/ide-install.mjs +208 -35
- package/ide-session.mjs +37 -9
- package/install-cleanup.mjs +59 -0
- package/local-budget.mjs +2 -1
- package/local-contracts.mjs +9 -1
- package/local-discipline-repository.mjs +41 -7
- package/local-runner-discipline.mjs +7 -3
- package/local-stage-boundary.mjs +92 -0
- package/manifest.json +1 -1
- package/package.json +4 -1
- package/release-state.json +22 -19
- package/release.mjs +10 -2
- package/releases.json +18 -0
- package/remote-client.mjs +78 -1
- package/task-entry-client.mjs +4 -0
- package/workflow-local-contracts.mjs +2 -0
package/ide-install.mjs
CHANGED
|
@@ -1,60 +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
|
-
|
|
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
|
-
|
|
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,
|
|
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())
|
|
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
|
-
function mergeHooks(existing, previous, current) {
|
|
67
|
+
function mergeHooks(existing, previous, current, adapter) {
|
|
25
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');
|
|
26
69
|
const next = { ...existing, hooks: { ...existing?.hooks } };
|
|
27
|
-
for (const event of events) {
|
|
70
|
+
for (const event of new Set([...Object.keys(previous?.hooks ?? {}), ...adapter.events])) {
|
|
28
71
|
const old = previous?.hooks?.[event]?.[0], entries = next.hooks[event] ?? [];
|
|
29
72
|
if (!Array.isArray(entries)) throw ideError('ENTROROVE_IDE_CONFIG', 'Existing hook list is invalid; it was preserved');
|
|
30
73
|
const ours = entries.filter(entry => old && JSON.stringify(entry) === JSON.stringify(old));
|
|
31
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');
|
|
32
|
-
|
|
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];
|
|
33
79
|
}
|
|
34
80
|
return next;
|
|
35
81
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
await writeIdeJson(recordPath, { schemaVersion: 'entrorove.ide-installation/1.0', version, definition: current,
|
|
56
|
-
files: Object.fromEntries(contents.map(([name, bytes]) => [name, ideDigest(bytes.toString('utf8'))])) });
|
|
57
|
-
return { host: 'Codex', configured: true, enforcement: 'review-required', hooksPath,
|
|
58
|
-
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;
|
|
59
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 };
|
|
60
233
|
}
|
package/ide-session.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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';
|
|
@@ -35,7 +36,7 @@ export async function writeIdeJson(file, value) {
|
|
|
35
36
|
}
|
|
36
37
|
export const writeBreakGlass = writeOfflineBreakGlass;
|
|
37
38
|
export async function readBreakGlass(home = os.homedir()) {
|
|
38
|
-
await ideDirectory(home, true);
|
|
39
|
+
const directory = await ideDirectory(home, true);
|
|
39
40
|
const value = await readIdeJson(breakGlassPath(home));
|
|
40
41
|
if (!value || value.schemaVersion !== breakGlassSchema || value.mode !== 'management-only'
|
|
41
42
|
|| typeof value.expiresAt !== 'string' || !Number.isFinite(Date.parse(value.expiresAt))
|
|
@@ -47,8 +48,10 @@ export async function ideSession(event, action, home = os.homedir()) {
|
|
|
47
48
|
const directory = await ideDirectory(home, true), file = path.join(directory, `session-${ideDigest(event.session_id)}.json`);
|
|
48
49
|
return withStateLock(file, async () => {
|
|
49
50
|
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
|
|
51
|
-
|
|
51
|
+
if (prior && (prior.schemaVersion !== 'entrorove.ide-session/1.0' || prior.sessionId !== event.session_id
|
|
52
|
+
|| prior.repositoryRoot !== root && prior.active)) throw ideError('ENTROROVE_IDE_IDENTITY', 'An active task cannot replace its original physical repository');
|
|
53
|
+
const state = prior?.repositoryRoot === root ? prior
|
|
54
|
+
: { schemaVersion: 'entrorove.ide-session/1.0', sessionId: event.session_id, repositoryRoot: root, active: false, phase: 'entry', seen: [], blockStreak: 0 };
|
|
52
55
|
state.stateFile = file;
|
|
53
56
|
const result = await action(state);
|
|
54
57
|
const { stateFile, ...persisted } = state;
|
|
@@ -79,18 +82,43 @@ export async function ideRuntime(home = os.homedir()) {
|
|
|
79
82
|
return runtime;
|
|
80
83
|
}
|
|
81
84
|
export function ideContext(state) {
|
|
82
|
-
|
|
85
|
+
const delivered = state.phase === 'delivered' && Number.isSafeInteger(state.deliveredStage)
|
|
86
|
+
? `阶段 ${state.deliveredStage} 已按边界独立交付(终态,不等于失败或阻塞);续跑从下一个边界继续,不重新执行已交付阶段。` : '';
|
|
87
|
+
return `Entrorove 已强制接管本任务。${state.taskNumber ? `云端任务 ${state.taskNumber}。` : '尚未获得云端任务编号,不得声称已进入云端。'}${state.flowId ? `继续原 flowId=${state.flowId},` : ''}${state.requestId ? `原 requestId=${state.requestId}。` : ''}当前阶段 ${state.phase}。${delivered}必须读 SKILL.md 并执行返回的 nextStep;禁止在失败后替换执行器。源文件、数据及终端写入必须走原任务的受控入口。`;
|
|
88
|
+
}
|
|
89
|
+
// Verified hosts only: the guard is universal in shape (host label + session
|
|
90
|
+
// id), and each host proves itself through its own adapter callback.
|
|
91
|
+
export const verifiedIdeGuards = { 'Codex': 'CODEX_THREAD_ID', 'Claude Code': 'CLAUDE_SESSION_ID' };
|
|
92
|
+
export function ideGuardBinding(environment = process.env) {
|
|
93
|
+
const host = environment.ENTROROVE_IDE ?? (environment.CODEX_THREAD_ID ? 'Codex' : environment.CLAUDE_CODE_ENTRYPOINT ? 'Claude Code'
|
|
94
|
+
: environment.CURSOR_TRACE_ID ? 'Cursor' : null);
|
|
95
|
+
const sessionId = environment.ENTROROVE_SESSION ?? (host ? environment[verifiedIdeGuards[host] ?? ''] : undefined) ?? environment.CODEX_THREAD_ID;
|
|
96
|
+
return { host, sessionId: typeof sessionId === 'string' ? sessionId : null };
|
|
97
|
+
}
|
|
98
|
+
async function declaredHost(environment) {
|
|
99
|
+
const declared = String(environment.ENTROROVE_IDE_ADAPTER ?? '').split(',').map(value => value.trim()).filter(Boolean);
|
|
100
|
+
for (const pathname of declared) {
|
|
101
|
+
try {
|
|
102
|
+
const module = await import(pathToFileURL(pathname).href);
|
|
103
|
+
const host = (module.default ?? module.adapter)?.host;
|
|
104
|
+
if (typeof host === 'string') return host;
|
|
105
|
+
} catch { return null; }
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
83
108
|
}
|
|
84
109
|
export async function assertIdeExecution(args, input, { home = os.homedir(), environment = process.env } = {}) {
|
|
85
|
-
const host
|
|
110
|
+
const { host, sessionId } = ideGuardBinding(environment);
|
|
86
111
|
if (!host || host === 'Entrorove CLI') return;
|
|
87
|
-
|
|
88
|
-
|
|
112
|
+
const declared = verifiedIdeGuards[host] ? null : await declaredHost(environment);
|
|
113
|
+
if (!verifiedIdeGuards[host] && declared !== host || !sessionId) {
|
|
114
|
+
throw ideError('ENTROROVE_IDE_GUARD_UNSUPPORTED', `${host} has no verified tool guard adapter. Execution is blocked; never continue with another executor.`);
|
|
115
|
+
}
|
|
116
|
+
const directory = await ideDirectory(home), state = await readIdeJson(path.join(directory, `session-${ideDigest(sessionId)}.json`));
|
|
89
117
|
const expected = ideDigest({ args, input });
|
|
90
118
|
const approved = [state?.pendingCall, ...Object.values(state?.readCalls ?? {})].find(call => call?.inputDigest === expected);
|
|
91
|
-
if (!state?.active || !approved || state.sessionId !==
|
|
119
|
+
if (!state?.active || !approved || state.sessionId !== sessionId
|
|
92
120
|
|| Date.now() - Date.parse(state.observedAt) > 120_000) {
|
|
93
|
-
throw ideError('ENTROROVE_IDE_GUARD_REQUIRED',
|
|
121
|
+
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
122
|
}
|
|
95
123
|
await bindIdeAccount(state, home);
|
|
96
124
|
approvedResultContext = { home, sessionId: state.sessionId, repositoryRoot: state.repositoryRoot,
|
|
@@ -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,
|
|
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,
|
package/local-contracts.mjs
CHANGED
|
@@ -85,6 +85,8 @@ add('budget-auto-renew-request', renewal, ['repositoryRoot', 'flowId', 'requestI
|
|
|
85
85
|
add('budget-auto-renew', { ...renewal, confirmation, grant: record }, ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy'], ['grant', 'confirmation']);
|
|
86
86
|
add('budget-auto-renew-stop', { ...flow, reason: enumeration(['revoked', 'completed']) }, ['repositoryRoot', 'flowId', 'reason']);
|
|
87
87
|
const probePaths = { anyOf: [paths, { const: ['.'] }] };
|
|
88
|
+
const importContexts = { ...array(object({ sourcePrefix: relativePath, configPath: relativePath }, ['sourcePrefix', 'configPath'])),
|
|
89
|
+
maxItems: 100, uniqueItems: true, $comment: 'Source prefixes must be unique even when config paths differ.' };
|
|
88
90
|
const scopeRequirement = object({ id: ledgerIdentifier, description: { ...text, pattern: '\\S', maxLength: 12000 } }, ['id', 'description']);
|
|
89
91
|
const scopeInteraction = object({ type: enumeration(['confirm', 'choice', 'multi', 'input']), question: text,
|
|
90
92
|
options: array(object({ id: ledgerIdentifier, label: text, hint: { type: 'string' } }, ['id', 'label'])), riskDescription: { type: 'string' },
|
|
@@ -94,7 +96,8 @@ const scopeTask = object({ goal: { ...text, pattern: '\\S', maxLength: 12000 },
|
|
|
94
96
|
unclear: { type: 'boolean' }, architectureContract: { type: 'boolean' }, explicitAimlockRequested: { type: 'boolean' }, explicitEntroroveRequested: { type: 'boolean' }, userSpecifiedSkills: array(text),
|
|
95
97
|
requirements: { ...array(scopeRequirement, 1), maxItems: 500 }, phase: enumeration(['plan', 'coordinate', 'architecture', 'verify', 'merge', 'decision']), baselineRef: text, interaction: scopeInteraction }, ['goal', 'workType', 'difficulty', 'risk']);
|
|
96
98
|
const probe = { ...step, paths: probePaths, targetPaths: probePaths, task: scopeTask, baselineRef: text, snapshotId: identifier,
|
|
97
|
-
targetSymbols: array(text, 1), contextMapPath: relativePath, signerConfigPath: nullable(text),
|
|
99
|
+
targetSymbols: array(text, 1), contextMapPath: relativePath, importContexts, signerConfigPath: nullable(text),
|
|
100
|
+
taskDigest: sha256, ownerId: ledgerIdentifier, chainDigest: sha256, repositoryId: identifier, challenge: ledgerIdentifier };
|
|
98
101
|
add('probe', { ...probe, handoffCandidate: HANDOFF_SNAPSHOT_CANDIDATE_SCHEMA }, ['repositoryRoot']);
|
|
99
102
|
schemas.probe.allOf = [{ if: { type: 'object', anyOf: ['flowId', 'stepId', 'snapshotId'].map(field => ({ required: [field] })) },
|
|
100
103
|
then: { type: 'object', required: ['flowId', 'stepId', 'snapshotId'], anyOf: ['paths', 'targetPaths', 'targetSymbols'].map(field => ({ required: [field] })) } },
|
|
@@ -155,6 +158,11 @@ export function validateLocalInput(operation, input) {
|
|
|
155
158
|
const details = validate.errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');
|
|
156
159
|
throw Object.assign(new Error(`Invalid ${operation} input: ${details}`), { code: 'ENTROROVE_INPUT_INVALID' });
|
|
157
160
|
}
|
|
161
|
+
if ((operation === 'probe' || operation === 'reassess') && Array.isArray(input.importContexts)) {
|
|
162
|
+
const prefixes = input.importContexts.map(context => context.sourcePrefix);
|
|
163
|
+
if (new Set(prefixes).size !== prefixes.length) throw Object.assign(
|
|
164
|
+
new Error(`Invalid ${operation} input: importContexts sourcePrefix values must be unique`), { code: 'ENTROROVE_INPUT_INVALID' });
|
|
165
|
+
}
|
|
158
166
|
return input;
|
|
159
167
|
}
|
|
160
168
|
export const operationSchemasFor = operations => Object.freeze(Object.fromEntries(Object.keys(operations).map(name => [name, schemas[name]])));
|
|
@@ -1,13 +1,39 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
5
|
import { mutationRead14, mutationPath14, mutationTarget14, mutationFail14 } from './local-mutation-files.mjs';
|
|
6
|
+
import { budgetBindingForRead } from './local-budget-reads.mjs';
|
|
7
|
+
import { budgetStatus } from './local-budget.mjs';
|
|
5
8
|
import { analyzeDisciplineSources } from './local-discipline-analysis.mjs';
|
|
6
9
|
import { importGraph, scopeCandidateImports } from './local-scope-repository.mjs';
|
|
7
10
|
import { nativeDisciplineDefinitions } from './local-discipline-native.mjs';
|
|
8
11
|
|
|
9
12
|
const script = /\.(?:[cm]?[jt]sx?|php|py|java|rs|vue|svelte|css)$/i;
|
|
10
13
|
const excluded = new Set(['.git', 'node_modules']);
|
|
14
|
+
const controlSources = new Set(['.aimlock', '.archguard', '.entrorove']);
|
|
15
|
+
const semanticSource = relative => script.test(relative) && !relative.split('/').some(part => controlSources.has(part));
|
|
16
|
+
const within = (relative, scope) => relative === scope || relative.startsWith(`${scope}/`);
|
|
17
|
+
function withinBudgetScope(relative, scope) {
|
|
18
|
+
return scope.allowedPaths.some(allowed => within(relative, allowed))
|
|
19
|
+
&& !scope.forbiddenPaths?.some(forbidden => within(relative, forbidden));
|
|
20
|
+
}
|
|
21
|
+
const sameFile = (left, right) => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode'].every(key => left[key] === right[key]);
|
|
22
|
+
async function integrityOnlyFile(root, relative) {
|
|
23
|
+
// This path returns only an integrity digest. It never exposes source bytes or
|
|
24
|
+
// contributes semantic facts outside the signed source-read scope.
|
|
25
|
+
const before = await mutationTarget14(root, relative);
|
|
26
|
+
const handle = await fs.open(before.target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
27
|
+
try {
|
|
28
|
+
const first = await handle.stat();
|
|
29
|
+
if (!first.isFile() || !sameFile(first, before.stat)) mutationFail14('ENTROROVE_FILE_CHANGED', `${relative} changed before integrity observation`);
|
|
30
|
+
const digest = createHash('sha256');
|
|
31
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) digest.update(chunk);
|
|
32
|
+
const last = await handle.stat(), current = await mutationTarget14(root, relative);
|
|
33
|
+
if (!sameFile(first, last) || !sameFile(last, current.stat)) mutationFail14('ENTROROVE_FILE_CHANGED', `${relative} changed during integrity observation`);
|
|
34
|
+
return { sha256: digest.digest('hex'), mode: last.mode & 0o777 };
|
|
35
|
+
} finally { await handle.close(); }
|
|
36
|
+
}
|
|
11
37
|
async function inventory(root, prefix = '') {
|
|
12
38
|
const entries = await fs.readdir(path.join(root, prefix), { withFileTypes: true }), files = [];
|
|
13
39
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
@@ -21,15 +47,23 @@ async function inventory(root, prefix = '') {
|
|
|
21
47
|
}
|
|
22
48
|
export async function disciplineRepositoryObservation(input, root, { analyze = false, overrides = new Map() } = {}) {
|
|
23
49
|
const paths = await inventory(root), files = [], sources = [], unresolved = [];
|
|
50
|
+
const binding = await budgetBindingForRead(input), budget = binding ? await budgetStatus(binding) : null;
|
|
51
|
+
const restricted = budget?.enforced ? budget.scope : null;
|
|
52
|
+
if (restricted && [...overrides.keys()].some(relative => !withinBudgetScope(relative, restricted))) {
|
|
53
|
+
mutationFail14('ENTROROVE_BUDGET_SCOPE', 'candidate analysis is outside the original cloud task scope');
|
|
54
|
+
}
|
|
24
55
|
for (const relative of paths) {
|
|
25
56
|
const stat = await fs.lstat(path.join(root, relative));
|
|
26
57
|
if (stat.isSymbolicLink()) {
|
|
27
58
|
files.push({ path: relative, sha256: sha256(await fs.readlink(path.join(root, relative))), mode: stat.mode & 0o777, kind: 'symlink' });
|
|
28
59
|
unresolved.push({ path: relative, reason: 'symlink-reference' }); continue;
|
|
29
60
|
}
|
|
30
|
-
const
|
|
61
|
+
const semantic = analyze && semanticSource(relative);
|
|
62
|
+
const allowed = !restricted || withinBudgetScope(relative, restricted);
|
|
63
|
+
const read = semantic && allowed ? await mutationRead14(input, root, relative) : await integrityOnlyFile(root, relative);
|
|
31
64
|
files.push({ path: relative, sha256: read.sha256, mode: read.mode, kind: 'file' });
|
|
32
|
-
if (
|
|
65
|
+
if (semantic && allowed) sources.push({ path: relative, content: overrides.get(relative) ?? read.content.toString('utf8') });
|
|
66
|
+
else if (semantic && !allowed) unresolved.push({ path: relative, reason: 'outside-signed-source-scope' });
|
|
33
67
|
}
|
|
34
68
|
if (canonical(paths) !== canonical(await inventory(root))) mutationFail14('ENTROROVE_DISCIPLINE_SCAN_CHANGED', 'Repository inventory changed while observing its original baseline');
|
|
35
69
|
const comparison = { schemaVersion: 'entrorove.repository-comparison/1.0', files,
|
|
@@ -38,20 +72,20 @@ export async function disciplineRepositoryObservation(input, root, { analyze = f
|
|
|
38
72
|
// The existing resolver supplies actual aliases, package exports and import edges.
|
|
39
73
|
// Unsupported/dynamic references stay unknown; they are never converted to unused.
|
|
40
74
|
const roots = sources.filter(file => /\.[cm]?[jt]sx?$/i.test(file.path)).map(file => ({ path: file.path, exists: true }));
|
|
41
|
-
for (const [relative, content] of overrides) if (!paths.includes(relative) &&
|
|
75
|
+
for (const [relative, content] of overrides) if (!paths.includes(relative) && semanticSource(relative)) sources.push({ path: relative, content });
|
|
42
76
|
let graph;
|
|
43
77
|
try { graph = roots.length ? await importGraph(root, roots, input) : { edges: [], dynamicSources: [], complete: true }; }
|
|
44
78
|
catch (error) {
|
|
45
|
-
if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT'].includes(error.code)) throw error;
|
|
79
|
+
if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
|
|
46
80
|
graph = { edges: [], dynamicSources: [], complete: false }; unresolved.push({ reason: error.code });
|
|
47
81
|
}
|
|
48
|
-
for (const [relative, content] of overrides) if (/\.[cm]?[jt]sx?$/i.test(relative)) {
|
|
82
|
+
for (const [relative, content] of overrides) if (semanticSource(relative) && /\.[cm]?[jt]sx?$/i.test(relative)) {
|
|
49
83
|
try {
|
|
50
84
|
const candidate = await scopeCandidateImports(root, { path: relative, content }, input);
|
|
51
85
|
graph.edges = [...graph.edges.filter(edge => edge.from !== relative), ...candidate.edges];
|
|
52
86
|
if (candidate.dynamic) unresolved.push({ path: relative, reason: 'candidate-dynamic-import' });
|
|
53
87
|
} catch (error) {
|
|
54
|
-
if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED'].includes(error.code)) throw error;
|
|
88
|
+
if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
|
|
55
89
|
unresolved.push({ path: relative, reason: error.code });
|
|
56
90
|
}
|
|
57
91
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
2
|
import { disciplineRepositoryObservation, disciplineRepositoryDifferences } from './local-discipline-repository.mjs';
|
|
3
|
-
import { atomicMutationJson14, privateMutationDirectory14, mutationStateRoot14 } from './local-mutation-files.mjs';
|
|
3
|
+
import { atomicMutationJson14, privateMutationDirectory14, mutationStateRoot14, readMutationJson14 } from './local-mutation-files.mjs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
6
|
export async function runnerDisciplineBefore(input, authority) {
|
|
@@ -17,7 +17,11 @@ export async function runnerDisciplineAfter(before, authority) {
|
|
|
17
17
|
changedCount: changed.length, changedPaths: changed.slice(0, 256), gates: { repositoryUnchanged: changed.length === 0 } };
|
|
18
18
|
const reportDigest = sha256(canonical(report)), state = await mutationStateRoot14(before.input);
|
|
19
19
|
const directory = await privateMutationDirectory14(path.join(state, 'discipline-reports'));
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const file = path.join(directory.target, `${reportDigest}.json`);
|
|
21
|
+
const record = { ...report, reportDigest, original: before.original, actual: after };
|
|
22
|
+
try { await atomicMutationJson14(file, record); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code !== 'ENTROROVE_STATE_EXISTS' || canonical(await readMutationJson14(file)) !== canonical(record)) throw error;
|
|
25
|
+
}
|
|
22
26
|
return { ...report, reportDigest };
|
|
23
27
|
}
|