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.
Files changed (110) hide show
  1. package/README.md +2 -2
  2. package/dist/commands/execute/plan-verify.mjs +1 -1
  3. package/dist/commands/execute/worktree-status.mjs +8 -2
  4. package/dist/commands/execute/worktree-status.mjs.map +1 -1
  5. package/dist/commands/lifecycle/install.mjs +1 -1
  6. package/dist/commands/lifecycle/install.mjs.map +1 -1
  7. package/dist/commands/report/render-final-report.mjs +3 -3
  8. package/docs/architecture/storage-model.md +3 -3
  9. package/docs/architecture.md +10 -9
  10. package/docs/cli.md +11 -13
  11. package/docs/for-ai/skills/okstra-inspect.md +3 -3
  12. package/docs/for-ai/skills/okstra-schedule-gen.md +2 -2
  13. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  14. package/docs/project-structure-overview.md +10 -11
  15. package/docs/task-process/implementation-planning.md +1 -1
  16. package/docs/task-process/implementation.md +1 -1
  17. package/package.json +1 -1
  18. package/runtime/BUILD.json +2 -2
  19. package/runtime/agents/workers/report-writer-worker.md +11 -12
  20. package/runtime/bin/lib/okstra/globals.sh +2 -2
  21. package/runtime/bin/lib/okstra/interactive.sh +1 -1
  22. package/runtime/bin/lib/okstra/usage.sh +11 -9
  23. package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
  24. package/runtime/bin/okstra-central.sh +2 -2
  25. package/runtime/bin/okstra-render-final-report.py +1 -1
  26. package/runtime/bin/okstra-token-usage.py +1 -1
  27. package/runtime/prompts/launch.template.md +1 -1
  28. package/runtime/prompts/lead/adapters/cmux.md +6 -1
  29. package/runtime/prompts/lead/context-loader.md +3 -2
  30. package/runtime/prompts/lead/convergence.md +1 -1
  31. package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
  32. package/runtime/prompts/lead/plan-body-verification.md +3 -3
  33. package/runtime/prompts/lead/report-writer.md +21 -20
  34. package/runtime/prompts/lead/team-contract.md +1 -1
  35. package/runtime/prompts/profiles/_common-contract.md +5 -4
  36. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
  37. package/runtime/prompts/profiles/_implementation-executor.md +2 -1
  38. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  39. package/runtime/prompts/profiles/implementation-planning.md +9 -5
  40. package/runtime/prompts/profiles/implementation.md +4 -4
  41. package/runtime/prompts/profiles/improvement-discovery.md +2 -2
  42. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
  43. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
  44. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
  45. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
  46. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
  47. package/runtime/python/okstra_ctl/agent_prompt_cli.py +55 -3
  48. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
  49. package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
  50. package/runtime/python/okstra_ctl/backfill.py +12 -5
  51. package/runtime/python/okstra_ctl/consumers.py +70 -3
  52. package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
  53. package/runtime/python/okstra_ctl/dispatch_core.py +47 -11
  54. package/runtime/python/okstra_ctl/dispatch_state.py +20 -19
  55. package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
  56. package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
  57. package/runtime/python/okstra_ctl/execution_mutation_audit.py +5 -0
  58. package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
  59. package/runtime/python/okstra_ctl/handoff.py +1 -2
  60. package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
  61. package/runtime/python/okstra_ctl/index.py +4 -4
  62. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
  63. package/runtime/python/okstra_ctl/listing.py +4 -2
  64. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  65. package/runtime/python/okstra_ctl/manager_sync.py +1 -1
  66. package/runtime/python/okstra_ctl/path_hints.py +2 -2
  67. package/runtime/python/okstra_ctl/paths.py +13 -10
  68. package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
  69. package/runtime/python/okstra_ctl/recap.py +3 -2
  70. package/runtime/python/okstra_ctl/reconcile.py +3 -1
  71. package/runtime/python/okstra_ctl/render.py +22 -22
  72. package/runtime/python/okstra_ctl/report_finalize.py +4 -4
  73. package/runtime/python/okstra_ctl/rollup.py +1 -1
  74. package/runtime/python/okstra_ctl/run.py +139 -284
  75. package/runtime/python/okstra_ctl/run_audit.py +5 -5
  76. package/runtime/python/okstra_ctl/run_index_row.py +2 -2
  77. package/runtime/python/okstra_ctl/session_transcript.py +89 -0
  78. package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
  79. package/runtime/python/okstra_ctl/stage_map.py +28 -29
  80. package/runtime/python/okstra_ctl/stage_targets.py +61 -0
  81. package/runtime/python/okstra_ctl/user_response.py +97 -12
  82. package/runtime/python/okstra_ctl/wizard.py +43 -65
  83. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
  84. package/runtime/python/okstra_ctl/worker_runner.py +76 -213
  85. package/runtime/python/okstra_ctl/workflow.py +1 -1
  86. package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
  87. package/runtime/python/okstra_ctl/write_policy.py +45 -6
  88. package/runtime/python/okstra_project/state.py +2 -2
  89. package/runtime/python/okstra_token_usage/__init__.py +1 -1
  90. package/runtime/python/okstra_token_usage/cli.py +3 -3
  91. package/runtime/python/okstra_token_usage/report.py +7 -24
  92. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  93. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  94. package/runtime/schemas/final-report-v2.0.schema.json +11 -1
  95. package/runtime/skills/okstra-inspect/facets/history.md +3 -3
  96. package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
  97. package/runtime/skills/okstra-inspect/facets/report.md +5 -5
  98. package/runtime/skills/okstra-inspect/facets/status.md +2 -2
  99. package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
  100. package/runtime/skills/okstra-run/SKILL.md +1 -1
  101. package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
  102. package/runtime/skills/okstra-user-response/SKILL.md +3 -3
  103. package/runtime/templates/project-docs/task-index.template.md +1 -1
  104. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  105. package/runtime/validators/forbidden_actions.py +76 -5
  106. package/runtime/validators/lib/fixtures.sh +14 -10
  107. package/runtime/validators/lib/runners.sh +1 -1
  108. package/runtime/validators/validate-implementation-plan-stages.py +3 -0
  109. package/runtime/validators/validate-report-views.py +1 -1
  110. 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(markdown_path: Path) -> list[StageMapStage]:
345
- """Read one report's Stage Map, whichever schema wrote it.
346
-
347
- THE entry point for every caller. A schema-v2 report carries the stage map
348
- in its `.data.json` sidecar and has no `## 5.5 Stage Map` section at all, so
349
- a caller that parses the markdown directly works only against v1 reports —
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
- resolved = Path(markdown_path).resolve()
354
- data_path = resolved.with_suffix(".data.json")
355
- if not data_path.exists():
356
- return _parse_stage_map_markdown(resolved)
357
- try:
358
- data = json.loads(data_path.read_text(encoding="utf-8"))
359
- except (OSError, UnicodeError, json.JSONDecodeError) as exc:
360
- raise StageMapError("stage_map", str(exc), str(data_path)) from exc
361
- if not isinstance(data, dict):
362
- raise StageMapError(
363
- "stage_map", "structured report must be an object", str(data_path)
364
- )
365
- if data.get("schemaVersion") != "2.0":
366
- return _parse_stage_map_markdown(resolved)
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(markdown_path: Path) -> dict[str, Any]:
371
- """The schema-v2 sidecar as a whole, `{}` for a v1 report.
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
- data_path = Path(markdown_path).resolve().with_suffix(".data.json")
379
- if not data_path.exists():
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(data_path.read_text(encoding="utf-8"))
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(data_path)) from exc
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/{report_path.name}"
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(r"final-report-.+-(\w+)\.md$")
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 body's own cross-reference labels (RB-002, FU-001,
561
- # O-002, C-017 …) — always uppercase-led so `gpt-5` and lowercase slugs don't
562
- # match. `§x.y` and `path.ext:line` are the other two ref shapes.
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 to its plain-text definition from the report
633
- body, or None when the report text alone cannot resolve it (a `path:line`
634
- pointer). The deterministic lookup the skill uses to rewrite each
635
- clarification question in self-contained plain language."""
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": resolve_refs(text, refs)})
752
+ "resolvedRefs": resolved})
668
753
  return {"reportPath": str(report_path), "rows": rows}
669
754
 
670
755