okstra 0.169.0 → 0.169.1

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/docs/cli.md CHANGED
@@ -774,7 +774,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
774
774
  | `okstra codex-run <args…>` | Codex lead-adapter dry-run entry point. Accepts the same arguments as `render-bundle` but owns `--render-only --lead-runtime codex`. It prepares the task bundle and prints the prompt for the Codex lead without dispatching workers |
775
775
  | `okstra worker-dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--dry-run]` | Provider-neutral deterministic dispatcher for `runner=cli-wrapper` assignments. It verifies each adjacent invocation specification against the immutable run manifest immediately before process creation and records `core-pre-dispatch`; native-session rows stay with the host. The default selects CLI analysis assignments only. Phase 6 uses explicit `--workers report-writer`, and a mixed analysis/report batch is rejected. `--dry-run` performs the same verification and resolution without starting a provider process. |
776
776
  | `okstra codex-dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--dry-run]` | Compatibility alias for `okstra worker-dispatch`; it no longer selects a Codex-only transport-agent path. |
777
- | `okstra agent-prompt materialize\|verify\|record-dispatch\|link-result\|materialize-result\|complete\|verify-completion` | Internal invocation-contract CLI. `materialize` composes model assignment, functional duty, and task instructions; `verify` rejects identity, path, snapshot, assignment, source, or digest drift. Run-backed calls resolve `assignmentRef` from the manifest, enforce `authorizedPaths`, and reject real-path or symbolic-link escape. `record-dispatch` records a verified host-native specification before dispatch and `link-result` binds the accepted result. Standalone calls are identified by `(purpose, invocationId)` under `.okstra/agent-invocations/<purpose>/`; they publish a canonical result envelope and publish the completion marker last. Consumers use only the `returnedBody` from `verify-completion`. Metadata contains exactly `catalogDigest`, `assignmentDigest`, `dutyDigest`, `instructionDigest`, and `promptDigest`; JSON inputs use UTF-8, sorted keys, compact separators, and no non-finite values, while duty files use versioned sorted-name/byte framing. Instruction sources use `{kind: project\|runtime, path: <relative POSIX path>}` and never persist an installed absolute runtime path. |
777
+ | `okstra agent-prompt materialize\|verify\|record-dispatch\|link-result\|materialize-result\|complete\|verify-completion` | Internal invocation-contract CLI. `materialize` composes model assignment, functional duty, and task instructions; `verify` rejects identity, path, snapshot, assignment, source, or digest drift. Run-backed calls resolve `assignmentRef` from the manifest, enforce `authorizedPaths`, and reject real-path or symbolic-link escape. `record-dispatch` records a verified host-native specification before dispatch and `link-result` binds the accepted result. Standalone calls are identified by `(purpose, invocationId)` under `.okstra/agent-invocations/<purpose>/`; they publish a canonical result envelope and publish the completion marker last. Consumers use only the `returnedBody` from `verify-completion`. Metadata contains exactly `catalogDigest`, `assignmentDigest`, `dutyDigest`, `instructionDigest`, and `promptDigest`; JSON inputs use UTF-8, sorted keys, compact separators, and no non-finite values, while duty files use versioned sorted-name/byte framing. Instruction sources use `{kind: project\|runtime, path: <relative POSIX path>}` and never persist an installed absolute runtime path. A published prompt is immutable, so re-running `materialize` with an edited instruction file fails as `existing_invocation_conflict`; `--replace-undispatched` is the one exit, for a prompt that failed a pre-dispatch gate and therefore ran nowhere. It republishes prompt and metadata together, and it is verified rather than trusted — a row in `agentDispatches` or `workerDispatches` naming this `invocationId` refuses the replacement and names the dispatch that used it. |
778
778
  | `okstra team dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--jobs-file <path>] [--dry-run]` / `okstra team await --project-root <dir> --run-manifest <path> [--json]` / `okstra team teardown --project-root <dir> --run-manifest <path> [--dry-run] [--json]` | Read a `leadRuntime=external` run manifest and dispatch, await, or tear down tmux-pane workers. Default dispatch excludes report writer; Phase 6 selects it explicitly, and mixed analysis/report jobs are rejected. If a tmux pane cannot be created, gracefully degrade to the CLI wrapper and record the fallback in `workerDispatches[].degradedFrom` |
779
779
  | `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run the whole Phase 7 post-report sequence in its contractual order: `token-usage` → `render-views` → `spawn-followups` → `validate-run`. Stops at the first non-zero exit and names the failing step, then prints a per-step `[ok]` / `[FAIL]` / `[skip]` summary on stderr so the outcome is legible without parsing the JSON payload. Every step is idempotent, so re-running after a fix is safe — but `--only <step>` (repeatable) reruns just the named steps in contractual order, which matters because `validate-run` is the step that usually fails and retrying it otherwise repeats the three steps before it at full token and wall-clock cost. This is the same code path (`scripts/okstra_ctl/report_finalize.py`) the Codex lead adapter runs automatically after its report-writer completes, so a Claude-led and a Codex-led run finalize identically. `--workspace-root` is owned by the Node wrapper. Prefer this over invoking the four steps individually |
780
780
  | `okstra render-views <final-report.data.json\|final-report.md>` | The Phase 7 `render-views` step, runnable on its own. Schema v2 data is rendered directly (contract: `schemas/final-report-v2.0.schema.json`) into an always-generated, task-specific human HTML sibling while `templates/reports/final-report-v2.template.md` independently owns the AI handoff Markdown. Passing the Markdown sibling locates the same v2 data.json. Schema v1 and quick reports keep the legacy conditional renderer. The Node wrapper calls `scripts/okstra-render-report-views.py`; `validators/validate-report-views.py` verifies source/schema/template digests, required human fields, form controls, external assets, diagram/table ID parity, and Response ID parity |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.169.0",
3
+ "version": "0.169.1",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.169.0",
3
- "builtAt": "2026-08-13T13:31:24.833Z",
2
+ "package": "0.169.1",
3
+ "builtAt": "2026-08-13T15:45:32.359Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -1,296 +1,52 @@
1
1
  #!/usr/bin/env python3
2
- """OKSTRA error log helper.
2
+ """OKSTRA error log helper — CLI adapter.
3
3
 
4
- Single writer for runs/<task-type>/logs/errors-<task-type>-<seq>.jsonl.
4
+ The writer itself lives in `okstra_ctl.error_log_write`, which the deterministic
5
+ dispatcher also calls in-process. This file is the argparse surface over it and
6
+ re-exports the writer's names so the contract tests keep reading one module.
5
7
  """
6
8
  from __future__ import annotations
7
9
 
8
10
  import argparse
9
- import datetime as dt
10
11
  import json
11
- import os
12
12
  from pathlib import Path
13
13
 
14
- STDERR_EXCERPT_MAX_BYTES = 2048
15
- TRUNCATION_SUFFIX = "...[truncated]"
16
- PIPE_BUF_BYTES = 4096
17
-
18
- ALLOWED_ERROR_TYPES = {"tool-failure", "cli-failure", "contract-violation"}
19
- ALLOWED_AGENTS = {
20
- "claude-lead", "claude-worker", "codex-worker",
21
- "antigravity-worker", "report-writer",
22
- }
23
- ALLOWED_AGENT_ROLES = {"lead", "worker", "report-writer"}
24
- SUPPORTED_SIDECAR_SCHEMA_VERSIONS = {1}
25
-
26
- ALLOWED_CAUSES = {
27
- "sandbox-denied", "service-unavailable", "auth-failed", "unknown",
28
- }
29
- # A `sandbox-denied` claim is only admissible with both probes attached.
30
- CAUSE_EVIDENCE_FIELDS = ("targetProbe", "controlProbe")
31
- # Both probes share a record's PIPE_BUF_BYTES budget with stderrExcerpt, so
32
- # they cannot reuse the 2048 cap that assumes stderrExcerpt owns it alone.
33
- CAUSE_PROBE_MAX_BYTES = 256
34
- # Backstop vocabulary: scanned in `message` only — never in stderrExcerpt,
35
- # where a kernel's real "Operation not permitted" is legitimate content.
36
- # Deliberately excludes bare "blocked"/"blocks": everyday English that would
37
- # reject honest records like "test blocked on upstream dependency".
38
- _BLOCKING_CLAIM_TERMS = (
39
- "sandbox", "not permitted", "permission denied", "eperm",
14
+ from okstra_ctl.error_log_write import (
15
+ ALLOWED_AGENT_ROLES,
16
+ ALLOWED_AGENTS,
17
+ ALLOWED_CAUSES,
18
+ ALLOWED_ERROR_TYPES,
19
+ CAUSE_EVIDENCE_FIELDS,
20
+ CAUSE_PROBE_MAX_BYTES,
21
+ PIPE_BUF_BYTES,
22
+ STDERR_EXCERPT_MAX_BYTES,
23
+ SUPPORTED_SIDECAR_SCHEMA_VERSIONS,
24
+ TRUNCATION_SUFFIX,
25
+ append_jsonl_line,
26
+ append_observed,
27
+ dump_from_worker_sidecar,
28
+ normalize_cause_context,
29
+ truncate_stderr,
40
30
  )
41
- # The backstop targets *unclassified* blocking claims. A worker that declared
42
- # a specific cause has already done the honest work — `auth-failed` legitimately
43
- # reads "permission denied" (MySQL 1045).
44
- _UNCLASSIFIED_CAUSES = (None, "unknown")
45
-
46
-
47
- def _now_utc():
48
- return dt.datetime.now(dt.timezone.utc)
49
-
50
-
51
- def _iso(t):
52
- return t.isoformat()
53
-
54
-
55
- def _truncate_utf8(s, limit):
56
- """Truncate to `limit` bytes without splitting a multibyte character."""
57
- if s is None:
58
- return None
59
- encoded = s.encode("utf-8")
60
- if len(encoded) <= limit:
61
- return s
62
- cut = encoded[:limit]
63
- while cut:
64
- try:
65
- return cut.decode("utf-8") + TRUNCATION_SUFFIX
66
- except UnicodeDecodeError:
67
- cut = cut[:-1]
68
- return TRUNCATION_SUFFIX
69
-
70
-
71
- def truncate_stderr(s):
72
- """Truncate stderr text to STDERR_EXCERPT_MAX_BYTES, multibyte-safe."""
73
- return _truncate_utf8(s, STDERR_EXCERPT_MAX_BYTES)
74
-
75
-
76
- def normalize_cause_context(context, *, message):
77
- """Validate a record's cause claim and return the normalized context.
78
-
79
- Raises ValueError on three conditions:
80
- - `cause` is set to a value outside ALLOWED_CAUSES;
81
- - `cause` is 'sandbox-denied' but the two probes are missing or blank —
82
- those probes are what distinguish a real denial from an unreachable
83
- or auth-gated target, the misdiagnosis this gate exists to stop;
84
- - `message` asserts a block in prose while the record left its cause
85
- unclassified, which would smuggle the same claim past the gate.
86
- """
87
- cause = context.get("cause") if isinstance(context, dict) else None
88
-
89
- if cause is not None and cause not in ALLOWED_CAUSES:
90
- raise ValueError(
91
- f"invalid cause: {cause!r} (allowed: {sorted(ALLOWED_CAUSES)})"
92
- )
93
-
94
- if cause == "sandbox-denied":
95
- evidence = context.get("causeEvidence")
96
- if not isinstance(evidence, dict):
97
- raise ValueError(
98
- "cause 'sandbox-denied' requires context.causeEvidence with "
99
- f"{list(CAUSE_EVIDENCE_FIELDS)}"
100
- )
101
- normalized_evidence = {}
102
- for field in CAUSE_EVIDENCE_FIELDS:
103
- value = evidence.get(field)
104
- if not isinstance(value, str) or not value.strip():
105
- raise ValueError(
106
- f"cause 'sandbox-denied' requires a non-empty "
107
- f"context.causeEvidence.{field}: record the command and "
108
- f"its raw output that proves the claim"
109
- )
110
- normalized_evidence[field] = _truncate_utf8(
111
- value, CAUSE_PROBE_MAX_BYTES
112
- )
113
- return {**context, "causeEvidence": normalized_evidence}
114
31
 
115
- if message and cause in _UNCLASSIFIED_CAUSES:
116
- lowered = message.lower()
117
- hit = next((t for t in _BLOCKING_CLAIM_TERMS if t in lowered), None)
118
- if hit:
119
- raise ValueError(
120
- f"message asserts a blocking claim ({hit!r}) without "
121
- "context.cause='sandbox-denied' + context.causeEvidence. "
122
- "Either attach the two probes, or state the cause you "
123
- "actually verified."
124
- )
125
-
126
- return context
127
-
128
-
129
- def append_jsonl_line(path, record):
130
- """Append a single JSON record as one line to ``path``.
131
-
132
- Atomicity guarantee (POSIX only):
133
- With ``O_APPEND`` and a single ``write()`` syscall, the kernel
134
- appends the entire payload as one indivisible operation as long as
135
- the payload size is at most ``PIPE_BUF`` (4096 bytes on Linux and
136
- macOS). Larger payloads may be split across syscalls and interleave
137
- with concurrent writers, so this helper rejects them with
138
- ``ValueError`` rather than silently losing atomicity.
139
-
140
- The atomicity contract holds only on POSIX filesystems with O_APPEND
141
- semantics. Concurrent writers using ``O_TRUNC``, ``unlink``, or
142
- non-append modes against the same path break the contract and are
143
- out of scope for this helper.
144
-
145
- Caller responsibilities:
146
- - Keep records small (this module's stderr excerpt cap of
147
- ``STDERR_EXCERPT_MAX_BYTES`` exists to keep records well under
148
- ``PIPE_BUF_BYTES``).
149
- - Handle ``TypeError`` from ``json.dumps`` for non-serializable values.
150
-
151
- Creates parent directories as needed.
152
- """
153
- p = Path(path)
154
- p.parent.mkdir(parents=True, exist_ok=True)
155
- # ensure_ascii=False keeps UTF-8 compact (no \uXXXX escapes).
156
- # json.dumps escapes literal newlines inside string values, so the
157
- # only unescaped newline is the record separator we append below.
158
- line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
159
- data = line.encode("utf-8")
160
- if len(data) > PIPE_BUF_BYTES:
161
- raise ValueError(
162
- f"record too large for atomic append: {len(data)} bytes > "
163
- f"PIPE_BUF ({PIPE_BUF_BYTES})"
164
- )
165
- # mode 0o644: owner read/write, group/world read-only.
166
- fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
167
- try:
168
- os.write(fd, data)
169
- finally:
170
- os.close(fd)
171
-
172
-
173
- def append_observed(
174
- *,
175
- out_path,
176
- task_key,
177
- phase,
178
- agent,
179
- agent_role,
180
- model,
181
- error_type,
182
- command,
183
- command_kind,
184
- exit_code,
185
- duration_ms,
186
- message,
187
- stderr_excerpt,
188
- context,
189
- now=None,
190
- ):
191
- """Append a lead-observed error event to errors.jsonl."""
192
- if error_type not in ALLOWED_ERROR_TYPES:
193
- raise ValueError(f"invalid errorType: {error_type!r}")
194
- if agent not in ALLOWED_AGENTS:
195
- raise ValueError(f"invalid agent: {agent!r}")
196
- if agent_role not in ALLOWED_AGENT_ROLES:
197
- raise ValueError(f"invalid agentRole: {agent_role!r}")
198
- # Runs before append_jsonl_line so a rejected claim leaves no trace in the
199
- # log: a written-then-flagged record is still a record someone can cite.
200
- context = normalize_cause_context(context, message=message)
201
- ts = _iso(now or _now_utc())
202
- rec = {
203
- "ts": ts,
204
- "recordedAt": ts,
205
- "taskKey": task_key,
206
- "phase": str(phase),
207
- "agent": agent,
208
- "agentRole": agent_role,
209
- "model": model,
210
- "source": "lead-observed",
211
- "errorType": error_type,
212
- "command": command,
213
- "commandKind": command_kind,
214
- "exitCode": exit_code,
215
- "durationMs": duration_ms,
216
- "message": message,
217
- "stderrExcerpt": truncate_stderr(stderr_excerpt),
218
- "context": context,
219
- }
220
- append_jsonl_line(out_path, rec)
221
- return rec
222
-
223
-
224
- def dump_from_worker_sidecar(
225
- *,
226
- sidecar_path,
227
- out_path,
228
- task_key,
229
- agent,
230
- agent_role,
231
- model,
232
- now=None,
233
- ):
234
- """Read worker sidecar errors[] and append each to errors.jsonl with
235
- Lead-side metadata filled in. Returns number of records appended.
236
-
237
- Raises ValueError if:
238
- - ``agent`` or ``agent_role`` is not in the allow-lists
239
- - sidecar ``schemaVersion`` is not in ``SUPPORTED_SIDECAR_SCHEMA_VERSIONS``
240
- - any entry's ``errorType`` is not in ``ALLOWED_ERROR_TYPES``
241
- - any entry asserts a blocking cause without its required evidence
242
-
243
- Returns 0 (no-op) if the sidecar file does not exist or its
244
- ``errors`` list is empty.
245
-
246
- Partial-failure semantics: entries are validated and appended in
247
- order. If entry N fails validation, entries 0..N-1 have already
248
- been written to ``out_path`` and are NOT rolled back. Callers that
249
- require atomicity must validate the sidecar payload before invoking
250
- this function.
251
- """
252
- if agent not in ALLOWED_AGENTS:
253
- raise ValueError(f"invalid agent: {agent!r}")
254
- if agent_role not in ALLOWED_AGENT_ROLES:
255
- raise ValueError(f"invalid agentRole: {agent_role!r}")
256
- p = Path(sidecar_path)
257
- if not p.exists():
258
- return 0
259
- payload = json.loads(p.read_text())
260
- schema = payload.get("schemaVersion")
261
- if schema not in SUPPORTED_SIDECAR_SCHEMA_VERSIONS:
262
- raise ValueError(f"unsupported sidecar schemaVersion: {schema!r}")
263
- entries = payload.get("errors") or []
264
- recorded_at = _iso(now or _now_utc())
265
- count = 0
266
- for e in entries:
267
- et = e.get("errorType")
268
- if et not in ALLOWED_ERROR_TYPES:
269
- raise ValueError(f"invalid errorType in sidecar: {et!r}")
270
- entry_context = normalize_cause_context(
271
- e.get("context"), message=e.get("message")
272
- )
273
- rec = {
274
- "ts": e.get("ts"),
275
- "recordedAt": recorded_at,
276
- "taskKey": task_key,
277
- "phase": str(e.get("phase")) if e.get("phase") is not None else None,
278
- "agent": agent,
279
- "agentRole": agent_role,
280
- "model": model,
281
- "source": "worker-reported",
282
- "errorType": et,
283
- "command": e.get("command"),
284
- "commandKind": e.get("commandKind"),
285
- "exitCode": e.get("exitCode"),
286
- "durationMs": e.get("durationMs"),
287
- "message": e.get("message"),
288
- "stderrExcerpt": truncate_stderr(e.get("stderrExcerpt")),
289
- "context": entry_context,
290
- }
291
- append_jsonl_line(out_path, rec)
292
- count += 1
293
- return count
32
+ __all__ = [
33
+ "ALLOWED_AGENT_ROLES",
34
+ "ALLOWED_AGENTS",
35
+ "ALLOWED_CAUSES",
36
+ "ALLOWED_ERROR_TYPES",
37
+ "CAUSE_EVIDENCE_FIELDS",
38
+ "CAUSE_PROBE_MAX_BYTES",
39
+ "PIPE_BUF_BYTES",
40
+ "STDERR_EXCERPT_MAX_BYTES",
41
+ "SUPPORTED_SIDECAR_SCHEMA_VERSIONS",
42
+ "TRUNCATION_SUFFIX",
43
+ "append_jsonl_line",
44
+ "append_observed",
45
+ "dump_from_worker_sidecar",
46
+ "main",
47
+ "normalize_cause_context",
48
+ "truncate_stderr",
49
+ ]
294
50
 
295
51
 
296
52
  def _build_parser():
@@ -235,6 +235,15 @@ prompt/result/audit paths. The returned `promptPath` is the only body that may
235
235
  be dispatched; do not append role prose or reconstruct model headers after
236
236
  materialization.
237
237
 
238
+ If the dispatch gate then rejects that prompt, fix the task-instructions file
239
+ and re-run the same `materialize` call with `--replace-undispatched` — keep the
240
+ `--invocation-id`. A published prompt is otherwise immutable, so without that
241
+ flag the retry fails as `existing_invocation_conflict`; do NOT delete the
242
+ reservation under `prompts/.agent-invocations` and do NOT mint a second
243
+ invocation id to get around it, because both detach the audit chain from the
244
+ call it describes. The flag is checked: once any dispatch row names this
245
+ invocation, the prompt is history and the replacement is refused.
246
+
238
247
  Run `okstra agent-prompt verify --run-manifest <path> --metadata
239
248
  <metadataPath> --json` immediately before dispatch. A failed verification is a
240
249
  pre-dispatch contract failure. For `runner=native-session`, pass only the
@@ -261,7 +270,7 @@ Call `await_workers(handles)` through the same adapter and apply the shared term
261
270
 
262
271
  ### Required reverify-prompt anchor headers (BLOCKING)
263
272
 
264
- Every reverify prompt MUST start with these 9 anchor headers — in this exact order, before any other content:
273
+ Every reverify prompt MUST start with these 8 anchor headers — in this exact order, before any other content:
265
274
 
266
275
  ```
267
276
  **Project Root:** <absolute-path>
@@ -269,7 +278,6 @@ Every reverify prompt MUST start with these 9 anchor headers — in this exact o
269
278
  **Result Path:** runs/<task-type>/worker-results/<role-slug>-reverify-r<N>-<task-type>-<seq>.md
270
279
  **Audit sidecar path:** <absolute-path>
271
280
  Assigned worker prompt history path: <Project Root>/<Prompt History Path>
272
- **Model:** <role>, <modelExecutionValue>
273
281
  **Errors log path:** <absolute-path>
274
282
  **Errors sidecar path:** <absolute-path>
275
283
  **Read scope:** Read only the paths this prompt enumerates (`[Required reading]`, `## Inputs`, verification-target paths) plus source/evidence paths a finding must cite. Host session instructions (SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs) do NOT apply inside an okstra worker run: do not auto-read `graphify-out/`, `SKILL.md`, or other artifacts outside `<PROJECT_ROOT>/.okstra/`. If an un-enumerated file seems essential, record it under *Missing Information or Assumptions* instead of reading it.
@@ -281,7 +289,9 @@ Before dispatch, materialize `**Audit sidecar path:**` by passing the exact reve
281
289
 
282
290
  The two errors paths carry the same absolute values the lead forwarded in the initial Phase 4 dispatch for that role (source: the launch prompt's `## Run Logs (error-log wiring)` section). Omitting either one makes `worker-dispatch` reject the CLI invocation before it starts the provider process — the path-delivery contract in [team-contract](./team-contract.md) "Error reporting" is not relaxed for reverify.
283
291
 
284
- Relative to the Phase 4 anchor set rendered by `okstra_ctl.worker_prompt_headers.worker_prompt_headers()`, a reverify prompt adds `**Model:**` and drops two anchors whose targets lightweight mode never reads: `**Worker Preamble Path:**` and `**Coding preflight pack:**`.
292
+ Relative to the Phase 4 anchor set rendered by `okstra_ctl.worker_prompt_headers.worker_prompt_headers()`, a reverify prompt drops two anchors whose targets lightweight mode never reads: `**Worker Preamble Path:**` and `**Coding preflight pack:**`.
293
+
294
+ **Where the composer's sections go.** `okstra agent-prompt materialize` (§"Invocation materialization gate") writes the dispatched body itself, as: these anchors, then the model-assignment block it appends (`**Provider:**`, `**Model:**`, `**Model execution value:**`, `**Runner:**`, `**Host runtime:**`, and `**Host model value:**` for a native host), then `## Duty Contract`, then `## Task Instructions` followed verbatim by the task-instructions file the lead wrote. So the lead authors only the last part, and every rule below about ordering — the phase boundary before the instruction headings, the `**Model:** <role>, <modelExecutionValue>` line — is about the lead's own file, not about the composed document. The composer's `**Model:** <modelExecutionValue>` anchor is a different line with a different shape; do not try to reshape it, and do not count it among the 8.
285
295
 
286
296
  For an `antigravity` assignment, append the exact `PLAIN_FILE_WRITE_HEADER`
287
297
  value from `okstra_ctl.worker_prompt_headers` immediately after
@@ -290,20 +300,28 @@ persisted initial Phase 4 prompt; do not paraphrase or reconstruct it. If the
290
300
  persisted initial prompt does not contain that generated header, abort the
291
301
  reverify dispatch and record a `contract-violation` event instead of
292
302
  dispatching without the plain-file safeguard. This provider-specific header is
293
- outside the common 9-header count above. Other providers do not receive it.
303
+ outside the common 8-header count above. Other providers do not receive it.
294
304
 
295
305
  The rationale for both drops is §"Reverify prompt: required-reading suppression" below.
296
306
 
297
- Immediately after the anchor headers (and the provider-specific plain-file
298
- header when present), copy this phase-boundary block before any reverify
299
- instructions:
307
+ The task-instructions file the lead writes MUST open with this block, before any
308
+ `##` heading of its own:
300
309
 
301
310
  ```markdown
311
+ **Model:** <role>, <modelExecutionValue>
302
312
  **Task Type:** <task-manifest taskType>
303
313
  **Forbidden actions:**
304
314
  <active-run-context workflow.forbiddenActions, verbatim>
305
315
  ```
306
316
 
317
+ This is the same placement `okstra_ctl.worker_prompt_body` uses for an initial
318
+ Phase 4 prompt, and it is where the checks look: `validate_reverify_prompt()`
319
+ reads the region after `## Task Instructions`, so a `**Model:**` line left in
320
+ the anchors is invisible to it, and a phase boundary written below the file's
321
+ first heading fails. Both rules are about this file — the composer's own
322
+ `## Duty Contract` heading sits above everything here and is not what they
323
+ measure against.
324
+
307
325
  Do not summarize, shorten, or reconstruct the forbidden-actions text. The
308
326
  selected adapter validates the task type and exact block through
309
327
  `okstra_ctl.worker_prompt_contract.validate_reverify_prompt()` before starting
@@ -587,6 +605,34 @@ persisted assignment or either model value required by its runner is absent,
587
605
  record `critic-skipped: model-unresolved`; never resolve a replacement model.
588
606
  Result path: `runs/<task-type>/worker-results/<provider>-worker-critic-<task-type>-<seq>.md`.
589
607
 
608
+ **What the critic task-instructions file MUST contain (BLOCKING).** A critic
609
+ dispatch is not a reverify dispatch: `dispatchKind = "critic"` keeps
610
+ `audience = "analysis"`, so `worker_prompt_contract.validate_initial_prompts`
611
+ judges it by the full initial-analysis contract. Two of those requirements are
612
+ satisfied by the generated body for a Phase 4 worker
613
+ (`okstra_ctl.worker_prompt_body`) and by nothing at all for a critic, whose
614
+ instructions the lead writes — the materializer's anchor block supplies neither.
615
+ Put both in the instructions file:
616
+
617
+ ```markdown
618
+ **Prompt Delivery Mode:** eager-include
619
+ ```
620
+
621
+ and, under the file's `## Inputs`, exactly one line in this shape — the literal
622
+ label and the backticks are what the check matches, so a bare path or a
623
+ differently-worded label counts as zero:
624
+
625
+ ```markdown
626
+ - Primary analysis packet: `<path ending in analysis-packet.md>`
627
+ ```
628
+
629
+ Omitting either one fails `okstra team dispatch --dispatch-kind critic` before
630
+ any process starts, reported as `<task-type> prompt contract: <worker>: exactly
631
+ one Primary analysis packet path is required (found 0)` and `exactly one
632
+ non-empty **Prompt Delivery Mode:** header is required`. Fix the instructions
633
+ file and re-materialize with `--replace-undispatched` (§"Invocation
634
+ materialization gate") rather than editing the published prompt.
635
+
590
636
  The `-worker-` token is load-bearing, not decoration: the critic prompt carries the same generated anchor headers as every other worker ([team-contract](./team-contract.md) §"Worker prompts"), and its `**Audit sidecar path:**` comes from passing that result path through `okstra_ctl.worker_artifact_paths.audit_sidecar_rel()`, which inserts `-audit-` after the token and raises without it. A `<provider>-critic-...` name leaves the lead choosing between breaking the contract and hand-inventing the sidecar name. Note that `originWorker` stays `"<provider>-critic"` — that is a worker id in the convergence state, not a filename, and the two do not have to match.
591
637
 
592
638
  The critic prompt carries the full analysis contract those anchor headers belong to — worker preamble, error-contract path, audit sidecar, packet boundary — but it is **exempt from the initial-analysis equality group**. Initial analysis workers must receive byte-identical normalized bodies (`worker_prompt_contract.validate_analysis_prompt_set`), and a critic body is deliberately unlike them, so `okstra_ctl.worker_prompt_policy.resolve_prompt_plan` resolves `dispatchKind = "critic"` to `equality_group = None` while keeping `audience = "analysis"`. Never reshape a critic prompt to match the initial one to satisfy that check — matching it would delete the pass. **Enforced:** `tests/contract/test_critic_prompt_equality_exemption.py` pins both directions — the critic is exempt, and two mismatched *initial* prompts still fail.
@@ -270,7 +270,7 @@ okstra error-log append-from-worker \
270
270
 
271
271
  `--agent`, `--agent-role`, and `--error-type` are **closed enums**, not free-form labels — the role names used elsewhere in these contracts (`Codex worker`, `Claude worker`) are rejected. Use exactly:
272
272
 
273
- - `--agent` — `claude-worker` | `codex-worker` | `antigravity-worker` | `report-writer`
273
+ - `--agent` — `claude-worker` | `codex-worker` | `antigravity-worker` | `grok-worker` | `kimi-worker` | `report-writer`
274
274
  - `--agent-role` — `lead` | `worker` | `report-writer`
275
275
  - `--error-type` — `cli-failure` | `contract-violation` | `tool-failure`
276
276
 
@@ -125,6 +125,9 @@ class AgentInvocationRequest:
125
125
  prompt_path: Path
126
126
  metadata_path: Path
127
127
  dispatch_kind: str
128
+ # Republish a prompt this invocation id already wrote, but only while no
129
+ # dispatch has used it. See `_publish_or_reuse`.
130
+ replace_undispatched: bool = False
128
131
 
129
132
 
130
133
  @dataclass(frozen=True)
@@ -254,13 +257,13 @@ def prepare_agent_invocation(
254
257
  materialized = _materialize(request)
255
258
  if materialized.reservation_root is None:
256
259
  with _exclusive_lock(_prompt_lock_path(request.prompt_path)):
257
- _publish_or_reuse(materialized)
260
+ _publish_or_reuse(request, materialized)
258
261
  else:
259
262
  reservation_lock = materialized.reservation_root / "publish.lock"
260
263
  with _exclusive_lock(reservation_lock):
261
264
  _publish_or_reuse_reservation(materialized)
262
265
  with _exclusive_lock(_prompt_lock_path(request.prompt_path)):
263
- _publish_or_reuse(materialized)
266
+ _publish_or_reuse(request, materialized)
264
267
  errors = verify_agent_invocation(
265
268
  request.metadata_path,
266
269
  project_root=request.project_root,
@@ -889,7 +892,9 @@ def _publish_or_reuse_reservation(materialized: _MaterializedInvocation) -> None
889
892
  _publish_exclusive(path, _pretty_json(reservation))
890
893
 
891
894
 
892
- def _publish_or_reuse(materialized: _MaterializedInvocation) -> None:
895
+ def _publish_or_reuse(
896
+ request: AgentInvocationRequest, materialized: _MaterializedInvocation
897
+ ) -> None:
893
898
  prompt = materialized.prepared.prompt_path
894
899
  metadata = materialized.prepared.metadata_path
895
900
  prompt_exists = prompt.exists()
@@ -897,7 +902,8 @@ def _publish_or_reuse(materialized: _MaterializedInvocation) -> None:
897
902
  if metadata_exists and not prompt_exists:
898
903
  raise _existing_conflict("metadata exists without prompt")
899
904
  if prompt_exists and prompt.read_bytes() != materialized.prompt_bytes:
900
- raise _existing_conflict("existing prompt differs")
905
+ _replace_undispatched(request, materialized)
906
+ return
901
907
  if prompt_exists and metadata_exists:
902
908
  if metadata.read_bytes() != materialized.metadata_bytes:
903
909
  raise _existing_conflict("existing metadata differs")
@@ -907,6 +913,73 @@ def _publish_or_reuse(materialized: _MaterializedInvocation) -> None:
907
913
  _publish_exclusive(metadata, materialized.metadata_bytes)
908
914
 
909
915
 
916
+ def _replace_undispatched(
917
+ request: AgentInvocationRequest, materialized: _MaterializedInvocation
918
+ ) -> None:
919
+ """Rewrite a prompt this invocation id wrote but no dispatch ever used.
920
+
921
+ Immutability protects the audit chain: a prompt that a worker ran must keep
922
+ reading the way it ran. A prompt that failed a pre-dispatch gate ran
923
+ nowhere, and there the same rule cost a lead its only clean way forward —
924
+ fix the instruction file and the republish is refused, so the remaining
925
+ moves were to delete the reservation by hand or to burn a second invocation
926
+ id and blur which call is which. Both damage the chain this protects.
927
+
928
+ The exit is explicit (`--replace-undispatched`) and it is checked, not
929
+ trusted: a single recorded dispatch against this invocation id — worker or
930
+ agent — puts the prompt back under the original rule.
931
+ """
932
+ if not request.replace_undispatched:
933
+ raise _existing_conflict("existing prompt differs")
934
+ dispatched = _recorded_dispatch_ids(request)
935
+ if dispatched:
936
+ raise _existing_conflict(
937
+ "existing prompt differs and this invocation was already "
938
+ f"dispatched as {', '.join(sorted(dispatched))}"
939
+ )
940
+ _publish_exclusive(
941
+ materialized.prepared.prompt_path, materialized.prompt_bytes, replace=True
942
+ )
943
+ _publish_exclusive(
944
+ materialized.prepared.metadata_path,
945
+ materialized.metadata_bytes,
946
+ replace=True,
947
+ )
948
+
949
+
950
+ def _recorded_dispatch_ids(request: AgentInvocationRequest) -> set[str]:
951
+ """Dispatch rows in team-state that already reference this invocation id.
952
+
953
+ Both ledgers are read: `agentDispatches` is written by the host-native
954
+ spec-link gate, `workerDispatches` by the deterministic worker dispatch, and
955
+ a prompt used by either one is history.
956
+ """
957
+ if request.run_manifest_path is None:
958
+ return set()
959
+ manifest = _load_json_object(request.run_manifest_path, "run manifest")
960
+ team_state_value = manifest.get("teamStatePath")
961
+ if not isinstance(team_state_value, str) or not team_state_value.strip():
962
+ return set()
963
+ team_state_path = _project_path(
964
+ request.project_root, team_state_value, must_exist=False
965
+ )
966
+ team_state = _read_json_if_object(team_state_path)
967
+ if team_state is None:
968
+ return set()
969
+ found: set[str] = set()
970
+ for key in ("agentDispatches", "workerDispatches"):
971
+ rows = team_state.get(key)
972
+ if not isinstance(rows, list):
973
+ continue
974
+ for row in rows:
975
+ if not isinstance(row, Mapping):
976
+ continue
977
+ if row.get("invocationId") != request.invocation_id:
978
+ continue
979
+ found.add(str(row.get("dispatchId") or request.invocation_id))
980
+ return found
981
+
982
+
910
983
  def _existing_conflict(detail: str) -> AgentInvocationError:
911
984
  return AgentInvocationError(
912
985
  f"existing_invocation_conflict: {detail}",
@@ -930,7 +1003,7 @@ def _prompt_lock_path(prompt_path: Path) -> Path:
930
1003
  return prompt_path.with_name(prompt_path.name + ".publish.lock")
931
1004
 
932
1005
 
933
- def _publish_exclusive(path: Path, body: bytes) -> None:
1006
+ def _publish_exclusive(path: Path, body: bytes, *, replace: bool = False) -> None:
934
1007
  path.parent.mkdir(parents=True, exist_ok=True)
935
1008
  temp_path: Path | None = None
936
1009
  try:
@@ -944,7 +1017,14 @@ def _publish_exclusive(path: Path, body: bytes) -> None:
944
1017
  handle.write(body)
945
1018
  handle.flush()
946
1019
  os.fsync(handle.fileno())
947
- os.link(temp_path, path)
1020
+ # `link` is what makes a first publish exclusive — it fails rather than
1021
+ # overwrite. A sanctioned replacement wants the opposite and still needs
1022
+ # to be atomic, so it swaps the same fully-written temp file into place.
1023
+ if replace:
1024
+ os.replace(temp_path, path)
1025
+ temp_path = None
1026
+ else:
1027
+ os.link(temp_path, path)
948
1028
  except FileExistsError as exc:
949
1029
  raise _existing_conflict(f"published path already exists: {path}") from exc
950
1030
  finally:
@@ -71,6 +71,13 @@ def _parser() -> argparse.ArgumentParser:
71
71
  materialize.add_argument("--model-role")
72
72
  materialize.add_argument("--model", default="")
73
73
  materialize.add_argument("--purpose")
74
+ materialize.add_argument(
75
+ "--replace-undispatched",
76
+ action="store_true",
77
+ help="rewrite a prompt this invocation id already wrote, allowed only "
78
+ "while no dispatch has referenced it — the exit for a prompt that "
79
+ "failed a pre-dispatch gate and never ran",
80
+ )
74
81
  materialize.add_argument("--json", action="store_true")
75
82
 
76
83
  verify = commands.add_parser("verify")
@@ -329,6 +336,7 @@ def _materialize_run(
329
336
  prompt_path=prompt_path,
330
337
  metadata_path=prompt_path.with_name(prompt_path.name + ".meta.json"),
331
338
  dispatch_kind=args.dispatch_kind,
339
+ replace_undispatched=args.replace_undispatched,
332
340
  ))
333
341
 
334
342