@lyupro/codex-bridge 0.6.9 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/cli/lifecycle-lock-inspect.mjs +3 -2
  4. package/cli/lifecycle-lock.mjs +19 -132
  5. package/cli/read.mjs +20 -0
  6. package/cli/settings-merge.mjs +3 -29
  7. package/package.json +2 -2
  8. package/scripts/home-write-inventory.mjs +12 -2
  9. package/src/agents/codex-advisor.md +22 -12
  10. package/src/agents/codex-build.md +24 -14
  11. package/src/agents/codex-review.md +17 -10
  12. package/src/agents/codex-scout.md +22 -12
  13. package/src/home/hooks/order-gate.mjs +25 -11
  14. package/src/home/hooks/reply-parser.mjs +1 -1
  15. package/src/home/lib/command-path.mjs +32 -0
  16. package/src/home/lib/dispatcher-command.mjs +4 -2
  17. package/src/home/lib/kernel-lock.mjs +171 -0
  18. package/src/home/lib/meta/chain.mjs +19 -8
  19. package/src/home/lib/meta/context-only.mjs +43 -0
  20. package/src/home/lib/meta/events.mjs +14 -2
  21. package/src/home/lib/meta/launch-rows.mjs +23 -0
  22. package/src/home/lib/meta/reply.mjs +37 -3
  23. package/src/home/lib/meta/run-state.mjs +3 -0
  24. package/src/home/lib/meta/verdict.mjs +16 -8
  25. package/src/home/lib/required-inputs.mjs +48 -8
  26. package/src/home/lib/run-codex.mjs +6 -3
  27. package/src/home/lib/runner/advise-carry.mjs +2 -1
  28. package/src/home/lib/runner/args.mjs +16 -5
  29. package/src/home/lib/runner/attach.mjs +21 -14
  30. package/src/home/lib/runner/build-evidence.mjs +29 -0
  31. package/src/home/lib/runner/codex-availability.mjs +50 -0
  32. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  33. package/src/home/lib/runner/continuation.mjs +60 -8
  34. package/src/home/lib/runner/exit-codes.mjs +13 -0
  35. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  36. package/src/home/lib/runner/flag-scan.mjs +165 -0
  37. package/src/home/lib/runner/git-state.mjs +0 -33
  38. package/src/home/lib/runner/launcher.mjs +233 -273
  39. package/src/home/lib/runner/order-claim.mjs +84 -0
  40. package/src/home/lib/runner/order-owner.mjs +18 -0
  41. package/src/home/lib/runner/pass-gate.mjs +109 -0
  42. package/src/home/lib/runner/preflight.mjs +22 -25
  43. package/src/home/lib/runner/prompts.mjs +7 -2
  44. package/src/home/lib/runner/question-kind.mjs +41 -0
  45. package/src/home/lib/runner/run-env.mjs +2 -1
  46. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  47. package/src/home/lib/runner/scope-check.mjs +29 -11
  48. package/src/home/lib/runner/task-input.mjs +11 -2
  49. package/src/home/lib/runner/worker-admission.mjs +43 -0
  50. package/src/home/lib/runner/worker.mjs +19 -18
  51. package/src/home/lib/task-header.mjs +127 -0
  52. package/src/home/lib/write-meta.mjs +17 -5
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Records dirty files' start bytes so Plan_60 D3a can distinguish this run's flags from earlier uncommitted work.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { headSha, runsPrefixInside } from './git-state.mjs';
7
+ import { diffNames, listUntrackedPaths } from './git-paths.mjs';
8
+ import { readJsonFileSync } from '../json-file.mjs';
9
+
10
+ export const FLAG_BASELINE_VERSION = 1;
11
+ export const FLAG_BASELINE_MANIFEST = 'flags-baseline.json';
12
+ export const FLAG_BASELINE_DIR = 'flags-baseline';
13
+ export const FLAG_BASELINE_LIMITS = Object.freeze({
14
+ perFileBytes: 1024 * 1024,
15
+ perRunBytes: 32 * 1024 * 1024,
16
+ });
17
+
18
+ export function captureFlagBaseline({ runDir, repoRoot, isGitRepo, limits = FLAG_BASELINE_LIMITS }) {
19
+ const manifest = {
20
+ version: FLAG_BASELINE_VERSION,
21
+ head: '',
22
+ complete: false,
23
+ reason: '',
24
+ limits: { ...limits },
25
+ files: [],
26
+ };
27
+ if (!isGitRepo) {
28
+ manifest.reason = 'not a git repository';
29
+ } else {
30
+ const head = headSha(repoRoot);
31
+ // rev-parse prints the literal HEAD on an unborn branch; it is not a start commit.
32
+ manifest.head = head === 'HEAD' ? '' : head;
33
+ if (!manifest.head) {
34
+ manifest.reason = 'no start commit';
35
+ } else {
36
+ const dirty = diffNames(repoRoot, 'HEAD');
37
+ const untracked = listUntrackedPaths(repoRoot);
38
+ if (dirty === null || untracked === null) {
39
+ manifest.reason = 'git listing failed';
40
+ } else {
41
+ const tracked = new Set(dirty);
42
+ const skip = runsPrefixInside(repoRoot);
43
+ const names = [...new Set([...dirty, ...untracked])]
44
+ .filter((name) => !(skip && `${name}/`.startsWith(skip)))
45
+ .sort();
46
+ let total = 0;
47
+ for (const [index, name] of names.entries()) {
48
+ const entry = { path: name, tracked: tracked.has(name), state: 'unreadable' };
49
+ manifest.files.push(entry);
50
+ const full = path.join(repoRoot, name);
51
+ let content;
52
+ try {
53
+ const stat = fs.lstatSync(full);
54
+ if (!stat.isFile()) continue;
55
+ if (stat.size > limits.perFileBytes) {
56
+ entry.state = 'truncated';
57
+ continue;
58
+ }
59
+ if (total + stat.size > limits.perRunBytes) {
60
+ entry.state = 'over-run-cap';
61
+ continue;
62
+ }
63
+ content = fs.readFileSync(full);
64
+ } catch (error) {
65
+ if (error.code === 'ENOENT') entry.state = entry.tracked ? 'deleted' : 'absent';
66
+ continue;
67
+ }
68
+ // Recheck actual bytes: the file may have grown since its metadata was read.
69
+ if (content.length > limits.perFileBytes) {
70
+ entry.state = 'truncated';
71
+ } else if (total + content.length > limits.perRunBytes) {
72
+ entry.state = 'over-run-cap';
73
+ } else if (content.subarray(0, 8000).includes(0)) {
74
+ entry.state = 'binary';
75
+ } else {
76
+ const copyDir = path.join(runDir, FLAG_BASELINE_DIR);
77
+ fs.mkdirSync(copyDir, { recursive: true });
78
+ const copy = `${index}.bin`;
79
+ fs.writeFileSync(path.join(copyDir, copy), content);
80
+ Object.assign(entry, { state: 'copied', copy, bytes: content.length });
81
+ total += content.length;
82
+ }
83
+ }
84
+ manifest.complete = manifest.files.every((entry) =>
85
+ ['copied', 'deleted', 'absent'].includes(entry.state));
86
+ if (!manifest.complete) manifest.reason = 'some start contents are unknown';
87
+ }
88
+ }
89
+ }
90
+ fs.writeFileSync(path.join(runDir, FLAG_BASELINE_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
91
+ return manifest;
92
+ }
93
+
94
+ export function readFlagBaseline(runDir) {
95
+ try {
96
+ const manifest = readJsonFileSync(path.join(runDir, FLAG_BASELINE_MANIFEST));
97
+ return manifest?.version === FLAG_BASELINE_VERSION ? manifest : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export function removeFlagBaselineCopies(runDir) {
104
+ fs.rmSync(path.join(runDir, FLAG_BASELINE_DIR), { recursive: true, force: true });
105
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Judges added lines against start bytes because Plan_60 D3a must not blame earlier dirty work or TODO test data.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { readFlagBaseline, removeFlagBaselineCopies, FLAG_BASELINE_DIR } from './flag-baseline.mjs';
9
+ import { runSnapshotChanges } from '../meta/run-snapshots.mjs';
10
+
11
+ const STUB = /test\.(skip|only)|it\.(skip|only)|describe\.(skip|only)|NotImplemented/;
12
+ const MARKER_PREFIX = /(?:^[ \t]*|\/\/[ \t]*|\/\*+[ \t]*|#[ \t]*|<!--[ \t]*|--[ \t]*|^[ \t]*\*[ \t]*)$/;
13
+ const LIST_PREFIX = /^[ \t]*(?:[-+*]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?$/;
14
+
15
+ export function isFlagLine(text) {
16
+ if (STUB.test(text)) return true;
17
+ for (const match of text.matchAll(/(?:TODO|FIXME)(?![A-Za-z0-9_])/g)) {
18
+ const prefix = text.slice(0, match.index);
19
+ if (MARKER_PREFIX.test(prefix) || LIST_PREFIX.test(prefix)) return true;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ function addedLines(diff) {
25
+ const added = [];
26
+ let inside = false;
27
+ let number = 0;
28
+ for (const line of diff.split('\n')) {
29
+ if (line.startsWith('diff ')) {
30
+ inside = false;
31
+ continue;
32
+ }
33
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
34
+ if (hunk) {
35
+ inside = true;
36
+ number = Number(hunk[1]);
37
+ } else if (inside && line.startsWith('+')) {
38
+ added.push({ number, text: line.slice(1) });
39
+ number += 1;
40
+ } else if (inside && line.startsWith(' ')) {
41
+ number += 1;
42
+ }
43
+ }
44
+ return added;
45
+ }
46
+
47
+ function compareFile(baseFile, currentFile) {
48
+ const result = spawnSync('git', [
49
+ '-c', 'core.quotepath=false', 'diff', '--no-index', '--no-color', '--no-ext-diff', '--no-textconv',
50
+ '--ignore-cr-at-eol', '--diff-algorithm=myers', '-U0', '--', baseFile, currentFile,
51
+ ], { windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 });
52
+ if (result.error || ![0, 1].includes(result.status)) return null;
53
+ return result.status === 0 ? [] : addedLines(result.stdout.toString('utf8'));
54
+ }
55
+
56
+ // `git show <head>:<path>` exits non-zero both for a path the start commit lacks (a new file: every line is added)
57
+ // and for a start commit nobody can read; only a readable commit lets the first case accuse anyone.
58
+ function headReadable(repoRoot, head) {
59
+ const result = spawnSync('git', ['-C', repoRoot, 'cat-file', '-e', `${head}^{commit}`], {
60
+ windowsHide: true, timeout: 30_000,
61
+ });
62
+ return !result.error && result.status === 0;
63
+ }
64
+
65
+ function scanPath({ name, entry, baseline, headKnown, runDir, repoRoot, gaps }) {
66
+ const currentFile = path.join(repoRoot, name);
67
+ let current;
68
+ try {
69
+ const stat = fs.lstatSync(currentFile);
70
+ if (!stat.isFile()) return [];
71
+ if (stat.size > baseline.limits.perFileBytes) {
72
+ gaps.push(`${name}: end content over the limit`);
73
+ return [];
74
+ }
75
+ current = fs.readFileSync(currentFile);
76
+ } catch (error) {
77
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') gaps.push(`${name}: end content unreadable`);
78
+ return [];
79
+ }
80
+ if (current.length > baseline.limits.perFileBytes) {
81
+ gaps.push(`${name}: end content over the limit`);
82
+ return [];
83
+ }
84
+ if (current.subarray(0, 8000).includes(0)) return [];
85
+
86
+ let base = Buffer.alloc(0);
87
+ let baseFile;
88
+ let temporary = false;
89
+ if (entry) {
90
+ if (entry.state === 'copied') {
91
+ baseFile = path.join(runDir, FLAG_BASELINE_DIR, entry.copy);
92
+ try {
93
+ base = fs.readFileSync(baseFile);
94
+ } catch {
95
+ gaps.push(`${name}: start content unreadable`);
96
+ return [];
97
+ }
98
+ } else if (!['deleted', 'absent'].includes(entry.state)) {
99
+ gaps.push(`${name}: start content ${entry.state}`);
100
+ return [];
101
+ }
102
+ } else if (!headKnown) {
103
+ gaps.push(`${name}: start commit unreadable`);
104
+ return [];
105
+ } else {
106
+ const result = spawnSync('git', ['-C', repoRoot, 'show', `${baseline.head}:${name}`], {
107
+ windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024,
108
+ });
109
+ if (result.error || result.status === null) {
110
+ gaps.push(`${name}: start content unreadable`);
111
+ return [];
112
+ }
113
+ if (result.status === 0) base = result.stdout;
114
+ if (base.length) {
115
+ const copyDir = path.join(runDir, FLAG_BASELINE_DIR);
116
+ fs.mkdirSync(copyDir, { recursive: true });
117
+ baseFile = path.join(copyDir, `head-${randomUUID()}.bin`);
118
+ fs.writeFileSync(baseFile, base, { flag: 'wx' });
119
+ temporary = true;
120
+ }
121
+ }
122
+
123
+ if (!base.length) {
124
+ return current.toString('utf8').split('\n').map((text, index) => ({ number: index + 1, text }));
125
+ }
126
+ let added;
127
+ try {
128
+ added = compareFile(baseFile, currentFile);
129
+ } finally {
130
+ if (temporary) fs.unlinkSync(baseFile);
131
+ }
132
+ if (added === null) {
133
+ gaps.push(`${name}: diff failed`);
134
+ return [];
135
+ }
136
+ return added;
137
+ }
138
+
139
+ export function scanRunFlags({ runDir, repoRoot }) {
140
+ const gaps = [];
141
+ const hits = [];
142
+ try {
143
+ const baseline = readFlagBaseline(runDir);
144
+ if (!baseline) {
145
+ gaps.push('no flag baseline');
146
+ } else if (!baseline.complete && baseline.files.length === 0) {
147
+ gaps.push(`flag baseline incomplete: ${baseline.reason}`);
148
+ } else {
149
+ const entries = new Map(baseline.files.map((entry) => [entry.path, entry]));
150
+ const comparison = runSnapshotChanges(runDir);
151
+ if (!comparison.ok) gaps.push('snapshots not comparable');
152
+ const names = [...new Set([...entries.keys(), ...(comparison.ok ? comparison.changed : [])])].sort();
153
+ const headKnown = headReadable(repoRoot, baseline.head);
154
+ for (const name of names) {
155
+ const added = scanPath({ name, entry: entries.get(name), baseline, headKnown, runDir, repoRoot, gaps });
156
+ for (const line of added) {
157
+ if (hits.length < 20 && isFlagLine(line.text)) hits.push(`${name}:${line.number}: ${line.text.trim()}\n`);
158
+ }
159
+ }
160
+ }
161
+ } finally {
162
+ removeFlagBaselineCopies(runDir);
163
+ }
164
+ return { text: hits.join(''), coverage: { complete: gaps.length === 0, gaps } };
165
+ }
@@ -94,39 +94,6 @@ export const headSha = (repo) => (git(repo, ['rev-parse', 'HEAD']).stdout || '')
94
94
  export const branchName = (repo) =>
95
95
  (git(repo, ['symbolic-ref', '--short', '-q', 'HEAD']).stdout || '').trim();
96
96
 
97
- const FAKE_DONE_RE =
98
- /TODO|FIXME|test\.(skip|only)|it\.(skip|only)|describe\.(skip|only)|NotImplemented/;
99
-
100
- /**
101
- * Traces of fake completion. `git diff` carries nothing for untracked files, so a brand
102
- * new file full of TODOs would otherwise pass as "Flags: none" — the one case the check
103
- * exists for.
104
- */
105
- export function findFakeDone(repo) {
106
- const skip = runsPrefixInside(repo);
107
- const hits = (git(repo, ['diff', '-U0']).stdout || '')
108
- .split(/\r?\n/)
109
- .filter((l) => l.startsWith('+') && FAKE_DONE_RE.test(l));
110
- for (const file of (listUntrackedPaths(repo) ?? [])
111
- // Same reason as in worktreeSnapshot: inside ~/.claude the run folder is part of the
112
- // worktree, and task.md spells out the very words this scans for ("do not leave TODOs,
113
- // test.skip"). A run flagged itself for quoting its own instructions.
114
- .filter((file) => !(skip && `${file}/`.startsWith(skip)))) {
115
- const full = path.join(repo, file);
116
- try {
117
- if (fs.statSync(full).size > 1024 * 1024) continue;
118
- fs.readFileSync(full, 'utf8')
119
- .split(/\r?\n/)
120
- .forEach((l, i) => {
121
- if (FAKE_DONE_RE.test(l)) hits.push(`${file}:${i + 1}: ${l.trim()}`);
122
- });
123
- } catch {
124
- // Binary or unreadable: nothing to flag.
125
- }
126
- }
127
- return hits.length ? `${hits.slice(0, 20).join('\n')}\n` : '';
128
- }
129
-
130
97
  /** What exactly is under review, resolved from git rather than from wording. */
131
98
  export function reviewScope(repo, changeset) {
132
99
  if (changeset.startsWith('base:')) {