docs/solutions/workflow-issues/2026-05-10-completion-check-scoped-state-must-not-use-newest-file.md
The completion checker supported scoped files, but the no-id fallback selected the newest scoped file. That made one session's Stop hook fail because another session had a newer pending plan.
bun run completion-check failed with another session's pending scoped file.pending, and a session with no scoped
state still inherited that failure.active goal state. That file is repo-global, so it
cannot safely identify the current session when multiple sessions run.CODEX_THREAD_ID but not
finding a matching scoped file. That makes the hook inherit another session's
state..tmp/completion-checks/<id>.md and
.tmp/continue/<id>.md. It works, but it forces every workflow doc and
operator to remember two unrelated path shapes for one session.Make scoped completion files opt-in and co-locate session artifacts:
active goal stateactive goal stateThe checker should read a scoped file when COMPLETION_CHECK_ID,
CODEX_THREAD_ID, CODEX_SESSION_ID, --id, or --file selects it. With no
selector, workflow docs and generated skills should create an explicit plan id
and use the same scoped layout. When an inherited session id has no matching
scoped file, the hook should exit successfully with a skip message instead of
reading another session's file. Legacy .tmp/completion-checks/<id>.md files
can remain readable as a compatibility fallback, but new state should use
active goal state.
Cover the main scoped path:
test('prefers a matching CODEX_THREAD_ID state', async () => {
await mkdir(path.join(cwd, '.tmp/session-a'), { recursive: true });
await writeFile(
path.join(cwd, 'active goal state'),
'status: done\n'
);
const result = await runCompletionCheck({
cwd,
env: { CODEX_THREAD_ID: 'session-a' },
});
assert.equal(result.code, 0);
assert.match(result.stdout, /tmp\/session-a\/completion-check\.md/);
});
The missing-session regression is the important guard:
test('skips when the implicit session has no state file', async () => {
const result = await runCompletionCheck({
cwd,
env: { CODEX_THREAD_ID: 'missing-session' },
});
assert.equal(result.code, 0);
assert.match(result.stdout, /no state for session: missing-session/);
});
Keep the no-selector guard too:
test('skips unselected scoped states when no session id is available', async () => {
await mkdir(path.join(cwd, '.tmp/other-session'), { recursive: true });
await writeFile(
path.join(cwd, 'active goal state'),
'status: pending\n'
);
const result = await runCompletionCheck({
cwd,
env: { CODEX_SESSION_ID: '', CODEX_THREAD_ID: '' },
});
assert.equal(result.code, 0);
assert.match(result.stdout, /no session id/);
});
Update the generated skill rules at the same time so future ralph and
Ralplan prompts stop documenting newest-file fallback behavior and stop writing
the repo-global active goal state.
Some Codex Desktop child processes expose CODEX_THREAD_ID, but Stop-hook
commands may only provide the session through hook JSON on stdin. Keep the UI
hook command pointed at the JSON-safe wrapper:
node tooling/scripts/completion-check-hook.mjs
The wrapper maps hook stdin session_id into CODEX_THREAD_ID when the env var
is absent, then runs bun run completion-check. The checker resolves that to
active goal state when the file exists. If the file
does not exist, the hook passes because this session has no active completion
gate. If no session id is visible, workflow docs should create an explicit
completion id before starting a lane. That gives each parallel session its own
state and continuation prompt without a repo-level multi-plan index.
When the state is pending, record the session continuation prompt in the state file:
status: pending
plan: docs/plans/current-plan.md
continue_file: active goal state
The checker prints continue_file before failing so the Stop hook output points
at the prompt that belongs to the blocked session.
A scoped plan id is authority. Modification time is not. The checker can still block the correct scoped plan when an id is provided, but an unscoped Stop hook no longer gets hijacked by unrelated active work.
active goal state has the same ownership bug as root completion state. Moving
continuation prompts to active goal state gives the checker and
human operator the same session key.
CODEX_THREAD_ID, hook stdin
session_id, COMPLETION_CHECK_ID=<session-id>, or
bun run completion-check -- --id <session-id>.active goal state or new .tmp/continue/<id>.md files; use
active goal state and record it as continue_file in the
completion state.