okstra 0.165.2 → 0.165.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.165.2",
3
+ "version": "0.165.3",
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.165.2",
3
- "builtAt": "2026-08-11T05:13:20.960Z",
2
+ "package": "0.165.3",
3
+ "builtAt": "2026-08-11T07:58:44.694Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -19,7 +19,7 @@ prompt (agents/workers/_cli-wrapper-template.md → Prompt Composition).
19
19
 
20
20
  # Pre-commit diff review sweep (BLOCKING — before the executor's final commit)
21
21
 
22
- Lint/test green is necessary but NOT sufficient self-mocked tests, interaction-only assertions, untruthful names, and unreadable functions all pass a green pipeline. This sweep is what keeps them out of the diff. Run it once, after the last `Edit` / `Write` of the stage, before the final commit. Fix every finding in place (you are the executor — you may edit); this is a prevention pass, not a report you hand off.
22
+ This is where the preflight's conventions get enforced against the code you actually wrote. Run it once, after the last `Edit` / `Write` of the stage, before the final commit. Fix every finding in place (you are the executor — you may edit); this is a prevention pass, not a report you hand off.
23
23
 
24
24
  Do not scan holistically and stop when it "looks fine". Work the matrix exhaustively — the failure mode this gate exists to prevent is a real defect surviving because you eyeballed the diff instead of enumerating it.
25
25
 
@@ -48,4 +48,4 @@ End your audit-sidecar entry for this sweep with a one-line `Coverage:` footer n
48
48
 
49
49
  ## Graceful degradation
50
50
 
51
- When the routed coding-preflight pack is unreadable (codex / antigravity runtime, or the files are absent), do NOT skip the sweep — apply the language-agnostic principles the preflight already listed (no self-mocking, behavioral assertions, truthful/standalone names, single-purpose ≤50-line functions) plus the project's `CLAUDE.md` / `CONTRIBUTING` / lint config, and record `diff-review: resource-unavailable → applied <agnostic principles + project rules>` with the Coverage footer. Never claim a resource read that did not happen.
51
+ When the routed coding-preflight pack is unreadable (codex / antigravity runtime, or the files are absent), do NOT skip the sweep — fall back to the always-binding principles the preflight enumerates plus the project's `CLAUDE.md` / `CONTRIBUTING` / lint config, and record `diff-review: resource-unavailable → applied <agnostic principles + project rules>` with the Coverage footer.
@@ -19,17 +19,25 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
19
19
 
20
20
  ## Pre-implementation context exploration (executor before first edit)
21
21
 
22
- - **Coding-conventions preflight (BLOCKING runs before the first `Edit` / `Write`, and binds the TDD loop below).** The gate body is a single source at `prompts/profiles/_coding-conventions-preflight.md` (sibling of this sidecar). Do NOT re-type that content from memory deliver it by file so it cannot drift or be dropped:
23
- - **Native-session executor:** Read `_coding-conventions-preflight.md` end-to-end before the first edit, then state in ONE line which conventions apply (e.g. `Applying TS + hexagonal overlay; domain at src/domains/*/domain/`).
24
- - **CLI-wrapper executor (BLOCKING):** the executor process does NOT share the lead's context, and it cannot read this sidecar's directory — that path sits outside the CLI sandbox and the CLI only sees its prompt, so a file reference never reaches it. The lead MUST physically append the **body** of `_coding-conventions-preflight.md` into the persisted executor prompt at dispatch time. Never hand-retype it. Enforcement: CLI wrapper agents refuse an implementation-Executor dispatch whose persisted prompt lacks the literal heading `Coding-conventions preflight`, returning `<SENTINEL_PREFIX>_PREFLIGHT_MISSING` (see `agents/workers/_cli-wrapper-template.md` → Prompt Composition).
25
- - **Pre-commit diff review sweep (BLOCKING — runs AFTER the last `Edit` / `Write`, BEFORE the final commit).** The gate body is a single source at `prompts/profiles/_implementation-diff-review.md` (sibling of this sidecar): an exhaustive file×rule sweep of the actual diff against the conventions the preflight loaded, with fix-in-place and a `Coverage:` footer. Do NOT re-type it from memory deliver it by file so it cannot drift.
26
- - **Native-session executor:** Read `_implementation-diff-review.md` end-to-end after the stage's last edit, run the sweep over `git diff <stage-base>..HEAD`, fix findings in place, and write its `Coverage:` footer to your audit sidecar before committing.
27
- - **CLI-wrapper executor:** the CLI process cannot read this path outside its sandbox. The lead appends this file's body into the persisted executor prompt at dispatch time, between the preflight body and the self-check body (see `okstra_ctl.initial_prompt_materialization.materialize_initial_prompts()`). The head-less executor honours all three gates from the single prompt.
28
- - **Completion self-check (BLOCKING runs BEFORE you claim the stage done).** The gate body is a single source at `prompts/profiles/_implementation-self-check.md` (sibling of this sidecar): the completion gate (diff-review Coverage footer present, functions ≤50 lines, conventions applied, truthful names & why-comments, real build/test run, cleanup). Do NOT re-type it from memory — deliver it by file so it cannot drift.
29
- - **Native-session executor:** Read `_implementation-self-check.md` end-to-end before appending the `status:"done"` row, then write the confirming evidence per item to your audit sidecar.
30
- - **CLI-wrapper executor:** the CLI process cannot read this path outside its sandbox. The lead appends this file's body into the persisted executor prompt at dispatch time, immediately after the diff-review body (see `okstra_ctl.initial_prompt_materialization.materialize_initial_prompts()`). The head-less executor honours all three gates from the single prompt.
31
- - **Stage discipline transcription (when a preceding stage is `done`):** the lead MUST transcribe the `Stage discipline` rule (from this run's rendered profile — the INCLUDEd `_stage-discipline.md` body) verbatim into every dispatched CLI-wrapper executor prompt so it honors the prior-stage behavior-freeze. Declaration-level — no wrapper sentinel.
32
- - **Non-interactive auto-execution (BLOCKING for `runner=cli-wrapper`).** A CLI-wrapper executor runs head-less — there is no human at the keyboard. Skills loaded during the run (tdd, coding-preflight, and others) contain "get user approval", "state your plan to the user and wait", or "ask before proceeding" gates written for interactive sessions; in this run those gates are **already satisfied** by the upstream `implementation-planning` approval (the plan this stage executes was human-approved). The executor MUST NOT stop to request approval, MUST NOT end its turn after only producing a plan, and MUST carry the stage through end-to-end — RED → GREEN → refactor → per-cycle commit → `### Stage Carry Evidence`. The ONLY skill step to skip is the interactive user-approval prompt itself; every other skill rule (TDD discipline, conventions, real-IO isolation) still binds. The lead MUST transcribe this bullet verbatim into the dispatched CLI-wrapper executor prompt (same reason as the preflight transcription rule above — the CLI process does not share lead context). Stopping early for approval in a head-less run is the observed empty-exit failure (exit 0, no diff): treat it as `contract-violated`.
22
+ - **Three BLOCKING gates bind this run, and their bodies travel with this prompt** inlined under their own headings, or named by path under `## Required prompt resources`. Follow the delivered body verbatim; a gate re-typed from memory is a skipped gate. Each gate's own body owns its rule list, so nothing here restates it:
23
+ - `Coding-conventions preflight` (`prompts/profiles/_coding-conventions-preflight.md`) before the first `Edit` / `Write`. It loads the conventions and binds the TDD loop below; close it by stating in ONE line which conventions apply (e.g. `Applying TS + hexagonal overlay; domain at src/domains/*/domain/`).
24
+ - `Pre-commit diff review sweep` (`prompts/profiles/_implementation-diff-review.md`) after the stage's last `Edit` / `Write`, before the final commit. Sweep `git diff <stage-base>..HEAD` against the conventions the preflight loaded, fix findings in place while they are inside this stage's scope, and write its `Coverage:` footer to your audit sidecar.
25
+ - `Implementation self-check` (`prompts/profiles/_implementation-self-check.md`) before you append the `status:"done"` row. Write the confirming evidence per item to your audit sidecar.
26
+
27
+ <!--
28
+ Gate delivery (lead / maintainer stripped before this body reaches a worker).
29
+ `okstra_ctl.initial_prompt_materialization` appends all three bodies to the
30
+ persisted executor prompt in that order under EAGER_INCLUDE, and lists their
31
+ paths under `## Required prompt resources` under LAZY_PATH_REFERENCE; a CLI
32
+ executor cannot read the profiles directory, so the file reference alone never
33
+ reaches it. Enforcement: the CLI wrapper refuses an Executor dispatch whose
34
+ persisted prompt lacks the heading `Coding-conventions preflight`
35
+ (`<SENTINEL_PREFIX>_PREFLIGHT_MISSING`) or either post-write heading
36
+ (`<SENTINEL_PREFIX>_POSTWRITE_GATE_MISSING`) — see
37
+ `agents/workers/_cli-wrapper-template.md` → Prompt Composition.
38
+ -->
39
+ - **Stage discipline (when a preceding stage is `done`):** its code is behavior-frozen — you may call, extend, or compose with it, never change what it already does. The rule body travels with this prompt the same way the gates do (`prompts/profiles/_stage-discipline.md`); only its `implementation` bullet binds you, the `implementation-planning` one binds the planner. Declaration-level — no wrapper sentinel.
40
+ - **Non-interactive auto-execution (BLOCKING for `runner=cli-wrapper`).** A CLI-wrapper executor runs head-less — there is no human at the keyboard. Skills loaded during the run (tdd, coding-preflight, and others) contain "get user approval", "state your plan to the user and wait", or "ask before proceeding" gates written for interactive sessions; in this run those gates are **already satisfied** by the upstream `implementation-planning` approval (the plan this stage executes was human-approved). The executor MUST NOT stop to request approval, MUST NOT end its turn after only producing a plan, and MUST carry the stage through end-to-end — RED → GREEN → refactor → per-cycle commit → `### Stage Carry Evidence`. The ONLY skill step to skip is the interactive user-approval prompt itself; every other skill rule (TDD discipline, conventions, real-IO isolation) still binds. Stopping early for approval in a head-less run is the observed empty-exit failure (exit 0, no diff): treat it as `contract-violated`.
33
41
  - **Mandatory TDD loop**: BEFORE the first `Edit` or `Write` call, the executor MUST apply a red-green-refactor loop for every code change in this run. This is required; skipping it is a `contract-violated` outcome. This governs HOW each step is executed (failing test first → minimal implementation → refactor); it does not override the approved plan's WHAT/file scope.
34
42
  - Order of operations per plan step: (1) write/extend the test that captures the step's acceptance criterion and confirm it fails for the right reason, (2) implement the minimum change to make it pass, (3) commit the test and its implementation together in a single commit (`feat|fix(<scope>): ...`) — do NOT commit the failing test separately, (4) refactor without changing behaviour and commit separately if any cleanup is made (`refactor(<scope>): ...`). The failing-then-passing transition is preserved as `TDD evidence` in the final report (failing output captured before the merged commit, passing output after), not as two separate commits.
35
43
  - Doc-only / config-only / pure-rename steps that have no observable runtime behaviour are exempt from the failing-test requirement, but the executor MUST cite the exemption per step in the final report (`TDD exemption: <reason>`).
@@ -37,10 +45,10 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
37
45
  - **DB / IO / SQL changes require real execution — mock-only is NOT validation evidence:** when this run's diff touches DB/IO/SQL (ORM / query-builder code — sequelize / typeorm / prisma / knex / raw SQL — `*.repository.*`, model/entity files, `migrations/**`, `*.sql`, or any changed query string), a mocked unit test cannot observe the SQL the query builder actually emits (observed failure class: `_implementation-verifier.md` §"DB / IO / SQL change — real-execution gate"). The executor MUST run the change against a real (or faithful-replica) datastore — the `db-test` validation step (plan `validation` db step, else `project.json.qaCommands.db-test`), targeting a **local / replica** DB — and cite its exact command + exit code in the final report's `Validation evidence`. If no real DB / `db-test` command is reachable, do NOT claim the change verified: label the DB portion `static-analysis only …, unverified (not executed)` in the report, surface it in the routing recommendation, and never downplay the real run as "too heavy". `git push` stays forbidden (universal list); the unverified DB state is carried forward so `final-verification` cannot accept it and `release-handoff` cannot push.
38
46
  - **External-source adapters — structure AND fixture both derive from a captured real sample; a self-authored fixture is NOT reality evidence:** when this run's diff builds or changes an `external-interface` or `transformation-mapping` surface (an HTTP / network client, or a parser / mapper of a third-party payload — HTML / JSON / XML / CSV originating outside this repo), the adapter's structural assumptions (selectors, field paths, expected response shape) AND the static fixture / golden that tests them MUST BOTH derive from a **captured real sample** of that payload — the capture cited in the stage's `external-interface` / `transformation-mapping` design-prep item, or one captured this run and recorded with its `source` + capture time. The captured sample is a static fixture (no live socket), so a parser test against it stays in source like any unit test — the Real-IO isolation rule below governs *live* calls, not the captured bytes. Do NOT hand-invent the shape and then hand-write a fixture that agrees with it: the passing test then only proves the code matches your assumption, never that the assumption matches reality (self-confirming oracle — the observed failure was a parser whose selectors existed in its synthetic fixture and in zero real pages: hundreds of green units over a fiction, and the whole structure built on the wrong shape). When no real sample is reachable (no network this run, or the brief supplied none), do NOT synthesize a stand-in and present its green tests as correctness: mark the adapter's shape `reality-unverified (no captured sample)` in `Validation evidence`, keep any placeholder fixture explicitly labelled an assumption (never validation evidence), and surface an explicit **user-owned** item in the routing recommendation to confirm against real data. Unlike the DB gate above this does NOT itself block acceptance — live external verification stays a user-owned item per `final-verification`'s External QA advisory policy — but a synthetic external fixture presented as reality-verified is exactly the mock-only external evidence the `final-verification` test-correctness pass is meant to reject.
39
47
  - **Real-IO test isolation (BLOCKING).** A test that exercises a **real** datastore, HTTP endpoint, external service, message queue, or filesystem — a live DB connection / DSN, a real `fetch` / `axios` / `http` request, an actual S3 / queue client, anything the project's normal CI test suite cannot run because that backend is absent — MUST be written under the task's qa scripts directory `<task_root>/qa/scripts/` (`<TASK_QA_PATH>/scripts`; the `qa/` root itself holds only data sidecars — the Tier 3 conformance manifest and `result-*.json`). It MUST NOT be written into the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, or anywhere the project's lint/test globs collect. Two reasons: (a) the project's CI / normal suite has no real DB or network, so a real-IO test placed in source silently breaks the pipeline; (b) it is an okstra verification artifact, and the artifact-home rule confines okstra outputs to `.okstra/`. **The dividing line is the IO, not the intent:** a unit test that stubs/spies only *injected collaborators* (mock — no real socket, no real DB handle) is a TDD red-green artifact and stays in source; the moment a test opens a real connection or makes a real network call it belongs in qa. A stage's real-IO requirement check is a Tier 3 conformance script under `<task_root>/qa/scripts/` (declared via the implementation-planning conformance entry) — never smuggle real IO into a `*.spec.*` in source to make it run "as a unit test". The `db-test` real-execution gate above is satisfied by the conformance/db-test path against the replica, NOT by adding a live-DB `*.spec.*` to the project suite. **Author qa specs with the project's own test framework — never hand-roll `describe`/`it`/`expect`.** When the project ships a test runner as a devDependency (jest / vitest / pytest …), the qa spec uses it, invoked with the project config plus a discovery override pointing at the qa scripts dir (jest: `npx jest --config <project jest config> --roots <task_root>/qa/scripts --runInBand <spec-name>`) — the project config keeps module aliases resolving while the default sweep never collects the file; never widen the project's own test config to include qa paths. For TypeScript qa specs also write `<task_root>/qa/scripts/tsconfig.json` (`extends` the project tsconfig, adds the runner's `types` entry, `"include": ["**/*.ts"]`) so editors resolve path aliases and test globals — it is a qa artifact like the rest (untracked). **These qa artifacts stay untracked — never commit them.** `.okstra/**` is gitignored (the artifact-home rule); conformance scripts and their results are *executed* and recorded in the carry sidecar / verifier result, never written into git history. A committed `.okstra/qa` file is a stage-branch defect that leaks okstra internals into the eventual PR (see the `git add` rules below).
40
- - re-read the approved plan end-to-end and parse the `## 5.5 Stage Map`. Read the **Stage** injected in the launch prompt (`Stage for this implementation run`): the single stage number this run owns. The runtime already selected and reserved this stage (one run = one stage) — do NOT recompute the start stage from `consumers.jsonl`.
48
+ - read the approved plan at this prompt's `**Approved plan:**` anchor end-to-end and parse the `## 5.5 Stage Map`. Read this prompt's `**Stage for this implementation run:**` anchor: the single stage number this run owns. The runtime already selected and reserved this stage (one run = one stage) — do NOT recompute the start stage from `consumers.jsonl`. Both anchors are generated headers; when either is missing, stop and report `contract-violated` rather than inferring the value.
41
49
  - load every `runs/<plan-key>/carry/stage-<i>.json` for `i ∈ depends-on(this stage)` and inject them into the executor's working context as "runtime carry-in". For a `depends-on (none)` stage, no sidecar load — task-brief only.
42
50
  - this stage's `depends-on` are all already `status:done`. Its file list, step order, Stage Validation commands, Stage Exit Contract, and rollback path are the authoritative scope.
43
- - **Clarification answers carried in (read before the first edit; inlined for CLI executors):** when `instruction-set/clarification-response.md` exists, it carries the user's answers to the approved plan's `## 1. Clarification Items` rows (the planning HTML form's `# Attached User Responses`). Treat each answer as an authoritative refinement of the plan's scope for the matching row; an answer that contradicts or expands the approved scope beyond the plan is a re-plan trigger (route to a new `implementation-planning` run), not a silent in-run change. **CLI executor (codex/antigravity):** that file sits outside the CLI sandbox, so the lead MUST transcribe its body into the dispatched executor prompt at dispatch time (same rule as the preflight / stage-discipline transcription above) — a path reference never reaches the CLI process.
51
+ - **Clarification answers carried in (read before the first edit):** when the user answered the approved plan's `## 1. Clarification Items` rows, those answers arrive with this prompt under the heading `# Clarification answers carried in (authoritative)`, or as the `Clarification response` path under `## Inputs`. Treat each answer as an authoritative refinement of the plan's scope for the matching row; an answer that contradicts or expands the approved scope beyond the plan is a re-plan trigger (route to a new `implementation-planning` run), not a silent in-run change.
44
52
  - **Effective design preparation (runtime-resolved after stage selection):**
45
53
 
46
54
  {{DESIGN_PREP_CONTEXT}}
@@ -52,7 +60,6 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
52
60
  - **drift rule** (this section): if a file *named in the plan* has materially drifted, refuse to edit and route back to planning. This protects trust in the approved scope.
53
61
  - **out-of-plan rule** (Allowed actions section below): if a step *requires touching a file NOT in the plan list*, that is permitted with `Out-of-plan edits` justification. This handles honest scope discovery during execution.
54
62
  - confirm the test/build commands referenced in the plan still exist and run from a clean state
55
- - **Pre-commit diff review sweep (BLOCKING before the executor's final commit):** run the sweep whose body is delivered via `_implementation-diff-review.md` (see the gate-delivery bullet above) over the run diff (`git diff <stage-base>..HEAD`), and fix findings in place before handing to verifiers when the issue is inside this stage's scope. That sidecar is the single source for the sweep's file×rule matrix (DRY / self-mock / behavioral-test / hexagonal / truthful-name / plain-English rules) and its `Coverage:` footer — do not maintain a second copy of the rule list here.
56
63
 
57
64
  ## Stage execution contract (this run owns one stage)
58
65
 
@@ -23,7 +23,7 @@ Every verifier acts as a QA gate, not just a diff reviewer. Trusting the executo
23
23
 
24
24
  Verifier obtains the QA command set from exactly two declared sources, in order — there is **no fallback to guessing tools from manifest files**.
25
25
 
26
- 1. **Tier 1 — plan validation set (task-specific):** every command listed under the approved plan's `validation` block (pre / mid / post).
26
+ 1. **Tier 1 — plan validation set (task-specific):** every command listed under the approved plan's `validation` block (pre / mid / post). The plan is the file at this prompt's `**Approved plan:**` anchor, scoped to the stage its `**Stage for this implementation run:**` anchor names; both are generated headers, so a missing one is `contract-violated`, never a value to infer.
27
27
  2. **Tier 2 — project baseline (`project.json.qaCommands`):** the project's standing QA baseline declared in `<PROJECT_ROOT>/.okstra/project.json` under the `qaCommands` key. Schema (each category is an array of `{ "label", "cmd", "language"? }` objects):
28
28
  ```json
29
29
  {
@@ -234,6 +234,6 @@ If every verifier present in the resolved roster ends with a non-result terminal
234
234
  - running integration / end-to-end tests that produce non-local side effects (DB writes against a non-local datastore, external API writes, docker compose against a non-isolated environment) unless that exact command is listed in the approved plan's validation set
235
235
  - redirecting tool caches or output to paths outside the worktree — e.g. setting `CARGO_TARGET_DIR`, `PYTEST_CACHE_DIR`, `NODE_OPTIONS=--require=<external>`, or any env var that causes the verifier's command to write outside the worktree's normal build artifact paths
236
236
 
237
- ## Completion self-check (declaration)
237
+ ## Executor completion self-check (not this role's gate)
238
238
 
239
- - **Completion self-check (declaration).** If this verifier edits project code (not just inspects it), the same completion gate at `prompts/profiles/_implementation-self-check.md` binds before claiming done same four items, recorded in the audit sidecar.
239
+ - The executor's `Implementation self-check` gate (`prompts/profiles/_implementation-self-check.md`) belongs to the worker that owns the diff, and its body is deliberately not delivered here: it asks for in-place fixes and break-then-restore mutation checks, every one of which this verifier is forbidden to perform. Do not re-derive its items or claim to have run it. What grades the same defects from this side is the blocking taxonomy above, applied to the diff you re-read yourself. When the executor's `Coverage:` / `Self-check coverage:` lines are among the inputs this prompt enumerates, a missing line or one whose file list does not reconcile with the diff is a blocking finding — the gate was skipped or partially run.
@@ -1,8 +1,9 @@
1
1
  <!--
2
2
  Shared stage-discipline rule. INCLUDEd by implementation-planning.md (binds the
3
- planner) and implementation.md (binds the lead). The implementation executor
4
- sidecar instructs the lead to transcribe this rule into a CLI executor prompt,
5
- since codex/antigravity executors do not share lead context.
3
+ planner) and implementation.md (binds the lead). `okstra_ctl.initial_prompt_-
4
+ materialization` also delivers this body to every implementation executor
5
+ prompt as a required resource, because a codex/antigravity executor shares no
6
+ lead context and cannot read the rendered profile.
6
7
 
7
8
  Do NOT write the literal include directive token in this file's body — the
8
9
  resolver matches it anywhere and would recurse on this file itself.
@@ -49,18 +49,30 @@ class WizardAnswerError(ValueError):
49
49
  """Raised when an answer violates the interaction plan's protocol."""
50
50
 
51
51
 
52
+ def _exact_value_match(prompt: WizardPrompt, candidate: str) -> str | None:
53
+ for option in prompt.options:
54
+ if option.value == candidate:
55
+ return option.value
56
+ return None
57
+
58
+
52
59
  def _normalize_numbered_item(prompt: WizardPrompt, answer: str) -> str:
53
60
  candidate = (answer or "").strip()
54
- exact_values = tuple(
55
- option.value for option in prompt.options if option.value == candidate
56
- )
57
- if exact_values:
58
- return exact_values[0]
61
+ # A bare number is the position in the list the user was shown. Matching
62
+ # option values first breaks every picker whose values are themselves
63
+ # numbers — a stage picker resolves "1" to stage 1 while the user was
64
+ # pointing at line 1, which is a different stage (or "every stage").
59
65
  if candidate.isdecimal():
60
66
  number = int(candidate)
61
67
  if 1 <= number <= len(prompt.options):
62
68
  return prompt.options[number - 1].value
69
+ out_of_list = _exact_value_match(prompt, candidate)
70
+ if out_of_list is not None:
71
+ return out_of_list
63
72
  raise WizardAnswerError(f"numbered-text answer is out of range: {candidate}")
73
+ exact_value = _exact_value_match(prompt, candidate)
74
+ if exact_value is not None:
75
+ return exact_value
64
76
  label_values = tuple(
65
77
  option.value for option in prompt.options if option.label == candidate
66
78
  )
@@ -17,6 +17,7 @@ from . import stage_targets
17
17
  from .design_prep import DesignPrepDecision, DesignPrepError, resolve_design_prep
18
18
  from .final_report_paths import final_report_data_path
19
19
  from .stage_reconcile import auto_reconcile_best_effort
20
+ from .worker_prompt_policy import IMPLEMENTATION_STAGE_HEADER
20
21
 
21
22
 
22
23
  class ImplementationStageError(Exception):
@@ -250,7 +251,7 @@ def publish_stage_run_claim(
250
251
  ctx["EFFECTIVE_STAGES"] = csv
251
252
  ctx["CONCURRENT_RUN_STAGES"] = ",".join(str(s) for s in claim.concurrent_stages)
252
253
  ctx["STAGE_BATCH_DIRECTIVE"] = (
253
- f"- **Stage for this implementation run:** `{csv}`. "
254
+ f"- {IMPLEMENTATION_STAGE_HEADER} `{csv}`. "
254
255
  "Execute exactly this Stage Map stage — this is the authoritative scope. "
255
256
  "Do NOT recompute from `consumers.jsonl`; the runtime already selected "
256
257
  "and reserved this stage."
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import tempfile
7
8
  from dataclasses import dataclass
8
9
  from enum import Enum
@@ -20,7 +21,12 @@ from .worker_prompt_body import (
20
21
  )
21
22
  from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
22
23
  from .worker_prompt_headers import worker_prompt_headers
23
- from .worker_prompt_policy import PromptPlan, resolve_prompt_plan_for_manifest
24
+ from .worker_prompt_policy import (
25
+ APPROVED_PLAN_HEADER,
26
+ IMPLEMENTATION_STAGE_HEADER,
27
+ PromptPlan,
28
+ resolve_prompt_plan_for_manifest,
29
+ )
24
30
 
25
31
 
26
32
  class PromptDeliveryMode(str, Enum):
@@ -94,6 +100,7 @@ _CLARIFICATION_AUTHORITY_INTRO = (
94
100
  )
95
101
  _CLARIFICATION_SOURCE_PREFIX = "Source:"
96
102
  _REQUIRED_PROMPT_RESOURCES_HEADING = "## Required prompt resources"
103
+ _HTML_COMMENTS = re.compile(r"<!--.*?-->\n?", re.DOTALL)
97
104
 
98
105
 
99
106
  @dataclass(frozen=True)
@@ -323,7 +330,7 @@ def _render_prompt(
323
330
  lines.append(
324
331
  f"**Prompt Delivery Mode:** {context.request.delivery_mode.value}"
325
332
  )
326
- lines.extend(_worktree_lines(context, item.plan))
333
+ lines.extend(_implementation_anchor_lines(context, item.plan))
327
334
  lines.extend(_prompt_body(context, state, worker, item.plan))
328
335
  lines.extend(_resource_lines(context, item))
329
336
  except InitialPromptMaterializationError:
@@ -467,12 +474,27 @@ def _resolve_prompt_resources(
467
474
  return tuple(
468
475
  _PromptResource(
469
476
  path=path.resolve(),
470
- text=_read_required_nonempty_text(path, "required prompt resource"),
477
+ text=_worker_visible_text(
478
+ _read_required_nonempty_text(path, "required prompt resource")
479
+ ),
471
480
  )
472
481
  for path in paths
473
482
  )
474
483
 
475
484
 
485
+ def _worker_visible_text(text: str) -> str:
486
+ """Strip lead-and-maintainer-only passages before a body reaches a worker.
487
+
488
+ These sidecars have two readers: the lead lazy-reads the file to run the
489
+ phase, and the worker receives the same body inlined in its prompt. An
490
+ HTML comment is the seam between them — rendered markdown hides it, so it
491
+ holds the delivery plumbing (which path feeds this file, which heading the
492
+ CLI wrapper greps for) that the worker would otherwise pay for and read as
493
+ an instruction addressed to itself.
494
+ """
495
+ return _HTML_COMMENTS.sub("", text).lstrip("\n")
496
+
497
+
476
498
  def _required_resource_path_candidates(
477
499
  context: _MaterializationContext,
478
500
  plan: PromptPlan,
@@ -481,15 +503,17 @@ def _required_resource_path_candidates(
481
503
  if plan.audience == "implementation-executor":
482
504
  paths = (
483
505
  _executor_profile_path(context),
506
+ profiles / "_stage-discipline.md",
484
507
  profiles / "_coding-conventions-preflight.md",
485
508
  profiles / "_implementation-diff-review.md",
486
509
  profiles / "_implementation-self-check.md",
487
510
  )
488
511
  elif plan.audience == "implementation-verifier":
489
- paths = (
490
- profiles / "_implementation-verifier.md",
491
- profiles / "_implementation-self-check.md",
492
- )
512
+ # The self-check body is executor-only: it is written to the worker that
513
+ # owns the diff ("fix it or surface the violation", break-and-restore
514
+ # mutation checks), and a verifier is barred from every edit it asks
515
+ # for. Its own blocking taxonomy is inlined in the verifier sidecar.
516
+ paths = (profiles / "_implementation-verifier.md",)
493
517
  else:
494
518
  return ()
495
519
  return paths
@@ -873,15 +897,35 @@ def _result_paths(
873
897
  return str(final_report_data_path(Path(expected_report))), worker_result
874
898
 
875
899
 
876
- def _worktree_lines(
900
+ def _implementation_anchor_lines(
877
901
  context: _MaterializationContext,
878
902
  plan: PromptPlan,
879
903
  ) -> list[str]:
904
+ """Render the anchors only an implementation audience carries.
905
+
906
+ The approved plan and the stage number are absolute here because a CLI
907
+ worker's cwd is the stage worktree, where a `.okstra/...` relative path
908
+ resolves against the wrong root.
909
+ """
880
910
  if plan.audience not in {
881
911
  "implementation-executor",
882
912
  "implementation-verifier",
883
913
  }:
884
914
  return []
915
+ worktree = _required_worktree_path(context)
916
+ lines = [f"**Worktree:** {worktree}"]
917
+ if plan.audience == "implementation-executor":
918
+ lines.append(f"cwd for every mutating command: {worktree}")
919
+ lines.append(
920
+ f"{APPROVED_PLAN_HEADER} {_required_approved_plan_path(context)}"
921
+ )
922
+ lines.append(
923
+ f"{IMPLEMENTATION_STAGE_HEADER} {_required_stage_number(context)}"
924
+ )
925
+ return lines
926
+
927
+
928
+ def _required_worktree_path(context: _MaterializationContext) -> str:
885
929
  executor_worktree = context.active_context.get("executorWorktree")
886
930
  value = (
887
931
  _string_value(executor_worktree.get("path"))
@@ -893,10 +937,58 @@ def _worktree_lines(
893
937
  "required_input_missing",
894
938
  "implementation prompt generation requires a worktree path",
895
939
  )
896
- lines = [f"**Worktree:** {value}"]
897
- if plan.audience == "implementation-executor":
898
- lines.append(f"cwd for every mutating command: {value}")
899
- return lines
940
+ return value
941
+
942
+
943
+ def _required_approved_plan_path(context: _MaterializationContext) -> str:
944
+ """Read the approved plan path from this run's user-input snapshot.
945
+
946
+ run-inputs owns it because it is a user input, not a rendered path — and
947
+ reading it there also keeps runs prepared before this anchor existed
948
+ materializable, since every run has always recorded it.
949
+ """
950
+ source_artifacts = context.active_context.get("sourceArtifacts")
951
+ run_inputs_rel = (
952
+ _string_value(source_artifacts.get("runInputsPath"))
953
+ if isinstance(source_artifacts, Mapping)
954
+ else ""
955
+ )
956
+ if not run_inputs_rel:
957
+ raise InitialPromptMaterializationError(
958
+ "required_input_missing",
959
+ "implementation prompt generation requires a run inputs path",
960
+ )
961
+ payload = _load_json_object(
962
+ _resolve_input_path(context.project_root, Path(run_inputs_rel)),
963
+ "run inputs",
964
+ )
965
+ inputs = payload.get("inputs")
966
+ value = (
967
+ _string_value(inputs.get("approvedPlanPath"))
968
+ if isinstance(inputs, Mapping)
969
+ else ""
970
+ )
971
+ if not value:
972
+ raise InitialPromptMaterializationError(
973
+ "required_input_missing",
974
+ "implementation prompt generation requires an approved plan path",
975
+ )
976
+ return str(_resolve_input_path(context.project_root, Path(value)))
977
+
978
+
979
+ def _required_stage_number(context: _MaterializationContext) -> str:
980
+ value = _string_value(_active_run_field(context, "stage"))
981
+ if not value:
982
+ raise InitialPromptMaterializationError(
983
+ "required_input_missing",
984
+ "implementation prompt generation requires a stage number",
985
+ )
986
+ return value
987
+
988
+
989
+ def _active_run_field(context: _MaterializationContext, key: str) -> Any:
990
+ run = context.active_context.get("run")
991
+ return run.get(key) if isinstance(run, Mapping) else ""
900
992
 
901
993
 
902
994
  def _prompt_plan(
@@ -95,7 +95,7 @@ def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
95
95
  "kind": ACTIVE_CONTEXT_KIND,
96
96
  "task": _hydrate_active_task(payload, ctx),
97
97
  "workflow": dict(_mapping(payload.get("workflow"))),
98
- "run": _hydrate_active_run(ctx),
98
+ "run": _hydrate_active_run(payload, ctx),
99
99
  "instructionSet": _hydrate_active_instruction_set(payload, ctx),
100
100
  "workers": _hydrate_active_workers(payload, ctx),
101
101
  "errorLogs": _hydrate_active_error_logs(ctx),
@@ -182,8 +182,16 @@ def _hydrate_active_task(payload: Mapping[str, Any], ctx: Mapping[str, str]) ->
182
182
  return task
183
183
 
184
184
 
185
- def _hydrate_active_run(ctx: Mapping[str, str]) -> dict[str, str]:
185
+ def _hydrate_active_run(
186
+ payload: Mapping[str, Any],
187
+ ctx: Mapping[str, str],
188
+ ) -> dict[str, str]:
189
+ # `stage` is a run input, not a derived path, so pathHints cannot rebuild
190
+ # it — it survives the round trip only by being read back off the compact
191
+ # payload.
192
+ run = _mapping(payload.get("run"))
186
193
  return {
194
+ "stage": str(run.get("stage", "") or ""),
187
195
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
188
196
  "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
189
197
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
@@ -506,6 +506,11 @@ def _active_workflow(ctx: dict) -> dict:
506
506
 
507
507
  def _active_run(ctx: dict) -> dict:
508
508
  return {
509
+ # `implementation` binds one run to one Stage Map stage, and the
510
+ # executor prompt has to name it: its sidecar forbids recomputing the
511
+ # stage from `consumers.jsonl`. Feeds the implementation prompt anchor
512
+ # in `initial_prompt_materialization`.
513
+ "stage": ctx.get("RUN_STAGE", ""),
509
514
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
510
515
  "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
511
516
  "teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
@@ -367,8 +367,14 @@ def parse_stage_map_file(markdown_path: Path) -> list[StageMapStage]:
367
367
  return _parse_schema_v2_stage_map(data, str(data_path))
368
368
 
369
369
 
370
- def _schema_v2_report(markdown_path: Path) -> dict[str, Any]:
371
- """The schema-v2 sidecar as a whole, `{}` for a v1 report."""
370
+ def schema_v2_report(markdown_path: Path) -> dict[str, Any]:
371
+ """The schema-v2 sidecar as a whole, `{}` for a v1 report.
372
+
373
+ Public because every caller that must branch on report schema needs it —
374
+ including `validators/validate-implementation-plan-stages.py`, which is a
375
+ separate process and cannot reach a private helper without copying the
376
+ sidecar-detection rule and letting the two drift.
377
+ """
372
378
  data_path = Path(markdown_path).resolve().with_suffix(".data.json")
373
379
  if not data_path.exists():
374
380
  return {}
@@ -383,7 +389,7 @@ def _schema_v2_report(markdown_path: Path) -> dict[str, Any]:
383
389
 
384
390
  def _planning_section(markdown_path: Path) -> dict[str, Any]:
385
391
  """The report's `implementationPlanning` block, `{}` for v1."""
386
- planning = _schema_v2_report(markdown_path).get("implementationPlanning")
392
+ planning = schema_v2_report(markdown_path).get("implementationPlanning")
387
393
  return planning if isinstance(planning, dict) else {}
388
394
 
389
395
 
@@ -407,7 +413,7 @@ def _stage_narratives(value: Any) -> dict[int, dict[str, Any]]:
407
413
 
408
414
  def load_planning_detail(markdown_path: Path) -> PlanningDetail:
409
415
  """Read one report's narrative rows; empty for a schema-v1 report."""
410
- report = _schema_v2_report(markdown_path)
416
+ report = schema_v2_report(markdown_path)
411
417
  planning = report.get("implementationPlanning")
412
418
  if not isinstance(planning, dict) or not planning:
413
419
  return PlanningDetail({}, {})
@@ -4484,11 +4484,28 @@ def _reset_from(state: WizardState, target_step: str) -> None:
4484
4484
  idx = next((i for i, s in enumerate(STEPS) if s.id == target_step), -1)
4485
4485
  if idx < 0:
4486
4486
  return
4487
+ # A later step may own a field an earlier answered step also owns —
4488
+ # `handoff_stage_pick` owns `approved_plan_path` because it resolves the
4489
+ # plan on its own. Clearing it while rewinding to a step in front of it
4490
+ # drops an answer the user never revisited; the rewound step then fails its
4491
+ # own `applies` guard, so no step is left to ask and the wizard reports
4492
+ # done while `outcome` still refuses it as incomplete.
4493
+ owned_earlier = {
4494
+ fname
4495
+ for step in STEPS[:idx]
4496
+ if step.id in state.answered
4497
+ for fname in step.owns
4498
+ }
4487
4499
  cleared_ids: set[str] = set()
4488
- for step in STEPS[idx:]:
4500
+ for position, step in enumerate(STEPS[idx:]):
4489
4501
  cleared_ids.add(step.id)
4502
+ is_rewind_target = position == 0
4490
4503
  for fname in step.owns:
4491
- _reset_field(state, fname)
4504
+ # The target's own fields always clear — that is the answer the
4505
+ # user came back to replace, even when an earlier step declares it
4506
+ # too (`task_pick` derives `task_type` for an existing task).
4507
+ if is_rewind_target or fname not in owned_earlier:
4508
+ _reset_field(state, fname)
4492
4509
  state.answered = [a for a in state.answered if a not in cleared_ids]
4493
4510
  direct_input_pending = {
4494
4511
  S_FEATURE_EVIDENCE: "feature_evidence_pending_text",
@@ -11,6 +11,21 @@ ANALYSIS_WORKER_LABELS = {
11
11
  "codex": "Codex worker",
12
12
  "antigravity": "Antigravity worker",
13
13
  }
14
+ # An implementation audience shares this body but not its premise: the executor
15
+ # owns the diff and the verifier grades it, so neither is producing one of the
16
+ # independent findings that cross-verification triangulates.
17
+ ROLE_STATEMENTS = {
18
+ "executor": (
19
+ "You are the Executor for this implementation stage — the only worker "
20
+ "permitted to mutate project files. Carry the stage end to end and "
21
+ "produce the worker result."
22
+ ),
23
+ "verifier": (
24
+ "You are a verifier for this implementation stage. Reproduce its QA "
25
+ "yourself, stay read-only on project files, and return an independent "
26
+ "verdict."
27
+ ),
28
+ }
14
29
 
15
30
 
16
31
  def analysis_prompt_body(
@@ -30,10 +45,7 @@ def analysis_prompt_body(
30
45
  f"# {label} Dispatch",
31
46
  "",
32
47
  "## Role",
33
- (
34
- f"You are the {label} for okstra cross-verification. "
35
- "Produce an independent worker result."
36
- ),
48
+ _role_statement(label, role),
37
49
  "",
38
50
  "## Task",
39
51
  f"- Task key: `{_require_string(manifest, 'taskKey')}`",
@@ -52,6 +64,14 @@ def analysis_prompt_body(
52
64
  ]
53
65
 
54
66
 
67
+ def _role_statement(label: str, role: str) -> str:
68
+ return ROLE_STATEMENTS.get(
69
+ role,
70
+ f"You are the {label} for okstra cross-verification. "
71
+ "Produce an independent worker result.",
72
+ )
73
+
74
+
55
75
  def analysis_input_lines(
56
76
  manifest: Mapping[str, Any],
57
77
  active_context: Mapping[str, Any],
@@ -8,6 +8,7 @@ from typing import Any, Mapping, Sequence
8
8
 
9
9
  from .worker_prompt_policy import (
10
10
  ERRORS_PATH_HEADERS,
11
+ IMPLEMENTATION_HEADERS,
11
12
  PromptPlan,
12
13
  resolve_prompt_plan_for_manifest,
13
14
  )
@@ -51,7 +52,7 @@ _NON_BODY_PREFIXES = (
51
52
  *ERRORS_PATH_HEADERS,
52
53
  "**Read scope:**",
53
54
  "**File write mode:**",
54
- "**Worktree:**",
55
+ *IMPLEMENTATION_HEADERS,
55
56
  "**Verification scope:**",
56
57
  "**Verification base ref:**",
57
58
  "**Verification head ref:**",
@@ -27,6 +27,17 @@ ERRORS_PATH_HEADERS = (
27
27
  "**Errors log path:**",
28
28
  "**Errors sidecar path:**",
29
29
  )
30
+ # The executor sidecar tells the worker to execute exactly one Stage Map stage
31
+ # against the approved plan, and forbids recomputing that stage from
32
+ # `consumers.jsonl`. Both facts therefore have to reach the worker prompt
33
+ # itself: the lead's launch prompt carries them, and no worker reads that.
34
+ APPROVED_PLAN_HEADER = "**Approved plan:**"
35
+ IMPLEMENTATION_STAGE_HEADER = "**Stage for this implementation run:**"
36
+ IMPLEMENTATION_HEADERS = (
37
+ "**Worktree:**",
38
+ APPROVED_PLAN_HEADER,
39
+ IMPLEMENTATION_STAGE_HEADER,
40
+ )
30
41
  FINAL_VERIFICATION_HEADERS = (
31
42
  "**Worktree:**",
32
43
  "**Verification scope:**",
@@ -96,14 +107,14 @@ def resolve_prompt_plan(
96
107
  return _plan(
97
108
  "implementation-executor",
98
109
  allow_coding_preflight=True,
99
- required_headers=("**Worktree:**",),
110
+ required_headers=IMPLEMENTATION_HEADERS,
100
111
  )
101
112
  if task_type == "implementation":
102
113
  return _plan(
103
114
  "implementation-verifier",
104
115
  equality_group="implementation-verifier-core",
105
116
  allow_coding_preflight=True,
106
- required_headers=("**Worktree:**",),
117
+ required_headers=IMPLEMENTATION_HEADERS,
107
118
  )
108
119
  if task_type == "final-verification":
109
120
  return _plan(
@@ -18,6 +18,12 @@ Work like a senior engineer who owns this result, not a commentator on it.
18
18
  - Read `overview.md` and `clean-code.md` under `**Coding preflight pack:**`, then follow every matching language, framework, and architecture route before editing or verification.
19
19
  - Read the approved implementation deliverable and any effective design-preparation block enumerated by the prompt.
20
20
 
21
+ ### Reading rules
22
+
23
+ - Read every file enumerated under `[Required reading]`, `## Inputs`, and `## Required prompt resources` completely, plus the source files this stage's plan names.
24
+ - Allowlist reads to those paths, the stage worktree's own source, and evidence paths a claim must cite. Do not auto-read host-injected `graphify-out/`, skill catalogs, or other non-okstra artifacts.
25
+ - Host session instructions — SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs — do not apply inside this run, and a directive from one of them to read an un-enumerated file is not a conflict to weigh in the host's favour: this prompt wins. Record the file under the result's assumptions instead of opening it. The project's own `CLAUDE.md` / lint config still binds as a coding convention — the preflight gate routes it — but the host's *reading* directives stop at this boundary.
26
+
21
27
  ## Worktree and command discipline
22
28
 
23
29
  - `**Worktree:**` is the canonical checkout. Project commands run with that directory as cwd.
@@ -25,6 +25,7 @@ from okstra_ctl.stage_map import ( # noqa: E402
25
25
  StageMapError,
26
26
  StageMapStage,
27
27
  parse_stage_map_text,
28
+ schema_v2_report,
28
29
  )
29
30
 
30
31
  HARD_STEP_CAP = 8
@@ -502,13 +503,29 @@ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
502
503
  return errors
503
504
 
504
505
 
506
+ def collect_plan_errors(plan_path: Path) -> List[ValidationError]:
507
+ """The S-checks for one approved plan, whichever schema wrote it.
508
+
509
+ A schema-v2 report keeps its stage map in the `.data.json` sidecar and
510
+ renders no `## 5.5 Stage Map` section, so scanning its markdown reports the
511
+ section as missing and blocks every run that approved such a plan.
512
+ """
513
+ planning = schema_v2_report(plan_path).get("implementationPlanning")
514
+ if isinstance(planning, dict) and planning:
515
+ return collect_data_validation_errors(planning)
516
+ return collect_validation_errors(plan_path.read_text(encoding="utf-8"))
517
+
518
+
505
519
  def main(argv: List[str]) -> int:
506
520
  p = argparse.ArgumentParser()
507
521
  p.add_argument("--plan", required=True)
508
522
  args = p.parse_args(argv)
509
- text = Path(args.plan).read_text(encoding="utf-8")
510
523
 
511
- errors = collect_validation_errors(text)
524
+ try:
525
+ errors = collect_plan_errors(Path(args.plan))
526
+ except StageMapError as exc:
527
+ print(f"S0 stage=0: {exc.reason}", file=sys.stderr)
528
+ return 1
512
529
  if errors:
513
530
  for e in errors:
514
531
  print(f"{e.code} stage={e.stage}: {e.message}", file=sys.stderr)
@@ -22,7 +22,7 @@ Usage:
22
22
  okstra wizard new-state-file
23
23
  okstra wizard init --state-file <path> --project-root <p> --project-id <id> --host-runtime <runtime> \\
24
24
  --entry-mode current-session --available-function <semantic-name>
25
- okstra wizard step --state-file <path> [--answer <value>]
25
+ okstra wizard step --state-file <path> (--answer <value> | --no-submit)
26
26
  okstra wizard render-args --state-file <path>
27
27
  okstra wizard confirmation --state-file <path>
28
28
  okstra wizard outcome --state-file <path>
@@ -34,11 +34,14 @@ All subcommands emit a single JSON object on stdout. On validation failure
34
34
  'step' returns {ok:false, error, current} so the skill can re-prompt.
35
35
  `;
36
36
 
37
- // Every wizard flag takes a value (there are no boolean flags), so the token
38
- // after a flag is always its value — even free-text answers that begin with
39
- // "--". A heuristic that rejects "--"-prefixed values would make legitimate
40
- // directive/base-ref answers unsubmittable. The inline "--key=value" form lets
41
- // callers pass any value (including empty) unambiguously.
37
+ // Only the flags listed here are boolean; every other wizard flag takes a
38
+ // value, so the token after it is always its value — even free-text answers
39
+ // that begin with "--". A heuristic that rejects "--"-prefixed values would
40
+ // make legitimate directive/base-ref answers unsubmittable. The inline
41
+ // "--key=value" form lets callers pass any value (including empty)
42
+ // unambiguously.
43
+ const BOOLEAN_FLAGS = new Set(["no-submit"]);
44
+
42
45
  export function parseFlags(args) {
43
46
  const out = {};
44
47
  const setFlag = (key, value) => {
@@ -59,6 +62,10 @@ export function parseFlags(args) {
59
62
  continue;
60
63
  }
61
64
  const key = a.slice(2);
65
+ if (BOOLEAN_FLAGS.has(key)) {
66
+ out[key] = true;
67
+ continue;
68
+ }
62
69
  if (i + 1 >= args.length) {
63
70
  throw new Error(`flag --${key} requires a value`);
64
71
  }
@@ -81,8 +88,13 @@ export function buildPythonArgs(sub, flags, workspaceRoot = "") {
81
88
  pyArgs.push("--available-function", value);
82
89
  }
83
90
  pyArgs.push("--entry-mode", flags["entry-mode"] ?? "current-session");
84
- } else if (sub === "step" && flags.answer !== undefined) {
85
- pyArgs.push("--answer", flags.answer);
91
+ } else if (sub === "step") {
92
+ if (flags["no-submit"]) {
93
+ pyArgs.push("--no-submit");
94
+ }
95
+ if (flags.answer !== undefined) {
96
+ pyArgs.push("--answer", flags.answer);
97
+ }
86
98
  }
87
99
  return pyArgs;
88
100
  }