okstra 0.183.2 → 0.184.0

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 (33) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture.md +2 -2
  3. package/docs/cli.md +7 -4
  4. package/docs/project-structure-overview.md +1 -1
  5. package/docs/task-process/README.md +1 -1
  6. package/docs/task-process/common-flow.md +2 -3
  7. package/docs/task-process/error-analysis.md +3 -4
  8. package/docs/task-process/final-verification.md +2 -3
  9. package/docs/task-process/implementation-planning.md +2 -3
  10. package/docs/task-process/implementation.md +2 -3
  11. package/docs/task-process/release-handoff.md +3 -4
  12. package/docs/task-process/requirements-discovery.md +3 -4
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/prompts/launch.template.md +8 -7
  16. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  17. package/runtime/prompts/lead/plan-body-verification.md +27 -19
  18. package/runtime/prompts/lead/report-writer.md +3 -3
  19. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  20. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  21. package/runtime/prompts/profiles/implementation-planning.md +9 -11
  22. package/runtime/prompts/wizard/prompts.ko.json +7 -10
  23. package/runtime/python/okstra_ctl/conformance.py +37 -1
  24. package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
  25. package/runtime/python/okstra_ctl/plan_items.py +410 -1
  26. package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
  27. package/runtime/python/okstra_ctl/render.py +4 -0
  28. package/runtime/python/okstra_ctl/wizard.py +39 -73
  29. package/runtime/schemas/final-report-v2.0.schema.json +12 -0
  30. package/runtime/schemas/final-report-v3.0.schema.json +12 -0
  31. package/runtime/skills/okstra-run/SKILL.md +2 -2
  32. package/runtime/validators/validate-run.py +164 -66
  33. package/runtime/validators/validate_session_conformance.py +24 -5
@@ -78,7 +78,7 @@ also remain contract violations.
78
78
  ```
79
79
  `overall` is exactly one of `PASS` / `FAIL` / `MISSING`. Writing the honest sidecar is mandatory whenever the script runs and on the exemption/waiver skip path. A missing `io`-only sidecar blocks; a missing external-advisory sidecar is reported as `ADVISORY` rather than accepted as hidden evidence.
80
80
  - **Read-only command log.** Record the `runCommand` exact line + its exit code in the Read-only command log. Tier 3 external non-PASS evidence MUST remain visible with status `ADVISORY`. Unlike Tiers 1·2, a conformance script MAY mutate the **replica datastore** (exercising integrated state is its whole purpose) — but only the `qaEnv` replica target, never a shared/staging/prod store. The `runCommand` itself is still subject to the same source/lockfile mutation deny-list as Tier 2 (`--fix`, `npm install` without `ci`, etc.); a denied token aborts with `contract-violated`.
81
- - **No manifest / no entry for this stage.** If the manifest file is absent, or it has no entry whose `stageKey` matches this run's stageKey, the verifier records `conformance: no manifest entry for <stageKey>` and proceeds (forcing the *declaration* of conformance entries is the job of planning Step 11 + the `validate-run.py` diff-surface cross-check, not the verifier).
81
+ - **No manifest / no entry for this stage.** If the approved plan declared `Conformance exemption:` for this stage, and the manifest is absent or has no matching `stageKey`, record `conformance: no manifest entry for <stageKey>` and proceed. If the approved plan declared `Conformance tests:` and the script file or matching entry is absent, that is a FAIL — do not treat it as a skip. **Enforced:** `validators/validate-run.py` `_validate_conformance`.
82
82
 
83
83
  ### Self-mock detection (changed test files)
84
84
 
@@ -4,7 +4,7 @@
4
4
  roles:
5
5
  - role: planner
6
6
  min: 2
7
- recommended: 3
7
+ recommended: 2
8
8
  max: 5
9
9
  duty: planning-worker
10
10
  - role: critic
@@ -103,7 +103,7 @@ roles:
103
103
  - Phase 5.5 finding convergence runs in **adversarial mode** for this phase (`convergence.adversarial=true`). Verifiers actively try to refute each worker finding (requirement gap / risk / plan item) by re-inspecting its cited evidence; the burden of proof sits on the claim. See `prompts/lead/convergence.md` §"Adversarial Verification Mode".
104
104
  - §5.5.9 plan-body verification runs with an **adversarial posture** (`prompts/lead/plan-body-verification.md` §"Adversarial plan-body posture"): verifiers open and confirm every cited path / command and put the burden of proof on the plan. The gate threshold is majority-based for kinds `b`/`c`/`e`, but a single `DISAGREE` blocks on its own for the concrete, safety-critical kind `a` (path/symbol mismatch) — and `f` on `P-Req-*` items. `P-Var-*` items are excepted from the kind-`a` exception: a variation-point defect takes a majority. Rollback ordering (`d`) is advisory and never blocks the gate — a rollback is executed by a human, not by okstra's workers or verifiers. A majority also needs ≥2 participating votes, so a lone dissent whose peer returned a non-result does not block on a majority-gated kind (see that contract's §"Adversarial plan-body posture").
105
105
  - **Incremental re-verification scope (clarification re-runs):** when the lead's `okstra incremental-scope` decision is `mode == "incremental"` (procedure in `prompts/launch.template.md` §"Clarification Response Carried In"), workers re-analyze ONLY the stages listed in `reverify_stages` (the downstream closure of the impacted stages). Workers MUST NOT re-open, re-score, or re-judge any stage in `carry_stages` — those stages' prior plan-item verdicts are carried forward verbatim, and a worker never overwrites a carried verdict with its own judgement. When the decision is `mode == "full"` (the default), every stage is re-analyzed as usual.
106
- - **Single incremental-scope decision:** the lead calls `okstra incremental-scope` exactly once for the re-run, passing the answered `C-NNN` ids through `--answered-clarifications`, changed design-preparation IDs through `--prep-items`, and any lead-resolved stage numbers through `--impacted`; the CLI unions all three before applying the existing dependency closure and cutoff. The clarification ids are resolved to stages by the CLI from the prior report's own `planItems[].clarificationId` and `blocked C-NNN` coverage links — the lead does not map answers to stage numbers. An answer that changes the selected planning payload, Stage Map, or execution approach is not a local impact: pass every CSV empty so the same call returns `mode == "full"`. A clarification id that traces to no stage, unknown PREP IDs, or invalid `stageRefs` also return an explicit full decision instead of being guessed. When the user pinned a scope at the wizard (`REVERIFY_SCOPE_MODE` / `REVERIFY_SCOPE_STAGES` in `prompts/launch.template.md` §"Clarification Response Carried In" step 0), that pin is an input to this same single call — `full` supplies the `--full-reason`, and pinned stage numbers join `--impacted` — never a second call or a bypass of the CLI's closure and cutoff.
106
+ - **Single incremental-scope decision:** the lead calls `okstra incremental-scope` once the inputs are complete, passing the answered `C-NNN` ids through `--answered-clarifications`, changed design-preparation IDs through `--prep-items`, and any lead-resolved stage numbers through `--impacted`; the CLI unions all three before applying the existing dependency closure and cutoff. The clarification ids are resolved to stages by the CLI from the prior report's own `planItems[].clarificationId` and `blocked C-NNN` coverage links — the lead does not map answers to stage numbers. An answer that changes the selected planning payload, Stage Map, or execution approach is not a local impact: pass `--full-reason`, which is the only structural path that still forces `mode == "full"`. A clarification id that traces to no stage returns `mode == "unresolved"` — ask the user for stage numbers and call again with `--impacted`; do not treat it as full and do not silently drop the id. Unknown PREP IDs or invalid `stageRefs` still return an explicit full decision instead of being guessed. When the wizard pin is `auto` and the CLI returns `mode == "incremental"`, keep it — do not upgrade to full. When the user pinned a scope at the wizard (`REVERIFY_SCOPE_MODE` / `REVERIFY_SCOPE_STAGES` in `prompts/launch.template.md` §"Clarification Response Carried In" step 0), that pin is an input to this same call — `full` supplies the `--full-reason`, and pinned stage numbers join `--impacted` — never a bypass of the CLI's closure and cutoff.
107
107
  - **Stage-aware carry:** for an incremental decision, the report writer copies each `carry_stages` stage row unchanged into its narrative. After plan-item seeding, pass the decision's `carry_stages` and `reverify_stages` CSVs unchanged to `okstra incremental-carry --cur-narrative ... --state ... --out-state ...`. The helper rejects a changed or missing carried stage and copies only its prior `P-Step-*` / `P-Prep-*` verdicts into the convergence-owned state. Overlap, omissions, and canonical conflicts return `CarryError`. On that error, discard the partial state and run full re-verification.
108
108
  {{INCLUDE:_coverage-critic.md}}
109
109
  - Non-goals:
@@ -169,17 +169,15 @@ roles:
169
169
  - **Clean-tree assertions use `okstra worktree-status --check-clean`.** A bare `git status --porcelain` is never empty there, so an assertion built on one fails on okstra's scaffolding rather than on the stage's work. The okstra command asks the same question over source paths only and exits 1 when dirty, so it stands alone as a step's assertion: `okstra worktree-status --check-clean`. Validator S13 rejects the bare form. Do not add a `git tag stage-<N>-exit` to the step — okstra writes that tag itself when it settles the stage, at the commit the carry evidence records, and a step that tags mid-stage puts it on an earlier commit.
170
170
  - **Never read an `.okstra/` artifact back out of a git object.** `.okstra/**` is gitignored and never committed — the executor aborts a commit that stages an ignored path and the verifier reports a committed `.okstra` path as a branch defect — so `git cat-file -e <tag>:.okstra/…`, `git show <tag>:.okstra/…`, and every variant of that read can never resolve, at any tag, in any stage. A later stage that needs a QA artifact reads it from the working tree or receives it through the carry sidecar / verifier result; do not design a stage contract around one being reachable from a tag. Validator S12 rejects the read.
171
171
  - **Per-stage conformance declaration (mandatory one line, in the stage section — same placement freedom as `TDD exemption:`):** the stage MUST carry exactly one of:
172
- - `Conformance tests: stage-<N> — <task_root>/qa/scripts/stage-<N>.<ext> (requires=[db|io|http|external,...])` — a Tier3 verification script that proves this stage's upstream requirements (brief / requirements-discovery / error-analysis / improvement-discovery → this stage's `Acceptance`) hold against **real** DB rows, real endpoints, or the real external API — NOT mocks. When you emit this line you MUST also (a) write the script to `<task_root>/qa/scripts/stage-<N>.<ext>` and (b) add a matching entry to `<task_root>/qa/conformance-manifest.json` with fields `stageKey` (= `<task-id>-stage-<N>`), `script`, `runCommand`, `requirementIds`, `requires` (subset of `{db, io, http, external}`), `passContract`, `exemption: null`, `waiver: null`. The script's standard interface: a `main` that exits `0`=PASS / non-zero=FAIL, and whose stdout ends with `QA-RESULT: PASS|FAIL` followed by one `REQ <id>: PASS|FAIL: <reason>` line per requirement. When the verification body is a test spec, author it with the project's own test framework (devDependency) invoked via a discovery override at `<task_root>/qa/scripts/` (jest: `--config <project config> --roots <task_root>/qa/scripts`) — never hand-roll `describe`/`expect` and never widen the project's own test config; for TypeScript specs also write `<task_root>/qa/scripts/tsconfig.json` extending the project tsconfig with the runner's `types` entry so editors resolve the file.
173
- - `Conformance exemption: <reason>` — only for stages that touch no db/io/http/external surface, or where unit tests fully cover the increment. (If the eventual `implementation` diff actually touches one of those surfaces, `validate-run.py`'s diff-surface cross-check is BLOCKING — an exemption cannot hide a real db/io/http/external change.)
174
- - **External QA outcome guideline:** after satisfying the mandatory script,
175
- manifest, and S11 declarations above, an entry whose `requires` contains
176
- `db`, `http`, or `external` should document the exact `runCommand`,
177
- prerequisites, requirement IDs, and expected `QA-RESULT`. Okstra may start the
178
- environment and run it automatically, but `FAIL`, missing evidence, or an
172
+ - `Conformance tests: stage-<N> — <task_root>/qa/scripts/stage-<N>.<ext> (requires=[db|io|http|external,...])` — declare that a Tier3 verification script will prove this stage's upstream requirements (brief / requirements-discovery / error-analysis / improvement-discovery → this stage's `Acceptance`) hold against **real** DB rows, real endpoints, or the real external API — NOT mocks. This phase emits the line and the `requires` set only. Do NOT write `<task_root>/qa/scripts/stage-<N>.*` and do NOT add a `runCommand` or `conformance-manifest.json` entry here the matching `implementation` stage run creates the script file and the manifest `runCommand`. A plan that declares tests with no script file on disk is valid at this gate.
173
+ - `Conformance exemption: <reason>` — only for stages that touch no db/io/http/external surface, or where unit tests fully cover the increment. Exemption stays a planning declaration; do not move it to implementation. (If the eventual `implementation` diff actually touches one of those surfaces, `validate-run.py`'s diff-surface cross-check is BLOCKING — an exemption cannot hide a real db/io/http/external change.)
174
+ - **External QA outcome guideline:** after satisfying the S11 declaration above, a line whose `requires` contains
175
+ `db`, `http`, or `external` should name those capabilities here so the later `runCommand` can be written against them.
176
+ Okstra may start the environment and run it automatically, but `FAIL`, missing evidence, or an
179
177
  unavailable environment is a user-owned follow-up, never a plan approval or
180
178
  later run blocker. `requires=[]` and `requires=[io]` remain blocking.
181
179
  Remote IO should also declare `external`.
182
- The manifest lives at the **task level** (`<task_root>/qa/`, path token `TASK_QA_PATH`) and is shared across planning → implementation → final-verification. The verifier runs `runCommand` from the **worktree cwd**, and that cwd is the tree under test — it is what makes the script see this stage's diff. So `runCommand` MUST NOT repoint it: a leading `cd <checkout> &&` sends the script at whichever tree it names, which at implementation time is a checkout without the stage's changes and at final-verification is not the integrated tree either. Absolute paths are fine and usually necessary — the script and its `tsconfig` live under `<task_root>/qa/scripts/`, i.e. under `.okstra/`, and a worktree does not carry `.okstra/` (worker preamble: "`.okstra/**` artifacts remain anchored at `**Project Root:**`; the worktree may not contain them"). Point at those by absolute path; leave the cwd alone. **Enforced:** `scripts/okstra_ctl/conformance.py` `_check_entry` rejects a `runCommand` whose first word in any `&&` / `;` segment changes directory. Layout split: 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` fails when a stage that declared `Conformance tests:` has no matching `-stage-<N>` entry in the shared manifest (a declaration that was never materialized); the manifest JSON structure — including each entry's `script` living under `qa/scripts/` is enforced by `validate_conformance_manifest` (called from both the run path and validate-run); and the result policy is evaluated by `conformance.py` and `validate-run.py`.
180
+ 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.
183
181
  - `### Stage Exit Contract` — predicted added/modified files, newly exposed identifiers/types/endpoints, downstream-usable resources.
184
182
  - `### Stage Validation` — pre / mid / post exact commands or observable outcomes for this stage only.
185
183
  - **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`.
@@ -263,6 +261,6 @@ roles:
263
261
  10. **Decision-draft materialization check** — when `decisionDrafts` is non-empty, confirm as a reviewer which stage's stepwise order contains the matching materialization step (creating `.okstra/decisions/<NNNN>-<slug>.md`) and that the number of drafts corresponds 1:1 with the materialization steps. The validator only checks the *existence* of the step, so the `<NNNN>-<slug>` correctness and count correspondence are the self-review's responsibility.
264
262
  11. **Variation-point & seam check** — read `variationPointAnalysis` as a skeptic. Is `hasMultipleImplementations` honest against the brief and the sibling code you inspected during pre-planning, or was `false` chosen because it is the cheaper field to fill? For every point with `extract: true`, confirm the `extractionDecision` names a real interface (a `port` for a hexagonal project, not a shared helper) and a `coveredBy` stage that exists in the Stage Map — an interface no stage builds is a decision nobody executes. Then read the chosen realization's `testSeams`: each `injectedAs` must name a construction or wiring point a test can actually substitute at, not a symbol the test would have to re-implement — a seam nothing can be injected into leaves the executor writing self-mocks. An empty `testSeams` array is only acceptable when you can defend it in one sentence; the validator accepts it either way, so this is the check that catches an unfilled field posing as a decision.
265
263
  12. **Approval blast-radius check (BLOCKING).** Every approval clarification must be reachable from `planItems[].clarificationRefs[]` or a requirement-coverage blocker. Report assembly derives plan-item links from activity `clarificationRefs[]` plus `planItemIds[]`; `okstra incremental-scope` reads the resulting reverse links.
266
- - **The link must resolve to a stage, not merely exist.** `incremental-scope` reads the stage number out of a `P-Step-<stage>.<step>` / `P-Prep-S<stage>-<kind>` plan-item id, or out of a `Stage N` citation in the blocked coverage row's `coveredBy`. Every other plan-item prefix (`P-Dir-1`, `P-Req-*`, `P-Val-*`, `P-Opt-*`, `P-Dep-*`, `P-Rb-*`) carries no stage, so a blocker linked only that way MUST also have its coverage row cite the stage in `coveredBy`. Writing the blocked row's `coveredBy` as prose with no `Stage N` in it — `No stage.`, `Partly covered — …` — satisfies nothing: the row passes the link check and the re-run still re-verifies everything.
264
+ - **The link must resolve to a stage, not merely exist.** `incremental-scope` reads the stage number out of a `P-Step-<stage>.<step>` / `P-Prep-S<stage>-<kind>` plan-item id, or out of a `Stage N` citation in the blocked coverage row's `coveredBy`. Every other plan-item prefix (`P-Dir-1`, `P-Req-*`, `P-Val-*`, `P-Opt-*`, `P-Dep-*`, `P-Rb-*`) carries no stage, so a blocker linked only that way MUST also have its coverage row cite the stage in `coveredBy`. Writing the blocked row's `coveredBy` as prose with no `Stage N` in it — `No stage.`, `Partly covered — …` — satisfies nothing: the row passes the link check and the next re-run cannot place the answer without asking for stage numbers.
267
265
  - What to write when no stage covers the requirement yet: name the stage the answer will change, not the stage that satisfies the requirement today. A `Blocks=approval` row is admissible only when, absent an answer, `implementation` would produce wrong or unsafe code (see the admissibility rule above) — so some stage's code is at stake by construction. If you genuinely cannot name one, the row fails the admissibility test and belongs in `## 5. Missing Information and Risks` with `Blocks=none`, not in the approval gate.
268
266
  **Enforced:** `validators/validate-run.py` `_validate_approval_clarification_backtrace` — one failure for a missing link, a separate one for a link that resolves to no stage.
@@ -408,6 +408,7 @@
408
408
  },
409
409
  "reverify_scope_pick": {
410
410
  "label": "재검증 범위를 어떻게 할까요? (답변한 항목이 모두 직전 리포트의 stage 에 연결돼 있어 범위를 좁힐 수 있는 상태입니다 — 최종 확정은 run 시점 base-ref 비교)",
411
+ "label_unlinked": "답변한 항목 중 직전 리포트의 stage 에 연결되지 않은 것이 있습니다. 다시 볼 stage 번호를 지정하거나 전체 재검증을 고르세요. 연결되지 않은 id 가 재실행 전체를 full 로 만들지는 않습니다.",
411
412
  "echo_template": "reverify-scope: {value}",
412
413
  "options": {
413
414
  "auto": "관련 stage 만 (추천) — 답변이 닿는 stage 와 그 하위만 다시 검증하고 나머지는 직전 판정을 그대로 이월",
@@ -417,6 +418,9 @@
417
418
  "echo_suffixes": {
418
419
  "auto": "reverify-scope: auto (좁힐 수 있으면 좁힘)",
419
420
  "full": "reverify-scope: full (전체 재검증)"
421
+ },
422
+ "errors": {
423
+ "unlinked_auto": "직전 리포트의 stage 에 연결되지 않은 답변이 있어 자동 판정을 쓸 수 없습니다: {ids}. stage 번호를 지정하거나 전체 재검증을 고르세요."
420
424
  }
421
425
  },
422
426
  "reverify_scope_stages": {
@@ -428,7 +432,8 @@
428
432
  "errors": {
429
433
  "not_a_number": "stage 번호는 정수여야 합니다: {token}",
430
434
  "unknown_stage": "직전 리포트의 Stage Map 에 없는 stage 입니다: {stages} (Stage Map 의 번호: {known})",
431
- "no_stage_map": "직전 리포트에서 Stage Map 을 읽을 수 없어 stage 번호를 검증할 수 없습니다: {reason}"
435
+ "no_stage_map": "직전 리포트에서 Stage Map 을 읽을 수 없어 stage 번호를 검증할 수 없습니다: {reason}",
436
+ "unlinked_empty": "직전 리포트의 stage 에 연결되지 않은 답변이 있어 빈 입력(자동)을 쓸 수 없습니다: {ids}. stage 번호를 입력하세요."
432
437
  }
433
438
  },
434
439
  "pr_template_pick": {
@@ -480,14 +485,6 @@
480
485
  "no": "아니오 — 단계별로 다시 입력"
481
486
  }
482
487
  },
483
- "leader_session": {
484
- "label": "리더는 현재 세션 모델을 씁니다 (읽기 전용): {model_ref}{effort_suffix}",
485
- "echo_template": "leader-session: {value}",
486
- "options": {
487
- "continue": "계속"
488
- },
489
- "effort_suffix": " · effort {effort}"
490
- },
491
488
  "role_count": {
492
489
  "label": "{role} 역할 인스턴스 수를 선택하세요 ({minimum}..{maximum}, 적정 {default})",
493
490
  "echo_template": "role-count: {value}",
@@ -636,7 +633,7 @@
636
633
  "clarification_sidecars_attached": " user-responses: 사이드카 {files}개 · 답변 {count}개 함께 첨부 — {ids}",
637
634
  "clarification_sidecars_none_parsed": "답변으로 셀 항목 없음 (reframe 등)",
638
635
  "reverify_scope_incremental": " reverify-scope: incremental 가능 — 답변된 항목이 모두 직전 리포트의 stage 에 연결됨 (최종 확정은 run 시점 base-ref 비교)",
639
- "reverify_scope_unlinked": " reverify-scope: full 예상 — {ids} 이(가) 직전 리포트의 어느 stage 에도 연결되지 않아 범위를 좁히지 못함\n (좁히지 못하는 것은 재검증 범위이지 답변이 아닙니다 — 답변은 모두 carry-in 되어 반영되고, 대신 stage 전체를 다시 검증합니다)",
636
+ "reverify_scope_unlinked": " reverify-scope: stage 번호 필요 — {ids} 이(가) 직전 리포트의 어느 stage 에도 연결되지 않음 (자동 판정 불가; full 강제되지는 않음)",
640
637
  "reverify_scope_full": " reverify-scope: full 예상 — {reason}\n (좁히지 못하는 것은 재검증 범위이지 답변이 아닙니다 — 답변은 모두 carry-in 되어 반영되고, 대신 stage 전체를 다시 검증합니다)",
641
638
  "reverify_scope_user_full": " reverify-scope: full (사용자 지정 — 전체 재검증)",
642
639
  "reverify_scope_user_stages": " reverify-scope: stage {stages} 재검증 지정 (사용자 지정 — 하위 stage 포함, 나머지는 직전 판정 이월)",
@@ -5,14 +5,17 @@ implementation/final-verification 의 verifier 는 stage 별 conformance 스크
5
5
 
6
6
  1. `conformance-manifest.json` 구조 검증 (`validate_conformance_manifest`).
7
7
  2. 스크립트 stdout 의 `QA-RESULT` 마커 파싱 (`parse_qa_result`).
8
+ 3. 선언된 script 파일 부재 (`missing_declared_scripts`) — 구현/최종검증만.
8
9
 
9
10
  스크립트 실행/게이트 강제는 verifier prompt 와 validators/validate-run.py 가 담당한다.
11
+ 계획 단계는 선언만 내고 이 모듈의 파일 존재 검사를 호출하지 않는다.
10
12
  """
11
13
  from __future__ import annotations
12
14
 
13
15
  import fnmatch
14
16
  import re
15
17
  from dataclasses import dataclass
18
+ from pathlib import Path
16
19
 
17
20
  # 셸에서 프로세스의 cwd 를 바꾸는 명령. verifier 가 워크트리 cwd 에서 실행하는
18
21
  # 계약이 이것들로 무력화된다.
@@ -45,6 +48,39 @@ def normalize_conformance_script(script: str) -> str:
45
48
  return script[len(prefix):] if script.startswith(prefix) else script
46
49
 
47
50
 
51
+ def missing_declared_scripts(entries: object, task_root: Path) -> list[str]:
52
+ """승인 계획이 가리키는 script 파일이 task_root 아래에 없으면 오류.
53
+
54
+ 계획 단계는 호출하지 않는다. 스크립트와 runCommand 는 매칭 implementation
55
+ stage 가 만들고, 구현/최종검증 게이트만 이 함수로 부재를 닫힌 실패로 본다.
56
+ """
57
+ errors: list[str] = []
58
+ if not isinstance(entries, list):
59
+ return errors
60
+ root = task_root.resolve()
61
+ for entry in entries:
62
+ if not isinstance(entry, dict):
63
+ continue
64
+ script = entry.get("script")
65
+ if not isinstance(script, str) or not script.strip():
66
+ continue
67
+ relative = normalize_conformance_script(script)
68
+ candidate = (root / relative).resolve()
69
+ stage_number = str(entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
70
+ try:
71
+ candidate.relative_to(root)
72
+ except ValueError:
73
+ errors.append(
74
+ f"stage {stage_number} declared script escapes task root: {relative}"
75
+ )
76
+ continue
77
+ if not candidate.is_file():
78
+ errors.append(
79
+ f"stage {stage_number} declared script is missing: {relative}"
80
+ )
81
+ return errors
82
+
83
+
48
84
  def parse_conformance_tests(value: object) -> tuple[str, frozenset[str]] | None:
49
85
  """stage 선언의 `<script> (requires=[cap,...])` 를 파싱. 형식이 아니면 None."""
50
86
  if not isinstance(value, str):
@@ -156,7 +192,7 @@ def _check_entry(entry: object, idx: int, errors: list[str]) -> None:
156
192
  _check_nonempty_str(entry.get("stageKey"), f"{path}.stageKey", errors)
157
193
  _check_nonempty_str(entry.get("script"), f"{path}.script", errors)
158
194
  script = entry.get("script")
159
- # 실행 스크립트는 qa/scripts/ 하위 격리가 계약(implementation-planning §conformance);
195
+ # 실행 스크립트는 qa/scripts/ 하위 격리가 계약(implementation 이 파일을 씀);
160
196
  # qa/ 루트는 manifest·result-*.json 데이터 사이드카 전용이다.
161
197
  if isinstance(script, str) and script.strip() and "qa/scripts/" not in script:
162
198
  errors.append(f"{path}.script must live under the task qa scripts dir (qa/scripts/), got {script!r}")
@@ -28,6 +28,11 @@ CUTOFF_RATIO = 0.5
28
28
  # either a structural judgement or a mapping the lead never made.
29
29
  DECLARED_FULL_PREFIX = "declared structural change:"
30
30
 
31
+ # CLI-to-lead signal only: an answered id traced to no stage and the user has
32
+ # not named `--impacted` stages. Never recorded as incrementalDecision (schema
33
+ # enum is incremental|full). The lead asks for stage numbers and calls again.
34
+ UNRESOLVED_MODE = "unresolved"
35
+
31
36
  # `P-Step-<stage>.<step>` and `P-Prep-S<stage>-<kind>` carry their stage in the
32
37
  # id itself. Every other prefix (`P-Opt`, `P-Dep`, `P-Val`, `P-Rb`, `P-Req`) is
33
38
  # numbered by position in its own array, so its stage is only recoverable from
@@ -37,7 +42,7 @@ _STRUCTURAL_STAGE_IN_ID_RE = re.compile(r"^P-(?:Step-(\d+)\.\d+|Prep-S(\d+)-)")
37
42
 
38
43
  @dataclass
39
44
  class IncrementalDecision:
40
- mode: str # "incremental" | "full"
45
+ mode: str # "incremental" | "full" | "unresolved"
41
46
  reverify_stages: list[int]
42
47
  carry_stages: list[int]
43
48
  reason: str
@@ -167,6 +172,25 @@ def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
167
172
  return stages
168
173
 
169
174
 
175
+ def partition_clarification_stages(
176
+ data: dict, clarification_ids: set[str]
177
+ ) -> tuple[set[int], list[str]]:
178
+ """연결된 stage 번호와, 아무 stage 에도 안 닿는 id.
179
+
180
+ preview 와 판정 경로가 같은 분할을 쓴다. unlinked 는 full 강등이 아니라
181
+ `--impacted` 를 받거나 `--full-reason` 을 받는 분기이다.
182
+ """
183
+ impacted: set[int] = set()
184
+ unlinked: list[str] = []
185
+ for clarification_id in sorted(clarification_ids):
186
+ stages = stages_for_clarification(data, clarification_id)
187
+ if stages:
188
+ impacted |= stages
189
+ else:
190
+ unlinked.append(clarification_id)
191
+ return impacted, unlinked
192
+
193
+
170
194
  def clarification_impacted_stages(
171
195
  data: dict, clarification_ids: set[str]
172
196
  ) -> set[int]:
@@ -179,16 +203,15 @@ def clarification_impacted_stages(
179
203
 
180
204
  Raises when any id resolves to no stage — a partially-resolved set would
181
205
  narrow the re-run past an answer whose blast radius nobody established.
206
+ The CLI decision path does not use this raise as a full fallback; it asks
207
+ for `--impacted` instead.
182
208
  """
183
- impacted: set[int] = set()
184
- for clarification_id in sorted(clarification_ids):
185
- stages = stages_for_clarification(data, clarification_id)
186
- if not stages:
187
- raise ValueError(
188
- f"answered clarification {clarification_id} traces to no stage "
189
- "(no plan item or coverage row cites one)"
190
- )
191
- impacted |= stages
209
+ impacted, unlinked = partition_clarification_stages(data, clarification_ids)
210
+ if unlinked:
211
+ raise ValueError(
212
+ f"answered clarification {unlinked[0]} traces to no stage "
213
+ "(no plan item or coverage row cites one)"
214
+ )
192
215
  return impacted
193
216
 
194
217
 
@@ -227,20 +250,19 @@ def preview_link_availability(data: dict, clarification_ids: set[str]) -> dict:
227
250
  be shown while the run is still reshapeable.
228
251
 
229
252
  `wouldForceFull: false` is therefore not a promise of `incremental` — it
230
- says only that this half found nothing forcing full.
253
+ says only that this half found nothing forcing full. An unlinked id is
254
+ not such a thing: it cannot auto-narrow, and it does not force full.
231
255
  """
232
- unlinked = sorted(
233
- clarification_id
234
- for clarification_id in clarification_ids
235
- if not stages_for_clarification(data, clarification_id)
236
- )
256
+ _, unlinked = partition_clarification_stages(data, clarification_ids)
237
257
  if unlinked:
238
258
  return {
239
- "wouldForceFull": True,
259
+ "wouldForceFull": False,
240
260
  "unlinkedIds": unlinked,
241
261
  "reason": (
242
262
  f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
243
- "an answer whose blast radius was never recorded cannot narrow anything"
263
+ "name those stages via --impacted, or declare a structural change "
264
+ "with --full-reason; an unlinked id does not force the whole re-run "
265
+ "to full"
244
266
  ),
245
267
  }
246
268
  if not clarification_ids:
@@ -350,6 +372,47 @@ def _preview_result(args) -> dict:
350
372
  }
351
373
 
352
374
 
375
+ def _decision_for_run(args) -> IncrementalDecision:
376
+ """SHA·폐포·컷오프 판정. unlinked 는 full 로 강등하지 않는다."""
377
+ data = load_owned_object(
378
+ Path(args.prev_data), artifact="incremental scope report"
379
+ )
380
+ stages = parse_stage_graph(data)
381
+ impacted = {
382
+ int(token.strip()) for token in args.impacted.split(",") if token.strip()
383
+ }
384
+ prep_ids = {
385
+ token.strip() for token in args.prep_items.split(",") if token.strip()
386
+ }
387
+ impacted.update(design_prep_impacted_stages(data, prep_ids))
388
+ answered = {
389
+ token.strip()
390
+ for token in args.answered_clarifications.split(",")
391
+ if token.strip()
392
+ }
393
+ linked, unlinked = partition_clarification_stages(data, answered)
394
+ if unlinked and not impacted:
395
+ return IncrementalDecision(
396
+ UNRESOLVED_MODE,
397
+ [],
398
+ [],
399
+ f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
400
+ "pass --impacted with the stage numbers those answers affect, or "
401
+ "--full-reason for a structural change",
402
+ )
403
+ impacted.update(linked)
404
+ unknown_stages = impacted - {stage for stage, _ in stages}
405
+ if unknown_stages:
406
+ unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
407
+ raise ValueError(f"impacted stage(s) absent from Stage Map: {unknown}")
408
+ return decide_scope(
409
+ stages=stages,
410
+ impacted_stages=impacted,
411
+ prev_base_sha=args.prev_base_sha,
412
+ cur_base_sha=args.cur_base_sha,
413
+ )
414
+
415
+
353
416
  def main(argv: list[str]) -> int:
354
417
  ap = argparse.ArgumentParser(prog="okstra incremental-scope")
355
418
  ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
@@ -395,31 +458,13 @@ def main(argv: list[str]) -> int:
395
458
  return 0
396
459
 
397
460
  try:
398
- data = load_owned_object(
399
- Path(args.prev_data), artifact="incremental scope report"
400
- )
401
- stages = parse_stage_graph(data)
402
- impacted = {int(t.strip()) for t in args.impacted.split(",") if t.strip()}
403
- prep_ids = {t.strip() for t in args.prep_items.split(",") if t.strip()}
404
- impacted.update(design_prep_impacted_stages(data, prep_ids))
405
- answered = {
406
- t.strip() for t in args.answered_clarifications.split(",") if t.strip()
407
- }
408
- if answered:
409
- impacted.update(clarification_impacted_stages(data, answered))
410
- unknown_stages = impacted - {stage for stage, _ in stages}
411
- if unknown_stages:
412
- unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
413
- raise ValueError(f"impacted stage(s) absent from Stage Map: {unknown}")
414
- decision = decide_scope(
415
- stages=stages, impacted_stages=impacted,
416
- prev_base_sha=args.prev_base_sha, cur_base_sha=args.cur_base_sha,
417
- )
461
+ decision = _decision_for_run(args)
418
462
  # Every bad input degrades to a full re-verification rather than raising:
419
463
  # `full` is always the safe answer, and the caller reads the decision off
420
464
  # stdout, so a traceback would leave it with no decision at all. OSError
421
465
  # covers an unreadable --prev-data, KeyError/TypeError a stageMap row that
422
- # is missing `stage` or is not a mapping.
466
+ # is missing `stage` or is not a mapping. Unlinked ids are not this case
467
+ # — `_decision_for_run` returns `unresolved` instead of raising.
423
468
  except (OSError, ValueError, KeyError, TypeError) as exc:
424
469
  decision = IncrementalDecision(
425
470
  "full", [], [], f"invalid incremental-scope input: {exc}",