harness-everything 0.14.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +32 -0
- package/bin/cli.js +17 -0
- package/ci/contract-integrity.test.js +155 -1
- package/ci/mechanism-2al-codex-hooks-compat.test.js +286 -0
- package/contract-integrity/scripts/audit.js +225 -2
- package/docs/platform-capabilities.md +16 -0
- package/docs/platform-compatibility.json +4 -4
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +4 -2
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/contract-integrity/scripts/audit.js +225 -2
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/verification-loop/SKILL.md +2 -2
- package/plugins/harness-everything/skills/verification-loop/references/verification-phases.md +9 -1
- package/scripts/codex-hooks-compat.js +521 -0
- package/verification-loop/SKILL.md +2 -2
- package/verification-loop/references/verification-phases.md +9 -1
|
@@ -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
|
+
};
|
|
@@ -4,7 +4,7 @@ description: "Run objective verification gates (build, types, lint, tests, secur
|
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
6
|
author: Miya Daniel
|
|
7
|
-
version: 0.
|
|
7
|
+
version: 0.15.0
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Verification Loop
|
|
@@ -41,7 +41,7 @@ and rerun from the first affected gate.
|
|
|
41
41
|
4. Tests and coverage: `npm run test -- --coverage`.
|
|
42
42
|
5. Security scan: secrets and stray logs.
|
|
43
43
|
6. Diff review: `git diff --stat` and unintended-change checks.
|
|
44
|
-
7. Contract reconciliation when the work cites `REQ-*` / ADR/spec/ticket lineage or a contract-integrity trace exists: run the strict `<this-skill-dir>/scripts/contract-integrity-audit.js` gate with current #58 evidence. `AUDIT`, `NOT_EVALUATED`, stale source binding, or drift is **not ready** for delivery.
|
|
44
|
+
7. Contract reconciliation when the work cites `REQ-*` / ADR/spec/ticket lineage or a contract-integrity trace exists: run the strict `<this-skill-dir>/scripts/contract-integrity-audit.js` gate with current #58 evidence and `--workspace <workspace>` so traced source/test/implementation artifacts are fingerprinted. `AUDIT`, `NOT_EVALUATED`, stale source binding, or drift is **not ready** for delivery.
|
|
45
45
|
8. Fill `<this-skill-dir>/templates/verification-report.template.md` with evidence.
|
|
46
46
|
|
|
47
47
|
Deep dive: `<this-skill-dir>/references/verification-phases.md`
|
|
@@ -110,11 +110,19 @@ This phase is applicable when any of the following is true:
|
|
|
110
110
|
Use the current trace plus current #58 TDD evidence:
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
-
node "<this-skill-dir>/scripts/contract-integrity-audit.js" <trace.json> --tdd-evidence <tdd-evidence.json> --output <report.json> --markdown <report.md>
|
|
113
|
+
node "<this-skill-dir>/scripts/contract-integrity-audit.js" <trace.json> --tdd-evidence <tdd-evidence.json> --workspace <workspace> --output <report.json> --markdown <report.md>
|
|
114
114
|
```
|
|
115
115
|
|
|
116
116
|
Delivery requires strict **PASS**. Treat `FAIL`, `AUDIT`, `NOT_EVALUATED`, source conflicts/defects, stale spec/test/implementation lineage, surviving required probes, and audit-only output as **NOT READY**. Do not downgrade the gate merely because build/tests are otherwise green.
|
|
117
117
|
|
|
118
|
+
If an existing JSON report is being reused rather than regenerated immediately, verify its content fingerprints first:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
node "<this-skill-dir>/scripts/contract-integrity-audit.js" <trace.json> --tdd-evidence <tdd-evidence.json> --workspace <workspace> --verify-fresh <report.json>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Any trace, #58 evidence, spec, test, ticket, ADR, or implementation artifact change makes the prior report stale and unable to authorize completion.
|
|
125
|
+
|
|
118
126
|
If this phase is not applicable, record `NOT_APPLICABLE` with a one-line reason. Never use `NOT_APPLICABLE` to hide known requirement lineage.
|
|
119
127
|
|
|
120
128
|
## Output Format
|