okstra 0.186.0 → 0.186.2

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.
@@ -887,7 +887,7 @@ Entry points:
887
887
  - Python routing references: `scripts/okstra_ctl/report_views.py` owns the version split; `scripts/okstra_ctl/report_html/router.py` maps all v2 task types to dedicated view-model builders and templates. The HTML JavaScript response serializer remains byte-identical to Python `serialize_user_response`.
888
888
  - CLI: `scripts/okstra-render-report-views.py <final-report.data.json|final-report.md>` or delegated Node wrapper `bin/okstra render-views <path>`.
889
889
  - Validation: `validators/validate-report-views.py` checks schema/source/template digests, required human fields, form controls, external assets, SVG/table ID parity, and Response ID parity. The v2 renderer rejects an unknown task type instead of falling back to a generic page.
890
- - User-response sidecar schema source of truth: `templates/reports/user-response.template.md`. The in-session skill reads `okstra user-response show-view`, then the cited `path:line` artifacts and linked plan items that view lists, before asking. The recorded answer still comes from `options[]` via `--option-number`. Task pick, each clarification, and the record confirmation use the current host's native question tool from the relay `native-single` mapping (`AskUserQuestion`, `ask_user_question`, or `request_user_input`). A numbered list is only the fallback when that tool is missing or the option count misses `nativeLimits`.
890
+ - User-response sidecar schema source of truth: `templates/reports/user-response.template.md`. The in-session skill reads `okstra user-response show-view`, then the cited `path:line` artifacts and linked plan items that view lists, before asking. The recorded answer still comes from `options[]` via `--option-number`. Task pick, each clarification, and the record confirmation use the current host's native question tool from the relay `native-single` mapping (`AskUserQuestion`, `ask_user_question`, or `request_user_input`). The picker shows those choices only — it does not append `Enter directly`; custom text uses the host free-text row. A numbered list is only the fallback when that tool is missing or the option count misses `nativeLimits`.
891
891
  - In-run user confirmation before an approval blocker follows the same briefing order and the same host picker: the lead investigates cited plan items, worker findings, and files, then asks through the adapter `prompt_user` mapping (`prompts/lead/okstra-lead-contract.md`).
892
892
 
893
893
  Generating a view never modifies the original final-report MD.
@@ -221,7 +221,7 @@ Inform the user on one line.
221
221
  Took over as Okstra lead (`<host-runtime>`) for `<taskKey>` (`<task-type>`). Run dir: `<RUN_DIR_RELATIVE_PATH>`. Beginning Phase 1 (context loading).
222
222
  ```
223
223
 
224
- For a single-element chain, the end of Step 6 is the end of the run. Step 7 below applies only when the `chain-stages` CSV has 2 or more elements. After an `implementation-planning` run, if `workflow.awaitingApproval` is true, tell the user to approve the plan; do not start another planning run. If the pointer is `blocked`, send the user to `okstra-user-response` first.
224
+ For a single-element chain, the end of Step 6 is the end of the run. Step 7 below applies only when the `chain-stages` CSV has 2 or more elements. When the run is over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`: open or answered approval blockers `/okstra-user-response`; awaiting approval `/okstra-run` → `implementation` or `--approve` (do not start another planning run); `validate-run` failed one-line cause then `/okstra-run` or `/okstra-inspect recap`; pointer `ready` → `/okstra-run` for that phase.
225
225
 
226
226
  ## implementation unattended chaining (chain-stages)
227
227
 
@@ -27,8 +27,8 @@ The legacy `list` and `show` JSON commands remain for automation compatibility.
27
27
  ## Flow
28
28
 
29
29
  1. Run `okstra preflight --runtime <host-runtime>` for the current harness. On `Okstra preflight: failed`, show `Reason` and `Recovery`, then stop. On `Okstra preflight: ready`, carry `Project root`, `Project ID`, `Runtime`, and `Relay contract`, then run `okstra paths --field home`.
30
- 2. Read the relay `Wizard interaction relay` JSON. When `native-single` is available and the option count fits `nativeLimits`, call `interactions.native-single.function` (`AskUserQuestion` / `ask_user_question` / `request_user_input` from that field). Do not print a numbered list in chat while the native tool is available. Otherwise render a numbered Markdown list. Do not substitute one host function name for another.
31
- 3. Select a task from `list-view` through that host picker.
30
+ 2. Read the relay `Wizard interaction relay` JSON. When `native-single` is available and the option count fits `nativeLimits`, call `interactions.native-single.function` (`AskUserQuestion` / `ask_user_question` / `request_user_input` from that field). Do not print a numbered list in chat while the native tool is available. Otherwise render a numbered Markdown list. Do not substitute one host function name for another. Pass only the choices this step already owns. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row are `Enters an answer`; on a numbered list, so is a next message that is not a listed label or its 1-based number. Do not ask a second question for the custom value.
31
+ 3. Select a task from `list-view` through that host picker. A host free-text row or unmatched next message is the report path or task key.
32
32
  4. Read only `show-view --report <reportPath> --project-root <projectRoot>` for report facts. The view also prints `Why asked`, `Linked plan items`, and `Cited artifacts`.
33
33
  5. Read every cited `path:line` under the project root and every linked plan-item definition before asking. Do not search beyond that list. Investigation explains; it never changes `options[]`.
34
34
  6. Ask one open clarification at a time in the user's language through the host picker. The question body is why, what is already decided, the fork, and what stays blocked. Do not lead with `Kind`, `Blocks`, `Expected form`, or `C-NNN`. Keep the row id in parentheses at the end.
@@ -277,6 +277,7 @@ Important modules:
277
277
  | `report_assembly.py` | Validates the role-owned report inputs and publishes the contract 3.0 record once (assembled in a temp file, then atomically promoted); a bad input aborts with owner / artifact path / field path / reason and preserves the existing `data.json` |
278
278
  | `report_projections.py` | Pure projections turning role-owned execution inputs (agent activity, execution status, convergence, design, token usage) into canonical final-report fragments |
279
279
  | `report_narrative.py` | Lossless read/write contract for the report-writer-owned narrative Markdown (`report-narrative-<task-type>-<seq>.md`) — the only artifact the report-writer authors under contract 3.0 |
280
+ | `report_synthesis_packet.py` | Builds the frozen, read-only synthesis input packet handed to the report-writer — collects each source's label / owner / path / content digest into `report-writer-synthesis-packet-<task-type>-<seq>.{json,md}`, materialized at dispatch by `initial_prompt_materialization.py` so the writer synthesizes from a byte-stable snapshot. `report_assembly.py` calls `verify_report_synthesis_packet_sources` at publish time so a drifted source aborts the record; schema is `schemas/report-synthesis-packet-v1.0.schema.json` |
280
281
  | `approval_decisions.py` | Lead-owned approval-decision input ledger — `disposition` (`select`/`accept-risk`/`request-revision`/`reject`), reach, scope effect, and classification invariants (`correctness-critical`/`noncritical-dissent`/`user-decision`) |
281
282
  | `design_snapshot.py` | Builds the design-surface-detector-owned snapshot from the report narrative — reproducible design surfaces plus conservative `PREP-NNN` preparation items (delegates surface detection to `design_surfaces.py`) |
282
283
  | `report_markdown.py` | Schema-ordered Markdown serialisation of a data.json subtree for the full reading copy — headings, tables for uniform row sets, prose for narrative fields; field order read from the schema, not from the mapping |
@@ -696,4 +697,4 @@ Clarifications now live in the unified `## 1. Clarification Items` table. Deprec
696
697
 
697
698
  ---
698
699
 
699
- *Updated: 2026-08-23 · Source of truth checked against `package.json`, `bin/okstra`, `src/cli-registry.mts`, `src/lib/skill-catalog.mts`, `tools/build.mjs`, `scripts/`, `skills/`, `agents/`, `templates/`, `schemas/`, `validators/`, and tests.*
700
+ *Updated: 2026-08-24 · Source of truth checked against `package.json`, `bin/okstra`, `src/cli-registry.mts`, `src/lib/skill-catalog.mts`, `tools/build.mjs`, `scripts/`, `skills/`, `agents/`, `templates/`, `schemas/`, `validators/`, and tests.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.186.0",
3
+ "version": "0.186.2",
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.186.0",
3
- "builtAt": "2026-08-23T16:32:26.938Z",
2
+ "package": "0.186.2",
3
+ "builtAt": "2026-08-24T02:07:41.323Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -425,7 +425,19 @@ jq -s 'group_by(.errorType) | map({type: .[0].errorType, count: length})' <runDi
425
425
 
426
426
  The errors log is informational. Its presence/absence does not affect the final verdict. Do not block report writing on it.
427
427
 
428
- After persistence, reply briefly in the resolved Report Language with: completion status, the human report path, the report record path, team-state path, validator result, resume command path, any remaining blocker. For `implementation-planning` that left `workflow.awaitingApproval` true, the first sentence after the task identity tells the user to approve this plan (`okstra-run` → `implementation`, which asks `approve_plan_confirm`, or `--approve`) and does not propose another planning run. For a `blocked` pointer after planning, the first sentence names the open `C-NNN` ids and sends the user to `okstra-user-response`; do not start implementation and do not re-run planning until those answers exist. **Lead this reply with the run's task identity** — state `<task-group>/<task-id>` (or the full `taskKey`) first, so the reader knows which task the reply is about. **Every run-artifact path in this reply MUST be task-qualified** — report the human report as `.okstra/tasks/<task-group>/<task-id>/runs/<task-type>/reports/final-report-<task-type>-<seq>.html` rooted at the task bundle, NOT the bare `runs/<task-type>/reports/...` form (byte-for-byte identical across every task of the same task-type, so it cannot identify the task). Under that, cite the report record (`.data.json`) and one line to render the full reading copy: `okstra render-final-report <task-qualified data.json>`. The same task-qualified rule applies to the team-state path, resume command path, and any other run-artifact path this reply cites.
428
+ After persistence, reply briefly in the resolved Report Language. **Lead this reply with the run's task identity** — state `<task-group>/<task-id>` (or the full `taskKey`) first. Then: completion status, the task-qualified human report path, the report record path, validator result, any remaining blocker. **Close with the user's next action** one command they can run now. A prohibition (`do not start implementation`) is not a next action. A status dump is not a close.
429
+
430
+ Pick the next action from this table; the first matching row wins:
431
+
432
+ - Open or answered `blocks: approval` rows → `/okstra-user-response` (name the `C-NNN` ids). Do not start implementation and do not re-run planning until those answers exist.
433
+ - `workflow.awaitingApproval` is true → `/okstra-run` → `implementation` (asks `approve_plan_confirm`) or `--approve`. Do not propose another planning run.
434
+ - Phase 7 `validate-run` failed → one line naming the blocking cause, then `/okstra-run` to re-run this phase with the recorded sidecar, or `/okstra-inspect recap` if the resume flags are unknown.
435
+ - Pointer `status: ready` → `/okstra-run` for that `phase`.
436
+ - Otherwise → `/okstra-inspect status` for this task.
437
+
438
+ When the host native picker is available and two of those rows could apply, ask with that picker (recommended first). Do not end the turn after the status dump.
439
+
440
+ **Every run-artifact path in this reply MUST be task-qualified** — report the human report as `.okstra/tasks/<task-group>/<task-id>/runs/<task-type>/reports/final-report-<task-type>-<seq>.html` rooted at the task bundle, NOT the bare `runs/<task-type>/reports/...` form (byte-for-byte identical across every task of the same task-type, so it cannot identify the task). Under that, cite the report record (`.data.json`) and one line to render the full reading copy: `okstra render-final-report <task-qualified data.json>`. The same task-qualified rule applies to the team-state path, resume command path, and any other run-artifact path this reply cites.
429
441
 
430
442
  ## Run-scoped worker-resource lifecycle
431
443
 
@@ -195,7 +195,7 @@ roles:
195
195
  - dependency / migration risk assessment (ordering constraints, data backfills, feature-flag prerequisites, repo-internal sequencing)
196
196
  - **Cross-Project Dependencies (conditionally required):** when the plan depends on work in another project / repo / published package, add (a) a `kind: cross-project` DM row to `dependencyMigrationRisk`, and (b) a matching `XP-NNN` row to `crossProjectDependencies`. An upstream-precondition row must have concrete `requiredWork` / `verificationSignal` / `howToStart` — `validators/validate-run.py` enforces that a DM `cross-project` ⇒ at least one `direction: upstream-precondition` XP row, and the schema enforces non-empty row fields. A cross-project dependency is recorded as this structured precondition, not as a soft Recommended Next Step. A single-project plan uses an empty array.
197
197
  - **recommendedNextSteps policy:** keep the substance of cross-project preconditions/carries in `crossProjectDependencies`, and put in `§3 Recommended Next Steps` only a pointer to that section (`§5.4 Cross-Project Dependencies`) — no double recording.
198
- - **Resuming from an approval blocker (BLOCKING).** When this report carries any `blocks: approval` clarification, one `recommendedNextSteps` entry MUST name the `--answered-clarifications` re-run, in its `text` or in one of its `commands`. The reader of the report is the person who has to act, and until now the mechanics of resuming lived only in the lead prompt which is read after the next run has already started. Point them at `okstra recap assemble`, which prints the answered ids, the exact flag value, the sidecar paths, and whether the re-verification would fall back to full. Do not restate those values here: they are unknown while you write, because the user has not answered yet. The first human action is answering those rows (`okstra-user-response`); do not tell the reader to start another planning run before the answers exist. **Enforced:** `validators/validate-run.py` `_validate_rerun_guidance`.
198
+ - **Resuming from an approval blocker (BLOCKING).** When this report carries an unresolved (`open` or `answered`) `blocks: approval` clarification, `recommendedNextSteps[0]` MUST be a command the reader can run now: `/okstra-user-response`, and the `--answered-clarifications` re-run in that step's `text` or `commands`. Do not write "The Okstra lead will …" as the first step. Point them at `okstra recap assemble`, which prints the answered ids, the exact flag value, the sidecar paths, and whether the re-verification would fall back to full. Do not restate those values here: they are unknown while you write, because the user has not answered yet. Do not tell the reader to start another planning run before the answers exist. **Enforced:** `validators/validate-run.py` `_validate_rerun_guidance`.
199
199
  - **Asking for approval (BLOCKING).** When `outcome` is `plan-ready`, the gate is `passed` or `passed-with-dissent`, and no `blocks: approval` row is open, one `recommendedNextSteps` entry MUST tell the reader to approve (`--approve` or the in-session wizard). Do not recommend another `implementation-planning` run. **Enforced:** `validators/validate-run.py` `_validate_approval_guidance`.
200
200
  - validation checklist (pre / mid / post) — each item is an exact command or observable outcome
201
201
  - rollback strategy — exact revert path (commits, flags, migrations) and the signal that triggers rollback
@@ -181,6 +181,84 @@ def _stage_rows(planning: Mapping[str, Any]) -> dict[int, Mapping[str, Any]]:
181
181
 
182
182
 
183
183
  _LINE_RANGE_SUFFIX = re.compile(r":\d+(?:-\d+)?$")
184
+ _PAREN_SUFFIX = re.compile(r"\s*\([^)]*\)\s*$")
185
+
186
+
187
+ def _split_declared_paths(raw: str) -> list[str]:
188
+ """쉼표·줄바꿈으로 이어진 경로를 파일 단위로 나눈다. `{a,b}` 안 쉼표는 유지.
189
+
190
+ `fileStructure.path` 는 원래 열 수 있는 경로 하나인데, 계획이 여러 파일을
191
+ 한 칸에 이어 쓰면 추출기가 그 문자열 전체를 한 경로로 보고 스테이지 매핑에
192
+ 실패한다.
193
+ """
194
+ parts: list[str] = []
195
+ buf: list[str] = []
196
+ depth = 0
197
+ for char in raw:
198
+ if char == "{":
199
+ depth += 1
200
+ buf.append(char)
201
+ elif char == "}":
202
+ depth = max(0, depth - 1)
203
+ buf.append(char)
204
+ elif char in ",\n" and depth == 0:
205
+ token = "".join(buf).strip()
206
+ if token:
207
+ parts.append(token)
208
+ buf = []
209
+ else:
210
+ buf.append(char)
211
+ token = "".join(buf).strip()
212
+ if token:
213
+ parts.append(token)
214
+ return parts
215
+
216
+
217
+ def _step_files_cells(step: Mapping[str, Any]) -> list[str]:
218
+ raw = step.get("files")
219
+ if isinstance(raw, list):
220
+ return [_normalise(item) for item in raw if item]
221
+ text = _normalise(raw)
222
+ return [text] if text else []
223
+
224
+
225
+ def _files_cell_tokens(cell: str) -> list[str]:
226
+ tokens: list[str] = []
227
+ for part in _split_declared_paths(cell) or [cell]:
228
+ token = _LINE_RANGE_SUFFIX.sub("", part)
229
+ token = _PAREN_SUFFIX.sub("", token).strip()
230
+ if token:
231
+ tokens.append(token)
232
+ return tokens
233
+
234
+
235
+ def _cell_covers_path(cell: str, path: str) -> bool:
236
+ path_pattern = rf"(?<![\w./@-]){re.escape(path)}(?![\w./@-])"
237
+ if re.search(path_pattern, cell) is not None:
238
+ return True
239
+ for token in _files_cell_tokens(cell):
240
+ if token == path:
241
+ return True
242
+ if "*" in token and fnmatch.fnmatch(path, token):
243
+ return True
244
+ return False
245
+
246
+
247
+ def _stages_touching_path(
248
+ path: str,
249
+ stages: Mapping[int, Mapping[str, Any]],
250
+ ) -> list[int]:
251
+ hits: list[int] = []
252
+ for stage_number, stage in stages.items():
253
+ cells = [
254
+ cell
255
+ for step in stage.get("stepwiseExecution") or []
256
+ if isinstance(step, Mapping)
257
+ for cell in _step_files_cells(step)
258
+ ]
259
+ if any(_cell_covers_path(cell, path) for cell in cells):
260
+ hits.append(stage_number)
261
+ return hits
184
262
 
185
263
 
186
264
  def _stages_for_selected_path(
@@ -200,21 +278,27 @@ def _stages_for_selected_path(
200
278
  Zero stages is still an error: the recommended option declares a file that
201
279
  no step creates or edits.
202
280
  """
203
- normalised = _LINE_RANGE_SUFFIX.sub("", _normalise(path))
204
- path_pattern = rf"(?<![\w./@-]){re.escape(normalised)}(?![\w./@-])"
205
- matched = []
206
- for stage_number, stage in stages.items():
207
- files = [
208
- _normalise(step.get("files"))
209
- for step in stage.get("stepwiseExecution") or []
210
- if isinstance(step, Mapping)
211
- ]
212
- if any(re.search(path_pattern, cell) is not None for cell in files):
213
- matched.append(stage_number)
214
- if not matched:
281
+ declared = [
282
+ _LINE_RANGE_SUFFIX.sub("", _normalise(part))
283
+ for part in (_split_declared_paths(path) or [path])
284
+ ]
285
+ declared = [part for part in declared if part]
286
+ if not declared:
215
287
  raise DesignSurfaceError(
216
288
  f"selected option path {path!r} is not mapped to a stage"
217
289
  )
290
+ matched: list[int] = []
291
+ seen: set[int] = set()
292
+ for part in declared:
293
+ hits = _stages_touching_path(part, stages)
294
+ if not hits:
295
+ raise DesignSurfaceError(
296
+ f"selected option path {part!r} is not mapped to a stage"
297
+ )
298
+ for stage_number in hits:
299
+ if stage_number not in seen:
300
+ seen.add(stage_number)
301
+ matched.append(stage_number)
218
302
  return matched
219
303
 
220
304
 
@@ -243,12 +327,13 @@ def detect_design_surfaces(
243
327
  continue
244
328
  path = str(row.get("path") or "")
245
329
  for stage_number in _stages_for_selected_path(path, stages):
246
- for kind, evidence in _matching_rule_evidence(
247
- step=None,
248
- field="files",
249
- value=path,
250
- ):
251
- grouped.setdefault((stage_number, kind), []).append(evidence)
330
+ for declared in _split_declared_paths(path) or [path]:
331
+ for kind, evidence in _matching_rule_evidence(
332
+ step=None,
333
+ field="files",
334
+ value=declared,
335
+ ):
336
+ grouped.setdefault((stage_number, kind), []).append(evidence)
252
337
  rule_order = {rule.kind: index for index, rule in enumerate(RULES)}
253
338
  return [
254
339
  DesignSurfaceTrigger(stage, kind, tuple(grouped[(stage, kind)]))
@@ -6,25 +6,14 @@ from typing import Any, Mapping, Sequence
6
6
  from .worker_prompt_policy import PromptPlan
7
7
 
8
8
 
9
- _ANALYSIS_WORKER_LABELS = {
10
- "claude": "Claude worker",
11
- "codex": "Codex worker",
12
- "antigravity": "Antigravity worker",
13
- }
14
-
15
-
16
9
  def analysis_worker_label(worker_id: str) -> str:
17
10
  """The role label this worker's prompt body is titled with.
18
11
 
19
- The map above only supplies display capitalization for the three providers
20
- that predate it; every other worker id (`grok`, `kimi`, a user-installed
21
- adapter) takes the id itself. Both branches are identity deltas the equality
22
- group must normalize away, so `worker_prompt_contract` erases exactly what
23
- this function returns rather than restating the map — an enumeration that
24
- covered only the map's keys let `# grok worker Dispatch` through and failed
25
- every roster carrying grok or kimi before publication.
12
+ The label is `{worker_id} worker` for every id. A three-provider display
13
+ map made grok/kimi a second branch, and any consumer that restated the map
14
+ treated those ids as unnamed.
26
15
  """
27
- return _ANALYSIS_WORKER_LABELS.get(worker_id, f"{worker_id} worker")
16
+ return f"{worker_id} worker"
28
17
 
29
18
 
30
19
  def analysis_prompt_body(
@@ -159,11 +159,8 @@ def _worker_label_pattern(worker_ids: Iterable[str]) -> re.Pattern[str] | None:
159
159
  """Match the role label the body renderer titled each compared worker with.
160
160
 
161
161
  Built from `analysis_worker_label`, the same function that writes the label,
162
- so a provider outside its display map (`grok`, `kimi`) is covered as it
163
- comes. Restating the map here is what forked the roster: the enumeration
164
- named only Claude / Codex / Antigravity, `# grok worker Dispatch` survived
165
- normalization, and every run rostering grok or kimi failed the equality group
166
- before publication with no prompt defect to fix.
162
+ so every worker id in the comparison group is covered. Restating a
163
+ three-provider list here is what forked the roster.
167
164
  """
168
165
  labels = sorted(
169
166
  {
@@ -390,4 +390,4 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
390
390
 
391
391
  - Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
392
392
  - Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
393
- - After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. After an `implementation-planning` run, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. If awaiting approval, the next sentence to the user is to approve the plan (`okstra-run` → `implementation`, or `--approve`). Do not start another planning run. If the pointer is `blocked`, name the rationale and send the user to `okstra-user-response`; do not re-run planning until those answers exist.
393
+ - After Step 6, begin the lead workflow without re-summarizing the skill itself. For a single run, the end of Step 6 is the end of the run — but in an unattended chain where `orchestration.chainStages` has 2+ elements, repeat Step 6 per stage until Step 7's queue is empty (or it stops at a "not ready" / exception gate), then finish. When the lead (or this skill, after the lead returns) reports the run over, close with the user's next action — one command they can run now. A prohibition is not a next action. After `implementation-planning`, read `workflow.awaitingApproval` and the next-phase pointer from the task manifest. Open or answered `blocks: approval` rows `/okstra-user-response`. Awaiting approval `/okstra-run` → `implementation` or `--approve` (do not start another planning run). Phase 7 `validate-run` failed → one-line cause, then `/okstra-run` to re-run this phase with the sidecar, or `/okstra-inspect recap`. Pointer `status: ready` → `/okstra-run` for that phase. Otherwise `/okstra-inspect status`.
@@ -59,6 +59,8 @@ Read the absolute path in the fixed `Relay contract` line. In that file, take th
59
59
  - When `native-single` is available and the option count fits `nativeLimits` (unique labels, within min/max): call `interactions.native-single.function` once with one question and every option as `{label, description}` in original order. Do not print a numbered list in chat while the native tool is available. Claude Code's function is `AskUserQuestion`, Grok's is `ask_user_question`, Codex's is `request_user_input` — copy the relay field; do not substitute one name for another.
60
60
  - Otherwise render a 1-based numbered Markdown list and wait for the next message. Do not drop options to force the native tool.
61
61
 
62
+ Pass only the choices this step already owns — the `list-view` rows, the report `options[]`, or the two confirmation labels. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row already collect a custom answer; that row is `Enters an answer`. When native-single is unavailable, a next message that is not a listed label or its 1-based number is the same `Enters an answer`. Do not ask a second question for the custom value.
63
+
62
64
  Never invent a picker function. Never ask the user to type a number when the native tool is available.
63
65
 
64
66
  ## Step 1: Select a task from the fixed list view
@@ -69,7 +71,7 @@ okstra user-response list-view --home <resolved-home> --project <projectId> --li
69
71
 
70
72
  The view gives `Task key`, `Task type`, `Report`, open-item counts, and readability status. If the count is zero, answer `No task has open clarification items.` and stop. Do not continue with an unreadable entry.
71
73
 
72
- Present up to three task choices through the host picker. The final picker option is `Enter directly`, where the user may provide a report path or task key.
74
+ Present up to three task choices through the host picker. A host free-text row or unmatched next message is the report path or task key.
73
75
 
74
76
  ## Step 2: Read the fixed report view
75
77
 
@@ -79,7 +81,7 @@ okstra user-response show-view --report <reportPath> --project-root <projectRoot
79
81
 
80
82
  The view contains the report identity, contract version, every open clarification question, its expected form, its current response and disposition, its options, approval context, plan option candidates, current plan decision, resolved context, why the row is asked, linked plan items, and cited artifacts. Question text and `options[]` come only from this view. Do not open a report record to select fields.
81
83
 
82
- Each entry in `options[]` corresponds to `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. Put the `recommended` option first and suffix its label with `(Recommended)`. Then put the alternatives in view order and finish with `Enter directly`.
84
+ Each entry in `options[]` corresponds to `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. Put the `recommended` option first and suffix its label with `(Recommended)`. Then put the alternatives in view order.
83
85
 
84
86
  Contract 3.0 options also expose `reach` and `scopeEffects`. Contract 3.0 approval-blocking rows expose `approvalContext`.
85
87
 
@@ -133,27 +133,34 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
133
133
  display: flex;
134
134
  flex-direction: column;
135
135
  align-items: flex-end;
136
+ gap: .4rem;
136
137
  }
137
- .back-to-top {
138
+ .back-to-top-actions { display: flex; gap: .4rem; }
139
+ .back-to-top,
140
+ .back-to-top-toggle {
138
141
  padding: .55rem .9rem;
139
142
  border-radius: 8px;
140
- border: 1px solid color-mix(in srgb, CanvasText 28%, transparent);
141
- background: color-mix(in srgb, Canvas 92%, CanvasText 8%);
143
+ border: 1px solid color-mix(in srgb, CanvasText 40%, transparent);
144
+ background: Canvas;
142
145
  color: CanvasText;
143
146
  text-decoration: none;
144
147
  font: inherit;
145
148
  font-size: .9rem;
146
- box-shadow: 0 2px 8px color-mix(in srgb, CanvasText 18%, transparent);
149
+ font-weight: 600;
150
+ cursor: pointer;
151
+ box-shadow: 0 2px 10px color-mix(in srgb, CanvasText 28%, transparent);
147
152
  }
148
- .back-to-top:hover { background: color-mix(in srgb, CanvasText 12%, Canvas); }
149
- .back-to-top:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
153
+ .back-to-top:hover,
154
+ .back-to-top-toggle:hover { background: color-mix(in srgb, CanvasText 12%, Canvas); }
155
+ .back-to-top:focus-visible,
156
+ .back-to-top-toggle:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
150
157
  .back-to-top-index {
151
158
  display: none;
152
159
  box-sizing: border-box;
153
160
  width: min(22rem, calc(100vw - 2.4rem));
154
161
  max-height: min(70vh, 32rem);
155
162
  overflow: auto;
156
- margin: 0 0 .4rem;
163
+ margin: 0;
157
164
  padding: .8rem 1rem;
158
165
  border-radius: 12px;
159
166
  border: 1px solid color-mix(in srgb, CanvasText 14%, transparent);
@@ -163,10 +170,13 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
163
170
  .back-to-top-index ol { margin: 0; padding-left: 1.2rem; }
164
171
  .back-to-top-index li { margin: .25em 0; }
165
172
  .back-to-top-index a { color: inherit; }
173
+ .back-to-top-wrap.is-open .back-to-top-index { display: block; }
166
174
  @media (hover: hover) {
167
- .back-to-top-wrap:hover .back-to-top-index,
168
- .back-to-top-wrap:focus-within .back-to-top-index { display: block; }
175
+ .back-to-top-wrap:hover .back-to-top-index { display: block; }
169
176
  }
177
+ .clarification-option { cursor: pointer; border-radius: 10px; padding: .6rem .7rem; }
178
+ .clarification-option.is-selected { background: color-mix(in srgb, Highlight 12%, Canvas); }
179
+ .clarification-item.is-closed .clarification-option { cursor: default; }
170
180
  /* A seven-column table needs 42em of floor, more than a phone can give. */
171
181
  @media (max-width: 640px) { section { padding: 1rem; } .visualization { display: none; } th, td { min-width: 4.5em; } nav.report-index ol { columns: 1; } }
172
182
  @media print { .skip-link, .back-to-top-wrap, script { display: none !important; } body { color: #000; background: #fff; } section { break-inside: avoid; border-color: #bbb; } .visualization-fallback { display: table; } }
@@ -2,4 +2,25 @@
2
2
  "use strict";
3
3
 
4
4
  document.documentElement.classList.add("js-enabled");
5
+
6
+ // 목차는 hover 만으로는 안 열린다. 클릭으로 연다.
7
+ var wrap = document.querySelector(".back-to-top-wrap");
8
+ var toggle = wrap && wrap.querySelector(".back-to-top-toggle");
9
+ if (!wrap || !toggle) return;
10
+
11
+ function setOpen(open) {
12
+ wrap.classList.toggle("is-open", open);
13
+ toggle.setAttribute("aria-expanded", open ? "true" : "false");
14
+ }
15
+
16
+ toggle.addEventListener("click", function (event) {
17
+ event.stopPropagation();
18
+ setOpen(!wrap.classList.contains("is-open"));
19
+ });
20
+ document.addEventListener("click", function (event) {
21
+ if (!wrap.contains(event.target)) setOpen(false);
22
+ });
23
+ document.addEventListener("keydown", function (event) {
24
+ if (event.key === "Escape") setOpen(false);
25
+ });
5
26
  })();
@@ -115,8 +115,11 @@
115
115
  <pre id="user-response-output" aria-live="polite"></pre>
116
116
  </footer>{% endif %}
117
117
  <div class="back-to-top-wrap">
118
- <nav class="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--></nav>
119
- <a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
118
+ <nav class="back-to-top-index" id="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--></nav>
119
+ <div class="back-to-top-actions">
120
+ <button type="button" class="back-to-top-toggle" aria-expanded="false" aria-controls="back-to-top-index">{{ t('base.contents') }}</button>
121
+ <a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
122
+ </div>
120
123
  </div>
121
124
  <script id="run-meta" type="application/json">{{ {
122
125
  "task-key": runMeta.task_key,
@@ -159,6 +159,7 @@
159
159
  "count-answered-questions": "{count} answered questions",
160
160
  "your-answer-to-id": "Your answer to {id}",
161
161
  "choose-one": "Choose one",
162
+ "other-answer": "Other (type your own)",
162
163
  "recommended": "Recommended",
163
164
  "scope-impact": "Scope",
164
165
  "added-work": "Added work",
@@ -159,6 +159,7 @@
159
159
  "count-answered-questions": "답한 질문 {count}건",
160
160
  "your-answer-to-id": "{id}에 대한 답변",
161
161
  "choose-one": "하나를 선택하세요",
162
+ "other-answer": "기타 (직접 입력)",
162
163
  "recommended": "권장",
163
164
  "scope-impact": "범위",
164
165
  "added-work": "추가 작업",
@@ -78,7 +78,7 @@
78
78
  {% if options %}
79
79
  <ol class="clarification-options">
80
80
  {% for option in options %}
81
- <li class="clarification-option{% if option.role == 'recommended' %} is-recommended{% endif %}">
81
+ <li class="clarification-option{% if option.role == 'recommended' %} is-recommended{% endif %}" data-option-value="{{ option.answer }}">
82
82
  <p class="clarification-option-answer">{{ option.answer }}{% if option.role == 'recommended' %} <span class="badge">{{ t('macros.forms.recommended') }}</span>{% endif %}</p>
83
83
  {% if option.disposition | default(None) %}<p class="clarification-option-disposition"><code>{{ option.disposition }}</code></p>{% endif %}
84
84
  <p class="clarification-option-rationale">{{ option.rationale }}</p>
@@ -92,11 +92,16 @@
92
92
  </ol>
93
93
  {% endif %}
94
94
  <label for="response-{{ row.id }}">{{ t('macros.forms.your-answer-to-id') | replace('{id}', row.id) }}</label>
95
- {% if row.kind == 'decision' and approval_context and options %}
95
+ {% if options %}
96
+ {% set pick = namespace(has_other=false, current=row.userInput | default('')) %}
97
+ {% for option in options %}{% if option.answer == '__other__' %}{% set pick.has_other = true %}{% endif %}{% endfor %}
98
+ {% set is_custom = pick.current and pick.current not in (options | map(attribute='answer') | list) %}
96
99
  <select id="response-{{ row.id }}" data-response-id="{{ row.id }}"{% if is_closed %} disabled{% endif %}>
97
100
  <option value="">{{ t('macros.forms.choose-one') }}</option>
98
- {% for option in options %}<option value="{{ option.answer }}" data-disposition="{{ option.disposition | default('') }}"{% if option.role == 'recommended' %} data-recommended="true"{% endif %}{% if row.userInput | default('') == option.answer %} selected{% endif %}>{{ option.answer }}</option>{% endfor %}
101
+ {% for option in options %}<option value="{{ option.answer }}" data-disposition="{{ option.disposition | default('') }}"{% if option.role == 'recommended' %} data-recommended="true"{% endif %}{% if pick.current == option.answer %} selected{% endif %}>{{ option.answer }}</option>{% endfor %}
102
+ {% if not pick.has_other %}<option value="__other__"{% if is_custom %} selected{% endif %}>{{ t('macros.forms.other-answer') }}</option>{% endif %}
99
103
  </select>
104
+ {% if not pick.has_other %}<textarea data-other-for="{{ row.id }}" rows="2"{% if is_closed %} disabled{% endif %}{% if not is_custom %} hidden{% endif %}>{% if is_custom %}{{ pick.current }}{% endif %}</textarea>{% endif %}
100
105
  {% else %}
101
106
  <textarea id="response-{{ row.id }}" data-response-id="{{ row.id }}" rows="4"{% if is_closed %} disabled{% endif %}>{{ row.userInput | default('') }}</textarea>
102
107
  {% endif %}
@@ -274,6 +274,38 @@
274
274
  }
275
275
  }
276
276
 
277
+ // 선택 카드는 보여 주기만 하면 고른 것처럼 보이지만 값이 안 실린다.
278
+ // 카드를 누르면 같은 칸의 select 를 맞춘다.
279
+ function bindOptionCardPicks() {
280
+ var articles = document.querySelectorAll("article[data-response-id]");
281
+ for (var i = 0; i < articles.length; i++) {
282
+ (function (article) {
283
+ var sel = article.querySelector("select[data-response-id]");
284
+ if (!sel) return;
285
+ var cards = article.querySelectorAll(".clarification-option[data-option-value]");
286
+ var sync = function () {
287
+ for (var c = 0; c < cards.length; c++) {
288
+ cards[c].classList.toggle(
289
+ "is-selected",
290
+ cards[c].getAttribute("data-option-value") === sel.value
291
+ );
292
+ }
293
+ };
294
+ for (var c = 0; c < cards.length; c++) {
295
+ cards[c].addEventListener("click", function (event) {
296
+ if (sel.disabled) return;
297
+ if (event.target.closest && event.target.closest("a")) return;
298
+ sel.value = event.currentTarget.getAttribute("data-option-value") || "";
299
+ sel.dispatchEvent(new Event("change"));
300
+ sync();
301
+ });
302
+ }
303
+ sel.addEventListener("change", sync);
304
+ sync();
305
+ })(articles[i]);
306
+ }
307
+ }
308
+
277
309
  function quotedReviewField(label, value) {
278
310
  var cleaned = trimMultiline(value);
279
311
  if (!cleaned) return "- " + label + ":\n";
@@ -547,6 +579,7 @@
547
579
  setReaderMode("action");
548
580
  }
549
581
  bindOtherInputToggle();
582
+ bindOptionCardPicks();
550
583
  }
551
584
 
552
585
  if (typeof window !== "undefined") {
@@ -4117,6 +4117,77 @@ def _state_classification(item: dict, gate_class: str) -> str:
4117
4117
  return "dissent-isolated" if dissenting == 1 else "partial-consensus"
4118
4118
 
4119
4119
 
4120
+ def _clarification_resolution(row: dict, context: dict) -> dict | None:
4121
+ """계약 3.0 은 행의 `resolution`, 2.0 은 `approvalContext.resolution`."""
4122
+ for candidate in (row.get("resolution"), context.get("resolution")):
4123
+ if isinstance(candidate, dict):
4124
+ return candidate
4125
+ return None
4126
+
4127
+
4128
+ def _clarification_ids_on_activity(activity: dict) -> set[str]:
4129
+ refs: set[str] = set()
4130
+ for key in ("clarificationRefs", "evidenceRefs"):
4131
+ for value in activity.get(key) or []:
4132
+ if isinstance(value, str) and _APPROVAL_CLARIFICATION_ID_RE.fullmatch(value):
4133
+ refs.add(value)
4134
+ return refs
4135
+
4136
+
4137
+ def _plan_item_ids_for_clarification(
4138
+ row: dict, context: dict, data: dict,
4139
+ ) -> list[str]:
4140
+ """이 C 행이 가리키는 계획 항목.
4141
+
4142
+ 계약 3.0 `approvalContext` 는 `planItemIds` 를 갖지 않는다. 활동
4143
+ `evidenceRefs` / `clarificationRefs` 와 `planItems[].clarificationRefs` 가
4144
+ 역추적이다. 이 C 만 인용한 활동을 묶음 활동보다 앞세운다.
4145
+ """
4146
+ linked = [
4147
+ item_id
4148
+ for item_id in (context.get("planItemIds") or [])
4149
+ if isinstance(item_id, str) and item_id
4150
+ ]
4151
+ if linked:
4152
+ return linked
4153
+ row_id = str(row.get("id") or "")
4154
+ if not row_id:
4155
+ return []
4156
+ singleton: list[str] = []
4157
+ bulk: list[str] = []
4158
+ for activity in data.get("agentActivity") or []:
4159
+ if not isinstance(activity, dict):
4160
+ continue
4161
+ refs = _clarification_ids_on_activity(activity)
4162
+ if row_id not in refs:
4163
+ continue
4164
+ ids = [
4165
+ item_id
4166
+ for item_id in (activity.get("planItemIds") or [])
4167
+ if isinstance(item_id, str) and item_id
4168
+ ]
4169
+ if refs == {row_id}:
4170
+ singleton.extend(ids)
4171
+ else:
4172
+ bulk.extend(ids)
4173
+ if singleton or bulk:
4174
+ return singleton or bulk
4175
+ items = (
4176
+ ((data.get("implementationPlanning") or {}).get("planBodyVerification")
4177
+ or {}).get("planItems") or []
4178
+ )
4179
+ return [
4180
+ str(item.get("id") or "")
4181
+ for item in items
4182
+ if isinstance(item, dict)
4183
+ and row_id in {
4184
+ ref for ref in (item.get("clarificationRefs") or [])
4185
+ if isinstance(ref, str)
4186
+ }
4187
+ and item.get("id")
4188
+ ]
4189
+
4190
+
4120
4191
  def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
4121
4192
  """Plan items whose remaining dissent the user explicitly accepted."""
4122
4193
  accepted: set[str] = set()
@@ -4126,22 +4197,28 @@ def _resolved_noncritical_dissent_ids(data: dict) -> set[str]:
4126
4197
  context = row.get("approvalContext")
4127
4198
  if not isinstance(context, dict):
4128
4199
  continue
4129
- resolution = context.get("resolution")
4200
+ resolution = _clarification_resolution(row, context)
4130
4201
  if (
4131
- row.get("status") == "resolved"
4132
- and context.get("classification") == "noncritical-dissent"
4133
- and isinstance(resolution, dict)
4134
- and resolution.get("disposition") == "accept-risk"
4135
- and str(resolution.get("userText") or "").strip()
4136
- and _approval_context_activity_refs_exist(
4137
- data, str(row.get("id") or ""), context, resolution
4138
- )
4202
+ row.get("status") != "resolved"
4203
+ or context.get("classification") != "noncritical-dissent"
4204
+ or not isinstance(resolution, dict)
4205
+ or resolution.get("disposition") != "accept-risk"
4206
+ or not str(resolution.get("userText") or "").strip()
4139
4207
  ):
4208
+ continue
4209
+ row_id = str(row.get("id") or "")
4210
+ if context.get("planItemIds") or context.get("activityIds"):
4211
+ if not _approval_context_activity_refs_exist(
4212
+ data, row_id, context, resolution
4213
+ ):
4214
+ continue
4140
4215
  accepted.update(
4141
4216
  item_id
4142
4217
  for item_id in context.get("planItemIds") or []
4143
4218
  if isinstance(item_id, str)
4144
4219
  )
4220
+ continue
4221
+ accepted.update(_plan_item_ids_for_clarification(row, context, data))
4145
4222
  return accepted
4146
4223
 
4147
4224
 
@@ -4957,14 +5034,6 @@ def _validate_resolved_approval(
4957
5034
  f"final-report data.json: resolved approval clarification `{row_id}` "
4958
5035
  "requires non-empty resolution.checkRefs."
4959
5036
  )
4960
- if (
4961
- context.get("classification") == "noncritical-dissent"
4962
- and resolution.get("disposition") != "accept-risk"
4963
- ):
4964
- failures.append(
4965
- f"final-report data.json: resolved noncritical-dissent `{row_id}` "
4966
- "requires an explicit accept-risk disposition."
4967
- )
4968
5037
 
4969
5038
 
4970
5039
  def _has_successful_targeted_reverification(item: dict) -> bool:
@@ -6173,6 +6242,7 @@ def _validate_approval_clarification_backtrace(
6173
6242
 
6174
6243
 
6175
6244
  _RERUN_FLAG = "--answered-clarifications"
6245
+ _USER_RESPONSE_HINT = re.compile(r"okstra-user-response", re.IGNORECASE)
6176
6246
  _APPROVE_HINT = re.compile(r"--approve|\bapprov", re.IGNORECASE)
6177
6247
 
6178
6248
 
@@ -6190,9 +6260,11 @@ def _next_step_texts(steps: object) -> list[str]:
6190
6260
  return texts
6191
6261
 
6192
6262
 
6193
- def _has_blocks_approval_row(data: dict) -> bool:
6263
+ def _has_unresolved_approval_blocker(data: dict) -> bool:
6194
6264
  return any(
6195
- isinstance(row, dict) and row.get("blocks") == "approval"
6265
+ isinstance(row, dict)
6266
+ and row.get("blocks") == "approval"
6267
+ and str(row.get("status") or "") in {"open", "answered"}
6196
6268
  for row in data.get("clarificationItems") or []
6197
6269
  )
6198
6270
 
@@ -6220,19 +6292,20 @@ def _validate_rerun_guidance(data: dict, failures: list[str]) -> None:
6220
6292
  """
6221
6293
  if (data.get("header") or {}).get("taskType") != "implementation-planning":
6222
6294
  return
6223
- has_blocker = _has_blocks_approval_row(data)
6224
- if not has_blocker:
6295
+ if not _has_unresolved_approval_blocker(data):
6225
6296
  return
6226
- if any(_RERUN_FLAG in text for text in _next_step_texts(
6227
- data.get("recommendedNextSteps")
6228
- )):
6297
+ texts = _next_step_texts(data.get("recommendedNextSteps"))
6298
+ if any(_RERUN_FLAG in text for text in texts) and any(
6299
+ _USER_RESPONSE_HINT.search(text) for text in texts
6300
+ ):
6229
6301
  return
6230
6302
  failures.append(
6231
- "final-report data.json: this plan withholds approval on a "
6303
+ "final-report data.json: this plan withholds approval on an unresolved "
6232
6304
  "`blocks: approval` clarification, but no `recommendedNextSteps` entry "
6233
- f"tells the reader how to resume — name the `{_RERUN_FLAG}` re-run in a "
6234
- "step's `text` or one of its `commands`. `okstra recap assemble` "
6235
- "prints the exact ids and flag value once the answers are recorded."
6305
+ "tells the reader the command to run now — name `/okstra-user-response` "
6306
+ f"and the `{_RERUN_FLAG}` re-run in a step's `text` or one of its "
6307
+ "`commands`. `okstra recap assemble` prints the exact ids and flag "
6308
+ "value once the answers are recorded."
6236
6309
  )
6237
6310
 
6238
6311
 
@@ -6247,7 +6320,7 @@ def _validate_approval_guidance(data: dict, failures: list[str]) -> None:
6247
6320
  planning = data.get("implementationPlanning")
6248
6321
  if not isinstance(planning, dict) or planning.get("outcome") != "plan-ready":
6249
6322
  return
6250
- if _has_blocks_approval_row(data) or _planning_gate_blocks_approval(data):
6323
+ if _has_unresolved_approval_blocker(data) or _planning_gate_blocks_approval(data):
6251
6324
  return
6252
6325
  if _report_already_approved(data):
6253
6326
  return
@@ -7245,6 +7318,7 @@ def _validate_unresolved_tie_was_reverified(
7245
7318
  for item in pbv.get("planItems") or []
7246
7319
  if isinstance(item, dict)
7247
7320
  and not item.get("carriedForwardFromSeq")
7321
+ and _stage_scope_bucket(item, pbv) == "in-scope"
7248
7322
  and _is_unsettled_tie(item)
7249
7323
  })
7250
7324
  if not unsettled:
@@ -7272,11 +7346,14 @@ def _validate_tie_received_extra_vote(
7272
7346
  pbv = ip.get("planBodyVerification")
7273
7347
  if not isinstance(pbv, dict):
7274
7348
  return
7349
+ accepted = _resolved_noncritical_dissent_ids(data)
7275
7350
  missing = sorted({
7276
7351
  str(item.get("id") or "").strip()
7277
7352
  for item in pbv.get("planItems") or []
7278
7353
  if isinstance(item, dict)
7279
7354
  and not item.get("carriedForwardFromSeq")
7355
+ and str(item.get("id") or "").strip() not in accepted
7356
+ and _stage_scope_bucket(item, pbv) == "in-scope"
7280
7357
  and _is_even_blocking_split(item)
7281
7358
  and _distinct_verdict_workers(item) < 3
7282
7359
  })
@@ -54,11 +54,12 @@ _ANALYSIS_PARENT_KEYS = {
54
54
  "feature-analysis": "featureAnalysis",
55
55
  "change-impact-analysis": "changeImpactAnalysis",
56
56
  }
57
- _ANALYSIS_WORKER_ROLES = {
58
- "Claude worker",
59
- "Codex worker",
60
- "Antigravity worker",
61
- }
57
+
58
+
59
+ def _is_report_writer_role(role: str) -> bool:
60
+ return "reportwriter" in re.sub(r"[^a-z0-9]", "", role.lower())
61
+
62
+
62
63
  _FINAL_ANALYSIS_REPORT_RE = re.compile(
63
64
  r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
64
65
  r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
@@ -467,7 +468,7 @@ def validate_analysis_semantics(
467
468
  ) -> None:
468
469
  required_roles = _required_worker_roles(run_manifest, errors)
469
470
  analysis_worker_roles = tuple(
470
- role for role in required_roles if role in _ANALYSIS_WORKER_ROLES
471
+ role for role in required_roles if not _is_report_writer_role(role)
471
472
  )
472
473
  verdict_roles_unambiguous = _validate_execution_status_roles(
473
474
  data, analysis_worker_roles, errors
@@ -235,8 +235,24 @@ def _worker_role(value: str) -> str | None:
235
235
 
236
236
 
237
237
  def _worker_roles(worker: dict) -> set[str]:
238
- role = _worker_role(str(worker.get("role") or ""))
239
- return {role, role.replace(" ", "-")} if role else set()
238
+ """명단 `role` `workerId` 활동 `agent` 가 쓸 수 있는 별칭으로 펼친다.
239
+
240
+ 활동은 `codex` / `codex-worker` 를 쓰고 명단은 `Codex worker` 를 쓴다.
241
+ 역할 문자열만 보면 둘은 다른 사람이 된다.
242
+ """
243
+ roles: set[str] = set()
244
+ for raw in (worker.get("role"), worker.get("workerId")):
245
+ role = _worker_role(str(raw or ""))
246
+ if not role:
247
+ continue
248
+ compact = role.replace(" ", "-")
249
+ roles.add(role)
250
+ roles.add(compact)
251
+ if role.endswith(" worker"):
252
+ roles.add(role[: -len(" worker")])
253
+ if compact.endswith("-worker"):
254
+ roles.add(compact[: -len("-worker")])
255
+ return roles
240
256
 
241
257
 
242
258
  def _analysis_workers(team_state: Mapping[str, Any]) -> list[dict]:
@@ -1036,12 +1052,17 @@ def _check_activity_contract(
1036
1052
  if not _is_activity_contract_v1_planning(run_manifest):
1037
1053
  return
1038
1054
  indexed = _activity_index(evidence.activities)
1055
+ workers = [
1056
+ worker
1057
+ for worker in (team_state.get("workers") or [])
1058
+ if isinstance(worker, dict)
1059
+ ]
1039
1060
  analysis_workers = _analysis_workers(team_state)
1040
1061
  report_data = _read_report_data(report_path)
1041
1062
  _check_activity_id_order(evidence.activities, errors)
1042
1063
  _check_projected_agent_activity(evidence.activities, report_data, errors)
1043
1064
  _check_activity_worker_pairs(evidence, indexed, analysis_workers, errors)
1044
- _check_activity_worker_agents(indexed, analysis_workers, errors)
1065
+ _check_activity_worker_agents(indexed, workers, errors)
1045
1066
  _check_activity_round_counts(indexed, report_data, run_dir, suffix, errors)
1046
1067
  _check_activity_user_decisions(indexed, report_data, errors)
1047
1068