harness-everything 0.31.1 → 0.31.3
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 +30 -0
- package/bin/cli.js +1 -0
- package/ci/lib/isolated-test-process.js +30 -0
- package/ci/mechanism-2al-codex-hooks-compat.test.js +14 -14
- package/ci/mechanism-2ao-plugin-sync.test.js +19 -2
- package/ci/mechanism-2as-workflow-disposition-help.test.js +41 -0
- package/ci/mechanism-2at-fable-execution-phases.test.js +46 -0
- package/ci/mechanism-2au-codex-runtime-safety.test.js +340 -0
- package/ci/mechanism-2av-test-home-isolation.test.js +48 -0
- package/ci/mechanism-test.js +2 -2
- package/docs/platform-capabilities.md +4 -0
- package/docs/platform-compatibility.json +1 -1
- package/fable-mode/SKILL.md +1 -1
- package/fable-mode/execution-guardrails/SKILL.md +1 -1
- package/fable-mode/fable-haiku/SKILL.md +1 -1
- package/fable-mode/fable-opus/SKILL.md +1 -1
- package/fable-mode/fable-sonnet/SKILL.md +1 -1
- package/fable-mode/references/execution-phases.md +93 -25
- package/hooks/scripts/workflow-disposition.js +4 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +2 -2
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +4 -0
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
- package/plugins/harness-everything/skills/fable-mode/references/execution-phases.md +93 -25
- package/plugins/harness-everything/skills/self-evolve/SKILL.md +1 -1
- package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +8 -6
- package/sbom.cdx.json +12 -12
- package/scripts/codex-hooks-compat.js +265 -25
- package/self-evolve/SKILL.md +1 -1
- package/self-evolve/scripts/self-regression.js +8 -6
|
@@ -5,12 +5,14 @@ const crypto = require('crypto');
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
8
9
|
|
|
9
10
|
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
10
11
|
const SOURCE_PLUGIN_ROOT = path.join(PACKAGE_ROOT, 'plugins', 'harness-everything');
|
|
11
12
|
const PACKAGE_VERSION = require(path.join(PACKAGE_ROOT, 'package.json')).version;
|
|
12
13
|
const MANIFEST_SCHEMA = 1;
|
|
13
14
|
const MANIFEST_MODE = 'codex-user-hooks-compat';
|
|
15
|
+
const RUNTIME_NAME_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?-([0-9a-f]{12})$/;
|
|
14
16
|
|
|
15
17
|
function stable(value) {
|
|
16
18
|
if (Array.isArray(value)) return value.map(stable);
|
|
@@ -26,7 +28,8 @@ function sha256(value) {
|
|
|
26
28
|
return crypto.createHash('sha256').update(value).digest('hex');
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
// `include` (relative paths) limits a Git-tracked source to its released file set.
|
|
32
|
+
function treeHash(root, include = null) {
|
|
30
33
|
if (fs.lstatSync(root).isSymbolicLink()) {
|
|
31
34
|
throw new Error('refusing linked compatibility runtime: ' + root);
|
|
32
35
|
}
|
|
@@ -36,7 +39,9 @@ function treeHash(root) {
|
|
|
36
39
|
const full = path.join(dir, entry.name);
|
|
37
40
|
const rel = path.relative(root, full).replace(/\\/g, '/');
|
|
38
41
|
if (entry.isDirectory()) walk(full);
|
|
39
|
-
else if (entry.isFile())
|
|
42
|
+
else if (entry.isFile()) {
|
|
43
|
+
if (!include || include.has(rel)) rows.push(rel + ':' + sha256(fs.readFileSync(full)));
|
|
44
|
+
}
|
|
40
45
|
else if (entry.isSymbolicLink()) throw new Error('refusing linked compatibility runtime entry: ' + full);
|
|
41
46
|
}
|
|
42
47
|
}
|
|
@@ -182,6 +187,19 @@ function readManifest(file, expectedPaths = null) {
|
|
|
182
187
|
throw new Error('refusing unknown/corrupt Harness compatibility manifest: ' + file);
|
|
183
188
|
}
|
|
184
189
|
|
|
190
|
+
if (data.previousRuntimeDir != null) {
|
|
191
|
+
const previous = data.previousRuntimeDir;
|
|
192
|
+
const previousHash = data.previousSourcePluginSha256;
|
|
193
|
+
const match = typeof previous === 'string' && RUNTIME_NAME_RE.exec(path.basename(previous));
|
|
194
|
+
if (!match ||
|
|
195
|
+
!samePath(path.dirname(previous), expectedPaths.runtimeRoot) ||
|
|
196
|
+
(previousHash != null && (typeof previousHash !== 'string' ||
|
|
197
|
+
!/^[0-9a-f]{64}$/.test(previousHash) || previousHash.slice(0, 12) !== match[1]))) {
|
|
198
|
+
throw new Error('refusing corrupt previous compatibility runtime: ' + file);
|
|
199
|
+
}
|
|
200
|
+
assertOwnedPath(expectedPaths.codexHome, previous, 'previous runtime');
|
|
201
|
+
}
|
|
202
|
+
|
|
185
203
|
const allowed = new Map();
|
|
186
204
|
for (const record of hookEntriesFrom(path.resolve(data.runtimeDir), path.resolve(data.runtimeDir))) {
|
|
187
205
|
const key = record.event + '\0' + record.hash;
|
|
@@ -307,7 +325,21 @@ function sourceHookEntries(runtimeDir, sourcePluginRoot = SOURCE_PLUGIN_ROOT) {
|
|
|
307
325
|
return hookEntriesFrom(sourcePluginRoot, runtimeDir);
|
|
308
326
|
}
|
|
309
327
|
|
|
310
|
-
|
|
328
|
+
// Filesystem integrity only: Codex hook trust and dispatch are not observable here.
|
|
329
|
+
function previousRuntimeHealth(manifest) {
|
|
330
|
+
const runtimeDir = manifest.previousRuntimeDir;
|
|
331
|
+
if (!runtimeDir) return { status: 'none', runtimeDir: null, healthy: true };
|
|
332
|
+
if (!fs.existsSync(runtimeDir)) return { status: 'missing', runtimeDir, healthy: false };
|
|
333
|
+
const actual = treeHash(runtimeDir);
|
|
334
|
+
const intact = manifest.previousSourcePluginSha256
|
|
335
|
+
? actual === manifest.previousSourcePluginSha256
|
|
336
|
+
: actual.slice(0, 12) === RUNTIME_NAME_RE.exec(path.basename(runtimeDir))[1];
|
|
337
|
+
return intact
|
|
338
|
+
? { status: 'present', runtimeDir, healthy: true }
|
|
339
|
+
: { status: 'modified', runtimeDir, healthy: false };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function ensureRuntime(runtimeDir, sourceHash, sourcePluginRoot = SOURCE_PLUGIN_ROOT, include = null) {
|
|
311
343
|
if (fs.existsSync(runtimeDir)) {
|
|
312
344
|
if (treeHash(runtimeDir) === sourceHash) return;
|
|
313
345
|
fs.rmSync(runtimeDir, { recursive: true, force: true });
|
|
@@ -316,7 +348,11 @@ function ensureRuntime(runtimeDir, sourceHash, sourcePluginRoot = SOURCE_PLUGIN_
|
|
|
316
348
|
const temp = runtimeDir + '.tmp-' + process.pid;
|
|
317
349
|
try {
|
|
318
350
|
fs.rmSync(temp, { recursive: true, force: true });
|
|
319
|
-
fs.cpSync(sourcePluginRoot, temp, {
|
|
351
|
+
fs.cpSync(sourcePluginRoot, temp, {
|
|
352
|
+
recursive: true,
|
|
353
|
+
filter: src => !include || fs.lstatSync(src).isDirectory() ||
|
|
354
|
+
include.has(path.relative(sourcePluginRoot, src).replace(/\\/g, '/')),
|
|
355
|
+
});
|
|
320
356
|
if (treeHash(temp) !== sourceHash) throw new Error('copied compatibility runtime hash mismatch');
|
|
321
357
|
fs.renameSync(temp, runtimeDir);
|
|
322
358
|
} finally {
|
|
@@ -324,13 +360,194 @@ function ensureRuntime(runtimeDir, sourceHash, sourcePluginRoot = SOURCE_PLUGIN_
|
|
|
324
360
|
}
|
|
325
361
|
}
|
|
326
362
|
|
|
363
|
+
// Inspect the actual source rather than the installer checkout or caller CWD.
|
|
364
|
+
// An enclosing repository only owns this source if it tracks a plugin manifest.
|
|
365
|
+
// Returns the tracked file set for a Git-owned source, or null to copy the whole tree.
|
|
366
|
+
// Ignored files (.DS_Store, __pycache__) are excluded from the runtime instead of refusing.
|
|
367
|
+
function releasedSourceFiles(sourcePluginRoot, allowUnreleasedSource) {
|
|
368
|
+
if (allowUnreleasedSource) return null;
|
|
369
|
+
let root = fs.realpathSync(sourcePluginRoot);
|
|
370
|
+
while (!fs.existsSync(path.join(root, '.git'))) {
|
|
371
|
+
const parent = path.dirname(root);
|
|
372
|
+
if (parent === root) return null;
|
|
373
|
+
root = parent;
|
|
374
|
+
}
|
|
375
|
+
const sourceRelative = path.relative(root, fs.realpathSync(sourcePluginRoot)).replace(/\\/g, '/');
|
|
376
|
+
let tracked = false;
|
|
377
|
+
for (const manifest of ['.codex-plugin/plugin.json', 'plugin.json']) {
|
|
378
|
+
const relative = path.posix.join(sourceRelative, manifest);
|
|
379
|
+
const result = spawnSync('git', ['-C', root, 'ls-files', '--error-unmatch', '--', ':(literal)' + relative], {
|
|
380
|
+
encoding: 'utf8',
|
|
381
|
+
});
|
|
382
|
+
if (result.error || ![0, 1].includes(result.status)) {
|
|
383
|
+
throw new Error('refusing unverifiable Git source; use --allow-unreleased-source explicitly');
|
|
384
|
+
}
|
|
385
|
+
if (result.status === 0) { tracked = true; break; }
|
|
386
|
+
}
|
|
387
|
+
if (!tracked) return null;
|
|
388
|
+
if (fs.statSync(path.join(root, '.git')).isFile()) {
|
|
389
|
+
throw new Error('refusing unreleased linked Git worktree source; use --allow-unreleased-source explicitly');
|
|
390
|
+
}
|
|
391
|
+
const pathspec = ':(literal)' + (sourceRelative || '.');
|
|
392
|
+
const result = spawnSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all',
|
|
393
|
+
'--', pathspec], { encoding: 'utf8' });
|
|
394
|
+
if (result.error || result.status !== 0 || result.stdout.trim()) {
|
|
395
|
+
throw new Error('refusing dirty or unverifiable Git source; use --allow-unreleased-source explicitly');
|
|
396
|
+
}
|
|
397
|
+
const listed = spawnSync('git', ['-C', root, 'ls-files', '-z', '--', pathspec], { encoding: 'utf8' });
|
|
398
|
+
if (listed.error || listed.status !== 0) {
|
|
399
|
+
throw new Error('refusing unverifiable Git source; use --allow-unreleased-source explicitly');
|
|
400
|
+
}
|
|
401
|
+
const prefix = sourceRelative ? sourceRelative + '/' : '';
|
|
402
|
+
return new Set(listed.stdout.split('\0').filter(Boolean)
|
|
403
|
+
.filter(file => file.startsWith(prefix)).map(file => file.slice(prefix.length)));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
407
|
+
const GUARD_STALE_MS = 30 * 1000;
|
|
408
|
+
const LOCK_TIMEOUT_MS = 60 * 1000;
|
|
409
|
+
|
|
410
|
+
function sleepSync(ms) {
|
|
411
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function readLockOwner(dir) {
|
|
415
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, 'owner.json'), 'utf8')); }
|
|
416
|
+
catch (_) { return null; }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Returns the stale owner's token (or '' for an ownerless/corrupt dir), or null when not stale.
|
|
420
|
+
function staleLockToken(dir, staleMs) {
|
|
421
|
+
const owner = readLockOwner(dir);
|
|
422
|
+
if (owner && owner.hostname === os.hostname() && Number.isInteger(owner.pid) && owner.pid > 0) {
|
|
423
|
+
try { process.kill(owner.pid, 0); return null; }
|
|
424
|
+
catch (error) { return error.code === 'ESRCH' ? String(owner.token || '') : null; }
|
|
425
|
+
}
|
|
426
|
+
// Foreign-host or unreadable owner: only age proves abandonment.
|
|
427
|
+
try { return Date.now() - fs.statSync(dir).mtimeMs > staleMs ? String((owner && owner.token) || '') : null; }
|
|
428
|
+
catch (_) { return null; }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Creates `dir` together with its owner record: rename never replaces a non-empty
|
|
432
|
+
// directory, so a lock is never observable without its owner token.
|
|
433
|
+
function tryCreateLockDir(dir, token) {
|
|
434
|
+
const pending = dir + '.pending-' + token;
|
|
435
|
+
fs.mkdirSync(pending);
|
|
436
|
+
try {
|
|
437
|
+
fs.writeFileSync(path.join(pending, 'owner.json'), JSON.stringify({
|
|
438
|
+
pid: process.pid,
|
|
439
|
+
hostname: os.hostname(),
|
|
440
|
+
token,
|
|
441
|
+
createdAt: new Date().toISOString(),
|
|
442
|
+
}));
|
|
443
|
+
fs.renameSync(pending, dir);
|
|
444
|
+
return true;
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error;
|
|
447
|
+
return false;
|
|
448
|
+
} finally {
|
|
449
|
+
try { fs.rmSync(pending, { recursive: true, force: true }); } catch (_) {}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Removes `dir` only if it still carries `expectedToken`. The rename isolates one exact
|
|
454
|
+
// directory; a mismatched (live) owner is put back instead of being deleted.
|
|
455
|
+
function removeLockDirIfOwned(dir, expectedToken, onRenamed) {
|
|
456
|
+
const tomb = dir + '.tomb-' + crypto.randomBytes(6).toString('hex');
|
|
457
|
+
try { fs.renameSync(dir, tomb); } catch (_) { return false; }
|
|
458
|
+
if (onRenamed) onRenamed();
|
|
459
|
+
const owner = readLockOwner(tomb);
|
|
460
|
+
if (String((owner && owner.token) || '') === expectedToken) {
|
|
461
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
try { fs.renameSync(tomb, dir); } catch (_) {}
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function waitOrTimeout(deadline, lockDir) {
|
|
469
|
+
if (Date.now() >= deadline) {
|
|
470
|
+
throw new Error('timed out waiting for Harness compatibility lock ' + lockDir +
|
|
471
|
+
'; remove it only if no Harness codex-hooks install/uninstall is running');
|
|
472
|
+
}
|
|
473
|
+
sleepSync(25 + Math.floor(Math.random() * 50));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// The guard serializes every non-holder mutation of the main lock path (acquire and
|
|
477
|
+
// stale reclaim), so a stale observation is always revalidated before it is acted on.
|
|
478
|
+
// It is held for milliseconds; a guard left by a crash is reclaimed by token after
|
|
479
|
+
// its owner dies or GUARD_STALE_MS passes.
|
|
480
|
+
function withGuard(guardDir, deadline, fn) {
|
|
481
|
+
const token = crypto.randomBytes(12).toString('hex');
|
|
482
|
+
while (!tryCreateLockDir(guardDir, token)) {
|
|
483
|
+
const stale = staleLockToken(guardDir, GUARD_STALE_MS);
|
|
484
|
+
if (stale === null || !removeLockDirIfOwned(guardDir, stale)) waitOrTimeout(deadline, guardDir);
|
|
485
|
+
}
|
|
486
|
+
try { return fn(); }
|
|
487
|
+
finally { removeLockDirIfOwned(guardDir, token); }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Serializes read-manifest → prepare runtime → commit hooks/manifest → collect runtimes
|
|
491
|
+
// across processes, so one installer's GC cannot delete another installer's runtime.
|
|
492
|
+
function withOwnerLock(paths, options, fn) {
|
|
493
|
+
const lockDir = paths.ownerRoot + '.lock';
|
|
494
|
+
const guardDir = lockDir + '.guard';
|
|
495
|
+
assertOwnedPath(paths.codexHome, lockDir, 'compatibility lock');
|
|
496
|
+
assertOwnedPath(paths.codexHome, guardDir, 'compatibility lock guard');
|
|
497
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
498
|
+
const timeoutMs = Number.isFinite(options.lockTimeoutMs) ? options.lockTimeoutMs : LOCK_TIMEOUT_MS;
|
|
499
|
+
const deadline = Date.now() + timeoutMs;
|
|
500
|
+
const emit = event => { if (typeof options.onLockEvent === 'function') options.onLockEvent(event); };
|
|
501
|
+
const token = crypto.randomBytes(12).toString('hex');
|
|
502
|
+
for (;;) {
|
|
503
|
+
const acquired = withGuard(guardDir, deadline, () => {
|
|
504
|
+
if (tryCreateLockDir(lockDir, token)) return true;
|
|
505
|
+
const stale = staleLockToken(lockDir, LOCK_STALE_MS);
|
|
506
|
+
if (stale === null) return false;
|
|
507
|
+
emit('stale-observed');
|
|
508
|
+
// Revalidated under the guard; still token-checked in case a crashed guard was reclaimed.
|
|
509
|
+
return removeLockDirIfOwned(lockDir, stale, () => emit('reclaim-renamed')) &&
|
|
510
|
+
tryCreateLockDir(lockDir, token);
|
|
511
|
+
});
|
|
512
|
+
if (acquired) break;
|
|
513
|
+
waitOrTimeout(deadline, lockDir);
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
return fn();
|
|
517
|
+
} finally {
|
|
518
|
+
// Only this holder's own token is released; a live same-host holder is never stale.
|
|
519
|
+
removeLockDirIfOwned(lockDir, token);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function collectRuntimes(paths, keep) {
|
|
524
|
+
if (!fs.existsSync(paths.runtimeRoot)) return;
|
|
525
|
+
for (const name of fs.readdirSync(paths.runtimeRoot)) {
|
|
526
|
+
// Only fingerprinted runtime directories belong to this installer.
|
|
527
|
+
if (!RUNTIME_NAME_RE.test(name)) continue;
|
|
528
|
+
const runtime = path.join(paths.runtimeRoot, name);
|
|
529
|
+
if (keep.some(retained => retained && samePath(runtime, retained))) continue;
|
|
530
|
+
assertOwnedPath(paths.codexHome, runtime, 'retired runtime');
|
|
531
|
+
fs.rmSync(runtime, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
327
535
|
function install(options = {}) {
|
|
328
536
|
const codexHome = path.resolve(options.codexHome || defaultCodexHome(options.env));
|
|
329
537
|
const sourcePluginRoot = path.resolve(options.sourcePluginRoot || SOURCE_PLUGIN_ROOT);
|
|
538
|
+
const include = releasedSourceFiles(sourcePluginRoot, options.allowUnreleasedSource);
|
|
330
539
|
const packageVersion = sourcePluginVersion(sourcePluginRoot);
|
|
331
|
-
const sourceHash = treeHash(sourcePluginRoot);
|
|
540
|
+
const sourceHash = treeHash(sourcePluginRoot, include);
|
|
332
541
|
const p = pathsFor(codexHome, sourceHash, packageVersion);
|
|
333
542
|
assertCompatibilityPaths(p);
|
|
543
|
+
return withOwnerLock(p, options, () => installLocked(p, {
|
|
544
|
+
codexHome, sourcePluginRoot, include, packageVersion, sourceHash,
|
|
545
|
+
onRuntimeReady: options.onRuntimeReady,
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Every manifest read and runtime/hooks write happens inside the owner lock.
|
|
550
|
+
function installLocked(p, { codexHome, sourcePluginRoot, include, packageVersion, sourceHash, onRuntimeReady }) {
|
|
334
551
|
const existedBefore = fs.existsSync(p.hooksFile);
|
|
335
552
|
const hooksBefore = existedBefore ? fs.readFileSync(p.hooksFile) : null;
|
|
336
553
|
const runtimeExistedBefore = fs.existsSync(p.runtimeDir);
|
|
@@ -346,7 +563,9 @@ function install(options = {}) {
|
|
|
346
563
|
base = removal.config;
|
|
347
564
|
}
|
|
348
565
|
|
|
349
|
-
ensureRuntime(p.runtimeDir, sourceHash, sourcePluginRoot);
|
|
566
|
+
ensureRuntime(p.runtimeDir, sourceHash, sourcePluginRoot, include);
|
|
567
|
+
// Test seam: lets regressions force two installers to overlap before commit.
|
|
568
|
+
if (typeof onRuntimeReady === 'function') onRuntimeReady(p.runtimeDir);
|
|
350
569
|
assertCompatibilityPaths(p);
|
|
351
570
|
const generated = sourceHookEntries(p.runtimeDir, sourcePluginRoot);
|
|
352
571
|
const ownedEntries = [];
|
|
@@ -360,6 +579,13 @@ function install(options = {}) {
|
|
|
360
579
|
next.hooks[record.event] = list;
|
|
361
580
|
}
|
|
362
581
|
|
|
582
|
+
// A lost or modified previous runtime cannot be restored; drop it so status recovers.
|
|
583
|
+
let previous = null;
|
|
584
|
+
if (prior && !samePath(prior.runtimeDir, p.runtimeDir)) {
|
|
585
|
+
previous = { runtimeDir: prior.runtimeDir, sourcePluginSha256: prior.sourcePluginSha256 };
|
|
586
|
+
} else if (prior && previousRuntimeHealth(prior).status === 'present') {
|
|
587
|
+
previous = { runtimeDir: prior.previousRuntimeDir, sourcePluginSha256: prior.previousSourcePluginSha256 || null };
|
|
588
|
+
}
|
|
363
589
|
const manifest = {
|
|
364
590
|
schemaVersion: MANIFEST_SCHEMA,
|
|
365
591
|
package: 'harness-everything',
|
|
@@ -367,6 +593,8 @@ function install(options = {}) {
|
|
|
367
593
|
packageVersion,
|
|
368
594
|
sourcePluginSha256: sourceHash,
|
|
369
595
|
runtimeDir: p.runtimeDir,
|
|
596
|
+
previousRuntimeDir: previous ? previous.runtimeDir : null,
|
|
597
|
+
previousSourcePluginSha256: previous ? previous.sourcePluginSha256 : null,
|
|
370
598
|
hooksFile: p.hooksFile,
|
|
371
599
|
createdHooksFile: prior ? Boolean(prior.createdHooksFile) : !existedBefore,
|
|
372
600
|
installedAt: new Date().toISOString(),
|
|
@@ -389,11 +617,7 @@ function install(options = {}) {
|
|
|
389
617
|
throw error;
|
|
390
618
|
}
|
|
391
619
|
|
|
392
|
-
|
|
393
|
-
const ownedRoot = path.resolve(p.runtimeRoot) + path.sep;
|
|
394
|
-
const oldRuntime = path.resolve(prior.runtimeDir);
|
|
395
|
-
if (oldRuntime.startsWith(ownedRoot)) fs.rmSync(oldRuntime, { recursive: true, force: true });
|
|
396
|
-
}
|
|
620
|
+
collectRuntimes(p, [manifest.runtimeDir, manifest.previousRuntimeDir]);
|
|
397
621
|
|
|
398
622
|
return {
|
|
399
623
|
status: prior ? 'updated' : 'installed',
|
|
@@ -411,8 +635,17 @@ function uninstall(options = {}) {
|
|
|
411
635
|
const codexHome = path.resolve(options.codexHome || defaultCodexHome(options.env));
|
|
412
636
|
const p = pathsFor(codexHome);
|
|
413
637
|
assertCompatibilityPaths(p);
|
|
638
|
+
const notInstalled = { status: 'not-installed', codexHome, removed: 0, trustChanged: false };
|
|
639
|
+
// Avoid creating lock directories in a Codex home that was never installed.
|
|
640
|
+
if (!fs.existsSync(p.manifestFile)) return notInstalled;
|
|
641
|
+
const result = withOwnerLock(p, options, () => uninstallLocked(p, codexHome) || notInstalled);
|
|
642
|
+
try { fs.rmdirSync(path.dirname(p.ownerRoot)); } catch (_) {}
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function uninstallLocked(p, codexHome) {
|
|
414
647
|
const prior = readManifest(p.manifestFile, p);
|
|
415
|
-
if (!prior) return
|
|
648
|
+
if (!prior) return null;
|
|
416
649
|
|
|
417
650
|
const hooksFileExists = fs.existsSync(p.hooksFile);
|
|
418
651
|
const config = readHooksFile(p.hooksFile);
|
|
@@ -437,15 +670,10 @@ function uninstall(options = {}) {
|
|
|
437
670
|
atomicWriteJson(p.hooksFile, next);
|
|
438
671
|
}
|
|
439
672
|
|
|
440
|
-
|
|
441
|
-
const ownedRoot = path.resolve(p.runtimeRoot) + path.sep;
|
|
442
|
-
const runtime = path.resolve(prior.runtimeDir);
|
|
443
|
-
if (runtime.startsWith(ownedRoot)) fs.rmSync(runtime, { recursive: true, force: true });
|
|
444
|
-
}
|
|
673
|
+
collectRuntimes(p, []);
|
|
445
674
|
fs.rmSync(p.manifestFile, { force: true });
|
|
446
675
|
try { fs.rmdirSync(p.runtimeRoot); } catch (_) {}
|
|
447
676
|
try { fs.rmdirSync(p.ownerRoot); } catch (_) {}
|
|
448
|
-
try { fs.rmdirSync(path.dirname(p.ownerRoot)); } catch (_) {}
|
|
449
677
|
|
|
450
678
|
return {
|
|
451
679
|
status: 'uninstalled',
|
|
@@ -465,13 +693,22 @@ function status(options = {}) {
|
|
|
465
693
|
const config = readHooksFile(p.hooksFile);
|
|
466
694
|
const removal = removePriorEntries(config, prior);
|
|
467
695
|
const expected = prior.entries.length;
|
|
468
|
-
const
|
|
469
|
-
|
|
696
|
+
const currentHealthy = fs.existsSync(prior.runtimeDir) && treeHash(prior.runtimeDir) === prior.sourcePluginSha256;
|
|
697
|
+
const previousRuntime = previousRuntimeHealth(prior);
|
|
698
|
+
const entriesHealthy = removal.removed.length === expected && removal.drift.length === 0;
|
|
699
|
+
const healthy = entriesHealthy && currentHealthy && previousRuntime.healthy;
|
|
700
|
+
const reason = healthy ? 'codex-hook-trust-state-is-not-readable-here'
|
|
701
|
+
: !entriesHealthy || !currentHealthy ? 'compatibility-hooks-unhealthy'
|
|
702
|
+
: 'previous-runtime-' + previousRuntime.status;
|
|
470
703
|
return {
|
|
471
704
|
installed: true,
|
|
472
705
|
codexHome,
|
|
473
706
|
hooksFile: p.hooksFile,
|
|
474
707
|
runtimeDir: prior.runtimeDir,
|
|
708
|
+
runtimes: {
|
|
709
|
+
current: { status: currentHealthy ? 'present' : 'missing-or-modified', runtimeDir: prior.runtimeDir, healthy: currentHealthy },
|
|
710
|
+
previous: previousRuntime,
|
|
711
|
+
},
|
|
475
712
|
packageVersion: prior.packageVersion,
|
|
476
713
|
sourcePluginSha256: prior.sourcePluginSha256,
|
|
477
714
|
expectedEntries: expected,
|
|
@@ -479,8 +716,9 @@ function status(options = {}) {
|
|
|
479
716
|
drift: removal.drift,
|
|
480
717
|
healthy,
|
|
481
718
|
observationCapture: {
|
|
482
|
-
|
|
483
|
-
|
|
719
|
+
// A lost previous runtime breaks older sessions, not new-session capture.
|
|
720
|
+
status: entriesHealthy && currentHealthy ? 'configured-unverified' : 'inactive',
|
|
721
|
+
reason,
|
|
484
722
|
trustRequired: true,
|
|
485
723
|
},
|
|
486
724
|
trustChanged: false,
|
|
@@ -490,14 +728,16 @@ function status(options = {}) {
|
|
|
490
728
|
function parseArgs(argv) {
|
|
491
729
|
const action = argv[0] || 'status';
|
|
492
730
|
if (!['install', 'uninstall', 'status'].includes(action)) {
|
|
493
|
-
throw new Error('Usage: harness codex-hooks <install|uninstall|status> [--codex-home <path>]');
|
|
731
|
+
throw new Error('Usage: harness codex-hooks <install|uninstall|status> [--codex-home <path>] [--allow-unreleased-source]');
|
|
494
732
|
}
|
|
495
733
|
let codexHome;
|
|
734
|
+
let allowUnreleasedSource = false;
|
|
496
735
|
for (let i = 1; i < argv.length; i++) {
|
|
497
736
|
if (argv[i] === '--codex-home') codexHome = path.resolve(argv[++i]);
|
|
737
|
+
else if (argv[i] === '--allow-unreleased-source') allowUnreleasedSource = true;
|
|
498
738
|
else throw new Error('unknown argument: ' + argv[i]);
|
|
499
739
|
}
|
|
500
|
-
return { action, codexHome };
|
|
740
|
+
return { action, codexHome, allowUnreleasedSource };
|
|
501
741
|
}
|
|
502
742
|
|
|
503
743
|
function main(argv = process.argv.slice(2)) {
|
|
@@ -510,7 +750,7 @@ function main(argv = process.argv.slice(2)) {
|
|
|
510
750
|
|
|
511
751
|
try {
|
|
512
752
|
const result = args.action === 'install'
|
|
513
|
-
? install({ codexHome: args.codexHome })
|
|
753
|
+
? install({ codexHome: args.codexHome, allowUnreleasedSource: args.allowUnreleasedSource })
|
|
514
754
|
: args.action === 'uninstall'
|
|
515
755
|
? uninstall({ codexHome: args.codexHome })
|
|
516
756
|
: status({ codexHome: args.codexHome });
|
package/self-evolve/SKILL.md
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* their results must not be confused with a local tracker simulation.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const { spawnSync } = require('child_process');
|
|
10
9
|
const path = require('path');
|
|
11
10
|
const fs = require('fs');
|
|
12
11
|
|
|
@@ -22,12 +21,15 @@ if (pkgName !== 'harness-everything') {
|
|
|
22
21
|
process.exit(0);
|
|
23
22
|
}
|
|
24
23
|
|
|
24
|
+
// This development-only helper is absent from installed skill packages.
|
|
25
|
+
const { spawnTestNode } = require('../../ci/lib/isolated-test-process');
|
|
26
|
+
|
|
25
27
|
console.log('=================================================');
|
|
26
28
|
console.log(' Harness OS - Self-Regression Test Suite ');
|
|
27
29
|
console.log('=================================================');
|
|
28
30
|
|
|
29
31
|
function runNode(label, script, args = [], options = {}) {
|
|
30
|
-
const result =
|
|
32
|
+
const result = spawnTestNode([script, ...args], {
|
|
31
33
|
cwd: projectRoot,
|
|
32
34
|
stdio: 'inherit',
|
|
33
35
|
...options,
|
|
@@ -54,7 +56,7 @@ function walkDir(dir) {
|
|
|
54
56
|
}
|
|
55
57
|
for (const folder of foldersToScan) walkDir(path.join(projectRoot, folder));
|
|
56
58
|
for (const file of jsFiles) {
|
|
57
|
-
const check =
|
|
59
|
+
const check = spawnTestNode(['--check', file]);
|
|
58
60
|
if (check.status !== 0) {
|
|
59
61
|
console.error(` FAIL syntax: ${path.relative(projectRoot, file)}`);
|
|
60
62
|
console.error(check.stderr.toString().trim());
|
|
@@ -65,7 +67,7 @@ console.log(` PASS syntax: ${jsFiles.length} JavaScript file(s)`);
|
|
|
65
67
|
|
|
66
68
|
console.log('\n[Phase 1b] CLI Command Smoke Test...');
|
|
67
69
|
const cliPath = path.join(projectRoot, 'bin', 'cli.js');
|
|
68
|
-
const nextCheck =
|
|
70
|
+
const nextCheck = spawnTestNode([cliPath, 'next', 'add a new login endpoint with tests'], { cwd: projectRoot });
|
|
69
71
|
if (nextCheck.status !== 0 || !nextCheck.stdout.toString().includes('RECOMMENDED TIER')) {
|
|
70
72
|
console.error(' FAIL `harness next` did not produce a routing recommendation.');
|
|
71
73
|
hasErrors = true;
|
|
@@ -73,7 +75,7 @@ if (nextCheck.status !== 0 || !nextCheck.stdout.toString().includes('RECOMMENDED
|
|
|
73
75
|
console.error(' FAIL `harness next` did not establish the invariant-first kernel contract.');
|
|
74
76
|
hasErrors = true;
|
|
75
77
|
} else console.log(' PASS `harness next` produced routing + invariant contract.');
|
|
76
|
-
const verifyCheck =
|
|
78
|
+
const verifyCheck = spawnTestNode([cliPath, 'verify'], {
|
|
77
79
|
cwd: projectRoot,
|
|
78
80
|
env: { ...process.env, HARNESS_SKIP_PROJECT_CHECKS: '1' },
|
|
79
81
|
});
|
|
@@ -105,7 +107,7 @@ runNode('behavioral case validation', path.join(projectRoot, 'behavioral-evals',
|
|
|
105
107
|
console.log('\n[Phase 3b] Fable Model Mode Contract Tests...');
|
|
106
108
|
const fableModeTestPath = path.join(projectRoot, 'ci', 'fable-mode-test.js');
|
|
107
109
|
if (fs.existsSync(fableModeTestPath)) {
|
|
108
|
-
const fableModeCheck =
|
|
110
|
+
const fableModeCheck = spawnTestNode([fableModeTestPath], { stdio: 'inherit', cwd: projectRoot });
|
|
109
111
|
if (fableModeCheck.status !== 0) {
|
|
110
112
|
console.error("\n ❌ Fable model mode contract suite failed!");
|
|
111
113
|
hasErrors = true;
|