okstra 0.141.3 → 0.142.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 (39) hide show
  1. package/docs/architecture.md +11 -2
  2. package/docs/cli.md +15 -0
  3. package/docs/for-ai/skills/okstra-setup.md +8 -0
  4. package/docs/project-structure-overview.md +4 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +2 -1
  8. package/runtime/prompts/coding-preflight/architectures/hexagonal.md +3 -3
  9. package/runtime/prompts/coding-preflight/overview.md +1 -1
  10. package/runtime/prompts/lead/adapters/claude-code.md +2 -2
  11. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  12. package/runtime/prompts/lead/plan-body-verification.md +20 -9
  13. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -0
  14. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -3
  15. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  16. package/runtime/prompts/profiles/_implementation-verifier.md +2 -1
  17. package/runtime/prompts/profiles/forbidden-actions.json +0 -1
  18. package/runtime/prompts/profiles/implementation-planning.md +7 -2
  19. package/runtime/prompts/profiles/requirements-discovery.md +7 -0
  20. package/runtime/python/okstra_ctl/clarification_items.py +99 -5
  21. package/runtime/python/okstra_ctl/paths.py +34 -7
  22. package/runtime/python/okstra_ctl/phase_cleanup.py +235 -0
  23. package/runtime/python/okstra_ctl/plan_items.py +38 -0
  24. package/runtime/python/okstra_ctl/wizard.py +16 -0
  25. package/runtime/python/okstra_ctl/worker_heartbeat.py +15 -5
  26. package/runtime/python/okstra_ctl/workflow.py +1 -1
  27. package/runtime/python/okstra_project/resolver.py +25 -0
  28. package/runtime/schemas/final-report-v1.0.schema.json +63 -4
  29. package/runtime/skills/okstra-run/SKILL.md +3 -1
  30. package/runtime/skills/okstra-setup/SKILL.md +3 -0
  31. package/runtime/skills/okstra-setup/references/project-config.md +47 -0
  32. package/runtime/templates/reports/final-report.template.md +25 -0
  33. package/runtime/templates/reports/i18n/en.json +12 -1
  34. package/runtime/templates/reports/i18n/ko.json +12 -1
  35. package/runtime/templates/reports/implementation-input.template.md +1 -2
  36. package/runtime/templates/reports/task-brief.template.md +1 -1
  37. package/runtime/validators/validate-run.py +143 -12
  38. package/src/cli-registry.mjs +10 -0
  39. package/src/commands/execute/phase-cleanup.mjs +38 -0
@@ -400,7 +400,8 @@
400
400
  "planBodyVerification",
401
401
  "crossProjectDependencies",
402
402
  "decisionDrafts",
403
- "skippedAdrCandidates"
403
+ "skippedAdrCandidates",
404
+ "variationPointAnalysis"
404
405
  ],
405
406
  "additionalProperties": false,
406
407
  "properties": {
@@ -426,6 +427,7 @@
426
427
  "items": { "$ref": "#/$defs/ImplementationPlanStage" }
427
428
  },
428
429
  "designPreparation": { "$ref": "#/$defs/DesignPreparation" },
430
+ "variationPointAnalysis": { "$ref": "#/$defs/VariationPointAnalysis" },
429
431
  "stepwiseExecution": {
430
432
  "description": "Legacy flat summary kept for compatibility only. New reports use stageMap/stages.",
431
433
  "type": "array",
@@ -1294,13 +1296,70 @@
1294
1296
 
1295
1297
  "RecommendedOption": {
1296
1298
  "type": "object",
1297
- "required": ["name", "coreReason", "rationale", "rejectedSummary"],
1299
+ "required": ["name", "coreReason", "rationale", "rejectedSummary", "testSeams"],
1298
1300
  "additionalProperties": false,
1299
1301
  "properties": {
1300
1302
  "name": { "type": "string", "minLength": 1 },
1301
1303
  "coreReason": { "type": "string", "minLength": 1 },
1302
1304
  "rationale": { "type": "string", "minLength": 1 },
1303
- "rejectedSummary": { "type": "string", "minLength": 1 }
1305
+ "rejectedSummary": { "type": "string", "minLength": 1 },
1306
+ "testSeams": {
1307
+ "type": "array",
1308
+ "description": "Boundaries a test injects and replaces. An empty array is a deliberate declaration that no seam is needed (the plan body must justify it).",
1309
+ "items": {
1310
+ "type": "object",
1311
+ "required": ["boundary", "injectedAs", "replacedInTest"],
1312
+ "additionalProperties": false,
1313
+ "properties": {
1314
+ "boundary": { "type": "string", "minLength": 1 },
1315
+ "injectedAs": { "type": "string", "minLength": 1 },
1316
+ "replacedInTest": { "type": "string", "minLength": 1 }
1317
+ }
1318
+ }
1319
+ }
1320
+ }
1321
+ },
1322
+
1323
+ "VariationPointAnalysis": {
1324
+ "type": "object",
1325
+ "required": ["hasMultipleImplementations", "noVariationRationale", "points"],
1326
+ "additionalProperties": false,
1327
+ "properties": {
1328
+ "hasMultipleImplementations": { "type": "boolean" },
1329
+ "noVariationRationale": {
1330
+ "type": "string",
1331
+ "description": "Why no variation point exists when hasMultipleImplementations is false. Empty string when it is true."
1332
+ },
1333
+ "points": {
1334
+ "type": "array",
1335
+ "items": { "$ref": "#/$defs/VariationPoint" }
1336
+ }
1337
+ }
1338
+ },
1339
+
1340
+ "VariationPoint": {
1341
+ "type": "object",
1342
+ "required": ["behavior", "implementations", "evidence", "extractionDecision"],
1343
+ "additionalProperties": false,
1344
+ "properties": {
1345
+ "behavior": { "type": "string", "minLength": 1 },
1346
+ "implementations": {
1347
+ "type": "array",
1348
+ "minItems": 2,
1349
+ "items": { "type": "string", "minLength": 1 }
1350
+ },
1351
+ "evidence": { "type": "string", "minLength": 1 },
1352
+ "extractionDecision": {
1353
+ "type": "object",
1354
+ "required": ["extract", "interfaceKind", "coveredBy", "rationale"],
1355
+ "additionalProperties": false,
1356
+ "properties": {
1357
+ "extract": { "type": "boolean" },
1358
+ "interfaceKind": { "type": "string" },
1359
+ "coveredBy": { "type": "string" },
1360
+ "rationale": { "type": "string", "minLength": 1 }
1361
+ }
1362
+ }
1304
1363
  }
1305
1364
  },
1306
1365
 
@@ -2157,7 +2216,7 @@
2157
2216
  "rollbackCommand": { "type": "string", "minLength": 1 },
2158
2217
  "verification": { "type": "string", "minLength": 1 },
2159
2218
  "result": {
2160
- "enum": ["ok", "unableroute back to planning"]
2219
+ "enum": ["ok", "not-applicable", "advisory human-run"]
2161
2220
  }
2162
2221
  }
2163
2222
  },
@@ -161,6 +161,8 @@ okstra config set pr-template-path "<value>" --scope global
161
161
 
162
162
  If an action has an unknown `command`, `key`, or `scope`, stop and report the wizard output instead of inventing a command.
163
163
 
164
+ Before rendering the next phase's bundle, reclaim the prior phase's residual resources: run `okstra phase-cleanup --task-key <args.project-id>:<args.task-group>:<args.task-id> --project-root "<PROJECT_ROOT>" --fallback-team "session-<lead.sessionId-prefix>"`, then send `SendMessage(to: <name>, message: { type: "shutdown_request" })` to each `dismissible-teammates` name — only to teammates you have confirmed complete, never the lead and never an in-flight worker. `--fallback-team` takes the same `session-<lead.sessionId-prefix>` label the lead recorded in team-state, and must always be passed: after a resume or compaction the session id is re-issued, and without the label the reconcile finds no live roster and prints no teammate to dismiss. Assemble `--task-key` as the 3-token `project-id:task-group:task-id` literal from `outcome.renderArgs`; any other shape (a `<task-group>/<task-id>` pair, a bare task-id) is rejected and the whole cleanup — teammate reconcile included — is silently skipped. **This applies only when advancing to a different analysis phase.** The implementation stage chain (Step 7) does its own per-stage reclaim in its step 3 and does NOT run this `--task-key` form. `--run-dir` is unnecessary — the command auto-discovers the newest completed run across phases, including staged (implementation stage / single-stage final-verification) `runs/<type>/stage-N/` runs; pass `--run-dir` only to override that discovery with a specific prior run. A cleanup failure never blocks the next phase (the Python worker returns 0); only a malformed invocation exits non-zero.
165
+
164
166
  Build the `okstra render-bundle` invocation from `outcome.renderArgs`, passing each key as `--<key>` and the value verbatim (including empty strings — they are intentional `use phase default` markers).
165
167
 
166
168
  Step 3's empty-answer and escaping rules apply verbatim: every flag whose value is the empty string MUST still be passed explicitly as `--<key> ""` (e.g. `--workers ""`, `--directive ""`) — `render-bundle` distinguishes "flag absent" from "flag present with empty value", and the wizard's intent is always the latter.
@@ -258,7 +260,7 @@ Queue = the topologically-sorted stage list from splitting `chain-stages` on `,`
258
260
 
259
261
  1. Call Step 5's `render-bundle` with the same arguments but `--stage N` (the base commit is auto-computed by prepare from the predecessor's done `head_commit`, so do not pass it by hand). Step 5's blocking local conformance waiver offer·concurrent-run detection·git-reconcile gates apply identically to each stage's `render-bundle`.
260
262
  2. As in Step 6, become Claude lead and run that stage's Phase 1–7 inline. Phase 6's lead post-stage persistence appends that stage's `status:"done"` row to `runs/<plan-task-key>/consumers.jsonl` (per the implementation profile directive).
261
- 3. After confirming that `done` row was written, move to the next stage. Clean up context at each stage boundary (leftover panes·finished teammates from the previous batch).
263
+ 3. After confirming that `done` row was written, run `okstra phase-cleanup --run-dir "<the run dir of the stage you just completed>" --project-root "<PROJECT_ROOT>" --fallback-team "session-<lead.sessionId-prefix>"`; for each `dismissible-teammates` name, send `SendMessage(to: <name>, message: { type: "shutdown_request" })` — only to teammates you have confirmed complete, never the lead and never an in-flight worker. Then move to the next stage.
262
264
  4. One-line report at each stage start/finish: `stage N/<total> start` / `stage N done → next K`.
263
265
 
264
266
  Once the whole queue is consumed, end the chain and report completion to the user.
@@ -116,6 +116,9 @@ matching section:
116
116
  offered on the first release-handoff run.
117
117
  - **E. final report language** (`reportLanguage`) — default is auto (follows
118
118
  the brief's language).
119
+ - **F. declared architecture style** (`architecture.style`) — `hexagonal` /
120
+ `layered` / `none`; hand-added, default `none`. Declaring one makes that
121
+ architecture's placement rules binding in planning + verification.
119
122
 
120
123
  ## Step 4: Verify
121
124
 
@@ -196,3 +196,50 @@ okstra config set report-language <en|ko|auto> --scope project
196
196
 
197
197
  Set the global default manually with `--scope global` as described in the
198
198
  README's "global config" guidance — this flow offers only project scope.
199
+
200
+ ## F. Declared architecture style (`architecture.style`)
201
+
202
+ `architecture.style` declares the project's architecture — one of
203
+ `hexagonal`, `layered`, or `none`. It is optional and defaults to `none`: an
204
+ absent field, an unrecognized value, or an unreadable `project.json` all
205
+ resolve to `none`
206
+ (`scripts/okstra_project/resolver.py::resolve_architecture`), so a project
207
+ that never declares one keeps today's behaviour unchanged.
208
+
209
+ Like `worktreeSyncDirs` and `qaCommands`, `okstra setup` does NOT write this
210
+ field — hand-add it to `project.json`. It is preserved across the runtime's
211
+ auto-upserts (only `projectId`, `projectRoot`, `createdAt`, `updatedAt` are
212
+ runtime-owned), so the manual edit survives every subsequent `okstra setup` /
213
+ `okstra run` invocation.
214
+
215
+ ```json
216
+ {
217
+ "projectId": "...",
218
+ "projectRoot": "...",
219
+ "architecture": { "style": "hexagonal" }
220
+ }
221
+ ```
222
+
223
+ Declaring `hexagonal` or `layered` promotes that architecture's placement
224
+ rules from advisory to a binding planning + verification constraint:
225
+
226
+ - `hexagonal` — extraction itself stays a free decision (`extract: false`
227
+ remains legal), but a variation point `implementation-planning` *does*
228
+ extract has to sit behind a port: an `extractionDecision` carrying
229
+ `extract: true` with any `interfaceKind` other than `"port"` fails
230
+ `validators/validate-run.py::_validate_variation_point_analysis`. The
231
+ implementation executor loads the `architectures/hexagonal.md` preflight
232
+ pack even when directory-shape detection did not match it, and the verifier
233
+ grades a service dependency the diff adds or modifies that injects a
234
+ concrete adapter instead of a port as a blocking `FAIL` rather than a
235
+ recommendation.
236
+ - `layered` — no preflight pack resource. Its binding invariant is dependency
237
+ direction: an upper layer may import a lower one, never the reverse. A
238
+ reverse import is a blocking placement violation found by worker judgement,
239
+ because no machine check reads layer names.
240
+ - `none` (or the field left out) — the style-agnostic planning rules still
241
+ run on every plan (variation-point analysis and test seams); only the
242
+ placement overlay stays advisory and detection-driven.
243
+
244
+ The full two-layer model lives in `docs/architecture.md` § Project
245
+ self-registration.
@@ -477,6 +477,31 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
477
477
  {% endfor %}
478
478
  {%- endif %}
479
479
 
480
+ ### 5.5.11 Variation-Point Analysis{% if t("implementationPlanning.variationPointAnalysis.heading") != "Variation-Point Analysis" %} ({{ t("implementationPlanning.variationPointAnalysis.heading") }}){% endif %}
481
+
482
+ - **{{ t("implementationPlanning.variationPointAnalysis.hasMultiple") }}:** `{% if implementationPlanning.variationPointAnalysis.hasMultipleImplementations %}yes{% else %}no{% endif %}`
483
+
484
+ {% if not implementationPlanning.variationPointAnalysis.hasMultipleImplementations -%}
485
+ - **{{ t("implementationPlanning.variationPointAnalysis.rationale") }}:** {{ implementationPlanning.variationPointAnalysis.noVariationRationale }}
486
+ {% else %}
487
+ {% for point in implementationPlanning.variationPointAnalysis["points"] %}
488
+ - **{{ t("implementationPlanning.variationPointAnalysis.behavior") }}:** {{ point.behavior }}
489
+ - **{{ t("implementationPlanning.variationPointAnalysis.implementations") }}:** {{ point.implementations | join(", ") }}
490
+ - **{{ t("implementationPlanning.variationPointAnalysis.evidence") }}:** {{ point.evidence }}
491
+ - **{{ t("implementationPlanning.variationPointAnalysis.extract") }}:** `{% if point.extractionDecision.extract %}yes{% else %}no{% endif %}` — {{ point.extractionDecision.interfaceKind }} ({{ point.extractionDecision.coveredBy }}) — {{ point.extractionDecision.rationale }}
492
+ {% endfor %}
493
+ {% endif %}
494
+
495
+ **{{ t("implementationPlanning.variationPointAnalysis.testSeams") }}:**
496
+
497
+ {% if implementationPlanning.recommendedOption["testSeams"] | length == 0 -%}
498
+ {{ t("implementationPlanning.variationPointAnalysis.seamEmpty") }}
499
+ {% else %}
500
+ {% for seam in implementationPlanning.recommendedOption["testSeams"] -%}
501
+ - {{ seam.boundary }} — {{ seam.injectedAs }} → {{ seam.replacedInTest }}
502
+ {% endfor %}
503
+ {% endif %}
504
+
480
505
  {% endif %}
481
506
  {% if header.taskType == 'release-handoff' %}
482
507
  ## 5.6 Release Handoff Deliverables
@@ -112,7 +112,7 @@
112
112
  "planBodyGateLegend": "Gate values — `passed`: agreed, no dissent · `passed-with-dissent`: a minority dissent remains but the gate passes (a majority dissent would block approval) · `blocked-by-disagreement`: majority dissent blocks approval · `aborted-non-result`: verification itself produced no result.",
113
113
  "planBodyBlockedByLegend": "Which input blocked the gate — `majority-disagree`: a worker majority dissent · `coverage-gap`: a Requirement Coverage gap / blocked row, independent of worker votes · `non-result`: verification produced no result. Absent means nothing blocked.",
114
114
  "planBodyVerdictLegend": "Verdict — **AGREE**: executable as written and internally consistent with other items · **SUPPLEMENT**: item is sound but a dependency / edge case / precondition is missing · **DISAGREE**: has a defect (see Breakage kind) · **verification-error**: the worker produced no result.",
115
- "planBodyBreakageLegend": "Breakage kind — a: cited file path/symbol mismatches another step or option · b: command is not executable or is ambiguous · c: validation signal is not observable · d: rollback violates commit/dependency order · e: contradicts the trade-off matrix · f: requirement-coverage row does not map to an option/stage/step that actually satisfies the requirement. (`--` = not applicable) Fixability: planner-fixable = correctable from code + plan + brief; needs-user-input = requires an external decision.",
115
+ "planBodyBreakageLegend": "Breakage kind — a: cited file path/symbol mismatches another step or option · b: command is not executable or is ambiguous · c: validation signal is not observable · d: rollback violates commit/dependency order (advisory — a rollback is human-run, so this never blocks the gate) · e: contradicts the trade-off matrix · f: requirement-coverage row does not map to an option/stage/step that actually satisfies the requirement. (`--` = not applicable) Fixability: planner-fixable = correctable from code + plan + brief; needs-user-input = requires an external decision.",
116
116
  "planBodySourceLabel": "source §",
117
117
  "planBodyBlockerLabel": "blocks approval →",
118
118
  "optionInterfacesLabel": "Affected interfaces / public contracts / downstream consumers",
@@ -168,6 +168,17 @@
168
168
  "notApplicableReason": "Not applicable reason",
169
169
  "none": "(none)",
170
170
  "empty": "No implementation design preparation items."
171
+ },
172
+ "variationPointAnalysis": {
173
+ "heading": "Variation-Point Analysis",
174
+ "hasMultiple": "Multiple implementations",
175
+ "rationale": "No-variation rationale",
176
+ "behavior": "Behavior",
177
+ "implementations": "Implementations",
178
+ "evidence": "Evidence",
179
+ "extract": "Extract as interface",
180
+ "testSeams": "Test seams",
181
+ "seamEmpty": "No seams declared."
171
182
  }
172
183
  },
173
184
  "releaseHandoff": {
@@ -112,7 +112,7 @@
112
112
  "planBodyGateLegend": "게이트 결과 값 뜻 — `passed`: 이견 없이 통과 · `passed-with-dissent`: 소수 워커의 반대가 남았으나 통과(반대가 다수였다면 승인 차단) · `blocked-by-disagreement`: 다수 반대로 승인 차단 · `aborted-non-result`: 검증 자체가 결과를 내지 못함.",
113
113
  "planBodyBlockedByLegend": "게이트를 막은 입력 — `majority-disagree`: 워커 다수 반대 · `coverage-gap`: Requirement Coverage 의 gap / blocked 행(워커 반대와는 무관) · `non-result`: 검증 자체가 결과를 내지 못함. 이 항목이 없으면 차단 원인이 없다는 뜻이다.",
114
114
  "planBodyVerdictLegend": "판정(Verdict) — **AGREE**: 적힌 대로 실행 가능하고 다른 항목과 내부적으로 일관됨 · **SUPPLEMENT**: 항목 자체는 타당하나 의존성·엣지케이스·전제조건이 누락됨 · **DISAGREE**: 결함이 있음(결함 유형 참조) · **verification-error**: 워커 검증이 결과를 내지 못함.",
115
- "planBodyBreakageLegend": "결함 유형(Breakage kind) — a: 인용한 파일 경로/심볼이 다른 스텝·옵션과 불일치 · b: 명령이 실행 불가하거나 모호함 · c: 검증 신호가 관측 불가 · d: 롤백이 커밋/의존성 순서를 위반 · e: 트레이드오프 매트릭스와 모순 · f: 요구사항 커버리지 행이 요구사항을 실제로 충족하는 옵션/스테이지/스텝에 매핑되지 않음. (`--` = 해당 없음) · 자가수정 가능 여부(Fixability) — planner-fixable: 코드·계획·브리프만으로 수정 가능 · needs-user-input: 외부 결정 필요",
115
+ "planBodyBreakageLegend": "결함 유형(Breakage kind) — a: 인용한 파일 경로/심볼이 다른 스텝·옵션과 불일치 · b: 명령이 실행 불가하거나 모호함 · c: 검증 신호가 관측 불가 · d: 롤백이 커밋/의존성 순서를 위반 (참고용 — 롤백은 사람이 수행하므로 게이트를 막지 않음) · e: 트레이드오프 매트릭스와 모순 · f: 요구사항 커버리지 행이 요구사항을 실제로 충족하는 옵션/스테이지/스텝에 매핑되지 않음. (`--` = 해당 없음) · 자가수정 가능 여부(Fixability) — planner-fixable: 코드·계획·브리프만으로 수정 가능 · needs-user-input: 외부 결정 필요",
116
116
  "planBodySourceLabel": "출처 §",
117
117
  "planBodyBlockerLabel": "승인 차단 →",
118
118
  "optionInterfacesLabel": "영향 인터페이스 / 공개 계약 / 다운스트림 소비자",
@@ -168,6 +168,17 @@
168
168
  "notApplicableReason": "해당 없음 이유",
169
169
  "none": "(없음)",
170
170
  "empty": "구현 설계 준비 항목이 없습니다."
171
+ },
172
+ "variationPointAnalysis": {
173
+ "heading": "변이점 분석",
174
+ "hasMultiple": "복수 구현",
175
+ "rationale": "변이점 없음 근거",
176
+ "behavior": "동작",
177
+ "implementations": "구현체",
178
+ "evidence": "근거",
179
+ "extract": "인터페이스로 추출",
180
+ "testSeams": "테스트 이음새",
181
+ "seamEmpty": "선언된 이음새가 없습니다."
171
182
  }
172
183
  },
173
184
  "releaseHandoff": {
@@ -80,8 +80,7 @@ taskType: "{{FM_TASK_TYPE}}"
80
80
  2. (Executor) Did any change touch a file or symbol that appears in `Out of Scope`? If yes, halt and report — do not silently include it.
81
81
  3. (Verifiers) Does the diff match the plan's File Structure and step ordering?
82
82
  4. (Verifiers) Does the validation evidence include actual command output and exit codes for every plan checkpoint?
83
- 5. (Verifiers) Is the rollback path still valid after the changes?
84
- 6. (Verifiers) Does the diff contain any "while I'm here" edits (rename, reformat, comment cleanup, adjacent refactor) that the plan did not authorise? Flag each such hunk explicitly.
83
+ 5. (Verifiers) Does the diff contain any "while I'm here" edits (rename, reformat, comment cleanup, adjacent refactor) that the plan did not authorise? Flag each such hunk explicitly.
85
84
 
86
85
  ## Phase Boundary
87
86
 
@@ -67,7 +67,7 @@ taskType: "{{FM_TASK_TYPE}}"
67
67
  - If `Task Type` is `implementation`:
68
68
  - Which approved `implementation-planning` final report authorises this run, and is its frontmatter `approved: true` cited verbatim?
69
69
  - What is the authoritative file list and step order copied from that plan?
70
- - Which validation, TDD, and rollback commands must be executed and recorded with actual output?
70
+ - Which validation and TDD commands must be executed and recorded with actual output? (Rollback is human-run — record the revert path for reference; do not require executing it.)
71
71
  - If `Task Type` is `final-verification`:
72
72
  - What was delivered?
73
73
  - What acceptance criteria must pass?
@@ -34,6 +34,7 @@ except ImportError: # pragma: no cover — runtime guarantees this import
34
34
 
35
35
  from okstra_project import project_json_path # noqa: E402
36
36
  from okstra_project.dirs import tasks_root as _okstra_tasks_root # noqa: E402
37
+ from okstra_project.resolver import resolve_architecture # noqa: E402
37
38
 
38
39
  from okstra_ctl.conformance import ( # noqa: E402
39
40
  CAPABILITY_WHITELIST,
@@ -2343,6 +2344,11 @@ def validate_final_report_data(
2343
2344
  _validate_round_recorded_verdicts(data, failures)
2344
2345
  _validate_verdicts_match_current_subjects(data, failures)
2345
2346
  _validate_plan_item_extraction_completeness(data, failures)
2347
+ _validate_variation_point_analysis(
2348
+ (data.get("implementationPlanning") or {}).get("variationPointAnalysis"),
2349
+ resolve_architecture(_project_root_from_report(report_path)),
2350
+ failures,
2351
+ )
2346
2352
  _validate_plan_item_subject_substance(data, failures)
2347
2353
  _validate_plan_body_clarification_matching(data, failures)
2348
2354
  _validate_disagree_has_fixability(data, failures)
@@ -2524,10 +2530,16 @@ _PLAN_GATE_RANK = {
2524
2530
 
2525
2531
  # Breakage kinds where a single DISAGREE blocks the gate on its own (no majority
2526
2532
  # needed), because the defect is concrete, safety-critical, and adversarially
2527
- # verifiable: `a` = cited path/symbol mismatch, `d` = rollback violates
2528
- # commit/dependency order. `b`/`c`/`e` still need a majority `b` in particular
2529
- # is prone to planning-vs-implementation environment false positives.
2530
- _SINGLE_VOTE_BLOCKING_KINDS = {"a", "d"}
2533
+ # verifiable: `a` = cited path/symbol mismatch. `b`/`c`/`e` still need a
2534
+ # majority `b` in particular is prone to planning-vs-implementation
2535
+ # environment false positives.
2536
+ _SINGLE_VOTE_BLOCKING_KINDS = {"a"}
2537
+
2538
+ # Rollback ordering (`d`) is executed by a human, not by okstra's workers or
2539
+ # verifiers, so a rollback-ordering dissent is recorded but never gates
2540
+ # approval: it is dropped from the blocking-disagree tally entirely, so an item
2541
+ # whose only DISAGREEs are advisory-only can never rise above `has-dissent`.
2542
+ _ADVISORY_ONLY_KINDS = {"d"}
2531
2543
 
2532
2544
  # Stop reasons that justify promoting a still-broken planner-fixable item to the
2533
2545
  # user: the self-fix budget ran out, or a round produced no net resolution so
@@ -2535,6 +2547,20 @@ _SINGLE_VOTE_BLOCKING_KINDS = {"a", "d"}
2535
2547
  _SELF_FIX_EXHAUSTED_REASONS = frozenset({"max-rounds-reached", "no-progress"})
2536
2548
 
2537
2549
 
2550
+ def _is_variation_point_item(item: dict) -> bool:
2551
+ """Whether this is a `P-Var-*` variation-point item, which is majority-gated
2552
+ (`prompts/lead/plan-body-verification.md` "`P-Var-<N>` … is majority-gated").
2553
+ Whether a behavior has two implementations, and whether the plan extracted the
2554
+ right interface for it, is a design judgement — it lacks the concrete certainty
2555
+ of kind `a`, where a verifier points at two spelled-out references that
2556
+ contradict each other. So kind `a` carries no extra weight on a P-Var item: it
2557
+ neither single-vote-blocks nor counts as correctness-critical, exactly like the
2558
+ `b` / `c` / `e` kinds the prompt routes P-Var defects to. Only a
2559
+ `majority-disagree` gates it — that part is unchanged.
2560
+ """
2561
+ return str(item.get("id") or "").upper().startswith("P-VAR")
2562
+
2563
+
2538
2564
  def _classify_plan_item_gate(item: dict) -> str:
2539
2565
  """Recompute one plan item's gate class from its per-worker verdicts,
2540
2566
  per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
@@ -2556,15 +2582,32 @@ def _classify_plan_item_gate(item: dict) -> str:
2556
2582
  return "all-non-result"
2557
2583
  disagree = [(vd, bk) for (vd, bk) in non_error if vd == "DISAGREE"]
2558
2584
  agree = [(vd, bk) for (vd, bk) in non_error if vd in ("AGREE", "SUPPLEMENT")]
2559
- disagree_kinds = {bk for (_vd, bk) in disagree if bk}
2560
2585
  if not disagree:
2561
2586
  return "full-consensus"
2587
+ # Rollback is a human-run operation, so rollback dissent never blocks the
2588
+ # gate — closed from two angles so a verifier cannot re-block it by relabelling:
2589
+ # (1) a whole rollback plan item (`P-Rb-*`) is advisory regardless of
2590
+ # breakage kind — otherwise a `DISAGREE(b)` "rollback command is
2591
+ # ambiguous" would sail past the kind-`d` exemption and block;
2592
+ # (2) a rollback-ordering dissent (`d`) is advisory on ANY item, since a
2593
+ # rollback-order defect raised against a non-rollback item is still a
2594
+ # human-run concern.
2595
+ # Both are recorded as dissent and fold into `has-dissent`, never blocking.
2596
+ if str(item.get("id") or "").upper().startswith("P-RB"):
2597
+ return "has-dissent"
2598
+ blocking_disagree = [(vd, bk) for (vd, bk) in disagree if bk not in _ADVISORY_ONLY_KINDS]
2599
+ if not blocking_disagree:
2600
+ return "has-dissent"
2601
+ blocking_kinds = {bk for (_vd, bk) in blocking_disagree if bk}
2562
2602
  # Single-vote-blocking kinds: one confirmed DISAGREE on a concrete,
2563
2603
  # safety-critical, adversarially-verifiable defect is enough to block, even
2564
2604
  # in a two-worker roster — a lone correct dissent must not be outvoted here.
2565
- # `a`/`d` for any item; `f` only for P-Req items (requirement coverage).
2605
+ # `a` for any item except `P-Var-*` (majority-gated, see
2606
+ # `_is_variation_point_item`); `f` only for P-Req items (requirement coverage).
2566
2607
  is_req = str(item.get("id") or "").upper().startswith("P-REQ")
2567
- if disagree_kinds & _SINGLE_VOTE_BLOCKING_KINDS or (is_req and "f" in disagree_kinds):
2608
+ if not _is_variation_point_item(item) and (
2609
+ blocking_kinds & _SINGLE_VOTE_BLOCKING_KINDS or (is_req and "f" in blocking_kinds)
2610
+ ):
2568
2611
  # "One confirmed DISAGREE" presupposes the item was actually
2569
2612
  # cross-verified. When the peer returned a non-result nothing confirmed
2570
2613
  # the dissent, so blocking here would reproduce the same
@@ -2577,7 +2620,7 @@ def _classify_plan_item_gate(item: dict) -> str:
2577
2620
  # two participating votes, so a lone surviving DISAGREE (its peer returned a
2578
2621
  # non-result) does NOT block. That fixes the paradox where a worker failure
2579
2622
  # made the gate stricter than a healthy roster would.
2580
- if len(non_error) >= 2 and len(disagree) > len(agree):
2623
+ if len(non_error) >= 2 and len(blocking_disagree) > len(agree):
2581
2624
  return "majority-disagree"
2582
2625
  return "has-dissent"
2583
2626
 
@@ -2604,11 +2647,18 @@ def _has_planner_fixable_majority(item: dict) -> bool:
2604
2647
 
2605
2648
  def _is_correctness_critical(item: dict) -> bool:
2606
2649
  """Whether this item's defect would make `implementation` produce wrong or
2607
- unsafe code — the single-vote-blocking kinds `a` (cited path/symbol
2608
- mismatch) and `d` (rollback violates commit order) on any item, or `f`
2609
- (requirement-coverage mismatch) on a `P-Req-*` item. Kinds `b`/`c`/`e` are
2610
- plan-prose defects: they degrade the document, not the resulting code.
2650
+ unsafe code — the single-vote-blocking kind `a` (cited path/symbol mismatch)
2651
+ on any item but `P-Var-*`, or `f` (requirement-coverage mismatch) on a
2652
+ `P-Req-*` item.
2653
+ Kinds `b`/`c`/`e` are plan-prose defects: they degrade the document, not the
2654
+ resulting code. Rollback ordering (`d`) is advisory — a human runs the
2655
+ rollback — so it never counts as correctness-critical. A `P-Var-*` item is
2656
+ majority-gated end to end, so a kind-`a` dissent on one is no more critical
2657
+ than the `b`/`e` its defect should have been raised under; otherwise the same
2658
+ mis-tag that no longer single-vote-blocks would still veto the downgrade.
2611
2659
  """
2660
+ if _is_variation_point_item(item):
2661
+ return False
2612
2662
  kinds = _disagree_breakage_kinds(item)
2613
2663
  is_req = str(item.get("id") or "").upper().startswith("P-REQ")
2614
2664
  return bool(kinds & _SINGLE_VOTE_BLOCKING_KINDS) or (is_req and "f" in kinds)
@@ -4131,6 +4181,87 @@ def _validate_plan_item_extraction_completeness(
4131
4181
  )
4132
4182
 
4133
4183
 
4184
+ def _validate_variation_point_analysis(
4185
+ vpa: object,
4186
+ architecture_style: str,
4187
+ failures: list[str],
4188
+ ) -> None:
4189
+ """Conditional rules + architecture-style overlay for variation points.
4190
+
4191
+ The schema enforces shape only. Two layers of meaning sit on top:
4192
+
4193
+ Layer 1 is style-agnostic. Declaring "no variation exists" is a claim that
4194
+ needs a written reason, and it must not be paired with declared points —
4195
+ `plan_items._extract_variation_point_items` emits a lone `P-Var-0` in that
4196
+ branch and drops them, so the contradiction would silently exempt every
4197
+ declared point from per-point verification. `extract: true` is a claim in
4198
+ the same way: it names the interface the next implementation plugs into and
4199
+ the Stage Map stage that builds it, so both fields have to be filled. The
4200
+ schema cannot carry this as a `minLength` — the empty string is the natural
4201
+ shape of an `extract: false` decision.
4202
+
4203
+ Layer 2 fires only for a project that declares `architecture.style`
4204
+ `hexagonal`: extracting a variation point there means introducing a port,
4205
+ not a helper. An unconfigured project resolves to `none` and keeps layer-1
4206
+ behaviour only.
4207
+ """
4208
+ if not isinstance(vpa, dict):
4209
+ failures.append("variationPointAnalysis is missing or not an object")
4210
+ return
4211
+ # Schema violations are reported, not raised, so this check still runs on a
4212
+ # malformed block. A type guard here keeps a bad field from aborting the
4213
+ # whole validation and discarding every failure collected so far.
4214
+ raw_points = vpa.get("points")
4215
+ if raw_points is not None and not isinstance(raw_points, list):
4216
+ failures.append("variationPointAnalysis: points must be an array")
4217
+ return
4218
+ points = raw_points or []
4219
+ if not bool(vpa.get("hasMultipleImplementations")):
4220
+ rationale = vpa.get("noVariationRationale")
4221
+ if not isinstance(rationale, str) or not rationale.strip():
4222
+ failures.append(
4223
+ "variationPointAnalysis: hasMultipleImplementations=false "
4224
+ "requires a non-empty noVariationRationale"
4225
+ )
4226
+ if points:
4227
+ failures.append(
4228
+ "variationPointAnalysis: hasMultipleImplementations=false but "
4229
+ "points is non-empty — declared variation points would be "
4230
+ "silently dropped"
4231
+ )
4232
+ return
4233
+ if not points:
4234
+ failures.append(
4235
+ "variationPointAnalysis: hasMultipleImplementations=true requires "
4236
+ "at least one point"
4237
+ )
4238
+ return
4239
+ for index, point in enumerate(points, start=1):
4240
+ if not isinstance(point, dict):
4241
+ failures.append(
4242
+ f"variationPointAnalysis point {index}: must be an object"
4243
+ )
4244
+ continue
4245
+ decision = point.get("extractionDecision")
4246
+ if not isinstance(decision, dict):
4247
+ continue # shape is the schema's job; don't double-report it.
4248
+ if not decision.get("extract"):
4249
+ continue
4250
+ for field in ("interfaceKind", "coveredBy"):
4251
+ value = decision.get(field)
4252
+ if not isinstance(value, str) or not value.strip():
4253
+ failures.append(
4254
+ f"variationPointAnalysis point {index}: extract=true "
4255
+ f"requires a non-empty {field}, got {value!r}"
4256
+ )
4257
+ if architecture_style == "hexagonal" and decision.get("interfaceKind") != "port":
4258
+ failures.append(
4259
+ f"variationPointAnalysis point {index}: architecture style "
4260
+ f"'hexagonal' requires interfaceKind 'port', got "
4261
+ f"{decision.get('interfaceKind')!r}"
4262
+ )
4263
+
4264
+
4134
4265
  _DESIGN_PREP_CONTRACT = "implementation-design-prep-v1"
4135
4266
  _DESIGN_PREP_REQUEST_STATUSES = {"provisional", "blocked"}
4136
4267
  _DESIGN_PREP_TERMINAL_STATUSES = {"ready", "not-applicable"}
@@ -101,6 +101,16 @@ export const COMMAND_REGISTRY = [
101
101
  category: "admin",
102
102
  summary: ["stage 들을 task 브랜치에 통합 머지하고 stage worktree 를 정리"],
103
103
  },
104
+ {
105
+ name: "phase-cleanup",
106
+ module: "./commands/execute/phase-cleanup.mjs",
107
+ export: "run",
108
+ category: "admin",
109
+ summary: [
110
+ "Reclaim the prior phase/batch's completed panes and list dismissible",
111
+ "teammates for the lead to shut down (tmux-aware)",
112
+ ],
113
+ },
104
114
  {
105
115
  name: "paths",
106
116
  module: "./commands/lifecycle/paths.mjs",
@@ -0,0 +1,38 @@
1
+ import { runPythonModule } from "../../lib/python-helper.mjs";
2
+
3
+ const USAGE = `okstra phase-cleanup — reclaim the prior phase/batch's completed panes and teammates
4
+
5
+ Usage:
6
+ okstra phase-cleanup --project-root <dir> [--task-key <k>] [--run-dir <dir>]
7
+ [--fallback-team <label>] [--json]
8
+
9
+ --task-key project-id:task-group:task-id — finds the newest completed run
10
+ across phases, including staged (implementation / final-
11
+ verification) stage-N runs.
12
+ --run-dir explicit prior run dir (wins over --task-key).
13
+ --fallback-team
14
+ the live team label (session-<lead-session-prefix>) so teammate
15
+ reconcile still finds the roster after a session-id re-issue
16
+ (resume/compaction); omit and it resolves the current live
17
+ session only.
18
+
19
+ Reclaims only completed tmux panes (lead pane and in-flight workers are preserved),
20
+ and prints the dismissible-teammate names for the lead to shut down. In a non-tmux
21
+ session no panes exist, so it skips pane reclaim and only reconciles teammates.
22
+ A cleanup failure never blocks the next phase (the worker returns 0); a malformed
23
+ invocation still exits non-zero.
24
+ `;
25
+
26
+ export async function run(args) {
27
+ if (args.includes("--help") || args.includes("-h")) {
28
+ process.stdout.write(USAGE);
29
+ return 0;
30
+ }
31
+
32
+ const result = await runPythonModule({
33
+ module: "okstra_ctl.phase_cleanup",
34
+ args,
35
+ stdio: "inherit-stdout",
36
+ });
37
+ return result.code;
38
+ }