@lyupro/codex-bridge 0.6.7 → 0.6.9
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 +148 -0
- package/README.md +10 -6
- package/bin/codex-bridge.mjs +35 -172
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +21 -11
- package/cli/dispatcher-contract.mjs +6 -5
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +35 -7
- package/cli/handback-witness-check.mjs +10 -18
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +36 -21
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +101 -77
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +91 -0
- package/cli/lifecycle-lock.mjs +335 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/probe-contract.mjs +26 -10
- package/cli/probe-target.mjs +33 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +2 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/session-hosts.mjs +51 -0
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -126
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +49 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +33 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/dispatcher-gate.mjs +15 -2
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +2 -8
- package/src/home/hooks/reply-guard.mjs +24 -9
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +33 -25
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +60 -0
- package/src/home/lib/host-version.mjs +27 -0
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/events.mjs +2 -1
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +14 -16
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +38 -34
- package/src/home/lib/runner/launcher.mjs +2 -2
- package/src/home/lib/runner/preflight.mjs +7 -0
- package/src/home/lib/runner/prompts.mjs +35 -2
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +29 -20
- package/src/home/lib/runner/worker.mjs +3 -3
|
@@ -3,11 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two sides spell the same file differently — git prints repo-relative paths with forward
|
|
5
5
|
* slashes, Codex writes whatever it likes — so nothing above this module compares a path
|
|
6
|
-
* before it has been through here.
|
|
7
|
-
* filesystem for reading artifacts: everything else receives text, JSON or a byte count from it.
|
|
6
|
+
* before it has been through here. Strict snapshot judgement belongs to run-snapshots.mjs.
|
|
8
7
|
*/
|
|
9
8
|
import fs from 'node:fs';
|
|
10
9
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
10
|
+
import { compareSnapshots, decodeSnapshot } from './snapshot-format.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `slice` counts UTF-16 units and can leave half of an emoji or a rare CJK character at the cut;
|
|
14
|
+
* a lone surrogate in a reply is the same kind of broken text Plan_68 D6 refuses from the model.
|
|
15
|
+
*/
|
|
16
|
+
export const safeSlice = (value, max) => {
|
|
17
|
+
const text = String(value ?? '').toWellFormed();
|
|
18
|
+
const result = text.slice(0, max);
|
|
19
|
+
return result.isWellFormed() ? result : result.slice(0, -1);
|
|
20
|
+
};
|
|
11
21
|
|
|
12
22
|
/** One line, no newlines, bounded length — a five-line reply must stay five lines. */
|
|
13
23
|
export const line = (value, max = 200) => {
|
|
@@ -15,7 +25,7 @@ export const line = (value, max = 200) => {
|
|
|
15
25
|
if (text.length <= max) return text;
|
|
16
26
|
|
|
17
27
|
const ellipsis = '...';
|
|
18
|
-
if (max <= ellipsis.length) return text
|
|
28
|
+
if (max <= ellipsis.length) return safeSlice(text, Math.max(0, max));
|
|
19
29
|
|
|
20
30
|
const contentLimit = max - ellipsis.length;
|
|
21
31
|
const boundary = text.lastIndexOf(' ', contentLimit);
|
|
@@ -24,11 +34,11 @@ export const line = (value, max = 200) => {
|
|
|
24
34
|
// where a word ended. Dropping a trailing number here as well cost `All 298 12345678 rest`
|
|
25
35
|
// its 298 — a number that fitted whole — because the digit test compared the limit rather
|
|
26
36
|
// than the point the text was actually cut at.
|
|
27
|
-
if (boundary > 0) return `${text
|
|
37
|
+
if (boundary > 0) return `${safeSlice(text, boundary)}${ellipsis}`;
|
|
28
38
|
|
|
29
39
|
// No space to retreat to: the limit lands inside a single long word. Only here can the cut
|
|
30
40
|
// land mid-number, so only here is a partial number dropped.
|
|
31
|
-
return `${text
|
|
41
|
+
return `${safeSlice(text, contentLimit).replace(/\d+$/, '')}${ellipsis}`;
|
|
32
42
|
};
|
|
33
43
|
|
|
34
44
|
export const readText = (file) => {
|
|
@@ -60,32 +70,22 @@ export const size = (file) => {
|
|
|
60
70
|
};
|
|
61
71
|
|
|
62
72
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
73
|
+
* Tolerant display reader for reply, run-state and write-meta; invalid snapshots display
|
|
74
|
+
* an empty Map. Strict judgement goes through run-snapshots.mjs.
|
|
65
75
|
*/
|
|
66
76
|
export const snapshotMap = (text) => {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
const parts = row.split('\t');
|
|
70
|
-
if (parts.length < 3) continue;
|
|
71
|
-
map.set(parts.slice(2).join('\t').trim(), `${parts[0]}\t${parts[1]}`);
|
|
72
|
-
}
|
|
73
|
-
return map;
|
|
77
|
+
const snapshot = decodeSnapshot(text);
|
|
78
|
+
return snapshot.ok ? snapshot.rows : new Map();
|
|
74
79
|
};
|
|
75
80
|
|
|
76
81
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
82
|
+
* Tolerant display reader for reply, run-state and write-meta; invalid comparisons display
|
|
83
|
+
* no changes. Strict judgement goes through run-snapshots.mjs. Snapshot states measure
|
|
84
|
+
* edits even when a file's porcelain letter stays unchanged.
|
|
80
85
|
*/
|
|
81
86
|
export const changedPaths = (before, after) => {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
const paths = [];
|
|
85
|
-
for (const [file, state] of now) if (was.get(file) !== state) paths.push(file);
|
|
86
|
-
// A path that dropped out was touched too: Codex restored it to its committed state.
|
|
87
|
-
for (const file of was.keys()) if (!now.has(file)) paths.push(file);
|
|
88
|
-
return paths;
|
|
87
|
+
const comparison = compareSnapshots(before, after);
|
|
88
|
+
return comparison.ok ? comparison.changed : [];
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
/** Directories a delegated build has no business editing, whatever the task says. */
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a run's before/after snapshots and compare them strictly.
|
|
3
|
+
* On 2026-09-30 git-quoted Cyrillic names made an in-scope edit look out of scope;
|
|
4
|
+
* missing or untrustworthy snapshots must refuse judgement, never imply a clean tree.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { compareSnapshots } from './snapshot-format.mjs';
|
|
9
|
+
|
|
10
|
+
export function readSnapshot(file) {
|
|
11
|
+
try {
|
|
12
|
+
return fs.readFileSync(file, 'utf8');
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT') return null;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runSnapshotChanges(runDir) {
|
|
20
|
+
return compareSnapshots(
|
|
21
|
+
readSnapshot(path.join(runDir, 'state-before.txt')),
|
|
22
|
+
readSnapshot(path.join(runDir, 'state-after.txt')),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function snapshotRefusal(comparison) {
|
|
27
|
+
const issue = comparison.side ? `${comparison.side} ${comparison.issue}` : comparison.issue;
|
|
28
|
+
return `worktree snapshots cannot be compared (${issue}): ${comparison.detail}`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize, validate and compare worktree snapshots; owns the version rule.
|
|
3
|
+
*
|
|
4
|
+
* On 2026-09-30 git's quoted Cyrillic names made an in-scope edit look out of scope
|
|
5
|
+
* and hid a new Cyrillic file. JSON names preserve exact spelling; mixed versions
|
|
6
|
+
* must refuse comparison rather than repeat that incident.
|
|
7
|
+
*/
|
|
8
|
+
import { parseJsonText } from '../json-file.mjs';
|
|
9
|
+
|
|
10
|
+
export const SNAPSHOT_V2_HEADER = '# codex-bridge-state-v2';
|
|
11
|
+
|
|
12
|
+
// `unreadable`: listed but not readable (a Windows file held by another process gives EBUSY/EPERM).
|
|
13
|
+
const STATE_RE = /^(?:\d+\t\d+|-\t-|U\t(?:\d+:[0-9a-fA-F]{64}|missing|unreadable))$/;
|
|
14
|
+
const LEGACY_ROW_RE = /^(?:\d+\t\d+|-\t-|U\t\d+)\t\S.*$/;
|
|
15
|
+
const validState = (state) => typeof state === 'string' && STATE_RE.exec(state)?.[0] === state;
|
|
16
|
+
const malformed = (detail) => ({ ok: false, issue: 'malformed', detail });
|
|
17
|
+
|
|
18
|
+
export function encodeSnapshot(rows) {
|
|
19
|
+
const paths = new Set();
|
|
20
|
+
const ordered = [];
|
|
21
|
+
for (const row of rows) {
|
|
22
|
+
if (!row || typeof row.path !== 'string' || !validState(row.state)) {
|
|
23
|
+
throw new TypeError('Snapshot rows require a string path and a valid v2 state.');
|
|
24
|
+
}
|
|
25
|
+
if (paths.has(row.path)) throw new TypeError(`Duplicate snapshot path: ${JSON.stringify(row.path)}`);
|
|
26
|
+
paths.add(row.path);
|
|
27
|
+
ordered.push({ path: row.path, state: row.state });
|
|
28
|
+
}
|
|
29
|
+
ordered.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
30
|
+
return [SNAPSHOT_V2_HEADER, ...ordered.map(({ path, state }) => `${state}\t${JSON.stringify(path)}`)]
|
|
31
|
+
.join('\n') + '\n';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function decodeSnapshot(text) {
|
|
35
|
+
if (text === null) return { ok: false, issue: 'missing', detail: 'Snapshot file is absent.' };
|
|
36
|
+
if (typeof text !== 'string') return malformed('Snapshot text must be a string or null.');
|
|
37
|
+
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
|
|
38
|
+
const version = lines[0] === SNAPSHOT_V2_HEADER ? 2 : 1;
|
|
39
|
+
if (version === 2) lines.shift();
|
|
40
|
+
// Only the final line terminator is framing; an empty interior row is damaged data.
|
|
41
|
+
if (lines.at(-1) === '') lines.pop();
|
|
42
|
+
const rows = new Map();
|
|
43
|
+
for (const [index, line] of lines.entries()) {
|
|
44
|
+
const location = `Snapshot line ${index + (version === 2 ? 2 : 1)}`;
|
|
45
|
+
let path;
|
|
46
|
+
let state;
|
|
47
|
+
if (version === 2) {
|
|
48
|
+
const fields = line.split('\t');
|
|
49
|
+
if (fields.length !== 3) return malformed(`${location} must have exactly three tab fields.`);
|
|
50
|
+
state = `${fields[0]}\t${fields[1]}`;
|
|
51
|
+
if (!validState(state)) return malformed(`${location} has an invalid v2 state.`);
|
|
52
|
+
try {
|
|
53
|
+
path = parseJsonText(location, fields[2]);
|
|
54
|
+
} catch {
|
|
55
|
+
return malformed(`${location} has an invalid JSON path.`);
|
|
56
|
+
}
|
|
57
|
+
if (typeof path !== 'string') return malformed(`${location} path must be a JSON string.`);
|
|
58
|
+
} else {
|
|
59
|
+
// The legacy launcher wrote `${snapshot}\n`, so a clean tree is the text "\n" and the old
|
|
60
|
+
// reader skipped blank rows. Refusing them failed every build that started on a clean tree.
|
|
61
|
+
if (line === '') continue;
|
|
62
|
+
if (!LEGACY_ROW_RE.test(line)) return malformed(`${location} has an invalid legacy row.`);
|
|
63
|
+
const fields = line.split('\t');
|
|
64
|
+
state = `${fields[0]}\t${fields[1]}`;
|
|
65
|
+
path = fields.slice(2).join('\t').trim();
|
|
66
|
+
if (path.startsWith('"')) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
issue: 'legacy-quoted-name',
|
|
70
|
+
detail: `${location} contains a git-quoted legacy name whose original spelling is lost.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (rows.has(path)) return malformed(`${location} repeats path ${JSON.stringify(path)}.`);
|
|
75
|
+
rows.set(path, state);
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, version, rows };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function compareSnapshots(beforeText, afterText) {
|
|
81
|
+
const before = decodeSnapshot(beforeText);
|
|
82
|
+
if (!before.ok) return { ...before, side: 'before' };
|
|
83
|
+
const after = decodeSnapshot(afterText);
|
|
84
|
+
if (!after.ok) return { ...after, side: 'after' };
|
|
85
|
+
if (before.version !== after.version) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
issue: 'incompatible-versions',
|
|
89
|
+
detail: 'The run started under another package version; restart the run before comparing snapshots.',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const changed = [];
|
|
93
|
+
for (const [path, state] of after.rows) if (before.rows.get(path) !== state) changed.push(path);
|
|
94
|
+
for (const path of before.rows.keys()) if (!after.rows.has(path)) changed.push(path);
|
|
95
|
+
return { ok: true, changed };
|
|
96
|
+
}
|
|
@@ -10,7 +10,6 @@ import fs from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import {
|
|
12
12
|
SERVICE_RE,
|
|
13
|
-
changedPaths,
|
|
14
13
|
declaredHits,
|
|
15
14
|
displayPath,
|
|
16
15
|
expandDeclared,
|
|
@@ -29,6 +28,8 @@ import { startupGap } from './startup.mjs';
|
|
|
29
28
|
import { transportGap } from './transport.mjs';
|
|
30
29
|
import { reasonFrom } from './reason.mjs';
|
|
31
30
|
import { adviceGap } from './advice-status.mjs';
|
|
31
|
+
import { compareSnapshots } from './snapshot-format.mjs';
|
|
32
|
+
import { readSnapshot, runSnapshotChanges, snapshotRefusal } from './run-snapshots.mjs';
|
|
32
33
|
|
|
33
34
|
// How much prose an answer must carry once coordinates and paths are subtracted. Measured
|
|
34
35
|
// on the scout run of 2026-07-30 that replied with a table of `file.ts:60-79` rows: every
|
|
@@ -79,16 +80,14 @@ const WRONG_WORK = 'wrong work was done: ';
|
|
|
79
80
|
*/
|
|
80
81
|
export function reportVersusWork(runDir, result, ctx) {
|
|
81
82
|
const declared = (result?.changes || []).flatMap((c) => expandDeclared(c?.file));
|
|
82
|
-
const
|
|
83
|
+
const comparison = runSnapshotChanges(runDir);
|
|
84
|
+
const broken = (reason) => ({ ok: false, carried: false, reason });
|
|
85
|
+
if (!comparison.ok) return broken(snapshotRefusal(comparison));
|
|
83
86
|
// Only the run's own work is compared: a report that names nothing is honest when the sole
|
|
84
87
|
// change in the tree was written by the tooling around the run.
|
|
85
|
-
const touched = splitRunChanges(
|
|
86
|
-
runDir,
|
|
87
|
-
changedPaths(readText(path.join(runDir, 'state-before.txt')), after),
|
|
88
|
-
).work;
|
|
88
|
+
const touched = splitRunChanges(runDir, comparison.changed).work;
|
|
89
89
|
const show = (list) => list.slice(0, 3).join(', ');
|
|
90
90
|
const agreed = (carried = false) => ({ ok: true, carried, reason: null });
|
|
91
|
-
const broken = (reason) => ({ ok: false, carried: false, reason });
|
|
92
91
|
|
|
93
92
|
if (!declared.length && !touched.length) return agreed();
|
|
94
93
|
if (!declared.length) {
|
|
@@ -110,8 +109,11 @@ export function reportVersusWork(runDir, result, ctx) {
|
|
|
110
109
|
|
|
111
110
|
const baseline = chainBaseline(ctx?.runsRoot, ctx?.repo, ctx?.slug, ctx?.taskHash, ctx?.orderId);
|
|
112
111
|
if (baseline !== null) {
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
112
|
+
const chain = compareSnapshots(baseline, readSnapshot(path.join(runDir, 'state-after.txt')));
|
|
113
|
+
if (chain.ok) {
|
|
114
|
+
const accumulated = splitRunChanges(runDir, chain.changed).work;
|
|
115
|
+
if (declared.some((d) => declaredHits(d, accumulated))) return agreed(true);
|
|
116
|
+
}
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
return broken(
|
|
@@ -349,13 +351,9 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
|
|
|
349
351
|
// Environment writes are subtracted first: attributing them to the run failed an honest
|
|
350
352
|
// pass for a file it never opened. What was subtracted is reported, not swallowed —
|
|
351
353
|
// see the "Environment" line in reply.mjs and environment_changes in meta.json.
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
readText(path.join(runDir, 'state-before.txt')),
|
|
356
|
-
readText(path.join(runDir, 'state-after.txt')),
|
|
357
|
-
),
|
|
358
|
-
);
|
|
354
|
+
const comparison = runSnapshotChanges(runDir);
|
|
355
|
+
if (!comparison.ok) return { status: 'FAIL', reason: snapshotRefusal(comparison) };
|
|
356
|
+
const { work } = splitRunChanges(runDir, comparison.changed);
|
|
359
357
|
const strays = outOfScope(work, patterns);
|
|
360
358
|
if (strays.length) {
|
|
361
359
|
return {
|
|
@@ -53,7 +53,16 @@ export function readRunConfig(file = CONFIG_PATH) {
|
|
|
53
53
|
try {
|
|
54
54
|
parsed = readJsonFileSync(file);
|
|
55
55
|
} catch (err) {
|
|
56
|
-
|
|
56
|
+
// Only a missing file means "no config". Every error code used to mean that, so a config the
|
|
57
|
+
// run could not open (EACCES, EISDIR, EPERM) ran every role on default models with nobody told
|
|
58
|
+
// (Plan_69 stage 0) — the one reader of an operator file that treated failure as absence.
|
|
59
|
+
if (err.code === 'ENOENT') return { ...DEFAULTS };
|
|
60
|
+
if (err.code) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${file} cannot be read (${err.code}). Fix its permissions or type, or delete it — without it, ` +
|
|
63
|
+
'the default mode applies: hooks and plugins are disabled.',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
57
66
|
throw new Error(
|
|
58
67
|
`${file} cannot be parsed as JSON (${err.cause?.message || err.message}). Fix or delete the file — without it, ` +
|
|
59
68
|
'the default mode applies: hooks and plugins are disabled.',
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One responsibility: every git call that lists repository paths runs here with `-z` and returns exact names.
|
|
3
|
+
* On 2026-09-30, quoted octal escapes for areas/tickets-и-notifier.md made scope preflight
|
|
4
|
+
* refuse both its literal path and an ASCII glob, blocking a vault with Cyrillic file names.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
function pathRecords(repoRoot, args) {
|
|
9
|
+
const result = spawnSync(
|
|
10
|
+
'git',
|
|
11
|
+
['-C', repoRoot, ...args],
|
|
12
|
+
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
|
13
|
+
);
|
|
14
|
+
if (result.error || result.status !== 0) return null;
|
|
15
|
+
|
|
16
|
+
let output;
|
|
17
|
+
try {
|
|
18
|
+
// A leading BOM can be part of the first name, so decoding must preserve it too.
|
|
19
|
+
output = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(result.stdout);
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
const error = new Error(`Repository ${repoRoot} contains a file name that is not UTF-8`, { cause });
|
|
22
|
+
error.code = 'ERR_GIT_PATH_NOT_UTF8';
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
const names = output.split('\0');
|
|
26
|
+
if (names.at(-1) === '') names.pop();
|
|
27
|
+
return names;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listRepositoryPaths(repoRoot) {
|
|
31
|
+
return pathRecords(repoRoot, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function listUntrackedPaths(repoRoot) {
|
|
35
|
+
return pathRecords(repoRoot, ['ls-files', '-z', '-o', '--exclude-standard']);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function numstatRows(repoRoot) {
|
|
39
|
+
const records = pathRecords(repoRoot, ['diff', 'HEAD', '--numstat', '--no-renames', '-z']);
|
|
40
|
+
if (records === null) return null;
|
|
41
|
+
return records.map((record) => {
|
|
42
|
+
const firstTab = record.indexOf('\t');
|
|
43
|
+
const secondTab = record.indexOf('\t', firstTab + 1);
|
|
44
|
+
return {
|
|
45
|
+
added: record.slice(0, firstTab),
|
|
46
|
+
deleted: record.slice(firstTab + 1, secondTab),
|
|
47
|
+
path: record.slice(secondTab + 1),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The owner spells each command whole: a caller that composes path-listing arguments itself is what
|
|
53
|
+
// tests/git-path-listing-guard.test.mjs exists to catch.
|
|
54
|
+
export function diffNames(repoRoot, range) {
|
|
55
|
+
return pathRecords(repoRoot, ['diff', '--name-only', '-z', range]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function commitNames(repoRoot, sha) {
|
|
59
|
+
return pathRecords(repoRoot, ['show', '--name-only', '--format=', '-z', sha]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function porcelainPaths(repoRoot) {
|
|
63
|
+
const records = pathRecords(repoRoot, ['status', '--porcelain', '-z']);
|
|
64
|
+
if (records === null) return null;
|
|
65
|
+
const names = new Set();
|
|
66
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
67
|
+
const record = records[index];
|
|
68
|
+
names.add(record.slice(3));
|
|
69
|
+
if (record[0] === 'R' || record[1] === 'R' || record[0] === 'C' || record[1] === 'C') {
|
|
70
|
+
names.add(records[++index]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...names];
|
|
74
|
+
}
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
13
14
|
import { runsRoot } from './runs-root.mjs';
|
|
15
|
+
import { commitNames, diffNames, listUntrackedPaths, numstatRows, porcelainPaths } from './git-paths.mjs';
|
|
16
|
+
import { encodeSnapshot } from '../meta/snapshot-format.mjs';
|
|
14
17
|
|
|
15
18
|
export const MAX_LOG = 256 * 1024 * 1024;
|
|
16
19
|
|
|
@@ -32,38 +35,48 @@ export function runsPrefixInside(repo) {
|
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* State of the worktree in terms of actual content, not porcelain letters: line counts
|
|
35
|
-
* per tracked file plus
|
|
38
|
+
* per tracked file plus content hashes of untracked ones. A porcelain code stays ` M` when Codex
|
|
36
39
|
* edits an already-modified file, so comparing codes would report "0 files changed" for
|
|
37
40
|
* a run that did real work.
|
|
38
41
|
*/
|
|
39
42
|
export function worktreeSnapshot(repo) {
|
|
40
43
|
const skip = runsPrefixInside(repo);
|
|
41
|
-
// The path is the third tab-separated field in both git outputs used here.
|
|
42
|
-
const mine = (line) => Boolean(skip) && (line.split('\t')[2] || '').startsWith(skip);
|
|
43
44
|
// `--no-renames` because every reader of this snapshot compares paths against a scope: with
|
|
44
45
|
// rename detection on, numstat prints one row spelled `old => new` (or `dir/{a => b}/file`),
|
|
45
46
|
// which is not a path and matches no pattern — an in-scope rename would be judged a stray, and
|
|
46
47
|
// the witness named that token at the orchestrator on 2026-09-20. Without it a rename is a
|
|
47
48
|
// deletion plus an addition: two rows, both real paths, both judged on their own merits.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.filter((
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
const untracked = (git(repo, ['ls-files', '-o', '--exclude-standard']).stdout || '')
|
|
54
|
-
.split(/\r?\n/)
|
|
55
|
-
.filter(Boolean)
|
|
49
|
+
// Plan_73 D4 preserves the existing git-failure meaning: no rows from that listing.
|
|
50
|
+
const tracked = (numstatRows(repo) ?? [])
|
|
51
|
+
.filter((row) => !(skip && row.path.startsWith(skip)))
|
|
52
|
+
.map((row) => ({ path: row.path, state: `${row.added}\t${row.deleted}` }));
|
|
53
|
+
const untracked = (listUntrackedPaths(repo) ?? [])
|
|
56
54
|
.filter((file) => !(skip && `${file}/`.startsWith(skip)))
|
|
57
|
-
.map((file) => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
55
|
+
.map((file) => ({ path: file, state: untrackedState(path.join(repo, file)) }));
|
|
56
|
+
return encodeSnapshot([...tracked, ...untracked]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Size plus sha256 of an untracked file, read in chunks so a large one does not have to fit in
|
|
61
|
+
* memory. A file that vanished after listing is `missing`; one that cannot be read (on Windows a
|
|
62
|
+
* file held by another process) is `unreadable` rather than an exception that stops the run.
|
|
63
|
+
*/
|
|
64
|
+
function untrackedState(file) {
|
|
65
|
+
let fd;
|
|
66
|
+
try {
|
|
67
|
+
fd = fs.openSync(file, 'r');
|
|
68
|
+
const hash = createHash('sha256');
|
|
69
|
+
const chunk = Buffer.alloc(1024 * 1024);
|
|
70
|
+
let bytes = 0;
|
|
71
|
+
for (let read; (read = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0; bytes += read) {
|
|
72
|
+
hash.update(chunk.subarray(0, read));
|
|
73
|
+
}
|
|
74
|
+
return `U\t${bytes}:${hash.digest('hex')}`;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return error.code === 'ENOENT' ? 'U\tmissing' : 'U\tunreadable';
|
|
77
|
+
} finally {
|
|
78
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
79
|
+
}
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
/**
|
|
@@ -94,9 +107,7 @@ export function findFakeDone(repo) {
|
|
|
94
107
|
const hits = (git(repo, ['diff', '-U0']).stdout || '')
|
|
95
108
|
.split(/\r?\n/)
|
|
96
109
|
.filter((l) => l.startsWith('+') && FAKE_DONE_RE.test(l));
|
|
97
|
-
for (const file of (
|
|
98
|
-
.split(/\r?\n/)
|
|
99
|
-
.filter(Boolean)
|
|
110
|
+
for (const file of (listUntrackedPaths(repo) ?? [])
|
|
100
111
|
// Same reason as in worktreeSnapshot: inside ~/.claude the run folder is part of the
|
|
101
112
|
// worktree, and task.md spells out the very words this scans for ("do not leave TODOs,
|
|
102
113
|
// test.skip"). A run flagged itself for quoting its own instructions.
|
|
@@ -123,9 +134,7 @@ export function reviewScope(repo, changeset) {
|
|
|
123
134
|
return {
|
|
124
135
|
label: `branch changes against base ${base}`,
|
|
125
136
|
diffCommand: `git diff ${base}...HEAD`,
|
|
126
|
-
files: (
|
|
127
|
-
.split(/\r?\n/)
|
|
128
|
-
.filter(Boolean),
|
|
137
|
+
files: diffNames(repo, `${base}...HEAD`) ?? [],
|
|
129
138
|
};
|
|
130
139
|
}
|
|
131
140
|
if (changeset.startsWith('commit:')) {
|
|
@@ -133,17 +142,12 @@ export function reviewScope(repo, changeset) {
|
|
|
133
142
|
return {
|
|
134
143
|
label: `commit ${sha}`,
|
|
135
144
|
diffCommand: `git show ${sha}`,
|
|
136
|
-
files: (
|
|
137
|
-
.split(/\r?\n/)
|
|
138
|
-
.filter(Boolean),
|
|
145
|
+
files: commitNames(repo, sha) ?? [],
|
|
139
146
|
};
|
|
140
147
|
}
|
|
141
148
|
return {
|
|
142
149
|
label: 'uncommitted changes (staged, unstaged, untracked)',
|
|
143
150
|
diffCommand: 'git status --porcelain && git diff HEAD',
|
|
144
|
-
files: (
|
|
145
|
-
.split(/\r?\n/)
|
|
146
|
-
.filter(Boolean)
|
|
147
|
-
.map((l) => l.slice(3).trim()),
|
|
151
|
+
files: porcelainPaths(repo) ?? [],
|
|
148
152
|
};
|
|
149
153
|
}
|
|
@@ -327,8 +327,8 @@ export async function launcher(argv = process.argv.slice(2)) {
|
|
|
327
327
|
if (opts.agent === 'codex-build') {
|
|
328
328
|
fs.writeFileSync(path.join(runDir, 'head-before.txt'), `${isGitRepo ? headSha(repoRoot) : ''}\n`);
|
|
329
329
|
fs.writeFileSync(path.join(runDir, 'branch-before.txt'), `${isGitRepo ? branchName(repoRoot) : ''}\n`);
|
|
330
|
-
fs.writeFileSync(path.join(runDir, 'git-before.txt'), git(repoRoot, ['status', '--porcelain']).stdout || '');
|
|
331
|
-
fs.writeFileSync(path.join(runDir, 'state-before.txt'),
|
|
330
|
+
fs.writeFileSync(path.join(runDir, 'git-before.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
|
|
331
|
+
fs.writeFileSync(path.join(runDir, 'state-before.txt'), worktreeSnapshot(repoRoot));
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
// The worker's entire order, on disk. Not passed as arguments: the launcher may be gone
|
|
@@ -70,6 +70,13 @@ export function scopeRunRefusal({ agent, phase, runsRoot, grantRun }) {
|
|
|
70
70
|
|
|
71
71
|
/** D2: phase mistakes must refuse before even the sandbox probe can spend quota. */
|
|
72
72
|
export function resolveRunPhase({ agent, phase, continue: isContinue }, budgets) {
|
|
73
|
+
// OW-040, 2026-09-30: continuing a failed scope spent the order's only continuation and blocked advise.
|
|
74
|
+
if (agent === 'codex-advisor' && phase === 'scope' && isContinue) {
|
|
75
|
+
die('codex-advisor --phase scope refuses --continue: a scope pass is never continued, and continuing one ' +
|
|
76
|
+
"spends the order's single continuation that its advise phase needs. Action: repeat the scope under a new order id " +
|
|
77
|
+
"without --continue and without a continue: grant, then run advise as that order's continuation. The run folder " +
|
|
78
|
+
'was not created; quota was not spent.');
|
|
79
|
+
}
|
|
73
80
|
// Plan_59 D7: phase 2 must retain the scope run's predictions before quota is spent.
|
|
74
81
|
if (agent === 'codex-advisor' && phase === 'advise' && !isContinue) {
|
|
75
82
|
die('codex-advisor --phase advise requires --continue: phase 2 continues the scope run of the same order ' +
|
|
@@ -4,9 +4,35 @@
|
|
|
4
4
|
* These are data, not comments: the wording IS the contract a run is graded against, and
|
|
5
5
|
* every rule in it was added by an incident. Keyed by agent name, next to the matching
|
|
6
6
|
* schema in schemas.mjs — one lookup picks both halves of an agent's strategy.
|
|
7
|
+
* Plan_68 D6 renders array bounds from schemas after recommendation truncations on 2026-09-27
|
|
8
|
+
* (`…cli/install.mjs:` + U+0000) and 2026-09-28 (`…(docs/plans/Plan_` + `恋`).
|
|
7
9
|
*/
|
|
8
10
|
import { DEFAULTS } from '../run-config.mjs';
|
|
9
11
|
import { RUN_ENV } from './run-env.mjs';
|
|
12
|
+
import { advisorSchema, schemaFor } from './schemas.mjs';
|
|
13
|
+
|
|
14
|
+
function boundedArrayRules(schema, path = '') {
|
|
15
|
+
if (schema.type === 'object') {
|
|
16
|
+
return Object.entries(schema.properties ?? {}).flatMap(([name, child]) =>
|
|
17
|
+
boundedArrayRules(child, path ? `${path}.${name}` : name));
|
|
18
|
+
}
|
|
19
|
+
if (schema.type !== 'array') return [];
|
|
20
|
+
|
|
21
|
+
const { minItems, maxItems } = schema;
|
|
22
|
+
const quantity = minItems !== undefined && maxItems !== undefined
|
|
23
|
+
? `${minItems}-${maxItems}`
|
|
24
|
+
: minItems !== undefined ? `at least ${minItems}` : `at most ${maxItems}`;
|
|
25
|
+
const noun = (maxItems ?? minItems) === 1 ? 'item' : 'items';
|
|
26
|
+
const ownRule = minItems === undefined && maxItems === undefined
|
|
27
|
+
? [] : [`- ${path}: ${quantity} ${noun}`];
|
|
28
|
+
return [...ownRule, ...boundedArrayRules(schema.items, `${path}[]`)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderArrayBounds(schema) {
|
|
32
|
+
const rules = boundedArrayRules(schema);
|
|
33
|
+
return rules.length ? `Output-schema array bounds:\n${rules.join('\n')}` : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
const BODIES = {
|
|
11
37
|
// Plan_59 D3-D5/D7/D10: independent design advice needs explicit lenses and a risk ledger.
|
|
12
38
|
'codex-advisor': (opts) => {
|
|
@@ -27,12 +53,13 @@ Rules for both phases:
|
|
|
27
53
|
an opinion, not advice. Agreeing is fine when your own checks support it.
|
|
28
54
|
${opts.phase === 'scope' ? `
|
|
29
55
|
Scope phase:
|
|
30
|
-
- BEFORE reading in depth, name
|
|
56
|
+
- BEFORE reading in depth, name predicted_risks with ids r1..r5: where this design most likely
|
|
31
57
|
breaks. Record those predictions first, then read; do not replace them with hindsight.
|
|
32
58
|
- Answer sufficient, missing_paths (concrete repository paths you still need), and taken_on_trust
|
|
33
59
|
(facts accepted from the task without checking, never empty), together with predicted_risks.
|
|
34
60
|
` : `
|
|
35
61
|
Advise phase:
|
|
62
|
+
- Give a concise, complete recommendation that can stand on its own.
|
|
36
63
|
- Settle every predicted risk listed under \`## Scope phase results\` in risk_outcomes with its
|
|
37
64
|
risk_id, an outcome of confirmed or refuted, a \`note\` explaining it, and evidence.
|
|
38
65
|
- Give ONE recommendation by task option_id, or none-of-these with a described unlisted_option.
|
|
@@ -142,5 +169,11 @@ const languageRule = () =>
|
|
|
142
169
|
`${RUN_ENV?.answerLanguage || DEFAULTS.answerLanguage}, whatever language the task is written in.`;
|
|
143
170
|
|
|
144
171
|
export const INSTRUCTIONS = Object.fromEntries(
|
|
145
|
-
Object.entries(BODIES).map(([agent, body]) => [agent, (...args) =>
|
|
172
|
+
Object.entries(BODIES).map(([agent, body]) => [agent, (...args) => {
|
|
173
|
+
const opts = args[0] ?? {};
|
|
174
|
+
const instructions = body(...args);
|
|
175
|
+
const schema = agent === 'codex-advisor' ? advisorSchema(opts.phase) : schemaFor(agent);
|
|
176
|
+
const bounds = renderArrayBounds(schema);
|
|
177
|
+
return `${instructions}\n${languageRule()}${bounds ? `\n\n${bounds}` : ''}`;
|
|
178
|
+
}]),
|
|
146
179
|
);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The shape each agent's answer must have, one JSON schema per agent.
|
|
3
3
|
* Plan_59 D10 adds alternatives, rated assumptions, risk outcomes, pre-mortems and open questions.
|
|
4
|
+
* Plan_68 D6 removes recommendation.text's hidden character cap after the 2026-09-27
|
|
5
|
+
* `…cli/install.mjs:` + U+0000 and 2026-09-28 `…(docs/plans/Plan_` + `恋` truncation incidents.
|
|
4
6
|
*
|
|
5
7
|
* Handed to `codex exec --output-schema`, so a run that answers in the wrong shape is
|
|
6
8
|
* rejected by Codex itself rather than discovered later by write-meta.mjs. Keyed by agent
|
|
@@ -222,7 +224,8 @@ export const PHASE_SCHEMAS = {
|
|
|
222
224
|
required: ['option_id', 'text'],
|
|
223
225
|
properties: {
|
|
224
226
|
option_id: { type: 'string' },
|
|
225
|
-
|
|
227
|
+
// Plan_68 D8: the 2026-10-01 runs hit control characters when prose line breaks were banned.
|
|
228
|
+
text: { type: 'string' },
|
|
226
229
|
},
|
|
227
230
|
},
|
|
228
231
|
unlisted_option: { type: 'string' },
|