harness-everything 0.15.0 → 0.16.0

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.
@@ -10,7 +10,7 @@
10
10
  "name": "harness-everything",
11
11
  "source": "./",
12
12
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
13
- "version": "0.15.0",
13
+ "version": "0.16.0",
14
14
  "author": {
15
15
  "name": "Miya Daniel"
16
16
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-everything",
3
3
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.16.0](https://github.com/dyphn1/Harness-everything/compare/v0.15.0...v0.16.0) (2026-09-20)
6
+
7
+ ### Features
8
+
9
+ * **codex:** add explicit hooks compatibility installer ([3fb622b](https://github.com/dyphn1/Harness-everything/commit/3fb622b57eef3a9ee5110529bcd2cdc8c81b6a75))
10
+ * **codex:** expose explicit hook fallback CLI ([83e4cf1](https://github.com/dyphn1/Harness-everything/commit/83e4cf1eadd77585d4681bb73acbf360efd63509))
11
+
12
+ ### Bug Fixes
13
+
14
+ * **codex:** contain compatibility runtime paths ([1ffcc0d](https://github.com/dyphn1/Harness-everything/commit/1ffcc0d984e7dbfd2dd987a03b189fb950e83de8))
15
+ * **codex:** do not recreate removed hooks config ([ee62a1d](https://github.com/dyphn1/Harness-everything/commit/ee62a1d9f466a2e585e4737576a7289ae12087d5))
16
+ * **codex:** fail closed on duplicate compat hooks ([29920d0](https://github.com/dyphn1/Harness-everything/commit/29920d07e76a73c84c35440b60453c3e0b648b29))
17
+ * **codex:** harden compatibility hook ownership ([3ff5c14](https://github.com/dyphn1/Harness-everything/commit/3ff5c1456e6c44398ef661763057c93bcfcbb57c))
18
+ * **codex:** record only inserted hook ownership ([90e0c9b](https://github.com/dyphn1/Harness-everything/commit/90e0c9bbfd4672955473d33f2e33832f760bf294))
19
+
5
20
  ## [0.15.0](https://github.com/dyphn1/Harness-everything/compare/v0.14.0...v0.15.0) (2026-09-20)
6
21
 
7
22
  ### Features
package/bin/cli.js CHANGED
@@ -37,6 +37,9 @@ switch (command) {
37
37
  case 'verify-install':
38
38
  runInstallVerification(args.slice(1));
39
39
  break;
40
+ case 'codex-hooks':
41
+ runCodexHooksCompat(args.slice(1));
42
+ break;
40
43
  default:
41
44
  console.error(`[Error] Unknown command: "${command}"`);
42
45
  showHelp();
@@ -97,6 +100,10 @@ Commands:
97
100
  work; hosts with UserPromptSubmit hooks receive it automatically.
98
101
  verify-install Compare installed Harness manifests and skill trees with
99
102
  this package source; stale versions or missing files fail.
103
+ codex-hooks Explicit Codex user-hook compatibility fallback for hosts
104
+ that fail to mount enabled plugin-bundled hooks.
105
+ Usage: harness codex-hooks <install|status|uninstall>
106
+ Does not trust hooks or modify Codex config.toml.
100
107
  verify Run the pre-delivery verification gate (lint/test from the
101
108
  nearest package.json). Exits non-zero if checks fail. This is
102
109
  the explicit stand-in for a hard stop gate on platforms where
@@ -162,3 +169,13 @@ function runInstallVerification(verifyArgs) {
162
169
  const result = spawnSync('node', [script, ...verifyArgs], { stdio: 'inherit' });
163
170
  process.exit(result.status === null ? 1 : result.status);
164
171
  }
172
+
173
+ function runCodexHooksCompat(compatArgs) {
174
+ const script = path.resolve(__dirname, '..', 'scripts', 'codex-hooks-compat.js');
175
+ if (!fs.existsSync(script)) {
176
+ console.error('[Error] Codex hooks compatibility script not found.');
177
+ process.exit(1);
178
+ }
179
+ const result = spawnSync('node', [script, ...compatArgs], { stdio: 'inherit' });
180
+ process.exit(result.status === null ? 1 : result.status);
181
+ }
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const assert = require('assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const compat = require('../scripts/codex-hooks-compat');
10
+
11
+ let failed = 0;
12
+ function check(condition, message, detail = '') {
13
+ if (condition) console.log(' PASS ' + message);
14
+ else {
15
+ failed++;
16
+ console.error(' FAIL ' + message + (detail ? ' — ' + detail : ''));
17
+ }
18
+ }
19
+
20
+ function read(file) {
21
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
22
+ }
23
+
24
+ function findHarnessEntries(config, runtimeDir) {
25
+ const out = [];
26
+ for (const [event, entries] of Object.entries(config.hooks || {})) {
27
+ for (const entry of entries || []) {
28
+ if (JSON.stringify(entry).toLowerCase().includes(path.resolve(runtimeDir).replace(/\\/g, '\\\\').toLowerCase()) ||
29
+ JSON.stringify(entry).replace(/\\\\/g, '/').toLowerCase().includes(path.resolve(runtimeDir).replace(/\\/g, '/').toLowerCase())) {
30
+ out.push({ event, entry });
31
+ }
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ console.log('\n[2al] Codex user-hook compatibility fallback (#122)...');
38
+
39
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-codex-hooks-compat-'));
40
+ const codexHome = path.join(root, '.codex');
41
+ fs.mkdirSync(codexHome, { recursive: true });
42
+ const hooksFile = path.join(codexHome, 'hooks.json');
43
+ const configFile = path.join(codexHome, 'config.toml');
44
+ const userHook = {
45
+ matcher: 'Bash',
46
+ hooks: [{
47
+ type: 'command',
48
+ command: 'node "/user/policy.js"',
49
+ timeout: 7,
50
+ statusMessage: 'User policy',
51
+ }],
52
+ };
53
+ const original = {
54
+ description: 'user-owned metadata',
55
+ hooks: {
56
+ PreToolUse: [userHook],
57
+ Stop: [{
58
+ hooks: [{ type: 'command', command: 'node "/user/stop.js"', timeout: 5 }],
59
+ }],
60
+ },
61
+ };
62
+ fs.writeFileSync(hooksFile, JSON.stringify(original, null, 4) + '\n', 'utf8');
63
+ fs.writeFileSync(configFile, 'model = "test"\n# user trust/config state\n', 'utf8');
64
+ const configBefore = fs.readFileSync(configFile, 'utf8');
65
+
66
+ try {
67
+ const installed = compat.install({ codexHome });
68
+ check(installed.status === 'installed' && installed.entries > 0,
69
+ 'explicit install materializes compatibility entries');
70
+ check(installed.trustChanged === false && fs.readFileSync(configFile, 'utf8') === configBefore,
71
+ 'install never changes Codex trust/config.toml state');
72
+
73
+ const state = compat.status({ codexHome });
74
+ check(state.installed && state.healthy && state.exactEntriesPresent === state.expectedEntries,
75
+ 'status verifies exact installed entry hashes and runtime tree');
76
+
77
+ const config = read(hooksFile);
78
+ check(config.description === original.description &&
79
+ JSON.stringify(config.hooks.PreToolUse[0]) === JSON.stringify(userHook) &&
80
+ config.hooks.Stop.some(entry => entry.hooks?.[0]?.command === 'node "/user/stop.js"'),
81
+ 'existing unrelated user hooks and top-level metadata are preserved semantically');
82
+
83
+ const manifest = read(path.join(codexHome, 'harness-everything', 'compat-hooks', 'manifest.json'));
84
+ const harnessEntries = findHarnessEntries(config, manifest.runtimeDir);
85
+ check(harnessEntries.length === manifest.entries.length,
86
+ 'every compatibility hook command resolves to the Harness-owned runtime copy');
87
+ check(!JSON.stringify(harnessEntries).includes('${PLUGIN_ROOT}') &&
88
+ !JSON.stringify(harnessEntries).includes('${CLAUDE_PLUGIN_ROOT}') &&
89
+ !JSON.stringify(harnessEntries).includes('%PLUGIN_ROOT%'),
90
+ 'compatibility entries use literal absolute runtime paths without shell-specific plugin expansion');
91
+ check(fs.existsSync(path.join(manifest.runtimeDir, 'hooks', 'scripts', 'codex-action-gate-pre.js')) &&
92
+ fs.existsSync(path.join(manifest.runtimeDir, 'skills', 'harness-everything', 'scripts', 'kernel-router.js')),
93
+ 'compatibility runtime is self-contained for hook and router dependencies');
94
+
95
+ const countsBefore = Object.fromEntries(Object.entries(config.hooks).map(([event, entries]) => [event, entries.length]));
96
+ const updated = compat.install({ codexHome });
97
+ const configAfterUpdate = read(hooksFile);
98
+ const countsAfter = Object.fromEntries(Object.entries(configAfterUpdate.hooks).map(([event, entries]) => [event, entries.length]));
99
+ check(updated.status === 'updated' && JSON.stringify(countsAfter) === JSON.stringify(countsBefore),
100
+ 'reinstall/update is idempotent and does not duplicate hook entries');
101
+
102
+ configAfterUpdate.hooks.PostToolUse = configAfterUpdate.hooks.PostToolUse || [];
103
+ const addedUserHook = { hooks: [{ type: 'command', command: 'node "/user/later.js"', timeout: 3 }] };
104
+ configAfterUpdate.hooks.PostToolUse.unshift(addedUserHook);
105
+ fs.writeFileSync(hooksFile, JSON.stringify(configAfterUpdate, null, 2) + '\n', 'utf8');
106
+ compat.install({ codexHome });
107
+ check(read(hooksFile).hooks.PostToolUse.some(entry => entry.hooks?.[0]?.command === 'node "/user/later.js"'),
108
+ 'update preserves user hooks added after the initial compatibility install');
109
+
110
+ const beforeTamper = fs.readFileSync(hooksFile, 'utf8');
111
+ const tampered = read(hooksFile);
112
+ const currentManifest = read(path.join(codexHome, 'harness-everything', 'compat-hooks', 'manifest.json'));
113
+ let changed = false;
114
+ for (const [event, entries] of Object.entries(tampered.hooks)) {
115
+ for (const entry of entries) {
116
+ if (!changed && JSON.stringify(entry).replace(/\\\\/g, '/').toLowerCase()
117
+ .includes(path.resolve(currentManifest.runtimeDir).replace(/\\/g, '/').toLowerCase())) {
118
+ entry.hooks[0].statusMessage = 'user modified this installed Harness hook';
119
+ changed = true;
120
+ }
121
+ }
122
+ }
123
+ fs.writeFileSync(hooksFile, JSON.stringify(tampered, null, 2) + '\n', 'utf8');
124
+ const tamperedBytes = fs.readFileSync(hooksFile, 'utf8');
125
+ let updateRefused = false;
126
+ try { compat.install({ codexHome }); } catch (error) { updateRefused = /modified/.test(error.message); }
127
+ check(updateRefused && fs.readFileSync(hooksFile, 'utf8') === tamperedBytes,
128
+ 'modified Harness compatibility entry makes update fail closed without rewriting user config');
129
+
130
+ let uninstallRefused = false;
131
+ try { compat.uninstall({ codexHome }); } catch (error) { uninstallRefused = /modified/.test(error.message); }
132
+ check(uninstallRefused && fs.readFileSync(hooksFile, 'utf8') === tamperedBytes,
133
+ 'modified Harness compatibility entry makes uninstall fail closed rather than deleting user edits');
134
+
135
+ // Restore the exact generated config by starting from the pre-tamper bytes.
136
+ fs.writeFileSync(hooksFile, beforeTamper, 'utf8');
137
+ compat.install({ codexHome });
138
+ const removed = compat.uninstall({ codexHome });
139
+ const finalConfig = read(hooksFile);
140
+ check(removed.status === 'uninstalled' && removed.removed > 0 && removed.trustChanged === false,
141
+ 'explicit uninstall removes exact Harness-owned entries without changing trust state');
142
+ check(finalConfig.description === original.description &&
143
+ finalConfig.hooks.PreToolUse.some(entry => entry.hooks?.[0]?.command === 'node "/user/policy.js"') &&
144
+ finalConfig.hooks.Stop.some(entry => entry.hooks?.[0]?.command === 'node "/user/stop.js"') &&
145
+ finalConfig.hooks.PostToolUse.some(entry => entry.hooks?.[0]?.command === 'node "/user/later.js"'),
146
+ 'uninstall preserves unrelated pre-existing and later-added user hooks');
147
+ check(fs.readFileSync(configFile, 'utf8') === configBefore,
148
+ 'uninstall leaves config.toml/trust state byte-identical');
149
+ check(!fs.existsSync(path.join(codexHome, 'harness-everything', 'compat-hooks', 'manifest.json')),
150
+ 'uninstall removes Harness compatibility ownership manifest');
151
+
152
+ const emptyHome = path.join(root, 'empty-codex');
153
+ fs.mkdirSync(emptyHome, { recursive: true });
154
+ compat.install({ codexHome: emptyHome });
155
+ check(fs.existsSync(path.join(emptyHome, 'hooks.json')),
156
+ 'compatibility install can create a new hooks.json when none exists');
157
+ compat.uninstall({ codexHome: emptyHome });
158
+ check(!fs.existsSync(path.join(emptyHome, 'hooks.json')),
159
+ 'uninstall removes hooks.json only when Harness created it and no user entries remain');
160
+
161
+ const duplicateHome = path.join(root, 'duplicate-codex');
162
+ fs.mkdirSync(duplicateHome, { recursive: true });
163
+ compat.install({ codexHome: duplicateHome });
164
+ const duplicateConfig = read(path.join(duplicateHome, 'hooks.json'));
165
+ duplicateConfig.hooks.PreToolUse.push(JSON.parse(JSON.stringify(duplicateConfig.hooks.PreToolUse[0])));
166
+ fs.writeFileSync(path.join(duplicateHome, 'hooks.json'), JSON.stringify(duplicateConfig, null, 2) + '\n', 'utf8');
167
+ const duplicateBytes = fs.readFileSync(path.join(duplicateHome, 'hooks.json'), 'utf8');
168
+ let duplicateRefused = false;
169
+ try { compat.uninstall({ codexHome: duplicateHome }); } catch (error) { duplicateRefused = /modified/.test(error.message); }
170
+ check(duplicateRefused && fs.readFileSync(path.join(duplicateHome, 'hooks.json'), 'utf8') === duplicateBytes,
171
+ 'extra identical Harness hook is treated as user config drift, not silently deleted');
172
+
173
+ const removedConfigHome = path.join(root, 'removed-config-codex');
174
+ fs.mkdirSync(removedConfigHome, { recursive: true });
175
+ fs.writeFileSync(path.join(removedConfigHome, 'hooks.json'), JSON.stringify(original, null, 2) + '\n', 'utf8');
176
+ compat.install({ codexHome: removedConfigHome });
177
+ fs.rmSync(path.join(removedConfigHome, 'hooks.json'), { force: true });
178
+ const removedConfigResult = compat.uninstall({ codexHome: removedConfigHome });
179
+ check(removedConfigResult.status === 'uninstalled' && !fs.existsSync(path.join(removedConfigHome, 'hooks.json')),
180
+ 'uninstall never recreates a user hooks.json that was removed after compatibility install');
181
+
182
+ const preexistingHome = path.join(root, 'preexisting-identical-codex');
183
+ fs.mkdirSync(preexistingHome, { recursive: true });
184
+ const sourceHash = compat.treeHash(compat.SOURCE_PLUGIN_ROOT);
185
+ const preexistingPaths = compat.pathsFor(preexistingHome, sourceHash);
186
+ const preexistingEntries = compat.sourceHookEntries(preexistingPaths.runtimeDir);
187
+ const preexistingHooks = {};
188
+ for (const record of preexistingEntries) {
189
+ preexistingHooks[record.event] = preexistingHooks[record.event] || [];
190
+ preexistingHooks[record.event].push(JSON.parse(JSON.stringify(record.entry)));
191
+ }
192
+ fs.writeFileSync(path.join(preexistingHome, 'hooks.json'),
193
+ JSON.stringify({ hooks: preexistingHooks }, null, 2) + '\n', 'utf8');
194
+ const preexistingBefore = fs.readFileSync(path.join(preexistingHome, 'hooks.json'), 'utf8');
195
+ const preexistingInstall = compat.install({ codexHome: preexistingHome });
196
+ const preexistingManifest = read(path.join(preexistingHome, 'harness-everything', 'compat-hooks', 'manifest.json'));
197
+ check(preexistingInstall.status === 'installed' && preexistingManifest.entries.length === 0,
198
+ 'byte-identical pre-existing user hooks are reused but never recorded as Harness-owned');
199
+ const preexistingRemoved = compat.uninstall({ codexHome: preexistingHome });
200
+ check(preexistingRemoved.removed === 0 &&
201
+ fs.readFileSync(path.join(preexistingHome, 'hooks.json'), 'utf8') === preexistingBefore,
202
+ 'uninstall preserves byte-identical hooks that predated compatibility install');
203
+
204
+ const forgedManifestHome = path.join(root, 'forged-manifest-codex');
205
+ fs.mkdirSync(forgedManifestHome, { recursive: true });
206
+ fs.writeFileSync(path.join(forgedManifestHome, 'hooks.json'),
207
+ JSON.stringify({ hooks: { PreToolUse: [userHook] } }, null, 2) + '\n', 'utf8');
208
+ compat.install({ codexHome: forgedManifestHome });
209
+ const forgedManifestPath = path.join(
210
+ forgedManifestHome, 'harness-everything', 'compat-hooks', 'manifest.json');
211
+ const forgedManifest = read(forgedManifestPath);
212
+ forgedManifest.entries.push({
213
+ event: 'PreToolUse',
214
+ hash: compat.entryHash(userHook),
215
+ entry: userHook,
216
+ });
217
+ fs.writeFileSync(forgedManifestPath, JSON.stringify(forgedManifest, null, 2) + '\n', 'utf8');
218
+ const forgedHooksBefore = fs.readFileSync(path.join(forgedManifestHome, 'hooks.json'), 'utf8');
219
+ let forgedManifestRefused = false;
220
+ try { compat.uninstall({ codexHome: forgedManifestHome }); }
221
+ catch (error) { forgedManifestRefused = /corrupt/.test(error.message); }
222
+ check(forgedManifestRefused &&
223
+ fs.readFileSync(path.join(forgedManifestHome, 'hooks.json'), 'utf8') === forgedHooksBefore,
224
+ 'forged manifest ownership fails closed without deleting a user hook');
225
+
226
+ const failedInstallHome = path.join(root, 'failed-install-codex');
227
+ fs.mkdirSync(failedInstallHome, { recursive: true });
228
+ const failedInstallHooks = path.join(failedInstallHome, 'hooks.json');
229
+ fs.writeFileSync(failedInstallHooks,
230
+ JSON.stringify({ description: 'must survive', hooks: { PreToolUse: [userHook] } }, null, 4) + '\n',
231
+ 'utf8');
232
+ const failedInstallBefore = fs.readFileSync(failedInstallHooks, 'utf8');
233
+ const failedManifestPath = path.join(
234
+ failedInstallHome, 'harness-everything', 'compat-hooks', 'manifest.json');
235
+ const realRenameSync = fs.renameSync;
236
+ let failedInstallRefused = false;
237
+ fs.renameSync = function injectedManifestFailure(source, destination) {
238
+ if (path.resolve(destination) === path.resolve(failedManifestPath)) {
239
+ throw new Error('injected manifest commit failure');
240
+ }
241
+ return realRenameSync(source, destination);
242
+ };
243
+ try { compat.install({ codexHome: failedInstallHome }); }
244
+ catch (error) { failedInstallRefused = /injected manifest commit failure/.test(error.message); }
245
+ finally { fs.renameSync = realRenameSync; }
246
+ check(failedInstallRefused &&
247
+ fs.readFileSync(failedInstallHooks, 'utf8') === failedInstallBefore &&
248
+ !fs.existsSync(failedManifestPath),
249
+ 'failed manifest commit rolls hooks.json back byte-identically');
250
+
251
+ const linkedOwnerHome = path.join(root, 'linked-owner-codex');
252
+ const linkedOwnerOutside = path.join(root, 'linked-owner-outside');
253
+ fs.mkdirSync(path.join(linkedOwnerHome, 'harness-everything'), { recursive: true });
254
+ fs.mkdirSync(linkedOwnerOutside, { recursive: true });
255
+ fs.symlinkSync(
256
+ linkedOwnerOutside,
257
+ path.join(linkedOwnerHome, 'harness-everything', 'compat-hooks'),
258
+ process.platform === 'win32' ? 'junction' : 'dir'
259
+ );
260
+ let linkedOwnerRefused = false;
261
+ try { compat.install({ codexHome: linkedOwnerHome }); }
262
+ catch (error) { linkedOwnerRefused = /linked|outside/.test(error.message); }
263
+ check(linkedOwnerRefused && fs.readdirSync(linkedOwnerOutside).length === 0,
264
+ 'linked compatibility owner path cannot redirect writes outside Codex home');
265
+
266
+ const malformedHome = path.join(root, 'malformed-codex');
267
+ fs.mkdirSync(malformedHome, { recursive: true });
268
+ const malformed = path.join(malformedHome, 'hooks.json');
269
+ fs.writeFileSync(malformed, '{ this is not json', 'utf8');
270
+ const malformedBefore = fs.readFileSync(malformed, 'utf8');
271
+ let malformedRefused = false;
272
+ try { compat.install({ codexHome: malformedHome }); } catch (_) { malformedRefused = true; }
273
+ check(malformedRefused && fs.readFileSync(malformed, 'utf8') === malformedBefore,
274
+ 'malformed user hooks.json fails closed without replacement');
275
+
276
+ const source = read(path.join(compat.SOURCE_PLUGIN_ROOT, 'hooks', 'hooks.json'));
277
+ const sourceCount = Object.values(source.hooks).reduce((sum, entries) => sum + entries.length, 0);
278
+ check(sourceCount === installed.entries,
279
+ 'fallback derives from the current packaged hook manifest instead of maintaining a second manual hook inventory');
280
+ } finally {
281
+ fs.rmSync(root, { recursive: true, force: true });
282
+ }
283
+
284
+ console.log('\n' + (failed === 0 ? 'PASS' : 'FAIL') +
285
+ ': #122 Codex compatibility fallback (' + failed + ' failure' + (failed === 1 ? '' : 's') + ')');
286
+ process.exit(failed === 0 ? 0 : 1);
@@ -64,6 +64,22 @@ Codex has two paths that must not be collapsed:
64
64
 
65
65
  The package is tested at the mechanism layer and remains subject to the host’s hook review/trust flow. A fresh host session is still required before claiming that a particular ChatGPT/Codex installation loaded and fired the hooks or that the model followed read-before-skip.
66
66
 
67
+ ### Explicit Codex user-hook compatibility fallback
68
+
69
+ Current Codex documentation supports plugin-bundled hooks, so native plugin mounting remains the preferred path. Some host/source combinations have nevertheless failed to expose enabled plugin hooks for review/entry attribution (#122 and upstream Codex reports). Harness therefore provides an **explicit compatibility fallback**, not an automatic replacement for native plugin hooks:
70
+
71
+ ```bash
72
+ harness codex-hooks install
73
+ harness codex-hooks status
74
+ harness codex-hooks uninstall
75
+ ```
76
+
77
+ The fallback copies the current packaged runtime into `$CODEX_HOME/harness-everything/compat-hooks/` and merges absolute-path entries into `$CODEX_HOME/hooks.json`. It does **not** modify `config.toml`, trust hashes, approval policy, or hook enablement. Codex must still show/review/trust the resulting non-managed hooks through its normal `/hooks` flow.
78
+
79
+ Ownership is deliberately strict: update/uninstall removes only exact entries recorded in the Harness compatibility manifest. If an installed Harness entry was edited after installation, the operation fails closed rather than overwriting or deleting that user-modified entry. Unrelated pre-existing and later-added user hooks are preserved.
80
+
81
+ This fallback solves only plugin-source discovery/mounting. It does not claim to repair a Codex carrier that fails to dispatch `PreToolUse` at all; those host-level execution-path limitations remain a live compatibility boundary.
82
+
67
83
  The public OpenAI Skills-only plugin is narrower again. Its ZIP contains the packaged `skills/` tree and referenced files, but not the local `.codex-plugin` lifecycle hooks. Public submission readiness is therefore a package/form contract, not evidence of public-directory approval or live hook execution.
68
84
 
69
85
  ## Install/uninstall ownership boundary
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": "harness-platform-compatibility-v1",
3
- "last_reviewed": "2026-09-14",
3
+ "last_reviewed": "2026-09-19",
4
4
  "status_vocabulary": {
5
5
  "Supported": "An official or native mechanism exists and the Harness behavior is verified.",
6
6
  "Mechanism verified": "Harness package, adapter, or installer checks pass, but a real host run is not confirmed.",
@@ -109,13 +109,13 @@
109
109
  "standaloneSkills": {"status": "Supported", "paths": {"project": [".agents/skills"], "global": ["~/.agents/skills"]}},
110
110
  "pluginSupport": {"status": "Mechanism verified", "paths": {"manifest": ["plugin.json", ".codex-plugin/plugin.json"]}},
111
111
  "bundledSkills": {"status": "Mechanism verified", "notes": "The local package contains a root skills/ tree."},
112
- "hooksLifecycle": {"status": "Mechanism verified", "notes": "Codex-supported SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, SubagentStart, SubagentStop, and Stop are covered by package checks; the plugin intentionally does not declare unsupported PostToolUseFailure. Non-managed hooks still require trust."},
112
+ "hooksLifecycle": {"status": "Mechanism verified", "notes": "Codex officially supports plugin-bundled hooks and the local package is mechanism-tested. The plugin intentionally does not declare unsupported PostToolUseFailure; Codex uses documented PostToolUse semantics instead. Harness also provides an explicit user-config compatibility fallback that copies the packaged runtime and merges exact-owned entries into $CODEX_HOME/hooks.json without changing trust/config.toml. Neither path is promoted to live verified until a real host exposes and executes the hooks."},
113
113
  "projectScope": {"status": "Mechanism verified", "paths": {"skills": [".agents/skills"], "instructions": ["AGENTS.md"]}},
114
114
  "globalScope": {"status": "Mechanism verified", "paths": {"skills": ["~/.agents/skills"]}},
115
- "installUninstallSymmetry": {"status": "Mechanism verified", "notes": "Harness installer round-trip checks cover owned files and user-owned collisions."},
115
+ "installUninstallSymmetry": {"status": "Mechanism verified", "notes": "General Harness installer ownership is mechanism-tested. The optional codex-hooks compatibility fallback separately has exact-entry update/uninstall ownership tests and preserves unrelated user hooks; live Codex trust/dispatch still requires host evidence."},
116
116
  "assetsReferences": {"status": "Mechanism verified", "notes": "Packaged skill references are checked and remain inside the package."},
117
117
  "updateSync": {"status": "Unknown", "notes": "A real marketplace refresh has not been run in a Codex host."},
118
- "liveHostVerification": {"status": "Unknown", "notes": "No preserved Codex plugin/session artifact is committed."}
118
+ "liveHostVerification": {"status": "Unknown", "notes": "No preserved Codex plugin/session artifact proves current bundled-hook entry. Native plugin mounting is preferred; the explicit user-hook fallback is mechanism-verified only and does not solve host carriers that bypass hook dispatch."}
119
119
  }
120
120
  },
121
121
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -54,7 +54,9 @@
54
54
  "test:contract-integrity": "node ci/contract-integrity.test.js",
55
55
  "contract:integrity": "node contract-integrity/scripts/audit.js",
56
56
  "waza:check": "node scripts/waza-harness-check.js",
57
- "test:waza:compat": "node ci/mechanism-2aj-waza-reference-adapter.test.js"
57
+ "test:waza:compat": "node ci/mechanism-2aj-waza-reference-adapter.test.js",
58
+ "codex:hooks": "node scripts/codex-hooks-compat.js",
59
+ "test:codex:hooks-compat": "node ci/mechanism-2al-codex-hooks-compat.test.js"
58
60
  },
59
61
  "engines": {
60
62
  "node": ">=22"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "harness-everything",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
6
6
  "author": {
7
7
  "name": "Miya Daniel",
@@ -0,0 +1,521 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('crypto');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
10
+ const SOURCE_PLUGIN_ROOT = path.join(PACKAGE_ROOT, 'plugins', 'harness-everything');
11
+ const PACKAGE_VERSION = require(path.join(PACKAGE_ROOT, 'package.json')).version;
12
+ const MANIFEST_SCHEMA = 1;
13
+ const MANIFEST_MODE = 'codex-user-hooks-compat';
14
+
15
+ function stable(value) {
16
+ if (Array.isArray(value)) return value.map(stable);
17
+ if (!value || typeof value !== 'object') return value;
18
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
19
+ }
20
+
21
+ function stableJson(value) {
22
+ return JSON.stringify(stable(value));
23
+ }
24
+
25
+ function sha256(value) {
26
+ return crypto.createHash('sha256').update(value).digest('hex');
27
+ }
28
+
29
+ function treeHash(root) {
30
+ if (fs.lstatSync(root).isSymbolicLink()) {
31
+ throw new Error('refusing linked compatibility runtime: ' + root);
32
+ }
33
+ const rows = [];
34
+ function walk(dir) {
35
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
36
+ const full = path.join(dir, entry.name);
37
+ const rel = path.relative(root, full).replace(/\\/g, '/');
38
+ if (entry.isDirectory()) walk(full);
39
+ else if (entry.isFile()) rows.push(rel + ':' + sha256(fs.readFileSync(full)));
40
+ else if (entry.isSymbolicLink()) throw new Error('refusing linked compatibility runtime entry: ' + full);
41
+ }
42
+ }
43
+ walk(root);
44
+ return sha256(rows.join('\n'));
45
+ }
46
+
47
+ function entryHash(entry) {
48
+ return sha256(stableJson(entry));
49
+ }
50
+
51
+ function defaultCodexHome(env = process.env) {
52
+ return path.resolve(env.CODEX_HOME || path.join(os.homedir(), '.codex'));
53
+ }
54
+
55
+ function pathsFor(codexHome, sourceHash = null) {
56
+ const home = path.resolve(codexHome);
57
+ const ownerRoot = path.join(home, 'harness-everything', 'compat-hooks');
58
+ const runtimeId = sourceHash ? PACKAGE_VERSION + '-' + sourceHash.slice(0, 12) : null;
59
+ return {
60
+ codexHome: home,
61
+ hooksFile: path.join(home, 'hooks.json'),
62
+ configFile: path.join(home, 'config.toml'),
63
+ ownerRoot,
64
+ manifestFile: path.join(ownerRoot, 'manifest.json'),
65
+ runtimeRoot: path.join(ownerRoot, 'runtime'),
66
+ runtimeDir: runtimeId ? path.join(ownerRoot, 'runtime', runtimeId) : null,
67
+ };
68
+ }
69
+
70
+ function readJsonObject(file, fallback) {
71
+ if (!fs.existsSync(file)) return fallback;
72
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
73
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
74
+ throw new Error(file + ' must contain a JSON object');
75
+ }
76
+ return parsed;
77
+ }
78
+
79
+ function readHooksFile(file) {
80
+ const data = readJsonObject(file, { hooks: {} });
81
+ if (data.hooks === undefined) data.hooks = {};
82
+ if (!data.hooks || typeof data.hooks !== 'object' || Array.isArray(data.hooks)) {
83
+ throw new Error(file + ' has invalid hooks object');
84
+ }
85
+ for (const [event, entries] of Object.entries(data.hooks)) {
86
+ if (!Array.isArray(entries)) throw new Error(file + ' hooks.' + event + ' must be an array');
87
+ }
88
+ return data;
89
+ }
90
+
91
+ function samePath(left, right) {
92
+ const normalize = value => {
93
+ const resolved = path.resolve(value);
94
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
95
+ };
96
+ return normalize(left) === normalize(right);
97
+ }
98
+
99
+ function isPathInside(root, candidate) {
100
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
101
+ return relative !== '' && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
102
+ }
103
+
104
+ function assertOwnedPath(codexHome, target, label) {
105
+ const home = path.resolve(codexHome);
106
+ const resolvedTarget = path.resolve(target);
107
+ if (!samePath(home, resolvedTarget) && !isPathInside(home, resolvedTarget)) {
108
+ throw new Error('refusing outside Codex home ' + label + ': ' + target);
109
+ }
110
+ if (!fs.existsSync(home)) return;
111
+
112
+ const realpath = fs.realpathSync.native || fs.realpathSync;
113
+ const physicalHome = realpath(home);
114
+ const relative = path.relative(home, resolvedTarget);
115
+ let current = home;
116
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
117
+ current = path.join(current, segment);
118
+ if (!fs.existsSync(current)) break;
119
+ if (fs.lstatSync(current).isSymbolicLink()) {
120
+ throw new Error('refusing linked ' + label + ': ' + current);
121
+ }
122
+ const physical = realpath(current);
123
+ if (!samePath(physicalHome, physical) && !isPathInside(physicalHome, physical)) {
124
+ throw new Error('refusing outside Codex home ' + label + ': ' + current);
125
+ }
126
+ }
127
+ }
128
+
129
+ function assertCompatibilityPaths(paths) {
130
+ for (const [label, target] of [
131
+ ['hooks file', paths.hooksFile],
132
+ ['compatibility owner', paths.ownerRoot],
133
+ ['compatibility manifest', paths.manifestFile],
134
+ ['compatibility runtime root', paths.runtimeRoot],
135
+ ['compatibility runtime', paths.runtimeDir],
136
+ ]) {
137
+ if (target) assertOwnedPath(paths.codexHome, target, label);
138
+ }
139
+ }
140
+
141
+ function readManifest(file, expectedPaths = null) {
142
+ if (!fs.existsSync(file)) return null;
143
+ const data = readJsonObject(file, null);
144
+ if (data.schemaVersion !== MANIFEST_SCHEMA ||
145
+ data.package !== 'harness-everything' ||
146
+ data.mode !== MANIFEST_MODE ||
147
+ typeof data.packageVersion !== 'string' ||
148
+ !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(data.packageVersion) ||
149
+ typeof data.sourcePluginSha256 !== 'string' ||
150
+ !/^[0-9a-f]{64}$/.test(data.sourcePluginSha256) ||
151
+ typeof data.runtimeDir !== 'string' ||
152
+ typeof data.hooksFile !== 'string' ||
153
+ typeof data.createdHooksFile !== 'boolean' ||
154
+ !Array.isArray(data.entries)) {
155
+ throw new Error('refusing unknown/corrupt Harness compatibility manifest: ' + file);
156
+ }
157
+ if (!expectedPaths) return data;
158
+
159
+ const expectedRuntimeDir = path.join(
160
+ expectedPaths.runtimeRoot,
161
+ data.packageVersion + '-' + data.sourcePluginSha256.slice(0, 12)
162
+ );
163
+ assertOwnedPath(expectedPaths.codexHome, data.runtimeDir, 'manifest runtime');
164
+ if (!samePath(data.hooksFile, expectedPaths.hooksFile) ||
165
+ !isPathInside(expectedPaths.runtimeRoot, data.runtimeDir) ||
166
+ !samePath(data.runtimeDir, expectedRuntimeDir) ||
167
+ !fs.existsSync(data.runtimeDir) ||
168
+ treeHash(data.runtimeDir) !== data.sourcePluginSha256) {
169
+ throw new Error('refusing unknown/corrupt Harness compatibility manifest: ' + file);
170
+ }
171
+
172
+ const allowed = new Map();
173
+ for (const record of hookEntriesFrom(path.resolve(data.runtimeDir), path.resolve(data.runtimeDir))) {
174
+ const key = record.event + '\0' + record.hash;
175
+ if (!allowed.has(key)) allowed.set(key, []);
176
+ allowed.get(key).push(stableJson(record.entry));
177
+ }
178
+ for (const record of data.entries) {
179
+ if (!record || typeof record !== 'object' || Array.isArray(record) ||
180
+ typeof record.event !== 'string' || !record.event ||
181
+ typeof record.hash !== 'string' || !/^[0-9a-f]{64}$/.test(record.hash) ||
182
+ !record.entry || typeof record.entry !== 'object' || Array.isArray(record.entry) ||
183
+ entryHash(record.entry) !== record.hash) {
184
+ throw new Error('refusing unknown/corrupt Harness compatibility manifest: ' + file);
185
+ }
186
+ const key = record.event + '\0' + record.hash;
187
+ const candidates = allowed.get(key) || [];
188
+ const serialized = stableJson(record.entry);
189
+ const match = candidates.indexOf(serialized);
190
+ if (match === -1) {
191
+ throw new Error('refusing unknown/corrupt Harness compatibility manifest: ' + file);
192
+ }
193
+ candidates.splice(match, 1);
194
+ }
195
+ return data;
196
+ }
197
+
198
+ function atomicWriteFile(file, value) {
199
+ fs.mkdirSync(path.dirname(file), { recursive: true });
200
+ const temp = file + '.' + process.pid + '.tmp';
201
+ try {
202
+ fs.writeFileSync(temp, value);
203
+ fs.renameSync(temp, file);
204
+ } finally {
205
+ try { if (fs.existsSync(temp)) fs.rmSync(temp, { force: true }); } catch (_) {}
206
+ }
207
+ }
208
+
209
+ function atomicWriteJson(file, value) {
210
+ atomicWriteFile(file, JSON.stringify(value, null, 2) + '\n');
211
+ }
212
+
213
+ function replacePluginRoot(value, runtimeDir, windowsForm) {
214
+ if (typeof value !== 'string') return value;
215
+ const root = windowsForm
216
+ ? runtimeDir.replace(/\//g, '\\')
217
+ : runtimeDir.replace(/\\/g, '/');
218
+ return value
219
+ .replace(/\$\{PLUGIN_ROOT\}/g, root)
220
+ .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, root);
221
+ }
222
+
223
+ function transformEntry(entry, runtimeDir) {
224
+ function visit(value, key) {
225
+ if (Array.isArray(value)) return value.map(item => visit(item, key));
226
+ if (value && typeof value === 'object') {
227
+ return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, visit(child, childKey)]));
228
+ }
229
+ if (typeof value === 'string') return replacePluginRoot(value, runtimeDir, key === 'commandWindows');
230
+ return value;
231
+ }
232
+ return visit(JSON.parse(JSON.stringify(entry)), null);
233
+ }
234
+
235
+ function containsPath(entry, target) {
236
+ const normalized = path.resolve(target).replace(/\\/g, '/').toLowerCase();
237
+ const text = JSON.stringify(entry).replace(/\\\\/g, '/').replace(/\\/g, '/').toLowerCase();
238
+ return text.includes(normalized);
239
+ }
240
+
241
+ function removePriorEntries(config, prior) {
242
+ const priorByEvent = new Map();
243
+ for (const record of prior.entries || []) {
244
+ if (!priorByEvent.has(record.event)) priorByEvent.set(record.event, new Map());
245
+ const counts = priorByEvent.get(record.event);
246
+ counts.set(record.hash, (counts.get(record.hash) || 0) + 1);
247
+ }
248
+
249
+ const drift = [];
250
+ const removed = [];
251
+ const nextHooks = { ...config.hooks };
252
+ for (const [event, expectedCounts] of priorByEvent) {
253
+ const remaining = new Map(expectedCounts);
254
+ const entries = Array.isArray(nextHooks[event]) ? nextHooks[event] : [];
255
+ const kept = [];
256
+ for (const entry of entries) {
257
+ const hash = entryHash(entry);
258
+ const count = remaining.get(hash) || 0;
259
+ if (count > 0) {
260
+ removed.push({ event, hash });
261
+ remaining.set(hash, count - 1);
262
+ continue;
263
+ }
264
+ if (prior.runtimeDir && containsPath(entry, prior.runtimeDir)) {
265
+ drift.push({
266
+ event,
267
+ hash,
268
+ reason: expectedCounts.has(hash)
269
+ ? 'unexpected duplicate Harness compatibility hook'
270
+ : 'Harness compatibility hook was modified after install',
271
+ });
272
+ }
273
+ kept.push(entry);
274
+ }
275
+ nextHooks[event] = kept;
276
+ }
277
+ return { config: { ...config, hooks: nextHooks }, drift, removed };
278
+ }
279
+
280
+ function hookEntriesFrom(pluginRoot, runtimeDir) {
281
+ const sourceFile = path.join(pluginRoot, 'hooks', 'hooks.json');
282
+ const source = readHooksFile(sourceFile);
283
+ const records = [];
284
+ for (const [event, entries] of Object.entries(source.hooks)) {
285
+ for (const entry of entries) {
286
+ const transformed = transformEntry(entry, runtimeDir);
287
+ records.push({ event, hash: entryHash(transformed), entry: transformed });
288
+ }
289
+ }
290
+ return records;
291
+ }
292
+
293
+ function sourceHookEntries(runtimeDir) {
294
+ return hookEntriesFrom(SOURCE_PLUGIN_ROOT, runtimeDir);
295
+ }
296
+
297
+ function ensureRuntime(runtimeDir, sourceHash) {
298
+ if (fs.existsSync(runtimeDir)) {
299
+ if (treeHash(runtimeDir) === sourceHash) return;
300
+ fs.rmSync(runtimeDir, { recursive: true, force: true });
301
+ }
302
+ fs.mkdirSync(path.dirname(runtimeDir), { recursive: true });
303
+ const temp = runtimeDir + '.tmp-' + process.pid;
304
+ try {
305
+ fs.rmSync(temp, { recursive: true, force: true });
306
+ fs.cpSync(SOURCE_PLUGIN_ROOT, temp, { recursive: true });
307
+ if (treeHash(temp) !== sourceHash) throw new Error('copied compatibility runtime hash mismatch');
308
+ fs.renameSync(temp, runtimeDir);
309
+ } finally {
310
+ try { if (fs.existsSync(temp)) fs.rmSync(temp, { recursive: true, force: true }); } catch (_) {}
311
+ }
312
+ }
313
+
314
+ function install(options = {}) {
315
+ const codexHome = path.resolve(options.codexHome || defaultCodexHome(options.env));
316
+ const sourceHash = treeHash(SOURCE_PLUGIN_ROOT);
317
+ const p = pathsFor(codexHome, sourceHash);
318
+ assertCompatibilityPaths(p);
319
+ const existedBefore = fs.existsSync(p.hooksFile);
320
+ const hooksBefore = existedBefore ? fs.readFileSync(p.hooksFile) : null;
321
+ const runtimeExistedBefore = fs.existsSync(p.runtimeDir);
322
+ const config = readHooksFile(p.hooksFile);
323
+ const prior = readManifest(p.manifestFile, p);
324
+
325
+ let base = config;
326
+ if (prior) {
327
+ const removal = removePriorEntries(config, prior);
328
+ if (removal.drift.length) {
329
+ throw new Error('refusing to update: installed Harness compatibility hooks were modified; restore or uninstall them manually first');
330
+ }
331
+ base = removal.config;
332
+ }
333
+
334
+ ensureRuntime(p.runtimeDir, sourceHash);
335
+ assertCompatibilityPaths(p);
336
+ const generated = sourceHookEntries(p.runtimeDir);
337
+ const ownedEntries = [];
338
+ const next = { ...base, hooks: { ...base.hooks } };
339
+ for (const record of generated) {
340
+ const list = Array.isArray(next.hooks[record.event]) ? [...next.hooks[record.event]] : [];
341
+ if (!list.some(entry => entryHash(entry) === record.hash)) {
342
+ list.push(record.entry);
343
+ ownedEntries.push(record);
344
+ }
345
+ next.hooks[record.event] = list;
346
+ }
347
+
348
+ const manifest = {
349
+ schemaVersion: MANIFEST_SCHEMA,
350
+ package: 'harness-everything',
351
+ mode: MANIFEST_MODE,
352
+ packageVersion: PACKAGE_VERSION,
353
+ sourcePluginSha256: sourceHash,
354
+ runtimeDir: p.runtimeDir,
355
+ hooksFile: p.hooksFile,
356
+ createdHooksFile: prior ? Boolean(prior.createdHooksFile) : !existedBefore,
357
+ installedAt: new Date().toISOString(),
358
+ entries: ownedEntries.map(({ event, hash, entry }) => ({ event, hash, entry })),
359
+ };
360
+ try {
361
+ assertCompatibilityPaths(p);
362
+ atomicWriteJson(p.hooksFile, next);
363
+ atomicWriteJson(p.manifestFile, manifest);
364
+ } catch (error) {
365
+ try {
366
+ if (hooksBefore === null) fs.rmSync(p.hooksFile, { force: true });
367
+ else atomicWriteFile(p.hooksFile, hooksBefore);
368
+ } catch (rollbackError) {
369
+ error.message += '; hooks.json rollback also failed: ' + rollbackError.message;
370
+ }
371
+ if (!runtimeExistedBefore) {
372
+ try { fs.rmSync(p.runtimeDir, { recursive: true, force: true }); } catch (_) {}
373
+ }
374
+ throw error;
375
+ }
376
+
377
+ if (prior && prior.runtimeDir && path.resolve(prior.runtimeDir) !== path.resolve(p.runtimeDir)) {
378
+ const ownedRoot = path.resolve(p.runtimeRoot) + path.sep;
379
+ const oldRuntime = path.resolve(prior.runtimeDir);
380
+ if (oldRuntime.startsWith(ownedRoot)) fs.rmSync(oldRuntime, { recursive: true, force: true });
381
+ }
382
+
383
+ return {
384
+ status: prior ? 'updated' : 'installed',
385
+ codexHome,
386
+ hooksFile: p.hooksFile,
387
+ runtimeDir: p.runtimeDir,
388
+ entries: generated.length,
389
+ trustChanged: false,
390
+ };
391
+ }
392
+
393
+ function uninstall(options = {}) {
394
+ const codexHome = path.resolve(options.codexHome || defaultCodexHome(options.env));
395
+ const p = pathsFor(codexHome);
396
+ assertCompatibilityPaths(p);
397
+ const prior = readManifest(p.manifestFile, p);
398
+ if (!prior) return { status: 'not-installed', codexHome, removed: 0, trustChanged: false };
399
+
400
+ const hooksFileExists = fs.existsSync(p.hooksFile);
401
+ const config = readHooksFile(p.hooksFile);
402
+ const removal = removePriorEntries(config, prior);
403
+ if (removal.drift.length) {
404
+ throw new Error('refusing to uninstall modified Harness compatibility hooks; no user config was changed');
405
+ }
406
+
407
+ const remainingHooks = Object.fromEntries(
408
+ Object.entries(removal.config.hooks)
409
+ .map(([event, entries]) => [event, entries])
410
+ .filter(([, entries]) => Array.isArray(entries) && entries.length > 0)
411
+ );
412
+ const next = { ...removal.config, hooks: remainingHooks };
413
+ const otherTopLevelKeys = Object.keys(next).filter(key => key !== 'hooks');
414
+
415
+ if (!hooksFileExists) {
416
+ // The user or host already removed the config. Uninstall must not recreate it.
417
+ } else if (prior.createdHooksFile && otherTopLevelKeys.length === 0 && Object.keys(remainingHooks).length === 0) {
418
+ fs.rmSync(p.hooksFile, { force: true });
419
+ } else {
420
+ atomicWriteJson(p.hooksFile, next);
421
+ }
422
+
423
+ if (prior.runtimeDir) {
424
+ const ownedRoot = path.resolve(p.runtimeRoot) + path.sep;
425
+ const runtime = path.resolve(prior.runtimeDir);
426
+ if (runtime.startsWith(ownedRoot)) fs.rmSync(runtime, { recursive: true, force: true });
427
+ }
428
+ fs.rmSync(p.manifestFile, { force: true });
429
+ try { fs.rmdirSync(p.runtimeRoot); } catch (_) {}
430
+ try { fs.rmdirSync(p.ownerRoot); } catch (_) {}
431
+ try { fs.rmdirSync(path.dirname(p.ownerRoot)); } catch (_) {}
432
+
433
+ return {
434
+ status: 'uninstalled',
435
+ codexHome,
436
+ removed: removal.removed.length,
437
+ trustChanged: false,
438
+ };
439
+ }
440
+
441
+ function status(options = {}) {
442
+ const codexHome = path.resolve(options.codexHome || defaultCodexHome(options.env));
443
+ const p = pathsFor(codexHome);
444
+ assertCompatibilityPaths(p);
445
+ const prior = readManifest(p.manifestFile, p);
446
+ if (!prior) return { installed: false, codexHome, hooksFile: p.hooksFile, trustChanged: false };
447
+ const config = readHooksFile(p.hooksFile);
448
+ const removal = removePriorEntries(config, prior);
449
+ const expected = prior.entries.length;
450
+ return {
451
+ installed: true,
452
+ codexHome,
453
+ hooksFile: p.hooksFile,
454
+ runtimeDir: prior.runtimeDir,
455
+ packageVersion: prior.packageVersion,
456
+ sourcePluginSha256: prior.sourcePluginSha256,
457
+ expectedEntries: expected,
458
+ exactEntriesPresent: removal.removed.length,
459
+ drift: removal.drift,
460
+ healthy: removal.removed.length === expected && removal.drift.length === 0 &&
461
+ fs.existsSync(prior.runtimeDir) && treeHash(prior.runtimeDir) === prior.sourcePluginSha256,
462
+ trustChanged: false,
463
+ };
464
+ }
465
+
466
+ function parseArgs(argv) {
467
+ const action = argv[0] || 'status';
468
+ if (!['install', 'uninstall', 'status'].includes(action)) {
469
+ throw new Error('Usage: harness codex-hooks <install|uninstall|status> [--codex-home <path>]');
470
+ }
471
+ let codexHome;
472
+ for (let i = 1; i < argv.length; i++) {
473
+ if (argv[i] === '--codex-home') codexHome = path.resolve(argv[++i]);
474
+ else throw new Error('unknown argument: ' + argv[i]);
475
+ }
476
+ return { action, codexHome };
477
+ }
478
+
479
+ function main(argv = process.argv.slice(2)) {
480
+ let args;
481
+ try { args = parseArgs(argv); }
482
+ catch (error) {
483
+ console.error('[Codex Hooks Compatibility] ' + error.message);
484
+ return 2;
485
+ }
486
+
487
+ try {
488
+ const result = args.action === 'install'
489
+ ? install({ codexHome: args.codexHome })
490
+ : args.action === 'uninstall'
491
+ ? uninstall({ codexHome: args.codexHome })
492
+ : status({ codexHome: args.codexHome });
493
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
494
+ if (args.action === 'status' && result.installed && !result.healthy) return 1;
495
+ return 0;
496
+ } catch (error) {
497
+ console.error('[Codex Hooks Compatibility] ' + error.message);
498
+ return 1;
499
+ }
500
+ }
501
+
502
+ if (require.main === module) process.exit(main());
503
+
504
+ module.exports = {
505
+ MANIFEST_MODE,
506
+ MANIFEST_SCHEMA,
507
+ SOURCE_PLUGIN_ROOT,
508
+ defaultCodexHome,
509
+ entryHash,
510
+ install,
511
+ main,
512
+ pathsFor,
513
+ readHooksFile,
514
+ removePriorEntries,
515
+ sourceHookEntries,
516
+ stableJson,
517
+ status,
518
+ transformEntry,
519
+ treeHash,
520
+ uninstall,
521
+ };