@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.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bin/codex-bridge.mjs +105 -0
- package/cli/copy.mjs +38 -0
- package/cli/doctor.mjs +170 -0
- package/cli/hosts.mjs +50 -0
- package/cli/install.mjs +109 -0
- package/cli/manifest.mjs +210 -0
- package/cli/settings-merge.mjs +101 -0
- package/cli/stop.mjs +125 -0
- package/cli/uninstall.mjs +62 -0
- package/cli/update.mjs +156 -0
- package/package.json +48 -0
- package/scripts/check-file-size.mjs +193 -0
- package/scripts/run-tests.mjs +26 -0
- package/src/agents/codex-build.md +224 -0
- package/src/agents/codex-review.md +166 -0
- package/src/agents/codex-scout.md +188 -0
- package/src/commands/env.md +38 -0
- package/src/commands/usage.md +88 -0
- package/src/hooks/reply-guard.mjs +311 -0
- package/src/meta/chain.mjs +87 -0
- package/src/meta/environment.mjs +36 -0
- package/src/meta/paths.mjs +193 -0
- package/src/meta/reply.mjs +133 -0
- package/src/meta/run-state.mjs +197 -0
- package/src/meta/verdict.mjs +362 -0
- package/src/rules/codex-bridge.rules +173 -0
- package/src/run-codex.mjs +113 -0
- package/src/run-config.json +4 -0
- package/src/run-config.mjs +302 -0
- package/src/runner/args.mjs +111 -0
- package/src/runner/attach.mjs +111 -0
- package/src/runner/codex-cmd.mjs +265 -0
- package/src/runner/git-state.mjs +144 -0
- package/src/runner/launcher.mjs +374 -0
- package/src/runner/project-dir.mjs +92 -0
- package/src/runner/prompts.mjs +97 -0
- package/src/runner/run-context.mjs +52 -0
- package/src/runner/run-env.mjs +38 -0
- package/src/runner/runs-root.mjs +16 -0
- package/src/runner/schemas.mjs +135 -0
- package/src/runner/worker.mjs +62 -0
- package/src/write-meta.mjs +135 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the earlier passes of the task a run belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Runs are tied together by slug or by the task text itself, against the repository — all
|
|
5
|
+
* read from the status.json each run writes before Codex is even probed. Nothing here judges
|
|
6
|
+
* a run; it only says which other runs were the same task, so a verdict can be graded against
|
|
7
|
+
* the whole of it.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { normalizePath, readJson } from './paths.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What makes two runs the same task when their names disagree.
|
|
16
|
+
*
|
|
17
|
+
* The slug alone cannot answer it: the slug is chosen by whoever repeats the run. On
|
|
18
|
+
* 2026-08-02 a dispatcher lost its launcher to a kill, decided the run had died, and started
|
|
19
|
+
* the identical task again as `<slug>-v2` — 46k of quota on a repeat the --continue gate was
|
|
20
|
+
* built to refuse. Whitespace and case are normalized away because the same order re-sent
|
|
21
|
+
* through a shell is rewrapped, not rewritten; anything more forgiving would tie together
|
|
22
|
+
* tasks that merely look alike.
|
|
23
|
+
*/
|
|
24
|
+
export const taskFingerprint = (taskText) => {
|
|
25
|
+
const normalized = String(taskText ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
26
|
+
return normalized ? createHash('sha256').update(normalized).digest('hex').slice(0, 16) : '';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every run of one task, oldest first — same slug against the same repository. A task is
|
|
31
|
+
* not a run: after a kill or a LIMIT the work is finished by a second pass, and grading
|
|
32
|
+
* that pass against its own delta alone is what produced the false verdict of
|
|
33
|
+
* 2026-07-31_121703, where Codex found its earlier edits already in place, listed them
|
|
34
|
+
* honestly, changed nothing and was told it had done no work. Folder names rather than
|
|
35
|
+
* full paths; the current run joins the list as soon as its status.json exists.
|
|
36
|
+
*/
|
|
37
|
+
export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
|
|
38
|
+
const wantedRepo = normalizePath(repo);
|
|
39
|
+
const wantedSlug = String(slug ?? '').trim().toLowerCase();
|
|
40
|
+
const wantedHash = String(taskHash ?? '').trim().toLowerCase();
|
|
41
|
+
const wantedOrderId = String(orderId ?? '').trim();
|
|
42
|
+
// No handle on the task, or no repo, means nothing to chain — an empty context must find
|
|
43
|
+
// nothing rather than fall back to "every run in this folder".
|
|
44
|
+
if (!wantedRepo || (!wantedSlug && !wantedHash && !wantedOrderId)) return [];
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const runs = [];
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isDirectory()) continue;
|
|
54
|
+
const status = readJson(path.join(runsRoot, entry.name, 'status.json'));
|
|
55
|
+
if (!status) continue;
|
|
56
|
+
// Either handle identifies the task. The hash catches a repeat that renamed itself; the
|
|
57
|
+
// slug still catches the follow-up whose wording the orchestrator edited on purpose.
|
|
58
|
+
const sameSlug = Boolean(wantedSlug) && String(status.slug ?? '').trim().toLowerCase() === wantedSlug;
|
|
59
|
+
const sameTask = Boolean(wantedHash) && String(status.task_hash ?? '').trim().toLowerCase() === wantedHash;
|
|
60
|
+
const sameOrder = Boolean(wantedOrderId) && String(status.order_id ?? '').trim() === wantedOrderId;
|
|
61
|
+
if (!sameSlug && !sameTask && !sameOrder) continue;
|
|
62
|
+
if (normalizePath(status.repo) !== wantedRepo) continue;
|
|
63
|
+
runs.push({ name: entry.name, at: String(status.started_at || '') });
|
|
64
|
+
}
|
|
65
|
+
// started_at decides, folder name breaks ties: folders are stamped `<date>_<time>_<slug>`,
|
|
66
|
+
// so they sort the same way for runs written before the field existed.
|
|
67
|
+
runs.sort((a, b) => (a.at === b.at ? (a.name < b.name ? -1 : 1) : a.at < b.at ? -1 : 1));
|
|
68
|
+
return runs.map((r) => r.name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The tree as it stood before the task began: state-before.txt of the FIRST run of the
|
|
73
|
+
* chain. The first and not the previous one, because a middle run can end without an
|
|
74
|
+
* "after" snapshot at all — 2026-07-31_114736 wrote eleven files and left neither
|
|
75
|
+
* state-after.txt nor meta.json — and its own "before" is then the last honest base.
|
|
76
|
+
* null when there is no chain or that first run never got a snapshot; an existing but
|
|
77
|
+
* empty snapshot is a clean tree, which is data, not absence.
|
|
78
|
+
*/
|
|
79
|
+
export function chainBaseline(runsRoot, repo, slug, taskHash = '', orderId = '') {
|
|
80
|
+
const [first] = chainRuns(runsRoot, repo, slug, taskHash, orderId);
|
|
81
|
+
if (!first) return null;
|
|
82
|
+
try {
|
|
83
|
+
return fs.readFileSync(path.join(runsRoot, first, 'state-before.txt'), 'utf8');
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tells apart what a run changed from what the tooling around it changed.
|
|
3
|
+
*
|
|
4
|
+
* Every verdict about a build run rests on the worktree either side of it, and that
|
|
5
|
+
* comparison assumes the run is the only writer. In a repository under OMC and Claude Code it
|
|
6
|
+
* never is: on 2026-08-02 a run that touched exactly its three scoped files was failed for
|
|
7
|
+
* “out-of-scope changes: .omc/project-memory.json”, written by the orchestrator's own tooling
|
|
8
|
+
* while Codex worked.
|
|
9
|
+
*
|
|
10
|
+
* The patterns are read from the run's own env.json, not from the config, so recomputing an
|
|
11
|
+
* old run's verdict gives the answer it gave then — the config may have changed since.
|
|
12
|
+
*/
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { displayPath, globToRegExp, normalizePath, readJson } from './paths.mjs';
|
|
15
|
+
|
|
16
|
+
/** Empty for runs made before the list existed: absence means "the run was the only writer". */
|
|
17
|
+
export function environmentPatterns(runDir) {
|
|
18
|
+
const configured = readJson(path.join(runDir, 'env.json'))?.environmentPaths;
|
|
19
|
+
return Array.isArray(configured) ? configured.map((row) => String(row).trim()).filter(Boolean) : [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Touched paths, normalized and sorted into the run's own work and the environment's. */
|
|
23
|
+
export function splitEnvironment(paths, patterns) {
|
|
24
|
+
const matchers = (patterns || []).filter(Boolean).map(globToRegExp);
|
|
25
|
+
const work = [];
|
|
26
|
+
const environment = [];
|
|
27
|
+
for (const raw of paths || []) {
|
|
28
|
+
const file = displayPath(raw);
|
|
29
|
+
if (!normalizePath(file)) continue;
|
|
30
|
+
(matchers.some((re) => re.test(file)) ? environment : work).push(file);
|
|
31
|
+
}
|
|
32
|
+
return { work, environment };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The same split for a run on disk, using the patterns that were in force when it started. */
|
|
36
|
+
export const splitRunChanges = (runDir, paths) => splitEnvironment(paths, environmentPatterns(runDir));
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a run's artifacts on disk into values the rest of the verdict can compare.
|
|
3
|
+
*
|
|
4
|
+
* Two sides spell the same file differently — git prints repo-relative paths with forward
|
|
5
|
+
* slashes, Codex writes whatever it likes — so nothing above this module compares a path
|
|
6
|
+
* before it has been through here. This is also the only module that touches the
|
|
7
|
+
* filesystem for reading: everything else receives text, JSON or a byte count from it.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
|
|
11
|
+
/** One line, no newlines, bounded length — a five-line reply must stay five lines. */
|
|
12
|
+
export const line = (value, max = 200) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
13
|
+
|
|
14
|
+
export const readText = (file) => {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readFileSync(file, 'utf8');
|
|
17
|
+
} catch {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A run's JSON artifact, or null when there is none to read. The byte-order mark is stripped
|
|
24
|
+
* first: JSON.parse rejects it, and a status.json rewritten by a Windows editor or by
|
|
25
|
+
* PowerShell's `Set-Content -Encoding utf8` would then read as "no such run" — silently
|
|
26
|
+
* excusing that run from every check that looks it up.
|
|
27
|
+
*/
|
|
28
|
+
export const readJson = (file) => {
|
|
29
|
+
try {
|
|
30
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
31
|
+
// Compared by code point rather than matched by a literal: a byte-order mark in the
|
|
32
|
+
// source of this file would be invisible to the next reader.
|
|
33
|
+
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const size = (file) => {
|
|
40
|
+
try {
|
|
41
|
+
return fs.statSync(file).size;
|
|
42
|
+
} catch {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Snapshot lines are `<added>\t<deleted>\t<path>` for tracked files and `U\t<bytes>\t<path>`
|
|
49
|
+
* for untracked ones — see worktreeSnapshot() in runner/git-state.mjs. Path last, state first.
|
|
50
|
+
*/
|
|
51
|
+
export const snapshotMap = (text) => {
|
|
52
|
+
const map = new Map();
|
|
53
|
+
for (const row of text.split(/\r?\n/).filter(Boolean)) {
|
|
54
|
+
const parts = row.split('\t');
|
|
55
|
+
if (parts.length < 3) continue;
|
|
56
|
+
map.set(parts.slice(2).join('\t').trim(), `${parts[0]}\t${parts[1]}`);
|
|
57
|
+
}
|
|
58
|
+
return map;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Paths Codex actually touched, by content rather than by porcelain letter: a file that
|
|
63
|
+
* was already ` M` stays ` M` after further edits, so status codes would report zero
|
|
64
|
+
* work. Line counts (or size, for untracked files) do change.
|
|
65
|
+
*/
|
|
66
|
+
export const changedPaths = (before, after) => {
|
|
67
|
+
const was = snapshotMap(before);
|
|
68
|
+
const now = snapshotMap(after);
|
|
69
|
+
const paths = [];
|
|
70
|
+
for (const [file, state] of now) if (was.get(file) !== state) paths.push(file);
|
|
71
|
+
// A path that dropped out was touched too: Codex restored it to its committed state.
|
|
72
|
+
for (const file of was.keys()) if (!now.has(file)) paths.push(file);
|
|
73
|
+
return paths;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Directories a delegated build has no business editing, whatever the task says. */
|
|
77
|
+
export const SERVICE_RE = /^(\.omx|\.omc|\.claude|\.codex|\.git|node_modules)\//;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Paths arrive from two sides that spell them differently: git prints repo-relative with
|
|
81
|
+
* forward slashes, while Codex writes whatever it likes — backslashes, `./` prefixes,
|
|
82
|
+
* absolute paths, the occasional backtick left over from markdown.
|
|
83
|
+
*/
|
|
84
|
+
export const displayPath = (value) =>
|
|
85
|
+
String(value ?? '')
|
|
86
|
+
.trim()
|
|
87
|
+
.replace(/^[`'"]+|[`'"]+$/g, '')
|
|
88
|
+
.replace(/\\/g, '/')
|
|
89
|
+
.replace(/^\.\//, '');
|
|
90
|
+
|
|
91
|
+
/** Comparison spelling: displayPath's canonical separators plus Windows-style case folding. */
|
|
92
|
+
export const normalizePath = (value) => displayPath(value).toLowerCase();
|
|
93
|
+
|
|
94
|
+
/** Suffix match, so an absolute path from Codex still meets a relative one from git. */
|
|
95
|
+
export const samePath = (declared, touched) => {
|
|
96
|
+
const left = normalizePath(declared);
|
|
97
|
+
const right = normalizePath(touched);
|
|
98
|
+
return left === right || right.endsWith(`/${left}`) || left.endsWith(`/${right}`);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One --scope pattern as a regular expression. Deliberately a small glob dialect and not a
|
|
103
|
+
* dependency: `**` crosses directory boundaries, `*` and `?` stop at `/`, matching is
|
|
104
|
+
* case-insensitive because Windows paths arrive in whatever case Codex felt like using.
|
|
105
|
+
* A slash right after `**` is optional, so `src/**` covers `src/a.ts` too — otherwise the
|
|
106
|
+
* most natural way to write a scope would silently match nothing.
|
|
107
|
+
*/
|
|
108
|
+
export function globToRegExp(pattern) {
|
|
109
|
+
const glob = String(pattern ?? '')
|
|
110
|
+
.trim()
|
|
111
|
+
.replace(/\\/g, '/')
|
|
112
|
+
.replace(/^\.\//, '');
|
|
113
|
+
let out = '';
|
|
114
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
115
|
+
const ch = glob[i];
|
|
116
|
+
if (ch === '*') {
|
|
117
|
+
if (glob[i + 1] === '*') {
|
|
118
|
+
i += 1;
|
|
119
|
+
if (glob[i + 1] === '/') {
|
|
120
|
+
i += 1;
|
|
121
|
+
out += '(?:.*/)?';
|
|
122
|
+
} else {
|
|
123
|
+
out += '.*';
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
out += '[^/]*';
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (ch === '?') {
|
|
131
|
+
out += '[^/]';
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
135
|
+
}
|
|
136
|
+
return new RegExp(`^${out}$`, 'i');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The paths one changes[].file entry actually names. BUILD_SCHEMA asks only for a non-empty
|
|
141
|
+
* string, so Codex folds several files into one: 2026-07-31_120340 edited three real files
|
|
142
|
+
* and called them `.../cost/{types,phase-cost-recorder,tier1-capture}.ts`, a string no git
|
|
143
|
+
* path can ever equal. Enumerations split on `;` and `,` outside braces, braces expand one
|
|
144
|
+
* level, and globs come back as patterns — `src/*.test.ts` is a claim about a set of files,
|
|
145
|
+
* not a file. Nested braces are deliberately unsupported: Codex has never written one.
|
|
146
|
+
*/
|
|
147
|
+
export function expandDeclared(file) {
|
|
148
|
+
const raw = String(file ?? '');
|
|
149
|
+
const parts = [];
|
|
150
|
+
let depth = 0;
|
|
151
|
+
let current = '';
|
|
152
|
+
for (const ch of raw) {
|
|
153
|
+
if (ch === '{') depth += 1;
|
|
154
|
+
else if (ch === '}') depth = Math.max(0, depth - 1);
|
|
155
|
+
if ((ch === ';' || ch === ',') && depth === 0) {
|
|
156
|
+
parts.push(current);
|
|
157
|
+
current = '';
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
current += ch;
|
|
161
|
+
}
|
|
162
|
+
parts.push(current);
|
|
163
|
+
|
|
164
|
+
const expand = (value) => {
|
|
165
|
+
const brace = /\{([^{}]*)\}/.exec(value);
|
|
166
|
+
if (!brace) return [value];
|
|
167
|
+
const head = value.slice(0, brace.index);
|
|
168
|
+
const tail = value.slice(brace.index + brace[0].length);
|
|
169
|
+
return brace[1].split(',').flatMap((option) => expand(`${head}${option.trim()}${tail}`));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Deduplication compares exact spellings, not suffixes: `a/x.mjs` and `b/a/x.mjs` declared in
|
|
173
|
+
// one entry are two files, and samePath would fold the pair into one.
|
|
174
|
+
const out = [];
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
for (const candidate of expand(part)) {
|
|
178
|
+
const displayed = displayPath(candidate);
|
|
179
|
+
const key = normalizePath(displayed);
|
|
180
|
+
if (!key || seen.has(key)) continue;
|
|
181
|
+
seen.add(key);
|
|
182
|
+
out.push(displayed);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A declared entry against real paths: a glob matches as a pattern, a path by suffix. */
|
|
189
|
+
export const declaredHits = (declared, paths) => {
|
|
190
|
+
if (!/[*?]/.test(declared)) return paths.some((p) => samePath(declared, p));
|
|
191
|
+
const re = globToRegExp(declared);
|
|
192
|
+
return paths.some((p) => re.test(p));
|
|
193
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the reply lines a dispatcher is allowed to return, one format per agent.
|
|
3
|
+
*
|
|
4
|
+
* AGENTS is the registry of the three dispatchers: for each one it names the result file
|
|
5
|
+
* that run writes, how to tell that file is filled in, and which of the three reply
|
|
6
|
+
* strategies below renders it. FAIL and LIMIT bypass the per-agent strategy — a run that
|
|
7
|
+
* produced nothing has nothing agent-specific left to say.
|
|
8
|
+
*
|
|
9
|
+
* The lines built here ARE the reply. Agents forward this text verbatim instead of
|
|
10
|
+
* composing prose, which is what keeps a delegated run at five lines.
|
|
11
|
+
*/
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { changedPaths, line, readText } from './paths.mjs';
|
|
14
|
+
import { splitRunChanges } from './environment.mjs';
|
|
15
|
+
import { scoutCoverage } from './verdict.mjs';
|
|
16
|
+
|
|
17
|
+
/** What the run itself changed, and what the tooling around it changed while it worked. */
|
|
18
|
+
const runChanges = (runDir) =>
|
|
19
|
+
splitRunChanges(
|
|
20
|
+
runDir,
|
|
21
|
+
changedPaths(readText(path.join(runDir, 'state-before.txt')), readText(path.join(runDir, 'state-after.txt'))),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const AGENTS = {
|
|
25
|
+
'codex-scout': {
|
|
26
|
+
result: 'result.json',
|
|
27
|
+
filled: (r) => Boolean(String(r?.answer || '').trim()),
|
|
28
|
+
reply: scoutReply,
|
|
29
|
+
},
|
|
30
|
+
'codex-build': {
|
|
31
|
+
result: 'result.json',
|
|
32
|
+
filled: (r) => Boolean(String(r?.summary || '').trim()),
|
|
33
|
+
reply: buildReply,
|
|
34
|
+
},
|
|
35
|
+
'codex-review': {
|
|
36
|
+
result: 'review.json',
|
|
37
|
+
filled: (r) => Boolean(String(r?.verdict || '').trim()),
|
|
38
|
+
reply: reviewReply,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function scoutReply(ctx) {
|
|
43
|
+
const r = ctx.result;
|
|
44
|
+
const top = (r.findings || [])[0];
|
|
45
|
+
const unknowns = (r.unknowns || []).filter(Boolean);
|
|
46
|
+
const coverage = scoutCoverage(ctx.runDir, r);
|
|
47
|
+
return [
|
|
48
|
+
`OK — ${line(r.answer, 160)}`,
|
|
49
|
+
// Any explicit question gets a coverage line, including a valid one-question order.
|
|
50
|
+
...(coverage ? [`Coverage: ${coverage}`] : []),
|
|
51
|
+
`Key finding: ${top ? `${line(top.fact, 130)} (${line(top.where, 60)})` : 'no findings listed'}`,
|
|
52
|
+
`Unresolved: ${unknowns.length ? line(unknowns.join('; '), 160) : 'none'}`,
|
|
53
|
+
`Report: ${ctx.file('report.md')} · Log: ${ctx.file('raw.log')}`,
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildReply(ctx) {
|
|
58
|
+
const r = ctx.result;
|
|
59
|
+
const { work: touchedPaths, environment } = runChanges(ctx.runDir);
|
|
60
|
+
const paths = touchedPaths.slice(0, 3).join(', ');
|
|
61
|
+
const flags = readText(path.join(ctx.runDir, 'flags.txt')).split(/\r?\n/).filter(Boolean);
|
|
62
|
+
// A multi-line verification is several commands, and collapsing them into one 60-character
|
|
63
|
+
// line cut the last one mid-word: the reply named a command nobody could run.
|
|
64
|
+
const commands = String(r.verify_command ?? '')
|
|
65
|
+
.split(/\r?\n/)
|
|
66
|
+
.map((command) => command.trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
const verify = commands.length
|
|
69
|
+
? `${line(commands[0], 60)}${commands.length > 1 ? ` (+${commands.length - 1} more)` : ''}`
|
|
70
|
+
: 'not run';
|
|
71
|
+
const verdict = r.verify_command
|
|
72
|
+
? r.verify_passed === true
|
|
73
|
+
? 'pass'
|
|
74
|
+
: r.verify_passed === false
|
|
75
|
+
? 'fail'
|
|
76
|
+
: 'result not reported'
|
|
77
|
+
: 'n/a';
|
|
78
|
+
// A pass that changed nothing because the previous pass of the same task already did the
|
|
79
|
+
// work says so on the files line: "0 changed" alone reads as a run that achieved nothing.
|
|
80
|
+
const files = ctx.carried
|
|
81
|
+
? `${paths ? `${paths} · ` : ''}changes were made by an earlier run of this task`
|
|
82
|
+
: paths || 'worktree untouched';
|
|
83
|
+
return [
|
|
84
|
+
`OK — ${line(r.summary, 160)}`,
|
|
85
|
+
`Files: ${touchedPaths.length} changed · ${files}`,
|
|
86
|
+
// Only when something outside the run wrote to the tree. Subtracting those paths from the
|
|
87
|
+
// verdict without naming them would hide a real edit behind a pattern.
|
|
88
|
+
...(environment.length
|
|
89
|
+
? [`Environment: ${environment.length} changed outside the run — ${line(environment.slice(0, 3).join(', '), 120)}`]
|
|
90
|
+
: []),
|
|
91
|
+
`Verification: ${verify} — ${verdict}`,
|
|
92
|
+
`Flags: ${flags.length ? `${flags.length} TODO/skip — ${line(flags.slice(0, 3).join(' | '), 140)}` : 'none'}`,
|
|
93
|
+
`Report: ${ctx.file('report.md')} · Log: ${ctx.file('raw.log')}`,
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function reviewReply(ctx) {
|
|
98
|
+
const r = ctx.result;
|
|
99
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
100
|
+
(r.findings || []).forEach((f) => {
|
|
101
|
+
if (counts[f.severity] !== undefined) counts[f.severity] += 1;
|
|
102
|
+
});
|
|
103
|
+
const top = (r.findings || []).find((f) => f.severity === 'critical' || f.severity === 'high');
|
|
104
|
+
return [
|
|
105
|
+
`OK — verdict ${line(r.verdict, 40)}`,
|
|
106
|
+
`Findings: critical ${counts.critical} · high ${counts.high} · medium ${counts.medium} · low ${counts.low}`,
|
|
107
|
+
`Top: ${top ? `${top.severity} ${line(top.file, 80)}:${top.line_start} — ${line(top.title, 90)}` : 'no critical or high findings'}`,
|
|
108
|
+
`Report: ${ctx.file(path.basename(ctx.resultPath))} · Log: ${ctx.file('raw.log')}`,
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function failReply(ctx, meta) {
|
|
113
|
+
return [
|
|
114
|
+
`FAIL — ${line(meta.reason, 170)}`,
|
|
115
|
+
`Artifacts: raw.log ${meta.log_bytes} B · ${path.basename(ctx.resultPath)} ${meta.result_ok ? 'filled' : 'empty or missing'} · exit=${meta.exit}`,
|
|
116
|
+
`Log: ${ctx.file('raw.log')}`,
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function limitReply(ctx, meta) {
|
|
121
|
+
const rows = [
|
|
122
|
+
'LIMIT — ChatGPT quota exhausted, work not completed',
|
|
123
|
+
`Signal: ${line(meta.reason, 170)}`,
|
|
124
|
+
];
|
|
125
|
+
if (ctx.agent === 'codex-build') {
|
|
126
|
+
const { work: touched } = runChanges(ctx.runDir);
|
|
127
|
+
rows.push(
|
|
128
|
+
`Worktree: ${touched.length ? `has unfinished changes (${touched.length}), see git-after.txt` : 'no new changes'}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
rows.push(`Log: ${ctx.file('raw.log')}`);
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records what state a run is in, on disk, where the next run can see it.
|
|
3
|
+
*
|
|
4
|
+
* A killed runner leaves no report and no meta.json, and four abandoned folders from one
|
|
5
|
+
* order were indistinguishable from four runs still working — status.json is what makes
|
|
6
|
+
* that difference visible. writeFailure() lives here for the same reason: a failure that
|
|
7
|
+
* happens before Codex could produce anything still has to leave both files behind, so
|
|
8
|
+
* there is no second path by which a reply could exist without an artifact backing it.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { changedPaths, line, normalizePath, readJson, readText, size } from './paths.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Is this pid still running? EPERM means the process exists and belongs to someone else,
|
|
16
|
+
* which for our purposes is alive — treating it as dead would mark a live run abandoned.
|
|
17
|
+
*/
|
|
18
|
+
export const pidAlive = (pid) => {
|
|
19
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return err.code === 'EPERM';
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run state on disk, merged over whatever is already there. A killed runner leaves no
|
|
30
|
+
* report and no meta.json, and four abandoned folders from one order were indistinguishable
|
|
31
|
+
* from four runs still working — status.json is what makes that difference visible.
|
|
32
|
+
*/
|
|
33
|
+
export function writeStatus(runDir, patch) {
|
|
34
|
+
const current = readJson(path.join(runDir, 'status.json')) || {};
|
|
35
|
+
const next = { ...current, ...patch };
|
|
36
|
+
fs.writeFileSync(path.join(runDir, 'status.json'), `${JSON.stringify(next, null, 2)}\n`);
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Closes out runs whose runner died without writing a verdict. Called at the start of every
|
|
42
|
+
* run, so a folder left by a killed dispatcher gets an explicit state instead of reading as
|
|
43
|
+
* "something is missing here". A dead pid with meta.json present is not abandoned — the
|
|
44
|
+
* verdict exists, only the final status write was lost, so the state is repaired to
|
|
45
|
+
* finished. The caller supplies the current worktree snapshot because this module deliberately
|
|
46
|
+
* has no git calls; that snapshot is compared with the run's own state-before.txt and described
|
|
47
|
+
* as a later-run file list rather than exact authorship by the abandoned run. Returns what
|
|
48
|
+
* changed, for callers that want to report it.
|
|
49
|
+
*/
|
|
50
|
+
export function markAbandoned(runsRoot, currentTree) {
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const changed = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (!entry.isDirectory()) continue;
|
|
60
|
+
const runDir = path.join(runsRoot, entry.name);
|
|
61
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
62
|
+
if (!status || status.state !== 'running' || pidAlive(status.pid)) continue;
|
|
63
|
+
const metaPath = path.join(runDir, 'meta.json');
|
|
64
|
+
const meta = readJson(metaPath);
|
|
65
|
+
if (!meta && fs.existsSync(metaPath)) continue;
|
|
66
|
+
let patch = meta
|
|
67
|
+
? { state: 'finished', status: meta.status, finished_at: meta.finished_at }
|
|
68
|
+
: {
|
|
69
|
+
state: 'abandoned',
|
|
70
|
+
// The verdict is not the only thing missing. 2026-07-31_114736 wrote eleven files
|
|
71
|
+
// and never snapshotted the tree afterwards, so the next pass of that task started
|
|
72
|
+
// from a base that already contained them and every later count lied in silence.
|
|
73
|
+
// The flag exists so that base is read as unknown rather than as clean.
|
|
74
|
+
tree_after: false,
|
|
75
|
+
abandoned_reason:
|
|
76
|
+
'runner process is dead, meta.json was not recorded; post-run worktree state was not ' +
|
|
77
|
+
'captured — its changes will enter the baseline of the next run',
|
|
78
|
+
abandoned_at: new Date().toISOString(),
|
|
79
|
+
};
|
|
80
|
+
if (!meta) {
|
|
81
|
+
const hasCurrentTree = typeof currentTree === 'string';
|
|
82
|
+
const beforePath = path.join(runDir, 'state-before.txt');
|
|
83
|
+
const hasBeforeSnapshot = fs.existsSync(beforePath);
|
|
84
|
+
const files = hasCurrentTree && hasBeforeSnapshot
|
|
85
|
+
? changedPaths(readText(beforePath), currentTree)
|
|
86
|
+
: [];
|
|
87
|
+
const treeReason = hasCurrentTree && hasBeforeSnapshot
|
|
88
|
+
? files.length
|
|
89
|
+
? `the current worktree differs from state-before.txt in: ${files.join(', ')}`
|
|
90
|
+
: 'the current worktree has no files differing from state-before.txt'
|
|
91
|
+
: hasCurrentTree
|
|
92
|
+
? 'the run has no state-before.txt, so no file list is available'
|
|
93
|
+
: 'no current worktree snapshot was supplied, so no file list is available';
|
|
94
|
+
const reason =
|
|
95
|
+
'runner process is dead, meta.json was not recorded; ' +
|
|
96
|
+
`${treeReason}. This comparison runs at the start of a later run, so its file list may ` +
|
|
97
|
+
'include work made after the abandoned run and is not a definitive list of that run\'s work; ' +
|
|
98
|
+
'post-run worktree state was not captured — its changes will enter the baseline of the next run';
|
|
99
|
+
const { meta: failure } = writeFailure(runDir, status.agent, reason);
|
|
100
|
+
fs.writeFileSync(metaPath, `${JSON.stringify({ ...failure, reason }, null, 2)}\n`);
|
|
101
|
+
patch = { ...patch, abandoned_reason: reason, abandoned_at: failure.finished_at };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
writeStatus(runDir, patch);
|
|
105
|
+
changed.push({ run: entry.name, state: patch.state });
|
|
106
|
+
} catch {
|
|
107
|
+
// A folder that cannot be written to is not worth failing a fresh run over.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return changed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A live run of the same agent against the same repository, or null. Two writing runs share
|
|
115
|
+
* one worktree with no isolation: the second one's before/after snapshot picks up the
|
|
116
|
+
* first one's edits, so an honest run gets failed for work it never did. Read-only agents
|
|
117
|
+
* are not looked at — running scouting alongside a build is the normal way to work.
|
|
118
|
+
*/
|
|
119
|
+
export function activeRun(runsRoot, repo, agent = 'codex-build') {
|
|
120
|
+
let entries;
|
|
121
|
+
try {
|
|
122
|
+
entries = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const wanted = normalizePath(repo);
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!entry.isDirectory()) continue;
|
|
129
|
+
const status = readJson(path.join(runsRoot, entry.name, 'status.json'));
|
|
130
|
+
if (!status || status.state !== 'running' || status.agent !== agent) continue;
|
|
131
|
+
if (normalizePath(status.repo) !== wanted) continue;
|
|
132
|
+
if (pidAlive(status.pid)) return entry.name;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Finds the newest abandoned run that recorded a branch while the repository is detached.
|
|
139
|
+
* The caller supplies the current branch because this module deliberately has no git calls.
|
|
140
|
+
*/
|
|
141
|
+
export function abandonedBranchDrift(runsRoot, repo, currentBranch) {
|
|
142
|
+
if (String(currentBranch ?? '').trim()) return null;
|
|
143
|
+
const wanted = normalizePath(repo);
|
|
144
|
+
if (!wanted) return null;
|
|
145
|
+
let entries;
|
|
146
|
+
try {
|
|
147
|
+
entries = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const matches = [];
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (!entry.isDirectory()) continue;
|
|
154
|
+
const runDir = path.join(runsRoot, entry.name);
|
|
155
|
+
const status = readJson(path.join(runDir, 'status.json'));
|
|
156
|
+
if (!status || status.state !== 'abandoned' || normalizePath(status.repo) !== wanted) continue;
|
|
157
|
+
let branch;
|
|
158
|
+
try {
|
|
159
|
+
branch = fs.readFileSync(path.join(runDir, 'branch-before.txt'), 'utf8').trim();
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (branch) matches.push({ name: entry.name, at: String(status.started_at || ''), branch });
|
|
164
|
+
}
|
|
165
|
+
matches.sort((a, b) => (a.at === b.at ? (a.name < b.name ? -1 : 1) : a.at < b.at ? -1 : 1));
|
|
166
|
+
const newest = matches.at(-1);
|
|
167
|
+
return newest ? { run: newest.name, branch: newest.branch } : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A failure that happens before Codex could produce anything — missing CLI, a crash of
|
|
172
|
+
* the runner itself. It still goes through meta.json, so there is no second path by
|
|
173
|
+
* which a reply could exist without an artifact backing it.
|
|
174
|
+
*/
|
|
175
|
+
export function writeFailure(runDir, agent, reason, extraLines = []) {
|
|
176
|
+
const meta = {
|
|
177
|
+
agent,
|
|
178
|
+
project: path.basename(path.dirname(runDir)),
|
|
179
|
+
run: path.basename(runDir),
|
|
180
|
+
finished_at: new Date().toISOString(),
|
|
181
|
+
exit: null,
|
|
182
|
+
status: 'FAIL',
|
|
183
|
+
reason: line(reason, 300),
|
|
184
|
+
result_ok: false,
|
|
185
|
+
log_bytes: size(path.join(runDir, 'raw.log')),
|
|
186
|
+
tokens: null,
|
|
187
|
+
tokens_reported: false,
|
|
188
|
+
model: null,
|
|
189
|
+
sandbox: null,
|
|
190
|
+
session_id: null,
|
|
191
|
+
env: readJson(path.join(runDir, 'env.json')),
|
|
192
|
+
};
|
|
193
|
+
fs.writeFileSync(path.join(runDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
194
|
+
writeStatus(runDir, { state: 'failed', status: 'FAIL', finished_at: meta.finished_at });
|
|
195
|
+
const reply = [`FAIL — ${meta.reason}`, ...extraLines, `Run: ${runDir}`].join('\n');
|
|
196
|
+
return { meta, reply };
|
|
197
|
+
}
|