okstra 0.178.0 → 0.179.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/README.md +2 -2
- package/dist/commands/execute/plan-verify.mjs +1 -1
- package/dist/commands/execute/worktree-status.mjs +8 -2
- package/dist/commands/execute/worktree-status.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +1 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/dist/commands/report/render-final-report.mjs +3 -3
- package/docs/architecture/storage-model.md +3 -3
- package/docs/architecture.md +10 -9
- package/docs/cli.md +11 -13
- package/docs/for-ai/skills/okstra-inspect.md +3 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +10 -11
- package/docs/task-process/implementation-planning.md +1 -1
- package/docs/task-process/implementation.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +11 -12
- package/runtime/bin/lib/okstra/globals.sh +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +1 -1
- package/runtime/bin/lib/okstra/usage.sh +11 -9
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-central.sh +2 -2
- package/runtime/bin/okstra-render-final-report.py +1 -1
- package/runtime/bin/okstra-token-usage.py +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +6 -1
- package/runtime/prompts/lead/context-loader.md +3 -2
- package/runtime/prompts/lead/convergence.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
- package/runtime/prompts/lead/plan-body-verification.md +3 -3
- package/runtime/prompts/lead/report-writer.md +21 -20
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +5 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +2 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +9 -5
- package/runtime/prompts/profiles/implementation.md +4 -4
- package/runtime/prompts/profiles/improvement-discovery.md +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +55 -3
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
- package/runtime/python/okstra_ctl/backfill.py +12 -5
- package/runtime/python/okstra_ctl/consumers.py +70 -3
- package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
- package/runtime/python/okstra_ctl/dispatch_core.py +47 -11
- package/runtime/python/okstra_ctl/dispatch_state.py +20 -19
- package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +5 -0
- package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
- package/runtime/python/okstra_ctl/handoff.py +1 -2
- package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
- package/runtime/python/okstra_ctl/index.py +4 -4
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
- package/runtime/python/okstra_ctl/listing.py +4 -2
- package/runtime/python/okstra_ctl/manager_launch.py +1 -1
- package/runtime/python/okstra_ctl/manager_sync.py +1 -1
- package/runtime/python/okstra_ctl/path_hints.py +2 -2
- package/runtime/python/okstra_ctl/paths.py +13 -10
- package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
- package/runtime/python/okstra_ctl/recap.py +3 -2
- package/runtime/python/okstra_ctl/reconcile.py +3 -1
- package/runtime/python/okstra_ctl/render.py +22 -22
- package/runtime/python/okstra_ctl/report_finalize.py +4 -4
- package/runtime/python/okstra_ctl/rollup.py +1 -1
- package/runtime/python/okstra_ctl/run.py +139 -284
- package/runtime/python/okstra_ctl/run_audit.py +5 -5
- package/runtime/python/okstra_ctl/run_index_row.py +2 -2
- package/runtime/python/okstra_ctl/session_transcript.py +89 -0
- package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
- package/runtime/python/okstra_ctl/stage_map.py +28 -29
- package/runtime/python/okstra_ctl/stage_targets.py +61 -0
- package/runtime/python/okstra_ctl/user_response.py +97 -12
- package/runtime/python/okstra_ctl/wizard.py +43 -65
- package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
- package/runtime/python/okstra_ctl/worker_runner.py +76 -213
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
- package/runtime/python/okstra_ctl/write_policy.py +45 -6
- package/runtime/python/okstra_project/state.py +2 -2
- package/runtime/python/okstra_token_usage/__init__.py +1 -1
- package/runtime/python/okstra_token_usage/cli.py +3 -3
- package/runtime/python/okstra_token_usage/report.py +7 -24
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v2.0.schema.json +11 -1
- package/runtime/skills/okstra-inspect/facets/history.md +3 -3
- package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
- package/runtime/skills/okstra-inspect/facets/report.md +5 -5
- package/runtime/skills/okstra-inspect/facets/status.md +2 -2
- package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
- package/runtime/skills/okstra-user-response/SKILL.md +3 -3
- package/runtime/templates/project-docs/task-index.template.md +1 -1
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/validators/forbidden_actions.py +76 -5
- package/runtime/validators/lib/fixtures.sh +14 -10
- package/runtime/validators/lib/runners.sh +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +3 -0
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +95 -37
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""워커 세션 기록.
|
|
2
|
+
|
|
3
|
+
한 줄에 시각·화자·본문 셋을 담는다. 나중에 리드가 같은 파일에 쓰면 화자만
|
|
4
|
+
달라지므로, 지금 형식을 정해 두면 그때 파일이 바뀌지 않는다.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
OKSTRA = "okstra"
|
|
13
|
+
_SPEAKER_WIDTH = 14
|
|
14
|
+
|
|
15
|
+
# 파일 사본이 담는 워커 진행 줄의 상한. 진행은 워커 출력의 부피가 몰리는
|
|
16
|
+
# 자리다 — 파일 하나만 읽는 디스패치도 킬로바이트 단위 도구 에코를 남기고,
|
|
17
|
+
# 관측된 사이드카는 8MB 에 이르러 프로젝트의 `.okstra/` 바이트를 지배했다.
|
|
18
|
+
# 상한은 블록이 아니라 run 전체에 건다: 블록 경계는 공급자 자신의 어휘이고
|
|
19
|
+
# 이 기록기는 그것을 모른다. 그 대가로 아주 긴 run 은 표본이 아니라 앞부분을
|
|
20
|
+
# 남기며, 생략 표시가 그 사실을 드러낸다.
|
|
21
|
+
LOG_LINE_CAP = 5000
|
|
22
|
+
_ELISION_NOTICE_EVERY = 500
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _now() -> str:
|
|
26
|
+
return datetime.now().strftime("%H:%M:%S")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SessionTranscript:
|
|
30
|
+
"""워커 한 명의 세션 기록. 화면 출력도 함께 맡는다."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
path: Path,
|
|
35
|
+
*,
|
|
36
|
+
live: bool,
|
|
37
|
+
clock: Callable[[], str] = _now,
|
|
38
|
+
) -> None:
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
self._file = path.open("w", encoding="utf-8")
|
|
41
|
+
self._live = live
|
|
42
|
+
self._clock = clock
|
|
43
|
+
self._archived = 0
|
|
44
|
+
self._elided = 0
|
|
45
|
+
|
|
46
|
+
def write(self, speaker: str, line: str, *, capped: bool = True) -> None:
|
|
47
|
+
"""한 줄을 화면과 기록에 남긴다.
|
|
48
|
+
|
|
49
|
+
``capped=False`` 는 상한을 넘겨도 반드시 파일에 남길 줄이다 — 워커의
|
|
50
|
+
결론과 okstra 자신의 기록이 그렇다. 잘린 도구 에코는 세부를 잃지만
|
|
51
|
+
잘린 결론은 사후 분석 전체를 잃는다.
|
|
52
|
+
"""
|
|
53
|
+
# 패딩이 본문 앞 공백을 겸한다. 뒤에 공백을 한 칸 더 넣으면
|
|
54
|
+
# `[worker:grok]`(13칸) 줄이 두 칸이 되고 `[okstra]` 정렬이 깨진다.
|
|
55
|
+
label = f"[{speaker}]".ljust(_SPEAKER_WIDTH)
|
|
56
|
+
row = f"{self._clock()} {label}{line}".rstrip()
|
|
57
|
+
# 화면은 상한과 무관하다. 사람이 보고 있는 것을 잘라 낼 이유가 없다.
|
|
58
|
+
if self._live:
|
|
59
|
+
print(row, flush=True)
|
|
60
|
+
if not capped:
|
|
61
|
+
self._append(row)
|
|
62
|
+
return
|
|
63
|
+
if self._archived < LOG_LINE_CAP:
|
|
64
|
+
self._archived += 1
|
|
65
|
+
self._append(row)
|
|
66
|
+
return
|
|
67
|
+
self._elided += 1
|
|
68
|
+
# 끝에서 한 번이 아니라 주기적으로 남긴다. 로그를 tail 하는 사람이
|
|
69
|
+
# run 이 아직 진행 중임을 봐야 하고, 멈춘 파일과 구별되어야 한다.
|
|
70
|
+
if self._elided % _ELISION_NOTICE_EVERY == 0:
|
|
71
|
+
self._note_elision()
|
|
72
|
+
|
|
73
|
+
def note(self, line: str) -> None:
|
|
74
|
+
"""okstra 자신이 남기는 줄. 워커의 말과 섞이되 화자로 구분된다."""
|
|
75
|
+
self.write(OKSTRA, line, capped=False)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
if self._elided % _ELISION_NOTICE_EVERY:
|
|
79
|
+
self._note_elision()
|
|
80
|
+
self._file.close()
|
|
81
|
+
|
|
82
|
+
def _append(self, row: str) -> None:
|
|
83
|
+
self._file.write(row + "\n")
|
|
84
|
+
self._file.flush()
|
|
85
|
+
|
|
86
|
+
def _note_elision(self) -> None:
|
|
87
|
+
self._append(
|
|
88
|
+
f" [okstra log-cap] {self._elided} progress line(s) elided"
|
|
89
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""계획 저작 쪽에 넘길 Stage 원장을 디스크에서 조립한다.
|
|
2
|
+
|
|
3
|
+
계획을 세우는 쪽은 지금 어느 stage 가 이미 구현됐는지 모른 채 계획을 쓴다.
|
|
4
|
+
이 모듈은 그 사실만 모아 준다 — 무엇을 계획해야 하는지는 말하지 않는다.
|
|
5
|
+
|
|
6
|
+
판정은 소유하지 않는다. 상태 어휘와 lifecycle 판정은 stage_targets 가, stage
|
|
7
|
+
map 의 출처 판정은 stage_map.load_task_stage_map 이 소유한다. 여기서는 둘을
|
|
8
|
+
잇고 직렬화만 한다.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .consumers import read_stage_consumer_state
|
|
17
|
+
from .paths import RunRef
|
|
18
|
+
from .stage_map import StageMapError, load_task_stage_map
|
|
19
|
+
from .stage_targets import stage_lifecycle_snapshot_from_state
|
|
20
|
+
from .task_target import infer_project_root
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_stage_ledger(task_root: Path) -> dict[str, Any] | None:
|
|
24
|
+
"""이 task 의 Stage 원장. 계획 리포트가 아직 없으면 ``None``.
|
|
25
|
+
|
|
26
|
+
``None`` 과 빈 리스트를 구분한다 — 계획이 없는 첫 run 과 stage 가 하나도
|
|
27
|
+
없는 계획은 저작 쪽에 다른 뜻이다.
|
|
28
|
+
"""
|
|
29
|
+
task_root = Path(task_root)
|
|
30
|
+
try:
|
|
31
|
+
snapshot = load_task_stage_map(task_root, {})
|
|
32
|
+
except StageMapError:
|
|
33
|
+
# 계획 출처가 충돌하거나 읽히지 않는 경우. 원장을 추측해 싣느니 싣지
|
|
34
|
+
# 않는다 — 틀린 원장은 없는 원장보다 나쁘다.
|
|
35
|
+
return None
|
|
36
|
+
if snapshot.state != "ready" or not snapshot.stages:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
plan_run_root = RunRef.from_task_root(
|
|
40
|
+
task_root, "implementation-planning"
|
|
41
|
+
).run_dir
|
|
42
|
+
# carry 사이드카에서 done 행을 복구한다. implementation prep 이 하는 것과
|
|
43
|
+
# 같은 멱등 복구이며, 이걸 건너뛰면 크래시 창에 걸린 완료 stage 가 원장에
|
|
44
|
+
# 미완으로 실려 저작 쪽이 이미 구현된 stage 를 다시 계획한다.
|
|
45
|
+
state = read_stage_consumer_state(plan_run_root, recover_from_carry=True)
|
|
46
|
+
lifecycle = stage_lifecycle_snapshot_from_state(snapshot.stages, state)
|
|
47
|
+
return {
|
|
48
|
+
"sourcePlan": _project_relative(task_root, snapshot.source_plan_path),
|
|
49
|
+
"stages": lifecycle.ledger_records(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_stage_ledger(ledger: dict[str, Any] | None) -> str:
|
|
54
|
+
"""packet 에 실릴 JSON 본문. 원장이 없으면 빈 문자열."""
|
|
55
|
+
if not ledger:
|
|
56
|
+
return ""
|
|
57
|
+
return json.dumps(ledger, ensure_ascii=False, indent=2)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _project_relative(task_root: Path, source_plan_path: str) -> str:
|
|
61
|
+
"""절대 경로를 프로젝트 상대로 낮춘다.
|
|
62
|
+
|
|
63
|
+
packet 은 워커에게 그대로 전달된다. 절대 경로를 실으면 실행 머신의
|
|
64
|
+
디렉터리 구조가 프롬프트에 박히고, 다른 머신에서 재현할 때 어긋난다.
|
|
65
|
+
"""
|
|
66
|
+
if not source_plan_path:
|
|
67
|
+
return ""
|
|
68
|
+
project_root = infer_project_root(task_root)
|
|
69
|
+
try:
|
|
70
|
+
return str(Path(source_plan_path).relative_to(project_root))
|
|
71
|
+
except ValueError:
|
|
72
|
+
return source_plan_path
|
|
@@ -341,47 +341,46 @@ def _parse_schema_v2_stage_map(
|
|
|
341
341
|
return stages
|
|
342
342
|
|
|
343
343
|
|
|
344
|
-
def parse_stage_map_file(
|
|
345
|
-
"""Read one report's Stage Map,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
which is how v2 support landed on one call site and left five reading a
|
|
351
|
-
section that modern reports do not have.
|
|
344
|
+
def parse_stage_map_file(plan_path: Path) -> list[StageMapStage]:
|
|
345
|
+
"""Read one report's Stage Map from the report record, or v1 markdown.
|
|
346
|
+
|
|
347
|
+
`--approved-plan` now passes the `.data.json` record. A `.md` path is the
|
|
348
|
+
schema-v1 reading-copy form and is parsed as markdown only — this function
|
|
349
|
+
does not look for a sibling record.
|
|
352
350
|
"""
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
return _parse_schema_v2_stage_map(data, str(data_path))
|
|
351
|
+
from .final_report_paths import is_report_record_path
|
|
352
|
+
|
|
353
|
+
resolved = Path(plan_path).resolve()
|
|
354
|
+
if is_report_record_path(resolved):
|
|
355
|
+
try:
|
|
356
|
+
data = json.loads(resolved.read_text(encoding="utf-8"))
|
|
357
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
358
|
+
raise StageMapError("stage_map", str(exc), str(resolved)) from exc
|
|
359
|
+
if not isinstance(data, dict):
|
|
360
|
+
raise StageMapError(
|
|
361
|
+
"stage_map", "structured report must be an object", str(resolved)
|
|
362
|
+
)
|
|
363
|
+
return _parse_schema_v2_stage_map(data, str(resolved))
|
|
364
|
+
return _parse_stage_map_markdown(resolved)
|
|
368
365
|
|
|
369
366
|
|
|
370
|
-
def schema_v2_report(
|
|
371
|
-
"""The schema-v2
|
|
367
|
+
def schema_v2_report(plan_path: Path) -> dict[str, Any]:
|
|
368
|
+
"""The schema-v2 report record as a whole, `{}` for a v1 markdown path.
|
|
372
369
|
|
|
373
370
|
Public because every caller that must branch on report schema needs it —
|
|
374
371
|
including `validators/validate-implementation-plan-stages.py`, which is a
|
|
375
372
|
separate process and cannot reach a private helper without copying the
|
|
376
373
|
sidecar-detection rule and letting the two drift.
|
|
377
374
|
"""
|
|
378
|
-
|
|
379
|
-
|
|
375
|
+
from .final_report_paths import is_report_record_path
|
|
376
|
+
|
|
377
|
+
resolved = Path(plan_path).resolve()
|
|
378
|
+
if not is_report_record_path(resolved) or not resolved.exists():
|
|
380
379
|
return {}
|
|
381
380
|
try:
|
|
382
|
-
data = json.loads(
|
|
381
|
+
data = json.loads(resolved.read_text(encoding="utf-8"))
|
|
383
382
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
384
|
-
raise StageMapError("stage_map", str(exc), str(
|
|
383
|
+
raise StageMapError("stage_map", str(exc), str(resolved)) from exc
|
|
385
384
|
if not isinstance(data, dict) or data.get("schemaVersion") != "2.0":
|
|
386
385
|
return {}
|
|
387
386
|
return data
|
|
@@ -8,6 +8,8 @@ the stage lifecycle rules behind one interface instead of leaking raw
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import heapq
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
11
13
|
from dataclasses import dataclass, replace
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Any
|
|
@@ -123,6 +125,27 @@ class StageLifecycleSnapshot:
|
|
|
123
125
|
pr_covered_stages: set[int]
|
|
124
126
|
lifecycles: list[StageLifecycle]
|
|
125
127
|
|
|
128
|
+
def ledger_records(self) -> list[dict[str, Any]]:
|
|
129
|
+
"""계획 저작 쪽에 넘길 stage 사실 기록.
|
|
130
|
+
|
|
131
|
+
상태 어휘는 `StageLifecycle.status` 를 그대로 쓴다 — 원장이 자기
|
|
132
|
+
어휘를 따로 가지면 같은 stage 가 소비처마다 다른 상태로 읽힌다.
|
|
133
|
+
"""
|
|
134
|
+
titles = {
|
|
135
|
+
int(row["stage_number"]): str(row.get("title") or "")
|
|
136
|
+
for row in self.stage_map
|
|
137
|
+
}
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
"stage": lifecycle.stage,
|
|
141
|
+
"title": titles.get(lifecycle.stage, ""),
|
|
142
|
+
"status": lifecycle.status,
|
|
143
|
+
"dependsOn": list(lifecycle.depends_on),
|
|
144
|
+
"headCommit": lifecycle.head_commit,
|
|
145
|
+
}
|
|
146
|
+
for lifecycle in self.lifecycles
|
|
147
|
+
]
|
|
148
|
+
|
|
126
149
|
def lifecycle_for(self, stage_number: int) -> StageLifecycle:
|
|
127
150
|
for lifecycle in self.lifecycles:
|
|
128
151
|
if lifecycle.stage == stage_number:
|
|
@@ -490,6 +513,7 @@ def resolve_stage_base_commit(
|
|
|
490
513
|
pred = deps[0]
|
|
491
514
|
head = (latest.get(pred) or {}).get("head_commit") or ""
|
|
492
515
|
if head:
|
|
516
|
+
_warn_if_branch_moved_past(project_root, pred, head, candidate_base)
|
|
493
517
|
return head
|
|
494
518
|
raise StageTargetError(
|
|
495
519
|
f"predecessor stage {pred} has no done row with head_commit in "
|
|
@@ -498,6 +522,43 @@ def resolve_stage_base_commit(
|
|
|
498
522
|
)
|
|
499
523
|
|
|
500
524
|
|
|
525
|
+
|
|
526
|
+
def _warn_if_branch_moved_past(
|
|
527
|
+
project_root: Path | None,
|
|
528
|
+
predecessor: int,
|
|
529
|
+
recorded_head: str,
|
|
530
|
+
branch_tip: str,
|
|
531
|
+
) -> None:
|
|
532
|
+
"""Say so when the branch has advanced past the commit this stage branches from.
|
|
533
|
+
|
|
534
|
+
A stage branches from its predecessor's recorded `head_commit`, not from the
|
|
535
|
+
branch tip — that is what makes the stage's base reproducible. It also means
|
|
536
|
+
a commit landing on the branch after the predecessor was settled is invisible
|
|
537
|
+
to this stage, and the divergence only surfaces later as a rebase. Two such
|
|
538
|
+
rebases were reported from one six-stage migration; both would have been
|
|
539
|
+
caught by this line. The base rule is unchanged: this only names the gap.
|
|
540
|
+
"""
|
|
541
|
+
if project_root is None or not branch_tip or branch_tip == recorded_head:
|
|
542
|
+
return
|
|
543
|
+
try:
|
|
544
|
+
contains = subprocess.run(
|
|
545
|
+
["git", "-C", str(project_root), "merge-base", "--is-ancestor",
|
|
546
|
+
recorded_head, branch_tip],
|
|
547
|
+
capture_output=True,
|
|
548
|
+
)
|
|
549
|
+
except OSError:
|
|
550
|
+
return
|
|
551
|
+
if contains.returncode != 0:
|
|
552
|
+
return
|
|
553
|
+
print(
|
|
554
|
+
f"okstra: stage branches from stage {predecessor}'s recorded head "
|
|
555
|
+
f"{recorded_head[:12]}, but the branch tip is {branch_tip[:12]} — "
|
|
556
|
+
"commits landed after that stage was settled and this stage will not "
|
|
557
|
+
"carry them. Integrate them first if they belong to this work.",
|
|
558
|
+
file=sys.stderr,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
501
562
|
def _resolve_whole_task_target(
|
|
502
563
|
*,
|
|
503
564
|
stage_map: list[dict[str, Any]],
|
|
@@ -33,6 +33,7 @@ from okstra_ctl.clarification_items import (
|
|
|
33
33
|
section_1_present_but_unparsed,
|
|
34
34
|
sidecar_answers,
|
|
35
35
|
_section_1_slice,
|
|
36
|
+
_v2_report_data,
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
_PLAN_DECISION_HEADING_RE = re.compile(r"^## PLAN DECISION\s*$", re.MULTILINE)
|
|
@@ -105,7 +106,7 @@ _ANALYSIS_REVIEW_STATUSES = frozenset({
|
|
|
105
106
|
})
|
|
106
107
|
_ANALYSIS_REPORT_RE = re.compile(
|
|
107
108
|
r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
|
|
108
|
-
r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
|
|
109
|
+
r"change-impact-analysis)-(?P<seq>\d{3})\.(?:md|data\.json)$"
|
|
109
110
|
)
|
|
110
111
|
|
|
111
112
|
|
|
@@ -358,8 +359,18 @@ def load_authoritative_analysis_review(
|
|
|
358
359
|
review = parse_analysis_review("".join(attached))
|
|
359
360
|
if review is None:
|
|
360
361
|
raise UserResponseError("analysis review sidecar is unreadable")
|
|
362
|
+
from .final_report_paths import (
|
|
363
|
+
final_report_markdown_path,
|
|
364
|
+
is_report_record_path,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
report_name = (
|
|
368
|
+
final_report_markdown_path(report_path).name
|
|
369
|
+
if is_report_record_path(report_path)
|
|
370
|
+
else report_path.name
|
|
371
|
+
)
|
|
361
372
|
expected_source = (
|
|
362
|
-
f"runs/{report_match.group('task_type')}/reports/{
|
|
373
|
+
f"runs/{report_match.group('task_type')}/reports/{report_name}"
|
|
363
374
|
)
|
|
364
375
|
if review.task_key != expected_task_key:
|
|
365
376
|
raise UserResponseError(
|
|
@@ -510,7 +521,9 @@ def parse_user_response_entries(sidecar_text: str) -> list[UserResponseEntry]:
|
|
|
510
521
|
return entries
|
|
511
522
|
|
|
512
523
|
|
|
513
|
-
_SEQ_FROM_REPORT_RE = re.compile(
|
|
524
|
+
_SEQ_FROM_REPORT_RE = re.compile(
|
|
525
|
+
r"final-report-.+-(\w+)\.(?:md|data\.json)$"
|
|
526
|
+
)
|
|
514
527
|
|
|
515
528
|
|
|
516
529
|
def _seq_from_report(report: Path) -> str:
|
|
@@ -557,11 +570,21 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
|
|
|
557
570
|
return out[:limit] if limit > 0 else out
|
|
558
571
|
|
|
559
572
|
|
|
560
|
-
# ID tokens are the report
|
|
561
|
-
#
|
|
562
|
-
#
|
|
573
|
+
# ID tokens are the report record's own row labels (RB-002, FU-001, O-002,
|
|
574
|
+
# C-017 …) — always uppercase-led so `gpt-5` and lowercase slugs don't match.
|
|
575
|
+
# `§x.y` is a full-reading-copy heading and is not a record coordinate.
|
|
576
|
+
# `path.ext:line` is a source pointer the record does not define.
|
|
563
577
|
_SECTION_REF_RE = re.compile(r"§[\d.]+|[A-Z]{1,4}-\d+|[\w./-]+\.\w+:\d+")
|
|
564
578
|
_ID_TOKEN_RE = re.compile(r"^[A-Z]{1,4}-\d+$")
|
|
579
|
+
_ROW_DEFINITION_KEYS = (
|
|
580
|
+
"statement",
|
|
581
|
+
"summary",
|
|
582
|
+
"item",
|
|
583
|
+
"title",
|
|
584
|
+
"check",
|
|
585
|
+
"action",
|
|
586
|
+
"evidence",
|
|
587
|
+
)
|
|
565
588
|
_DEFINITION_SNIPPET_CAP = 200
|
|
566
589
|
_SNIPPET_NOISE_RE = re.compile(r'<a id="[^"]*"></a>|`|\*\*')
|
|
567
590
|
_OPTION_LETTER_LABEL_RE = re.compile(r"^\([a-z]\)\s*")
|
|
@@ -629,10 +652,12 @@ def _resolve_section_ref(report_text: str, ref: str) -> str | None:
|
|
|
629
652
|
|
|
630
653
|
|
|
631
654
|
def resolve_refs(report_text: str, refs: list[str]) -> list[dict]:
|
|
632
|
-
"""Resolve each context ref
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
655
|
+
"""Resolve each context ref from a schema-v1 reading copy.
|
|
656
|
+
|
|
657
|
+
Schema-v1 has no report record; the markdown body is the source. A
|
|
658
|
+
`path:line` pointer is left unresolved. Schema-v2 callers use
|
|
659
|
+
`resolve_refs_from_record`.
|
|
660
|
+
"""
|
|
636
661
|
s1_slice = _section_1_slice(report_text)
|
|
637
662
|
resolved: list[dict] = []
|
|
638
663
|
for ref in refs:
|
|
@@ -646,11 +671,67 @@ def resolve_refs(report_text: str, refs: list[str]) -> list[dict]:
|
|
|
646
671
|
return resolved
|
|
647
672
|
|
|
648
673
|
|
|
674
|
+
def _row_definition(row: dict) -> str | None:
|
|
675
|
+
for key in _ROW_DEFINITION_KEYS:
|
|
676
|
+
value = row.get(key)
|
|
677
|
+
if isinstance(value, str) and value.strip():
|
|
678
|
+
return _clean_snippet(value)
|
|
679
|
+
if isinstance(value, list):
|
|
680
|
+
for item in value:
|
|
681
|
+
if isinstance(item, str) and item.strip():
|
|
682
|
+
return _clean_snippet(item)
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _index_record_ids(data: dict) -> dict[str, dict]:
|
|
687
|
+
"""First object in the report record whose `id` is a row token."""
|
|
688
|
+
index: dict[str, dict] = {}
|
|
689
|
+
|
|
690
|
+
def walk(node: object) -> None:
|
|
691
|
+
if isinstance(node, dict):
|
|
692
|
+
row_id = node.get("id")
|
|
693
|
+
if (
|
|
694
|
+
isinstance(row_id, str)
|
|
695
|
+
and _ID_TOKEN_RE.match(row_id)
|
|
696
|
+
and row_id not in index
|
|
697
|
+
):
|
|
698
|
+
index[row_id] = node
|
|
699
|
+
for value in node.values():
|
|
700
|
+
walk(value)
|
|
701
|
+
elif isinstance(node, list):
|
|
702
|
+
for item in node:
|
|
703
|
+
walk(item)
|
|
704
|
+
|
|
705
|
+
walk(data)
|
|
706
|
+
return index
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def resolve_refs_from_record(data: dict, refs: list[str]) -> list[dict]:
|
|
710
|
+
"""Resolve each context ref from the report record.
|
|
711
|
+
|
|
712
|
+
Only row identifiers (`RB-002`) are record coordinates. A section number
|
|
713
|
+
(`§4.7`) belongs to one full reading copy and is left unresolved. A
|
|
714
|
+
`path:line` pointer is left unresolved because the record does not hold
|
|
715
|
+
that file.
|
|
716
|
+
"""
|
|
717
|
+
index = _index_record_ids(data)
|
|
718
|
+
resolved: list[dict] = []
|
|
719
|
+
for ref in refs:
|
|
720
|
+
definition = None
|
|
721
|
+
if _ID_TOKEN_RE.match(ref):
|
|
722
|
+
row = index.get(ref)
|
|
723
|
+
if row is not None:
|
|
724
|
+
definition = _row_definition(row)
|
|
725
|
+
resolved.append({"ref": ref, "definition": definition})
|
|
726
|
+
return resolved
|
|
727
|
+
|
|
728
|
+
|
|
649
729
|
def show_open_rows(report_path: Path) -> dict:
|
|
650
|
-
text = report_path.read_text(encoding="utf-8")
|
|
651
730
|
# 사이드카에 답이 있는 행은 사용자가 이미 답한 것이다. 리포트의 `Status` 는
|
|
652
731
|
# 그 답을 반영하지 않으므로, 이걸 빼지 않으면 스킬이 같은 질문을 다시 묻는다.
|
|
653
732
|
answered = sidecar_answers(report_path)
|
|
733
|
+
record = _v2_report_data(report_path)
|
|
734
|
+
v1_text = None if record is not None else report_path.read_text(encoding="utf-8")
|
|
654
735
|
rows = []
|
|
655
736
|
for r in read_clarification_rows(report_path):
|
|
656
737
|
it = r["item"]
|
|
@@ -658,13 +739,17 @@ def show_open_rows(report_path: Path) -> dict:
|
|
|
658
739
|
continue
|
|
659
740
|
statement, expected = r["statement"], r["expected_form"]
|
|
660
741
|
refs = sorted(set(_SECTION_REF_RE.findall(statement + " " + expected)))
|
|
742
|
+
if record is not None:
|
|
743
|
+
resolved = resolve_refs_from_record(record, refs)
|
|
744
|
+
else:
|
|
745
|
+
resolved = resolve_refs(v1_text or "", refs)
|
|
661
746
|
rows.append({"id": it.row_id, "kind": it.kind, "blocks": it.blocks,
|
|
662
747
|
"status": it.status, "statement": statement,
|
|
663
748
|
"expectedForm": expected,
|
|
664
749
|
# v2 authors the choices; v1 only ever had the string.
|
|
665
750
|
"options": r["options"] or _options_from_expected_form(expected),
|
|
666
751
|
"contextRefs": refs,
|
|
667
|
-
"resolvedRefs":
|
|
752
|
+
"resolvedRefs": resolved})
|
|
668
753
|
return {"reportPath": str(report_path), "rows": rows}
|
|
669
754
|
|
|
670
755
|
|