okstra 0.223.0 → 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 (36) hide show
  1. package/dist/commands/pr/pr.d.mts +2 -0
  2. package/dist/commands/pr/pr.mjs +12 -3
  3. package/dist/commands/pr/pr.mjs.map +1 -1
  4. package/docs/cli.md +1 -1
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/coding-preflight/clean-code.md +8 -1
  8. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  9. package/runtime/python/okstra_ctl/incremental_carry.py +10 -16
  10. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
  11. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-diff-review.md +1 -1
  12. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -2
  13. package/runtime/python/okstra_ctl/phases/implementation/spec.md +1 -1
  14. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +98 -33
  15. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +22 -2
  16. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +33 -0
  17. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  18. package/runtime/python/okstra_ctl/plan_item_evidence.py +237 -0
  19. package/runtime/python/okstra_ctl/plan_items.py +12 -9
  20. package/runtime/python/okstra_ctl/plan_review_context.py +82 -0
  21. package/runtime/python/okstra_ctl/report_finalize.py +8 -3
  22. package/runtime/python/okstra_ctl/run.py +3 -0
  23. package/runtime/python/okstra_ctl/stage_fix_carry.py +118 -16
  24. package/runtime/python/okstra_ctl/stage_rebase.py +3 -0
  25. package/runtime/python/okstra_ctl/verdict_blocks.py +17 -1
  26. package/runtime/python/okstra_ctl/wizard/engine.py +26 -5
  27. package/runtime/python/okstra_ctl/wizard/registry.py +2 -0
  28. package/runtime/python/okstra_ctl/wizard/state.py +5 -0
  29. package/runtime/python/okstra_ctl/wizard/steps_identity.py +99 -16
  30. package/runtime/python/okstra_ctl/wizard/steps_options.py +11 -0
  31. package/runtime/python/okstra_ctl/wizard/steps_plan.py +14 -0
  32. package/runtime/python/okstra_ctl/worktree/rebase.py +11 -1
  33. package/runtime/schemas/final-report-v2.0.schema.json +3 -1
  34. package/runtime/schemas/final-report-v3.0.schema.json +3 -1
  35. package/runtime/skills/okstra-pr-gen/SKILL.md +2 -2
  36. package/runtime/templates/pr/drafting-rules.md +11 -4
@@ -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:
@@ -67,6 +67,13 @@ def rebase_stage_branch(
67
67
  raise RuntimeError(
68
68
  f"cannot rebase {branch}: recorded base {old} is not an ancestor"
69
69
  )
70
+ # A branch rebased by hand already sits linearly on the new base; replaying
71
+ # old..head would reapply the new base's own commits and conflict. A merge
72
+ # of the new base still goes through the rebase, which flattens it.
73
+ if is_ancestor(project_root, new, head) and not _checked_git(
74
+ project_root, "rev-list", "--merges", f"{new}..{head}"
75
+ ):
76
+ return head
70
77
  with _stage_worktree(project_root, branch) as holder:
71
78
  for state in ["rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD"]:
72
79
  path = Path(_checked_git(holder, "rev-parse", "--git-path", state))
@@ -103,6 +110,9 @@ def _apply_rebase(holder: Path, branch: str, old: str, new: str) -> str:
103
110
  f"rebase failed for {branch}; recovery worktree: {holder}; {detail}; abort: {abort.stderr}"
104
111
  )
105
112
  raise RuntimeError(
106
- f"rebase failed for {branch}; original branch restored: {detail}"
113
+ f"rebase failed for {branch}; original branch restored: {detail}\n"
114
+ f"To resolve: in the worktree holding {branch}, run "
115
+ f"`git rebase --onto {new} {old}`, resolve the conflicts, then re-run "
116
+ "the same prepare command; it records the new base without rebasing again."
107
117
  )
108
118
  return _checked_git(holder, "rev-parse", "HEAD")
@@ -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": {
@@ -87,8 +87,8 @@ okstra pr gen --base <base> --template <template>
87
87
  To describe a branch other than the checkout — a stage branch whose worktree is gone — add
88
88
  `--head <branch>`; nothing is checked out.
89
89
 
90
- Read the fixed `Base`, `Head`, `Current branch`, `Template name`, `Commits`, `Diff stat`,
91
- `Template`, and `Drafting rules` sections. Then fill the template by following every item of
90
+ Read the fixed `Base`, `Head`, `Current branch`, `Upstream`, `Merge base`, `Template name`,
91
+ `Commits`, `Diff stat`, `Template`, and `Drafting rules` sections. Then fill the template by following every item of
92
92
  `Drafting rules`: it says how to read the full diff, when to stop, how to fill sections and
93
93
  checkboxes, how to open the new-behavior section, and which identifiers the title and body may
94
94
  carry. release-handoff drafts its PR bodies from the same rules.
@@ -1,8 +1,15 @@
1
- 1. Read the full diff — it is the source of truth: `git diff <Base>...<Head>`, using the `Base` and
2
- `Head` values above. Read a large diff in sections.
1
+ 1. Read the full diff — it is the source of truth: `git diff <Merge base> <Head>`, pasting the
2
+ `Merge base` and `Head` values above literally. `Merge base` already folds in the base's upstream,
3
+ so changes the remote base has merged are excluded. Read a large diff in sections.
3
4
  2. If `Commits` or `Diff stat` above is `-`, there is nothing to describe: stop and say so.
4
- 3. Fill the template from the diff and the commits. Describe only what actually changed. Strip HTML
5
- comments; keep the template's sections as the structure.
5
+ 3. Fill the template from the diff and the commits. Describe only the net change from the merge base
6
+ to the head, as a reviewer sees it on the PR. Strip HTML comments; keep the template's sections as
7
+ the structure.
8
+ - "Current behavior" is the behavior at the merge base, never at an intermediate commit of the branch.
9
+ - A problem one commit of the branch introduced and a later commit fixed is not a change: leave it
10
+ out, or describe only the final state. Never present it as a pre-existing bug the PR fixes.
11
+ - A commit subject is not evidence of a change the diff does not show.
12
+ - The drafting session's conversation, the last commit alone, and earlier PR drafts are not sources.
6
13
  4. Mark a checklist box `[x]` only when the diff supports it (tests touched → the tests box, docs
7
14
  touched → the docs box).
8
15
  5. Open the new-behavior section — `What is the new behavior` in the default template, or the section