okstra 0.223.2 → 0.224.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 (22) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/prompts/coding-preflight/clean-code.md +8 -1
  4. package/runtime/python/okstra_ctl/incremental_carry.py +10 -16
  5. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
  6. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-diff-review.md +1 -1
  7. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -2
  8. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +98 -33
  9. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +22 -2
  10. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +33 -0
  11. package/runtime/python/okstra_ctl/plan_item_evidence.py +237 -0
  12. package/runtime/python/okstra_ctl/plan_items.py +12 -9
  13. package/runtime/python/okstra_ctl/plan_review_context.py +82 -0
  14. package/runtime/python/okstra_ctl/verdict_blocks.py +17 -1
  15. package/runtime/python/okstra_ctl/wizard/engine.py +26 -5
  16. package/runtime/python/okstra_ctl/wizard/registry.py +2 -0
  17. package/runtime/python/okstra_ctl/wizard/state.py +5 -0
  18. package/runtime/python/okstra_ctl/wizard/steps_identity.py +99 -16
  19. package/runtime/python/okstra_ctl/wizard/steps_options.py +11 -0
  20. package/runtime/python/okstra_ctl/wizard/steps_plan.py +14 -0
  21. package/runtime/schemas/final-report-v2.0.schema.json +3 -1
  22. package/runtime/schemas/final-report-v3.0.schema.json +3 -1
@@ -6,12 +6,15 @@ from pathlib import Path
6
6
  from collections.abc import Mapping
7
7
  from typing import Any, Optional
8
8
 
9
- from okstra_ctl import next_phase
9
+ from okstra_ctl import next_phase, worktree_registry
10
+ from okstra_ctl.consumers import latest_done_by_stage, read_consumers
10
11
  from okstra_ctl.ids import slugify_task_segment
12
+ from okstra_ctl.paths import RunRef
11
13
  from okstra_ctl.group_context import (
12
14
  QUEUE_DONE,
13
15
  QUEUE_IN_PROGRESS,
14
16
  QUEUE_NOT_STARTED,
17
+ group_briefs,
15
18
  group_context_file,
16
19
  group_queue,
17
20
  is_group_context_file,
@@ -79,15 +82,18 @@ def _validate_base_ref(ref: str, project_root: Path) -> str:
79
82
  ref = (ref or "").strip()
80
83
  if not ref:
81
84
  raise WizardError("base ref is empty")
82
- main_wt = _git_main_worktree(project_root)
85
+ if not _ref_resolves(_git_main_worktree(project_root), ref):
86
+ raise WizardError(f"base ref not found in repository: {ref}")
87
+ return ref
88
+
89
+
90
+ def _ref_resolves(main_wt: Path, ref: str) -> bool:
83
91
  rc = subprocess.call(
84
92
  ["git", "-C", str(main_wt), "rev-parse",
85
93
  "--verify", "--quiet", f"{ref}^{{commit}}"],
86
94
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
87
95
  )
88
- if rc != 0:
89
- raise WizardError(f"base ref not found in repository: {ref}")
90
- return ref
96
+ return rc == 0
91
97
 
92
98
 
93
99
  # --- builders ---
@@ -605,8 +611,12 @@ def _build_task_type(state: WizardState) -> Prompt:
605
611
  brief_phase = _brief_recommended_task_type(state) if fresh_task else ""
606
612
  recommended = (revision_requested or state.task_type
607
613
  or _recommended_task_type(state, manifest))
614
+ # Only a recommendation okstra recorded is taken without asking: the pointer,
615
+ # a revision request, or the brief's own entry phase.
616
+ auto_value = recommended
608
617
  if not recommended and not workflow:
609
618
  recommended = state.requested_task_type or brief_phase or TASK_TYPE_VALUES[0]
619
+ auto_value = recommended if recommended in (state.requested_task_type, brief_phase) else ""
610
620
  if brief_phase and recommended == brief_phase:
611
621
  recommended_suffix = t["options"].get("_BRIEF_SUFFIX", recommended_suffix)
612
622
  pointer = next_phase.promote(workflow.get("nextRecommendedPhase"))
@@ -638,7 +648,8 @@ def _build_task_type(state: WizardState) -> Prompt:
638
648
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
639
649
  return Prompt(step=S_TASK_TYPE, kind="pick",
640
650
  label=t["label"], options=options,
641
- echo_template=t["echo_template"])
651
+ echo_template=t["echo_template"],
652
+ auto_value=auto_value)
642
653
 
643
654
 
644
655
  def _carry_in_existing_brief(state: WizardState) -> str:
@@ -835,28 +846,100 @@ def _build_brief_carry(state: WizardState) -> Prompt:
835
846
  )
836
847
 
837
848
 
838
- def _checked_out_canonical_ref(project_root: Path) -> str:
839
- """The main worktree's branch when it is one of the listed base refs, else ''.
849
+ def _checked_out_branch(main_wt: Path) -> str:
850
+ """The main worktree's branch, or '' when HEAD is detached.
840
851
 
841
852
  Projects differ in their integration branch (main vs preprod), and nothing in
842
853
  okstra records it; the branch the user keeps checked out is the observed hint.
843
854
  """
844
- try:
845
- main_wt = _git_main_worktree(project_root)
846
- except WizardError:
847
- return ""
848
855
  result = subprocess.run(
849
856
  ["git", "-C", str(main_wt), "symbolic-ref", "--quiet", "--short", "HEAD"],
850
857
  capture_output=True, text=True,
851
858
  )
852
- branch = result.stdout.strip() if result.returncode == 0 else ""
853
- return branch if branch in CANONICAL_BASE_REFS else ""
859
+ return result.stdout.strip() if result.returncode == 0 else ""
860
+
861
+
862
+ def _recorded_stage_branches(state: WizardState, task_id: str) -> list[str]:
863
+ """A task's stage branches, top of the stack first: PR heads, then done stages.
864
+
865
+ release-handoff `record-pr` writes a `pr` row per stage PR; a task that never
866
+ reached handoff only has `done` rows, whose branch lives in the worktree
867
+ registry's stage row (kept after the stage is released).
868
+ """
869
+ key = f"{state.project_id}:{state.task_group}:{task_id}"
870
+ task_root = find_task_root(Path(state.project_root), key)
871
+ if task_root is None:
872
+ return []
873
+ rows = read_consumers(RunRef.from_task_root(task_root, "implementation-planning").run_dir)
874
+ prs = [row for row in rows if row.get("status") == "pr" and row.get("branch")]
875
+ prs.sort(key=lambda row: max(row.get("stages") or [0]), reverse=True)
876
+ branches = [str(row["branch"]) for row in prs]
877
+ for stage in sorted(latest_done_by_stage(rows), reverse=True):
878
+ stage_row = worktree_registry.get_stage_row(
879
+ state.project_id, state.task_group, task_id, stage) or {}
880
+ if stage_row.get("branch"):
881
+ branches.append(str(stage_row["branch"]))
882
+ return branches
883
+
884
+
885
+ def _predecessor_base_ref(state: WizardState, main_wt: Path) -> tuple[str, bool]:
886
+ """(branch, succession) from the nearest earlier task in the group's start order.
887
+
888
+ A follow-up task in a group usually builds on the previous task's unmerged
889
+ stage branches. `succession` is True when okstra records that this task
890
+ follows that one: it is the next row in the start order (what the
891
+ predecessor's closeout named as `nextInGroup`), or this task's brief waits
892
+ for that ticket in its Related Task Graph.
893
+ """
894
+ if not (state.project_root and state.task_group and state.task_id):
895
+ return "", False
896
+ project_root = Path(state.project_root)
897
+ queue = _group_start_order(project_root, state.task_group)
898
+ brief = _project_relative_path(Path(state.brief_path), project_root) if state.brief_path else ""
899
+ current = next(
900
+ (index for index, row in enumerate(queue)
901
+ if row.task_id == state.task_id or (brief and row.brief == brief)),
902
+ None,
903
+ )
904
+ if current is None:
905
+ return "", False
906
+ waits: set[str] = set()
907
+ for item in group_briefs(project_root, state.task_group):
908
+ waits.update(item["waits_for"].get(queue[current].ticket_id, set()))
909
+ for index in range(current - 1, -1, -1):
910
+ row = queue[index]
911
+ branch = next(
912
+ (b for b in _recorded_stage_branches(state, row.task_id) if _ref_resolves(main_wt, b)),
913
+ "",
914
+ )
915
+ if branch:
916
+ return branch, index == current - 1 or row.ticket_id in waits
917
+ return "", False
918
+
919
+
920
+ def _base_ref_candidates(state: WizardState) -> tuple[list[str], str]:
921
+ """(refs in display order, recommended ref or '')."""
922
+ try:
923
+ main_wt = _git_main_worktree(Path(state.project_root))
924
+ except WizardError:
925
+ return list(CANONICAL_BASE_REFS), ""
926
+ predecessor, succession = _predecessor_base_ref(state, main_wt)
927
+ checked_out = _checked_out_branch(main_wt)
928
+ derived = [predecessor] if predecessor else []
929
+ if (checked_out and checked_out not in CANONICAL_BASE_REFS
930
+ and checked_out not in derived and _ref_resolves(main_wt, checked_out)):
931
+ derived.append(checked_out)
932
+ if succession:
933
+ recommended = predecessor
934
+ else:
935
+ recommended = checked_out if checked_out in CANONICAL_BASE_REFS else ""
936
+ refs = derived + [r for r in CANONICAL_BASE_REFS if r not in derived]
937
+ return sorted(refs, key=lambda r: r != recommended), recommended
854
938
 
855
939
 
856
940
  def _build_base_ref_pick(state: WizardState) -> Prompt:
857
941
  t = _p(state.workspace_root, "base_ref_pick")
858
- recommended = _checked_out_canonical_ref(Path(state.project_root))
859
- refs = sorted(CANONICAL_BASE_REFS, key=lambda r: r != recommended)
942
+ refs, recommended = _base_ref_candidates(state)
860
943
  options = [_opt(r, recommended=r == recommended) for r in refs]
861
944
  for value, label in _static_options(t):
862
945
  options.append(_opt(value=value, label=label))
@@ -120,6 +120,9 @@ class _OptionalCachedPickSpec:
120
120
  # 추천 옵션 라벨·echo 뒤에 덧붙일 동적 안내(예: 함께 첨부될 사이드카 개수).
121
121
  # (state, 추천 relpath) -> note. 미설정 picker 는 영향 없음.
122
122
  recommend_note: Optional[Callable[["WizardState", str], str]] = None
123
+ # Whether the wizard takes the suggestion without asking. A previous directive
124
+ # is an instruction written for another run, so reusing it stays a question.
125
+ auto_take_suggestion: bool = True
123
126
 
124
127
 
125
128
  def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpec) -> Prompt:
@@ -137,10 +140,17 @@ def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpe
137
140
  setattr(state, spec.cache_attr, suggestion)
138
141
  options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
139
142
  options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
143
+ if not suggestion:
144
+ auto_value = PICK_SKIP
145
+ elif spec.auto_take_suggestion:
146
+ auto_value = spec.recommend_token
147
+ else:
148
+ auto_value = ""
140
149
  return Prompt(
141
150
  step=spec.step, kind="pick",
142
151
  label=t["label"], options=options,
143
152
  echo_template=t["echo_template"],
153
+ auto_value=auto_value,
144
154
  )
145
155
 
146
156
 
@@ -199,6 +209,7 @@ _DIRECTIVE_PICK_SPEC = _OptionalCachedPickSpec(
199
209
  cache_attr="last_directive_cached", target_attr="directive",
200
210
  pending_attr="directive_pending_text",
201
211
  recommend_note=_directive_context_note,
212
+ auto_take_suggestion=False,
202
213
  )
203
214
 
204
215
 
@@ -276,6 +276,7 @@ def _build_approved_plan_pick(state: WizardState) -> Prompt:
276
276
  step=S_APPROVED_PLAN_PICK, kind="pick",
277
277
  label=label, options=options,
278
278
  echo_template=t["echo_template"],
279
+ auto_value=PICK_USE_DEFAULT if default is not None else "",
279
280
  )
280
281
 
281
282
 
@@ -341,9 +342,22 @@ def _build_approve_plan_confirm(state: WizardState) -> Prompt:
341
342
  for k, v in options_map.items()
342
343
  ],
343
344
  echo_template=t["echo_template"],
345
+ auto_value="yes" if _already_approved(state) else "",
344
346
  )
345
347
 
346
348
 
349
+ def _already_approved(state: WizardState) -> bool:
350
+ """An approved plan with no exported approval sidecar leaves nothing to decide."""
351
+ if state.html_approval_sidecar or not state.approve_plan_candidate:
352
+ return False
353
+ try:
354
+ _, fully_approved = _classify_approved_plan(
355
+ state.approve_plan_candidate, Path(state.project_root))
356
+ except (PrepareError, WizardError):
357
+ return False
358
+ return fully_approved
359
+
360
+
347
361
  def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str]:
348
362
  allowed = ("yes_apply", "yes", "no") if state.html_approval_sidecar else ("yes", "no")
349
363
  if value not in allowed:
@@ -9557,7 +9557,8 @@
9557
9557
  "block": {"type": "string", "enum": ["execution", "record"]},
9558
9558
  "stageScope": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "integer", "minimum": 1}},
9559
9559
  "contentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of subject+payload+block+stageScope for the current extract."},
9560
- "verifiedContentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "contentHash at the last apply-verdicts. Matching hashes keep a pre-self-fix verdict."},
9560
+ "verifiedContentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Body hash at the last apply-verdicts; insufficient on its own for runtime verdict reuse."},
9561
+ "verifiedReviewHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Runtime fingerprint of the judged body, declared evidence closure, contract, roster and detailed votes."},
9561
9562
  "id": {
9562
9563
  "type": "string",
9563
9564
  "minLength": 1
@@ -9597,6 +9598,7 @@
9597
9598
  "additionalProperties": false,
9598
9599
  "properties": {
9599
9600
  "claimKind": {"description": "Set only on DISAGREE. `fact` claims are settled by reproduction, `judgement` by quorum.", "enum": ["fact", "judgement", null]},
9601
+ "dependencies": {"type": "object", "required": ["complete", "itemIds", "paths"], "additionalProperties": false, "properties": {"complete": {"type": "boolean"}, "itemIds": {"type": "array", "items": {"type": "string", "minLength": 1}}, "paths": {"type": "array", "items": {"type": "string", "minLength": 1}}}},
9600
9602
  "reproduction": {"description": "Set only on a `fact` claim. The probe okstra runs; see okstra_ctl/claim_reproduction.py.", "type": ["object", "null"]},
9601
9603
  "reproductionResult": {"description": "Written by okstra, not by the worker.", "enum": ["reproduced", "not-reproduced", "not-runnable", null]},
9602
9604
  "worker": {
@@ -9849,7 +9849,8 @@
9849
9849
  "block": {"type": "string", "enum": ["execution", "record"]},
9850
9850
  "stageScope": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "integer", "minimum": 1}},
9851
9851
  "contentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "SHA-256 of subject+payload+block+stageScope for the current extract."},
9852
- "verifiedContentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "contentHash at the last apply-verdicts. Matching hashes keep a pre-self-fix verdict."},
9852
+ "verifiedContentHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Body hash at the last apply-verdicts; insufficient on its own for runtime verdict reuse."},
9853
+ "verifiedReviewHash": {"type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Runtime fingerprint of the judged body, declared evidence closure, contract, roster and detailed votes."},
9853
9854
  "id": {
9854
9855
  "type": "string",
9855
9856
  "minLength": 1
@@ -9896,6 +9897,7 @@
9896
9897
  "additionalProperties": false,
9897
9898
  "properties": {
9898
9899
  "claimKind": {"description": "Set only on DISAGREE. `fact` claims are settled by reproduction, `judgement` by quorum.", "enum": ["fact", "judgement", null]},
9900
+ "dependencies": {"type": "object", "required": ["complete", "itemIds", "paths"], "additionalProperties": false, "properties": {"complete": {"type": "boolean"}, "itemIds": {"type": "array", "items": {"type": "string", "minLength": 1}}, "paths": {"type": "array", "items": {"type": "string", "minLength": 1}}}},
9899
9901
  "reproduction": {"description": "Set only on a `fact` claim. The probe okstra runs; see okstra_ctl/claim_reproduction.py.", "type": ["object", "null"]},
9900
9902
  "reproductionResult": {"description": "Written by okstra, not by the worker.", "enum": ["reproduced", "not-reproduced", "not-runnable", null]},
9901
9903
  "worker": {