okstra 0.71.2 → 0.72.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.
@@ -103,15 +103,14 @@ def task_mutex(task_key: str) -> Iterator[None]:
103
103
 
104
104
 
105
105
  @contextmanager
106
- def consumers_mutex(plan_run_root: Path) -> Iterator[None]:
107
- """plan run-root consumers.jsonl append mutex.
108
-
109
- lock 은 consumers.jsonl 같은 디렉토리에 두어 run-root 마다 1:1 로
110
- 격리한다. 마지막 경로 세그먼트(예: seq ``001``)만 키로 쓰면 서로 다른
111
- task/project 의 동일 seq run 이 같은 lock 을 공유하므로 금지.
112
- """
113
- plan_run_root.mkdir(parents=True, exist_ok=True)
114
- path = plan_run_root / ".consumers.lock"
106
+ def dir_flock(dir_path: Path, lock_filename: str) -> Iterator[None]:
107
+ """dir_path 아래 lock_filename 파일 기반 exclusive flock.
108
+
109
+ lock 은 보호 대상 파일과 같은 디렉토리에 두어 디렉토리마다 1:1 로
110
+ 격리한다 (마지막 세그먼트만 키로 쓰면 다른 task 동일 seq 가 같은
111
+ lock 을 공유하므로 금지)."""
112
+ dir_path.mkdir(parents=True, exist_ok=True)
113
+ path = dir_path / lock_filename
115
114
  path.touch(exist_ok=True)
116
115
  with path.open("r+") as f:
117
116
  fcntl.flock(f.fileno(), fcntl.LOCK_EX)
@@ -121,6 +120,13 @@ def consumers_mutex(plan_run_root: Path) -> Iterator[None]:
121
120
  fcntl.flock(f.fileno(), fcntl.LOCK_UN)
122
121
 
123
122
 
123
+ @contextmanager
124
+ def consumers_mutex(plan_run_root: Path) -> Iterator[None]:
125
+ """plan run-root 별 consumers.jsonl append mutex."""
126
+ with dir_flock(plan_run_root, ".consumers.lock"):
127
+ yield
128
+
129
+
124
130
  def _atomic_write_json(path: Path, payload: dict) -> None:
125
131
  path.parent.mkdir(parents=True, exist_ok=True)
126
132
  tmp = path.with_suffix(path.suffix + ".tmp")
@@ -50,7 +50,7 @@ from okstra_ctl.workers import (
50
50
  validate_workers_against_profile,
51
51
  )
52
52
  from okstra_ctl.workflow import PHASE_SEQUENCE
53
- from okstra_ctl import worktree_registry
53
+ from okstra_ctl import fix_cycles, worktree_registry
54
54
  from okstra_ctl.worktree import (
55
55
  compute_worktree_path,
56
56
  is_git_work_tree,
@@ -58,7 +58,7 @@ from okstra_ctl.worktree import (
58
58
  preview_stage_worktree_decision,
59
59
  preview_worktree_decision,
60
60
  )
61
- from okstra_ctl.paths import task_runs_dir
61
+ from okstra_ctl.paths import task_dir, task_runs_dir
62
62
  from okstra_ctl.run_context import latest_run_inputs
63
63
  from okstra_project.dirs import project_json_path
64
64
  from okstra_project.state import (
@@ -268,6 +268,7 @@ S_CLARIFICATION = "clarification"
268
268
  S_PR_TEMPLATE_PICK = "pr_template_pick"
269
269
  S_PR_TEMPLATE = "pr_template"
270
270
  S_PR_TEMPLATE_SCOPE = "pr_template_scope"
271
+ S_FIX_CYCLE_CONFIRM = "fix_cycle_confirm"
271
272
  S_BRANCH_CONFIRM = "branch_confirm"
272
273
  S_CONFIRM = "confirm"
273
274
  S_EDIT_TARGET = "edit_target"
@@ -376,6 +377,8 @@ class WizardState:
376
377
  last_pr_template_cached: str = ""
377
378
 
378
379
  # confirm / edit
380
+ # "" | "yes" | "no" — done(release-handoff) task 재진입의 fix-cycle 기록 여부
381
+ fix_cycle: str = ""
379
382
  branch_confirmed: Optional[bool] = None
380
383
  confirmed: Optional[bool] = None
381
384
  edit_target: str = ""
@@ -796,6 +799,26 @@ def _branch_confirm_required(state: WizardState) -> bool:
796
799
  return state.task_type != "final-verification"
797
800
 
798
801
 
802
+ def _fix_cycle_confirm_required(state: WizardState) -> bool:
803
+ """완료(release-handoff) task 에 entry phase 로 재진입하고, 아직 열린 fix
804
+ cycle 이 없을 때만 묻는다."""
805
+ if state.task_type not in fix_cycles.FIX_CYCLE_ENTRY_PHASES:
806
+ return False
807
+ task_root = task_dir(Path(state.project_root),
808
+ state.task_group, state.task_id)
809
+ manifest = task_root / "task-manifest.json"
810
+ if not manifest.is_file():
811
+ return False
812
+ try:
813
+ data = json.loads(manifest.read_text(encoding="utf-8"))
814
+ except (OSError, json.JSONDecodeError):
815
+ return False
816
+ workflow = data.get("workflow") or {}
817
+ if workflow.get("lastCompletedPhase") != "release-handoff":
818
+ return False
819
+ return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None
820
+
821
+
799
822
  def _stage_auto_allowed(state: WizardState) -> bool:
800
823
  return state.task_type == "implementation"
801
824
 
@@ -2424,6 +2447,30 @@ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
2424
2447
  return f"pr-template-scope: {value}"
2425
2448
 
2426
2449
 
2450
+ def _build_fix_cycle_confirm(state: WizardState) -> Prompt:
2451
+ t = _p(state.workspace_root, "fix_cycle_confirm")
2452
+ opts = t["options"]
2453
+ return Prompt(
2454
+ step=S_FIX_CYCLE_CONFIRM, kind="pick", label=t["label"],
2455
+ options=[
2456
+ _opt("yes", opts["yes"]),
2457
+ _opt("no", opts["no"]),
2458
+ _opt("abort", opts["abort"]),
2459
+ ],
2460
+ echo_template=t["echo_template"])
2461
+
2462
+
2463
+ def _submit_fix_cycle_confirm(state: WizardState, value: str) -> Optional[str]:
2464
+ v = value.strip().lower()
2465
+ if v == "abort":
2466
+ state.aborted = True
2467
+ return "fix-cycle: abort"
2468
+ if v not in ("yes", "no"):
2469
+ raise WizardError(f"expected 'yes' / 'no' / 'abort', got: {value!r}")
2470
+ state.fix_cycle = v
2471
+ return f"fix-cycle: {v}"
2472
+
2473
+
2427
2474
  def _build_branch_confirm(state: WizardState) -> Prompt:
2428
2475
  if state.task_type == "implementation":
2429
2476
  return _build_branch_confirm_impl_stage(state)
@@ -2854,14 +2901,24 @@ STEPS: list[Step] = [
2854
2901
  and S_PR_TEMPLATE_SCOPE not in s.answered),
2855
2902
  build=_build_pr_template_scope, submit=_submit_pr_template_scope,
2856
2903
  owns=("pr_template_scope",)),
2904
+ Step(S_FIX_CYCLE_CONFIRM,
2905
+ applies=lambda s: (_ready_for_confirm(s)
2906
+ and _fix_cycle_confirm_required(s)
2907
+ and not s.fix_cycle),
2908
+ build=_build_fix_cycle_confirm, submit=_submit_fix_cycle_confirm,
2909
+ owns=("fix_cycle",)),
2857
2910
  Step(S_BRANCH_CONFIRM,
2858
2911
  applies=lambda s: (_ready_for_confirm(s)
2912
+ and (not _fix_cycle_confirm_required(s)
2913
+ or bool(s.fix_cycle))
2859
2914
  and _branch_confirm_required(s)
2860
2915
  and s.branch_confirmed is None),
2861
2916
  build=_build_branch_confirm, submit=_submit_branch_confirm,
2862
2917
  owns=("branch_confirmed",)),
2863
2918
  Step(S_CONFIRM,
2864
2919
  applies=lambda s: (_ready_for_confirm(s)
2920
+ and (not _fix_cycle_confirm_required(s)
2921
+ or bool(s.fix_cycle))
2865
2922
  and (not _branch_confirm_required(s)
2866
2923
  or s.branch_confirmed is True)
2867
2924
  and s.confirmed is None),
@@ -2944,9 +3001,10 @@ _FIELD_DEFAULTS: dict[str, Any] = {
2944
3001
  "task_group_pending_text": False, "task_id_pending_text": False,
2945
3002
  "profile_workers": [], "profile_optional_workers": [],
2946
3003
  "keep_existing_brief": None,
2947
- "brief_path": "", "reuse_worktree": None, "base_ref": "",
3004
+ "brief_path": "", "brief_path_pending_text": False,
3005
+ "reuse_worktree": None, "base_ref": "",
2948
3006
  "base_ref_pending_text": False, "approved_plan_path": "",
2949
- "approved_plan_pending_text": False,
3007
+ "approved_plan_pending_text": False, "approve_plan_candidate": "",
2950
3008
  "selected_stage": "auto",
2951
3009
  "handoff_mode": "", "handoff_stages": "",
2952
3010
  "executor": "", "critic": "", "critic_pending_text": False,
@@ -2959,6 +3017,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
2959
3017
  "clarification_response_path": "", "clarification_pending_text": False,
2960
3018
  "pr_template_path": "", "pr_template_pending_text": False,
2961
3019
  "pr_template_scope": "",
3020
+ "fix_cycle": "",
2962
3021
  "branch_confirmed": None, "confirmed": None, "edit_target": "",
2963
3022
  }
2964
3023
 
@@ -3126,6 +3185,7 @@ def render_args(state: WizardState) -> dict[str, str]:
3126
3185
  "related-tasks": state.related_tasks_raw,
3127
3186
  "clarification-response": state.clarification_response_path,
3128
3187
  "pr-template-path": pr_template,
3188
+ "fix-cycle": state.fix_cycle,
3129
3189
  }
3130
3190
 
3131
3191
 
@@ -625,6 +625,31 @@
625
625
  }
626
626
  },
627
627
 
628
+ "fixCycle": {
629
+ "type": "object",
630
+ "description": "RENDER_IF fixCycle present — the post-release bug-fix cycle this run belongs to.",
631
+ "required": ["cycle", "targetReport", "symptom"],
632
+ "additionalProperties": false,
633
+ "properties": {
634
+ "cycle": { "type": "string", "pattern": "^fc-[0-9]{2,}$" },
635
+ "targetReport": { "type": "string" },
636
+ "symptom": { "type": "string" },
637
+ "runs": {
638
+ "type": "array",
639
+ "items": {
640
+ "type": "object",
641
+ "required": ["taskType", "runSeq"],
642
+ "additionalProperties": false,
643
+ "properties": {
644
+ "taskType": { "type": "string" },
645
+ "runSeq": { "type": "integer" },
646
+ "runManifest": { "type": "string" }
647
+ }
648
+ }
649
+ }
650
+ }
651
+ },
652
+
628
653
  "clarificationItems": {
629
654
  "type": "array",
630
655
  "items": { "$ref": "#/$defs/ClarificationRow" }
@@ -431,6 +431,9 @@ never error:
431
431
  1. **okstra-internal (authoritative)** — always check first:
432
432
  - `<PROJECT_ROOT>/.okstra/glossary.md` if present
433
433
  - `<PROJECT_ROOT>/.okstra/decisions/` titles if present
434
+ - Fix history (when the brief targets an existing task):
435
+ `<PROJECT_ROOT>/.okstra/tasks/<task-group>/<task-id>/history/fix-cycles.jsonl`
436
+ — read every `opened`/`closed` row.
434
437
  2. **Explicit source material only** — if the reporter cited a path outside
435
438
  okstra's subtree, read it as source evidence only; do not treat it as
436
439
  okstra memory.
@@ -602,6 +605,11 @@ Required sections:
602
605
  - **Related Artifacts** — files, URLs, issues, prior task-keys.
603
606
  - **Open Questions** — anything the user already flagged as undecided
604
607
  (becomes raw material for `requirements-discovery`).
608
+ - **Task Continuity Notes** — if the target task's
609
+ `history/fix-cycles.jsonl` exists, cite each cycle here as one line —
610
+ `fix-cycle fc-NN (open|closed): <symptom> (target: <target_report>)`.
611
+ An open cycle means the new brief continues a bug-fix in progress; say
612
+ so explicitly.
605
613
 
606
614
  Sections **deliberately omitted** (do NOT add them, do NOT prompt for them):
607
615
 
@@ -279,6 +279,8 @@ Section numbering follows `templates/reports/final-report.template.md` exactly
279
279
  6. **Recommended Next Steps** — prioritized actions. After Phase 7's follow-up spawner runs, append a row per newly created task-key (see "Phase 6 → Phase 7 execution sequence" above).
280
280
  7. **Follow-up Tasks** — auto-spawn-eligible table. Each row drives `okstra-spawn-followups.py`; see template §7 for the row schema.
281
281
 
282
+ **§5.10 Fix History (data-presence gated).** When the run-manifest carries a `fixCycleId`, fill the data.json `fixCycle` block (`cycle` / `targetReport` / `symptom` / `runs`). Read the values from the task root's `history/fix-cycles.jsonl`: `cycle` MUST equal `fixCycleId`, `targetReport` / `symptom` come from that cycle's `opened` row, and `runs` lists its attached `run` rows (`taskType` / `runSeq` / `runManifest`). The validator (`validators/validate-run.py` → `_validate_fix_cycle`) fails the run when the block is missing or `fixCycle.cycle` does not match `fixCycleId`. When the run-manifest has no `fixCycleId`, OMIT the `fixCycle` block entirely — the renderer omits §5.10.
283
+
282
284
  ### Writing Guidelines
283
285
 
284
286
  - Write in Markdown. **Prefer tables over prose bullet lists** for any section that enumerates multiple items with the same shape (evidence rows, risks, options, dependencies, rollback steps, follow-ups, open questions). Bullets are reserved for short, single-line standalone statements (e.g., "- 추가 정보 요청 없음."). When the template provides a table form, do NOT degrade it back to bullets in the rendered report.
@@ -178,7 +178,8 @@ okstra render-bundle \
178
178
  --report-writer-model "<args.report-writer-model>" \
179
179
  --related-tasks "<args.related-tasks>" \
180
180
  --clarification-response "<args.clarification-response>" \
181
- --pr-template-path "<args.pr-template-path>"
181
+ --pr-template-path "<args.pr-template-path>" \
182
+ --fix-cycle "<args.fix-cycle>"
182
183
  ```
183
184
 
184
185
  `render-bundle` auto-supplies `--workspace-root` and forces `--render-only`. Stdout prints `okstra task root:`, `okstra instruction-set:`, and the full rendered lead prompt. Parse the labelled lines for `TASK_ROOT` and `INSTRUCTION_SET_PATH`. Also watch for an optional `okstra concurrent-run stages:` label line — present only when a concurrent run is detected (see "동시-run 감지 분기" below).
@@ -37,6 +37,7 @@ taskType: "{{FM_TASK_TYPE}}"
37
37
  - Next Recommended Phase: `{{WORKFLOW_NEXT_RECOMMENDED_PHASE}}`
38
38
  - Awaiting Approval: `{{WORKFLOW_AWAITING_APPROVAL}}`
39
39
  - Routing Status: `{{WORKFLOW_ROUTING_STATUS}}`
40
+ - Fix cycles: {{FIX_CYCLES_SUMMARY}}
40
41
 
41
42
  ## Phase States
42
43
 
@@ -556,6 +556,20 @@ implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
556
556
  | Cand ID | Lens | Title | Scope | Severity | Effort | Consensus | Source workers | Recommended next-phase | Evidence |
557
557
  |---------|------|-------|-------|----------|--------|-----------|----------------|------------------------|----------|
558
558
 
559
+ {% endif %}
560
+ {% if fixCycle and fixCycle.cycle %}
561
+ ## 5.10 Fix History
562
+
563
+ > This run belongs to a post-release bug-fix cycle registered in `history/fix-cycles.jsonl`.
564
+
565
+ - Cycle: `{{ fixCycle.cycle }}` — {{ fixCycle.symptom }}
566
+ - Target report: `{{ fixCycle.targetReport }}`
567
+ {% if fixCycle.runs %}
568
+ - Runs in this cycle so far:
569
+ {% for r in fixCycle.runs %}
570
+ - {{ r.taskType }} seq {{ r.runSeq }}{% if r.runManifest %} (`{{ r.runManifest }}`){% endif %}
571
+ {% endfor %}
572
+ {% endif %}
559
573
  {% endif %}
560
574
  ## 6. Cross Verification Results
561
575
 
@@ -1369,6 +1369,19 @@ def _data_path_for(report_path: Path) -> Path:
1369
1369
  return report_path.with_suffix(".data.json")
1370
1370
 
1371
1371
 
1372
+ def _load_final_report_data(report_path: Path) -> dict:
1373
+ """Best-effort parse of the final-report data.json sibling. Returns {} when
1374
+ absent or unparseable — those conditions are already surfaced as failures by
1375
+ validate_final_report_data; this loader only feeds cross-field checks."""
1376
+ data_path = _data_path_for(report_path)
1377
+ if not data_path.is_file():
1378
+ return {}
1379
+ try:
1380
+ return json.loads(data_path.read_text(encoding="utf-8"))
1381
+ except (OSError, json.JSONDecodeError):
1382
+ return {}
1383
+
1384
+
1372
1385
  def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1373
1386
  """Validate the final-report data.json against the v1.0 schema.
1374
1387
 
@@ -1748,6 +1761,31 @@ def _validate_improvement_discovery(
1748
1761
  failures.append(f"improvement-discovery: {err}")
1749
1762
 
1750
1763
 
1764
+ def _validate_fix_cycle(run_manifest: dict, data: dict, failures: list[str]) -> None:
1765
+ """Enforce: when the run-manifest carries a fixCycleId, the final-report
1766
+ data.json MUST contain a fixCycle block whose ``cycle`` matches it.
1767
+
1768
+ Direction is one-way: a fixCycle block present without a run-manifest
1769
+ fixCycleId is NOT rejected — same posture as the schema-optional block.
1770
+ This check owns only the run→report direction (no missing/mismatched
1771
+ block when the run is attached), never the reverse."""
1772
+ cycle_id = (run_manifest or {}).get("fixCycleId", "")
1773
+ if not cycle_id:
1774
+ return
1775
+ block = (data or {}).get("fixCycle")
1776
+ if not isinstance(block, dict):
1777
+ failures.append(
1778
+ f"fix-cycle: run-manifest fixCycleId={cycle_id} but data.json has "
1779
+ "no fixCycle block"
1780
+ )
1781
+ return
1782
+ if block.get("cycle") != cycle_id:
1783
+ failures.append(
1784
+ f"fix-cycle: data.json fixCycle.cycle={block.get('cycle')!r} does "
1785
+ f"not match run-manifest fixCycleId={cycle_id!r}"
1786
+ )
1787
+
1788
+
1751
1789
  def _validate_session_conformance(
1752
1790
  team_state: dict,
1753
1791
  team_state_path: Path,
@@ -2157,6 +2195,9 @@ def main() -> int:
2157
2195
  # safety net for hand-edited or pre-v1.0 reports.
2158
2196
  task_type = effective_run_task_type(run_manifest, task_manifest)
2159
2197
  validate_final_report_data(report_path, failures)
2198
+ _validate_fix_cycle(
2199
+ run_manifest, _load_final_report_data(report_path), failures
2200
+ )
2160
2201
  validate_report(report_path, contract["required_agent_status_entries"], failures)
2161
2202
  validate_team_state_usage(team_state, failures)
2162
2203
 
@@ -18,11 +18,14 @@ Usage:
18
18
  [--gemini-model <m>] [--report-writer-model <m>] \\
19
19
  [--related-tasks <list>] [--base-ref <ref>] \\
20
20
  [--clarification-response <path>] [--work-category <cat>] \\
21
- [--stage <auto|N>] [--stages <csv>] [--pr-template-path <path>]
21
+ [--stage <auto|N>] [--stages <csv>] [--pr-template-path <path>] \\
22
+ [--fix-cycle <yes|no>]
22
23
 
23
24
  --stage implementation / final-verification only
24
25
  --stages release-handoff only: PR stage bundle csv (empty = whole-task)
25
26
  --pr-template-path release-handoff only
27
+ --fix-cycle entry phase only: record this re-entry of a done task as a
28
+ bug-fix cycle (yes opens/continues the cycle)
26
29
 
27
30
  release-handoff takes NO --task-brief (briefs belong to entry phases) —
28
31
  prepare generates the run's input document from the cited verification