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.
- package/docs/kr/architecture.md +16 -2
- package/docs/kr/cli.md +8 -2
- package/docs/kr/performance-improvement-plan-v2.md +27 -7
- 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 +2 -2
- 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/error-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +9 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +17 -0
- package/runtime/python/okstra_ctl/clarification_items.py +26 -0
- package/runtime/python/okstra_ctl/context_cost.py +32 -5
- package/runtime/python/okstra_ctl/fix_cycles.py +172 -0
- package/runtime/python/okstra_ctl/render.py +28 -4
- package/runtime/python/okstra_ctl/report_views.py +63 -6
- package/runtime/python/okstra_ctl/run.py +97 -4
- 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 +22 -0
- package/runtime/python/okstra_token_usage/collect.py +46 -0
- package/runtime/python/okstra_token_usage/cursor.py +3 -1
- package/runtime/schemas/final-report-v1.0.schema.json +27 -1
- package/runtime/skills/okstra-brief/SKILL.md +8 -0
- package/runtime/skills/okstra-convergence/SKILL.md +8 -4
- package/runtime/skills/okstra-inspect/SKILL.md +20 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +4 -2
- 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 +17 -3
- package/runtime/templates/reports/report.js +23 -2
- package/runtime/validators/validate-run.py +41 -0
- 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
|
-
|
|
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
|
|
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 =
|
|
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="{
|
|
465
|
-
f
|
|
466
|
-
for
|
|
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
|
-
|
|
1776
|
-
inp.clarification_response_path,
|
|
1777
|
-
|
|
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
|
|
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
|
|
|
@@ -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})"
|