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.
Files changed (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +30 -0
  4. package/bin/cli.js +1 -0
  5. package/ci/lib/isolated-test-process.js +30 -0
  6. package/ci/mechanism-2al-codex-hooks-compat.test.js +14 -14
  7. package/ci/mechanism-2ao-plugin-sync.test.js +19 -2
  8. package/ci/mechanism-2as-workflow-disposition-help.test.js +41 -0
  9. package/ci/mechanism-2at-fable-execution-phases.test.js +46 -0
  10. package/ci/mechanism-2au-codex-runtime-safety.test.js +340 -0
  11. package/ci/mechanism-2av-test-home-isolation.test.js +48 -0
  12. package/ci/mechanism-test.js +2 -2
  13. package/docs/platform-capabilities.md +4 -0
  14. package/docs/platform-compatibility.json +1 -1
  15. package/fable-mode/SKILL.md +1 -1
  16. package/fable-mode/execution-guardrails/SKILL.md +1 -1
  17. package/fable-mode/fable-haiku/SKILL.md +1 -1
  18. package/fable-mode/fable-opus/SKILL.md +1 -1
  19. package/fable-mode/fable-sonnet/SKILL.md +1 -1
  20. package/fable-mode/references/execution-phases.md +93 -25
  21. package/hooks/scripts/workflow-disposition.js +4 -0
  22. package/opencode-plugin/plugin.json +1 -1
  23. package/package.json +2 -2
  24. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  25. package/plugins/harness-everything/hooks/scripts/workflow-disposition.js +4 -0
  26. package/plugins/harness-everything/plugin.json +1 -1
  27. package/plugins/harness-everything/skills/fable-mode/SKILL.md +1 -1
  28. package/plugins/harness-everything/skills/fable-mode/execution-guardrails/SKILL.md +1 -1
  29. package/plugins/harness-everything/skills/fable-mode/fable-haiku/SKILL.md +1 -1
  30. package/plugins/harness-everything/skills/fable-mode/fable-opus/SKILL.md +1 -1
  31. package/plugins/harness-everything/skills/fable-mode/fable-sonnet/SKILL.md +1 -1
  32. package/plugins/harness-everything/skills/fable-mode/references/execution-phases.md +93 -25
  33. package/plugins/harness-everything/skills/self-evolve/SKILL.md +1 -1
  34. package/plugins/harness-everything/skills/self-evolve/scripts/self-regression.js +8 -6
  35. package/sbom.cdx.json +12 -12
  36. package/scripts/codex-hooks-compat.js +265 -25
  37. package/self-evolve/SKILL.md +1 -1
  38. 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
- function treeHash(root) {
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()) rows.push(rel + ':' + sha256(fs.readFileSync(full)));
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
- function ensureRuntime(runtimeDir, sourceHash, sourcePluginRoot = SOURCE_PLUGIN_ROOT) {
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, { recursive: true });
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
- if (prior && prior.runtimeDir && path.resolve(prior.runtimeDir) !== path.resolve(p.runtimeDir)) {
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 { status: 'not-installed', codexHome, removed: 0, trustChanged: false };
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
- if (prior.runtimeDir) {
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 healthy = removal.removed.length === expected && removal.drift.length === 0 &&
469
- fs.existsSync(prior.runtimeDir) && treeHash(prior.runtimeDir) === prior.sourcePluginSha256;
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
- status: healthy ? 'configured-unverified' : 'inactive',
483
- reason: healthy ? 'codex-hook-trust-state-is-not-readable-here' : 'compatibility-hooks-unhealthy',
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 });
@@ -4,7 +4,7 @@ description: "Extract root causes from resolved problems and persist them as def
4
4
  license: Apache-2.0
5
5
  metadata:
6
6
  author: Miya Daniel
7
- version: 0.12.0
7
+ version: 0.31.3
8
8
  ---
9
9
 
10
10
  # Self-evolve
@@ -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 = spawnSync('node', [script, ...args], {
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 = spawnSync('node', ['--check', file]);
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 = spawnSync('node', [cliPath, 'next', 'add a new login endpoint with tests'], { cwd: projectRoot });
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 = spawnSync('node', [cliPath, 'verify'], {
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 = spawnSync('node', [fableModeTestPath], { stdio: 'inherit', cwd: projectRoot });
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;