okstra 0.199.1 → 0.199.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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/prompts/launch.template.md +1 -1
  4. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  5. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  6. package/runtime/prompts/lead/report-writer.md +8 -7
  7. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  8. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  9. package/runtime/prompts/profiles/implementation-planning.md +5 -1
  10. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +8 -0
  11. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +5 -1
  12. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +7 -1
  13. package/runtime/python/okstra_ctl/conformance.py +7 -0
  14. package/runtime/python/okstra_ctl/convergence_store.py +16 -1
  15. package/runtime/python/okstra_ctl/error_log_core.py +2 -1
  16. package/runtime/python/okstra_ctl/error_report.py +20 -1
  17. package/runtime/python/okstra_ctl/implementation_direction.py +22 -4
  18. package/runtime/python/okstra_ctl/plan_items_cli.py +16 -0
  19. package/runtime/python/okstra_ctl/qa_commands.py +26 -2
  20. package/runtime/python/okstra_ctl/report_finalize.py +20 -0
  21. package/runtime/python/okstra_ctl/run.py +10 -0
  22. package/runtime/python/okstra_ctl/worker_prompt_contract.py +13 -0
  23. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -0
  24. package/runtime/skills/okstra-inspect/SKILL.md +4 -2
  25. package/runtime/skills/okstra-run/SKILL.md +1 -1
  26. package/runtime/validators/forbidden_actions.py +3 -0
  27. package/runtime/validators/validate-run.py +76 -3
  28. package/runtime/validators/validate_session_conformance.py +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.199.1",
3
+ "version": "0.199.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.199.1",
3
- "builtAt": "2026-09-11T21:21:59.712Z",
2
+ "package": "0.199.2",
3
+ "builtAt": "2026-09-11T23:34:24.321Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -57,7 +57,7 @@ For every other task type:
57
57
  - Pointer `status: terminal` → the lifecycle ends here. Say the task is finished and quote the pointer's `rationale`. Do not propose a run and do not send the user to `/okstra-inspect`: the decision is already made, so there is nothing to inspect. If this run registered follow-up tasks, name them and the command that starts one.
58
58
  - Pointer `status: blocked` → the pointer's `rationale` names what is in the way and what to run. Quote it and issue that command — `/okstra-user-response` for the `C-NNN` ids it lists, or `/okstra-run` for the phase it names.
59
59
  - Pointer `status: pending` with a `rationale` → the user's own input is what comes next. Quote the `rationale` and issue the command it names. Do not re-run the phase that just completed, and do not send the user to `/okstra-inspect`: a finished phase has nothing to inspect, and re-running it discards the result the user is being asked to act on.
60
- - Phase 7 `validate-run` failed → one line naming the blocking cause, then `/okstra-run` to re-run this phase. Only a failure matching the blocking allowlist (`okstra_ctl.blocking_checks`) reaches this row; every other finding was demoted to an advisory, printed as `validate-run: advisory — <finding>`, and the run passed. Name those advisories in one line and take the command from the matching pointer row above — an advisory is not a reason to re-run.
60
+ - Phase 7 `validate-run` failed → one line naming the blocking cause, then use the blocked pointer's `phase` as the recovery target with `/okstra-run`; re-run the current phase only when that target is empty. Only a failure matching the blocking allowlist (`okstra_ctl.blocking_checks`) reaches this row; every other finding was demoted to an advisory, printed as `validate-run: advisory — <finding>`, and the run passed. Name those advisories in one line and take the command from the matching pointer row above — an advisory is not a reason to re-run.
61
61
  - Otherwise → `/okstra-inspect status` for this task.
62
62
 
63
63
  Name every file in this reply as a markdown link — `[<what it is>](<path>)`, path inside the parentheses — so the user can open it. `reportPaths.markdown` in the `report-finalize` result already carries this run's report, report record, and team state that way. Commands stay in backticks.
@@ -6,6 +6,8 @@ This adapter maps the neutral Okstra lead operations to a cmux session, where Ok
6
6
 
7
7
  It overrides only the worker-dispatch portion of the selected host relay, not the host or lead contract. Your own runtime still decides how you read files, ask the user, and record your session — this file only decides how workers are started, awaited, and reclaimed.
8
8
 
9
+ Keep the selected host relay's dispatch permission guidance when calling `okstra team dispatch`. For a restricted Codex lead, its `Permission at the dispatch boundary` guidance applies to every live dispatch, including reverify and critic jobs; selecting cmux does not grant execution privileges.
10
+
9
11
  ## Capability declaration
10
12
 
11
13
  | Field | Value |
@@ -512,7 +512,7 @@ For every other task type:
512
512
  - Pointer `status: terminal` and the result carries `nextInGroup` → the task is finished and the task-group has a task not yet started: say this task is finished, then close on `/okstra-run` for `nextInGroup.briefId` from its `brief` path — the group's start order is the brief ordinal, and `nextCommand.note` already names the task and its brief.
513
513
  - Pointer `status: blocked` → the pointer's `rationale` names what is in the way and what to run. Quote it and issue that command — `/okstra-user-response` for the `C-NNN` ids it lists, or `/okstra-run` for the phase it names.
514
514
  - Pointer `status: pending` with a `rationale` → the user's own input is what comes next. Quote the `rationale` and issue the command it names. Do not re-run the phase that just completed, and do not send the user to `/okstra-inspect`: a finished phase has nothing to inspect, and re-running it discards the result the user is being asked to act on.
515
- - Phase 7 `validate-run` failed → one line naming the blocking cause, then `/okstra-run` to re-run this phase with the recorded sidecar. Only a failure matching the blocking allowlist (`okstra_ctl.blocking_checks`) reaches this row; every other finding was demoted to an advisory, printed as `validate-run: advisory — <finding>`, and the run passed. Name those advisories in one line and take the command from the matching pointer row above — an advisory is not a reason to re-run.
515
+ - Phase 7 `validate-run` failed → one line naming the blocking cause, then use the blocked pointer's `phase` as the recovery target with `/okstra-run`; re-run the current phase only when that target is empty. Only a failure matching the blocking allowlist (`okstra_ctl.blocking_checks`) reaches this row; every other finding was demoted to an advisory, printed as `validate-run: advisory — <finding>`, and the run passed. Name those advisories in one line and take the command from the matching pointer row above — an advisory is not a reason to re-run.
516
516
  - Otherwise → `/okstra-inspect status` for this task.
517
517
 
518
518
  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.
@@ -123,16 +123,17 @@ For historical schema-v1 Markdown only, the following heading table remains a re
123
123
 
124
124
  **Enforced:** `okstra_ctl.report_finalize.V3_STEP_ORDER` is the order — `report-finalize` runs the steps from that tuple, so the sequence cannot be reordered by a caller. Running the steps by hand is what this rule forbids, and that path is not reachable through the CLI.
125
125
 
126
- Do not run the eight steps below manually. Invoke `okstra report-finalize`; contract 3.0 runs them in this order:
126
+ Do not run the nine steps below manually. Invoke `okstra report-finalize`; contract 3.0 runs them in this order:
127
127
 
128
128
  1. **`token-usage`** — collect usage into team state without touching the final record.
129
129
  2. **`project-activity`** — report assembly validates every owner input and publishes the final record once.
130
- 3. **`translate`** — for a non-English `reportLanguage`, materialize and dispatch the translator worker and require its `*.i18n.<lang>.json` sidecar; a no-op for English or when the sidecar already exists.
131
- 4. **`render-views`** — render the Markdown reading copy and human HTML, with the translation sidecar overlaid.
132
- 5. **`spawn-followups`** — materialize registered follow-up tasks.
133
- 6. **`validate-run`** — validate the record, views, run manifest, and team state.
134
- 7. **`record-group-memory`** — write this run's conclusion (headline, decisions, watch-outs, open follow-ups, record path, next phase) and the group's start order into the task-group's `group-context.md` okstra region, creating the file when the group has none; skipped, like teardown, when an earlier step failed. Sibling tasks read it as `## Task-Group Memory`.
135
- 8. **`teardown-stages`** — remove eligible stage worktrees after successful validation.
130
+ 3. **`preflight`** — run the same schema and conformance checks used by final validation before translation, without writing run status. Structural failure skips translation; rendering and final validation still record the failure. `--only translate` also includes this check.
131
+ 4. **`translate`** — for a non-English `reportLanguage`, materialize and dispatch the translator worker and require its `*.i18n.<lang>.json` sidecar; a no-op for English or when the sidecar already exists.
132
+ 5. **`render-views`** — render the Markdown reading copy and human HTML, with the translation sidecar overlaid.
133
+ 6. **`spawn-followups`** — materialize registered follow-up tasks.
134
+ 7. **`validate-run`** — validate the record, views, run manifest, and team state.
135
+ 8. **`record-group-memory`** — write this run's conclusion (headline, decisions, watch-outs, open follow-ups, record path, next phase) and the group's start order into the task-group's `group-context.md` okstra region, creating the file when the group has none; skipped, like teardown, when an earlier step failed. Sibling tasks read it as `## Task-Group Memory`.
136
+ 9. **`teardown-stages`** — remove eligible stage worktrees after successful validation.
136
137
 
137
138
  After `report-finalize` returns, the lead — not the report writer — closes the run with the launch prompt's User closeout: one command the user can run now.
138
139
 
@@ -9,12 +9,14 @@ at Phase 5, BEFORE constructing the verifier worker dispatch prompts.
9
9
 
10
10
  - Every verdict comes from a fresh session with no shared context, never from the session that wrote the diff. Verifiers MUST NOT call Edit, Write, or any Bash command that mutates files outside the run's artifact directories. If a verifier wants a fix, it records the recommendation in its worker result; it does not apply the fix itself.
11
11
  - Session isolation is the primary self-review safeguard: each verifier is a separate invocation with its own context window. Reusing the executor's model is acceptable. The model comes from the run's stored assignment.
12
- - Verifiers read from the SAME working tree path the Executor used so they observe the exact diff the Executor produced. Verifiers remain strictly read-only there.
12
+ - Verifiers read from the SAME working tree path the Executor used so they observe the exact diff the Executor produced. Source files, lockfiles, Git state, and shared links remain read-only. Declared verification commands may create their normal worktree-local build/cache outputs and install dependencies with a frozen lockfile. This is the bounded exception to the preceding write restriction; it does not permit source repairs, moving shared links, or redirecting build outputs outside the worktree. Run-owned logs remain in the run artifact directories.
13
13
 
14
14
  **Enforced:** `_validate_verifier_command_log_is_read_only` in `validators/validate-run.py` scans every `verifierResults[].readOnlyCommandLog` for source-mutating commands (`sed -i`, `git checkout --`/`restore`/`reset --hard`/`stash`/`clean`/`apply`, `patch -p`, `rm -rf`, `truncate`). Read-only forms (`git stash list`, `git clean --dry-run`, `git apply --check`) pass.
15
15
 
16
16
  ## Verifier QA duties (independent re-run mandate)
17
17
 
18
+ `okstra_ctl.qa_commands.find_unfrozen_installs` rejects unfrozen npm/pnpm/yarn/bun installs in both the plan preflight and the verifier command log. If an approved command conflicts with these restrictions or cannot run in the provisioned worktree, report the exact checklist row and route to `implementation-planning`; do not substitute a command during verification.
19
+
18
20
  Every verifier acts as a QA gate, not just a diff reviewer. Trusting the executor's reported evidence is forbidden — verifiers MUST reproduce it themselves from the same worktree path the executor used.
19
21
 
20
22
  **Enforced:** `_validate_verifier_reran_independently` in `validators/validate-run.py` fails an empty `independentValidationRerun`. What you wrote in it is not pattern-matched — the wording check that used to reject phrasings citing the executor is gone, because it fired on reports that had reproduced the run and merely said so. Write the command you ran and its outcome; the Discrepancy rule below is what makes you name the divergence.
@@ -47,7 +47,7 @@
47
47
  ],
48
48
  "implementation-planning": [
49
49
  "source code edits of any kind (Edit/Write on project source files is forbidden)",
50
- "file writes outside the run`s artifact directories (`reports/`, `prompts/`, `state/`, `manifests/`, `worker-results/`, `status/`, `sessions/`) and the task-root qa tree (`<task_root>/qa/` — the Tier3 conformance scripts, manifest, and tsconfig this phase MUST write per the Stage Map conformance contract); in particular, do not write to `docs/superpowers/specs/` or `docs/superpowers/plans/`",
50
+ "file writes outside the run`s artifact directories (`reports/`, `prompts/`, `state/`, `manifests/`, `worker-results/`, `status/`, `sessions/`), including task-root QA scripts, manifest, and tsconfig (planning declares conformance commands and required dependencies; implementation writes these files); in particular, do not write to `docs/superpowers/specs/` or `docs/superpowers/plans/`",
51
51
  "executing builds, migrations, deployments, or any state-mutating command",
52
52
  "starting `implementation` inside this run (must be a separate run authorised by an approved deliverable from this phase), even if the user says \"다음 단계 진행해\"",
53
53
  "dispatching parallel sub-agents beyond the required worker roster (okstra owns worker fan-out)",
@@ -1,5 +1,9 @@
1
1
  # Implementation Planning Profile
2
2
 
3
+ Validation commands are executable inputs: preserve their newlines, quotes, and code bodies. Use frozen dependency installation (`npm ci`, `pnpm install --frozen-lockfile`, or the package manager's equivalent). Name QA scripts with absolute task-artifact paths when a command runs from a stage worktree; `.okstra/tasks/...` relative to that worktree does not point to the project task. `stage_validation_executability_errors` enforces command restrictions at planning validation, implementation entry, and report preflight, including advisory plan-body runs.
4
+
5
+ Plan for the actual worktree layout before approval. Shared documentation directories can be links to the main checkout. Choose a build command compatible with those links (for example, an installed Next.js version may provide `next build --webpack`); verify the available option rather than assuming it. Do not plan for a verifier to move links or repair its environment. Compare negative-case assertions with the brief and the script body: a requirement to cache existing assets does not establish that missing assets should be cached. Record any changed expectation in a new plan revision; preserve the earlier approved plan.
6
+
3
7
  ```yaml
4
8
  roles:
5
9
  - role: planner
@@ -185,7 +189,7 @@ roles:
185
189
  Layout split (the implementer writes these, not this phase): executable scripts (conformance + any real-IO test) live under `<task_root>/qa/scripts/`; data sidecars (`conformance-manifest.json`, `result-*.json`) stay at the `qa/` root. This declaration is enforced at four layers: `validators/validate-implementation-plan-stages.py` check **S11** forces every stage to carry one of the two lines; at the planning boundary `validators/validate-run.py` `_validate_planning_conformance_declared` accepts a well-formed `Conformance tests:` line even when the script file and manifest entry are absent (malformed `requires` still fails); the matching `implementation` stage run that inherited `Conformance tests:` fails closed when the script file is missing (`_validate_conformance`); and the manifest JSON structure — including each entry's `script` living under `qa/scripts/` and a `runCommand` that does not change cwd — is enforced by `validate_conformance_manifest` when the implementer writes the entry.
186
190
  - `### Stage Exit Contract` — predicted added/modified files, newly exposed identifiers/types/endpoints, downstream-usable resources.
187
191
  - `### Stage Validation` — pre / mid / post exact commands or observable outcomes for this stage only.
188
- - **Run-executable only (BLOCKING).** A `validationChecklist` row that carries `stageRefs` gates that stage's carry, so it MUST be executable by the implementation run itself — no deployment, no manual walk-through, no observation "by hand", no credentials the run does not hold. The implementation phase forbids deploys and holds no deploy credentials, so such a row can never pass and blocks a stage with zero code defects (observed: dev-10341 VC-013/VC-014). Manual or deployed-environment verification belongs in the brief's `External Gates` and final-verification's user-owned external QA — record it there without `stageRefs`. **Enforced:** `okstra_ctl.implementation_direction.validate_selected_direction_plan` (`_stage_validation_executability_errors`) rejects stage-gating rows whose observation text requires a manual or deployed-environment step.
192
+ - **Run-executable only (BLOCKING).** A `validationChecklist` row that carries `stageRefs` gates that stage's carry, so it MUST be executable by the implementation run itself — no deployment, no manual walk-through, no observation "by hand", no credentials the run does not hold. The implementation phase forbids deploys and holds no deploy credentials, so such a row can never pass and blocks a stage with zero code defects (observed: dev-10341 VC-013/VC-014). Manual or deployed-environment verification belongs in the brief's `External Gates` and final-verification's user-owned external QA — record it there without `stageRefs`. **Enforced:** `okstra_ctl.implementation_direction.validate_selected_direction_plan` (`stage_validation_executability_errors`) rejects stage-gating rows whose observation text requires a manual or deployed-environment step.
189
193
  - **Dependency precondition (stages that run the project toolchain).** The planning worktree is created without installed dependencies, so a stage whose steps call `npm` / `yarn` / `pytest` / `cargo` / equivalent cannot have those commands succeed at plan time — they exit `127`, not RED/GREEN. Declare the install **once** as a `phase: pre` row in `### Validation Checklist` (e.g. `VC-008 — the implementation run's stage worktree has workspace dependencies installed`) and have every such stage's `Stage Validation` cite that `VC-NNN` in its `pre:` line. Do not repeat the install commands per stage, and do not silently assume the tooling is present: a plan that never states the precondition produces steps whose commands never resolve, which the §5.5.9 round then reports as unverifiable. **Enforced (advisory):** `validators/validate-run.py` `_detect_missing_dependency_precondition` warns when a toolchain-invoking stage cites no `VC-NNN`, or cites one that is not `phase: pre`. Whether the cited row genuinely covers dependencies is a §5.5.9 judgement, not a machine check. Detection uses the token allowlist in `scripts/okstra_ctl/build_tools.py`; a project overrides it with `buildToolTokens` in `.okstra/project.json`.
190
194
  - **Vertical-slice-first partition rule (1st-class):** the grouping anchor is a **thin end-to-end vertical slice** — one stage delivers a single user-observable increment, crossing whatever layers are needed (data → service → API → UI) to make that one increment work. File/module proximity is demoted to the **intra-slice grouping rule**: within a slice, keep steps touching the same file/directory/module together so the diff, PR, and rollback unit stay cohesive. **Horizontal layer-splitting is forbidden** — never carve "the DB layer" into one stage and "the service layer" into the next; that produces stages that ship no standalone user value. A stage is split ONLY when (a) a real `depends-on` data/contract dependency exists, (b) effective steps would exceed 8, or (c) it is a distinct vertical slice (a different user-value increment). Maximising the number of parallel stages is NOT a reason to split — parallelism is an emergent property of independent stages, never a partitioning goal. **Config propagation is not a slice (BLOCKING):** a dependent stage whose planned paths are all configuration/CI files (`Dockerfile`, `docker-compose*`, `*.yml` / `*.yaml`, `.env*`) merely propagates a value another stage introduced — it ships no user-value increment of its own. Fold that work into the introducing stage as its own step(s) and separate it by **commits**, never by stages: each extra stage costs a full implementation run (executor + verifiers, 30min+ observed) for a few config lines. A first stage with no dependency is exempt — a task whose whole scope is configuration is legitimate. **Enforced:** `okstra_ctl.implementation_direction.validate_selected_direction_plan` (`_micro_stage_fold_errors`).
191
195
  - **Project-boundary partition rule (hard boundary):** a *project* boundary is either (a) a different repository / `PROJECT_ROOT`, or (b) a different top-level independently-deployable module within one repo. A stage maps to a single worktree on one repo/branch, so **no stage may contain edits belonging to more than one project** — this is a hard split that overrides the ≤8-step merging allowance; never co-locate two projects' changes in one stage to save a stage. Two cases:
@@ -178,6 +178,14 @@ Display the question only through the selected tool. Do not print it or its opti
178
178
 
179
179
  ## Codex dispatch details
180
180
 
181
+ ### Permission at the dispatch boundary
182
+
183
+ Before a live CLI worker dispatch, check the current host's declared sandbox and approval policy. In a restricted Codex session, request the host's supported execution permission on the dispatch command itself (`sandbox_permissions: "require_escalated"` for `exec_command` when that mechanism is available and permitted). Scope the request to the prepared run and explain that it starts the selected worker processes. Follow the host's approval decision; a wizard confirmation or a successful preflight does not grant this permission.
184
+
185
+ Apply this guidance to both `okstra worker-dispatch` and the cmux override's `okstra team dispatch`, including initial workers, reverify, critic, report-writer, and retries. Read-only previews do not start workers. A session already authorized to execute outside the sandbox does not need another request. When the host prohibits escalation, preserve the pending invocation and report the execution constraint instead of trying an alternate launcher or weakening host controls.
186
+
187
+ The worker's `--sandbox danger-full-access` flag only selects the child Codex policy; it does not remove restrictions inherited from the parent process. If startup reports `Operation not permitted`, retain the exact error and distinguish initialization failure from a worker verdict. Retry only through the permitted host mechanism after the execution conditions change, within the existing retry limit; do not repeat the same restricted invocation. This is host-call guidance, not a permission grant or a runtime-enforced check.
188
+
181
189
  - For convergence reverify, consume the persisted round plan exactly. This adapter may map and transport each returned batch, but it cannot change batch membership and does not classify findings or branch on task type, provider, or model identity.
182
190
  - Do not invoke Claude Code team or subagent tools.
183
191
  - The prepared run manifest and team-state are the dispatch authority. A `runner=native-session` assignment stays in the current Codex host; a `runner=cli-wrapper` assignment uses the registered provider wrapper. Unsupported explicitly requested workers fail; an adapter must not silently change the roster.
@@ -28,6 +28,7 @@ from ...convergence_store import (
28
28
  from ...worker_prompt_policy import (
29
29
  CRITIC_VERIFY_DISPATCH_KIND,
30
30
  PLAN_VERIFY_DISPATCH_KIND_PREFIX,
31
+ is_plan_critic_verification,
31
32
  verification_dispatch_round,
32
33
  )
33
34
  from .inputs import AgentPromptCliError
@@ -81,7 +82,10 @@ def _dynamic_verifier_source(
81
82
  and manifest.get("executionIdentityVersion") == 2
82
83
  )
83
84
  is_reverify = (
84
- args.assignment_ref.startswith("reverify/")
85
+ (args.assignment_ref.startswith("reverify/") or is_plan_critic_verification(
86
+ task_type=str(manifest.get("taskType", "")),
87
+ assignment_ref=args.assignment_ref, dispatch_kind=args.dispatch_kind,
88
+ ))
85
89
  and args.audience == "reverification-worker"
86
90
  )
87
91
  source = args.source_role_execution_ref
@@ -10,6 +10,7 @@ from typing import Any, Mapping
10
10
 
11
11
  from ...worker_prompt_policy import (
12
12
  CRITIC_DUTY_BY_ASSIGNMENT_SEGMENT,
13
+ is_plan_critic_verification,
13
14
  resolve_prompt_plan_for_manifest,
14
15
  )
15
16
  from .inputs import AgentPromptCliError
@@ -35,7 +36,12 @@ def _validate_run_identity(
35
36
  elif assignment_ref.startswith("critic/"):
36
37
  scope = assignment_ref.split("/", 1)[1]
37
38
  expected = CRITIC_DUTY_BY_ASSIGNMENT_SEGMENT.get(scope, "")
38
- if not expected or dispatch_kind != "critic":
39
+ if is_plan_critic_verification(
40
+ task_type=str(manifest.get("taskType", "")),
41
+ assignment_ref=assignment_ref, dispatch_kind=dispatch_kind,
42
+ ):
43
+ expected = "reverification-worker"
44
+ elif not expected or dispatch_kind != "critic":
39
45
  raise AgentPromptCliError("critic assignment identity is invalid")
40
46
  # provider 대조는 여기 두지 않는다. critic 의 `worker_id` 는 배정 참조의
41
47
  # 마지막 마디(`scope` / `acceptance`)이고 provider 이름이 아니다
@@ -61,6 +61,13 @@ def normalize_conformance_script(
61
61
  value = script[len(prefix):] if script.startswith(prefix) else script
62
62
  while value.startswith("./"):
63
63
  value = value[2:]
64
+ if task_root is not None and value.startswith(".okstra/"):
65
+ for parent in task_root.resolve().parents:
66
+ if parent.name == ".okstra" and task_root.resolve().is_relative_to(
67
+ parent / "tasks"
68
+ ):
69
+ value = str((parent.parent / value).resolve())
70
+ break
64
71
  if task_root is not None and value.startswith("/"):
65
72
  try:
66
73
  return Path(value).resolve().relative_to(task_root.resolve()).as_posix()
@@ -17,6 +17,7 @@ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_obj
17
17
 
18
18
 
19
19
  DYNAMIC_VERIFIER_SOURCE_ROLES = frozenset({
20
+ "critic",
20
21
  "analyser",
21
22
  "designer",
22
23
  "planner",
@@ -163,12 +164,14 @@ def reserve_dynamic_verifier(
163
164
  manifest = read_execution_manifest(manifest_path)
164
165
  if manifest.legacy:
165
166
  raise ExecutionManifestError("cannot reserve a dynamic verifier in v1 data")
167
+ authority = load_owned_json_object(manifest_path)
166
168
  _validate_dynamic_verifier_request(
167
169
  manifest.role_executions,
168
170
  source_role_execution_ref,
169
171
  duty_id,
172
+ task_type=str(authority.get("taskType", "")),
173
+ dispatch_kind=dispatch_kind or f"reverify-r{round_number}",
170
174
  )
171
- authority = load_owned_json_object(manifest_path)
172
175
  if authority.get("taskKey") not in (None, task_key):
173
176
  raise ExecutionManifestError("dynamic verifier taskKey does not match manifest")
174
177
  project_root = _manifest_project_root(authority, manifest_path)
@@ -219,7 +222,12 @@ def _validate_dynamic_verifier_request(
219
222
  roles: tuple[RoleExecution, ...],
220
223
  source_role_execution_ref: str,
221
224
  duty_id: str,
225
+ *,
226
+ task_type: str,
227
+ dispatch_kind: str,
222
228
  ) -> None:
229
+ from .worker_prompt_policy import is_plan_verify_dispatch_kind
230
+
223
231
  try:
224
232
  duty_role = role_for_duty(duty_id)
225
233
  except RoleCatalogError as exc:
@@ -238,6 +246,13 @@ def _validate_dynamic_verifier_request(
238
246
  raise ExecutionManifestError(
239
247
  f"dynamic verifier source role is not eligible: {source.role}"
240
248
  )
249
+ if source is not None and source.role == "critic" and not (
250
+ task_type == "implementation-planning"
251
+ and is_plan_verify_dispatch_kind(dispatch_kind)
252
+ ):
253
+ raise ExecutionManifestError(
254
+ "critic source role is eligible only for planning verification"
255
+ )
241
256
 
242
257
 
243
258
  def _manifest_project_root(
@@ -31,7 +31,7 @@ def parse_records(paths: list[Path]) -> tuple[list[dict], int]:
31
31
  except (OSError, UnicodeDecodeError):
32
32
  skipped += 1
33
33
  continue
34
- for line in text.splitlines():
34
+ for line_number, line in enumerate(text.splitlines(), 1):
35
35
  line = line.strip()
36
36
  if not line:
37
37
  continue
@@ -42,6 +42,7 @@ def parse_records(paths: list[Path]) -> tuple[list[dict], int]:
42
42
  continue
43
43
  if isinstance(rec, dict):
44
44
  rec["_sourceLog"] = str(path)
45
+ rec["_sourceLine"] = line_number
45
46
  records.append(rec)
46
47
  else:
47
48
  skipped += 1
@@ -100,12 +100,31 @@ def prior_run_error_digest(
100
100
  )
101
101
  ],
102
102
  )
103
+ examples: dict[tuple[str, str], str] = {}
104
+ for record in carried:
105
+ message = str(record.get("message") or "")[:1200]
106
+ command = str(record.get("command") or "")[:800]
107
+ if message or command:
108
+ examples[(message, command)] = (
109
+ f"{record['_sourceLog']}:{record['_sourceLine']}"
110
+ )
111
+ details = (
112
+ _md_table(
113
+ ["Message", "Command", "Source"],
114
+ [
115
+ [message, command, source]
116
+ for (message, command), source in list(examples.items())[-20:]
117
+ ],
118
+ )
119
+ if examples
120
+ else ""
121
+ )
103
122
  return (
104
123
  "# Prior-Run Errors\n\n"
105
124
  "`contract-violation` and `tool-failure` records this task's earlier "
106
125
  "runs wrote. These are not findings about the work — they are traps "
107
126
  "that fired before and can fire again in this run.\n\n"
108
- f"{table}\n"
127
+ f"{table}\n\n{details}\n"
109
128
  )
110
129
 
111
130
 
@@ -31,6 +31,7 @@ from .json_boundary import (
31
31
  write_owned_object_atomic,
32
32
  )
33
33
  from .report_views import normalize_direction_selection_identity
34
+ from .qa_commands import find_denied_tokens
34
35
  from .scope_provenance import brief_end_state_id_sequence
35
36
  from .user_response import UserResponseError, parse_direction_selection
36
37
 
@@ -814,14 +815,31 @@ _MANUAL_VALIDATION_MARKERS = (
814
815
  )
815
816
 
816
817
 
817
- def _stage_validation_executability_errors(
818
+ def stage_validation_executability_errors(
818
819
  planning: Mapping[str, Any],
819
820
  ) -> list[str]:
820
821
  failures: list[str] = []
821
822
  for row in planning.get("validationChecklist") or ():
822
- if not isinstance(row, Mapping) or not row.get("stageRefs"):
823
+ if not isinstance(row, Mapping):
823
824
  continue
824
- lowered = str(row.get("commandOrObservation") or "").lower()
825
+ command = str(row.get("commandOrObservation") or "")
826
+ denied = find_denied_tokens(command)
827
+ if re.search(r"(?<![\w/])\.?/?\.okstra/tasks/", command):
828
+ denied.append(
829
+ "project-relative QA path (use the absolute task artifact path; keep worktree cwd)"
830
+ )
831
+ if re.search(r">\s*/(?:tmp|private/tmp|var/tmp)/", command):
832
+ denied.append(
833
+ "output outside the worktree (use a worktree-local output path)"
834
+ )
835
+ if denied:
836
+ failures.append(
837
+ f"validationChecklist {row.get('id')} conflicts with verifier command rules: "
838
+ f"{'; '.join(denied)}; correct and approve the plan before implementation"
839
+ )
840
+ if not row.get("stageRefs"):
841
+ continue
842
+ lowered = command.lower()
825
843
  matched = [
826
844
  marker for marker in _MANUAL_VALIDATION_MARKERS if marker in lowered
827
845
  ]
@@ -947,6 +965,6 @@ def validate_selected_direction_plan(
947
965
  planning, brief_end_state_id_sequence(Path(brief_path))
948
966
  )
949
967
  )
950
- failures.extend(_stage_validation_executability_errors(planning))
968
+ failures.extend(stage_validation_executability_errors(planning))
951
969
  failures.extend(_micro_stage_fold_errors(planning))
952
970
  return failures
@@ -469,6 +469,18 @@ def _label(key: str) -> str:
469
469
  return " ".join(part.capitalize() for part in key.replace("Id", " ID").split())
470
470
 
471
471
 
472
+ def _render_literal(label: str, value: str, indent: str = "") -> list[str]:
473
+ """명령·코드의 줄바꿈을 보존하고 본문보다 긴 울타리로 감싼다."""
474
+ fence = "`" * max(
475
+ 3, 1 + max((len(part) for part in re.findall(r"`+", value)), default=0)
476
+ )
477
+ body = "".join(indent + " " + row for row in value.splitlines(keepends=True))
478
+ ending = "" if value.endswith("\n") else "\n"
479
+ return [
480
+ f"{indent}- {label}:\n\n{indent} {fence}text\n{body}{ending}{indent} {fence}\n"
481
+ ]
482
+
483
+
472
484
  def _render_object_list(key: str, value: object) -> list[str]:
473
485
  if not isinstance(value, list):
474
486
  raise PlanItemContractError(f"plan item {key} must be an array")
@@ -491,6 +503,8 @@ def _render_object_list(key: str, value: object) -> list[str]:
491
503
  raise PlanItemContractError(
492
504
  f"plan item {field_path} must be scalar"
493
505
  )
506
+ elif field == "details" and isinstance(field_value, str):
507
+ rows.extend(_render_literal(f"`{field_path}`", field_value, " "))
494
508
  else:
495
509
  rows.append(
496
510
  f" - `{field_path}`: `{scalar(field_value)}`\n"
@@ -542,6 +556,8 @@ def _render_scalar_or_list(key: str, value: object, indent: str = "") -> list[st
542
556
  return rows
543
557
  if isinstance(value, Mapping):
544
558
  raise PlanItemContractError(f"plan item {key} must be scalar")
559
+ if key in {"command", "commandOrObservation"} and isinstance(value, str):
560
+ return _render_literal(_label(key), value, indent)
545
561
  return [indent + line(_label(key), value)]
546
562
 
547
563
 
@@ -72,6 +72,31 @@ def _has_insta_update_set(cmd: str) -> bool:
72
72
  return match.group(1).lower() != "no"
73
73
 
74
74
 
75
+ def find_unfrozen_installs(cmd: str) -> list[str]:
76
+ """계획과 실행 기록에서 같은 잠금파일 고정 조건을 판정한다."""
77
+ found: list[str] = []
78
+ if _has_npm_install_without_ci(cmd):
79
+ found.append("npm install (use 'npm ci' instead)")
80
+ for match in re.finditer(r"\b(pnpm|yarn|bun)\s+(?:install|i)\b([^;&|\n]*)", cmd):
81
+ flags = match.group(2).split()
82
+ frozen = "--frozen-lockfile" in flags or (
83
+ match.group(1) == "yarn" and "--immutable" in flags
84
+ )
85
+ disabled = any(
86
+ flag
87
+ in {
88
+ "--no-frozen-lockfile",
89
+ "--frozen-lockfile=false",
90
+ "--no-immutable",
91
+ "--immutable=false",
92
+ }
93
+ for flag in flags
94
+ )
95
+ if not frozen or disabled:
96
+ found.append(f"{match.group(1)} install (requires a frozen lockfile)")
97
+ return found
98
+
99
+
75
100
  def find_denied_tokens(cmd: str) -> list[str]:
76
101
  """`cmd` 안에 포함된 모든 denied 토큰 목록을 반환. 비어 있으면 안전."""
77
102
  if not isinstance(cmd, str):
@@ -84,8 +109,7 @@ def find_denied_tokens(cmd: str) -> list[str]:
84
109
  for sub in _DENIED_SUBSTRINGS:
85
110
  if sub in cmd:
86
111
  found.append(sub)
87
- if _has_npm_install_without_ci(cmd):
88
- found.append("npm install (use 'npm ci' instead)")
112
+ found.extend(find_unfrozen_installs(cmd))
89
113
  if _has_insta_update_set(cmd):
90
114
  found.append("INSTA_UPDATE=<not-no>")
91
115
  return found
@@ -93,6 +93,7 @@ STEP_SPAWN_FOLLOWUPS = "spawn-followups"
93
93
  STEP_VALIDATE_RUN = "validate-run"
94
94
  STEP_RECORD_GROUP_MEMORY = "record-group-memory"
95
95
  STEP_TEARDOWN_STAGES = "teardown-stages"
96
+ STEP_PREFLIGHT = "preflight"
96
97
 
97
98
  STEP_ORDER = (
98
99
  STEP_PROJECT_ACTIVITY,
@@ -112,6 +113,7 @@ STEP_ORDER = (
112
113
  V3_STEP_ORDER = (
113
114
  STEP_TOKEN_USAGE,
114
115
  STEP_PROJECT_ACTIVITY,
116
+ STEP_PREFLIGHT,
115
117
  # Before the render that overlays its sidecar.
116
118
  STEP_TRANSLATE,
117
119
  STEP_RENDER_VIEWS,
@@ -401,6 +403,11 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
401
403
  if ctx.report_contract_version != "3.0":
402
404
  return commands
403
405
  by_name = dict(commands)
406
+ by_name[STEP_PREFLIGHT] = [
407
+ *_validate_run_command(ctx, ctx.data_path),
408
+ "--section",
409
+ "preflight",
410
+ ]
404
411
  usage = by_name[STEP_TOKEN_USAGE]
405
412
  marker = usage.index("--substitute-data")
406
413
  by_name[STEP_TOKEN_USAGE] = usage[:marker]
@@ -585,6 +592,8 @@ def run_finalize(
585
592
 
586
593
  if only:
587
594
  selected = set(only)
595
+ if ctx.report_contract_version == "3.0" and STEP_TRANSLATE in selected:
596
+ selected.add(STEP_PREFLIGHT)
588
597
  contract_order = (
589
598
  V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
590
599
  )
@@ -603,15 +612,20 @@ def run_finalize(
603
612
 
604
613
  first_failure = ""
605
614
  validated = False
615
+ preflight_failed = False
606
616
  for name, command in commands:
607
617
  # 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라지고, 검증 안 된
608
618
  # 레코드를 기억하면 형제 task 가 그것을 결론으로 읽는다.
609
619
  if name in _SKIPPED_AFTER_FAILURE and first_failure:
610
620
  continue
621
+ if name == STEP_TRANSLATE and preflight_failed:
622
+ continue
611
623
  if before_step is not None:
612
624
  before_step(name)
613
625
  result = _run_finalize_step(ctx, name, command)
614
626
  steps.append(step_payload(name, command, result))
627
+ if name == STEP_PREFLIGHT:
628
+ preflight_failed = result.returncode != 0
615
629
  if result.returncode != 0 and not first_failure:
616
630
  first_failure = f"{name} failed with exit code {result.returncode}"
617
631
  if name == STEP_VALIDATE_RUN and result.returncode == 0:
@@ -1082,6 +1096,12 @@ def closeout_command(result: Mapping[str, Any]) -> dict[str, str]:
1082
1096
  if isinstance(step, Mapping)
1083
1097
  )
1084
1098
  if failed_validate:
1099
+ recovery = promote_next_phase(result.get("nextRecommendedPhase"))
1100
+ if recovery["status"] == STATUS_BLOCKED and recovery["phase"]:
1101
+ return {
1102
+ "command": f"/okstra-run → {recovery['phase']}",
1103
+ "note": recovery["rationale"],
1104
+ }
1085
1105
  return {
1086
1106
  "command": "/okstra-run",
1087
1107
  "note": "validate-run failed — name the blocking cause in one line, "
@@ -394,6 +394,16 @@ def _validate_approved_plan_conformance(path: Path) -> None:
394
394
  if loaded is None:
395
395
  return
396
396
  data_path, data = loaded
397
+ from .implementation_direction import stage_validation_executability_errors
398
+
399
+ command_errors = stage_validation_executability_errors(
400
+ data.get("implementationPlanning") or {}
401
+ )
402
+ if command_errors:
403
+ raise PrepareError(
404
+ f"approved plan command preflight failed: {data_path}\n"
405
+ + "\n".join(command_errors)
406
+ )
397
407
  bad = malformed_conformance_stages(data)
398
408
  if not bad:
399
409
  return
@@ -391,6 +391,19 @@ def complete_reverify_instruction(
391
391
  if not body.lstrip().startswith(("## ", "**")):
392
392
  body = "## Instructions\n\n" + body
393
393
  body = "\n".join(prefix) + "\n\n" + body
394
+ if task_type == "implementation-planning":
395
+ # 저장된 금지 목록은 감사 대조용으로 유지하되 폐기된 작성 의무를 정정한다.
396
+ body = body.rstrip() + (
397
+ "\n\n## Planning conformance ownership\n\n"
398
+ "Planning declares conformance commands and required dependencies; "
399
+ "implementation writes the QA scripts, manifest, and tsconfig. "
400
+ "Do not create those files during planning. Their absence before "
401
+ "implementation is not a planning defect by itself. This phase "
402
+ "ownership supersedes any legacy requirement in the frozen Forbidden "
403
+ "actions block saying this phase MUST write those artifacts. "
404
+ "Verify that the plan assigns their creation to implementation and "
405
+ "provides executable commands and required dependencies.\n"
406
+ )
394
407
  if _OUTPUT_CONTRACT_HEADING not in body:
395
408
  body = body.rstrip() + "\n\n" + output_contract.strip() + "\n"
396
409
  errors = _validate_output_contract_block(body)
@@ -121,6 +121,16 @@ def is_plan_verify_dispatch_kind(dispatch_kind: str) -> bool:
121
121
  return _numbered_round(dispatch_kind, PLAN_VERIFY_DISPATCH_KIND_PREFIX) is not None
122
122
 
123
123
 
124
+ def is_plan_critic_verification(
125
+ *, task_type: str, assignment_ref: str, dispatch_kind: str,
126
+ ) -> bool:
127
+ return (
128
+ task_type == "implementation-planning"
129
+ and assignment_ref == "critic/scope"
130
+ and is_plan_verify_dispatch_kind(dispatch_kind)
131
+ )
132
+
133
+
124
134
  def verification_dispatch_round(dispatch_kind: str) -> int | None:
125
135
  """검증 kind 가 적는 라운드 번호. 번호 없는 kind(`critic-verify`)는 1,
126
136
  검증 kind 가 아니거나 번호가 깨졌으면 None.
@@ -28,19 +28,21 @@ Single read-side entry point for okstra runtime inspection plus the one status m
28
28
 
29
29
  ## Step 0: Preflight (shared)
30
30
 
31
+ Resolve `<host-runtime>` from the launcher's `OKSTRA_RUNTIME_HOST` when present; otherwise use the registered host ID declared by the current harness (`codex` in Codex, `claude-code` in Claude Code). This follows `okstra-run`'s host selection rule. Do not infer the host from worker models, installed executables, or `PATH`. If neither source identifies the host, report that it is unknown instead of substituting Claude Code. Keep the same resolved host when retrying against another project directory.
32
+
31
33
  <!-- BEGIN FRAGMENT: bash-invocation-rule -->
32
34
  Run one Bash tool call, starting with the literal token `okstra` (never wrapped in `if`/`eval`/`export`/`$(...)`/`VAR=...`/`||`/`&&`/`npx` — a non-literal leading token defeats the `Bash(okstra:*)` permission match):
33
35
  <!-- END FRAGMENT: bash-invocation-rule -->
34
36
 
35
37
  ```bash
36
- okstra preflight --runtime claude-code
38
+ okstra preflight --runtime <host-runtime>
37
39
  ```
38
40
 
39
41
  The project check only sees the cwd of the Bash call. When the user is asking about a project that is **not** the cwd (a sibling repo, a monorepo subdir, or a project named explicitly in the request), the bare form can report `Okstra preflight: failed` — a false negative, not a missing setup; do not hard-stop on it.
40
42
 
41
43
  Branch on the fixed first line:
42
44
  - `Okstra preflight: ready` → carry `Project root` as a literal string; it is the base for every sub-command step below.
43
- - `Okstra preflight: failed` → before concluding "no setup", ask whether the user pointed at a specific project directory. If they did, re-run targeting it: `okstra preflight --runtime claude-code --cwd <that-dir>` (`--cwd` is the sanctioned way to target a project — a leading `cd` would break the permission match). Only if this also fails do you show `Reason` and `Recovery`, then stop.
45
+ - `Okstra preflight: failed` → before concluding "no setup", ask whether the user pointed at a specific project directory. If they did, re-run targeting it: `okstra preflight --runtime <host-runtime> --cwd <that-dir>` (`--cwd` is the sanctioned way to target a project — a leading `cd` would break the permission match). Only if this also fails do you show `Reason` and `Recovery`, then stop.
44
46
 
45
47
  <!-- BEGIN FRAGMENT: preflight-outdated-cli -->
46
48
  If the call fails with `unknown command: preflight`, the `okstra` binary on PATH predates this skill — tell the user to update it (`npm i -g okstra@latest`), then stop (`/okstra-setup` does not update the binary).
@@ -475,4 +475,4 @@ Do not read the wizard state file directly. `okstra wizard outcome` exposes any
475
475
  - Echo each captured answer (`result.echo`) on one short line so the user sees what was registered.
476
476
  - Name every file you show the user as a markdown link — `[<what it is>](<path>)`, with the path inside the parentheses. That is the only form the host renders as clickable; a path in backticks is text the user has to copy out. The `report-finalize` result's `reportPaths.markdown` carries the run's report, report record, and team state already in that form. Commands stay in backticks — a link is for a file, not for something to run.
477
477
  - Never invent identity; if a `text` prompt returns an empty answer where the wizard rejects it, the user must retry.
478
- - 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. Take the pointer from the `report-finalize` result's top-level `nextRecommendedPhase` (`phase`, `status`, `rationale`; also on stderr as `next phase status:` / `next phase:` / `next phase rationale:`) — do not re-derive it from the report, and treat a `nextRecommendedPhaseError` as "pointer unreadable", said in one line before the `validate-run` branch. The same result also carries `nextCommand` — `{command, note}`, the table below already applied to this run. When `command` is non-empty it is the close; when it is empty the `note` says what to do with the `rationale` instead. After `implementation-planning`, open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. No open approval blocker → `/okstra-run` → `implementation` or `--approve` (do not start another planning run; do not say `/okstra-inspect`). For every other task type, quote the pointer's `rationale` in every branch — that sentence is the report's own reason and it is what the user asked to be analysed. Pointer `status: ready` → `/okstra-run` for that phase; `status: terminal` → say the task is finished, name any follow-up tasks this run registered, and do not say `/okstra-inspect`; `status: blocked` → issue the command the `rationale` calls for (`/okstra-user-response` for the `C-NNN` ids, `/okstra-run` for the phase it names); `validate-run` failed → one-line cause then `/okstra-run`; otherwise `/okstra-inspect status`.
478
+ - 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. Take the pointer from the `report-finalize` result's top-level `nextRecommendedPhase` (`phase`, `status`, `rationale`; also on stderr as `next phase status:` / `next phase:` / `next phase rationale:`) — do not re-derive it from the report, and treat a `nextRecommendedPhaseError` as "pointer unreadable", said in one line before the `validate-run` branch. The same result also carries `nextCommand` — `{command, note}`, the table below already applied to this run. When `command` is non-empty it is the close; when it is empty the `note` says what to do with the `rationale` instead. After `implementation-planning`, open `blocks: approval` rows → `/okstra-user-response`. A recorded `accept-risk` / `select` / `answer` is not an open blocker. No open approval blocker → `/okstra-run` → `implementation` or `--approve` (do not start another planning run; do not say `/okstra-inspect`). For every other task type, quote the pointer's `rationale` in every branch — that sentence is the report's own reason and it is what the user asked to be analysed. Pointer `status: ready` → `/okstra-run` for that phase; `status: terminal` → say the task is finished, name any follow-up tasks this run registered, and do not say `/okstra-inspect`; `status: blocked` → issue the command the `rationale` calls for (`/okstra-user-response` for the `C-NNN` ids, `/okstra-run` for the phase it names); `validate-run` failed → one-line cause then `/okstra-run` for the blocked pointer's `phase` when present, otherwise the current phase; otherwise `/okstra-inspect status`.
@@ -108,11 +108,14 @@ def verifier_mutation_hits(command_log: str) -> list[tuple[str, str]]:
108
108
  로그는 자유 문자열이라 줄 단위로 본다 — 어느 명령이 문제인지 그대로 보고해야
109
109
  작성자가 고칠 자리를 안다. heredoc 본문은 기록되는 데이터이므로 걷어낸다.
110
110
  """
111
+ from okstra_ctl.qa_commands import find_unfrozen_installs
112
+
111
113
  hits: list[tuple[str, str]] = []
112
114
  for raw in strip_heredoc_bodies(command_log or "").splitlines():
113
115
  line = raw.strip()
114
116
  if not line or line.startswith("#"):
115
117
  continue
118
+ hits.extend((label, line) for label in find_unfrozen_installs(line))
116
119
  for label, pattern in (*_VERIFIER_MUTATION, *_VERIFIER_SOURCE_MUTATION):
117
120
  if pattern.search(line):
118
121
  hits.append((label, line))
@@ -902,11 +902,22 @@ def update_workflow_metadata(
902
902
  if current_phase:
903
903
  phase_states[current_phase] = current_phase_state
904
904
  last_completed_phase = workflow.get("lastCompletedPhase", "")
905
+ recovery = next_phase.project(report_data or {})
906
+ recovery_phase = recovery["phase"]
907
+ can_backtrack = (
908
+ current_phase in PHASE_SEQUENCE
909
+ and recovery_phase in PHASE_SEQUENCE
910
+ and PHASE_SEQUENCE.index(recovery_phase)
911
+ < PHASE_SEQUENCE.index(current_phase)
912
+ )
905
913
  next_recommended_phase = next_phase.make(
914
+ phase=recovery_phase if can_backtrack else "",
906
915
  status=next_phase.STATUS_BLOCKED,
907
916
  rationale=_blocked_rationale(
908
- current_phase,
909
- inherited_pointer["rationale"],
917
+ recovery_phase if can_backtrack else current_phase,
918
+ recovery["rationale"]
919
+ if can_backtrack
920
+ else inherited_pointer["rationale"],
910
921
  failures,
911
922
  run_manifest_path=str(run_manifest.get("runManifestPath") or ""),
912
923
  ),
@@ -2197,6 +2208,12 @@ def _validate_planning_conformance_declared(
2197
2208
  if not isinstance(ip, dict):
2198
2209
  return
2199
2210
  _planning_conformance_declarations(ip.get("stages"), failures)
2211
+ if ip.get("planningContract") != "selected-direction":
2212
+ from okstra_ctl.implementation_direction import (
2213
+ stage_validation_executability_errors,
2214
+ )
2215
+
2216
+ failures.extend(stage_validation_executability_errors(ip))
2200
2217
  for conflict in exempt_stage_surface_conflicts(data, surface_patterns):
2201
2218
  failures.append(
2202
2219
  "conformance gate BLOCKING: stage "
@@ -9363,13 +9380,62 @@ def _data_schema_failures(data: dict) -> list[str]:
9363
9380
  ]
9364
9381
 
9365
9382
 
9383
+ def run_preflight(report_path: Path, run_manifest_path: Path) -> int:
9384
+ """번역·표시·상태 갱신 전에 정본의 구조와 적합성만 검사한다."""
9385
+ try:
9386
+ data = load_json(_data_path_for(report_path))
9387
+ manifest = load_json(run_manifest_path)
9388
+ if not isinstance(data, dict) or not isinstance(manifest, dict):
9389
+ raise TypeError("preflight requires report and run-manifest JSON objects")
9390
+ except (OSError, ValueError, TypeError) as exc:
9391
+ print(json.dumps({"ok": False, "failures": [str(exc)]}))
9392
+ return 2
9393
+ failures = _data_schema_failures(data)
9394
+ task_type = manifest.get("taskType")
9395
+ warnings: list[str] = []
9396
+ if task_type in ("implementation", "final-verification"):
9397
+ project_root = Path(
9398
+ str(manifest.get("projectRoot") or run_manifest_path.parent)
9399
+ )
9400
+ warnings = _validate_conformance(
9401
+ report_path,
9402
+ failures,
9403
+ surface_patterns=_project_surface_patterns(project_root),
9404
+ approved_plan_path=_approved_plan_path_from_run_inputs(
9405
+ run_manifest_path, failures
9406
+ ),
9407
+ )
9408
+ elif task_type == "implementation-planning":
9409
+ from okstra_ctl.implementation_direction import (
9410
+ stage_validation_executability_errors,
9411
+ )
9412
+
9413
+ failures.extend(
9414
+ stage_validation_executability_errors(
9415
+ data.get("implementationPlanning") or {}
9416
+ )
9417
+ )
9418
+ _validate_planning_conformance_declared(report_path, failures)
9419
+ print(
9420
+ json.dumps(
9421
+ {
9422
+ "ok": not failures,
9423
+ "failures": list(dict.fromkeys(failures)),
9424
+ "warnings": warnings,
9425
+ },
9426
+ ensure_ascii=False,
9427
+ )
9428
+ )
9429
+ return 2 if failures else 0
9430
+
9431
+
9366
9432
  def main() -> int:
9367
9433
  parser = argparse.ArgumentParser(
9368
9434
  description="Validate okstra run contract artifacts."
9369
9435
  )
9370
9436
  parser.add_argument(
9371
9437
  "--section",
9372
- choices=(SECTION_FULL, SECTION_PLAN_BODY),
9438
+ choices=(SECTION_FULL, SECTION_PLAN_BODY, "preflight"),
9373
9439
  default=SECTION_FULL,
9374
9440
  help=(
9375
9441
  "Which contract surface to validate. `full` (default) validates the "
@@ -9415,6 +9481,13 @@ def main() -> int:
9415
9481
  )
9416
9482
  args = parser.parse_args()
9417
9483
 
9484
+ if args.section == "preflight":
9485
+ if not args.report or not args.run_manifest:
9486
+ parser.error("--section preflight requires --report and --run-manifest")
9487
+ return run_preflight(
9488
+ Path(args.report).resolve(), Path(args.run_manifest).resolve()
9489
+ )
9490
+
9418
9491
  if args.section == SECTION_PLAN_BODY:
9419
9492
  if args.narrative and args.state and not args.report:
9420
9493
  return run_plan_body_inputs(
@@ -561,6 +561,11 @@ def _progress_line_from_event(event) -> tuple[str, str, str] | None:
561
561
  message = details.get("message")
562
562
  tail = f" {message}" if isinstance(message, str) and message else ""
563
563
  line = f"PROGRESS: {phase}{tail}"
564
+ worker = details.get("worker")
565
+ if isinstance(worker, str) and worker.strip():
566
+ from okstra_ctl.lead_progress import render_progress_line
567
+
568
+ line = render_progress_line(phase, [("worker", worker), ("detail", line)])
564
569
  return (event.timestamp, phase, line)
565
570
 
566
571