@lyupro/codex-bridge 0.6.8 → 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 +109 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- 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 +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- 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 +11 -5
- 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 +88 -75
- 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/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/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- 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 +26 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- 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 +2 -7
- 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 +7 -4
- 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 +7 -4
- 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
|
@@ -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' },
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { listRepositoryPaths } from './git-paths.mjs';
|
|
10
10
|
import { globToRegExp, normalizePath, SERVICE_RE } from '../meta/paths.mjs';
|
|
11
11
|
|
|
12
12
|
// Directories a self-walk must never descend into. Only reached when the repository has no git:
|
|
@@ -25,6 +25,13 @@ function absolutePattern(pattern) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function structuralRefusal(repoRoot, pattern) {
|
|
28
|
+
// Plan_73 D7: scope lists store one pattern per line; literal line breaks cannot round-trip.
|
|
29
|
+
if (/[\n\r]/.test(pattern)) {
|
|
30
|
+
return {
|
|
31
|
+
reason: 'contains a newline or carriage return',
|
|
32
|
+
action: 'use a glob instead of a literal path containing a line break',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
28
35
|
if (absolutePattern(pattern)) {
|
|
29
36
|
return {
|
|
30
37
|
reason: 'is an absolute or drive-qualified path',
|
|
@@ -87,21 +94,6 @@ function repositoryPath(repoRoot, absolutePath) {
|
|
|
87
94
|
return normalizePath(relative);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
/**
|
|
91
|
-
* The repository's own answer to "which files are there": tracked plus new-but-uncommitted, minus
|
|
92
|
-
* everything ignored. It is the same boundary the run is judged by afterwards, it costs one process
|
|
93
|
-
* instead of a full walk, and it cannot wander into `.git` or a dependency tree.
|
|
94
|
-
*/
|
|
95
|
-
function gitPaths(repoRoot) {
|
|
96
|
-
const result = spawnSync(
|
|
97
|
-
'git',
|
|
98
|
-
['-C', repoRoot, 'ls-files', '--cached', '--others', '--exclude-standard'],
|
|
99
|
-
{ encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
|
100
|
-
);
|
|
101
|
-
if (result.error || result.status !== 0) return null;
|
|
102
|
-
return result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
97
|
/** Walks a directory that has no git, skipping dependency trees and anything unreadable. */
|
|
106
98
|
function walkPaths(repoRoot) {
|
|
107
99
|
const pending = [repoRoot];
|
|
@@ -129,9 +121,13 @@ function walkPaths(repoRoot) {
|
|
|
129
121
|
return found;
|
|
130
122
|
}
|
|
131
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Names come from git-paths.mjs so Plan_73's 2026-09-30 Cyrillic incident cannot recur through
|
|
126
|
+
* quoted git output or trimmed names. Its tracked-plus-untracked, non-ignored list costs one
|
|
127
|
+
* process instead of a walk and cannot wander into `.git` or a dependency tree.
|
|
128
|
+
*/
|
|
132
129
|
function matchingPaths(repoRoot, matchers) {
|
|
133
|
-
const candidates =
|
|
134
|
-
?? walkPaths(repoRoot);
|
|
130
|
+
const candidates = listRepositoryPaths(repoRoot) ?? walkPaths(repoRoot);
|
|
135
131
|
const matched = new Set();
|
|
136
132
|
for (const candidate of candidates) {
|
|
137
133
|
for (const matcher of matchers) {
|
|
@@ -146,7 +142,10 @@ function matchingPaths(repoRoot, matchers) {
|
|
|
146
142
|
|
|
147
143
|
function patternList(value, label) {
|
|
148
144
|
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
|
|
149
|
-
return value.map((pattern) =>
|
|
145
|
+
return value.map((pattern) => {
|
|
146
|
+
const text = String(pattern ?? '');
|
|
147
|
+
return /[\n\r]/.test(text) ? text : text.trim();
|
|
148
|
+
});
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
/**
|
|
@@ -181,7 +180,17 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
|
|
|
181
180
|
};
|
|
182
181
|
}
|
|
183
182
|
|
|
184
|
-
|
|
183
|
+
let matched;
|
|
184
|
+
try {
|
|
185
|
+
matched = matchingPaths(repoRoot, matchers);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error.code !== 'ERR_GIT_PATH_NOT_UTF8') throw error;
|
|
188
|
+
return {
|
|
189
|
+
pattern: required[0],
|
|
190
|
+
reason: error.message,
|
|
191
|
+
action: 'rename the non-UTF-8 file using a UTF-8 file name and retry',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
185
194
|
const missing = matchers.find((matcher) => !matched.has(matcher.pattern));
|
|
186
195
|
return missing ? noMatchRefusal(missing.pattern) : null;
|
|
187
196
|
}
|
|
@@ -55,9 +55,9 @@ export async function worker(runDir) {
|
|
|
55
55
|
if (cfg.agent === 'codex-build') {
|
|
56
56
|
fs.writeFileSync(path.join(runDir, 'head-after.txt'), `${cfg.is_git_repo ? headSha(repoRoot) : ''}\n`);
|
|
57
57
|
fs.writeFileSync(path.join(runDir, 'branch-after.txt'), `${cfg.is_git_repo ? branchName(repoRoot) : ''}\n`);
|
|
58
|
-
fs.writeFileSync(path.join(runDir, 'git-after.txt'), git(repoRoot, ['status', '--porcelain']).stdout || '');
|
|
59
|
-
fs.writeFileSync(path.join(runDir, 'state-after.txt'),
|
|
60
|
-
fs.writeFileSync(path.join(runDir, 'diff.stat'), git(repoRoot, ['diff', '--stat']).stdout || '');
|
|
58
|
+
fs.writeFileSync(path.join(runDir, 'git-after.txt'), git(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain']).stdout || '');
|
|
59
|
+
fs.writeFileSync(path.join(runDir, 'state-after.txt'), worktreeSnapshot(repoRoot));
|
|
60
|
+
fs.writeFileSync(path.join(runDir, 'diff.stat'), git(repoRoot, ['-c', 'core.quotepath=false', 'diff', '--stat']).stdout || '');
|
|
61
61
|
fs.writeFileSync(path.join(runDir, 'flags.txt'), findFakeDone(repoRoot));
|
|
62
62
|
}
|
|
63
63
|
|