codex-workflow-v2 2.0.0-beta.13.1 → 2.0.0-beta.13.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.
Files changed (85) hide show
  1. package/README.md +6 -1
  2. package/dist/reviewer-runtime-build.json +44 -20
  3. package/dist/src/alpha6/failed-step-planning-recovery.d.ts +10 -0
  4. package/dist/src/alpha6/failed-step-planning-recovery.js +93 -0
  5. package/dist/src/alpha6/failed-step-planning-recovery.js.map +1 -0
  6. package/dist/src/alpha6/plan-risk.d.ts +3 -0
  7. package/dist/src/alpha6/plan-risk.js +19 -9
  8. package/dist/src/alpha6/plan-risk.js.map +1 -1
  9. package/dist/src/alpha6/preexecution-replan.d.ts +38 -0
  10. package/dist/src/alpha6/preexecution-replan.js +130 -0
  11. package/dist/src/alpha6/preexecution-replan.js.map +1 -0
  12. package/dist/src/alpha6/remediation.d.ts +4 -2
  13. package/dist/src/alpha6/remediation.js +155 -20
  14. package/dist/src/alpha6/remediation.js.map +1 -1
  15. package/dist/src/alpha6/root-cause-replan-carryover.d.ts +18 -0
  16. package/dist/src/alpha6/root-cause-replan-carryover.js +396 -0
  17. package/dist/src/alpha6/root-cause-replan-carryover.js.map +1 -0
  18. package/dist/src/alpha7/autonomy.d.ts +3 -0
  19. package/dist/src/alpha7/autonomy.js +89 -42
  20. package/dist/src/alpha7/autonomy.js.map +1 -1
  21. package/dist/src/change-explanation.d.ts +64 -0
  22. package/dist/src/change-explanation.js +150 -0
  23. package/dist/src/change-explanation.js.map +1 -0
  24. package/dist/src/cli-actions.d.ts +3 -2
  25. package/dist/src/cli-actions.js +6 -0
  26. package/dist/src/cli-actions.js.map +1 -1
  27. package/dist/src/cli.js +73 -1
  28. package/dist/src/cli.js.map +1 -1
  29. package/dist/src/contracts.d.ts +36 -0
  30. package/dist/src/dependency-provenance.d.ts +3 -2
  31. package/dist/src/dependency-provenance.js +33 -2
  32. package/dist/src/dependency-provenance.js.map +1 -1
  33. package/dist/src/domain/plan-semantics.d.ts +11 -0
  34. package/dist/src/domain/plan-semantics.js +49 -0
  35. package/dist/src/domain/plan-semantics.js.map +1 -0
  36. package/dist/src/gateway-handshake.js +2 -0
  37. package/dist/src/gateway-handshake.js.map +1 -1
  38. package/dist/src/lifecycle/corrective-replan.js +3 -3
  39. package/dist/src/lifecycle/corrective-replan.js.map +1 -1
  40. package/dist/src/observed-routes.js +1 -0
  41. package/dist/src/observed-routes.js.map +1 -1
  42. package/dist/src/pending-review-update.d.ts +49 -0
  43. package/dist/src/pending-review-update.js +132 -0
  44. package/dist/src/pending-review-update.js.map +1 -0
  45. package/dist/src/repository.js +16 -4
  46. package/dist/src/repository.js.map +1 -1
  47. package/dist/src/reviewer.js +4 -1
  48. package/dist/src/reviewer.js.map +1 -1
  49. package/dist/src/state/corrective-replan-public-schema.js +15 -2
  50. package/dist/src/state/corrective-replan-public-schema.js.map +1 -1
  51. package/dist/src/state/corrective-replan-public.js +5 -4
  52. package/dist/src/state/corrective-replan-public.js.map +1 -1
  53. package/dist/src/state/corrective-yield-executor.js +12 -3
  54. package/dist/src/state/corrective-yield-executor.js.map +1 -1
  55. package/dist/src/state/store.d.ts +1 -1
  56. package/dist/src/state/store.js +17 -3
  57. package/dist/src/state/store.js.map +1 -1
  58. package/dist/src/version.d.ts +1 -1
  59. package/dist/src/version.js +1 -1
  60. package/dist/src/version.js.map +1 -1
  61. package/dist/src/workflow.d.ts +84 -1
  62. package/dist/src/workflow.js +1610 -355
  63. package/dist/src/workflow.js.map +1 -1
  64. package/docs/beta13.2-signal-review-recovery.md +38 -0
  65. package/docs/change-model.md +118 -0
  66. package/docs/delegated-approval.md +26 -0
  67. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
  68. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
  69. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
  70. package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +1 -1
  71. package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +3 -5
  72. package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +11 -3
  73. package/docs/pending-review-update.md +15 -0
  74. package/docs/release.md +273 -2
  75. package/docs/updating-existing-project.md +10 -2
  76. package/package.json +2 -1
  77. package/plugins/codex-workflow-gateway/references/chat-dispatch.md +204 -0
  78. package/plugins/codex-workflow-gateway/references/protocol.md +11 -0
  79. package/plugins/codex-workflow-gateway/scripts/chat-dispatch.mjs +298 -0
  80. package/plugins/codex-workflow-gateway/scripts/chat-model-policy.mjs +43 -0
  81. package/plugins/codex-workflow-gateway/scripts/chat-registry.mjs +55 -3
  82. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +218 -21
  83. package/schemas/preexecution-replan-event.schema.json +45 -0
  84. package/schemas/task.schema.json +55 -0
  85. package/schemas/transition-payloads.schema.json +16 -1
@@ -0,0 +1,204 @@
1
+ # Durable App dispatch and explicit model selection
2
+
3
+ This is an App orchestration journal, not Workflow authority. It does not call the App, approve
4
+ Git writes, renew grants, mint credentials, or replace Core status/next. All commands below use
5
+ `node <exact-package>/plugins/codex-workflow-gateway/scripts/chat-registry.mjs` and the existing
6
+ 24-hex Workflow `--project-id`. The App project ID is a separate field.
7
+
8
+ ## One reservation, one creation attempt
9
+
10
+ `allocate` reuses the same non-abandoned role/entity/membership/attempt reservation. Task and
11
+ Coordinator allocations also reuse it when a caller changes the attempt number. Review attempts
12
+ advance only after the actual Workflow review cycle advances. Never invent a new attempt or
13
+ reservation to bypass unresolved creation. Existing v1 registries remain readable; legacy bound
14
+ threads remain usable with explicit model/thinking arguments from the same policy; `model-select`
15
+ and `supervision-record` currently require a tracked dispatch and do not retrofit legacy evidence.
16
+ Legacy unbound reservations may have already created a chat: reconcile
17
+ those using real App/source evidence before using the new dispatch flow.
18
+
19
+ 1. Save the closed context packet to a private local file. Do not include bearer credentials,
20
+ parent transcript, or approval narratives. Prepare a private dispatch request JSON:
21
+
22
+ ```json
23
+ {
24
+ "repositoryRoot": "/absolute/canonical/repository",
25
+ "appProjectId": "project-id-from-list-projects",
26
+ "hostId": "local",
27
+ "promptFile": "/private/tmp/task-context.txt",
28
+ "modelRequest": {
29
+ "phase": "planning",
30
+ "complexity": "standard",
31
+ "highRisk": false,
32
+ "rationale": "New multi-file implementation plan with established requirements.",
33
+ "availableModels": [
34
+ {"id": "gpt-5.6-sol", "reasoningEfforts": ["low", "medium", "high", "xhigh", "max", "ultra"]}
35
+ ]
36
+ }
37
+ }
38
+ ```
39
+
40
+ Populate `availableModels` from the destination App tool's actual model/effort contract, not
41
+ this example, the parent's settings, API pricing pages, or a guessed alias. Explicit user
42
+ authorization to select models is required; journal input is not user authorization.
43
+ 2. Run `dispatch-begin --reservation-id ID --file request.json` **before** `create_thread`.
44
+ Save its complete output to a mode-600 file. It atomically persists a creation marker, packet
45
+ hashes, repository binding and requested model selection, then returns `createAllowed:true`
46
+ and exact `createArgs` including `model` and App field `thinking`.
47
+ 3. Only the process receiving that one permission may make **one** `create_thread(createArgs)`
48
+ call. A second begin, even after restart or timeout, returns `createAllowed:false` and no
49
+ create arguments. Do not reissue the external call after a lost/ambiguous response. The marker
50
+ prepended to the packet is routing provenance, not parent history.
51
+ 4. Save the full App result locally, normalize its successful tool text JSON to
52
+ `{"result":{"clientThreadId":"client-new-thread:...","hostId":"local"}}` or
53
+ `{"result":{"threadId":"actual-uuid","hostId":"local"}}`, and run `dispatch-result`.
54
+ Preserve raw evidence separately. Never parse truncated displayed stdout as the full receipt.
55
+ Invalid JSON cannot change the journal. A pending client ID is never a usable thread ID.
56
+ 5. Use `dispatch-status` to resume. Missing `list_threads` results are not proof of failure.
57
+ If the actual ID is unavailable, identify candidate rollout files from creation-time/worktree
58
+ evidence and run `dispatch-discover --file candidates.json`, where the file is
59
+ `{"sessionFiles":["/absolute/rollout.jsonl"]}`. The helper reads only supplied files, checks
60
+ actual session metadata, exact original dispatched prompt (including the App's routing XML),
61
+ and the Git common directory. It ignores guardian/subagent sessions and unrelated prompts.
62
+ It prints candidate IDs/paths only, never transcripts or reasoning. Use bounded candidate
63
+ lists, not vault searches. Malformed, oversized or partially written files need a later fresh
64
+ read; they never authorize another creation.
65
+ 6. Confirm a correlated candidate using `read_thread`. Save its full response as
66
+ `{"observation": <parsed read_thread JSON>}` and run `dispatch-observe`. It checks actual UUID,
67
+ host, repository/worktree and App project when supplied. One candidate still requires App
68
+ readback; several candidates produce a duplicate conflict and require explicit disposition,
69
+ not arbitrary selection. After confirming one canonical child, inspect every other candidate,
70
+ reconcile any work it performed, and archive only idle duplicates. Record
71
+ `dispatch-resolve-duplicates` with `canonicalThreadId` and `dispositions`, one object per duplicate:
72
+ `{threadId, observation: <read_thread JSON>, archiveResult: {threadId, archived: true}}`.
73
+ Preserve the actual archive tool receipt; never fabricate it. Repeat canonical readback and bind.
74
+ Candidate history is retained and no creation permit is restored. This is a local evidence
75
+ check, not independent App attestation.
76
+ 7. Run ordinary title `readback`/rename until exact match. After any rename, repeat
77
+ `dispatch-observe` with fresh App readback, then `readback` and `bind`. Binding a tracked
78
+ dispatch requires the same actual ID, host and observed title. Rebinding it to another child,
79
+ binding a client ID or sharing one child between reservations is rejected.
80
+ 8. Run `supervision-record --file report.json` with `threadId`, `ownerThreadId`, `state`
81
+ (`active|attention|completed`), `cursor` and `nextAction`. Persist significant wait boundaries;
82
+ only that supervisor waits for this child. The journal stores routing state, not a Workflow
83
+ verdict. Do not put secrets, CREFs or confirmation codes in it. On restart, read the child and
84
+ fresh Core status/next before continuing. Worker reports stay local; the coordinator pulls.
85
+
86
+ A crash between durable begin and actual submission is deliberately ambiguous. There is no
87
+ App idempotency-key API here, so this provides **at most one create permit per reservation**,
88
+ not an exactly-once guarantee. Never clear the marker, abandon it, or expire it by time to retry.
89
+ If no positive creation evidence can be found, report that exact unresolved App operation. A
90
+ permission denial is a separate real gate; do not reroute it through another chat. Fresh user
91
+ approval does not establish that an ambiguous prior creation never happened.
92
+
93
+ ## Model matrix
94
+
95
+ The versioned policy is `scripts/chat-model-policy.mjs`. It expresses a conservative relative
96
+ cost/latency preference, not measured savings or guaranteed account availability:
97
+
98
+ | Work | Preferred model | App thinking | Compatible fallback |
99
+ | --- | --- | --- | --- |
100
+ | Routine mechanical transitions, formatting, retrieval | gpt-5.4-mini | low | gpt-5.6-luna / low |
101
+ | Coordinator supervision; small implementation | gpt-5.6-luna | medium | gpt-5.6-terra / medium |
102
+ | Standard implementation | gpt-5.6-terra | medium | gpt-5.6-sol / medium |
103
+ | Task planning | gpt-5.6-sol | medium | gpt-6-astra / medium |
104
+ | Routine Step review | gpt-5.6-terra | medium | gpt-5.6-sol / medium |
105
+ | Standard Step review, final review, plan/corrective audit | gpt-5.6-sol | high | gpt-6-astra / high |
106
+ | Complex work or high risk, any role | gpt-6-astra | high | gpt-5.6-sol / high |
107
+
108
+ `routine` means a small, well-specified change or mechanical action with an existing check;
109
+ `standard` is ordinary multi-file work; `complex` covers new architecture, unresolved concurrency,
110
+ recovery/provenance or distributed state. Mark `highRisk` for authorization, security-sensitive
111
+ paths, data-loss exposure or irreversible migrations. The coordinator records the concrete
112
+ rationale; classification is a human/agent judgment, not an automatic risk detector. Unknown
113
+ complexity is not routine. Review/audit role floors override a falsely mechanical phase. No
114
+ automatic xhigh/max/ultra, and no escalation merely because a review failed or a tool timed out.
115
+
116
+ The policy chooses only a listed supported pair. If neither preferred nor fallback pair is
117
+ available, stop selection rather than silently inheriting the coordinator or lowering the review
118
+ floor. API docs explain the reasoning/latency tradeoff, while App availability and accepted field
119
+ names come from the live App tool contract:
120
+ https://developers.openai.com/api/docs/guides/latest-model
121
+
122
+ For a phase change in an existing bound chat, call `model-select` with its same `threadId` and
123
+ fresh `modelRequest`; copy the returned `followUpArgs` into `send_message_to_thread` with the
124
+ closed follow-up prompt. For example, planning uses Sol/medium, implementation Terra/medium,
125
+ then mechanical lifecycle work Mini/low. Do not create another Task just to switch model.
126
+ Keep the previous selection unless the phase/risk/scope changes materially. The journal records
127
+ requested settings; tool acceptance/runtime readback, when available, is required to claim the
128
+ actual model used. A model change never changes role, scope, credentials or approval authority.
129
+
130
+ ## Execution context before product writes
131
+
132
+ `dispatch-begin` accepts optional `executionMode: "local" | "worktree"` (default `worktree`).
133
+ Choose local only for an explicit user request to execute in the saved checkout. The observed
134
+ child checkout, not an absolute path copied from the parent's prompt, is the write destination.
135
+ `dispatch-observe` records it and refuses silent movement of a bound child.
136
+
137
+ Before the first write, and after any branch/cwd change, use read-only `dispatch-context-check`
138
+ with a fresh App observation, `writeRoot`, and `expectedBranch` from the current Core Task.
139
+ Require `matched=true`, then refresh Core next and writer authority. Mismatch means reconcile the
140
+ same Worker context; it never permits another create, an indirect write or an automatic handoff.
141
+ A matched context does not authorize external operations. See packaged `docs/change-model.md`.
142
+
143
+ ### Existing legacy bindings
144
+
145
+ `dispatch-context-check` also accepts an already bound legacy entry with no dispatch marker.
146
+ Supply the exact current Task `repositoryRoot` in addition to the fresh actual Worker observation,
147
+ `writeRoot`, and `expectedBranch`. The check verifies the bound thread/host, common Git repository,
148
+ actual checkout/write root and Task branch without changing the registry. It reports
149
+ `contextBinding=legacy-current-observation` and `historicalCheckoutVerified=false`: no historical
150
+ creation marker or checkout provenance is invented. It never enables another create permit.
151
+ This observation does not move a chat, authorize a write or reconcile a tracked dispatch whose
152
+ previous checkout changed; those remain explicit App/context operations followed by fresh checks.
153
+
154
+ ### Explicit App handoff reconciliation
155
+
156
+ An App handoff can return a new destination thread ID while retaining the task history.
157
+ `bind` still rejects replacing a bound child. After the App operation succeeds, use the
158
+ separate `dispatch-handoff-reconcile --reservation-id ... --file ... --project-id ...` route:
159
+
160
+ ```json
161
+ {
162
+ "sourceThreadId": "actual-source-thread-uuid-from-the-handoff-call",
163
+ "sourceHostId": "local",
164
+ "repositoryRoot": "/absolute/repository-from-fresh-Task",
165
+ "expectedBranch": "exact-task-branch",
166
+ "receipt": {
167
+ "operationId": "actual-operation-id",
168
+ "revision": 12,
169
+ "status": "success",
170
+ "destinationThreadId": "actual-destination-thread-uuid",
171
+ "destinationHostId": "local",
172
+ "destinationCwd": "/absolute/destination-checkout",
173
+ "threadTitle": "exact-current-bound-title"
174
+ },
175
+ "observation": { "thread": {
176
+ "id": "actual-destination-thread-uuid",
177
+ "hostId": "local",
178
+ "kind": "codex",
179
+ "cwd": "/absolute/destination-checkout",
180
+ "title": "exact-current-bound-title"
181
+ } }
182
+ }
183
+ ```
184
+
185
+ Copy the complete successful `get_handoff_status` receipt, including any extra returned fields,
186
+ and obtain a fresh destination `read_thread`. Preserve the source `handoff_thread` tool call:
187
+ its source ID is supplied separately because the result need not contain it. Pending, failed,
188
+ cancelled or inconsistent results cannot change the registry. The destination must match the
189
+ bound title, actual Git repository and current Task branch, and cannot belong to another entry.
190
+
191
+ This route changes only the existing reservation's current thread/host and, for tracked dispatch,
192
+ current execution context. An append-only `handoffHistory` retains the receipt, source and prior
193
+ dispatch snapshot; the original creation marker is never fabricated for legacy entries. Existing
194
+ observations and selection history are preserved. A tracked supervisor's old cursor is cleared
195
+ so it must observe the destination again. No create permit, grant, Task claim or lifecycle
196
+ transition is issued. Ordinary `dispatch-observe` still rejects an unexplained checkout change.
197
+ An exact retry of the latest handoff leaves registry bytes unchanged; changed receipts or replay
198
+ of a superseded handoff are rejected. Refresh Core status/next and writer authority after handoff.
199
+
200
+ These caller-supplied App results are coherence evidence, not cryptographic proof of origin or
201
+ external write authority. The coordinator must obtain genuine tool results and independently
202
+ check retained history and the safe writer boundary. The package does not execute a handoff,
203
+ verify arbitrary transcript continuity, or authorize writes merely because reconciliation passes.
204
+ Only destinations readable on the checking host with the same Git common directory are supported.
@@ -56,10 +56,21 @@
56
56
  A provenance recovery recorded after the final context-refresh audit may explain exactly one
57
57
  additional Task revision only when it belongs to that verified Git chain. Any unexplained
58
58
  revision remains a `doctor` boundary.
59
+ - Before a credential-consuming mutation, check the public `writerCredentialReference.expiresAt`
60
+ against the current time and its actor/action binding. Lease heartbeat or renewal does not
61
+ extend that reference's expiry. An expired, missing, consumed or unverifiable reference requires
62
+ the simultaneously advertised `next.writerCredentialRecovery` action with its exact Task,
63
+ revision and `requiredActor`, followed by fresh `status` and `next`; use the new reference.
64
+ If no recovery is advertised, stop before mutation. Never read or patch the vault, extend expiry
65
+ manually, or reuse the old reference. This check also applies after independent reviews and pauses.
59
66
  - `milestone progress --id <MS-ID>` is a read-only projection and the sole source for Coordinator
60
67
  progress tables. It preserves cancelled/replacement Tasks as distinct membership rows.
61
68
  - `scripts/chat-registry.mjs` atomically allocates project-monotonic `#NNN`, builds all six title
62
69
  families, verifies exact readback, supplies deterministic fallbacks, and binds the verified chat.
70
+ It also journals a single creation permit, pending/actual IDs, evidence-based resolution and
71
+ supervision cursors. Missing App listings never permit a retry. See `chat-dispatch.md` for
72
+ dispatch commands and the explicit role/phase model + thinking policy. These are App routing
73
+ helpers; they do not change Workflow protocol/state schema or grant approval authority.
63
74
 
64
75
  ## Handshake
65
76
 
@@ -0,0 +1,298 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { selectChatModel } from './chat-model-policy.mjs';
6
+
7
+ const hash = text => createHash('sha256').update(text).digest('hex');
8
+ const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
9
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
10
+ const text = (value, name) => {
11
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} is required.`);
12
+ return value.trim();
13
+ };
14
+ const actualId = value => {
15
+ const id = text(value, 'actual threadId');
16
+ if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)) throw new Error('Expected actual thread UUID, not clientThreadId.');
17
+ return id;
18
+ };
19
+ function jsonFile(file) {
20
+ if (statSync(file).size > 16 * 1024 * 1024) throw new Error('Input file exceeds 16 MiB.');
21
+ return JSON.parse(readFileSync(file, 'utf8'));
22
+ }
23
+ function commonGitDir(cwd) {
24
+ return realpathSync(execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
25
+ cwd, encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024,
26
+ }).trim());
27
+ }
28
+ function promptMatches(value, digest) {
29
+ if (typeof value !== 'string') return false;
30
+ if (hash(value) === digest) return true;
31
+ const wrapped = /^<codex_delegation>\s*<source_thread_id>[^<]+<\/source_thread_id>\s*<input>([\s\S]*)<\/input>\s*<\/codex_delegation>$/.exec(value);
32
+ return wrapped !== null && hash(wrapped[1]) === digest;
33
+ }
34
+ function view(entry) {
35
+ const d = entry.dispatch;
36
+
37
+ return {
38
+ reservationId: entry.reservationId, state: d?.state ?? (entry.threadId ? 'legacy-bound' : 'untracked'),
39
+ createAllowed: false, dispatchId: d?.id ?? null, clientThreadId: d?.clientThreadId ?? null,
40
+ threadId: entry.threadId ?? d?.candidateThreadId ?? null, hostId: d?.hostId ?? entry.hostId ?? null,
41
+ executionContext: d?.executionContext ?? null,
42
+ selection: d?.selection ?? null, supervision: d?.supervision ?? null,
43
+ candidates: d?.candidates ?? [],
44
+ nextAction: entry.threadId ? 'supervise-existing' : d?.state === 'resolved' ? 'title-readback-and-bind'
45
+ : d ? 'resolve-existing-creation' : 'dispatch-begin',
46
+ };
47
+ }
48
+ export function dispatchAction(action, entry, input = {}) {
49
+ if (action === 'dispatch-status') return view(entry);
50
+ if (action === 'dispatch-begin') {
51
+ // A durable marker is committed BEFORE the external call. Even a lost response never permits another call.
52
+ if (entry.dispatch || entry.threadId) return view(entry);
53
+ if (entry.status === 'abandoned' || entry.status === 'blocked') throw new Error('Reservation is not dispatchable.');
54
+ const repositoryRoot = realpathSync(text(input.repositoryRoot, 'repositoryRoot'));
55
+ const gitDir = commonGitDir(repositoryRoot);
56
+ const executionMode = input.executionMode ?? 'worktree';
57
+ if (!['local', 'worktree'].includes(executionMode)) throw new Error('executionMode must be local or worktree.');
58
+ const appProjectId = text(input.appProjectId, 'appProjectId');
59
+ const hostId = text(input.hostId, 'hostId');
60
+ const promptPath = realpathSync(text(input.promptFile, 'promptFile'));
61
+ if (statSync(promptPath).size > 1024 * 1024) throw new Error('TaskContextPacket exceeds 1 MiB.');
62
+ const prompt = readFileSync(promptPath, 'utf8');
63
+ if (!prompt.trim() || !prompt.includes(entry.entityId)) throw new Error('Prompt must include the exact entity ID.');
64
+ const selection = selectChatModel(entry.type, input.modelRequest);
65
+ const id = randomUUID();
66
+ const marker = `[workflow-dispatch:${id}:${hash(prompt)}]`;
67
+ const dispatchedPrompt = `${marker}\n${prompt}\nExecution boundary: use the actual child checkout returned by App readback. Before any write, reconcile it with the exact Task branch and run dispatch-context-check. A repository path in this packet is identity context, not authorization to write outside that checkout.`;
68
+ entry.dispatch = {
69
+ version: 1, id, state: 'creating', appProjectId, hostId, repositoryRoot, gitDir, executionMode,
70
+ promptPath, promptHash: hash(prompt), dispatchedPromptHash: hash(dispatchedPrompt), marker,
71
+ selection, selectionHistory: [selection], clientThreadId: null, candidateThreadId: null,
72
+ candidates: [], observations: [], createdAt: new Date().toISOString(),
73
+ };
74
+ return { ...view(entry), createAllowed: true, nextAction: 'create-thread-once', createArgs: {
75
+ target: { type: 'project', projectId: appProjectId, environment: { type: executionMode } },
76
+ title: entry.requestedTitle, prompt: dispatchedPrompt, model: selection.model, thinking: selection.thinking,
77
+ } };
78
+ }
79
+ const d = entry.dispatch;
80
+ if (action === 'dispatch-handoff-reconcile') {
81
+ const receipt = input.receipt;
82
+ const t = input.observation?.thread;
83
+ const sourceThreadId = actualId(input.sourceThreadId);
84
+ const sourceHostId = text(input.sourceHostId, 'sourceHostId');
85
+ const operationId = text(receipt?.operationId, 'handoff operationId');
86
+ if (receipt?.status !== 'success' || !Number.isSafeInteger(receipt.revision) || receipt.revision < 1
87
+ || !Array.isArray(receipt.steps) || receipt.steps.length === 0
88
+ || receipt.steps.some(step => !step || typeof step.id !== 'string' || !step.id || step.status !== 'done')) {
89
+ throw new Error('Require a successful completed App handoff receipt.');
90
+ }
91
+ const destinationThreadId = actualId(receipt.destinationThreadId);
92
+ const destinationHostId = text(receipt.destinationHostId, 'destinationHostId');
93
+ if (!entry.titleVerified || entry.status !== 'bound' || !entry.threadId
94
+ || receipt.threadTitle !== entry.observedTitle || receipt.threadTitle !== entry.requestedTitle
95
+ || t?.kind !== 'codex' || t.id !== destinationThreadId || t.hostId !== destinationHostId
96
+ || t.title !== receipt.threadTitle) throw new Error('Handoff requires the exact bound title and destination readback.');
97
+ const cwd = realpathSync(text(t.cwd, 'destination cwd'));
98
+ if (cwd !== realpathSync(text(receipt.destinationCwd, 'receipt destinationCwd'))) {
99
+ throw new Error('Handoff destination cwd does not match its receipt.');
100
+ }
101
+ const root = realpathSync(execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 5000 }).trim());
102
+ const repositoryRoot = realpathSync(text(input.repositoryRoot, 'repositoryRoot from fresh Task'));
103
+ const gitDir = commonGitDir(repositoryRoot);
104
+ const expectedBranch = text(input.expectedBranch, 'expectedBranch from fresh Task');
105
+ if (commonGitDir(root) !== gitDir || (d && d.gitDir !== gitDir)
106
+ || execFileSync('git', ['branch', '--show-current'], { cwd: root, encoding: 'utf8', timeout: 5000 }).trim() !== expectedBranch) {
107
+ throw new Error('Handoff repository or Task branch mismatch.');
108
+ }
109
+ const binding = { sourceThreadId, sourceHostId, receipt, repositoryRoot, expectedBranch,
110
+ destination: { threadId: destinationThreadId, hostId: destinationHostId, cwd, checkoutRoot: root, title: t.title } };
111
+ const bindingHash = hash(canonical(binding));
112
+ const history = entry.handoffHistory ?? [];
113
+ const previous = history.find(event => event.operationId === operationId);
114
+ if (previous) {
115
+ if (previous.bindingHash !== bindingHash || history.at(-1) !== previous
116
+ || entry.threadId !== destinationThreadId || entry.hostId !== destinationHostId) {
117
+ throw new Error('Handoff operation was already recorded with a different binding or superseded.');
118
+ }
119
+ return { ...view(entry), handoffOperationId: operationId, idempotentRetry: true };
120
+ }
121
+ if (entry.threadId !== sourceThreadId || entry.hostId !== sourceHostId
122
+ || (d && (d.hostId !== sourceHostId || d.candidateThreadId !== sourceThreadId))) {
123
+ throw new Error('Handoff source must match the currently bound Worker.');
124
+ }
125
+ const recordedAt = new Date().toISOString();
126
+ // Receipt input is coherence evidence, not a signed App capability. Preserve the
127
+ // complete prior dispatch instead of relabeling its original creation history.
128
+ entry.handoffHistory = [...history, { operationId, bindingHash, ...binding, recordedAt,
129
+ priorDispatch: d ? structuredClone(d) : null }];
130
+ entry.threadId = destinationThreadId;
131
+ entry.hostId = destinationHostId;
132
+ if (d) {
133
+ d.hostId = destinationHostId;
134
+ d.candidateThreadId = destinationThreadId;
135
+ d.executionContext = { checkoutRoot: root, gitDir, threadId: destinationThreadId, hostId: destinationHostId };
136
+ d.executionMode = root === d.repositoryRoot ? 'local' : 'worktree';
137
+ d.candidates = [];
138
+ d.duplicateDisposition = null;
139
+ d.state = 'resolved';
140
+ d.observations.push({ threadId: destinationThreadId, hostId: destinationHostId, cwd, title: t.title,
141
+ observedAt: recordedAt, handoffOperationId: operationId });
142
+ if (d.supervision) d.supervision = { ...d.supervision, cursor: null,
143
+ state: 'attention', nextAction: 'refresh-destination-observation-and-core-next', updatedAt: recordedAt };
144
+ }
145
+ return { ...view(entry), handoffOperationId: operationId, idempotentRetry: false };
146
+ }
147
+ if (action === 'dispatch-context-check') {
148
+ // A fresh observation is mandatory; prior readback is not a filesystem capability.
149
+ const t = input.observation?.thread;
150
+ if (!entry.threadId || t?.id !== entry.threadId || t.kind !== 'codex' || t.hostId !== (d?.hostId ?? entry.hostId)) {
151
+ throw new Error('Require a fresh observation of the bound Worker.');
152
+ }
153
+ const cwd = realpathSync(text(t.cwd, 'thread cwd'));
154
+ const writeRoot = realpathSync(text(input.writeRoot, 'writeRoot'));
155
+ const root = realpathSync(execFileSync('git', ['rev-parse', '--show-toplevel'], {
156
+ cwd, encoding: 'utf8', timeout: 5000,
157
+ }).trim());
158
+ const branch = execFileSync('git', ['branch', '--show-current'], {
159
+ cwd: root, encoding: 'utf8', timeout: 5000,
160
+ }).trim();
161
+ const expectedBranch = text(input.expectedBranch, 'expectedBranch from fresh Task');
162
+ const reasons = [];
163
+ const expectedGitDir = d?.gitDir ?? commonGitDir(realpathSync(text(input.repositoryRoot, 'repositoryRoot from fresh Task')));
164
+ if (commonGitDir(root) !== expectedGitDir) reasons.push('REPOSITORY_MISMATCH');
165
+ if (d && d.executionContext?.checkoutRoot !== root) reasons.push('WORKER_CHECKOUT_CHANGED');
166
+ if (writeRoot !== root) reasons.push('WRITE_ROOT_MISMATCH');
167
+ if (branch !== expectedBranch) reasons.push('TASK_BRANCH_MISMATCH');
168
+ return { kind: 'execution-context-check', readOnly: true, authority: 'none',
169
+ matched: reasons.length === 0, reasons, threadId: entry.threadId,
170
+ contextBinding: d ? 'tracked-dispatch' : 'legacy-current-observation',
171
+ historicalCheckoutVerified: Boolean(d?.executionContext),
172
+ checkoutRoot: root, writeRoot, branch, expectedBranch,
173
+ nextAction: reasons.length ? 'reconcile-existing-worker-context' : 'refresh-core-next-and-writer-authority',
174
+ limitations: ['This check does not grant filesystem access or override external approval.',
175
+ 'Recheck before a write after cwd or branch changes. No automatic handoff or duplicate dispatch.'] };
176
+ }
177
+ if (!d) throw new Error('No tracked dispatch; reconcile legacy chats manually, never assume creation failed.');
178
+ if (action === 'dispatch-result') {
179
+ const result = input.result;
180
+ if (!result || result.hostId !== d.hostId) throw new Error('Create result host mismatch.');
181
+ if (result.threadId && result.clientThreadId) throw new Error('Ambiguous create result.');
182
+ if (result.threadId) {
183
+ const id = actualId(result.threadId);
184
+ if (d.candidateThreadId && d.candidateThreadId !== id) throw new Error('Conflicting actual thread IDs.');
185
+ d.candidateThreadId = id;
186
+ } else {
187
+ const id = text(result.clientThreadId, 'clientThreadId');
188
+ if (!id.startsWith('client-new-thread:')) throw new Error('Invalid pending clientThreadId.');
189
+ if (d.clientThreadId && d.clientThreadId !== id) throw new Error('Conflicting creation attempts; reconcile duplicates.');
190
+ d.clientThreadId = id;
191
+ }
192
+ if (d.state === 'creating') d.state = 'pending';
193
+ return view(entry);
194
+ }
195
+ if (action === 'dispatch-discover') {
196
+ // Read only specifically supplied session files; never scan credential stores or print transcripts/reasoning.
197
+ if (!Array.isArray(input.sessionFiles) || input.sessionFiles.length > 200) throw new Error('Supply at most 200 candidate session files.');
198
+ const found = new Map(d.candidates.map(c => [c.threadId, c]));
199
+ for (const file of input.sessionFiles) {
200
+ if (statSync(file).size > 16 * 1024 * 1024) throw new Error('Session exceeds 16 MiB; narrow evidence required.');
201
+ const lines = readFileSync(file, 'utf8').split('\n');
202
+ const meta = JSON.parse(lines[0]).payload;
203
+ if (!meta || !['vscode', 'cli'].includes(meta.source) || typeof meta.cwd !== 'string') continue;
204
+ const rows = lines.slice(1).filter(Boolean).map(line => JSON.parse(line));
205
+ const bound = rows.some(row => {
206
+ if (row.type !== 'response_item') return false;
207
+ const p = row.payload;
208
+ // Actual App rollout format: a create_thread function output containing routing-only XML.
209
+ if (p?.type === 'function_call_output' && p.name === 'create_thread' && p.namespace === 'codex_app') {
210
+ return promptMatches(p.output, d.dispatchedPromptHash);
211
+ }
212
+ return p?.type === 'message' && p.role === 'user' && p.content?.some(c =>
213
+ ['input_text', 'text'].includes(c.type) && promptMatches(c.text, d.dispatchedPromptHash));
214
+ });
215
+ if (!bound) continue;
216
+ if (commonGitDir(meta.cwd) !== d.gitDir) throw new Error('Session belongs to another repository.');
217
+ const id = actualId(meta.id);
218
+ found.set(id, { threadId: id, cwd: realpathSync(meta.cwd), sourceFile: realpathSync(file) });
219
+ }
220
+ d.candidates = [...found.values()];
221
+ if (d.candidates.length > 1) d.state = 'duplicate-conflict';
222
+ return view(entry);
223
+ }
224
+ if (action === 'dispatch-resolve-duplicates') {
225
+ const canonical = actualId(input.canonicalThreadId);
226
+ if (entry.threadId && entry.threadId !== canonical) throw new Error('Cannot replace a bound child.');
227
+ if (d.candidateThreadId && d.candidateThreadId !== canonical) throw new Error('Cannot replace the correlated create result.');
228
+ if (!d.candidates.some(c => c.threadId === canonical)) throw new Error('Canonical child must be a discovered candidate.');
229
+ const others = d.candidates.filter(c => c.threadId !== canonical);
230
+ if (!others.length || !Array.isArray(input.dispositions) || input.dispositions.length !== others.length) {
231
+ throw new Error('Require one idle readback and archive receipt for every duplicate.');
232
+ }
233
+ const receipts = others.map(candidate => {
234
+ const matching = input.dispositions.filter(x => x?.threadId === candidate.threadId);
235
+ if (matching.length !== 1) throw new Error('Duplicate disposition missing or repeated.');
236
+ const item = matching[0];
237
+ const t = item.observation?.thread;
238
+ if (t?.id !== candidate.threadId || t.kind !== 'codex' || t.hostId !== d.hostId
239
+ || t.status?.type !== 'idle' || realpathSync(text(t.cwd, 'duplicate cwd')) !== candidate.cwd
240
+ || commonGitDir(t.cwd) !== d.gitDir || item.archiveResult?.threadId !== candidate.threadId
241
+ || item.archiveResult?.archived !== true) throw new Error('Duplicate requires matching idle readback and successful archive receipt.');
242
+ return { threadId: candidate.threadId, archived: true, recordedAt: new Date().toISOString() };
243
+ });
244
+ d.duplicateDisposition = { canonicalThreadId: canonical, archived: receipts };
245
+ d.state = 'pending';
246
+ return view(entry);
247
+ }
248
+ if (action === 'dispatch-observe') {
249
+ const observation = input.observation;
250
+ const t = observation?.thread;
251
+ if (!t || t.kind !== 'codex' || t.hostId !== d.hostId) throw new Error('Require actual Codex read_thread observation on the bound host.');
252
+ const id = actualId(t.id);
253
+ if (d.candidates.length > 1 && (d.duplicateDisposition?.canonicalThreadId !== id
254
+ || d.candidates.some(c => c.threadId !== id && !d.duplicateDisposition.archived.some(a => a.threadId === c.threadId)))) {
255
+ throw new Error('Multiple candidates: explicit duplicate disposition required before binding.');
256
+ }
257
+ const candidate = d.candidates.find(c => c.threadId === id);
258
+ if (id !== d.candidateThreadId && !candidate) throw new Error('Uncorrelated thread; require create result or exact session prompt evidence.');
259
+ if (commonGitDir(text(t.cwd, 'thread cwd')) !== d.gitDir
260
+ || (candidate && realpathSync(t.cwd) !== candidate.cwd)) throw new Error('Readback repository/cwd mismatch.');
261
+ if (t.projectId !== undefined && t.projectId !== d.appProjectId) throw new Error('Readback App project mismatch.');
262
+ if (d.candidateThreadId && d.candidateThreadId !== id) throw new Error('Readback differs from create result.');
263
+ const checkoutRoot = realpathSync(execFileSync('git', ['rev-parse', '--show-toplevel'], {
264
+ cwd: t.cwd, encoding: 'utf8', timeout: 5000,
265
+ }).trim());
266
+ if (d.executionMode === 'local' && checkoutRoot !== d.repositoryRoot) {
267
+ throw new Error('Local dispatch readback checkout mismatch.');
268
+ }
269
+ if (d.executionContext && d.executionContext.checkoutRoot !== checkoutRoot) {
270
+ throw new Error('Bound Worker checkout changed; reconcile context without replacing the child.');
271
+ }
272
+ d.executionContext = { checkoutRoot, gitDir: d.gitDir, threadId: id, hostId: d.hostId };
273
+ d.candidateThreadId = id;
274
+ d.state = 'resolved';
275
+ d.observations.push({ threadId: id, hostId: t.hostId, cwd: realpathSync(t.cwd),
276
+ title: text(t.title, 'observed title'), observedAt: new Date().toISOString() });
277
+ return view(entry);
278
+ }
279
+ if (action === 'supervision-record') {
280
+ if (!entry.threadId || input.threadId !== entry.threadId || input.ownerThreadId === entry.threadId) throw new Error('Require bound child and separate supervisor.');
281
+ const owner = actualId(input.ownerThreadId);
282
+ if (d.supervision && d.supervision.ownerThreadId !== owner) throw new Error('Supervisor already assigned; no parallel wait owner.');
283
+ if (!['active', 'attention', 'completed'].includes(input.state)) throw new Error('Invalid supervision state.');
284
+ d.supervision = { ownerThreadId: owner, state: input.state, cursor: input.cursor == null ? null : text(input.cursor, 'cursor'),
285
+ nextAction: text(input.nextAction, 'nextAction'), updatedAt: new Date().toISOString() };
286
+ return view(entry);
287
+ }
288
+ if (action === 'model-select') {
289
+ if (!entry.threadId || input.threadId !== entry.threadId) throw new Error('Phase changes require the same bound thread.');
290
+ const selection = selectChatModel(entry.type, input.modelRequest);
291
+ d.selection = selection;
292
+ d.selectionHistory.push(selection);
293
+ return { ...view(entry), followUpArgs: { threadId: entry.threadId, hostId: entry.hostId,
294
+ model: selection.model, thinking: selection.thinking } };
295
+ }
296
+ throw new Error(`Unknown dispatch action: ${action}`);
297
+ }
298
+ export function dispatchInput(file) { return jsonFile(file); }
@@ -0,0 +1,43 @@
1
+ // Relative cost/latency policy, not a price list or an availability oracle.
2
+ export const MODEL_POLICY_VERSION = 'chat-model-policy-v1';
3
+ const PROFILES = {
4
+ mechanical: [['gpt-5.4-mini', 'low'], ['gpt-5.6-luna', 'low']],
5
+ scoped: [['gpt-5.6-luna', 'medium'], ['gpt-5.6-terra', 'medium']],
6
+ implementation: [['gpt-5.6-terra', 'medium'], ['gpt-5.6-sol', 'medium']],
7
+ planning: [['gpt-5.6-sol', 'medium'], ['gpt-6-astra', 'medium']],
8
+ review: [['gpt-5.6-sol', 'high'], ['gpt-6-astra', 'high']],
9
+ 'scoped-review': [['gpt-5.6-terra', 'medium'], ['gpt-5.6-sol', 'medium']],
10
+ complex: [['gpt-6-astra', 'high'], ['gpt-5.6-sol', 'high']],
11
+ };
12
+ const TYPES = ['coordinator', 'task', 'step-review', 'final-review', 'corrective-audit', 'plan-audit'];
13
+ export function selectChatModel(type, request) {
14
+ if (!TYPES.includes(type)) throw new Error('Unknown chat type.');
15
+ if (!request || !['routine', 'standard', 'complex'].includes(request.complexity)
16
+ || !['planning', 'implementation', 'mechanical', 'review', 'coordination'].includes(request.phase)
17
+ || typeof request.rationale !== 'string' || !request.rationale.trim()
18
+ || typeof request.highRisk !== 'boolean') throw new Error('Explicit phase, complexity, highRisk and rationale are required.');
19
+ if (!Array.isArray(request.availableModels) || request.availableModels.length === 0
20
+ || request.availableModels.some(m => !m || typeof m.id !== 'string' || !Array.isArray(m.reasoningEfforts)
21
+ || m.reasoningEfforts.some(e => typeof e !== 'string'))
22
+ || new Set(request.availableModels.map(m => m.id)).size !== request.availableModels.length) {
23
+ throw new Error('Provide the destination host model/effort catalog; never infer availability.');
24
+ }
25
+ const reviewRole = type.endsWith('review') || type.endsWith('audit');
26
+ let profile;
27
+ if (request.highRisk || request.complexity === 'complex') profile = 'complex';
28
+ else if (type === 'step-review' && request.complexity === 'routine') profile = 'scoped-review';
29
+ else if (reviewRole || request.phase === 'review') profile = 'review';
30
+ else if (request.phase === 'planning') profile = 'planning';
31
+ else if (request.phase === 'mechanical') {
32
+ if (request.complexity !== 'routine') throw new Error('Mechanical phase requires routine complexity.');
33
+ profile = 'mechanical';
34
+ } else if (type === 'coordinator' || request.phase === 'coordination' || request.complexity === 'routine') profile = 'scoped';
35
+ else profile = 'implementation';
36
+ const selected = PROFILES[profile].find(([id, effort]) => request.availableModels.some(m => m.id === id && m.reasoningEfforts.includes(effort)));
37
+ if (!selected) throw new Error(`No supported model/effort for ${profile}; explicit policy revision required, never inherit coordinator settings.`);
38
+ return {
39
+ policyVersion: MODEL_POLICY_VERSION, profile, model: selected[0], thinking: selected[1],
40
+ phase: request.phase, complexity: request.complexity, highRisk: request.highRisk,
41
+ rationale: request.rationale.trim(), fallback: selected !== PROFILES[profile][0],
42
+ };
43
+ }