okstra 0.178.0 → 0.179.1

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 (114) 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 +3 -3
  31. package/runtime/prompts/lead/okstra-lead-contract.md +5 -5
  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/prompts/wizard/prompts.ko.json +1 -0
  43. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
  44. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
  45. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
  46. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
  47. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
  48. package/runtime/python/okstra_ctl/agent_prompt_cli.py +77 -6
  49. package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
  50. package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
  51. package/runtime/python/okstra_ctl/backfill.py +12 -5
  52. package/runtime/python/okstra_ctl/consumers.py +70 -3
  53. package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
  54. package/runtime/python/okstra_ctl/convergence_store.py +13 -2
  55. package/runtime/python/okstra_ctl/dispatch_core.py +85 -14
  56. package/runtime/python/okstra_ctl/dispatch_state.py +34 -20
  57. package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
  58. package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
  59. package/runtime/python/okstra_ctl/execution_manifest.py +22 -3
  60. package/runtime/python/okstra_ctl/execution_mutation_audit.py +35 -1
  61. package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
  62. package/runtime/python/okstra_ctl/handoff.py +1 -2
  63. package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
  64. package/runtime/python/okstra_ctl/index.py +4 -4
  65. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
  66. package/runtime/python/okstra_ctl/listing.py +4 -2
  67. package/runtime/python/okstra_ctl/manager_launch.py +1 -1
  68. package/runtime/python/okstra_ctl/manager_sync.py +1 -1
  69. package/runtime/python/okstra_ctl/path_hints.py +2 -2
  70. package/runtime/python/okstra_ctl/paths.py +24 -15
  71. package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
  72. package/runtime/python/okstra_ctl/recap.py +3 -2
  73. package/runtime/python/okstra_ctl/reconcile.py +3 -1
  74. package/runtime/python/okstra_ctl/render.py +22 -22
  75. package/runtime/python/okstra_ctl/report_finalize.py +4 -4
  76. package/runtime/python/okstra_ctl/rollup.py +1 -1
  77. package/runtime/python/okstra_ctl/run.py +170 -294
  78. package/runtime/python/okstra_ctl/run_audit.py +5 -5
  79. package/runtime/python/okstra_ctl/run_index_row.py +2 -2
  80. package/runtime/python/okstra_ctl/session_transcript.py +89 -0
  81. package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
  82. package/runtime/python/okstra_ctl/stage_map.py +28 -29
  83. package/runtime/python/okstra_ctl/stage_targets.py +61 -0
  84. package/runtime/python/okstra_ctl/user_response.py +97 -12
  85. package/runtime/python/okstra_ctl/wizard.py +102 -78
  86. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
  87. package/runtime/python/okstra_ctl/worker_runner.py +76 -213
  88. package/runtime/python/okstra_ctl/workflow.py +1 -1
  89. package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
  90. package/runtime/python/okstra_ctl/write_policy.py +51 -9
  91. package/runtime/python/okstra_project/state.py +2 -2
  92. package/runtime/python/okstra_token_usage/__init__.py +1 -1
  93. package/runtime/python/okstra_token_usage/cli.py +3 -3
  94. package/runtime/python/okstra_token_usage/report.py +7 -24
  95. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  96. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  97. package/runtime/schemas/final-report-v2.0.schema.json +11 -1
  98. package/runtime/skills/okstra-inspect/facets/history.md +3 -3
  99. package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
  100. package/runtime/skills/okstra-inspect/facets/report.md +5 -5
  101. package/runtime/skills/okstra-inspect/facets/status.md +2 -2
  102. package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
  103. package/runtime/skills/okstra-run/SKILL.md +1 -1
  104. package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
  105. package/runtime/skills/okstra-user-response/SKILL.md +3 -3
  106. package/runtime/templates/project-docs/task-index.template.md +1 -1
  107. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  108. package/runtime/validators/forbidden_actions.py +76 -5
  109. package/runtime/validators/lib/fixtures.sh +14 -10
  110. package/runtime/validators/lib/runners.sh +1 -1
  111. package/runtime/validators/validate-implementation-plan-stages.py +3 -0
  112. package/runtime/validators/validate-report-views.py +1 -1
  113. package/runtime/validators/validate-run.py +95 -37
  114. package/runtime/validators/validate_session_conformance.py +44 -8
@@ -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
-
273
+ def _record_approved_flag(path: Path) -> bool | None:
274
+ """정본 `frontmatter.approved`. 정본이 없으면(schema-v1) None."""
282
275
  loaded = _load_final_report_data_if_present(path)
283
276
  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:
301
- loaded = _load_final_report_data_if_present(path)
302
- 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
614
 
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
615
 
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
-
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
-
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
 
@@ -3252,6 +3102,23 @@ def _write_prior_run_error_digest(ctx: dict, instruction_set: Path) -> None:
3252
3102
  (instruction_set / "prior-run-errors.md").write_text(digest, encoding="utf-8")
3253
3103
 
3254
3104
 
3105
+ def _stage_or_clear(path: Path, body: str) -> None:
3106
+ """이 런의 값을 쓰거나, 값이 없으면 이전 런이 남긴 파일을 지운다.
3107
+
3108
+ instruction-set 은 run 단위가 아니라 **task 단위** 디렉터리다
3109
+ (`paths.py`: `task_root / "instruction-set"`). 그래서 값이 있을 때만 쓰고
3110
+ 없을 때 아무것도 안 하면, 이전 런의 파일이 그대로 남아 다음 런의 입력이 된다.
3111
+ directive 를 비우고 준비한 런에서 워커들이 예전 directive 를 읽고, 그 내용이
3112
+ 현재 기록과 어긋나 반박에 시간을 쓴 사례가 보고됐다. carry-in 답변
3113
+ (`clarification-response.md`)은 같은 형태에 결과가 더 나쁘다 — 남은 파일이
3114
+ analysis packet 에 그대로 실린다.
3115
+ """
3116
+ if body:
3117
+ path.write_text(body, encoding="utf-8")
3118
+ return
3119
+ path.unlink(missing_ok=True)
3120
+
3121
+
3255
3122
  def _write_instruction_set_sources(
3256
3123
  inp: PrepareInputs,
3257
3124
  ctx: dict,
@@ -3328,22 +3195,26 @@ def _write_instruction_set_sources(
3328
3195
  )
3329
3196
  ctx["HOST_ORCHESTRATION_RULES_RELATIVE_PATH"] = host_rules_relative
3330
3197
  if inp.clarification_response_path:
3331
- (instruction_set / "clarification-response.md").write_text(
3332
- clarification_response_with_sidecars(Path(inp.clarification_response_path)),
3333
- encoding="utf-8",
3198
+ clarification_body = clarification_response_with_sidecars(
3199
+ Path(inp.clarification_response_path)
3334
3200
  )
3335
3201
  elif inp.task_type == "implementation" and inp.approved_plan_path:
3336
3202
  # implementation carry-in: the approved plan reaches this run by path
3337
3203
  # (executor re-reads it), so attach ONLY the planning HTML form answers
3338
3204
  # (`runs/implementation-planning/.../user-responses/`) — without these
3339
3205
  # the user's clarification answers never reach the implementation run.
3340
- plan_responses = attached_user_responses_section(Path(inp.approved_plan_path))
3341
- if plan_responses:
3342
- (instruction_set / "clarification-response.md").write_text(
3343
- plan_responses, encoding="utf-8",
3344
- )
3345
- if inp.directive:
3346
- (instruction_set / "directive.txt").write_text(inp.directive + "\n", encoding="utf-8")
3206
+ clarification_body = attached_user_responses_section(
3207
+ Path(inp.approved_plan_path)
3208
+ )
3209
+ else:
3210
+ clarification_body = ""
3211
+ _stage_or_clear(
3212
+ instruction_set / "clarification-response.md", clarification_body
3213
+ )
3214
+ _stage_or_clear(
3215
+ instruction_set / "directive.txt",
3216
+ inp.directive + "\n" if inp.directive else "",
3217
+ )
3347
3218
  render_reference_expectations(
3348
3219
  str(inp.brief_path), str(instruction_set / "reference-expectations.md"), ctx,
3349
3220
  )
@@ -3361,6 +3232,11 @@ def _write_instruction_set_sources(
3361
3232
  instruction_set_relative_path=ctx["INSTRUCTION_SET_RELATIVE_PATH"],
3362
3233
  fix_history_text=fix_cycles.packet_summary(
3363
3234
  fix_cycles.read_rows(Path(ctx["TASK_MANIFEST_PATH"]).parent)),
3235
+ stage_ledger_json=(
3236
+ render_stage_ledger(
3237
+ build_stage_ledger(Path(ctx["TASK_MANIFEST_PATH"]).parent))
3238
+ if inp.task_type == "implementation-planning" else ""
3239
+ ),
3364
3240
  )
3365
3241
  if inp.task_type in ANALYSIS_TASK_TYPES:
3366
3242
  packet += (
@@ -3620,7 +3496,7 @@ def _maybe_open_fix_cycle(inp: PrepareInputs, task_root: Path, existing: dict,
3620
3496
  "(workflow.lastCompletedPhase 확인)")
3621
3497
  brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
3622
3498
  fix_cycles.append_opened(
3623
- task_root, target_report=str(existing.get("latestReportPath", "")),
3499
+ task_root, target_report=str(existing.get("latestReportRecordPath", "")),
3624
3500
  symptom=fix_cycles.derive_symptom(brief_text), opened_at=now)
3625
3501
  return fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
3626
3502
 
@@ -3650,7 +3526,7 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
3650
3526
  if handoff_attached:
3651
3527
  fix_cycles.append_closed(
3652
3528
  task_root, cycle=open_c["cycle"], closed_by="release-handoff",
3653
- report=str(existing.get("latestReportPath", "")), closed_at=now)
3529
+ report=str(existing.get("latestReportRecordPath", "")), closed_at=now)
3654
3530
  open_c = None
3655
3531
 
3656
3532
  if inp.fix_cycle == "yes" and open_c is None:
@@ -3680,7 +3556,7 @@ def _finalize_status_and_render_manifests(
3680
3556
  ctx["CURRENT_TASK_STATUS"] = "lead-session-started"
3681
3557
  ctx["CURRENT_RUN_STATUS"] = "in-progress"
3682
3558
  ctx["LATEST_REPORT_PATH"] = ctx["FINAL_REPORT_PATH"]
3683
- ctx["LATEST_REPORT_RELATIVE_PATH"] = ctx["FINAL_REPORT_RELATIVE_PATH"]
3559
+ ctx["LATEST_REPORT_RECORD_RELATIVE_PATH"] = ctx["FINAL_REPORT_RECORD_RELATIVE_PATH"]
3684
3560
  ctx.update(compute_workflow_state(
3685
3561
  task_type=inp.task_type,
3686
3562
  current_run_status=ctx["CURRENT_RUN_STATUS"],
@@ -4330,7 +4206,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4330
4206
  "VALIDATION_UPDATED_AT": "",
4331
4207
  "VALIDATION_FAILURES_JSON": "[]",
4332
4208
  "LATEST_REPORT_PATH": "",
4333
- "LATEST_REPORT_RELATIVE_PATH": "",
4209
+ "LATEST_REPORT_RECORD_RELATIVE_PATH": "",
4334
4210
  "RENDER_ONLY": "true" if inp.render_only else "false",
4335
4211
  "OKSTRA_VERSION": installed_version(),
4336
4212
  "EXECUTION_IDENTITY_JSON": json.dumps(
@@ -4516,8 +4392,8 @@ def build_prepare_argument_parser():
4516
4392
  dest="approve_plan_ack",
4517
4393
  help=(
4518
4394
  "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."
4395
+ "Sets the report record `frontmatter.approved` to true and refreshes "
4396
+ "the full reading copy."
4521
4397
  ),
4522
4398
  )
4523
4399
  p.add_argument(
@@ -4527,8 +4403,8 @@ def build_prepare_argument_parser():
4527
4403
  help=(
4528
4404
  "implementation task only. Name of the Option Candidate the user "
4529
4405
  "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 "
4406
+ "the report record `frontmatter.implementationOption` (schema-v1: "
4407
+ "the markdown `implementation-option:` line). When omitted, implementation falls back to the plan's "
4532
4408
  "`Recommended Option`."
4533
4409
  ),
4534
4410
  )