@lyupro/codex-bridge 0.6.9 → 0.6.11
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 +132 -0
- package/README.md +15 -4
- package/cli/dispatcher-model-check.mjs +59 -0
- package/cli/doctor-host-side.mjs +2 -1
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +13 -5
- package/cli/handback-witness-check.mjs +24 -18
- package/cli/hosts.mjs +2 -10
- package/cli/install-owners.mjs +1 -4
- package/cli/install-record.mjs +2 -1
- package/cli/inventory-transition.mjs +1 -1
- package/cli/lifecycle-lock-inspect.mjs +3 -2
- package/cli/lifecycle-lock.mjs +19 -132
- package/cli/manifest.mjs +6 -6
- package/cli/read.mjs +20 -0
- package/cli/settings-merge.mjs +3 -29
- package/package.json +2 -2
- package/scripts/home-write-inventory.mjs +12 -2
- package/src/agents/codex-advisor.md +37 -121
- package/src/agents/codex-build.md +27 -135
- package/src/agents/codex-review.md +19 -111
- package/src/agents/codex-scout.md +21 -130
- package/src/home/hooks/dispatcher-gate.mjs +14 -9
- package/src/home/hooks/live-runs.mjs +0 -41
- package/src/home/hooks/order-gate.mjs +80 -47
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- package/src/home/hooks/reply-parser.mjs +1 -1
- package/src/home/hooks/reply-verdicts.mjs +31 -27
- package/src/home/hooks/reply-witness.mjs +48 -0
- package/src/home/lib/claude-layout.mjs +14 -0
- package/src/home/lib/command-path.mjs +32 -0
- package/src/home/lib/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -54
- package/src/home/lib/dispatcher-gate.mjs +4 -0
- package/src/home/lib/dispatcher-model.mjs +112 -0
- package/src/home/lib/dispatcher-order.mjs +21 -22
- package/src/home/lib/dispatcher-pin.mjs +60 -0
- package/src/home/lib/dispatcher-protocol.mjs +91 -0
- package/src/home/lib/dispatcher-stop.mjs +11 -2
- package/src/home/lib/handback-witness.mjs +137 -48
- package/src/home/lib/home-registry.mjs +7 -0
- package/src/home/lib/install-owner-roots.mjs +53 -0
- package/src/home/lib/kernel-lock.mjs +171 -0
- package/src/home/lib/meta/chain.mjs +19 -8
- package/src/home/lib/meta/context-only.mjs +43 -0
- package/src/home/lib/meta/events.mjs +14 -2
- package/src/home/lib/meta/launch-rows.mjs +23 -0
- package/src/home/lib/meta/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +39 -4
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/verdict.mjs +16 -8
- package/src/home/lib/model-family.mjs +38 -0
- package/src/home/lib/observation-ledger.mjs +145 -0
- package/src/home/lib/order-schema.mjs +204 -0
- package/src/home/lib/order-values.mjs +46 -0
- package/src/home/lib/run-codex.mjs +6 -3
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +140 -220
- package/src/home/lib/runner/attach.mjs +24 -17
- package/src/home/lib/runner/build-evidence.mjs +29 -0
- package/src/home/lib/runner/codex-availability.mjs +50 -0
- package/src/home/lib/runner/codex-cmd.mjs +2 -19
- package/src/home/lib/runner/continuation.mjs +174 -140
- package/src/home/lib/runner/exit-codes.mjs +13 -0
- package/src/home/lib/runner/flag-baseline.mjs +105 -0
- package/src/home/lib/runner/flag-scan.mjs +165 -0
- package/src/home/lib/runner/git-state.mjs +0 -33
- package/src/home/lib/runner/launcher.mjs +237 -277
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +19 -0
- package/src/home/lib/runner/pass-gate.mjs +111 -0
- package/src/home/lib/runner/preflight.mjs +26 -28
- package/src/home/lib/runner/prompts.mjs +7 -2
- package/src/home/lib/runner/question-kind.mjs +41 -0
- package/src/home/lib/runner/run-env.mjs +2 -1
- package/src/home/lib/runner/sandbox-probe.mjs +1 -1
- package/src/home/lib/runner/scope-check.mjs +31 -12
- package/src/home/lib/runner/task-input.mjs +34 -12
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -0
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/write-meta.mjs +17 -5
- package/src/home/lib/required-inputs.mjs +0 -261
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds one kernel claim per project run store and exact order id during preparation.
|
|
3
|
+
* Plan_60 D4/D4c: on 2026-09-24 two launchers started 2.8 seconds apart with one order id
|
|
4
|
+
* and both ran and billed; a kernel-held claim queues preparation and dies with its holder.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
acquireKernelLock, directoryDigest, kernelLockAddress, kernelLockStrategy,
|
|
10
|
+
} from '../kernel-lock.mjs';
|
|
11
|
+
import { parseJsonText } from '../json-file.mjs';
|
|
12
|
+
import { chainRuns, readJson } from '../write-meta.mjs';
|
|
13
|
+
|
|
14
|
+
// Plan_60 D4/D4c: preparation takes seconds; expiry must refuse rather than permit double billing.
|
|
15
|
+
export const ORDER_CLAIM_TIMING = Object.freeze({ waitMs: 60_000, retryMs: 100, answerTimeoutMs: 1_000 });
|
|
16
|
+
|
|
17
|
+
export function orderClaimAddress(projectRunsRoot, orderId, platform = process.platform) {
|
|
18
|
+
if (typeof orderId !== 'string' || orderId.length === 0) {
|
|
19
|
+
throw new TypeError('orderId must be a non-empty string');
|
|
20
|
+
}
|
|
21
|
+
// Plan_60 A4 r5: owner matching is case-sensitive; retries must retain the exact order id.
|
|
22
|
+
const digest = directoryDigest(projectRunsRoot, { suffix: orderId });
|
|
23
|
+
if (digest === null) throw new Error(`project runs folder does not exist: ${projectRunsRoot}`);
|
|
24
|
+
if (kernelLockStrategy(platform) === null) return null;
|
|
25
|
+
return kernelLockAddress('order', digest, platform);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function acquireOrderClaim({
|
|
29
|
+
projectRunsRoot, orderId, role, platform = process.platform,
|
|
30
|
+
timing = ORDER_CLAIM_TIMING, createServer,
|
|
31
|
+
}) {
|
|
32
|
+
if (role !== 'launcher' && role !== 'worker') {
|
|
33
|
+
throw new TypeError('role must be launcher or worker');
|
|
34
|
+
}
|
|
35
|
+
const address = orderClaimAddress(projectRunsRoot, orderId, platform);
|
|
36
|
+
if (address === null) return { claimed: false, unsupported: true, release: async () => {} };
|
|
37
|
+
const holder = { pid: process.pid, role, orderId, token: randomUUID() };
|
|
38
|
+
const result = await acquireKernelLock({
|
|
39
|
+
address, holder, ...timing, parseAnswer: parseClaimHolder, label: 'order claim', createServer,
|
|
40
|
+
});
|
|
41
|
+
if (result.held) return { claimed: true, release: result.release };
|
|
42
|
+
return { claimed: false, busy: true, holder: result.holder };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseClaimHolder(line) {
|
|
46
|
+
try {
|
|
47
|
+
const holder = parseJsonText('<order claim holder>', line);
|
|
48
|
+
if (holder === null || typeof holder !== 'object') return null;
|
|
49
|
+
if (!Number.isInteger(holder.pid) || holder.pid <= 0) return null;
|
|
50
|
+
if (holder.role !== 'launcher' && holder.role !== 'worker') return null;
|
|
51
|
+
for (const key of ['orderId', 'token', 'acquiredAt']) {
|
|
52
|
+
if (typeof holder[key] !== 'string' || holder[key].length === 0) return null;
|
|
53
|
+
}
|
|
54
|
+
return holder;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Plan_60 D4/D4c: a holder answer diagnoses contention but cannot authorize anything.
|
|
61
|
+
export function orderClaimHolderText(holder) {
|
|
62
|
+
return holder === null
|
|
63
|
+
? 'a process that did not answer (unverified)'
|
|
64
|
+
: `${holder.role} pid ${holder.pid} since ${holder.acquiredAt}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function orderClaimBusyText(orderId, holder) {
|
|
68
|
+
return `order id "${orderId}" is being launched by ${orderClaimHolderText(holder)}; repeat the same command later — `
|
|
69
|
+
+ 'it attaches to that run instead of starting another. The run folder was not created; quota was not spent.';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Plan_60 D4, 2026-09-24: macOS has no kernel claim, so re-read just before registration.
|
|
74
|
+
* This shrinks the double-billing window to milliseconds and never waits on another launch.
|
|
75
|
+
*/
|
|
76
|
+
export function unclaimedRaceRefusal({ projectRunsRoot, repoRoot, slug, taskHash, orderId, grantRun, chainBefore }) {
|
|
77
|
+
const chain = chainRuns(projectRunsRoot, repoRoot, slug, taskHash, orderId, grantRun);
|
|
78
|
+
const before = new Set(chainBefore);
|
|
79
|
+
const appeared = chain.find((run) => !before.has(run)
|
|
80
|
+
&& readJson(path.join(projectRunsRoot, run, 'status.json'))?.order_id === orderId);
|
|
81
|
+
if (!appeared) return null;
|
|
82
|
+
return `another launch of order id "${orderId}" registered ${appeared} while this one was preparing; `
|
|
83
|
+
+ 'repeat the same command — it attaches to that run. The run folder was not created; quota was not spent.';
|
|
84
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Normalises one order's values into runner options, whatever channel carried them (Plan_63 D5). */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function orderOptions(agentType, order, { cwd = process.cwd() } = {}) {
|
|
5
|
+
const options = { orderId: (order.get('order id') ?? '').trim() };
|
|
6
|
+
const problems = [];
|
|
7
|
+
// Left unset when absent: the role's configured profile fills the gap, and a default here would always win.
|
|
8
|
+
// Plan_56 step 3 leaves effort support to the live catalogue and Codex at run start.
|
|
9
|
+
if (order.has('effort')) {
|
|
10
|
+
options.effort = order.get('effort');
|
|
11
|
+
if (!options.effort || /\s/.test(options.effort)) {
|
|
12
|
+
problems.push({ label: 'effort',
|
|
13
|
+
reason: `must be a non-empty single word with no whitespace; got ${JSON.stringify(options.effort)}` });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
options.repo = path.resolve(order.get('repository') || cwd);
|
|
17
|
+
options.slug = (order.get('slug') || options.orderId).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
18
|
+
// Plan_29 incident: a generic slug let an honest order inherit a days-old chain.
|
|
19
|
+
if (!/[A-Za-z0-9]/.test(options.slug)) {
|
|
20
|
+
problems.push({ label: order.has('slug') ? 'slug' : 'order id',
|
|
21
|
+
reason: `produces an unusable run folder name after sanitization: ${JSON.stringify(options.slug)} must contain a letter or digit.` });
|
|
22
|
+
}
|
|
23
|
+
options.changeset = order.get('changeset') || 'uncommitted';
|
|
24
|
+
const declaredScopePatterns = (order.get('scope') || '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
25
|
+
options.scopeNewPatterns = (order.get('scope new') || '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
26
|
+
// Plan_27 keeps ordinary patterns strict: only paths named explicitly as new may be absent.
|
|
27
|
+
options.scopePatterns = [...declaredScopePatterns, ...options.scopeNewPatterns];
|
|
28
|
+
if (order.has('phase')) options.phase = order.get('phase');
|
|
29
|
+
return { options, problems };
|
|
30
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** Keeps the runner and producer hook on one definition of which task owns an order id. */
|
|
2
|
+
import { TASK_HASH_SCHEME } from '../meta/chain.mjs';
|
|
3
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
2
4
|
|
|
3
5
|
const byStartThenName = (left, right) => {
|
|
4
6
|
const leftAt = String(left.status.started_at || '');
|
|
@@ -26,3 +28,20 @@ export function conflictingOrderOwner(runs, orderId, taskHash) {
|
|
|
26
28
|
const incomingHash = String(taskHash ?? '').trim().toLowerCase();
|
|
27
29
|
return ownerHash && incomingHash && ownerHash !== incomingHash ? owner : null;
|
|
28
30
|
}
|
|
31
|
+
|
|
32
|
+
export function ownerPredatesHashScheme(owner) {
|
|
33
|
+
return owner.status.task_hash_scheme !== TASK_HASH_SCHEME;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function orderOwnerConflictText(owner, ownerDir, orderId) {
|
|
37
|
+
const identity = `Order id collision: ${JSON.stringify(String(orderId ?? ''))} already belongs to run folder ${ownerDir} ` +
|
|
38
|
+
`(slug ${owner.status.slug}, started_at ${owner.status.started_at})`;
|
|
39
|
+
// Plan_75 D5, 2026-10-03: an old hash cannot prove that a repeated order is a different task.
|
|
40
|
+
if (ownerPredatesHashScheme(owner)) {
|
|
41
|
+
return `${identity}, recorded before the task header (hash scheme ${TASK_HASH_SCHEME}), so its task cannot be compared. ` +
|
|
42
|
+
`Its answer is already on disk in that folder (codex-bridge read "${ownerDir}"). ` +
|
|
43
|
+
'Another pass of that order needs a continue:/retry: header line; a new task needs a new order id.';
|
|
44
|
+
}
|
|
45
|
+
return `${identity} with a different task. Use a new order id (${orderInputName('order id')}), or a continue:/retry: header line ` +
|
|
46
|
+
'if this is another pass of that order.';
|
|
47
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides, before anything is registered, whether a call attaches to an existing run of its order,
|
|
3
|
+
* is a granted continuation, or is a refused repeat.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of launcher.mjs unchanged (Plan_75 P0): the launcher sat at the 400-line gate, and the
|
|
6
|
+
* 2026-10-03 TradeForge advise that died on model capacity showed this gate needs a retry path.
|
|
7
|
+
* It is handed no run directory, so like preflight.mjs it cannot leave a folder behind a refusal.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { chainRuns, startedRuns, taskFingerprint, readJson } from '../write-meta.mjs';
|
|
11
|
+
import { die } from './args.mjs';
|
|
12
|
+
import { EXIT } from './exit-codes.mjs';
|
|
13
|
+
import { attach } from './attach.mjs';
|
|
14
|
+
import { continuationRefusal, retryRefusal, readyGrantLines } from './continuation.mjs';
|
|
15
|
+
import { advisorTaskOrRefuse } from './advise-carry.mjs';
|
|
16
|
+
import { scopeRunRefusal } from './preflight.mjs';
|
|
17
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
18
|
+
|
|
19
|
+
export async function passGate({ opts, taskText, header, projectRunsRoot, repoRoot, beforeWait }) {
|
|
20
|
+
// A second pass at the same task is a real need — after a timeout or a LIMIT the work has
|
|
21
|
+
// to be finished — so it is allowed and made visible rather than forbidden. What is
|
|
22
|
+
// forbidden is repeating by accident: the orchestrator saw the previous reply, the runner
|
|
23
|
+
// did not, so only the orchestrator can decide there is something left to finish.
|
|
24
|
+
//
|
|
25
|
+
// The task is identified by its text as well as by its slug, because the slug is chosen by
|
|
26
|
+
// whoever repeats the run: on 2026-08-02 a dispatcher whose launcher was killed restarted
|
|
27
|
+
// the identical order as `<slug>-v2` and spent 46k on it. Refused before the folder exists,
|
|
28
|
+
// like an impossible scope.
|
|
29
|
+
const taskHash = taskFingerprint(taskText);
|
|
30
|
+
const grant = header.grant;
|
|
31
|
+
// OW-040, 2026-09-30: only a retry repeats failed scope without spending advise's continuation.
|
|
32
|
+
if (opts.agent === 'codex-advisor' && opts.phase === 'scope' && opts.continue && grant?.kind !== 'retry') {
|
|
33
|
+
die(`codex-advisor ${orderInputName('phase')} scope refuses ${orderInputName('continue')}: a scope pass is never continued, and continuing one ` +
|
|
34
|
+
"spends the order's single continuation that its advise phase needs. Action: repeat the scope under a new order id " +
|
|
35
|
+
`without ${orderInputName('continue')} and without a continue: grant, then run advise as that order's continuation; a ` +
|
|
36
|
+
'scope run that failed is repeated with a `retry:` grant instead. The run folder was not created; quota was not spent.');
|
|
37
|
+
}
|
|
38
|
+
let continuationGrant = grant?.kind === 'continue' ? grant : null;
|
|
39
|
+
const retryOf = grant?.kind === 'retry' ? grant.run : null;
|
|
40
|
+
const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId, grant?.run);
|
|
41
|
+
const startedChain = startedRuns(projectRunsRoot, chain);
|
|
42
|
+
const attachExistingRun = () => attach({
|
|
43
|
+
runsRoot: projectRunsRoot, repo: repoRoot, slug: opts.slug, taskHash, orderId: opts.orderId, chain,
|
|
44
|
+
grantRun: continuationGrant?.run, isContinue: opts.continue, noWait: opts.noWait,
|
|
45
|
+
retryRun: retryOf ?? undefined, beforeWait,
|
|
46
|
+
});
|
|
47
|
+
let attachedExitCode = opts.continue ? await attachExistingRun() : null;
|
|
48
|
+
if (attachedExitCode !== null) return { exitCode: attachedExitCode };
|
|
49
|
+
|
|
50
|
+
// The 2026-08-10_220535_plan25-2-install-table-two-roots incident exposed this ordering: an
|
|
51
|
+
// invalid grant must refuse before attach can print an older run's verdict (Plan_63 D8: the
|
|
52
|
+
// grant alone means continuation, so "grant without flag" no longer exists).
|
|
53
|
+
// markAbandoned() ran before this gate, so a dead runner already has meta.json with FAIL.
|
|
54
|
+
// A run without a verdict can therefore only still be in flight; ordinary repeats go through
|
|
55
|
+
// attach() after this gate. Repeated continuation calls attach first because their grant is spent.
|
|
56
|
+
if (grant?.kind === 'retry') {
|
|
57
|
+
const retryError = retryRefusal(projectRunsRoot, startedChain, opts.continue, opts.orderId, grant);
|
|
58
|
+
if (retryError) die(retryError);
|
|
59
|
+
const namedStatus = readJson(path.join(projectRunsRoot, grant.run, 'status.json'));
|
|
60
|
+
// A run written before phases existed has none; for every agent but the advisor that is 'default'.
|
|
61
|
+
const phaseOf = (value) => String(value ?? '').trim() || 'default';
|
|
62
|
+
if (namedStatus.agent !== opts.agent || phaseOf(namedStatus.phase) !== phaseOf(opts.phase)) {
|
|
63
|
+
die(`a retry repeats the named run's own agent and phase: ${namedStatus.agent}/${namedStatus.phase}. ` +
|
|
64
|
+
'The run folder was not created; quota was not spent.');
|
|
65
|
+
}
|
|
66
|
+
// Plan_75 D4, TradeForge capacity incident: retry advise against its original successful scope.
|
|
67
|
+
continuationGrant = String(namedStatus.continued_from ?? '').trim()
|
|
68
|
+
? { run: namedStatus.continued_from, reason: grant.reason } : null;
|
|
69
|
+
} else {
|
|
70
|
+
const continuationError = continuationRefusal(
|
|
71
|
+
projectRunsRoot,
|
|
72
|
+
startedChain,
|
|
73
|
+
opts.continue,
|
|
74
|
+
opts.orderId,
|
|
75
|
+
continuationGrant,
|
|
76
|
+
);
|
|
77
|
+
if (continuationError) die(continuationError);
|
|
78
|
+
}
|
|
79
|
+
// Plan_59 D14: the grant is valid by now; an advise pass may continue only an OK scope run.
|
|
80
|
+
const scopeRun = continuationGrant &&
|
|
81
|
+
scopeRunRefusal({ agent: opts.agent, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant.run });
|
|
82
|
+
if (scopeRun) die(scopeRun, EXIT.FAIL);
|
|
83
|
+
// Plan_59 D22: the advise snapshot is read before the folder exists, so a broken scope result refuses for free.
|
|
84
|
+
const advisorTask = opts.agent === 'codex-advisor' ? advisorTaskOrRefuse({ taskText, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant?.run }) : null;
|
|
85
|
+
|
|
86
|
+
// One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
|
|
87
|
+
// restart the synchronous launcher. A live same-order run is now the repeat target: attach
|
|
88
|
+
// before creating a folder, probing Codex or spending another token.
|
|
89
|
+
if (!opts.continue) attachedExitCode = await attachExistingRun();
|
|
90
|
+
if (attachedExitCode !== null) return { exitCode: attachedExitCode };
|
|
91
|
+
|
|
92
|
+
if (startedChain.length && !opts.continue) {
|
|
93
|
+
const last = startedChain[startedChain.length - 1];
|
|
94
|
+
const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
|
|
95
|
+
const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
|
|
96
|
+
die(
|
|
97
|
+
`${orderInputName('continue')} is required: ${
|
|
98
|
+
renamed
|
|
99
|
+
? `this task already ran in this repository under the name “${lastSlug}”`
|
|
100
|
+
: `runs for task “${opts.slug}” already exist in this repository`
|
|
101
|
+
} (${startedChain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
|
|
102
|
+
'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
|
|
103
|
+
`previous response and knows whether work remains. Add ${orderInputName('continue')} if you are finishing ` +
|
|
104
|
+
`the same task; changing ${orderInputName('slug')} with the same task text does not stop it being a repeat. ` +
|
|
105
|
+
`${readyGrantLines(projectRunsRoot, startedChain)} ` +
|
|
106
|
+
'The run folder was not created; quota was not spent.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { taskHash, chain, startedChain, continuationGrant, advisorTask, retryOf };
|
|
111
|
+
}
|
|
@@ -6,13 +6,30 @@
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { advisorTaskRefusal } from '../meta/advisor-task.mjs';
|
|
9
|
+
import { unavailableRows } from '../meta/reply.mjs';
|
|
9
10
|
import { activeRunDetails, readJson } from '../write-meta.mjs';
|
|
10
|
-
import {
|
|
11
|
+
import { probeCodexAvailability } from './codex-availability.mjs';
|
|
11
12
|
import { agentRole } from '../agents.mjs';
|
|
12
13
|
import { die } from './args.mjs';
|
|
14
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
15
|
+
|
|
16
|
+
/** Plan_60 D2: only conclusive evidence says UNAVAILABLE; an unclear probe stays an ordinary refusal. */
|
|
17
|
+
export async function codexAvailabilityRefusal({ probe = probeCodexAvailability } = {}) {
|
|
18
|
+
const result = await probe();
|
|
19
|
+
if (result.state === 'available') return null;
|
|
20
|
+
const unavailable = result.state === 'missing' || result.state === 'logged-out';
|
|
21
|
+
const rows = unavailable ? unavailableRows(result.detail) : [
|
|
22
|
+
`Codex CLI unavailable: ${result.detail}`,
|
|
23
|
+
'Operator check: codex --version (and codex login if authorization is rejected)',
|
|
24
|
+
];
|
|
25
|
+
return {
|
|
26
|
+
unavailable,
|
|
27
|
+
text: [...rows, 'The run folder was not created; quota was not spent.'].join('\n'),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
13
30
|
|
|
14
31
|
/** Plan_59 D5/D6: settle design authority before any probe can spend quota. */
|
|
15
|
-
export function taskPreflight({ agent, taskText }) {
|
|
32
|
+
export function taskPreflight({ agent, taskText, header }) {
|
|
16
33
|
const freeRefusal = (reason) => ({
|
|
17
34
|
refusal: `${reason}\nThe run folder was not created; quota was not spent.`,
|
|
18
35
|
});
|
|
@@ -21,13 +38,8 @@ export function taskPreflight({ agent, taskText }) {
|
|
|
21
38
|
return reason ? freeRefusal(reason) : { refusal: null };
|
|
22
39
|
}
|
|
23
40
|
if (agent !== 'codex-build') return { refusal: null };
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const match = line.match(/^\s*(?:-\s+)?advice\s*:\s*(.*?)\s*$/i);
|
|
27
|
-
return match ? [match[1]] : [];
|
|
28
|
-
});
|
|
29
|
-
const advice = lines[0];
|
|
30
|
-
if (lines.length === 1) {
|
|
41
|
+
const advice = header.advice;
|
|
42
|
+
if (advice !== null) {
|
|
31
43
|
if (['mechanical', 'revert', 'docs-only', 'test-only'].includes(advice)) {
|
|
32
44
|
return { refusal: null, advice };
|
|
33
45
|
}
|
|
@@ -44,7 +56,8 @@ export function taskPreflight({ agent, taskText }) {
|
|
|
44
56
|
}
|
|
45
57
|
}
|
|
46
58
|
}
|
|
47
|
-
return freeRefusal('codex-build requires
|
|
59
|
+
return freeRefusal('codex-build requires the task file to start with the header line `advice: <value>`, where the ' +
|
|
60
|
+
'value is mechanical | revert | docs-only | test-only ' +
|
|
48
61
|
'or an absolute path to an existing advisor run directory whose meta.json says agent codex-advisor, ' +
|
|
49
62
|
'phase advise, status OK. ' +
|
|
50
63
|
'An order that invents a construction needs a second opinion first; only work with no design choice may skip it.');
|
|
@@ -70,24 +83,17 @@ export function scopeRunRefusal({ agent, phase, runsRoot, grantRun }) {
|
|
|
70
83
|
|
|
71
84
|
/** D2: phase mistakes must refuse before even the sandbox probe can spend quota. */
|
|
72
85
|
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
|
-
}
|
|
80
86
|
// Plan_59 D7: phase 2 must retain the scope run's predictions before quota is spent.
|
|
81
87
|
if (agent === 'codex-advisor' && phase === 'advise' && !isContinue) {
|
|
82
|
-
die(
|
|
88
|
+
die(`codex-advisor ${orderInputName('phase')} advise requires a ${orderInputName('continue')} grant naming the scope run: phase 2 continues the scope run of the same order ` +
|
|
83
89
|
'so it can settle the risks phase 1 predicted. The run folder was not created; quota was not spent.');
|
|
84
90
|
}
|
|
85
91
|
const phases = budgets[agentRole(agent)];
|
|
86
92
|
const names = Object.keys(phases);
|
|
87
93
|
if (phase === undefined && names.length === 1 && names[0] === 'default') return 'default';
|
|
88
94
|
if (phase !== undefined && Object.hasOwn(phases, phase)) return phase;
|
|
89
|
-
die(`${phase === undefined ? '
|
|
90
|
-
`for ${agent}. Action: pass
|
|
95
|
+
die(`${phase === undefined ? `${orderInputName('phase')} is required` : `undeclared ${orderInputName('phase')} ${JSON.stringify(phase)}`} ` +
|
|
96
|
+
`for ${agent}. Action: pass ${orderInputName('phase')} <name>; allowed phases: ${names.join(', ')}. ` +
|
|
91
97
|
'The run folder was not created; quota was not spent.');
|
|
92
98
|
}
|
|
93
99
|
|
|
@@ -107,13 +113,5 @@ export function preflightRefusal({ agent, projectRunsRoot, repoRoot }) {
|
|
|
107
113
|
].join('\n');
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
const why = codexUnavailableReason();
|
|
111
|
-
if (why !== null) {
|
|
112
|
-
return [
|
|
113
|
-
`Codex CLI unavailable: ${why}`,
|
|
114
|
-
'Operator check: codex --version (and codex login if authorization is rejected)',
|
|
115
|
-
'The run folder was not created; quota was not spent.',
|
|
116
|
-
].join('\n');
|
|
117
|
-
}
|
|
118
116
|
return null;
|
|
119
117
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { DEFAULTS } from '../run-config.mjs';
|
|
11
11
|
import { RUN_ENV } from './run-env.mjs';
|
|
12
12
|
import { advisorSchema, schemaFor } from './schemas.mjs';
|
|
13
|
+
import { QUESTION_KIND } from './question-kind.mjs';
|
|
13
14
|
|
|
14
15
|
function boundedArrayRules(schema, path = '') {
|
|
15
16
|
if (schema.type === 'object') {
|
|
@@ -93,8 +94,12 @@ ${
|
|
|
93
94
|
: `- Put one object in answers with question_id "Q1": analysis in answer, references in evidence.
|
|
94
95
|
- The top-level answer is the same response summarized in 2-4 sentences.`
|
|
95
96
|
}
|
|
96
|
-
- Base the response on what you read; support every fact with a \`path:line\` reference in where.
|
|
97
|
-
|
|
97
|
+
- Base the response on what you read; support every fact about the code with a \`path:line\` reference in where.
|
|
98
|
+
${questions.some((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT)
|
|
99
|
+
? `- A sub-question marked [context-only] asks about what this run was handed at startup
|
|
100
|
+
(task, instructions, schema, environment), not about the repository. Answer it from that context,
|
|
101
|
+
cite evidence as \`startup:<source>\`, and never cite a repository \`path:line\` you did not read
|
|
102
|
+
with a command — that fails the run.\n` : ''}- If facts are missing, list them in unknowns; do not make them up.
|
|
98
103
|
- report_markdown is a full markdown report with sections: “Response” (2-4 sentences),
|
|
99
104
|
“How it works” (required: prose analysis ${questions.length ? 'for each sub-question' : 'of the mechanism'},
|
|
100
105
|
grounded in the code), “Findings” (fact / location / confidence table), “Missing information”.`,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies orderer-marked scout questions and refuses malformed markers before quota is spent.
|
|
3
|
+
* Plan_60 D1: only the orderer knows whether a question concerns handed startup context or code;
|
|
4
|
+
* the scout cannot choose its own kind, and startup context currently fails without a command.
|
|
5
|
+
*/
|
|
6
|
+
export const CONTEXT_ONLY_MARKER = '[context-only]';
|
|
7
|
+
export const QUESTION_KIND = Object.freeze({
|
|
8
|
+
STARTUP_CONTEXT: 'startup-context',
|
|
9
|
+
CODE_REQUIRED: 'code-required',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function questionKindRefusal(texts = []) {
|
|
13
|
+
for (const [index, text] of texts.entries()) {
|
|
14
|
+
const tokens = text.matchAll(/\[\s*context[\s_-]*only\s*\]/gi);
|
|
15
|
+
for (const token of tokens) {
|
|
16
|
+
if (token.index !== 0 || token[0] !== CONTEXT_ONLY_MARKER) {
|
|
17
|
+
return `Q${index + 1} has an invalid context-only marker in ${JSON.stringify(text)}`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (text.startsWith(CONTEXT_ONLY_MARKER)) {
|
|
21
|
+
const question = text.slice(CONTEXT_ONLY_MARKER.length);
|
|
22
|
+
if (!/^\s/.test(question) || !question.trim()) {
|
|
23
|
+
return `Q${index + 1} has an invalid context-only marker in ${JSON.stringify(text)}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function questionsFromTexts(texts = []) {
|
|
31
|
+
const refusal = questionKindRefusal(texts);
|
|
32
|
+
if (refusal) throw new Error(refusal);
|
|
33
|
+
return texts.map((text, index) => {
|
|
34
|
+
const marked = text.startsWith(CONTEXT_ONLY_MARKER);
|
|
35
|
+
return {
|
|
36
|
+
id: `Q${index + 1}`,
|
|
37
|
+
text: marked ? text.slice(CONTEXT_ONLY_MARKER.length).replace(/^\s+/, '') : text,
|
|
38
|
+
kind: marked ? QUESTION_KIND.STARTUP_CONTEXT : QUESTION_KIND.CODE_REQUIRED,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* falling back — a typo must not decide what environment a run gets.
|
|
14
14
|
*/
|
|
15
15
|
import { readRunConfig, disableFlags } from '../run-config.mjs';
|
|
16
|
+
import { EXIT } from './exit-codes.mjs';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* The switches as read (they go to env.json) and the flags they turn into (they go to
|
|
@@ -33,6 +34,6 @@ export function loadRunEnv() {
|
|
|
33
34
|
CLEAN_ENV = disableFlags(RUN_ENV);
|
|
34
35
|
} catch (err) {
|
|
35
36
|
console.log(`FAIL — ${err.message}\nRun was not started; quota was not spent`);
|
|
36
|
-
process.exit(
|
|
37
|
+
process.exit(EXIT.FAIL);
|
|
37
38
|
}
|
|
38
39
|
}
|
|
@@ -85,7 +85,7 @@ export async function probeSandbox({
|
|
|
85
85
|
|
|
86
86
|
const version = await attempt('version', ['--version']);
|
|
87
87
|
if (version.error || version.signal || version.status !== 0) {
|
|
88
|
-
//
|
|
88
|
+
// codexAvailabilityRefusal() owns the refusal for an unavailable CLI, earlier in the same pre-flight pass.
|
|
89
89
|
return finish('inconclusive', 'Codex CLI is unavailable.');
|
|
90
90
|
}
|
|
91
91
|
return finish('dead', 'The Codex sandbox on this host cannot start a process.');
|
|
@@ -8,6 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { listRepositoryPaths } from './git-paths.mjs';
|
|
10
10
|
import { globToRegExp, normalizePath, SERVICE_RE } from '../meta/paths.mjs';
|
|
11
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
11
12
|
|
|
12
13
|
// Directories a self-walk must never descend into. Only reached when the repository has no git:
|
|
13
14
|
// with git present the file list comes from the repository itself, which already knows what is
|
|
@@ -81,11 +82,22 @@ function structuralRefusal(repoRoot, pattern) {
|
|
|
81
82
|
return null;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function noMatchRefusal(pattern) {
|
|
85
|
+
function noMatchRefusal(pattern, repoRoot, gitListed) {
|
|
86
|
+
// OW-042: order plan72-r3-b1-live-thresholds-20261001 ran from a folder outside git, the walk missed a tracked
|
|
87
|
+
// file, and the refusal claimed the file did not exist. A walked folder is not the repository git would list.
|
|
88
|
+
if (gitListed === false) {
|
|
89
|
+
return {
|
|
90
|
+
pattern,
|
|
91
|
+
reason: `does not match any existing path under ${repoRoot}; ` +
|
|
92
|
+
'git could not list that folder as a repository, so it was walked instead',
|
|
93
|
+
action: `start the run from the repository root (cd into it, or pass ${orderInputName('repository')} <repository root>); ` +
|
|
94
|
+
'if this folder is the intended one, correct the pattern',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
85
97
|
return {
|
|
86
98
|
pattern,
|
|
87
99
|
reason: 'does not match any existing path in the repository',
|
|
88
|
-
action:
|
|
100
|
+
action: `correct the pattern or declare an intentionally new path with ${orderInputName('scope new')}`,
|
|
89
101
|
};
|
|
90
102
|
}
|
|
91
103
|
|
|
@@ -127,7 +139,8 @@ function walkPaths(repoRoot) {
|
|
|
127
139
|
* process instead of a walk and cannot wander into `.git` or a dependency tree.
|
|
128
140
|
*/
|
|
129
141
|
function matchingPaths(repoRoot, matchers) {
|
|
130
|
-
const
|
|
142
|
+
const listed = listRepositoryPaths(repoRoot);
|
|
143
|
+
const candidates = listed ?? walkPaths(repoRoot);
|
|
131
144
|
const matched = new Set();
|
|
132
145
|
for (const candidate of candidates) {
|
|
133
146
|
for (const matcher of matchers) {
|
|
@@ -137,7 +150,7 @@ function matchingPaths(repoRoot, matchers) {
|
|
|
137
150
|
}
|
|
138
151
|
if (matched.size === matchers.length) break;
|
|
139
152
|
}
|
|
140
|
-
return matched;
|
|
153
|
+
return { matched, gitListed: listed !== null };
|
|
141
154
|
}
|
|
142
155
|
|
|
143
156
|
function patternList(value, label) {
|
|
@@ -158,14 +171,18 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
|
|
|
158
171
|
const declared = patternList(patterns, 'patterns');
|
|
159
172
|
const newPaths = patternList(scopeNewPatterns, 'scopeNewPatterns');
|
|
160
173
|
const allPatterns = [...declared, ...newPaths];
|
|
174
|
+
const newPathKeys = new Set(newPaths.map((pattern) => normalizePath(pattern)));
|
|
161
175
|
|
|
176
|
+
// The label is decided by `scope new:` membership, not list position: orderOptions() merges every
|
|
177
|
+
// `scope new:` pattern into the declared list, so position named `scope:` for a directory given only to `scope new:`
|
|
178
|
+
// (OW-042 B1, caught on the live runner 2026-10-04 after the unit test passed separate lists).
|
|
162
179
|
for (const pattern of allPatterns) {
|
|
180
|
+
const label = newPathKeys.has(normalizePath(pattern)) ? 'scope new' : 'scope';
|
|
163
181
|
const refusal = structuralRefusal(repoRoot, pattern);
|
|
164
|
-
if (refusal) return { pattern, ...refusal };
|
|
165
|
-
if (!pattern) return noMatchRefusal(pattern);
|
|
182
|
+
if (refusal) return { pattern, label, ...refusal };
|
|
183
|
+
if (!pattern) return { ...noMatchRefusal(pattern), label };
|
|
166
184
|
}
|
|
167
185
|
|
|
168
|
-
const newPathKeys = new Set(newPaths.map((pattern) => normalizePath(pattern)));
|
|
169
186
|
const required = allPatterns.filter((pattern) => !newPathKeys.has(normalizePath(pattern)));
|
|
170
187
|
if (!required.length) return null;
|
|
171
188
|
|
|
@@ -174,23 +191,25 @@ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
|
|
|
174
191
|
matchers = required.map((pattern) => ({ pattern, regexp: globToRegExp(pattern) }));
|
|
175
192
|
} catch (error) {
|
|
176
193
|
return {
|
|
177
|
-
pattern: required[0]
|
|
194
|
+
pattern: required[0],
|
|
195
|
+
label: 'scope',
|
|
178
196
|
reason: `could not parse the pattern: ${error.message}`,
|
|
179
197
|
action: 'correct the pattern and retry',
|
|
180
198
|
};
|
|
181
199
|
}
|
|
182
200
|
|
|
183
|
-
let
|
|
201
|
+
let matches;
|
|
184
202
|
try {
|
|
185
|
-
|
|
203
|
+
matches = matchingPaths(repoRoot, matchers);
|
|
186
204
|
} catch (error) {
|
|
187
205
|
if (error.code !== 'ERR_GIT_PATH_NOT_UTF8') throw error;
|
|
188
206
|
return {
|
|
189
207
|
pattern: required[0],
|
|
208
|
+
label: 'scope',
|
|
190
209
|
reason: error.message,
|
|
191
210
|
action: 'rename the non-UTF-8 file using a UTF-8 file name and retry',
|
|
192
211
|
};
|
|
193
212
|
}
|
|
194
|
-
const missing = matchers.find((matcher) => !matched.has(matcher.pattern));
|
|
195
|
-
return missing ? noMatchRefusal(missing.pattern) : null;
|
|
213
|
+
const missing = matchers.find((matcher) => !matches.matched.has(matcher.pattern));
|
|
214
|
+
return missing ? { ...noMatchRefusal(missing.pattern, repoRoot, matches.gitListed), label: 'scope' } : null;
|
|
196
215
|
}
|
|
@@ -1,26 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Settles the order
|
|
3
|
-
*
|
|
2
|
+
* Settles the order header, task, sub-questions and verification command from the document,
|
|
3
|
+
* and refuses before a run folder or a paid process can exist.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* parsing runs before the task file has been read, and the file is read without knowing which
|
|
7
|
-
* flags were passed. Deciding it in either of them alone is how a scout order carrying its
|
|
8
|
-
* questions in the file got refused for not passing --question.
|
|
5
|
+
* Plan_63 D1/D8: transport arguments carry no order fields; file and stdin share this boundary.
|
|
9
6
|
*/
|
|
10
7
|
import { die, readTaskDocument } from './args.mjs';
|
|
8
|
+
import { questionKindRefusal } from './question-kind.mjs';
|
|
9
|
+
import { orderFromHeader, orderInputName, renderOrderProblems } from '../order-schema.mjs';
|
|
10
|
+
import { orderOptions } from './order-options.mjs';
|
|
11
11
|
|
|
12
|
-
export function settleTaskInput(opts) {
|
|
13
|
-
const { task, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
export function settleTaskInput(opts, { cwd = process.cwd() } = {}) {
|
|
13
|
+
const { task, header, questions: fileQuestions, verify: fileVerify } = readTaskDocument(opts);
|
|
14
|
+
const { order, problems } = orderFromHeader(opts.agent, header);
|
|
15
|
+
if (problems.length) {
|
|
16
|
+
die(renderOrderProblems(opts.agent, problems));
|
|
17
|
+
}
|
|
18
|
+
const normalized = orderOptions(opts.agent, order, { cwd });
|
|
19
|
+
if (normalized.problems.length) {
|
|
20
|
+
die(normalized.problems.map(({ label, reason }) => `${orderInputName(label)} ${reason}`).join('\n'));
|
|
21
|
+
}
|
|
22
|
+
Object.assign(opts, normalized.options);
|
|
23
|
+
opts.continue = Boolean(header.grant);
|
|
24
|
+
opts.questions = fileQuestions.length ? fileQuestions : undefined;
|
|
25
|
+
opts.verify = fileVerify;
|
|
16
26
|
// Still before the run folder exists and before a token of someone else's quota is touched,
|
|
17
27
|
// which is where every refusal of this kind belongs.
|
|
18
28
|
if (opts.agent === 'codex-scout' && !opts.questions?.length) {
|
|
19
29
|
die(
|
|
20
30
|
'a sub-question is required for codex-scout: put one Markdown list item per sub-question ' +
|
|
21
|
-
'under a `Questions` heading in the task
|
|
31
|
+
'under a `Questions` heading in the task document. ' +
|
|
22
32
|
'The runner will not infer questions from the task text; no quota was spent.',
|
|
23
33
|
);
|
|
24
34
|
}
|
|
25
|
-
|
|
35
|
+
if (opts.agent === 'codex-scout') {
|
|
36
|
+
const refusal = questionKindRefusal(opts.questions);
|
|
37
|
+
if (refusal) {
|
|
38
|
+
die(
|
|
39
|
+
`${refusal}; the marker is exactly [context-only] followed by a space and the question; no quota was spent.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (opts.noWait && opts.continue) {
|
|
44
|
+
die(`--no-wait cannot be combined with a ${orderInputName(header.grant.kind)} grant: ` +
|
|
45
|
+
'checking an existing run must never authorize a new one.');
|
|
46
|
+
}
|
|
47
|
+
return { task, header };
|
|
26
48
|
}
|