@lyupro/codex-bridge 0.6.11 → 0.6.14

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 (71) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +12 -9
  3. package/cli/active-hosts.mjs +44 -0
  4. package/cli/command-registry.mjs +58 -1
  5. package/cli/dispatcher-model-check.mjs +14 -12
  6. package/cli/doctor-host-side.mjs +117 -111
  7. package/cli/doctor-installation.mjs +132 -111
  8. package/cli/doctor-load-witness.mjs +75 -0
  9. package/cli/doctor-runs.mjs +41 -6
  10. package/cli/doctor-sandbox.mjs +64 -0
  11. package/cli/doctor.mjs +43 -18
  12. package/cli/guidance.mjs +42 -0
  13. package/cli/handback-witness-check.mjs +23 -19
  14. package/cli/host-inspection.mjs +1 -0
  15. package/cli/hosts.mjs +4 -3
  16. package/cli/install-record.mjs +9 -2
  17. package/cli/inventory-confirm.mjs +75 -0
  18. package/cli/inventory-transition.mjs +3 -1
  19. package/cli/manifest.mjs +6 -3
  20. package/cli/read.mjs +1 -2
  21. package/cli/removal-outcomes.mjs +5 -2
  22. package/cli/removal-plan.mjs +4 -1
  23. package/cli/remove-layout.mjs +4 -4
  24. package/cli/run-lookup.mjs +14 -2
  25. package/cli/runs-inventory.mjs +10 -2
  26. package/cli/runs-move-copy.mjs +123 -0
  27. package/cli/runs-move-history.mjs +78 -0
  28. package/cli/runs-move-remove.mjs +92 -0
  29. package/cli/runs-move.mjs +96 -0
  30. package/cli/stop.mjs +1 -2
  31. package/cli/uninstall-purge.mjs +1 -0
  32. package/cli/uninstall.mjs +2 -0
  33. package/cli/update.mjs +12 -7
  34. package/package.json +4 -2
  35. package/scripts/home-write-inventory.mjs +17 -2
  36. package/scripts/isolated-roots.mjs +2 -1
  37. package/src/{agents → claude/agents}/codex-advisor.md +8 -7
  38. package/src/{agents → claude/agents}/codex-build.md +7 -6
  39. package/src/{agents → claude/agents}/codex-review.md +6 -5
  40. package/src/{agents → claude/agents}/codex-scout.md +7 -6
  41. package/src/{commands → claude/commands}/usage.md +9 -5
  42. package/src/claude/rules/core.md +29 -0
  43. package/src/{rules → codex/rules}/codex-bridge.rules +1 -1
  44. package/src/home/guidance/advisor.md +42 -0
  45. package/src/home/guidance/budget.md +37 -0
  46. package/src/home/guidance/concurrency.md +17 -0
  47. package/src/home/guidance/order.md +66 -0
  48. package/src/home/guidance/records.md +32 -0
  49. package/src/home/hooks/live-runs.mjs +2 -1
  50. package/src/home/hooks/load-witness.mjs +23 -0
  51. package/src/home/lib/claude-layout.mjs +1 -0
  52. package/src/home/lib/codex-home.mjs +8 -0
  53. package/src/home/lib/home-registry.mjs +21 -0
  54. package/src/home/lib/hook-definitions.mjs +2 -0
  55. package/src/home/lib/host-version.mjs +33 -14
  56. package/src/home/lib/install-owner-roots.mjs +55 -53
  57. package/src/home/lib/load-witness.mjs +113 -0
  58. package/src/home/lib/meta/reply.mjs +1 -1
  59. package/src/home/lib/runner/codex-availability.mjs +11 -2
  60. package/src/home/lib/runner/exit-codes.mjs +1 -1
  61. package/src/home/lib/runner/launcher.mjs +10 -4
  62. package/src/home/lib/runner/preflight.mjs +12 -4
  63. package/src/home/lib/runner/retired-roots.mjs +81 -0
  64. package/src/home/lib/runner/run-autocommit.mjs +109 -0
  65. package/src/home/lib/runner/runs-root.mjs +47 -7
  66. package/src/home/lib/runner/sandbox-diagnosis.mjs +180 -0
  67. package/src/home/lib/runner/sandbox-probe.mjs +29 -1
  68. package/src/home/lib/runner/task-input.mjs +30 -0
  69. package/src/home/lib/runner/worker.mjs +5 -0
  70. /package/src/{commands → claude/commands}/env.md +0 -0
  71. /package/src/{commands → claude/commands}/model.md +0 -0
@@ -33,15 +33,15 @@ export async function removeEmptyParents(target, boundary) {
33
33
  }
34
34
 
35
35
  /**
36
- * The directory an emptied-parent walk must stop at. Plan_25 gave the host four package-owned
37
- * directories at once — the current agents and commands subdirectories and the two the previous
38
- * layout used — and a walk that started inside one of them but stopped at host.root would delete
39
- * the operator's own emptied directories on the way up.
36
+ * The directory an emptied-parent walk must stop at: five package-owned directories: current
37
+ * agents, commands and rules (Plan_64 D5, D9), plus the two legacy directories (Plan_25).
38
+ * Stopping there preserves the operator's own emptied parent directories.
40
39
  */
41
40
  export function claudeBoundary(host, target) {
42
41
  const directories = [
43
42
  host.agentsDir,
44
43
  host.commandsDir,
44
+ host.rulesDir,
45
45
  host.legacyAgentsDir,
46
46
  host.legacyCommandsDir,
47
47
  ].filter(Boolean);
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { git } from '../src/home/lib/runner/git-state.mjs';
5
5
  import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
6
- import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
6
+ import { retiredPathRefusal, runsRootResolution, staleOverrideRefusal } from '../src/home/lib/runner/runs-root.mjs';
7
7
 
8
8
  function projectRoot(cwd) {
9
9
  const result = git(cwd, ['rev-parse', '--show-toplevel']);
@@ -23,7 +23,8 @@ export function resolveRunFolder({
23
23
  command = 'stop',
24
24
  run,
25
25
  cwd = process.cwd(),
26
- runsRootPath = runsRoot(),
26
+ runsRootPath,
27
+ resolution = runsRootResolution(),
27
28
  } = {}) {
28
29
  const commandName = String(command || 'stop');
29
30
  const value = String(run ?? '').trim();
@@ -31,6 +32,17 @@ export function resolveRunFolder({
31
32
  return refusal(commandName, `${commandName} requires a run folder (full path or bare folder name)`);
32
33
  }
33
34
 
35
+ // Plan_77 D6: run-lookup.mjs:51-55 only said "Run folder not found" after stat on an old path.
36
+ if (path.isAbsolute(value)) {
37
+ const retired = retiredPathRefusal(value, resolution);
38
+ if (retired) return refusal(commandName, retired);
39
+ }
40
+ if (runsRootPath === undefined) {
41
+ const stale = staleOverrideRefusal(resolution);
42
+ if (stale) return refusal(commandName, stale);
43
+ runsRootPath = resolution.root;
44
+ }
45
+
34
46
  let runDir;
35
47
  try {
36
48
  if (path.isAbsolute(value)) runDir = path.resolve(value);
@@ -108,6 +108,7 @@ function readRun(runDir, livePaths) {
108
108
  const live = livePaths.has(pathKey(runDir));
109
109
  return {
110
110
  run: path.basename(runDir),
111
+ path: path.resolve(runDir),
111
112
  agent: text(facts?.agent),
112
113
  verdict: live ? 'running' : text(meta ? meta.status : runLiveness({ runDir, status }).state),
113
114
  tokens: meta ? number(meta.tokens) : null,
@@ -120,6 +121,8 @@ function readRun(runDir, livePaths) {
120
121
  function publicRun(run) {
121
122
  return {
122
123
  run: run.run,
124
+ // D10: the core names no run root, so each JSON row must carry its address.
125
+ path: run.path,
123
126
  agent: run.agent,
124
127
  verdict: run.verdict,
125
128
  tokens: run.tokens,
@@ -155,16 +158,21 @@ function buildProject(projectDir) {
155
158
  }
156
159
 
157
160
  function projectDirectory(root, name) {
158
- return directoryEntries(root)
161
+ return projectEntries(root)
159
162
  .find((entry) => entry.name === name)
160
163
  ? path.join(root, name)
161
164
  : null;
162
165
  }
163
166
 
167
+ function projectEntries(root) {
168
+ // Plan_77 B5b makes the moved store a git repository; its metadata is not a project.
169
+ return directoryEntries(root).filter((entry) => entry.name !== '.git');
170
+ }
171
+
164
172
  /** Lists one summary row per project directory. */
165
173
  export function listProjects(runsRootPath = runsRoot()) {
166
174
  const root = normalizeRoot(runsRootPath);
167
- return directoryEntries(root).map((entry) => buildProject(path.join(root, entry.name)).summary);
175
+ return projectEntries(root).map((entry) => buildProject(path.join(root, entry.name)).summary);
168
176
  }
169
177
 
170
178
  /** Lists one detail row per run directory in a named project, or null when there is no such project. */
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Copies a run store into a caller-created staging folder and proves every byte arrived.
3
+ *
4
+ * Plan_77 D1: the 2026-10-07 VaultForge journal cost $16.57 instead of $0.3-0.8
5
+ * because records lived in another project's git tree. Every record must survive.
6
+ * A 2026-10-08 Windows probe (Plan_77 B5a) preserved trailing dots with ordinary Node
7
+ * absolute paths; keep names verbatim, including tradeforge.loc (do not cd into codex-runs).
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createHash } from 'node:crypto';
12
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
13
+
14
+ export function sha256File(file) {
15
+ const digest = createHash('sha256');
16
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
17
+ const descriptor = fs.openSync(file, 'r');
18
+ try {
19
+ for (;;) {
20
+ const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, null);
21
+ if (!bytes) break;
22
+ digest.update(buffer.subarray(0, bytes));
23
+ }
24
+ return digest.digest('hex');
25
+ } finally {
26
+ fs.closeSync(descriptor);
27
+ }
28
+ }
29
+
30
+ function entryStat(file) {
31
+ const stat = fs.lstatSync(file);
32
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) {
33
+ throw new Error(`Unsupported run store entry: ${file}`);
34
+ }
35
+ return stat;
36
+ }
37
+
38
+ /** A read-only inventory also serves the dry run; empty folders count as records. */
39
+ export function inspectRunStore(root) {
40
+ if (!entryStat(root).isDirectory()) throw new Error(`Run store is not a directory: ${root}`);
41
+ const entries = new Map();
42
+ const totals = { files: 0, directories: 0, bytes: 0 };
43
+ function walk(relative) {
44
+ for (const name of fs.readdirSync(path.join(root, relative)).sort()) {
45
+ const child = path.join(relative, name);
46
+ const stat = entryStat(path.join(root, child));
47
+ const directory = stat.isDirectory();
48
+ entries.set(child, { directory, size: stat.size });
49
+ if (directory) {
50
+ totals.directories += 1;
51
+ walk(child);
52
+ } else {
53
+ totals.files += 1;
54
+ totals.bytes += stat.size;
55
+ }
56
+ }
57
+ }
58
+ walk('');
59
+ return { entries, ...totals };
60
+ }
61
+
62
+ /** Plan_77 D3: reject unsafe destinations before creating any copy. */
63
+ export function checkRunStoreDestination({ from, to }) {
64
+ const source = normalizeRepoPath(from);
65
+ const destination = normalizeRepoPath(to);
66
+ if (source === destination || source.startsWith(`${destination}/`) || destination.startsWith(`${source}/`)) {
67
+ throw new Error(`Run store paths overlap: ${from} and ${to}.`);
68
+ }
69
+ let stat;
70
+ try {
71
+ stat = fs.lstatSync(to);
72
+ } catch (error) {
73
+ if (error.code === 'ENOENT') return false;
74
+ throw error;
75
+ }
76
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(to).length) {
77
+ throw new Error(`Run store destination is not an empty directory: ${to}.`);
78
+ }
79
+ return true;
80
+ }
81
+
82
+ function verifyPaths(expected, actual, root) {
83
+ for (const relative of [...new Set([...expected.keys(), ...actual.keys()])].sort()) {
84
+ if (!expected.has(relative) || !actual.has(relative)
85
+ || expected.get(relative).directory !== actual.get(relative).directory) {
86
+ throw new Error(`Run store verification failed at ${path.join(root, relative)}: paths differ.`);
87
+ }
88
+ }
89
+ }
90
+
91
+ export function copyRunStore({ from, to, hash = sha256File }) {
92
+ const destinationExists = checkRunStoreDestination({ from, to });
93
+ const original = inspectRunStore(from);
94
+ if (!destinationExists) throw new Error(`Run store staging folder must already exist: ${to}.`);
95
+ try {
96
+ for (const [relative, entry] of original.entries) {
97
+ const src = path.join(from, relative);
98
+ const dst = path.join(to, relative);
99
+ entryStat(src); // Never follow a link introduced between the inventory and the copy.
100
+ if (entry.directory) fs.mkdirSync(dst);
101
+ else fs.copyFileSync(src, dst, fs.constants.COPYFILE_EXCL);
102
+ }
103
+ const source = inspectRunStore(from);
104
+ const destination = inspectRunStore(to);
105
+ verifyPaths(original.entries, source.entries, from);
106
+ verifyPaths(source.entries, destination.entries, to);
107
+ for (const [relative, entry] of source.entries) {
108
+ if (entry.directory) continue;
109
+ if (entry.size !== destination.entries.get(relative).size
110
+ || hash(path.join(from, relative)) !== hash(path.join(to, relative))) {
111
+ throw new Error(`Run store verification failed at ${relative}: size or hash differs.`);
112
+ }
113
+ }
114
+ // Check the path sets again after hashing, before declaring this copy verified.
115
+ verifyPaths(source.entries, inspectRunStore(from).entries, from);
116
+ verifyPaths(source.entries, inspectRunStore(to).entries, to);
117
+ return { files: source.files, directories: source.directories, bytes: source.bytes };
118
+ } catch (error) {
119
+ // Plan_77 F1: the caller guarantees this staging folder was exclusively created by this process.
120
+ fs.rmSync(to, { recursive: true, force: true });
121
+ throw error;
122
+ }
123
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Gives the moved run store the git history its records had in the repository they left.
3
+ *
4
+ * Plan_77 D4: split only a clone so the old repository's working tree, index and refs stay intact.
5
+ */
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ export function runGit(cwd, args) {
12
+ const { status, stdout, stderr } = spawnSync('git', ['-C', cwd, ...args], {
13
+ encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024,
14
+ });
15
+ return { status, stdout, stderr };
16
+ }
17
+
18
+ function gitFailure(step, result) {
19
+ const firstLine = (result.stderr || '').trim().split(/\r?\n/)[0];
20
+ return new Error(`Git ${step} failed (exit ${result.status}): ${firstLine}`);
21
+ }
22
+
23
+ function gitStep(git, cwd, args, statuses = [0]) {
24
+ let result;
25
+ try {
26
+ result = git(cwd, args);
27
+ } catch (error) {
28
+ throw gitFailure(args[0], { status: null, stderr: error.message });
29
+ }
30
+ if (!statuses.includes(result.status)) throw gitFailure(args[0], result);
31
+ return result;
32
+ }
33
+
34
+ /** D4: dry runs use exactly the same read-only eligibility checks, without creating a clone. */
35
+ export function inspectRunHistory({ from, git = runGit }) {
36
+ const repository = gitStep(git, from, ['rev-parse', '--show-toplevel'], [0, 128]);
37
+ if (repository.status !== 0) {
38
+ if (/not a git repository/i.test(repository.stderr || '')) return { imported: false, reason: 'not in git' };
39
+ throw gitFailure('rev-parse', repository);
40
+ }
41
+ const top = repository.stdout.trim();
42
+ const prefix = path.relative(top, from).split(path.sep).join('/');
43
+ if (!prefix) return { imported: false, reason: 'store is a repository of its own' };
44
+ const commits = Number(gitStep(git, top, ['rev-list', '--count', 'HEAD', '--', prefix]).stdout.trim());
45
+ if (commits === 0) return { imported: false, reason: 'no history' };
46
+ return { top, prefix, commits };
47
+ }
48
+
49
+ export function importRunHistory({ from, to, git = runGit, tmpdir = os.tmpdir() }) {
50
+ const history = inspectRunHistory({ from, git });
51
+ if (history.imported === false) return history;
52
+ const gitDir = path.join(to, '.git');
53
+ const hadGit = fs.existsSync(gitDir);
54
+ const clone = fs.mkdtempSync(path.join(tmpdir, 'codex-bridge-runs-history-'));
55
+ let initializing = false;
56
+ try {
57
+ gitStep(git, tmpdir, ['clone', '--quiet', '--no-checkout', history.top, clone]);
58
+ // D4: subtree requires the prefix on disk; materialize only that prefix in the disposable clone.
59
+ gitStep(git, clone, ['checkout', '--quiet', 'HEAD', '--', history.prefix]);
60
+ gitStep(git, clone, ['subtree', 'split', '--quiet', `--prefix=${history.prefix}`, '-b', 'codex-runs-history']);
61
+ initializing = true;
62
+ gitStep(git, to, ['init', '--quiet']);
63
+ gitStep(git, to, ['fetch', '--quiet', clone, 'codex-runs-history']);
64
+ // D4: mixed reset imports the index and history while keeping the verified on-disk records.
65
+ gitStep(git, to, ['reset', '--quiet', 'FETCH_HEAD']);
66
+ gitStep(git, to, ['add', '-A']);
67
+ if (gitStep(git, to, ['diff', '--cached', '--quiet'], [0, 1]).status === 1) {
68
+ gitStep(git, to, ['commit', '--quiet', '-m', 'Import run records moved by codex-bridge (Plan_77)']);
69
+ }
70
+ return { imported: true, commits: Number(gitStep(git, to, ['rev-list', '--count', 'HEAD']).stdout.trim()) };
71
+ } catch (error) {
72
+ // D4: roll back only metadata created by this import, never the source repository or records.
73
+ if (initializing && !hadGit) fs.rmSync(gitDir, { recursive: true, force: true });
74
+ throw error;
75
+ } finally {
76
+ fs.rmSync(clone, { recursive: true, force: true });
77
+ }
78
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Removes the old run store once the operator agrees and every file in it has an identical copy in the new one.
3
+ *
4
+ * Plan_77 D3/B5c: an older package can still write to the old root after the D7 switch.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { inspectRunStore, sha256File } from './runs-move-copy.mjs';
9
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
10
+
11
+ /** Returns the first 20 differing relative paths and the total count, including empty folders. */
12
+ export function oldStoreDifferences({ from, to, hash = sha256File }) {
13
+ const source = path.resolve(from);
14
+ const destination = path.resolve(to);
15
+ const relative = path.relative(source, destination);
16
+ const reverse = path.relative(destination, source);
17
+ const inside = (value) => value === '' || (!path.isAbsolute(value)
18
+ && value !== '..' && !value.startsWith(`..${path.sep}`));
19
+ if (inside(relative) || inside(reverse)) throw new Error(`Run store paths overlap: ${from} and ${to}.`);
20
+ const oldEntries = inspectRunStore(from).entries;
21
+ const newEntries = inspectRunStore(to).entries;
22
+ const paths = [];
23
+ let count = 0;
24
+ for (const [entryPath, oldEntry] of oldEntries) {
25
+ const newEntry = newEntries.get(entryPath);
26
+ if (!newEntry || oldEntry.directory !== newEntry.directory
27
+ || (!oldEntry.directory && (oldEntry.size !== newEntry.size
28
+ || hash(path.join(from, entryPath)) !== hash(path.join(to, entryPath))))) {
29
+ count += 1;
30
+ if (paths.length < 20) paths.push(entryPath);
31
+ }
32
+ }
33
+ return { paths, count };
34
+ }
35
+
36
+ function differencesResult(from, { paths, count }) {
37
+ return { exitCode: 1,
38
+ output: `The old folder ${from} has ${count} entries the new store does not hold identically:\n${paths.join('\n')}\nNothing was removed.` };
39
+ }
40
+
41
+ export async function removeOldStore({ from, to, ask = askYesNo, interactive = isInteractive,
42
+ questionOptions = {}, hash }) {
43
+ let quarantine;
44
+ const restore = () => {
45
+ if (!quarantine) return '';
46
+ if (!fs.existsSync(from)) {
47
+ try {
48
+ fs.renameSync(quarantine, from);
49
+ quarantine = undefined;
50
+ return '';
51
+ } catch (error) {
52
+ return `\nCould not restore ${quarantine} to ${from}: ${error.message}. Records remain in ${quarantine}.`;
53
+ }
54
+ }
55
+ return `\nThe old records remain in ${quarantine}; ${from} also exists. Both folders were kept.`;
56
+ };
57
+ try {
58
+ const differences = oldStoreDifferences({ from, to, hash });
59
+ if (differences.count) return differencesResult(from, differences);
60
+ if (!interactive(questionOptions)) {
61
+ return { exitCode: 0,
62
+ output: `The old folder ${from} is still there; run codex-bridge runs move in a terminal to remove it.` };
63
+ }
64
+ const answer = await ask(`Remove the old folder ${from}? Every file in it has an identical copy in ${to}. `
65
+ + 'It stays in the history of the git repository that holds it until that project commits the removal.', questionOptions);
66
+ if (answer === 'cancel') return { exitCode: 130, output: 'Cancelled; nothing was removed.' };
67
+ if (answer !== 'yes') return { exitCode: 0, output: 'The old folder was kept.' };
68
+ // Plan_77 F1: isolate the old tree before comparing; never delete a newly recreated from.
69
+ const isolated = `${from}.removing-${process.pid}-${Date.now()}`;
70
+ try {
71
+ fs.renameSync(from, isolated);
72
+ } catch (error) {
73
+ if (['EPERM', 'EBUSY', 'EACCES'].includes(error.code)) {
74
+ return { exitCode: 1,
75
+ output: `A process still uses a file in ${from}; nothing was removed. Close it and repeat.` };
76
+ }
77
+ throw error;
78
+ }
79
+ quarantine = isolated;
80
+ const confirmed = oldStoreDifferences({ from: quarantine, to, hash });
81
+ if (confirmed.count) {
82
+ const result = differencesResult(from, confirmed);
83
+ result.output += restore();
84
+ return result;
85
+ }
86
+ fs.rmSync(quarantine, { recursive: true });
87
+ quarantine = undefined;
88
+ return { exitCode: 0, output: `Removed ${from}.` };
89
+ } catch (error) {
90
+ return { exitCode: 1, output: `${error.message}\nNothing was removed.${restore()}` };
91
+ }
92
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Moves the run records from the retired default root into the package home, on the operator's signal.
3
+ *
4
+ * Plan_77 D3/D7: projects are closed by the operator; refuse live runs and prove
5
+ * the copy and import D4 history before writing the D7 move record that switches roots.
6
+ * The 2026-10-07 VaultForge $16.57 journal incident is why records leave foreign git.
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { runsRootResolution } from '../src/home/lib/runner/runs-root.mjs';
11
+ import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
12
+ import { writeRunsMoveRecord } from '../src/home/lib/runner/retired-roots.mjs';
13
+ import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
14
+ import { checkRunStoreDestination, copyRunStore, inspectRunStore } from './runs-move-copy.mjs';
15
+ import { importRunHistory, inspectRunHistory, runGit } from './runs-move-history.mjs';
16
+ import { oldStoreDifferences } from './runs-move-remove.mjs';
17
+
18
+ export function runsMove({ dryRun = false, resolution = runsRootResolution(), liveRuns = allLiveRuns,
19
+ stateDir, importHistory = importRunHistory, git = runGit } = {}) {
20
+ const { source, root, legacyRoot, homeRoot } = resolution;
21
+ const refuse = (output) => ({ exitCode: 1, output, oldStore: null, homeRoot });
22
+ if (source === 'CODEX_RUNS_ROOT') {
23
+ return refuse(`CODEX_RUNS_ROOT is set to ${root}; unset it to move the default run store.`);
24
+ }
25
+ if (source === 'moved') {
26
+ try {
27
+ if (fs.existsSync(legacyRoot) && fs.lstatSync(legacyRoot).isDirectory()) {
28
+ return { exitCode: 0, oldStore: dryRun ? null : legacyRoot, homeRoot,
29
+ output: `Run records already live in ${homeRoot}; the old folder ${legacyRoot} still exists.` };
30
+ }
31
+ } catch (error) {
32
+ return refuse(error.message);
33
+ }
34
+ return refuse(`Run records already live in ${homeRoot}.`);
35
+ }
36
+ if (source === 'default') return refuse(`No run store to move: ${legacyRoot} does not exist.`);
37
+ let liveRefusal = null;
38
+ try {
39
+ const live = liveRuns(legacyRoot);
40
+ if (live.length) {
41
+ liveRefusal = `Runs are still live in ${legacyRoot}:\n${live.map(({ dir }) => dir).join('\n')}. `
42
+ + 'Wait for them or stop them with codex-bridge stop, then repeat.';
43
+ // Like purge --dry-run: a preview names the refusal and still shows the move, since some
44
+ // project is nearly always running and the operator plans the move from these numbers.
45
+ if (!dryRun) return refuse(liveRefusal);
46
+ }
47
+ checkRunStoreDestination({ from: legacyRoot, to: homeRoot });
48
+ } catch (error) {
49
+ return refuse(liveRefusal ? `${liveRefusal}\n${error.message}` : error.message);
50
+ }
51
+ const staging = `${homeRoot}.moving-${process.pid}-${Date.now()}`;
52
+ let ownsStaging = false;
53
+ let published = false;
54
+ try {
55
+ if (!dryRun) {
56
+ fs.lstatSync(legacyRoot);
57
+ fs.mkdirSync(path.dirname(homeRoot), { recursive: true });
58
+ fs.mkdirSync(staging);
59
+ ownsStaging = true;
60
+ }
61
+ const counts = dryRun ? inspectRunStore(legacyRoot) : copyRunStore({ from: legacyRoot, to: staging });
62
+ const megabytes = (counts.bytes / (1024 * 1024)).toFixed(2);
63
+ if (dryRun) {
64
+ const history = inspectRunHistory({ from: legacyRoot, git });
65
+ const description = history.imported === false ? history.reason : `would import ${history.commits} commits`;
66
+ const preview = `Would copy ${counts.files} files (${megabytes} MB) in ${counts.directories} folders from ${legacyRoot} to ${homeRoot}; history: ${description}. Dry run: nothing changed.`;
67
+ return { exitCode: liveRefusal ? 1 : 0, oldStore: null, homeRoot,
68
+ output: liveRefusal ? `${liveRefusal}\n${preview}` : preview };
69
+ }
70
+ const history = importHistory({ from: legacyRoot, to: staging, git });
71
+ // Plan_77 F1: a writer may start or change records during copy/history import.
72
+ const live = liveRuns(legacyRoot);
73
+ const differences = oldStoreDifferences({ from: legacyRoot, to: staging });
74
+ if (live.length || differences.count) {
75
+ fs.rmSync(staging, { recursive: true, force: true });
76
+ ownsStaging = false;
77
+ return refuse(`A run started or a record changed during the move: ${[
78
+ ...live.map(({ dir }) => dir), ...differences.paths,
79
+ ].slice(0, 20).join(', ')}. Nothing was switched; repeat when no project is running.`);
80
+ }
81
+ // Plan_77 F1: publish only our exclusive staging folder; never roll back a competing store.
82
+ if (checkRunStoreDestination({ from: legacyRoot, to: homeRoot })) fs.rmdirSync(homeRoot);
83
+ fs.renameSync(staging, homeRoot);
84
+ ownsStaging = false;
85
+ published = true;
86
+ const description = history.imported ? `imported ${history.commits} commits` : history.reason;
87
+ // D7: publishing the record is the last write, after copy verification and D4 history import.
88
+ writeRunsMoveRecord(stateDir ?? resolution.stateDir ?? resolveBrandHome().stateDir, { root: legacyRoot });
89
+ return { exitCode: 0, oldStore: legacyRoot, homeRoot,
90
+ output: `Moved ${counts.files} files (${megabytes} MB) from ${legacyRoot} to ${homeRoot}; history: ${description}. New runs write to ${homeRoot}. The old folder is untouched.` };
91
+ } catch (error) {
92
+ if (ownsStaging) fs.rmSync(staging, { recursive: true, force: true });
93
+ if (published) fs.rmSync(homeRoot, { recursive: true, force: true });
94
+ return refuse(`${error.message}\nNothing was switched; the old folder is untouched.`);
95
+ }
96
+ }
package/cli/stop.mjs CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  processAlive,
11
11
  } from '../src/home/lib/process-identity.mjs';
12
12
  import { markAbandoned, readJson } from '../src/home/lib/write-meta.mjs';
13
- import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
14
13
  import { resolveRunFolder } from './run-lookup.mjs';
15
14
 
16
15
  const EXIT_POLL_MS = 25;
@@ -52,7 +51,7 @@ function result(exitCode, output) {
52
51
  export async function stop({
53
52
  run,
54
53
  cwd = process.cwd(),
55
- runsRootPath = runsRoot(),
54
+ runsRootPath,
56
55
  commandRunner,
57
56
  } = {}) {
58
57
  const lookup = resolveRunFolder({ command: 'stop', run, cwd, runsRootPath });
@@ -82,6 +82,7 @@ async function purgeInRun(options, ticket) {
82
82
  detached: true, imageMembers: plan.imageMembers, authorization,
83
83
  });
84
84
  await removeEmpty(host.commandsDir);
85
+ await removeEmpty(host.rulesDir);
85
86
  if (record) await removeOutside(writer, legacyInstallRecordPath(host));
86
87
  await removeEmpty(host.agentsDir);
87
88
  await removeEmptyLayout(host.legacyAgentsDir);
package/cli/uninstall.mjs CHANGED
@@ -109,6 +109,7 @@ no host is recorded as using it.`,
109
109
  const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
110
110
  imagePolicy });
111
111
  await removeEmpty(host.commandsDir);
112
+ await removeEmpty(host.rulesDir);
112
113
  await removeEmpty(host.agentsDir);
113
114
  await removeEmptyLayout(host.legacyAgentsDir);
114
115
  await removeEmptyLayout(host.legacyCommandsDir);
@@ -221,6 +222,7 @@ using ${host.brandRoot}.`);
221
222
  const { outcomes } = await executeHomePlan(host, plan,
222
223
  { detached: hostSide.detached, imageMembers: plan.imageMembers });
223
224
  await removeEmpty(host.commandsDir);
225
+ await removeEmpty(host.rulesDir);
224
226
  if (detachedRecord) await removeOutside(writer, legacyInstallRecordPath(host));
225
227
  await removeEmpty(host.agentsDir);
226
228
  await removeEmptyLayout(host.legacyAgentsDir);
package/cli/update.mjs CHANGED
@@ -73,8 +73,11 @@ function displayFile(file) {
73
73
  return `${entry.root}/${entry.path}`;
74
74
  }
75
75
 
76
- function classifyPlanned(state) {
77
- if (!state.recorded) return 'new';
76
+ function classifyPlanned(state, { adoptUnrecorded = false } = {}) {
77
+ // A planned path the record does not name but someone already filled is not ours to overwrite:
78
+ // install refuses it without --force, and update used to call it 'new' and copy over it — for
79
+ // the host rules file (Plan_64 B2c) that would silently replace the operator's own text.
80
+ if (!state.recorded) return state.exists && !state.matchesPackage && !adoptUnrecorded ? 'unowned' : 'new';
78
81
  if (!state.exists) return 'missing';
79
82
  if (state.matchesPackage) return 'up-to-date';
80
83
  if (state.recordedFingerprint === state.fingerprint) return 'outdated';
@@ -93,7 +96,7 @@ function appliedOutput(states) {
93
96
  [count('outdated'), 'updated'],
94
97
  [count('new'), 'added'],
95
98
  [count('missing'), 'restored'],
96
- [states.filter((state) => state.status === 'modified' && state.item).length, 'overwritten'],
99
+ [states.filter((state) => ['modified', 'unowned'].includes(state.status) && state.item).length, 'overwritten'],
97
100
  [states.filter((state) => state.status === 'orphaned' && state.exists).length, 'removed'],
98
101
  ].filter(([total]) => total > 0).map(([total, label]) => `${total} ${label}`);
99
102
  return parts.length ? `Updated codex-bridge: ${parts.join(', ')}.` : 'Updated codex-bridge.';
@@ -103,7 +106,7 @@ function conflictOutput(conflicts, legacy, dryRun) {
103
106
  const heading = dryRun ? 'Update would stop for these paths:' : 'Update stopped for these paths:';
104
107
  const lines = conflicts.map((state) => ` ${state.relative} (${state.status})`);
105
108
  if (legacy) lines.push('Installation record has no fingerprints; differing files are treated as modified.');
106
- lines.push('Run update again with --force to overwrite modified files and restore missing files.');
109
+ lines.push('Run update again with --force to overwrite modified or unowned files and restore missing files.');
107
110
  return [heading, ...lines].join('\n');
108
111
  }
109
112
 
@@ -112,7 +115,7 @@ function dryRunOutput(states, hookStates, legacy, oldHooks) {
112
115
  for (const state of states) {
113
116
  if (state.status === 'outdated') lines.push(`Would update ${state.relative}.`);
114
117
  if (state.status === 'new') lines.push(`Would create ${state.relative}.`);
115
- if (state.status === 'modified' && state.item) lines.push(`Would overwrite ${state.relative}.`);
118
+ if (['modified', 'unowned'].includes(state.status) && state.item) lines.push(`Would overwrite ${state.relative}.`);
116
119
  if (state.status === 'missing') lines.push(`Would restore ${state.relative}.`);
117
120
  if (state.status === 'orphaned' && state.exists) lines.push(`Would remove ${state.relative}.`);
118
121
  if (state.status === 'modified' && !state.item) lines.push(`Would preserve modified orphan ${state.relative}.`);
@@ -228,7 +231,9 @@ async function updateInRun({
228
231
  matchesPackage: ruleExists && await targetMatches(rule, host.brandRoot),
229
232
  fingerprint: ruleExists ? await fileFingerprint(rule.target) : null,
230
233
  };
231
- ruleState.status = classifyPlanned(ruleState);
234
+ // Records written before the Codex rules file was recorded name no rules, yet the file under the
235
+ // package's own name was written by this package: adopting it is the migration, not a collision.
236
+ ruleState.status = classifyPlanned(ruleState, { adoptUnrecorded: true });
232
237
  const orphanStates = await Promise.all(record.files
233
238
  .filter((file) => !planned.has(recordFileKey(file)))
234
239
  .map(async (file) => {
@@ -245,7 +250,7 @@ async function updateInRun({
245
250
  return { ...state, status: classifyOrphan(state) };
246
251
  }));
247
252
  const states = [...plannedStates, ruleState, ...orphanStates];
248
- const conflicts = states.filter((state) => state.status === 'modified' || state.status === 'missing');
253
+ const conflicts = states.filter((state) => ['modified', 'unowned', 'missing'].includes(state.status));
249
254
  const legacy = !record.fingerprints;
250
255
  if (conflicts.length && !force) {
251
256
  return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.11",
4
- "description": "Claude Code-to-Codex delegation with 7 guards, 3199 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.14",
4
+ "description": "Claude Code-to-Codex delegation with 8 guards, 3654 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -34,6 +34,7 @@
34
34
  "cli",
35
35
  "src",
36
36
  "scripts",
37
+ "!scripts/release-check",
37
38
  "README.md",
38
39
  "LICENSE",
39
40
  "CHANGELOG.md"
@@ -41,6 +42,7 @@
41
42
  "scripts": {
42
43
  "dev:install": "node bin/codex-bridge.mjs update --force",
43
44
  "test": "node scripts/run-tests.mjs",
45
+ "release-check:rules-load-claude": "node scripts/release-check/rules-load-claude.mjs",
44
46
  "check:size": "node scripts/check-file-size.mjs",
45
47
  "check:open-work": "node scripts/open-work/check.mjs --strict"
46
48
  },