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.
- package/docs/kr/architecture.md +14 -1
- package/docs/kr/cli.md +7 -1
- package/docs/superpowers/plans/2026-06-11-fix-cycle.md +1290 -0
- package/docs/superpowers/specs/2026-06-11-fix-cycle-design.md +94 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +1 -1
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +6 -1
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +1 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +9 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/conformance.py +5 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +172 -0
- package/runtime/python/okstra_ctl/render.py +45 -5
- package/runtime/python/okstra_ctl/run.py +93 -0
- package/runtime/python/okstra_ctl/run_context.py +15 -9
- package/runtime/python/okstra_ctl/wizard.py +64 -4
- package/runtime/python/okstra_token_usage/claude.py +64 -8
- package/runtime/python/okstra_token_usage/collect.py +30 -1
- package/runtime/schemas/final-report-v1.0.schema.json +25 -0
- package/runtime/skills/okstra-brief/SKILL.md +8 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +2 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -1
- package/runtime/templates/project-docs/task-index.template.md +1 -0
- package/runtime/templates/reports/final-report.template.md +14 -0
- package/runtime/validators/validate-run.py +81 -4
- package/runtime/validators/validate_session_conformance.py +7 -1
- 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
|
|
107
|
-
"""
|
|
108
|
-
|
|
109
|
-
lock 은
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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": "", "
|
|
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
|
-
|
|
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
|
|
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
|
|