@lyupro/codex-bridge 0.6.2 → 0.6.5

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 (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +3 -3
  3. package/cli/runs-inventory.mjs +5 -3
  4. package/cli/stop.mjs +2 -2
  5. package/cli/unlock.mjs +2 -2
  6. package/package.json +2 -2
  7. package/src/agents/codex-advisor.md +187 -0
  8. package/src/agents/codex-build.md +4 -0
  9. package/src/commands/env.md +5 -4
  10. package/src/home/hooks/live-runs.mjs +19 -21
  11. package/src/home/hooks/reply-guard.mjs +5 -2
  12. package/src/home/hooks/shell-write-intent.mjs +320 -154
  13. package/src/home/hooks/worktree-lock.mjs +9 -2
  14. package/src/home/hooks/worktree-witness.mjs +21 -40
  15. package/src/home/lib/agents.mjs +3 -0
  16. package/src/home/lib/budgets.mjs +64 -0
  17. package/src/home/lib/config-validate.mjs +3 -39
  18. package/src/home/lib/meta/advice-status.mjs +61 -0
  19. package/src/home/lib/meta/advice-verdict.mjs +226 -0
  20. package/src/home/lib/meta/advisor-task.mjs +69 -0
  21. package/src/home/lib/meta/events.mjs +4 -0
  22. package/src/home/lib/meta/launch-rows.mjs +45 -0
  23. package/src/home/lib/meta/reply.mjs +42 -34
  24. package/src/home/lib/meta/run-liveness.mjs +64 -0
  25. package/src/home/lib/meta/run-state.mjs +28 -34
  26. package/src/home/lib/meta/verdict.mjs +18 -0
  27. package/src/home/lib/required-inputs.mjs +25 -4
  28. package/src/home/lib/run-codex.mjs +1 -0
  29. package/src/home/lib/run-config.mjs +5 -1
  30. package/src/home/lib/runner/args.mjs +8 -4
  31. package/src/home/lib/runner/attach.mjs +5 -13
  32. package/src/home/lib/runner/codex-args.mjs +14 -5
  33. package/src/home/lib/runner/codex-cmd.mjs +105 -19
  34. package/src/home/lib/runner/git-state.mjs +6 -1
  35. package/src/home/lib/runner/launcher.mjs +36 -30
  36. package/src/home/lib/runner/preflight.mjs +109 -0
  37. package/src/home/lib/runner/prompts.mjs +39 -0
  38. package/src/home/lib/runner/sandbox-probe.mjs +109 -0
  39. package/src/home/lib/runner/schemas.mjs +137 -1
  40. package/src/home/lib/runner/scope-check.mjs +31 -3
  41. package/src/home/lib/runner/worker-order.mjs +4 -2
  42. package/src/home/lib/runner/worker.mjs +2 -2
  43. package/src/home/lib/write-meta.mjs +18 -1
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * The shape each agent's answer must have, one JSON schema per agent.
3
+ * Plan_59 D10 adds alternatives, rated assumptions, risk outcomes, pre-mortems and open questions.
3
4
  *
4
5
  * Handed to `codex exec --output-schema`, so a run that answers in the wrong shape is
5
6
  * rejected by Codex itself rather than discovered later by write-meta.mjs. Keyed by agent
@@ -149,9 +150,144 @@ const REVIEW_SCHEMA = {
149
150
  },
150
151
  };
151
152
 
152
- /** Same three keys as AGENTS in write-meta.mjs; parseArgs() has already refused anything else. */
153
+ /** Plan_59: execution schemas stay static; schemaFor selects the advisor contract by phase. */
153
154
  export const SCHEMAS = {
154
155
  'codex-scout': SCOUT_SCHEMA,
155
156
  'codex-build': BUILD_SCHEMA,
156
157
  'codex-review': REVIEW_SCHEMA,
157
158
  };
159
+
160
+ // Plan_59 D3/D4/D5/D10 keeps the design contract separate from the existing execution roles.
161
+ export const PHASE_SCHEMAS = {
162
+ advisor: {
163
+ scope: {
164
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
165
+ type: 'object',
166
+ additionalProperties: false,
167
+ required: ['sufficient', 'missing_paths', 'taken_on_trust', 'predicted_risks'],
168
+ properties: {
169
+ sufficient: { type: 'boolean' },
170
+ missing_paths: { type: 'array', items: { type: 'string' } },
171
+ taken_on_trust: { type: 'array', minItems: 1, items: { type: 'string' } },
172
+ predicted_risks: {
173
+ type: 'array',
174
+ minItems: 3,
175
+ maxItems: 5,
176
+ items: {
177
+ type: 'object',
178
+ additionalProperties: false,
179
+ required: ['id', 'risk'],
180
+ properties: { id: { type: 'string', pattern: '^r[1-9]$' }, risk: { type: 'string' } },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ advise: {
186
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
187
+ type: 'object',
188
+ additionalProperties: false,
189
+ required: [
190
+ 'recommendation', 'why', 'rejected', 'strongest_counterargument', 'question_defect',
191
+ 'unlisted_option', 'assumptions', 'risk_outcomes', 'pre_mortem', 'open_questions',
192
+ 'confidence', 'independent_checks',
193
+ ],
194
+ properties: {
195
+ recommendation: {
196
+ type: 'object',
197
+ additionalProperties: false,
198
+ required: ['option_id', 'text'],
199
+ properties: {
200
+ option_id: { type: 'string' },
201
+ text: { type: 'string', maxLength: 300, pattern: '^[^\\r\\n\\u2028\\u2029]*$' },
202
+ },
203
+ },
204
+ unlisted_option: { type: 'string' },
205
+ why: { type: 'array', minItems: 3, maxItems: 5, items: { type: 'string' } },
206
+ rejected: {
207
+ type: 'array',
208
+ minItems: 1,
209
+ items: {
210
+ type: 'object',
211
+ additionalProperties: false,
212
+ required: ['option_id', 'cost'],
213
+ properties: { option_id: { type: 'string' }, cost: { type: 'string' } },
214
+ },
215
+ },
216
+ strongest_counterargument: { type: 'string', minLength: 80 },
217
+ question_defect: { type: 'string' },
218
+ assumptions: {
219
+ type: 'array',
220
+ items: {
221
+ type: 'object',
222
+ additionalProperties: false,
223
+ required: ['claim', 'rating', 'address'],
224
+ properties: {
225
+ claim: { type: 'string' },
226
+ rating: { type: 'string', enum: ['VERIFIED', 'REASONABLE', 'FRAGILE'] },
227
+ address: { type: 'string' },
228
+ },
229
+ },
230
+ },
231
+ risk_outcomes: {
232
+ type: 'array',
233
+ items: {
234
+ type: 'object',
235
+ additionalProperties: false,
236
+ required: ['risk_id', 'outcome', 'address'],
237
+ properties: {
238
+ risk_id: { type: 'string' },
239
+ outcome: { type: 'string', enum: ['confirmed', 'refuted'] },
240
+ address: { type: 'string' },
241
+ },
242
+ },
243
+ },
244
+ pre_mortem: {
245
+ type: 'array',
246
+ minItems: 2,
247
+ maxItems: 3,
248
+ items: {
249
+ type: 'object',
250
+ additionalProperties: false,
251
+ required: ['scenario', 'early_check'],
252
+ properties: {
253
+ scenario: { type: 'string', minLength: 30 },
254
+ early_check: {
255
+ type: 'object',
256
+ additionalProperties: false,
257
+ required: ['kind', 'target'],
258
+ properties: {
259
+ kind: { type: 'string', enum: ['test', 'command', 'inspect'] },
260
+ target: { type: 'string', minLength: 3 },
261
+ },
262
+ },
263
+ },
264
+ },
265
+ },
266
+ open_questions: { type: 'array', items: { type: 'string' } },
267
+ confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
268
+ independent_checks: {
269
+ type: 'array',
270
+ minItems: 1,
271
+ items: {
272
+ type: 'object',
273
+ additionalProperties: false,
274
+ required: ['check', 'address'],
275
+ properties: { check: { type: 'string' }, address: { type: 'string' } },
276
+ },
277
+ },
278
+ },
279
+ },
280
+ },
281
+ };
282
+
283
+ // Plan_59: an advisor's phase, not its agent name alone, defines the answer contract.
284
+ export function schemaFor(agent, phase) {
285
+ return agent === 'codex-advisor' ? advisorSchema(phase) : SCHEMAS[agent];
286
+ }
287
+
288
+ export function advisorSchema(phase) {
289
+ if (!Object.hasOwn(PHASE_SCHEMAS.advisor, phase)) {
290
+ throw new RangeError(`Unknown advisor phase ${JSON.stringify(phase)}; use "scope" or "advise".`);
291
+ }
292
+ return PHASE_SCHEMAS.advisor[phase];
293
+ }
@@ -7,7 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { spawnSync } from 'node:child_process';
10
- import { globToRegExp, normalizePath } from '../meta/paths.mjs';
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:
13
13
  // with git present the file list comes from the repository itself, which already knows what is
@@ -24,7 +24,7 @@ function absolutePattern(pattern) {
24
24
  );
25
25
  }
26
26
 
27
- function structuralRefusal(pattern) {
27
+ function structuralRefusal(repoRoot, pattern) {
28
28
  if (absolutePattern(pattern)) {
29
29
  return {
30
30
  reason: 'is an absolute or drive-qualified path',
@@ -43,6 +43,34 @@ function structuralRefusal(pattern) {
43
43
  action: 'remove the .. segment and keep the path relative to the repository root',
44
44
  };
45
45
  }
46
+ // Plan_59 S1: 2026-09-22_235724_plan59-d1-advisor-docs-surface spent 22 minutes on a
47
+ // service-directory scope the verdict could never authorise. Refuse it before a run exists.
48
+ if (SERVICE_RE.test(normalizePath(pattern))) {
49
+ return {
50
+ reason: 'lies inside a service directory that no scope can authorise',
51
+ action: 'name the file outside the service directory, or make that edit yourself',
52
+ };
53
+ }
54
+ // Plan_58 D7: 2026-09-19_191402_ports-infra spent a run on a bare scope-new directory,
55
+ // which the verdict could only match as a file. Refuse that intent before spending a run.
56
+ if (!pattern || /[*?]/.test(pattern)) return null;
57
+ const reason = 'names a directory rather than a file';
58
+ const action = `write ${pattern.replace(/\/$/, '')}/** for everything inside it, or name the file itself`;
59
+ if (pattern.endsWith('/')) return { reason, action };
60
+
61
+ let entry;
62
+ try {
63
+ entry = fs.statSync(path.join(repoRoot, pattern));
64
+ } catch {
65
+ // An unreadable or vanished path is treated as missing, so only its spelling is checked.
66
+ }
67
+ if (entry?.isDirectory()) return { reason, action };
68
+ if (!entry && !pattern.split('/').at(-1).includes('.')) {
69
+ return {
70
+ reason,
71
+ action: `write ${pattern}/** if it is a directory, or declare the new file by its own name with an extension; a new extensionless file is declared through its directory`,
72
+ };
73
+ }
46
74
  return null;
47
75
  }
48
76
 
@@ -133,7 +161,7 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
133
161
  const allPatterns = [...declared, ...newPaths];
134
162
 
135
163
  for (const pattern of allPatterns) {
136
- const refusal = structuralRefusal(pattern);
164
+ const refusal = structuralRefusal(repoRoot, pattern);
137
165
  if (refusal) return { pattern, ...refusal };
138
166
  if (!pattern) return noMatchRefusal(pattern);
139
167
  }
@@ -4,7 +4,7 @@
4
4
  * After the launcher/worker split this file is the only connection between the two halves, and the
5
5
  * worker never re-reads the CLI or the configuration — a run that consulted config.json twice could
6
6
  * end up honouring two different limits. It carries `agent`, `slug`, `order_id`, `repo`,
7
- * `is_git_repo`, `launcher_pid`, `budget_minutes`, `scope_new`, `profile` and `args`; worker.mjs
7
+ * `is_git_repo`, `launcher_pid`, `phase`, `budget_minutes`, `scope_new`, `profile` and `args`; worker.mjs
8
8
  * reads `repo`, `agent`, `args`, `is_git_repo` and `budget_minutes`. The rest is deliberate: those
9
9
  * fields are what a run folder read back months later needs in order to explain itself — which
10
10
  * order it belonged to, which new paths it declared included, and which worker was actually
@@ -24,6 +24,7 @@ export function workerOrder({
24
24
  repo,
25
25
  isGitRepo,
26
26
  launcherPid,
27
+ phase,
27
28
  budgetMinutes,
28
29
  scopeNew,
29
30
  profile,
@@ -36,7 +37,8 @@ export function workerOrder({
36
37
  repo,
37
38
  is_git_repo: isGitRepo,
38
39
  launcher_pid: launcherPid,
39
- // The mode's wall-clock budget, resolved by the launcher and never re-read by the worker.
40
+ phase,
41
+ // D2: the phase's wall-clock budget is frozen before the worker, never re-read from config.
40
42
  budget_minutes: budgetMinutes,
41
43
  scope_new: scopeNew,
42
44
  // Which worker was ordered, and where each half of that answer came from. Recorded separately
@@ -4,8 +4,8 @@
4
4
  * It takes its whole order from worker.json, written by launcher.mjs into the run folder —
5
5
  * after the split that file is the only connection between the two halves. Read here:
6
6
  * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
7
- * `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
8
- * months later does. The launcher must write the selected mode's budget into worker.json before
7
+ * `phase`, `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
8
+ * months later does. The launcher must write the selected phase's budget into worker.json before
9
9
  * spawning this half, so the worker never re-reads config.json and one run cannot acquire two
10
10
  * configurations.
11
11
  * Neither side may change the shape alone.
@@ -29,6 +29,7 @@ import { readEvents } from './meta/events.mjs';
29
29
  import { writeStatus } from './meta/run-state.mjs';
30
30
  import { resolveStatus } from './meta/verdict.mjs';
31
31
  import { AGENTS, failReply, limitReply, withProfileRow } from './meta/reply.mjs';
32
+ import { withLaunchRows } from './meta/launch-rows.mjs';
32
33
 
33
34
  function readRunnerVersion() {
34
35
  try {
@@ -81,6 +82,7 @@ export function collect(runDir, agent, exitCode) {
81
82
  const exit = exitCode === undefined || exitCode === null ? null : Number(exitCode);
82
83
  const events = readEvents(runDir);
83
84
  const worker = readJson(path.join(runDir, 'worker.json'));
85
+ const launched = readJson(path.join(runDir, 'status.json')) || {};
84
86
 
85
87
  const args = Array.isArray(worker?.args) ? worker.args : [];
86
88
  const argValue = (flag) => {
@@ -98,6 +100,18 @@ export function collect(runDir, agent, exitCode) {
98
100
 
99
101
  const meta = {
100
102
  agent,
103
+ // D2: old runs have no phase fact; do not invent one from today's registry.
104
+ phase: worker?.phase ?? launched.phase ?? null,
105
+ // Plan_59 D4: the scope outcome is a field the orchestrator branches on, not a sentence in
106
+ // the reply. Null when the result never said, so a missing answer is not read as "sufficient".
107
+ ...(agent === 'codex-advisor'
108
+ ? {
109
+ sufficient: typeof result?.sufficient === 'boolean' ? result.sufficient : null,
110
+ missing_paths: Array.isArray(result?.missing_paths) ? result.missing_paths : null,
111
+ }
112
+ : {}),
113
+ // Plan_59 D6: which second opinion a build order named, so skipped advice is measurable.
114
+ ...(agent === 'codex-build' ? { advice: launched.advice ?? null } : {}),
101
115
  runner_version: RUNNER_VERSION,
102
116
  project: path.basename(path.dirname(runDir)),
103
117
  run: path.basename(runDir),
@@ -149,7 +163,10 @@ export function collect(runDir, agent, exitCode) {
149
163
  };
150
164
  const reply =
151
165
  withProfileRow(
152
- status === 'OK' ? cfg.reply(ctx) : status === 'LIMIT' ? limitReply(ctx, meta) : failReply(ctx, meta),
166
+ withLaunchRows(
167
+ status === 'OK' ? cfg.reply(ctx) : status === 'LIMIT' ? limitReply(ctx, meta) : failReply(ctx, meta),
168
+ runDir,
169
+ ),
153
170
  meta,
154
171
  );
155
172