okstra 0.71.2 → 0.73.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 (40) hide show
  1. package/docs/kr/architecture.md +16 -2
  2. package/docs/kr/cli.md +8 -2
  3. package/docs/kr/performance-improvement-plan-v2.md +27 -7
  4. package/docs/superpowers/plans/2026-06-11-fix-cycle.md +1290 -0
  5. package/docs/superpowers/specs/2026-06-11-fix-cycle-design.md +94 -0
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/agents/SKILL.md +2 -2
  9. package/runtime/bin/lib/okstra/cli.sh +5 -1
  10. package/runtime/bin/lib/okstra/globals.sh +1 -0
  11. package/runtime/bin/lib/okstra/usage.sh +6 -1
  12. package/runtime/bin/okstra.sh +1 -0
  13. package/runtime/prompts/profiles/error-analysis.md +1 -1
  14. package/runtime/prompts/profiles/final-verification.md +1 -1
  15. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  16. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  17. package/runtime/prompts/wizard/prompts.ko.json +9 -0
  18. package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
  19. package/runtime/python/okstra_ctl/clarification_items.py +26 -0
  20. package/runtime/python/okstra_ctl/context_cost.py +32 -5
  21. package/runtime/python/okstra_ctl/fix_cycles.py +172 -0
  22. package/runtime/python/okstra_ctl/render.py +28 -4
  23. package/runtime/python/okstra_ctl/report_views.py +63 -6
  24. package/runtime/python/okstra_ctl/run.py +97 -4
  25. package/runtime/python/okstra_ctl/run_context.py +15 -9
  26. package/runtime/python/okstra_ctl/wizard.py +64 -4
  27. package/runtime/python/okstra_token_usage/claude.py +22 -0
  28. package/runtime/python/okstra_token_usage/collect.py +46 -0
  29. package/runtime/python/okstra_token_usage/cursor.py +3 -1
  30. package/runtime/schemas/final-report-v1.0.schema.json +27 -1
  31. package/runtime/skills/okstra-brief/SKILL.md +8 -0
  32. package/runtime/skills/okstra-convergence/SKILL.md +8 -4
  33. package/runtime/skills/okstra-inspect/SKILL.md +20 -0
  34. package/runtime/skills/okstra-report-writer/SKILL.md +4 -2
  35. package/runtime/skills/okstra-run/SKILL.md +2 -1
  36. package/runtime/templates/project-docs/task-index.template.md +1 -0
  37. package/runtime/templates/reports/final-report.template.md +17 -3
  38. package/runtime/templates/reports/report.js +23 -2
  39. package/runtime/validators/validate-run.py +41 -0
  40. package/src/render-bundle.mjs +4 -1
@@ -27,6 +27,7 @@ from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
27
27
  # phase 시퀀스 / 기본 next-phase 매핑의 SSOT 는 workflow 모듈이다. 과거
28
28
  # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
29
29
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
30
+ from . import fix_cycles
30
31
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
31
32
 
32
33
 
@@ -597,6 +598,8 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
597
598
  "latestResumeCommandPath": s(manifest, "latestResumeCommandPath")
598
599
  or s(latest_run, "resumeCommandPath"),
599
600
  "historyTimelinePath": timeline_relative or rel(timeline_path),
601
+ "fixCycles": manifest.get("fixCycles")
602
+ or {"count": 0, "openCycleId": None, "latest": None},
600
603
  }
601
604
  )
602
605
  entries.sort(
@@ -945,6 +948,9 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
945
948
  "latestReportPath": latest_report_relative,
946
949
  "latestResumeCommandPath": latest_resume_command_relative,
947
950
  "teamStatePath": latest_team_state_relative,
951
+ "fixCycles": fix_cycles.summarize(
952
+ fix_cycles.read_rows(Path(manifest_path).parent)
953
+ ),
948
954
  "workflow": {
949
955
  "phaseSequence": PHASE_SEQUENCE,
950
956
  "currentPhase": current_phase,
@@ -1247,6 +1253,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1247
1253
  "renderOnly": ctx.get("RENDER_ONLY", ""),
1248
1254
  "createdAt": ctx.get("RUN_TIMESTAMP_ISO", ""),
1249
1255
  }
1256
+ if ctx.get("FIX_CYCLE_ID"):
1257
+ payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1250
1258
  _write_json(Path(run_manifest_path), payload)
1251
1259
 
1252
1260
 
@@ -1287,8 +1295,7 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1287
1295
  else {}
1288
1296
  )
1289
1297
  workflow = workflow or {}
1290
- filtered.append(
1291
- {
1298
+ entry = {
1292
1299
  "runTimestamp": ctx.get("RUN_TIMESTAMP_ISO", ""),
1293
1300
  "runDirectoryPath": ctx.get("RUN_DIR_RELATIVE_PATH", ""),
1294
1301
  "runManifestPath": ctx.get("RUN_MANIFEST_RELATIVE_PATH", ""),
@@ -1329,8 +1336,10 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1329
1336
  "routingStatus": workflow.get("routingStatus", ""),
1330
1337
  "lastSafeCheckpoint": workflow.get("lastSafeCheckpoint", {}),
1331
1338
  },
1332
- }
1333
- )
1339
+ }
1340
+ if ctx.get("FIX_CYCLE_ID"):
1341
+ entry["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1342
+ filtered.append(entry)
1334
1343
  payload = {
1335
1344
  "schemaVersion": "1.0",
1336
1345
  "projectId": ctx.get("PROJECT_ID", ""),
@@ -1342,6 +1351,20 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
1342
1351
  _write_json(path, payload)
1343
1352
 
1344
1353
 
1354
+ def _fix_cycles_index_line(manifest: dict) -> str:
1355
+ fc = manifest.get("fixCycles") or {}
1356
+ if not fc.get("count"):
1357
+ return "none"
1358
+ latest = fc.get("latest") or {}
1359
+ state = (
1360
+ f"open: {fc.get('openCycleId')}" if fc.get("openCycleId") else "all closed"
1361
+ )
1362
+ return (
1363
+ f"{fc.get('count')} cycle(s), {state} — "
1364
+ f"latest `{latest.get('cycle', '')}`: {latest.get('symptom', '')}"
1365
+ )
1366
+
1367
+
1345
1368
  def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1346
1369
  template = Path(template_path).read_text(encoding="utf-8")
1347
1370
  task_manifest_path = Path(ctx["TASK_MANIFEST_PATH"])
@@ -1501,6 +1524,7 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
1501
1524
  ),
1502
1525
  "{{WORKFLOW_PHASE_STATE_LINES}}": "\n".join(phase_state_lines),
1503
1526
  "{{WORKFLOW_LAST_SAFE_CHECKPOINT_LINES}}": "\n".join(checkpoint_lines),
1527
+ "{{FIX_CYCLES_SUMMARY}}": _fix_cycles_index_line(task_manifest),
1504
1528
  }
1505
1529
  fm_ctx = dict(ctx)
1506
1530
  fm_ctx.setdefault("DOC_TYPE", _doc_type_from_template_path(template_path))
@@ -109,6 +109,8 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
109
109
 
110
110
  title = html.escape(f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}")
111
111
  response_ids_json = "[" + ",".join('"' + html.escape(rid) + '"' for rid in response_ids) + "]"
112
+ sidecar_name = html.escape(f"user-response-{run_meta.task_type}-{run_meta.seq}.md")
113
+ sidecar_dir = html.escape(f"runs/{run_meta.task_type}/user-responses/")
112
114
 
113
115
  return (
114
116
  f"<!DOCTYPE html>\n"
@@ -127,6 +129,8 @@ def render_html(src_md: str, *, run_meta: RunMeta, css: str, js: str) -> str:
127
129
  f"<main>{body_html}</main>\n"
128
130
  f"<footer class=\"report-footer\">\n"
129
131
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
132
+ f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
133
+ f"<code>{sidecar_dir}</code> 에 저장하면 resume-clarification 이 자동으로 첨부합니다.</p>\n"
130
134
  f" <pre id=\"user-response-output\" aria-live=\"polite\"></pre>\n"
131
135
  f" <button type=\"button\" data-action=\"copy-user-response\">Copy</button>\n"
132
136
  f"</footer>\n"
@@ -353,6 +357,10 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
353
357
  (j for j, h in enumerate(header_cells) if h.startswith("Statement")),
354
358
  -1,
355
359
  )
360
+ expected_form_col = next(
361
+ (j for j, h in enumerate(header_cells) if h.startswith("Expected form")),
362
+ -1,
363
+ )
356
364
  user_input_col = (
357
365
  header_cells.index("User input") if "User input" in header_cells else -1
358
366
  )
@@ -367,11 +375,14 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
367
375
  statement = (
368
376
  row[statement_col] if 0 <= statement_col < len(row) else ""
369
377
  )
378
+ expected_form = (
379
+ row[expected_form_col] if 0 <= expected_form_col < len(row) else ""
380
+ )
370
381
  cells_html: list[str] = []
371
382
  for idx, cell in enumerate(row):
372
383
  if idx == user_input_col:
373
384
  cells_html.append(
374
- f"<td>{_form_control(meta.row_id, meta.kind, meta.status, cell, statement)}</td>"
385
+ f"<td>{_form_control(meta.row_id, meta.kind, meta.status, cell, statement, expected_form)}</td>"
375
386
  )
376
387
  else:
377
388
  cells_html.append(
@@ -439,12 +450,51 @@ def _parse_enum_options(statement: str) -> list[tuple[str, str]]:
439
450
  return out if len(out) >= 2 else []
440
451
 
441
452
 
453
+ _RECOMMENDED_CUE = "Recommended:"
454
+ _ALTERNATIVES_CUE = "Alternatives:"
455
+
456
+
457
+ def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
458
+ """Parse the ``Expected form`` contract format
459
+ (``Recommended: <answer> — <rationale>; Alternatives: <options>``,
460
+ `_common-contract.md` §Clarification request policy) into select
461
+ ``(value, label)`` options. Returns ``[]`` when the cell carries no
462
+ ``Recommended:`` cue — the caller falls back to the statement enum."""
463
+ if not expected_form:
464
+ return []
465
+ rec_idx = expected_form.find(_RECOMMENDED_CUE)
466
+ if rec_idx < 0:
467
+ return []
468
+ rec_body = expected_form[rec_idx + len(_RECOMMENDED_CUE):]
469
+ alt_body = ""
470
+ alt_idx = rec_body.find(_ALTERNATIVES_CUE)
471
+ if alt_idx >= 0:
472
+ alt_body = rec_body[alt_idx + len(_ALTERNATIVES_CUE):]
473
+ rec_body = rec_body[:alt_idx]
474
+ # The rationale follows " — "; the option keeps only the answer part.
475
+ answer = rec_body.split(" — ", 1)[0].strip(" .,;—-")
476
+ options: list[tuple[str, str]] = []
477
+ if answer:
478
+ options.append(("recommended", f"추천: {answer}"))
479
+ enum_alts = _parse_enum_options(alt_body)
480
+ if enum_alts:
481
+ options.extend(
482
+ (letter, f"({letter}) {text}") for letter, text in enum_alts
483
+ )
484
+ else:
485
+ alt_text = alt_body.strip(" .,;—-")
486
+ if alt_text:
487
+ options.append(("alternative", alt_text))
488
+ return options
489
+
490
+
442
491
  def _form_control(
443
492
  response_id: str,
444
493
  kind: str,
445
494
  status: str,
446
495
  current_value: str,
447
496
  statement: str = "",
497
+ expected_form: str = "",
448
498
  ) -> str:
449
499
  rid = html.escape(response_id)
450
500
  disabled = "" if status.lower() in ("open", "answered", "") else " disabled"
@@ -456,14 +506,21 @@ def _form_control(
456
506
  "data-point": "값",
457
507
  }.get(kind_lc, "응답")
458
508
 
459
- # decision 경우 statement 에서 (a)(b)(c) 후보를 추출해 select+기타 input 으로 렌더.
509
+ # decision 후보는 Expected form `Recommended: …; Alternatives: …`
510
+ # 계약(_common-contract.md §Clarification request policy)이 1순위,
511
+ # statement 안 (a)(b)(c) 열거가 fallback. 후보가 있으면 select+기타 input.
460
512
  if kind_lc == "decision":
461
- opts = _parse_enum_options(statement)
513
+ opts = _parse_expected_form_options(expected_form)
514
+ if not opts:
515
+ opts = [
516
+ (letter, f"({letter}) {text}")
517
+ for letter, text in _parse_enum_options(statement)
518
+ ]
462
519
  if opts:
463
520
  select_opts = "".join(
464
- f'<option value="{letter}">'
465
- f'{_inline(f"({letter}) " + text)}</option>'
466
- for letter, text in opts
521
+ f'<option value="{html.escape(value)}">'
522
+ f"{_inline(label)}</option>"
523
+ for value, label in opts
467
524
  )
468
525
  select_html = (
469
526
  f'<select name="{rid}" data-response-id="{rid}" '
@@ -28,8 +28,9 @@ 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
- from .clarification_items import scan_approval_gate
33
+ from .clarification_items import clarification_response_with_sidecars, scan_approval_gate
33
34
  from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
34
35
  from .material import (
35
36
  build_analysis_material,
@@ -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
@@ -1772,9 +1776,9 @@ def _write_instruction_set_sources(
1772
1776
  (instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
1773
1777
  shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
1774
1778
  if inp.clarification_response_path:
1775
- shutil.copyfile(
1776
- inp.clarification_response_path,
1777
- instruction_set / "clarification-response.md",
1779
+ (instruction_set / "clarification-response.md").write_text(
1780
+ clarification_response_with_sidecars(Path(inp.clarification_response_path)),
1781
+ encoding="utf-8",
1778
1782
  )
1779
1783
  if inp.directive:
1780
1784
  (instruction_set / "directive.txt").write_text(inp.directive + "\n", encoding="utf-8")
@@ -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
 
@@ -2,12 +2,18 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import re
5
6
  from datetime import datetime
6
7
  from pathlib import Path
7
8
 
8
9
  from .cursor import MAX_NEEDLES, fresh_cache, load_cache, save_cache
9
10
  from .paths import claude_project_dir, ts_in_window
10
11
 
12
+ # lead 의 단계 체크포인트 라인(agents/SKILL.md "Progress reporting") — phase id 가
13
+ # `phase-<digit>` 로 시작하는 라인만 마커로 인정해 일반 대화의 오탐을 줄인다.
14
+ _PROGRESS_RE = re.compile(r"^PROGRESS:\s+(phase-\d\S*(?:[ \t].*)?)$", re.MULTILINE)
15
+ _PROGRESS_MARKER_MAX_CHARS = 160
16
+
11
17
 
12
18
  def _event_from_record(rec: dict) -> dict | None:
13
19
  """jsonl 레코드 1개 → 압축 이벤트. 집계에 기여하지 않으면 None.
@@ -48,6 +54,18 @@ def _event_from_record(rec: dict) -> dict | None:
48
54
  if isinstance(b, dict) and b.get("type") == "tool_use")
49
55
  if tools:
50
56
  ev["u"] = tools
57
+ markers = [
58
+ m.group(1).strip()[:_PROGRESS_MARKER_MAX_CHARS]
59
+ for b in (msg.get("content") or [])
60
+ if isinstance(b, dict) and b.get("type") == "text"
61
+ for m in _PROGRESS_RE.finditer(b.get("text") or "")
62
+ ]
63
+ # 서로 다른 phase id 가 한 메시지에 섞이면 계약 인용/회고 요약이라 그
64
+ # 시각은 단계 경계가 아니다(관측: dev-9186 run 의 막판 요약 1건이 전
65
+ # phase 의 lastAt 을 오염). 같은 phase id 의 반복(병렬 dispatch 3줄,
66
+ # intake reading+complete)은 라이브 체크포인트로 인정한다.
67
+ if markers and len({m.split(None, 1)[0] for m in markers}) == 1:
68
+ ev["p"] = markers
51
69
  ts = rec.get("timestamp") or msg.get("timestamp")
52
70
  if ts:
53
71
  ev["t"] = ts
@@ -133,6 +151,7 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
133
151
  input_t = output_t = cache_create_t = cache_read_t = 0
134
152
  cache_create_5m_t = cache_create_1h_t = 0
135
153
  tool_uses = 0
154
+ progress_markers: list[dict] = []
136
155
  first_ts: str | None = None
137
156
  last_ts: str | None = None
138
157
  for ev in events:
@@ -146,6 +165,8 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
146
165
  cache_create_1h_t += ev.get("c1", 0)
147
166
  cache_read_t += ev.get("r", 0)
148
167
  tool_uses += ev.get("u", 0)
168
+ for marker in ev.get("p", ()):
169
+ progress_markers.append({"at": ts, "marker": marker})
149
170
  if ts:
150
171
  if first_ts is None or ts < first_ts:
151
172
  first_ts = ts
@@ -179,6 +200,7 @@ def _totals_from_events(events: list[dict], agent_name: str | None,
179
200
  "model": model,
180
201
  "startedAt": first_ts,
181
202
  "endedAt": last_ts,
203
+ "progressMarkers": progress_markers,
182
204
  }
183
205
 
184
206
 
@@ -141,6 +141,51 @@ def resolve_run_window(team_state_path: Path, state: dict) -> tuple[str | None,
141
141
  return since, until
142
142
 
143
143
 
144
+ def _wall_ms(start_iso: str | None, end_iso: str | None) -> int | None:
145
+ if not start_iso or not end_iso:
146
+ return None
147
+ try:
148
+ a = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
149
+ b = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
150
+ except ValueError:
151
+ return None
152
+ return max(0, int((b - a).total_seconds() * 1000))
153
+
154
+
155
+ def phase_timeline(markers: list[dict]) -> dict:
156
+ """lead 의 PROGRESS 체크포인트(agents/SKILL.md "Progress reporting")로
157
+ run 내부 단계 경계 wall-clock 을 복원한다 (perf plan v2 P0 계측).
158
+
159
+ phase id = marker 의 첫 토큰(`phase-5.5-convergence` 등). 같은 phase 의
160
+ 반복 마커(poll/collect/dispatch)는 first/last 로 접는다. `wallMsToNext` 는
161
+ 다음 phase 의 firstAt 까지 — 마지막 phase 는 None(run 종료 신호가 마커에
162
+ 없으므로 추정하지 않는다). 마커가 하나도 없으면 phases 가 빈 채로 남아
163
+ "이 run 은 측정 불가"를 명시적으로 표현한다.
164
+ """
165
+ phases: list[dict] = []
166
+ by_id: dict[str, dict] = {}
167
+ for m in markers:
168
+ at = m.get("at")
169
+ phase_id = (m.get("marker") or "").split(None, 1)[0]
170
+ if not phase_id:
171
+ continue
172
+ entry = by_id.get(phase_id)
173
+ if entry is None:
174
+ entry = {"phase": phase_id, "firstAt": at, "lastAt": at,
175
+ "markerCount": 0, "wallMsToNext": None}
176
+ by_id[phase_id] = entry
177
+ phases.append(entry)
178
+ entry["markerCount"] += 1
179
+ if at:
180
+ if entry["firstAt"] is None or at < entry["firstAt"]:
181
+ entry["firstAt"] = at
182
+ if entry["lastAt"] is None or at > entry["lastAt"]:
183
+ entry["lastAt"] = at
184
+ for current, nxt in zip(phases, phases[1:]):
185
+ current["wallMsToNext"] = _wall_ms(current["firstAt"], nxt["firstAt"])
186
+ return {"source": "lead-progress-markers", "phases": phases}
187
+
188
+
144
189
  def resolve_team_name(state: dict) -> str:
145
190
  """team-state 에서 이 run 의 team name 을 해소한다.
146
191
 
@@ -232,6 +277,7 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
232
277
  incremental=incremental)
233
278
  state["leadUsage"] = usage_block(totals, source="claude-jsonl")
234
279
  state["leadUsage"]["sessionId"] = lead_sid
280
+ state["phaseTimeline"] = phase_timeline(totals.get("progressMarkers") or [])
235
281
  else:
236
282
  state["leadUsage"] = na_block(
237
283
  f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"