okstra 0.71.1 → 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.
Files changed (33) hide show
  1. package/docs/kr/architecture.md +14 -1
  2. package/docs/kr/cli.md +7 -1
  3. package/docs/superpowers/plans/2026-06-11-fix-cycle.md +1290 -0
  4. package/docs/superpowers/specs/2026-06-11-fix-cycle-design.md +94 -0
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/SKILL.md +1 -1
  8. package/runtime/bin/lib/okstra/cli.sh +5 -1
  9. package/runtime/bin/lib/okstra/globals.sh +1 -0
  10. package/runtime/bin/lib/okstra/usage.sh +6 -1
  11. package/runtime/bin/okstra.sh +1 -0
  12. package/runtime/prompts/profiles/_implementation-executor.md +1 -1
  13. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  14. package/runtime/prompts/profiles/implementation-planning.md +2 -2
  15. package/runtime/prompts/wizard/prompts.ko.json +9 -0
  16. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  17. package/runtime/python/okstra_ctl/conformance.py +5 -0
  18. package/runtime/python/okstra_ctl/fix_cycles.py +172 -0
  19. package/runtime/python/okstra_ctl/render.py +45 -5
  20. package/runtime/python/okstra_ctl/run.py +93 -0
  21. package/runtime/python/okstra_ctl/run_context.py +15 -9
  22. package/runtime/python/okstra_ctl/wizard.py +64 -4
  23. package/runtime/python/okstra_token_usage/claude.py +64 -8
  24. package/runtime/python/okstra_token_usage/collect.py +30 -1
  25. package/runtime/schemas/final-report-v1.0.schema.json +25 -0
  26. package/runtime/skills/okstra-brief/SKILL.md +8 -0
  27. package/runtime/skills/okstra-report-writer/SKILL.md +2 -0
  28. package/runtime/skills/okstra-run/SKILL.md +2 -1
  29. package/runtime/templates/project-docs/task-index.template.md +1 -0
  30. package/runtime/templates/reports/final-report.template.md +14 -0
  31. package/runtime/validators/validate-run.py +81 -4
  32. package/runtime/validators/validate_session_conformance.py +7 -1
  33. package/src/render-bundle.mjs +4 -1
@@ -28,6 +28,7 @@ from pathlib import Path
28
28
 
29
29
  from okstra_project import project_json_path, upsert_project_json
30
30
  from okstra_project.state import slugify
31
+ from . import fix_cycles
31
32
  from .analysis_packet import build_analysis_packet
32
33
  from .clarification_items import scan_approval_gate
33
34
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
@@ -311,6 +312,9 @@ class PrepareInputs:
311
312
  # Only meaningful for `--task-type implementation-planning`; the manifest
312
313
  # records the value for other phases too to keep the schema stable.
313
314
  plan_verification_enabled: bool = True
315
+ # "" | "yes" | "no" — done(release-handoff) task 재진입을 fix-cycle 로
316
+ # 기록할지의 사용자 의사. "yes" 면 새 cycle 을 열고 이번 run 을 부착한다.
317
+ fix_cycle: str = ""
314
318
 
315
319
 
316
320
  @dataclass
@@ -1793,6 +1797,8 @@ def _write_instruction_set_sources(
1793
1797
  ),
1794
1798
  directive=inp.directive,
1795
1799
  instruction_set_relative_path=ctx["INSTRUCTION_SET_RELATIVE_PATH"],
1800
+ fix_history_text=fix_cycles.packet_summary(
1801
+ fix_cycles.read_rows(Path(ctx["TASK_MANIFEST_PATH"]).parent)),
1796
1802
  )
1797
1803
  (instruction_set / "analysis-packet.md").write_text(packet, encoding="utf-8")
1798
1804
  return instruction_set
@@ -1882,6 +1888,77 @@ def _persist_run_inputs(
1882
1888
  )
1883
1889
 
1884
1890
 
1891
+ def _read_existing_manifest(manifest_path: Path) -> dict:
1892
+ if not manifest_path.exists():
1893
+ return {}
1894
+ try:
1895
+ return json.loads(manifest_path.read_text(encoding="utf-8"))
1896
+ except (OSError, json.JSONDecodeError):
1897
+ return {}
1898
+
1899
+
1900
+ def _maybe_open_fix_cycle(inp: PrepareInputs, task_root: Path, existing: dict,
1901
+ now: str):
1902
+ """--fix-cycle yes 검증 후 새 cycle 을 연다. 부적격이면 PrepareError."""
1903
+ if inp.task_type not in fix_cycles.FIX_CYCLE_ENTRY_PHASES:
1904
+ raise PrepareError(
1905
+ "--fix-cycle yes 는 entry phase("
1906
+ f"{', '.join(fix_cycles.FIX_CYCLE_ENTRY_PHASES)})에서만 허용됩니다: "
1907
+ f"{inp.task_type}")
1908
+ workflow = existing.get("workflow") or {}
1909
+ if workflow.get("lastCompletedPhase") != "release-handoff":
1910
+ raise PrepareError(
1911
+ "--fix-cycle yes 는 release-handoff 까지 완료된 task 에만 허용됩니다 "
1912
+ "(workflow.lastCompletedPhase 확인)")
1913
+ brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
1914
+ fix_cycles.append_opened(
1915
+ task_root, target_report=str(existing.get("latestReportPath", "")),
1916
+ symptom=fix_cycles.derive_symptom(brief_text), opened_at=now)
1917
+ return fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
1918
+
1919
+
1920
+ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
1921
+ """fix-cycles.jsonl 의 lazy-close → opened → run 기록. cycle id 반환.
1922
+
1923
+ - lazy-close: open cycle 에 release-handoff run 이 부착됐고 디스크 manifest 의
1924
+ lastCompletedPhase 가 release-handoff 면 닫는다. manifest 는 prepare 가
1925
+ 이번 run 으로 재작성하기 *전* 값이어야 하므로 finalize 직전에 호출한다.
1926
+ - opened: --fix-cycle yes 일 때만. 완료 task + entry phase 미충족이면
1927
+ PrepareError.
1928
+ - run: open cycle 이 있으면 이번 run 을 무조건 부착.
1929
+ """
1930
+ task_root = Path(ctx["TASK_MANIFEST_PATH"]).parent
1931
+ existing = _read_existing_manifest(Path(ctx["TASK_MANIFEST_PATH"]))
1932
+ workflow = existing.get("workflow") or {}
1933
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1934
+
1935
+ open_c = fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
1936
+
1937
+ if open_c and workflow.get("lastCompletedPhase") == "release-handoff":
1938
+ rows = fix_cycles.read_rows(task_root)
1939
+ handoff_attached = any(
1940
+ r.get("event") == "run" and r.get("cycle") == open_c["cycle"]
1941
+ and r.get("task_type") == "release-handoff" for r in rows)
1942
+ if handoff_attached:
1943
+ fix_cycles.append_closed(
1944
+ task_root, cycle=open_c["cycle"], closed_by="release-handoff",
1945
+ report=str(existing.get("latestReportPath", "")), closed_at=now)
1946
+ open_c = None
1947
+
1948
+ if inp.fix_cycle == "yes" and open_c is None:
1949
+ open_c = _maybe_open_fix_cycle(inp, task_root, existing, now)
1950
+
1951
+ if open_c is None:
1952
+ return ""
1953
+ run_manifest_rel = os.path.relpath(
1954
+ ctx["RUN_MANIFEST_PATH"], str(Path(inp.project_root)))
1955
+ fix_cycles.append_run(
1956
+ task_root, cycle=open_c["cycle"], task_type=inp.task_type,
1957
+ run_seq=int(ctx.get("RUN_MANIFESTS_SEQ", 0) or 0),
1958
+ run_manifest=run_manifest_rel)
1959
+ return open_c["cycle"]
1960
+
1961
+
1885
1962
  def _finalize_status_and_render_manifests(
1886
1963
  inp: PrepareInputs, ctx: dict, task_index_template: Path
1887
1964
  ) -> None:
@@ -2183,6 +2260,13 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2183
2260
  prompt_seq=ctx["RUN_PROMPTS_SEQ"],
2184
2261
  )
2185
2262
 
2263
+ # ---- fix-cycle 기록 (manifest 재작성 전 디스크 값으로 lazy-close 판정) ----
2264
+ # instruction-set 빌드보다 *앞*에서 호출해 이번 run 에서 새로 open 되는
2265
+ # cycle 도 analysis-packet 의 Fix History 에 보이도록 한다. finalize 가
2266
+ # 여전히 이후에 manifest 를 재작성하므로 lazy-close 의 "디스크 manifest 는
2267
+ # prepare 재작성 전 값" 불변식은 유지된다.
2268
+ ctx["FIX_CYCLE_ID"] = _record_fix_cycle_events(inp, ctx)
2269
+
2186
2270
  # ---- write instruction-set scaffolding + lead prompt ----
2187
2271
  instruction_set = _write_instruction_set_sources(
2188
2272
  inp, ctx, profile_content, review_material
@@ -2256,6 +2340,14 @@ def main(argv: list[str]) -> int:
2256
2340
  ),
2257
2341
  )
2258
2342
  p.add_argument("--directive", default="")
2343
+ p.add_argument(
2344
+ "--fix-cycle", default="", choices=["", "yes", "no"], dest="fix_cycle",
2345
+ help=(
2346
+ "done(release-handoff)까지 완료된 task 에 entry phase 로 재진입할 때 "
2347
+ "이번 작업을 버그 픽스 사이클로 기록할지 결정. 'yes' = 새 cycle "
2348
+ "open + run 부착, 'no'/'' = 기록 안 함."
2349
+ ),
2350
+ )
2259
2351
  p.add_argument("--workers", default="", dest="workers_override")
2260
2352
  p.add_argument("--lead-model", default="")
2261
2353
  p.add_argument("--claude-model", default="")
@@ -2413,6 +2505,7 @@ def main(argv: list[str]) -> int:
2413
2505
  approve_plan_ack=args.approve_plan_ack,
2414
2506
  implementation_option=args.implementation_option,
2415
2507
  plan_verification_enabled=args.plan_verification_enabled,
2508
+ fix_cycle=args.fix_cycle,
2416
2509
  )
2417
2510
  try:
2418
2511
  out = prepare_task_bundle(inputs)
@@ -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
 
@@ -243,6 +243,19 @@ def _needle_scan(jsonl_path: Path, entry: dict, needle_lower: str) -> bool:
243
243
  return False
244
244
 
245
245
 
246
+ def _cached_needle_scan(jsonl_path: Path, cache: dict, needle_lower: str) -> bool:
247
+ """`cache['needles']` 의 per-needle cursor 를 유지하며 `_needle_scan` 수행.
248
+ 파일당 MAX_NEEDLES 개까지 오래된 순으로 교체 보존한다."""
249
+ needles = cache.setdefault("needles", {})
250
+ entry = needles.get(needle_lower)
251
+ if entry is None:
252
+ entry = {"offset": 0, "found": False}
253
+ while len(needles) >= MAX_NEEDLES:
254
+ needles.pop(next(iter(needles)))
255
+ needles[needle_lower] = entry
256
+ return _needle_scan(jsonl_path, entry, needle_lower)
257
+
258
+
246
259
  def find_claude_team_sessions(
247
260
  cwd: Path,
248
261
  team_name: str,
@@ -276,14 +289,7 @@ def find_claude_team_sessions(
276
289
  for p in proj_dir.glob("*.jsonl"):
277
290
  if incremental:
278
291
  cache = load_cache(p)
279
- needles = cache.setdefault("needles", {})
280
- entry = needles.get(needle_lower)
281
- if entry is None:
282
- entry = {"offset": 0, "found": False}
283
- while len(needles) >= MAX_NEEDLES:
284
- needles.pop(next(iter(needles)))
285
- needles[needle_lower] = entry
286
- if _needle_scan(p, entry, needle_lower):
292
+ if _cached_needle_scan(p, cache, needle_lower):
287
293
  out[p.stem] = p
288
294
  save_cache(p, cache)
289
295
  else:
@@ -294,3 +300,53 @@ def find_claude_team_sessions(
294
300
  if direct.is_file():
295
301
  out.setdefault(lead_sid, direct)
296
302
  return out
303
+
304
+
305
+ _TEAM_TAG_NEEDLE = '"teamname":"'
306
+
307
+
308
+ def find_claude_agent_sessions(
309
+ cwd: Path,
310
+ agent_prefixes: list[str],
311
+ projects_root: Path | None = None,
312
+ *,
313
+ incremental: bool = False,
314
+ ) -> dict[str, Path]:
315
+ """Map sessionId -> jsonl path for non-team sessions whose recorded
316
+ `agentName` matches one of ``agent_prefixes``.
317
+
318
+ no-team run(teamCreate `skipped`(concurrent-run) / `error` fallback)의
319
+ worker 세션은 team 태그가 없어 teamName needle 로는 발견되지 않는다. 대신
320
+ 하네스가 dispatch `name` 인자로 기록하는 `agentName` 을 needle 로 찾되,
321
+ team 태그가 있는 세션은 제외한다 — 그것은 동시 team-mode run 의 worker 다.
322
+
323
+ 같은 agentName 을 쓰는 다른 run 의 세션도 걸릴 수 있으므로, 호출자는 반드시
324
+ run 윈도우로 totals 를 스코핑하고 in-window 이벤트가 없는 세션을 버려야
325
+ 한다. 윈도우가 겹치는 두 no-team run 이 같은 role 을 dispatch 한 경우의
326
+ 교차 귀속은 구조적으로 분리 불가 — usage 블록의 sessionId 목록으로만
327
+ 추적 가능하다.
328
+ """
329
+ proj_dir = claude_project_dir(cwd, projects_root)
330
+ out: dict[str, Path] = {}
331
+ if not proj_dir.is_dir():
332
+ return out
333
+ agent_needles = [f'"agentname":"{p.lower()}' for p in agent_prefixes if p]
334
+ if not agent_needles:
335
+ return out
336
+ for p in proj_dir.glob("*.jsonl"):
337
+ if incremental:
338
+ cache = load_cache(p)
339
+ matched = any(_cached_needle_scan(p, cache, n) for n in agent_needles)
340
+ team_tagged = matched and _cached_needle_scan(p, cache, _TEAM_TAG_NEEDLE)
341
+ save_cache(p, cache)
342
+ else:
343
+ matched = any(
344
+ _needle_scan(p, {"offset": 0, "found": False}, n)
345
+ for n in agent_needles
346
+ )
347
+ team_tagged = matched and _needle_scan(
348
+ p, {"offset": 0, "found": False}, _TEAM_TAG_NEEDLE
349
+ )
350
+ if matched and not team_tagged:
351
+ out[p.stem] = p
352
+ return out
@@ -7,7 +7,11 @@ from pathlib import Path
7
7
  from okstra_project.dirs import OKSTRA_RELATIVE
8
8
 
9
9
  from .blocks import na_block, usage_block
10
- from .claude import claude_session_totals, find_claude_team_sessions
10
+ from .claude import (
11
+ claude_session_totals,
12
+ find_claude_agent_sessions,
13
+ find_claude_team_sessions,
14
+ )
11
15
  from .codex import codex_session_total, find_codex_session
12
16
  from .gemini import find_gemini_session, gemini_session_total
13
17
  from .paths import claude_project_dir, utc_now
@@ -197,6 +201,31 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
197
201
  unattributed_sessions.append(sid)
198
202
  unattributed_totals.append(totals)
199
203
 
204
+ # no-team run (teamCreate skipped/concurrent-run, or error fallback) —
205
+ # worker 세션에 team 태그가 없어 위 needle 탐색이 비므로, agentName 기반
206
+ # 발견으로 보강한다. run 윈도우 밖 세션(in-window 이벤트 없음 = startedAt
207
+ # 부재)은 같은 agentName 의 타 run 세션이므로 버린다.
208
+ team_create_status = str((state.get("teamCreate") or {}).get("status", "")).strip()
209
+ if team_create_status in ("skipped", "error"):
210
+ worker_prefix_pool = [
211
+ prefix
212
+ for w in state.get("workers", [])
213
+ for prefix in match_prefixes(w.get("workerId") or "")
214
+ ]
215
+ agent_sessions = find_claude_agent_sessions(
216
+ cwd, worker_prefix_pool, incremental=incremental
217
+ )
218
+ for sid, path in agent_sessions.items():
219
+ if sid == lead_sid or sid in claude_sessions:
220
+ continue
221
+ totals = claude_session_totals(path, since=run_since, until=run_until,
222
+ incremental=incremental)
223
+ if not totals.get("startedAt"):
224
+ continue
225
+ agent = totals.get("agentName")
226
+ if agent:
227
+ by_agent.setdefault(agent, []).append((sid, path, totals))
228
+
200
229
  # Lead.
201
230
  if lead_path is not None:
202
231
  totals = claude_session_totals(lead_path, since=run_since, until=run_until,
@@ -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