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
@@ -32,6 +32,7 @@ from okstra_project import project_json_path, upsert_project_json
32
32
  from okstra_project.state import slugify
33
33
  from . import fix_cycles
34
34
  from .analysis_packet import build_analysis_packet
35
+ from .stage_ledger import build_stage_ledger, render_stage_ledger
35
36
  from .analysis_inputs import (
36
37
  ANALYSIS_TASK_TYPES,
37
38
  AnalysisInputError,
@@ -66,7 +67,11 @@ from .material import (
66
67
  resolve_related_tasks,
67
68
  )
68
69
  from .final_report_schema import load_schema_version
69
- from .final_report_paths import final_report_data_path as _final_report_data_path
70
+ from .final_report_paths import (
71
+ final_report_data_path as _final_report_data_path,
72
+ final_report_markdown_path as _final_report_markdown_path,
73
+ require_approved_plan_record as _require_approved_plan_record_path,
74
+ )
70
75
  from .lead_events import LeadEvent, append_lead_event
71
76
  from .assignment_environment import load_assignment_context
72
77
  from .assignment_resolver import (
@@ -197,9 +202,9 @@ from .scope_provenance import brief_end_state_id_sequence
197
202
  # Frontmatter approval-flag matcher.
198
203
  #
199
204
  # Final-report 의 YAML frontmatter 안에서 `approved: true` / `approved: false`
200
- # 한 줄만 식별한다. report-writer 가 항상 이 한 줄을 출력하고, 사용자가
201
- # 직접 편집하거나 `--approve` CLI 가 이 줄만 toggle 한다. 본문(body) 의 다른
202
- # `approved:` 등장과 충돌하지 않도록 호출자는 frontmatter 블록을 먼저 추출
205
+ # 한 줄만 식별한다. schema-v1 에서는 이 줄이 정본이고, schema-v2 에서는
206
+ # 정본 `frontmatter.approved` 의 표시다. 본문(body) 의 다른 `approved:` 등장과
207
+ # 충돌하지 않도록 호출자는 frontmatter 블록을 먼저 추출
203
208
  # (`_extract_frontmatter_block`) 한 뒤 이 패턴을 적용한다.
204
209
  APPROVED_FRONTMATTER_PATTERN = re.compile(
205
210
  r"^approved:[ \t]+(true|false)[ \t]*$",
@@ -216,16 +221,14 @@ BLOCKING_PLAN_BODY_GATES = {"blocked-by-disagreement", "aborted-non-result"}
216
221
  #
217
222
  # `approved:` 바로 아래 줄의 `implementation-option:` 한 줄을 식별한다.
218
223
  # report-writer 가 빈 값으로 항상 emit 하므로 라인은 존재하되 값은 비어 있을
219
- # 수 있다. `--implementation-option <name>` CLI 가 이 줄의 값만 치환한다.
220
- # 값이 비면 implementation 은 plan 의 `Recommended Option` 으로 폴백한다.
224
+ # 수 있다. `--implementation-option <name>` CLI 가 schema-v1 에서 이 줄의
225
+ # 값만 치환한다. schema-v2 는 정본 `frontmatter.implementationOption` 을 쓰고
226
+ # 열람본을 다시 렌더한다. 값이 비면 implementation 은 plan 의
227
+ # `Recommended Option` 으로 폴백한다.
221
228
  IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN = re.compile(
222
229
  r"^implementation-option:[ \t]*(.*)$",
223
230
  re.MULTILINE,
224
231
  )
225
- SELECTED_DIRECTION_FRONTMATTER_PATTERN = re.compile(
226
- r"^selected-direction-ref:[ \t]*(.*)$",
227
- re.MULTILINE,
228
- )
229
232
 
230
233
  # validators/validate-run.py:_FRONTMATTER_BLOCK_RE 의 미러 — 선행 BOM/빈 줄을
231
234
  # 허용해 두 모듈이 같은 리포트의 frontmatter 게이트를 동일하게 판정하게 한다.
@@ -267,40 +270,11 @@ def _data_json_gate_result(data: dict) -> str:
267
270
  return str(verification.get("gateResult") or "").strip().lower()
268
271
 
269
272
 
270
- def _reject_blocking_plan_body_gate(path: Path, body: str, *, action: str) -> None:
271
- gate_match = PLAN_BODY_GATE_PATTERN.search(body)
272
- if gate_match:
273
- gate_value = gate_match.group("value").strip().lower()
274
- if gate_value in BLOCKING_PLAN_BODY_GATES:
275
- raise PrepareError(
276
- f"{action} rejected because approved plan Gate result is "
277
- f"`{gate_value}`: {path}\n"
278
- " resolve the plan-body verification disagreement and regenerate "
279
- "the implementation-planning report before approval."
280
- )
281
-
282
- loaded = _load_final_report_data_if_present(path)
283
- if loaded is None:
284
- return
285
- data_path, data = loaded
286
- data_gate = _data_json_gate_result(data)
287
- if data_gate in BLOCKING_PLAN_BODY_GATES:
288
- raise PrepareError(
289
- f"{action} rejected because approved plan data.json Gate result is "
290
- f"`{data_gate}`: {data_path}\n"
291
- " data.json is the final-report source of truth; regenerate the "
292
- "report after resolving the verification disagreement."
293
- )
294
-
295
-
296
- def _validate_data_json_approval_consistency(
297
- path: Path,
298
- *,
299
- markdown_approved: bool,
300
- ) -> None:
273
+ def _record_approved_flag(path: Path) -> bool | None:
274
+ """정본 `frontmatter.approved`. 정본이 없으면(schema-v1) None."""
301
275
  loaded = _load_final_report_data_if_present(path)
302
276
  if loaded is None:
303
- return
277
+ return None
304
278
  data_path, data = loaded
305
279
  frontmatter = data.get("frontmatter")
306
280
  if not isinstance(frontmatter, dict) or "approved" not in frontmatter:
@@ -312,15 +286,42 @@ def _validate_data_json_approval_consistency(
312
286
  raise PrepareError(
313
287
  f"approved plan data.json frontmatter.approved is not boolean: {data_path}"
314
288
  )
315
- if data_approved != markdown_approved:
289
+ return data_approved
290
+
291
+
292
+ def _unapproved_plan_message(path: str, displayed: str) -> str:
293
+ return (
294
+ f"approved plan is not yet approved (frontmatter `approved: {displayed}`): "
295
+ f"{path}\n"
296
+ " re-run okstra with `--approve`, or confirm approval in the "
297
+ "in-session wizard.\n"
298
+ " resolve any `Blocks=approval` rows in `## 1. Clarification Items` first."
299
+ )
300
+
301
+
302
+ def _reject_blocking_plan_body_gate(path: Path, body: str, *, action: str) -> None:
303
+ loaded = _load_final_report_data_if_present(path)
304
+ if loaded is not None:
305
+ data_path, data = loaded
306
+ data_gate = _data_json_gate_result(data)
307
+ if data_gate in BLOCKING_PLAN_BODY_GATES:
308
+ raise PrepareError(
309
+ f"{action} rejected because approved plan data.json Gate result is "
310
+ f"`{data_gate}`: {data_path}\n"
311
+ " resolve the plan-body verification disagreement and regenerate "
312
+ "the implementation-planning report before approval."
313
+ )
314
+ return
315
+ gate_match = PLAN_BODY_GATE_PATTERN.search(body)
316
+ if not gate_match:
317
+ return
318
+ gate_value = gate_match.group("value").strip().lower()
319
+ if gate_value in BLOCKING_PLAN_BODY_GATES:
316
320
  raise PrepareError(
317
- "approved plan markdown frontmatter and data.json disagree on "
318
- f"`approved`: markdown={str(markdown_approved).lower()}, "
319
- f"data.json={str(data_approved).lower()}\n"
320
- f" markdown: {path}\n"
321
- f" data.json: {data_path}\n"
322
- " regenerate the report from data.json or use `okstra --approve` "
323
- "so the source of truth is updated first."
321
+ f"{action} rejected because approved plan Gate result is "
322
+ f"`{gate_value}`: {path}\n"
323
+ " resolve the plan-body verification disagreement and regenerate "
324
+ "the implementation-planning report before approval."
324
325
  )
325
326
 
326
327
 
@@ -361,24 +362,10 @@ def _validate_approved_plan_conformance(path: Path) -> None:
361
362
  )
362
363
 
363
364
 
364
- def _set_data_json_approved_true_if_present(path: Path) -> bool:
365
- loaded = _load_final_report_data_if_present(path)
366
- if loaded is None:
367
- return False
368
- data_path, data = loaded
369
- data_gate = _data_json_gate_result(data)
370
- if data_gate in BLOCKING_PLAN_BODY_GATES:
371
- raise PrepareError(
372
- f"--approve rejected because approved plan data.json Gate result is "
373
- f"`{data_gate}`: {data_path}"
374
- )
375
- frontmatter = data.setdefault("frontmatter", {})
376
- if not isinstance(frontmatter, dict):
377
- raise PrepareError(
378
- f"approved plan data.json frontmatter must be an object: {data_path}"
379
- )
380
- frontmatter["approved"] = True
381
-
365
+ def _commit_data_json_and_rerender(
366
+ record_path: Path, data: dict, *, action: str
367
+ ) -> None:
368
+ """정본을 쓰고 전문 열람본을 그 정본에서 다시 렌더한다."""
382
369
  try:
383
370
  from .render_final_report import (
384
371
  FinalReportRenderError,
@@ -392,11 +379,12 @@ def _set_data_json_approved_true_if_present(path: Path) -> bool:
392
379
  )
393
380
  except FinalReportRenderError as exc:
394
381
  raise PrepareError(
395
- f"--approve could not re-render approved plan from data.json: {exc}"
382
+ f"{action} could not re-render approved plan from data.json: {exc}"
396
383
  ) from exc
397
384
 
398
- data_tmp = data_path.with_suffix(data_path.suffix + f".tmp.{os.getpid()}")
399
- md_tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
385
+ md_path = _final_report_markdown_path(record_path)
386
+ data_tmp = record_path.with_suffix(record_path.suffix + f".tmp.{os.getpid()}")
387
+ md_tmp = md_path.with_suffix(md_path.suffix + f".tmp.{os.getpid()}")
400
388
  data_tmp.write_text(
401
389
  json.dumps(data, ensure_ascii=False, indent=2) + "\n",
402
390
  encoding="utf-8",
@@ -404,16 +392,34 @@ def _set_data_json_approved_true_if_present(path: Path) -> bool:
404
392
  md_tmp.write_text(rendered, encoding="utf-8")
405
393
  # 두 파일을 같이 atomic 하게 commit 할 수 없으므로, 첫 replace 직전의
406
394
  # data.json 바이트를 보관했다가 markdown replace 가 실패하면 data.json 을
407
- # 되돌린다. 이렇게 하면 부분 실패가 "둘 다 approved 이전(consistent)" 상태로
408
- # 수렴해 이후 _validate_data_json_approval_consistency 의 hard-fail 을 막는다
409
- # (남는 불일치 창은 두 replace 사이의 hard process kill 뿐).
410
- data_prev = data_path.read_bytes()
411
- data_tmp.replace(data_path)
395
+ # 되돌린다. 남는 불일치 창은 두 replace 사이의 hard process kill 뿐이다.
396
+ data_prev = record_path.read_bytes()
397
+ data_tmp.replace(record_path)
412
398
  try:
413
- md_tmp.replace(path)
399
+ md_tmp.replace(md_path)
414
400
  except OSError:
415
- data_path.write_bytes(data_prev)
401
+ record_path.write_bytes(data_prev)
416
402
  raise
403
+
404
+
405
+ def _set_data_json_approved_true_if_present(path: Path) -> bool:
406
+ loaded = _load_final_report_data_if_present(path)
407
+ if loaded is None:
408
+ return False
409
+ data_path, data = loaded
410
+ data_gate = _data_json_gate_result(data)
411
+ if data_gate in BLOCKING_PLAN_BODY_GATES:
412
+ raise PrepareError(
413
+ f"--approve rejected because approved plan data.json Gate result is "
414
+ f"`{data_gate}`: {data_path}"
415
+ )
416
+ frontmatter = data.setdefault("frontmatter", {})
417
+ if not isinstance(frontmatter, dict):
418
+ raise PrepareError(
419
+ f"approved plan data.json frontmatter must be an object: {data_path}"
420
+ )
421
+ frontmatter["approved"] = True
422
+ _commit_data_json_and_rerender(data_path, data, action="--approve")
417
423
  return True
418
424
 
419
425
 
@@ -521,35 +527,19 @@ def _default(name: str, fallback: str) -> str:
521
527
  return os.environ.get(name, "") or fallback
522
528
 
523
529
 
530
+ def _approved_plan_record(path: str) -> Path:
531
+ try:
532
+ return _require_approved_plan_record_path(Path(path))
533
+ except ValueError as exc:
534
+ raise PrepareError(str(exc)) from exc
535
+
536
+
524
537
  def _validate_approved_plan(path: str) -> None:
525
- p = Path(path)
526
- if not p.is_file():
527
- raise PrepareError(f"approved plan file not found: {path}")
528
- body = p.read_text(encoding="utf-8", errors="replace")
529
- frontmatter = _extract_frontmatter_block(body)
530
- if frontmatter is None:
531
- raise PrepareError(
532
- f"approved plan has no YAML frontmatter block: {path}\n"
533
- " expected the report to begin with `---\\n...\\n---\\n`. "
534
- "report-writer worker emits this header on every run; "
535
- "regenerate the report if the header is missing."
536
- )
537
- m = APPROVED_FRONTMATTER_PATTERN.search(frontmatter)
538
- if not m:
539
- raise PrepareError(
540
- f"approved plan frontmatter has no `approved:` field: {path}\n"
541
- " expected a single line of the form `approved: true` "
542
- "(or `approved: false` for the unflipped state)."
543
- )
544
- if m.group(1).lower() != "true":
545
- raise PrepareError(
546
- f"approved plan is not yet approved (frontmatter `approved: {m.group(1)}`): {path}\n"
547
- " open the report and change the frontmatter line to `approved: true`, "
548
- "or re-run okstra with `--approve` to flip it from the CLI.\n"
549
- " resolve any `Blocks=approval` rows in `## 1. Clarification Items` first."
550
- )
551
- _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
552
- _validate_data_json_approval_consistency(p, markdown_approved=True)
538
+ p = _approved_plan_record(path)
539
+ record_approved = _record_approved_flag(p)
540
+ if record_approved is not True:
541
+ raise PrepareError(_unapproved_plan_message(str(p), "false"))
542
+ _reject_blocking_plan_body_gate(p, "", action="approved plan validation")
553
543
  _validate_approved_plan_conformance(p)
554
544
  # frontmatter approved == true 상태. §1 Clarification Items 의
555
545
  # Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
@@ -607,129 +597,37 @@ def _parse_stage_map_into_ctx(plan_path: str) -> list:
607
597
  ) from exc
608
598
 
609
599
 
610
- def _apply_cli_approval(path: str) -> str:
611
- """`--approve` 가 지정된 경우 approved-plan 의 frontmatter `approved` 를 true 로 토글.
600
+ def _apply_cli_approval(path: str) -> None:
601
+ """`--approve` 가 지정된 경우 정본 `frontmatter.approved` 를 true 로 토글.
612
602
 
613
- 동작 요약:
614
- - frontmatter 에 `approved: false` 가 있으면 `approved: true` 로 교체하고
615
- audit 라인을 append → `"frontmatter-flipped"` 반환.
616
- - 이미 `approved: true` 면 한 번도 기록되지 않은 audit 라인만 append →
617
- `"already-approved-audit-appended"`, 이미 동일 audit 가 있으면
618
- `"already-approved"` 로 no-op.
619
- - frontmatter 가 없거나 `approved:` 라인이 없으면 PrepareError.
620
-
621
- Idempotent: 동일 audit 라인은 한 번만 기록된다.
603
+ 정본을 쓰고 열람본을 다시 렌더한다. 이미 true 면 아무 것도 쓰지 않는다.
604
+ schema-v1 계획은 정본이 없어 `--approved-plan` 으로 받을 수 없다.
622
605
  """
623
- p = Path(path)
624
- if not p.is_file():
625
- raise PrepareError(f"approved plan file not found: {path}")
626
- body = p.read_text(encoding="utf-8", errors="replace")
627
- frontmatter = _extract_frontmatter_block(body)
628
- if frontmatter is None:
629
- raise PrepareError(
630
- f"--approve was given but the approved-plan file has no YAML frontmatter: {path}\n"
631
- " expected the report to begin with `---\\n...\\n---\\n`."
632
- )
633
- m = APPROVED_FRONTMATTER_PATTERN.search(frontmatter)
634
- if not m:
606
+ p = _approved_plan_record(path)
607
+ _reject_blocking_plan_body_gate(p, "", action="--approve")
608
+ if _record_approved_flag(p) is True:
609
+ return
610
+ if not _set_data_json_approved_true_if_present(p):
635
611
  raise PrepareError(
636
- f"--approve was given but the approved-plan frontmatter has no `approved:` field: {path}\n"
637
- " expected a single line of the form `approved: false` "
638
- "(report-writer worker emits this by default)."
612
+ f"--approve found a report record but could not update it: {p}"
639
613
  )
640
- _reject_blocking_plan_body_gate(p, body, action="--approve")
641
-
642
- audit_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
643
- audit_line = (
644
- f"- 승인 일시 (CLI ack): {audit_iso} — recorded by `okstra --approve` "
645
- "(user CLI invocation treated as approval signal)"
646
- )
647
-
648
- rendered_from_data = False
649
- if m.group(1).lower() != "true":
650
- rendered_from_data = _set_data_json_approved_true_if_present(p)
651
- if rendered_from_data:
652
- body = p.read_text(encoding="utf-8", errors="replace")
653
- frontmatter = _extract_frontmatter_block(body) or ""
654
- m = APPROVED_FRONTMATTER_PATTERN.search(frontmatter)
655
- if not m:
656
- raise PrepareError(
657
- f"--approve re-rendered the approved-plan but the markdown "
658
- f"frontmatter has no `approved:` field: {path}"
659
- )
660
-
661
- if m.group(1).lower() == "true":
662
- if audit_line.split(" — ")[1] in body:
663
- return "already-approved"
664
- new_body = body.rstrip("\n") + "\n" + audit_line + "\n"
665
- p.write_text(new_body, encoding="utf-8")
666
- if rendered_from_data:
667
- return "frontmatter-flipped"
668
- return "already-approved-audit-appended"
669
-
670
- flipped_frontmatter = APPROVED_FRONTMATTER_PATTERN.sub(
671
- "approved: true", frontmatter, count=1,
672
- )
673
- new_body = body.replace(frontmatter, flipped_frontmatter, 1)
674
- new_body = new_body.rstrip("\n") + "\n" + audit_line + "\n"
675
- p.write_text(new_body, encoding="utf-8")
676
- return "frontmatter-flipped"
677
-
678
614
 
679
- def _apply_cli_implementation_option(path: str, option_name: str) -> str:
680
- """`--implementation-option <name>` 이 지정된 경우 approved-plan frontmatter 의
681
- `implementation-option:` 라인 값을 `option_name` 으로 치환한다.
682
615
 
683
- 동작 요약:
684
- - frontmatter 의 `implementation-option:` 라인을
685
- `implementation-option: <option_name>` 으로 교체 → `"frontmatter-set"`.
686
- - audit 라인을 한 번 append (idempotent: 동일 audit 라인은 한 번만).
687
- - frontmatter 가 없거나 `implementation-option:` 라인이 없으면 PrepareError
688
- (report-writer 가 빈 값으로 이 라인을 항상 emit 하므로 라인은 존재해야 한다).
689
-
690
- `--approve` (`_apply_cli_approval`) 의 frontmatter-치환 + audit-append 패턴을
691
- 그대로 미러한다.
692
- """
693
- p = Path(path)
694
- if not p.is_file():
695
- raise PrepareError(f"approved plan file not found: {path}")
696
- body = p.read_text(encoding="utf-8", errors="replace")
697
- frontmatter = _extract_frontmatter_block(body)
698
- if frontmatter is None:
699
- raise PrepareError(
700
- f"--implementation-option was given but the approved-plan file has no YAML frontmatter: {path}\n"
701
- " expected the report to begin with `---\\n...\\n---\\n`."
702
- )
703
- if not IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN.search(frontmatter):
616
+ def _apply_cli_implementation_option(path: str, option_name: str) -> None:
617
+ """`--implementation-option <name>` 을 정본에 쓰고 열람본을 다시 렌더한다."""
618
+ p = _approved_plan_record(path)
619
+ loaded = _load_final_report_data_if_present(p)
620
+ if loaded is None:
621
+ raise PrepareError(f"approved plan data.json disappeared: {p}")
622
+ data_path, data = loaded
623
+ frontmatter = data.get("frontmatter")
624
+ if not isinstance(frontmatter, dict) or "implementationOption" not in frontmatter:
704
625
  raise PrepareError(
705
- f"--implementation-option was given but the approved-plan frontmatter has no "
706
- f"`implementation-option:` field: {path}\n"
707
- " expected a single line of the form `implementation-option:` "
708
- "(report-writer worker emits this empty by default)."
626
+ f"--implementation-option was given but the approved-plan "
627
+ f"data.json has no frontmatter.implementationOption field: {data_path}"
709
628
  )
710
-
711
- audit_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
712
- audit_line = (
713
- f"- 선택 옵션 (CLI): {audit_iso} — `{option_name}` recorded by "
714
- "`okstra --implementation-option` (user-selected Option Candidate)"
715
- )
716
-
717
- # 렌더 경로(`final-report.template.md` 의 `yaml_scalar`)와 동일하게 값을
718
- # YAML 따옴표 처리한다. 옵션명은 보통 `: `(콜론+공백)를 포함하므로 따옴표
719
- # 없이 쓰면 frontmatter 가 invalid YAML 이 되고 CLI/렌더 출력이 어긋난다.
720
- # 함수 치환을 써서 quoted 값 안의 백슬래시/그룹 참조가 re 치환 문법으로
721
- # 재해석되는 것까지 막는다 (리터럴 치환).
722
- from .render_final_report import _yaml_scalar
723
-
724
- quoted = _yaml_scalar(option_name)
725
- set_frontmatter = IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN.sub(
726
- lambda _m: f"implementation-option: {quoted}", frontmatter, count=1,
727
- )
728
- new_body = body.replace(frontmatter, set_frontmatter, 1)
729
- if audit_line.split(" — ")[1] not in new_body:
730
- new_body = new_body.rstrip("\n") + "\n" + audit_line + "\n"
731
- p.write_text(new_body, encoding="utf-8")
732
- return "frontmatter-set"
629
+ frontmatter["implementationOption"] = option_name
630
+ _commit_data_json_and_rerender(data_path, data, action="--implementation-option")
733
631
 
734
632
 
735
633
  def _ensure_task_directories(ctx: dict) -> None:
@@ -802,7 +700,7 @@ def _record_start(
802
700
  workers=[w for w in ctx.get("RECOMMENDED_ANALYSERS", "").split(",") if w],
803
701
  lead_model=ctx.get("LEAD_MODEL", ""),
804
702
  run_dir_rel=ctx.get("RUN_DIR_RELATIVE_PATH", ""),
805
- final_report_rel=ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
703
+ final_report_record_rel=ctx.get("FINAL_REPORT_RECORD_RELATIVE_PATH", ""),
806
704
  final_status_rel=ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
807
705
  argv=canonical_argv,
808
706
  cwd=cwd,
@@ -1204,7 +1102,7 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1204
1102
 
1205
1103
 
1206
1104
  _PLANNING_REPORT_RE = re.compile(
1207
- r"^final-report-implementation-planning-\d{3,}\.md$"
1105
+ r"^final-report-implementation-planning-\d{3,}\.(?:md|data\.json)$"
1208
1106
  )
1209
1107
 
1210
1108
 
@@ -1343,37 +1241,11 @@ def _validate_reverify_scope(inp: PrepareInputs) -> None:
1343
1241
  def _implementation_plan_contract(
1344
1242
  inp: PrepareInputs,
1345
1243
  ) -> tuple[str, Path, dict | None]:
1346
- """Classify an implementation plan from its sibling data.json contract."""
1347
- plan_path = Path(inp.approved_plan_path)
1348
- body = plan_path.read_text(encoding="utf-8", errors="replace")
1349
- frontmatter = _extract_frontmatter_block(body) or ""
1350
- markdown_ref_match = SELECTED_DIRECTION_FRONTMATTER_PATTERN.search(frontmatter)
1351
- markdown_ref: str | None = None
1352
- if markdown_ref_match is not None:
1353
- markdown_ref = markdown_ref_match.group(1).strip()
1354
- if markdown_ref.startswith('"'):
1355
- try:
1356
- markdown_ref = json.loads(markdown_ref)
1357
- except json.JSONDecodeError as exc:
1358
- raise PrepareError(
1359
- "selected-direction plan markdown `selected-direction-ref` is "
1360
- "not a valid quoted string"
1361
- ) from exc
1362
- elif len(markdown_ref) >= 2 and markdown_ref[0] == markdown_ref[-1] == "'":
1363
- markdown_ref = markdown_ref[1:-1].replace("''", "'")
1364
- if not isinstance(markdown_ref, str) or not markdown_ref.strip():
1365
- raise PrepareError(
1366
- "selected-direction plan markdown `selected-direction-ref` must "
1367
- "be a non-empty string"
1368
- )
1244
+ """Classify an implementation plan from its report record."""
1245
+ plan_path = _approved_plan_record(inp.approved_plan_path)
1369
1246
  loaded = _load_final_report_data_if_present(plan_path)
1370
1247
  if loaded is None:
1371
- if markdown_ref_match is not None:
1372
- raise PrepareError(
1373
- "selected-direction plan requires its sibling data.json before "
1374
- f"implementation entry: {_final_report_data_path(plan_path)}"
1375
- )
1376
- return "legacy", plan_path, None
1248
+ raise PrepareError(f"approved plan record could not be read: {plan_path}")
1377
1249
  _, data = loaded
1378
1250
  planning = data.get("implementationPlanning")
1379
1251
  if not isinstance(planning, dict):
@@ -1392,11 +1264,10 @@ def _implementation_plan_contract(
1392
1264
  )
1393
1265
  )
1394
1266
  if contract is None:
1395
- if markdown_ref_match is not None or selected_payload:
1267
+ if selected_payload:
1396
1268
  raise PrepareError(
1397
1269
  "selected-direction markers require "
1398
- "implementationPlanning.planningContract=`selected-direction`; "
1399
- "the markdown `selected-direction-ref` and data contract disagree"
1270
+ "implementationPlanning.planningContract=`selected-direction`"
1400
1271
  )
1401
1272
  return "legacy", plan_path, data
1402
1273
  if contract != "selected-direction":
@@ -1404,27 +1275,6 @@ def _implementation_plan_contract(
1404
1275
  "approved plan sibling data.json has unsupported planningContract "
1405
1276
  f"{contract!r}"
1406
1277
  )
1407
- if markdown_ref_match is None:
1408
- raise PrepareError(
1409
- "selected-direction plan markdown frontmatter must contain "
1410
- "`selected-direction-ref:`"
1411
- )
1412
- selected_ref = planning.get("selectedDirectionRef")
1413
- snapshot_ref = (
1414
- selected_ref.get("snapshotPath")
1415
- if isinstance(selected_ref, dict)
1416
- else None
1417
- )
1418
- if not isinstance(markdown_ref, str) or markdown_ref != snapshot_ref:
1419
- raise PrepareError(
1420
- "selected-direction plan markdown `selected-direction-ref` must "
1421
- "exactly match implementationPlanning.selectedDirectionRef.snapshotPath"
1422
- )
1423
- if IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN.search(frontmatter):
1424
- raise PrepareError(
1425
- "selected-direction plan must not contain an `implementation-option:` "
1426
- "frontmatter field"
1427
- )
1428
1278
  return "selected-direction", plan_path, data
1429
1279
 
1430
1280
 
@@ -3361,6 +3211,11 @@ def _write_instruction_set_sources(
3361
3211
  instruction_set_relative_path=ctx["INSTRUCTION_SET_RELATIVE_PATH"],
3362
3212
  fix_history_text=fix_cycles.packet_summary(
3363
3213
  fix_cycles.read_rows(Path(ctx["TASK_MANIFEST_PATH"]).parent)),
3214
+ stage_ledger_json=(
3215
+ render_stage_ledger(
3216
+ build_stage_ledger(Path(ctx["TASK_MANIFEST_PATH"]).parent))
3217
+ if inp.task_type == "implementation-planning" else ""
3218
+ ),
3364
3219
  )
3365
3220
  if inp.task_type in ANALYSIS_TASK_TYPES:
3366
3221
  packet += (
@@ -3620,7 +3475,7 @@ def _maybe_open_fix_cycle(inp: PrepareInputs, task_root: Path, existing: dict,
3620
3475
  "(workflow.lastCompletedPhase 확인)")
3621
3476
  brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
3622
3477
  fix_cycles.append_opened(
3623
- task_root, target_report=str(existing.get("latestReportPath", "")),
3478
+ task_root, target_report=str(existing.get("latestReportRecordPath", "")),
3624
3479
  symptom=fix_cycles.derive_symptom(brief_text), opened_at=now)
3625
3480
  return fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
3626
3481
 
@@ -3650,7 +3505,7 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
3650
3505
  if handoff_attached:
3651
3506
  fix_cycles.append_closed(
3652
3507
  task_root, cycle=open_c["cycle"], closed_by="release-handoff",
3653
- report=str(existing.get("latestReportPath", "")), closed_at=now)
3508
+ report=str(existing.get("latestReportRecordPath", "")), closed_at=now)
3654
3509
  open_c = None
3655
3510
 
3656
3511
  if inp.fix_cycle == "yes" and open_c is None:
@@ -3680,7 +3535,7 @@ def _finalize_status_and_render_manifests(
3680
3535
  ctx["CURRENT_TASK_STATUS"] = "lead-session-started"
3681
3536
  ctx["CURRENT_RUN_STATUS"] = "in-progress"
3682
3537
  ctx["LATEST_REPORT_PATH"] = ctx["FINAL_REPORT_PATH"]
3683
- ctx["LATEST_REPORT_RELATIVE_PATH"] = ctx["FINAL_REPORT_RELATIVE_PATH"]
3538
+ ctx["LATEST_REPORT_RECORD_RELATIVE_PATH"] = ctx["FINAL_REPORT_RECORD_RELATIVE_PATH"]
3684
3539
  ctx.update(compute_workflow_state(
3685
3540
  task_type=inp.task_type,
3686
3541
  current_run_status=ctx["CURRENT_RUN_STATUS"],
@@ -4330,7 +4185,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4330
4185
  "VALIDATION_UPDATED_AT": "",
4331
4186
  "VALIDATION_FAILURES_JSON": "[]",
4332
4187
  "LATEST_REPORT_PATH": "",
4333
- "LATEST_REPORT_RELATIVE_PATH": "",
4188
+ "LATEST_REPORT_RECORD_RELATIVE_PATH": "",
4334
4189
  "RENDER_ONLY": "true" if inp.render_only else "false",
4335
4190
  "OKSTRA_VERSION": installed_version(),
4336
4191
  "EXECUTION_IDENTITY_JSON": json.dumps(
@@ -4516,8 +4371,8 @@ def build_prepare_argument_parser():
4516
4371
  dest="approve_plan_ack",
4517
4372
  help=(
4518
4373
  "Treat the CLI invocation itself as the plan approval signal. "
4519
- "Flips `approved: false` to `approved: true` in the --approved-plan file's "
4520
- "YAML frontmatter and appends an audit line."
4374
+ "Sets the report record `frontmatter.approved` to true and refreshes "
4375
+ "the full reading copy."
4521
4376
  ),
4522
4377
  )
4523
4378
  p.add_argument(
@@ -4527,8 +4382,8 @@ def build_prepare_argument_parser():
4527
4382
  help=(
4528
4383
  "implementation task only. Name of the Option Candidate the user "
4529
4384
  "chose from the implementation-planning final-report. Written into "
4530
- "the --approved-plan file's `implementation-option:` frontmatter "
4531
- "line. When omitted, implementation falls back to the plan's "
4385
+ "the report record `frontmatter.implementationOption` (schema-v1: "
4386
+ "the markdown `implementation-option:` line). When omitted, implementation falls back to the plan's "
4532
4387
  "`Recommended Option`."
4533
4388
  ),
4534
4389
  )
@@ -93,10 +93,10 @@ def _latest_manifest(run_dir: Path) -> tuple[dict, Path | None]:
93
93
 
94
94
 
95
95
  def _resolve_report(run_dir: Path, project_root: str, expected: str) -> Path | None:
96
- """`expectedReportPath` 를 실제 파일로 푼다. 루트 밖 이탈이면 `None`.
96
+ """`expectedReportRecordPath` 를 실제 파일로 푼다. 루트 밖 이탈이면 `None`.
97
97
 
98
98
  이 값은 project_root 기준 상대경로다 — `render.py` 가
99
- `FINAL_REPORT_RELATIVE_PATH` 에서 채우고 그 값은 `paths.py` 의
99
+ `FINAL_REPORT_RECORD_RELATIVE_PATH` 에서 채우고 그 값은 `paths.py` 의
100
100
  `_rel(project_root, final_report)` 이며, 다른 소비자도
101
101
  `dispatch_state.resolve_required_path(project_root, ...)` 로 푼다.
102
102
 
@@ -116,7 +116,7 @@ def _resolve_report(run_dir: Path, project_root: str, expected: str) -> Path | N
116
116
 
117
117
 
118
118
  def _report_data_path(report_path: Path) -> Path:
119
- """구조화 본문이 있는 파일. 정본 `expectedReportPath` 는 마크다운이고
119
+ """구조화 본문이 있는 파일. 정본 `expectedReportRecordPath` 는 마크다운이고
120
120
  본문은 `.data.json` 형제다. 이미 data.json 을 가리키면 그대로 쓴다 —
121
121
  `final_report_data_path` 는 `.md` 가 아닌 이름에 `with_suffix` 를 걸어
122
122
  `x.data.json` 을 `x.data.data.json` 으로 바꿔 놓는다."""
@@ -205,7 +205,7 @@ def _never_ran(row: dict) -> bool:
205
205
 
206
206
  def _check_report(run_dir: Path, manifest: dict, manifest_path: Path,
207
207
  project_root: str, row: dict) -> list[dict]:
208
- expected = str(manifest.get("expectedReportPath", ""))
208
+ expected = str(manifest.get("expectedReportRecordPath", ""))
209
209
  if not expected:
210
210
  return []
211
211
  report_path = _resolve_report(run_dir, project_root, expected)
@@ -313,7 +313,7 @@ def _check_roster(manifest: dict, manifest_path: Path, project_root: str,
313
313
  `workerResultsDirectoryPath` 는 run_dir 이 아니라 **project_root** 기준
314
314
  상대경로다(`render.py` 가 `WORKER_RESULTS_RELATIVE_PATH` 에서 채운다). 실측
315
315
  manifest 139건 전부 그랬고, run_dir 기준으로 풀면 한 건도 디렉터리에 닿지
316
- 않아 감사가 통째로 빈손이 된다. `expectedReportPath` 와 같은 이유로
316
+ 않아 감사가 통째로 빈손이 된다. `expectedReportRecordPath` 와 같은 이유로
317
317
  `resolve_under_root` 를 거쳐 루트 밖 이탈도 함께 막는다."""
318
318
  raw_workers = manifest.get("recommendedWorkers")
319
319
  # 문자열이 들어오면 문자 단위로 순회해 `no result from c, l, a, u, d, e` 가
@@ -25,7 +25,7 @@ def build_run_index_row(*, project_id: str, project_root: str,
25
25
  run_seq: int, status: str, started_at: str,
26
26
  finished_at: Optional[str], workers: list, lead_model: str,
27
27
  validation: str, run_dir_rel: str,
28
- final_report_rel: str, final_status_rel: str,
28
+ final_report_record_rel: str, final_status_rel: str,
29
29
  execution_manifest_path: str = "",
30
30
  role_execution_refs: list | None = None) -> dict:
31
31
  """slim run-index row(14필드)를 만든다. 파생 4필드는 포함하지 않는다."""
@@ -42,7 +42,7 @@ def build_run_index_row(*, project_id: str, project_root: str,
42
42
  "workers": workers,
43
43
  "leadModel": lead_model,
44
44
  "validation": validation,
45
- "finalReportRel": final_report_rel,
45
+ "finalReportRecordRel": final_report_record_rel,
46
46
  "finalStatusRel": final_status_rel,
47
47
  "runDirRel": run_dir_rel,
48
48
  **({