okstra 0.183.2 → 0.185.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 (54) hide show
  1. package/README.md +2 -2
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.d.mts +1 -0
  5. package/dist/commands/chat/chat.mjs +385 -0
  6. package/dist/commands/chat/chat.mjs.map +1 -0
  7. package/dist/lib/skill-catalog.mjs +1 -0
  8. package/dist/lib/skill-catalog.mjs.map +1 -1
  9. package/docs/architecture.md +10 -8
  10. package/docs/cli.md +9 -5
  11. package/docs/for-ai/README.md +4 -2
  12. package/docs/for-ai/skills/okstra-chat.md +28 -0
  13. package/docs/for-ai/skills/okstra-inspect.md +1 -1
  14. package/docs/for-ai/skills/okstra-run.md +2 -2
  15. package/docs/for-ai/skills/okstra-user-response.md +10 -8
  16. package/docs/project-structure-overview.md +6 -5
  17. package/docs/task-process/README.md +2 -2
  18. package/docs/task-process/common-flow.md +2 -3
  19. package/docs/task-process/error-analysis.md +3 -4
  20. package/docs/task-process/final-verification.md +2 -3
  21. package/docs/task-process/implementation-planning.md +3 -4
  22. package/docs/task-process/implementation.md +2 -3
  23. package/docs/task-process/release-handoff.md +3 -4
  24. package/docs/task-process/requirements-discovery.md +3 -4
  25. package/package.json +1 -1
  26. package/runtime/BUILD.json +2 -2
  27. package/runtime/prompts/launch.template.md +8 -7
  28. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  29. package/runtime/prompts/lead/plan-body-verification.md +27 -19
  30. package/runtime/prompts/lead/report-writer.md +4 -4
  31. package/runtime/prompts/profiles/_clarification-recommendation.md +2 -2
  32. package/runtime/prompts/profiles/_implementation-executor.md +1 -0
  33. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  34. package/runtime/prompts/profiles/implementation-planning.md +11 -12
  35. package/runtime/prompts/wizard/prompts.ko.json +9 -10
  36. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -2
  37. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  38. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
  39. package/runtime/python/okstra_ctl/conformance.py +37 -1
  40. package/runtime/python/okstra_ctl/incremental_scope.py +84 -39
  41. package/runtime/python/okstra_ctl/next_phase.py +67 -4
  42. package/runtime/python/okstra_ctl/plan_items.py +410 -1
  43. package/runtime/python/okstra_ctl/plan_items_cli.py +346 -31
  44. package/runtime/python/okstra_ctl/render.py +4 -0
  45. package/runtime/python/okstra_ctl/user_response.py +147 -37
  46. package/runtime/python/okstra_ctl/wizard.py +52 -73
  47. package/runtime/schemas/final-report-v2.0.schema.json +12 -0
  48. package/runtime/schemas/final-report-v3.0.schema.json +12 -0
  49. package/runtime/skills/okstra-chat/SKILL.md +104 -0
  50. package/runtime/skills/okstra-inspect/facets/status.md +6 -5
  51. package/runtime/skills/okstra-run/SKILL.md +4 -4
  52. package/runtime/skills/okstra-user-response/SKILL.md +50 -16
  53. package/runtime/validators/validate-run.py +254 -81
  54. package/runtime/validators/validate_session_conformance.py +24 -5
@@ -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}",
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any, Mapping
11
11
 
12
+ from okstra_ctl.clarification_items import APPROVAL_BLOCKS, UNRESOLVED_STATUSES
13
+
12
14
  STATUS_READY = "ready"
13
15
  STATUS_PENDING = "pending"
14
16
  STATUS_BLOCKED = "blocked"
@@ -69,6 +71,12 @@ _OPTION_SELECTION_NON_PHASE = {
69
71
  "blocked": STATUS_BLOCKED,
70
72
  }
71
73
 
74
+ # run.py BLOCKING_PLAN_BODY_GATES 와 같아야 한다. next_phase 는 run 을
75
+ # 가져오지 않는다 — wizard 가 둘 다 import 해서 순환이 생긴다.
76
+ _BLOCKING_PLAN_GATES = frozenset(
77
+ {"blocked-by-disagreement", "aborted-non-result"}
78
+ )
79
+
72
80
  # final-verification 의 routing enum 중 phase 이름이 아니라 phase 에 붙은 범위
73
81
  # 한정자인 값 → 실제로 실행할 phase. `release-handoff(stage-group)` 은 넘길 stage
74
82
  # 묶음을 좁힌다는 뜻이지 다른 phase 가 아니다. 범위는 위저드의 handoff_stage_pick
@@ -210,13 +218,68 @@ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
210
218
  return make(phase=routing, status=STATUS_READY)
211
219
 
212
220
 
221
+ def _unresolved_approval_ids(report_data: Mapping[str, Any]) -> list[str]:
222
+ rows = report_data.get("clarificationItems")
223
+ if not isinstance(rows, list):
224
+ return []
225
+ ids: list[str] = []
226
+ for row in rows:
227
+ if not isinstance(row, Mapping):
228
+ continue
229
+ blocks = str(row.get("blocks") or "").strip().lower()
230
+ status = str(row.get("status") or "").strip().lower()
231
+ row_id = row.get("id")
232
+ if (
233
+ blocks in APPROVAL_BLOCKS
234
+ and status in UNRESOLVED_STATUSES
235
+ and isinstance(row_id, str)
236
+ and row_id
237
+ ):
238
+ ids.append(row_id)
239
+ return ids
240
+
241
+
242
+ def _planning_approval_block_reason(
243
+ report_data: Mapping[str, Any], planning: Mapping[str, Any]
244
+ ) -> str:
245
+ """plan-ready 인데 승인할 수 없으면 근거, 아니면 빈 문자열.
246
+
247
+ 자문 게이트(`passed-with-dissent`)와 재현 실패 `has-dissent` 는 여기 안
248
+ 들어온다. 차단은 `blocked-by-disagreement` / `aborted-non-result` 와
249
+ `Status` 가 open/answered 인 `Blocks=approval` 행뿐이다.
250
+ """
251
+ ids = _unresolved_approval_ids(report_data)
252
+ if ids:
253
+ listed = ", ".join(ids)
254
+ return (
255
+ f"{listed} 가 Blocks=approval 로 열려 승인할 수 없습니다. "
256
+ "okstra-user-response 로 답한 뒤 그 답을 가지고 계획 단계를 "
257
+ "재개하세요. 구현을 시작하거나, 답을 쓰기 전에 계획 단계를 "
258
+ "다시 돌리지 마세요."
259
+ )
260
+ verification = planning.get("planBodyVerification")
261
+ gate = ""
262
+ if isinstance(verification, Mapping):
263
+ gate = str(verification.get("gateResult") or "").strip().lower()
264
+ if gate in _BLOCKING_PLAN_GATES:
265
+ return (
266
+ f"계획 본문 게이트가 `{gate}` 이라 승인할 수 없습니다. "
267
+ "구현을 시작하거나 계획 단계를 바로 다시 돌리지 마세요."
268
+ )
269
+ return ""
270
+
271
+
213
272
  def _from_planning(report_data: Mapping[str, Any]) -> dict[str, str]:
214
- outcome = str(_block(report_data, "implementationPlanning").get("outcome") or "")
215
- if outcome == "plan-ready":
216
- return make(phase="implementation", status=STATUS_READY)
273
+ planning = _block(report_data, "implementationPlanning")
274
+ outcome = str(planning.get("outcome") or "")
217
275
  if outcome == "direction-invalidated":
218
276
  return make(phase="implementation-option-selection", status=STATUS_READY)
219
- return make(status=STATUS_PENDING)
277
+ if outcome != "plan-ready":
278
+ return make(status=STATUS_PENDING)
279
+ blocked_reason = _planning_approval_block_reason(report_data, planning)
280
+ if blocked_reason:
281
+ return make(status=STATUS_BLOCKED, rationale=blocked_reason)
282
+ return make(phase="implementation", status=STATUS_READY)
220
283
 
221
284
 
222
285
  def _from_target(report_data: Mapping[str, Any], key: str) -> dict[str, str]: