@lyupro/codex-bridge 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/uninstall.mjs CHANGED
@@ -1,31 +1,27 @@
1
- /** Uninstalls only recorded codex-bridge files while preserving host data and foreign files. */
1
+ /** Uninstalls only recorded files from both roots while preserving host data and foreign files. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import {
5
5
  definitionForRecordedHook,
6
6
  fileFingerprint,
7
7
  installRecordPath,
8
+ legacyInstallRecordPath,
8
9
  readInstallRecord,
10
+ recordTarget,
9
11
  } from './manifest.mjs';
10
12
  import { removePermissionRules } from './permissions.mjs';
11
- import { commandFor, removeHook, withSettingsRun } from './settings-merge.mjs';
13
+ import {
14
+ commandFor,
15
+ removeHook,
16
+ withSettingsRun,
17
+ } from './settings-merge.mjs';
12
18
  import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
13
-
14
- async function removeEmpty(directory) {
15
- try {
16
- if ((await fs.readdir(directory)).length === 0) await fs.rmdir(directory);
17
- } catch (err) {
18
- if (err.code !== 'ENOENT') throw err;
19
- }
20
- }
21
-
22
- async function removeEmptyParents(target, boundary) {
23
- let current = path.dirname(target);
24
- while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
25
- await removeEmpty(current);
26
- current = path.dirname(current);
27
- }
28
- }
19
+ import {
20
+ claudeBoundary,
21
+ removeEmpty,
22
+ removeEmptyLayout,
23
+ removeEmptyParents,
24
+ } from './remove-layout.mjs';
29
25
 
30
26
  function remainingOwnersText(count) {
31
27
  return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
@@ -37,6 +33,23 @@ function permissionOutput(host, removed, dryRun) {
37
33
  return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
38
34
  }
39
35
 
36
+ function displayFile(file) {
37
+ return `${file.root}/${file.path}`;
38
+ }
39
+
40
+ function hookRemovalSpec(host, hook) {
41
+ const definition = definitionForRecordedHook(hook);
42
+ const target = recordTarget(host, hook);
43
+ const full = commandFor(target);
44
+ const short = `codex-bridge hook ${definition.name}`;
45
+ return {
46
+ event: hook.event,
47
+ matcher: definition.matcher,
48
+ command: hook.command || full,
49
+ alternateCommands: [full, short],
50
+ };
51
+ }
52
+
40
53
  async function uninstallInRun({ host, dryRun = false } = {}) {
41
54
  // Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
42
55
  let registry = null;
@@ -50,13 +63,13 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
50
63
  const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
51
64
  const record = await readInstallRecord(host);
52
65
  const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
53
- + `configuration in ${path.join(host.agentsDir, 'run-config.json')} are preserved.`;
66
+ + `configuration in ${host.brandConfigPath} are preserved.`;
54
67
  if (!record) {
55
68
  return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
56
69
  }
57
70
 
58
71
  if (dryRun) {
59
- const lines = [permissionLine, ...record.files.map((file) => `Would remove ${file}`)];
72
+ const lines = [permissionLine, ...record.files.map((file) => `Would remove ${displayFile(file)}`)];
60
73
  if (record.rules) {
61
74
  if (registryError) {
62
75
  lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
@@ -81,19 +94,15 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
81
94
  const definition = definitionForRecordedHook(hook);
82
95
  lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
83
96
  }
84
- lines.push('Would remove the installation record.');
97
+ lines.push('Would remove the installation record from the brand root.');
85
98
  lines.push(preservation);
86
99
  return { exitCode: 0, output: lines.join('\n') };
87
100
  }
88
101
 
89
102
  for (const hook of record.hooks) {
90
- const definition = definitionForRecordedHook(hook);
91
- const target = path.join(host.root, hook.path);
92
- await removeHook(host.settingsPath, {
93
- event: hook.event,
94
- matcher: definition.matcher,
95
- command: commandFor(target),
96
- }, { createdGroup: hook.createdGroup === true });
103
+ await removeHook(host.settingsPath, hookRemovalSpec(host, hook), {
104
+ createdGroup: hook.createdGroup === true,
105
+ });
97
106
  }
98
107
  let ownership = null;
99
108
  if (!registryError) {
@@ -123,18 +132,24 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
123
132
  }
124
133
  }
125
134
  }
126
- for (const relative of record.files) {
127
- const target = path.join(host.root, relative);
135
+ for (const file of record.files) {
136
+ const target = recordTarget(host, file);
128
137
  await fs.rm(target, { force: true });
129
- const boundary = target.startsWith(`${host.commandsDir}${path.sep}`) ? host.commandsDir : host.agentsDir;
138
+ const boundary = file.root === 'brand'
139
+ ? host.brandRoot
140
+ : claudeBoundary(host, target);
130
141
  await removeEmptyParents(target, boundary);
131
142
  }
132
143
  await removeEmpty(host.commandsDir);
133
144
  await fs.rm(installRecordPath(host), { force: true });
145
+ await fs.rm(legacyInstallRecordPath(host), { force: true });
134
146
  await removeEmpty(host.agentsDir);
147
+ await removeEmptyLayout(host.legacyAgentsDir);
148
+ await removeEmptyLayout(host.legacyCommandsDir);
149
+ await removeEmpty(host.brandRoot);
135
150
  return {
136
151
  exitCode: 0,
137
- output: [`Uninstalled codex-bridge.`, permissionLine, ...rulesOutput, preservation].join('\n'),
152
+ output: ['Uninstalled codex-bridge.', permissionLine, ...rulesOutput, preservation].join('\n'),
138
153
  };
139
154
  }
140
155
 
package/cli/update.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /** Updates a recorded codex-bridge installation without overwriting unapproved host changes. */
1
+ /** Updates a recorded installation without overwriting unapproved host changes. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { install } from './install.mjs';
@@ -7,14 +7,26 @@ import {
7
7
  fileFingerprint,
8
8
  HOOK_DEFINITIONS,
9
9
  installedHookPath,
10
+ legacyInstallRecordPath,
10
11
  packageInfo,
12
+ recordFileKey,
11
13
  readInstallRecord,
12
14
  recordMatchesPackage,
15
+ recordTarget,
13
16
  rulesPlan,
14
17
  } from './manifest.mjs';
15
18
  import { targetMatches } from './copy.mjs';
16
- import { commandFor, inspectHook } from './settings-merge.mjs';
19
+ import { hookTargets } from './hook-targets.mjs';
20
+ import { definitionForRecordedHook, fileEntry, fingerprintFor } from './install-record.mjs';
21
+ import {
22
+ commandFor,
23
+ hookRegistration,
24
+ inspectHook,
25
+ removeHook,
26
+ withSettingsRun,
27
+ } from './settings-merge.mjs';
17
28
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
29
+ import { claudeBoundary, removeEmptyLayout, removeEmptyParents } from './remove-layout.mjs';
18
30
 
19
31
  async function exists(target) {
20
32
  try {
@@ -25,7 +37,19 @@ async function exists(target) {
25
37
  }
26
38
  }
27
39
 
28
- function classifyPlanned(state, record) {
40
+ /** Drops the previous layout's record and its emptied directories; safe to run when both are gone. */
41
+ async function retireLegacyLayout(host) {
42
+ await fs.rm(legacyInstallRecordPath(host), { force: true });
43
+ await removeEmptyLayout(host.legacyAgentsDir);
44
+ await removeEmptyLayout(host.legacyCommandsDir);
45
+ }
46
+
47
+ function displayFile(file) {
48
+ const entry = fileEntry(file);
49
+ return `${entry.root}/${entry.path}`;
50
+ }
51
+
52
+ function classifyPlanned(state) {
29
53
  if (!state.recorded) return 'new';
30
54
  if (!state.exists) return 'missing';
31
55
  if (state.matchesPackage) return 'up-to-date';
@@ -33,40 +57,24 @@ function classifyPlanned(state, record) {
33
57
  return 'modified';
34
58
  }
35
59
 
36
- function classifyOrphan(state, record) {
60
+ function classifyOrphan(state) {
37
61
  if (!state.exists) return 'orphaned';
38
- if (record.fingerprints?.[state.relative] === state.fingerprint) return 'orphaned';
62
+ if (state.recordedFingerprint === state.fingerprint) return 'orphaned';
39
63
  return 'modified';
40
64
  }
41
65
 
42
- function hookTargets(host) {
43
- return HOOK_DEFINITIONS.map((definition) => {
44
- const target = installedHookPath(host, definition);
45
- return {
46
- definition,
47
- relative: path.relative(host.root, target).split(path.sep).join('/'),
48
- spec: { event: definition.event, matcher: definition.matcher, command: commandFor(target) },
49
- };
50
- });
51
- }
52
-
53
66
  function recordHasHooks(record, targets) {
54
67
  return Boolean(record)
55
68
  && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
56
- hook.event === definition.event && hook.path === relative));
69
+ hook.event === definition.event && hook.root === 'brand' && hook.path === relative));
57
70
  }
58
71
 
59
- /**
60
- * What the run actually did, counted by outcome. `Updated codex-bridge.` alone told the operator
61
- * nothing about the files that were rewritten or removed on their behalf.
62
- */
63
72
  function appliedOutput(states) {
64
73
  const count = (status) => states.filter((state) => state.status === status).length;
65
74
  const parts = [
66
75
  [count('outdated'), 'updated'],
67
76
  [count('new'), 'added'],
68
77
  [count('missing'), 'restored'],
69
- // A modified orphan is preserved rather than rewritten, so it is not counted here.
70
78
  [states.filter((state) => state.status === 'modified' && state.item).length, 'overwritten'],
71
79
  [states.filter((state) => state.status === 'orphaned' && state.exists).length, 'removed'],
72
80
  ].filter(([total]) => total > 0).map(([total, label]) => `${total} ${label}`);
@@ -81,7 +89,7 @@ function conflictOutput(conflicts, legacy, dryRun) {
81
89
  return [heading, ...lines].join('\n');
82
90
  }
83
91
 
84
- function dryRunOutput(states, hookStates, legacy) {
92
+ function dryRunOutput(states, hookStates, legacy, oldHooks) {
85
93
  const lines = [];
86
94
  for (const state of states) {
87
95
  if (state.status === 'outdated') lines.push(`Would update ${state.relative}.`);
@@ -91,18 +99,47 @@ function dryRunOutput(states, hookStates, legacy) {
91
99
  if (state.status === 'orphaned' && state.exists) lines.push(`Would remove ${state.relative}.`);
92
100
  if (state.status === 'modified' && !state.item) lines.push(`Would preserve modified orphan ${state.relative}.`);
93
101
  }
102
+ // previousHooks() returns {hook, definition, spec}, and reading .event off that wrapper printed
103
+ // "the previous undefined hook registration" five times during the Plan_25 migration — the one
104
+ // dry run where the operator most needed to see which registrations were about to be taken away.
105
+ oldHooks.forEach(({ hook, definition }) => lines.push(
106
+ `Would remove the previous ${hook.event} hook registration for matcher ${definition.matcher}.`,
107
+ ));
94
108
  hookStates.forEach(({ state, target }) => {
95
- const { definition } = target;
96
- if (!state.present) lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher}.`);
109
+ const { definition, registration } = target;
110
+ if (!state.present) {
111
+ lines.push(`Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
112
+ }
97
113
  });
98
- lines.push('Would write installation record with new fingerprints.');
114
+ lines.push('Would write installation record with new fingerprints in both roots.');
99
115
  if (legacy && states.some((state) => state.status === 'modified')) {
100
116
  lines.push('Installation record has no fingerprints; differing files are treated as modified.');
101
117
  }
102
118
  return lines.join('\n');
103
119
  }
104
120
 
105
- export async function update({ host, dryRun = false, force = false, packageRoot } = {}) {
121
+ function previousHooks(host, record, targets) {
122
+ return record.hooks.filter((hook) => {
123
+ const current = targets.find(({ definition, relative }) =>
124
+ definition.event === hook.event && hook.root === 'brand' && relative === hook.path);
125
+ return !current;
126
+ }).map((hook) => {
127
+ const definition = definitionForRecordedHook(hook);
128
+ const target = recordTarget(host, hook);
129
+ return {
130
+ hook,
131
+ definition,
132
+ spec: {
133
+ event: hook.event,
134
+ matcher: definition.matcher,
135
+ command: hook.command || commandFor(target),
136
+ alternateCommands: [commandFor(target), `codex-bridge hook ${definition.name}`],
137
+ },
138
+ };
139
+ });
140
+ }
141
+
142
+ async function updateInRun({ host, dryRun = false, force = false, packageRoot, env = process.env } = {}) {
106
143
  await readRulesRegistry(host);
107
144
  const record = await readInstallRecord(host);
108
145
  if (!record) {
@@ -111,20 +148,20 @@ export async function update({ host, dryRun = false, force = false, packageRoot
111
148
 
112
149
  const plan = await buildInstallPlan(host, packageRoot);
113
150
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
114
- const planned = new Map(plan.map((item) => [item.relativeToHost, item]));
151
+ const planned = new Map(plan.map((item) => [recordFileKey(item), item]));
115
152
  const plannedStates = await Promise.all(plan.map(async (item) => {
116
- const relative = item.relativeToHost;
153
+ const relative = displayFile(item);
117
154
  const targetExists = await exists(item.target);
118
155
  const state = {
119
156
  relative,
120
157
  item,
121
- recorded: record.files.includes(relative),
122
- recordedFingerprint: record.fingerprints?.[relative],
158
+ recorded: record.files.some((file) => recordFileKey(file) === recordFileKey(item)),
159
+ recordedFingerprint: fingerprintFor(record, item),
123
160
  exists: targetExists,
124
- matchesPackage: targetExists && await targetMatches(item, host.agentsDir),
161
+ matchesPackage: targetExists && await targetMatches(item, host.brandRoot),
125
162
  fingerprint: targetExists ? await fileFingerprint(item.target) : null,
126
163
  };
127
- return { ...state, status: classifyPlanned(state, record) };
164
+ return { ...state, status: classifyPlanned(state) };
128
165
  }));
129
166
  const ruleExists = await exists(rule.target);
130
167
  const ruleState = {
@@ -133,22 +170,24 @@ export async function update({ host, dryRun = false, force = false, packageRoot
133
170
  recorded: Boolean(record.rules),
134
171
  recordedFingerprint: record.rules?.fingerprint,
135
172
  exists: ruleExists,
136
- matchesPackage: ruleExists && await targetMatches(rule, host.agentsDir),
173
+ matchesPackage: ruleExists && await targetMatches(rule, host.brandRoot),
137
174
  fingerprint: ruleExists ? await fileFingerprint(rule.target) : null,
138
175
  };
139
- ruleState.status = classifyPlanned(ruleState, record);
176
+ ruleState.status = classifyPlanned(ruleState);
140
177
  const orphanStates = await Promise.all(record.files
141
- .filter((relative) => !planned.has(relative))
142
- .map(async (relative) => {
143
- const target = path.join(host.root, relative);
178
+ .filter((file) => !planned.has(recordFileKey(file)))
179
+ .map(async (file) => {
180
+ const target = recordTarget(host, file);
144
181
  const targetExists = await exists(target);
145
182
  const state = {
146
- relative,
183
+ relative: displayFile(file),
184
+ entry: fileEntry(file),
147
185
  item: null,
148
186
  exists: targetExists,
187
+ recordedFingerprint: fingerprintFor(record, file),
149
188
  fingerprint: targetExists ? await fileFingerprint(target) : null,
150
189
  };
151
- return { ...state, status: classifyOrphan(state, record) };
190
+ return { ...state, status: classifyOrphan(state) };
152
191
  }));
153
192
  const states = [...plannedStates, ruleState, ...orphanStates];
154
193
  const conflicts = states.filter((state) => state.status === 'modified' || state.status === 'missing');
@@ -157,29 +196,51 @@ export async function update({ host, dryRun = false, force = false, packageRoot
157
196
  return { exitCode: 1, output: conflictOutput(conflicts, legacy, dryRun) };
158
197
  }
159
198
 
160
- const targets = hookTargets(host);
199
+ const currentPackage = await packageInfo(packageRoot);
200
+ const targets = hookTargets(host, env, currentPackage.version);
161
201
  const inspectedHooks = await Promise.all(targets.map(async (target) => ({
162
202
  target,
163
203
  state: await inspectHook(host.settingsPath, target.spec),
164
204
  })));
165
- const currentPackage = await packageInfo(packageRoot);
205
+ const oldHooks = previousHooks(host, record, targets);
166
206
  const recordCurrent = recordMatchesPackage(record, plan, currentPackage,
167
- new Map(plannedStates.map((state) => [state.relative, state.fingerprint])),
207
+ new Map(plannedStates.map((state) => [recordFileKey(state.item), state.fingerprint])),
168
208
  { path: rule.target, fingerprint: ruleState.fingerprint });
169
- const changed = states.some((state) => state.status !== 'up-to-date');
209
+ const changed = states.some((state) => state.status !== 'up-to-date') || oldHooks.length > 0;
170
210
  if (!changed && inspectedHooks.every(({ state }) => state.present)
171
211
  && recordHasHooks(record, targets) && recordCurrent) {
172
- if (!dryRun) await addRulesOwner(host);
212
+ if (!dryRun) {
213
+ await addRulesOwner(host);
214
+ // An update that installed the new layout but stopped before retiring the old one used to
215
+ // land here forever after: everything it compares is current, so it reported "up to date"
216
+ // while agents/codex sat next to agents/codex-bridge. Retirement is idempotent, so running
217
+ // it on this path costs nothing when there is nothing left to retire.
218
+ await retireLegacyLayout(host);
219
+ }
173
220
  return { exitCode: 0, output: 'codex-bridge is up to date' };
174
221
  }
175
222
  if (dryRun) {
176
- return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy) };
223
+ return { exitCode: 0, output: dryRunOutput(states, inspectedHooks, legacy, oldHooks) };
177
224
  }
178
225
 
179
226
  for (const state of orphanStates) {
180
- if (state.status === 'orphaned' && state.exists) await fs.rm(path.join(host.root, state.relative));
227
+ if (state.status !== 'orphaned' || !state.exists) continue;
228
+ const target = recordTarget(host, state.entry);
229
+ await fs.rm(target, { force: true });
230
+ const boundary = state.entry.root === 'brand' ? host.brandRoot : claudeBoundary(host, target);
231
+ await removeEmptyParents(target, boundary);
232
+ }
233
+ for (const { spec, hook } of oldHooks) {
234
+ await removeHook(host.settingsPath, spec, { createdGroup: hook.createdGroup === true });
181
235
  }
182
- const installed = await install({ host, force: true, packageRoot });
236
+ const installed = await install({ host, force: true, packageRoot, env });
183
237
  if (installed.exitCode !== 0) return installed;
238
+ await retireLegacyLayout(host);
184
239
  return { exitCode: 0, output: appliedOutput(states) };
185
240
  }
241
+
242
+ export async function update(options = {}) {
243
+ const host = options?.host;
244
+ if (!host?.settingsPath) return updateInRun(options);
245
+ return withSettingsRun(host.settingsPath, () => updateInRun(options));
246
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.4.0",
4
- "description": "Delegate implementation, reconnaissance and code review from Claude Code to the Codex CLI: dispatcher agents, a runner that survives a dropped call, and a verdict that checks the report against the worktree.",
3
+ "version": "0.5.0",
4
+ "description": "Claude Code-to-Codex delegation with 5 guards, 603 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -0,0 +1,13 @@
1
+ /** Names every root outside the repository that the suite must never run against for real. */
2
+
3
+ /**
4
+ * A fixture that does not name a root falls back to one of these variables, so the suite runner
5
+ * replaces all of them and the isolation test asserts each one. Two incidents put entries here:
6
+ * `~/.codex` collected a rules file on 2026-08-03, and `~/.lyupro/.codex-bridge` collected a
7
+ * config, conventions and an obsolete-file fixture on 2026-08-11, where the stray config then
8
+ * stood in the way of the seeded-file migration the release was about.
9
+ *
10
+ * It sits in its own module because the isolation test imports it, and importing the runner would
11
+ * start a second suite inside the first.
12
+ */
13
+ export const ISOLATED_ROOTS = Object.freeze(['CODEX_HOME', 'CODEX_BRIDGE_HOME']);
@@ -1,26 +1,36 @@
1
1
  /**
2
- * Runs the test suite with a Codex home of its own.
2
+ * Runs the test suite with every root it can install into replaced by a throwaway one.
3
3
  *
4
4
  * The installer writes its execpolicy file under `$CODEX_HOME/rules`, so a test that builds a host
5
5
  * without naming a Codex home installs into the real one: on 2026-08-03 the suite dropped
6
6
  * `codex-bridge.rules` into the operator's `~/.codex/rules/` and the next run failed on the file it
7
7
  * had left behind. Passing the isolation through the one command that starts the suite is what
8
8
  * makes it unforgettable — a fixture can omit it, this cannot.
9
+ *
10
+ * Plan_25 gave the package a second root outside the repository, and this file was not extended
11
+ * with it: on 2026-08-11 the suite wrote config.json, conventions.md and a lib/obsolete.txt fixture
12
+ * straight into the operator's real `~/.lyupro/.codex-bridge/`, where the stray config then stood
13
+ * in the way of the very migration the release was about. Roots live in one list here so a third
14
+ * one cannot be added without this file being the place it is declared.
9
15
  */
10
16
  import fs from 'node:fs';
11
17
  import os from 'node:os';
12
18
  import path from 'node:path';
13
19
  import { spawnSync } from 'node:child_process';
20
+ import { ISOLATED_ROOTS } from './isolated-roots.mjs';
14
21
 
15
- const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-home-'));
22
+ const created = ISOLATED_ROOTS.map((name) => [
23
+ name,
24
+ fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
25
+ ]);
16
26
  const pattern = process.argv[2] || 'tests/**/*.test.mjs';
17
27
 
18
28
  try {
19
29
  const result = spawnSync(process.execPath, ['--test', pattern], {
20
30
  stdio: 'inherit',
21
- env: { ...process.env, CODEX_HOME: codexHome },
31
+ env: { ...process.env, ...Object.fromEntries(created) },
22
32
  });
23
33
  process.exitCode = result.status ?? 1;
24
34
  } finally {
25
- fs.rmSync(codexHome, { recursive: true, force: true });
35
+ for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
26
36
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-build
3
- description: Имплементация ВНЕ подписки Claude — правки кода, новые модули, рефакторинг по образцу, починка тестов. Работу выполняет Codex CLI (подписка ChatGPT) с правом записи в репозиторий строго в пределах обязательного `scope`, Claude платит только за постановку задачи и ≤5 строк выжимки. Отчёт и полный лог кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. Ревью и приёмку делает отдельный агент Claude, не этот. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
3
+ description: Имплементация ВНЕ подписки Claude — правки кода, новые модули, рефакторинг по образцу, починка тестов. Работу выполняет Codex CLI (подписка ChatGPT) с правом записи в репозиторий строго в пределах обязательного `scope`, Claude платит только за постановку задачи и ≤5 строк выжимки. Отчёт и полный лог кладёт в ~/.claude/codex-runs/, в чат — короткая выжимка + путь. Ревью и приёмку делает отдельный агент Claude, не этот. {{CODEX_REQUIRED_INPUTS_SUMMARY}} {{CODEX_STOP_SUMMARY}}
4
4
  model: haiku
5
5
  tools: Bash
6
6
  ---
@@ -35,15 +35,15 @@ job is to report the run status honestly, including failure.
35
35
  reuse an order id from another order — the runner chains runs by that label, and a made-up label
36
36
  is how a repeat run hides. If the orchestrator did not give a required input, do not guess: start
37
37
  the runner without its flag and return the runner's refusal exactly.
38
- - `continue` — pass the `--continue` flag only when the orchestrator gave the matching `continue:`
39
- grant explicitly; do not add or guess it yourself. A continuation is assigned by the
40
- orchestrator, never chosen by you. After the verdict, return the exact attaching output and stop;
41
- do not issue or invent another continuation. The contract is the same kind as `--scope`: if this
42
- task (the same `slug` and the same repository) already had a run, the runner will refuse to start
43
- without `--continue` — before starting Codex, so no quota is spent. If the orchestrator did not
44
- grant a repeat, return the runner's refusal exactly.
45
- - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:`, `verify: <verification
46
- command>` (for example `npm test`, `tsc --noEmit`).
38
+ - `continue` — when the task text contains a line beginning with the `continue:` label, pass the
39
+ bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
40
+ or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
41
+ assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
42
+ output and stop; do not issue or invent another continuation. If no such grant line is present,
43
+ the flag must not be present.
44
+ - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>`, `slug:` (by default, the slug is
45
+ taken from the order id), `verify: <verification command>` (for example `npm test`,
46
+ `tsc --noEmit`).
47
47
 
48
48
  ## The only thing you do
49
49
 
@@ -61,8 +61,10 @@ Add `--effort "<value>"` only when the orchestrator named a depth, and only with
61
61
  decides, which is the intended default — a placeholder copied from this template is refused before
62
62
  Codex starts.
63
63
 
64
- If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
65
- command, with no value.
64
+ If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
65
+ flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
66
+ the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
67
+ present in the command.
66
68
  No value
67
69
  is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
68
70
  the
@@ -132,7 +134,7 @@ are designed for interactive work and cause harm in a delegated run — a failin
132
134
  and the task
133
135
  was not done; without them, 23k and the task was done. The flags affect one call; `~/.codex` is
134
136
  unchanged.
135
- The operator switches the mode through `/codex:env` (file `agents/codex/run-config.json`), and it
137
+ The operator switches the mode through `/codex-bridge:env` (file `~/.lyupro/.codex-bridge/config.json`), and it
136
138
  is recorded in the run's `meta.json` — do not guess it or add flags yourself.
137
139
  `--dangerously-bypass-approvals-and-sandbox` is never used. `--model` is not
138
140
  passed: model IDs are volatile. The script fixes this once, so silent
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-review
3
- description: Независимое второе мнение по коду от другой модели — ревью незакоммиченных правок, ветки или коммита силами Codex CLI (подписка ChatGPT). Не заменяет приёмку на стороне Claude, а дополняет её взглядом со стороны. Выдаёт строгий JSON с severity и confidence по каждой находке, в чат — ≤5 строк со счётчиком по важности. {{CODEX_REQUIRED_INPUTS_SUMMARY}}
3
+ description: Независимое второе мнение по коду от другой модели — ревью незакоммиченных правок, ветки или коммита силами Codex CLI (подписка ChatGPT). Не заменяет приёмку на стороне Claude, а дополняет её взглядом со стороны. Выдаёт строгий JSON с severity и confidence по каждой находке, в чат — ≤5 строк со счётчиком по важности. {{CODEX_REQUIRED_INPUTS_SUMMARY}} {{CODEX_STOP_SUMMARY}}
4
4
  model: haiku
5
5
  tools: Bash
6
6
  ---
@@ -34,12 +34,14 @@ opinion, not a verdict.
34
34
  another order — the runner chains runs by that label, and a made-up label is how a repeat run
35
35
  hides. If the orchestrator did not give a required input, do not guess — start the runner without
36
36
  its flag and return the runner's refusal verbatim.
37
- - `continue` is conditional — pass the `--continue` flag only when the orchestrator gave the
38
- matching `continue:` grant explicitly; do not add or guess it yourself. A continuation is
37
+ - `continue` — when the task text contains a line beginning with the `continue:` label, pass the
38
+ bare `--continue` flag even when its run name or reason looks malformed. Do not inspect, repair,
39
+ or swallow this grant; pass it through and let the runner issue the refusal. A continuation is
39
40
  assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
40
- output and stop; do not issue or invent another continuation.
41
- - Optional: review focus as text ("look for races and error handling"), `slug:`, `effort:
42
- <none|minimal|low|medium|high|xhigh|max>`.
41
+ output and stop; do not issue or invent another continuation. If no such grant line is present,
42
+ the flag must not be present.
43
+ - Optional: review focus as text ("look for races and error handling"), `slug:` (by default, the
44
+ slug is taken from the order id), `effort: <none|minimal|low|medium|high|xhigh|max>`.
43
45
 
44
46
  ## The only thing you do
45
47
 
@@ -61,8 +63,10 @@ line. To get the verdict, run the **identical command a second time** — same `
61
63
  `--slug`, same flags. That second call does not start a second run and costs no quota: it attaches
62
64
  to the run already in flight, prints `ATTACH=<path>`, blocks until the verdict exists and prints it.
63
65
 
64
- If the orchestrator gave the matching `continue:` grant, add the bare `--continue` flag to the
65
- command, with no value. If it was not given, the flag must not be present in the command at all.
66
+ If the task text contains a line beginning with the `continue:` label, add the bare `--continue`
67
+ flag even when its run name or reason looks malformed. Do not inspect, repair, or swallow the line;
68
+ the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
69
+ present in the command at all.
66
70
 
67
71
  Background execution (`run_in_background`, `&`, `nohup`) is prohibited. Interruption is no longer a
68
72
  problem worth handling: if the attaching call is killed by a time ceiling, repeat it — every repeat
@@ -114,7 +118,7 @@ properties that broke the contract.
114
118
  Regular `codex exec` follows the schema (verified during scouting), so the review uses it: with
115
119
  `--ignore-user-config` (structurally read-only, half the startup ballast), `--sandbox
116
120
  read-only`, `--disable hooks --disable plugins` (operator extensions do not affect the run;
117
- the default, switched through `/codex:env`), and without `--model` — model IDs are volatile. The
121
+ the default, switched through `/codex-bridge:env`), and without `--model` — model IDs are volatile. The
118
122
  runner determines the review scope
119
123
  itself through git: the exact file list and diff command go into the prompt and are saved in the run
120
124
  folder's