@lyupro/codex-bridge 0.1.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/bin/codex-bridge.mjs +105 -0
  5. package/cli/copy.mjs +38 -0
  6. package/cli/doctor.mjs +170 -0
  7. package/cli/hosts.mjs +50 -0
  8. package/cli/install.mjs +109 -0
  9. package/cli/manifest.mjs +210 -0
  10. package/cli/settings-merge.mjs +101 -0
  11. package/cli/stop.mjs +125 -0
  12. package/cli/uninstall.mjs +62 -0
  13. package/cli/update.mjs +156 -0
  14. package/package.json +48 -0
  15. package/scripts/check-file-size.mjs +193 -0
  16. package/scripts/run-tests.mjs +26 -0
  17. package/src/agents/codex-build.md +224 -0
  18. package/src/agents/codex-review.md +166 -0
  19. package/src/agents/codex-scout.md +188 -0
  20. package/src/commands/env.md +38 -0
  21. package/src/commands/usage.md +88 -0
  22. package/src/hooks/reply-guard.mjs +311 -0
  23. package/src/meta/chain.mjs +87 -0
  24. package/src/meta/environment.mjs +36 -0
  25. package/src/meta/paths.mjs +193 -0
  26. package/src/meta/reply.mjs +133 -0
  27. package/src/meta/run-state.mjs +197 -0
  28. package/src/meta/verdict.mjs +362 -0
  29. package/src/rules/codex-bridge.rules +173 -0
  30. package/src/run-codex.mjs +113 -0
  31. package/src/run-config.json +4 -0
  32. package/src/run-config.mjs +302 -0
  33. package/src/runner/args.mjs +111 -0
  34. package/src/runner/attach.mjs +111 -0
  35. package/src/runner/codex-cmd.mjs +265 -0
  36. package/src/runner/git-state.mjs +144 -0
  37. package/src/runner/launcher.mjs +374 -0
  38. package/src/runner/project-dir.mjs +92 -0
  39. package/src/runner/prompts.mjs +97 -0
  40. package/src/runner/run-context.mjs +52 -0
  41. package/src/runner/run-env.mjs +38 -0
  42. package/src/runner/runs-root.mjs +16 -0
  43. package/src/runner/schemas.mjs +135 -0
  44. package/src/runner/worker.mjs +62 -0
  45. package/src/write-meta.mjs +135 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The shape each agent's answer must have, one JSON schema per agent.
3
+ *
4
+ * Handed to `codex exec --output-schema`, so a run that answers in the wrong shape is
5
+ * rejected by Codex itself rather than discovered later by write-meta.mjs. Keyed by agent
6
+ * next to INSTRUCTIONS in prompts.mjs: the two are the same choice made twice, and looking
7
+ * either of them up by agent name is what keeps the three agents from drifting apart.
8
+ */
9
+ const SCOUT_SCHEMA = {
10
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
11
+ type: 'object',
12
+ additionalProperties: false,
13
+ required: ['answer', 'answers', 'findings', 'unknowns', 'report_markdown'],
14
+ properties: {
15
+ answer: { type: 'string', minLength: 1 },
16
+ // Required, not optional: a schema that only demanded a non-empty `answer` accepted a
17
+ // run that replied to six numbered questions with one table of coordinates. One object
18
+ // per question, each carrying its own prose — coordinates live in `evidence`.
19
+ answers: {
20
+ type: 'array',
21
+ items: {
22
+ type: 'object',
23
+ additionalProperties: false,
24
+ required: ['question_id', 'answer', 'evidence'],
25
+ properties: {
26
+ question_id: { type: 'string', pattern: '^Q\\d+$' },
27
+ answer: { type: 'string', minLength: 1 },
28
+ evidence: { type: 'array', items: { type: 'string' } },
29
+ },
30
+ },
31
+ },
32
+ findings: {
33
+ type: 'array',
34
+ items: {
35
+ type: 'object',
36
+ additionalProperties: false,
37
+ required: ['fact', 'where', 'confidence'],
38
+ properties: {
39
+ fact: { type: 'string', minLength: 1 },
40
+ where: { type: 'string' },
41
+ confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
42
+ },
43
+ },
44
+ },
45
+ unknowns: { type: 'array', items: { type: 'string' } },
46
+ report_markdown: { type: 'string', minLength: 1 },
47
+ },
48
+ };
49
+
50
+ const BUILD_SCHEMA = {
51
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
52
+ type: 'object',
53
+ additionalProperties: false,
54
+ required: ['summary', 'changes', 'verify_command', 'verify_passed', 'leftovers', 'report_markdown'],
55
+ properties: {
56
+ summary: { type: 'string', minLength: 1 },
57
+ changes: {
58
+ type: 'array',
59
+ items: {
60
+ type: 'object',
61
+ additionalProperties: false,
62
+ required: ['file', 'what', 'why'],
63
+ properties: {
64
+ // One entry, one path. As a bare non-empty string this field legitimately
65
+ // collected `src/cost/{types,reader}.ts` and `a/*.test.ts; b/*.test.ts` — honest
66
+ // answers to "which files", and unmatchable against the worktree, so a run that
67
+ // really had done the work was failed for the way it described it (2026-07-31,
68
+ // run 120340). The pattern makes the folded forms impossible instead of merely
69
+ // discouraged: no `*`, `?`, braces, `;`, `,` or whitespace.
70
+ file: {
71
+ type: 'string',
72
+ minLength: 1,
73
+ pattern: '^[^*?{};,\\s]+$',
74
+ description:
75
+ 'Exactly one repository-root-relative path, in the form printed by git ' +
76
+ '(for example packages/agent-sdk/src/cost/types.ts). Globs, brace expansion, ' +
77
+ 'and lists are prohibited — use a separate entry for each file.',
78
+ },
79
+ what: { type: 'string' },
80
+ why: { type: 'string' },
81
+ },
82
+ },
83
+ },
84
+ verify_command: { type: ['string', 'null'] },
85
+ verify_passed: { type: ['boolean', 'null'] },
86
+ leftovers: { type: 'array', items: { type: 'string' } },
87
+ report_markdown: { type: 'string', minLength: 1 },
88
+ },
89
+ };
90
+
91
+ const REVIEW_SCHEMA = {
92
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
93
+ type: 'object',
94
+ additionalProperties: false,
95
+ required: ['verdict', 'summary', 'findings', 'next_steps'],
96
+ properties: {
97
+ verdict: { type: 'string', enum: ['approve', 'needs-attention'] },
98
+ summary: { type: 'string', minLength: 1 },
99
+ findings: {
100
+ type: 'array',
101
+ items: {
102
+ type: 'object',
103
+ additionalProperties: false,
104
+ required: [
105
+ 'severity',
106
+ 'title',
107
+ 'body',
108
+ 'file',
109
+ 'line_start',
110
+ 'line_end',
111
+ 'confidence',
112
+ 'recommendation',
113
+ ],
114
+ properties: {
115
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
116
+ title: { type: 'string', minLength: 1 },
117
+ body: { type: 'string', minLength: 1 },
118
+ file: { type: 'string' },
119
+ line_start: { type: 'integer' },
120
+ line_end: { type: 'integer' },
121
+ confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
122
+ recommendation: { type: 'string', minLength: 1 },
123
+ },
124
+ },
125
+ },
126
+ next_steps: { type: 'array', items: { type: 'string' } },
127
+ },
128
+ };
129
+
130
+ /** Same three keys as AGENTS in write-meta.mjs; parseArgs() has already refused anything else. */
131
+ export const SCHEMAS = {
132
+ 'codex-scout': SCOUT_SCHEMA,
133
+ 'codex-build': BUILD_SCHEMA,
134
+ 'codex-review': REVIEW_SCHEMA,
135
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The half that outlives the caller: Codex itself, the "after" snapshots, the verdict.
3
+ *
4
+ * It takes its whole order from worker.json, written by launcher.mjs into the run folder —
5
+ * after the split that file is the only connection between the two halves. Read here:
6
+ * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
7
+ * `slug` and `launcher_pid`, which nothing here needs but a run folder read back months later
8
+ * does. The launcher must write the selected mode's budget into worker.json before spawning
9
+ * this half, so the worker never re-reads run-config.json and one run cannot acquire two
10
+ * configurations.
11
+ * Neither side may change the shape alone.
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { collect, exitCodeFor, AGENTS } from '../write-meta.mjs';
16
+ import { setRun, emitReply } from './run-context.mjs';
17
+ import { git, headSha, branchName, worktreeSnapshot, findFakeDone } from './git-state.mjs';
18
+ import { runCodex } from './codex-cmd.mjs';
19
+
20
+ /**
21
+ * Runs the order in runDir and answers into reply.txt, never to a console — nothing here
22
+ * may depend on anyone still being on the other end of a pipe.
23
+ */
24
+ export async function worker(runDir) {
25
+ const cfg = JSON.parse(fs.readFileSync(path.join(runDir, 'worker.json'), 'utf8'));
26
+ const repoRoot = cfg.repo;
27
+ setRun(runDir, cfg.agent);
28
+
29
+ const exit = await runCodex(
30
+ cfg.args,
31
+ fs.readFileSync(path.join(runDir, 'task.md'), 'utf8'),
32
+ path.join(runDir, 'raw.log'),
33
+ cfg.budget_minutes,
34
+ );
35
+
36
+ if (cfg.agent === 'codex-build') {
37
+ fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${cfg.is_git_repo ? headSha(repoRoot) : ''}\n`);
38
+ fs.writeFileSync(path.join(runDir, 'branch-after.txt'), `${cfg.is_git_repo ? branchName(repoRoot) : ''}\n`);
39
+ fs.writeFileSync(path.join(runDir, 'git-after.txt'), git(repoRoot, ['status', '--porcelain']).stdout || '');
40
+ fs.writeFileSync(path.join(runDir, 'state-after.txt'), `${worktreeSnapshot(repoRoot)}\n`);
41
+ fs.writeFileSync(path.join(runDir, 'diff.stat'), git(repoRoot, ['diff', '--stat']).stdout || '');
42
+ fs.writeFileSync(path.join(runDir, 'flags.txt'), findFakeDone(repoRoot));
43
+ }
44
+
45
+ // Human-readable report is carried inside the schema: a read-only scout cannot write
46
+ // files itself, so the runner unpacks it.
47
+ const resultPath = path.join(runDir, AGENTS[cfg.agent].result);
48
+ if (cfg.agent !== 'codex-review') {
49
+ try {
50
+ const result = JSON.parse(fs.readFileSync(resultPath, 'utf8'));
51
+ if (result.report_markdown) fs.writeFileSync(path.join(runDir, 'report.md'), `${result.report_markdown}\n`);
52
+ } catch {
53
+ // No parseable result — write-meta.mjs turns that into FAIL with the log reason.
54
+ }
55
+ }
56
+
57
+ // collect() writes meta.json and closes status.json; reply.txt comes last, so the launcher
58
+ // seeing a reply is proof the verdict behind it is already on disk.
59
+ const { meta, reply } = collect(runDir, cfg.agent, exit);
60
+ emitReply(reply);
61
+ process.exit(exitCodeFor(meta.status));
62
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Turns one Codex run directory into accounting plus a verdict:
4
+ * node write-meta.mjs <runDir> <agent> <exitCode>
5
+ *
6
+ * This is the only entry point that reads a run's artifacts. It writes meta.json — quota
7
+ * accounting that survives deletion of the gitignored raw.log — and derives the run
8
+ * status from what is actually on disk: exit code, log size, result file, git state.
9
+ * A dispatcher agent therefore cannot report an outcome its own run does not support.
10
+ *
11
+ * The reply lines printed here ARE the reply. Agents forward this stdout verbatim
12
+ * instead of composing prose, which is what keeps a delegated run at five lines.
13
+ *
14
+ * All three agents go through this file: accounting and verdict must not fork into
15
+ * three slightly different copies.
16
+ *
17
+ * The work itself lives in meta/, one concern per module: paths.mjs reads artifacts and
18
+ * makes two spellings of a path meet, chain.mjs finds the earlier passes of the same task,
19
+ * run-state.mjs keeps status.json honest, verdict.mjs decides OK/FAIL/LIMIT, reply.mjs
20
+ * renders the lines. Importers name this file and nothing below it.
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { changedPaths, readJson, readText, size } from './meta/paths.mjs';
26
+ import { splitRunChanges } from './meta/environment.mjs';
27
+ import { writeStatus } from './meta/run-state.mjs';
28
+ import { resolveStatus } from './meta/verdict.mjs';
29
+ import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
30
+
31
+ export { expandDeclared, globToRegExp, readJson } from './meta/paths.mjs';
32
+ export { chainBaseline, chainRuns, taskFingerprint } from './meta/chain.mjs';
33
+ export { abandonedBranchDrift, activeRun, markAbandoned, writeFailure } from './meta/run-state.mjs';
34
+ export { outOfScope, reportVersusWork } from './meta/verdict.mjs';
35
+ export { AGENTS, writeStatus };
36
+
37
+ /**
38
+ * Folder name a repository gets inside codex-runs. A repo whose own folder starts with a
39
+ * dot (~/.claude, ~/.omc) would otherwise produce a hidden run folder that plain `ls` does
40
+ * not show and that reads as a service directory rather than a project.
41
+ */
42
+ export const projectFolder = (repoRoot) => path.basename(repoRoot).replace(/^\.+/, '') || 'repo';
43
+
44
+ /**
45
+ * Reads the run directory, writes meta.json, returns {meta, reply}. `reply` is the
46
+ * exact text a dispatcher is allowed to return.
47
+ */
48
+ export function collect(runDir, agent, exitCode) {
49
+ const cfg = AGENTS[agent];
50
+ if (!cfg) throw new Error(`unknown agent: ${agent} (expected one of ${Object.keys(AGENTS).join(', ')})`);
51
+
52
+ const logPath = path.join(runDir, 'raw.log');
53
+ const resultPath = path.join(runDir, cfg.result);
54
+ const log = readText(logPath);
55
+ const logBytes = size(logPath);
56
+ const result = readJson(resultPath);
57
+ const resultOk = Boolean(result) && cfg.filled(result);
58
+ const exit = exitCode === undefined || exitCode === null ? null : Number(exitCode);
59
+
60
+ // "tokens used" is followed by the count on the next line, with a non-breaking
61
+ // thousands separator — strip every non-digit rather than trusting the spacing.
62
+ const usage = [...log.matchAll(/tokens used[\r\n]+([^\r\n]+)/g)].pop();
63
+
64
+ // `codex exec review` reports no usage at all. Null, never 0: a zero would silently
65
+ // understate the total in /codex:usage.
66
+ const tokens = usage ? parseInt(usage[1].replace(/\D/g, ''), 10) || null : null;
67
+
68
+ const pick = (re) => (log.match(re) || [])[1] || null;
69
+ const { status, reason, carried } = resolveStatus({ log, logBytes, resultOk, exit, agent, result, runDir });
70
+
71
+ const meta = {
72
+ agent,
73
+ project: path.basename(path.dirname(runDir)),
74
+ run: path.basename(runDir),
75
+ finished_at: new Date().toISOString(),
76
+ exit,
77
+ status,
78
+ reason,
79
+ // An OK earned by an earlier pass of the same task reads differently from an OK earned
80
+ // here, and accounting must not blur the two: always present, so its absence in a reply
81
+ // is never mistaken for "false".
82
+ carried_from_earlier_run: Boolean(carried),
83
+ // What the tooling around the run wrote into the tree while it worked. Kept out of the
84
+ // verdict but never out of the record: this is the audit trail for every path a pattern
85
+ // excused, so a real edit cannot hide behind one.
86
+ environment_changes: splitRunChanges(
87
+ runDir,
88
+ changedPaths(readText(path.join(runDir, 'state-before.txt')), readText(path.join(runDir, 'state-after.txt'))),
89
+ ).environment,
90
+ result_ok: resultOk,
91
+ log_bytes: logBytes,
92
+ tokens,
93
+ tokens_reported: tokens !== null,
94
+ model: pick(/^model:\s*(\S+)/m),
95
+ sandbox: pick(/^sandbox:\s*(\S+)/m),
96
+ // Null for runs made before env.json existed: absence is not the same as "both off".
97
+ env: readJson(path.join(runDir, 'env.json')),
98
+ session_id: pick(/^session id:\s*(\S+)/m),
99
+ };
100
+
101
+ fs.writeFileSync(path.join(runDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
102
+ // meta.json first, status.json after it: status only ever claims a verdict that is
103
+ // already on disk.
104
+ writeStatus(runDir, { state: 'finished', status, finished_at: meta.finished_at });
105
+
106
+ const ctx = {
107
+ runDir,
108
+ agent,
109
+ result,
110
+ resultPath,
111
+ carried: meta.carried_from_earlier_run,
112
+ file: (name) => path.join(runDir, name),
113
+ };
114
+ const reply =
115
+ status === 'OK' ? cfg.reply(ctx) : status === 'LIMIT' ? limitReply(ctx, meta) : failReply(ctx, meta);
116
+
117
+ return { meta, reply: reply.join('\n') };
118
+ }
119
+
120
+ /** Exit code mirrors the status so an orchestrator can branch without parsing text. */
121
+ export const exitCodeFor = (status) => (status === 'OK' ? 0 : status === 'LIMIT' ? 3 : 1);
122
+
123
+ const invokedDirectly =
124
+ process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
125
+
126
+ if (invokedDirectly) {
127
+ const [runDir, agent, exitCode] = process.argv.slice(2);
128
+ if (!runDir || !agent) {
129
+ console.error('usage: node write-meta.mjs <runDir> <agent> <exitCode>');
130
+ process.exit(1);
131
+ }
132
+ const { meta, reply } = collect(runDir, agent, exitCode);
133
+ console.log(reply);
134
+ process.exit(exitCodeFor(meta.status));
135
+ }