okstra 0.179.2 → 0.180.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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +14 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/report/approval-decision.d.mts +1 -0
  9. package/dist/commands/report/approval-decision.mjs +21 -0
  10. package/dist/commands/report/approval-decision.mjs.map +1 -0
  11. package/dist/commands/report/design-snapshot.d.mts +1 -0
  12. package/dist/commands/report/design-snapshot.mjs +19 -0
  13. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  14. package/docs/architecture/storage-model.md +1 -1
  15. package/docs/architecture.md +10 -10
  16. package/docs/cli.md +11 -8
  17. package/docs/project-structure-overview.md +15 -6
  18. package/docs/task-process/implementation-planning.md +2 -2
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/agents/workers/report-writer-worker.md +15 -164
  22. package/runtime/prompts/launch.template.md +6 -5
  23. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  24. package/runtime/prompts/lead/convergence.md +2 -2
  25. package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
  26. package/runtime/prompts/lead/plan-body-verification.md +39 -18
  27. package/runtime/prompts/lead/report-writer.md +64 -423
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  30. package/runtime/prompts/profiles/_common-contract.md +3 -3
  31. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  32. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  33. package/runtime/prompts/profiles/error-analysis.md +1 -1
  34. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  35. package/runtime/prompts/profiles/implementation-planning.md +13 -11
  36. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  37. package/runtime/prompts/profiles/project-analysis.md +1 -1
  38. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  39. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
  40. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  41. package/runtime/python/okstra_ctl/agent_activity.py +23 -3
  42. package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
  43. package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
  44. package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
  45. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  46. package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
  47. package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
  48. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  49. package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
  50. package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
  51. package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
  52. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  53. package/runtime/python/okstra_ctl/paths.py +12 -0
  54. package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
  55. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  56. package/runtime/python/okstra_ctl/render.py +48 -1
  57. package/runtime/python/okstra_ctl/render_final_report.py +7 -6
  58. package/runtime/python/okstra_ctl/report_assembly.py +354 -0
  59. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  60. package/runtime/python/okstra_ctl/report_finalize.py +60 -22
  61. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  62. package/runtime/python/okstra_ctl/report_markdown.py +69 -8
  63. package/runtime/python/okstra_ctl/report_narrative.py +319 -0
  64. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  65. package/runtime/python/okstra_ctl/run.py +25 -9
  66. package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
  67. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
  68. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  69. package/runtime/python/okstra_ctl/stage_map.py +70 -22
  70. package/runtime/python/okstra_ctl/team.py +1 -1
  71. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  72. package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  74. package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
  75. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  76. package/runtime/templates/report-writer-prompt-preamble.md +15 -21
  77. package/runtime/templates/reports/html/macros/forms.html +6 -4
  78. package/runtime/validators/validate-run.py +258 -10
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
  from pathlib import Path
8
8
  from typing import Any, Iterable
9
9
 
10
+ from .final_report_paths import final_report_data_path, is_full_reading_copy_path
10
11
  from .md_table import is_separator_row, split_pipe_row
11
12
  from .paths import RunRef
12
13
  from .plan_run_root import list_implementation_planning_reports
@@ -364,8 +365,8 @@ def parse_stage_map_file(plan_path: Path) -> list[StageMapStage]:
364
365
  return _parse_stage_map_markdown(resolved)
365
366
 
366
367
 
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.
368
+ def structured_report(plan_path: Path) -> dict[str, Any]:
369
+ """계약 2.0·3.0 구조화 리포트를 반환하고 Markdown에는 빈 값을 반환한다.
369
370
 
370
371
  Public because every caller that must branch on report schema needs it —
371
372
  including `validators/validate-implementation-plan-stages.py`, which is a
@@ -381,14 +382,19 @@ def schema_v2_report(plan_path: Path) -> dict[str, Any]:
381
382
  data = json.loads(resolved.read_text(encoding="utf-8"))
382
383
  except (OSError, UnicodeError, json.JSONDecodeError) as exc:
383
384
  raise StageMapError("stage_map", str(exc), str(resolved)) from exc
384
- if not isinstance(data, dict) or data.get("schemaVersion") != "2.0":
385
+ if not isinstance(data, dict) or data.get("schemaVersion") not in {"2.0", "3.0"}:
385
386
  return {}
386
387
  return data
387
388
 
388
389
 
390
+ def schema_v2_report(plan_path: Path) -> dict[str, Any]:
391
+ """기존 호출자를 위한 구조화 리포트 판독 별칭."""
392
+ return structured_report(plan_path)
393
+
394
+
389
395
  def _planning_section(markdown_path: Path) -> dict[str, Any]:
390
396
  """The report's `implementationPlanning` block, `{}` for v1."""
391
- planning = schema_v2_report(markdown_path).get("implementationPlanning")
397
+ planning = structured_report(markdown_path).get("implementationPlanning")
392
398
  return planning if isinstance(planning, dict) else {}
393
399
 
394
400
 
@@ -412,7 +418,7 @@ def _stage_narratives(value: Any) -> dict[int, dict[str, Any]]:
412
418
 
413
419
  def load_planning_detail(markdown_path: Path) -> PlanningDetail:
414
420
  """Read one report's narrative rows; empty for a schema-v1 report."""
415
- report = schema_v2_report(markdown_path)
421
+ report = structured_report(markdown_path)
416
422
  planning = report.get("implementationPlanning")
417
423
  if not isinstance(planning, dict) or not planning:
418
424
  return PlanningDetail({}, {})
@@ -456,9 +462,34 @@ def stage_map_records(stages: Iterable[StageMapStage]) -> list[dict[str, Any]]:
456
462
  ]
457
463
 
458
464
 
465
+ def _latest_plan_report(task_root: Path) -> Path | None:
466
+ reports_dir = RunRef.from_task_root(
467
+ task_root, "implementation-planning"
468
+ ).reports_dir
469
+ reports = list_implementation_planning_reports(reports_dir)
470
+ return reports[0] if reports else None
471
+
472
+
473
+ def _snapshot_for(source: Path | None) -> StageMapSnapshot:
474
+ if source is None:
475
+ return StageMapSnapshot("missing", "", [])
476
+ resolved = source.resolve()
477
+ return StageMapSnapshot(
478
+ "ready",
479
+ str(resolved),
480
+ stage_map_records(parse_stage_map_file(resolved)),
481
+ )
482
+
483
+
459
484
  def load_task_stage_map(
460
485
  task_root: Path, manifest: dict[str, Any],
461
486
  ) -> StageMapSnapshot:
487
+ """완료된 stage 를 무엇으로 지었는지 답하는 Stage Map.
488
+
489
+ carry 사이드카가 가리키는 계획을 우선한다 — 그게 실행이 실제로 따른
490
+ 문서이기 때문이다. "어떤 stage 번호가 이미 쓰였나" 는 다른 질문이며
491
+ `load_latest_plan_stage_map` 이 답한다.
492
+ """
462
493
  carried = _unique_carry_source_paths(task_root)
463
494
  if len(carried) > 1:
464
495
  paths = tuple(str(path) for path in carried)
@@ -467,22 +498,19 @@ def load_task_stage_map(
467
498
  "implementation carries reference different source plans",
468
499
  conflicting_paths=paths,
469
500
  )
470
- if carried:
471
- source = carried[0]
472
- else:
473
- reports_dir = RunRef.from_task_root(
474
- task_root, "implementation-planning"
475
- ).reports_dir
476
- reports = list_implementation_planning_reports(reports_dir)
477
- source = reports[0] if reports else None
478
- if source is None:
479
- return StageMapSnapshot("missing", "", [])
480
- resolved = source.resolve()
481
- return StageMapSnapshot(
482
- "ready",
483
- str(resolved),
484
- stage_map_records(parse_stage_map_file(resolved)),
485
- )
501
+ source = carried[0] if carried else _latest_plan_report(task_root)
502
+ return _snapshot_for(source)
503
+
504
+
505
+ def load_latest_plan_stage_map(task_root: Path) -> StageMapSnapshot:
506
+ """이 task 의 최신 계획 리포트가 선언한 Stage Map.
507
+
508
+ 번호 점유의 판정 기준이다. ADR-0015 는 stage 번호를 append-only 로 두고
509
+ 새 stage 를 `max+1` 로 붙이라고 정하는데, 그 `max` 는 최신 계획에서만
510
+ 나온다 — 완료된 stage 를 지을 때 쓴 계획 뒤에 stage 를 덧붙인 계획이
511
+ 있으면 그 번호들도 이미 점유된 상태다.
512
+ """
513
+ return _snapshot_for(_latest_plan_report(task_root))
486
514
 
487
515
 
488
516
  def _unique_carry_source_paths(task_root: Path) -> list[Path]:
@@ -495,10 +523,30 @@ def _unique_carry_source_paths(task_root: Path) -> list[Path]:
495
523
  continue
496
524
  value = carry.get("sourcePlanPath") if isinstance(carry, dict) else None
497
525
  if isinstance(value, str) and value:
498
- paths.add(_resolve_plan_path(task_root, value).resolve())
526
+ # carry 의 이 값은 executor 워커가 손으로 적는 자유 문자열이고,
527
+ # `--approved-plan` 이 레코드 전용으로 조여지기 전에 준비된 run 은
528
+ # 읽기본(`.md`) 경로를 남겼다. v2 리포트의 `.md` 에는 Stage Map
529
+ # 섹션이 없으므로 그대로 읽으면 원장이 통째로 사라진다. 같은 legacy
530
+ # 포인터 정규화를 run-index·타임라인 소비자는 이미 하고 있다.
531
+ # 읽는 쪽에서만 낮춘다 — 동결된 carry 사이드카는 고치지 않는다.
532
+ paths.add(_carry_source_path(task_root, value))
499
533
  return sorted(paths, key=str)
500
534
 
501
535
 
536
+ def _carry_source_path(task_root: Path, value: str) -> Path:
537
+ """carry 가 가리키는 계획의 읽을 경로.
538
+
539
+ 레코드 형제가 실재할 때만 읽기본(`.md`)을 레코드로 낮춘다. schema-v1 계획은
540
+ 레코드가 아예 없고 마크다운이 유일한 정본이므로, 무조건 낮추면 v1 태스크의
541
+ Stage Map 을 없는 파일에서 찾게 된다.
542
+ """
543
+ resolved = _resolve_plan_path(task_root, value).resolve()
544
+ if not is_full_reading_copy_path(resolved):
545
+ return resolved
546
+ record = final_report_data_path(resolved)
547
+ return record if record.is_file() else resolved
548
+
549
+
502
550
  def _resolve_plan_path(task_root: Path, value: str) -> Path:
503
551
  path = Path(value)
504
552
  if path.is_absolute():
@@ -81,7 +81,7 @@ def _add_dispatch_parser(sub) -> None:
81
81
  parser.add_argument("--workers", default="")
82
82
  parser.add_argument("--jobs-file", default="")
83
83
  parser.add_argument("--dry-run", action="store_true")
84
- parser.add_argument("--idle-timeout-seconds", type=int, default=600)
84
+ parser.add_argument("--idle-timeout-seconds", type=int, default=None)
85
85
  parser.add_argument("--dispatch-kind", default="initial")
86
86
 
87
87
 
@@ -76,7 +76,7 @@ def _parser() -> argparse.ArgumentParser:
76
76
  parser.add_argument("--okstra-bin", default="")
77
77
  parser.add_argument("--workers", default="")
78
78
  parser.add_argument("--dry-run", action="store_true")
79
- parser.add_argument("--idle-timeout-seconds", default="600")
79
+ parser.add_argument("--idle-timeout-seconds", default="")
80
80
  parser.add_argument("--dispatch-kind", default="initial")
81
81
  parser.add_argument("--jobs-file", default="")
82
82
  return parser
@@ -86,7 +86,10 @@ def _parse_workers(raw: str) -> list[str]:
86
86
  return [item.strip() for item in raw.split(",") if item.strip()]
87
87
 
88
88
 
89
- def _parse_idle_timeout(raw: str) -> int:
89
+ def _parse_idle_timeout(raw: str) -> int | None:
90
+ """명시된 예산, 또는 역할이 정하게 두는 ``None``."""
91
+ if not raw:
92
+ return None
90
93
  try:
91
94
  value = int(raw)
92
95
  except ValueError as exc:
@@ -91,6 +91,10 @@ def report_writer_prompt_body(
91
91
  report_language: str,
92
92
  ) -> list[str]:
93
93
  """Render the provider-neutral body for the report-writer audience."""
94
+ if str(manifest.get("reportContractVersion") or "") == "3.0":
95
+ return _report_writer_v3_body(
96
+ manifest, active_context, team_state, model, report_language
97
+ )
94
98
  return [
95
99
  f"**Model:** Report writer worker, {model}",
96
100
  f"**Report Language:** {report_language}",
@@ -124,6 +128,37 @@ def report_writer_prompt_body(
124
128
  ]
125
129
 
126
130
 
131
+ def _report_writer_v3_body(
132
+ manifest: Mapping[str, Any],
133
+ active_context: Mapping[str, Any],
134
+ team_state: Mapping[str, Any],
135
+ model: str,
136
+ report_language: str,
137
+ ) -> list[str]:
138
+ return [
139
+ f"**Model:** Report writer worker, {model}",
140
+ f"**Report Language:** {report_language}",
141
+ "",
142
+ "# Report Writer Worker Dispatch",
143
+ "",
144
+ "## Task",
145
+ f"- Task key: `{_require_string(manifest, 'taskKey')}`",
146
+ f"- Task type: `{_require_string(manifest, 'taskType')}`",
147
+ "",
148
+ "## Inputs",
149
+ *report_writer_input_lines(manifest, active_context, team_state),
150
+ "",
151
+ mcp_pointer_line(),
152
+ "",
153
+ "## Output Contract",
154
+ "You are the author of the report narrative Markdown at Result Path.",
155
+ "Write the worker-result pointer at Worker Result Path.",
156
+ "Maintain the separate audit sidecar at Audit sidecar path.",
157
+ "Do not write the final report record or another owner's input ledger.",
158
+ "Do not return the report inline.",
159
+ ]
160
+
161
+
127
162
  def report_writer_input_lines(
128
163
  manifest: Mapping[str, Any],
129
164
  active_context: Mapping[str, Any],
@@ -120,9 +120,7 @@ def resolve_prompt_plan(
120
120
  # that are *supposed* to differ and fail every critic-enabled run.
121
121
  analysis_equality_group = None if dispatch_kind == "critic" else "analysis-core"
122
122
  analysis_duty: AgentAudience = (
123
- "acceptance-critic"
124
- if dispatch_kind == "critic" and task_type == "final-verification"
125
- else "scope-critic"
123
+ critic_duty_for_task_type(task_type)
126
124
  if dispatch_kind == "critic"
127
125
  else "acceptance-verifier"
128
126
  if task_type == "final-verification"
@@ -232,6 +230,36 @@ def _worker_facing_headers(
232
230
  return (*ERRORS_PATH_HEADERS, *required_headers)
233
231
 
234
232
 
233
+ # 하나의 run 이 실제로 디스패치할 수 있는 critic 은 한 종류다. 판정은 task type
234
+ # 만의 함수이므로 여기 한 번만 적고, 로스터 등록과 프롬프트 정체성 검사가 같은
235
+ # 값을 읽는다 — 로스터가 실행될 수 없는 critic 행을 하나 더 들고 있으면 provider
236
+ # 로만 만든 role 라벨이 겹쳐 validate-run 의 중복 역할 검사에 걸린다.
237
+ CRITIC_DUTY_BY_ASSIGNMENT_SEGMENT: dict[str, AgentAudience] = {
238
+ "scope": "scope-critic",
239
+ "acceptance": "acceptance-critic",
240
+ }
241
+
242
+
243
+ def critic_duty_for_task_type(task_type: str) -> AgentAudience:
244
+ """이 task type 의 critic 직무."""
245
+ return (
246
+ "acceptance-critic"
247
+ if task_type == "final-verification"
248
+ else "scope-critic"
249
+ )
250
+
251
+
252
+ def critic_assignment_ref(task_type: str) -> str:
253
+ """이 task type 이 등록할 유일한 critic 배정 참조."""
254
+ duty = critic_duty_for_task_type(task_type)
255
+ segment = next(
256
+ key
257
+ for key, value in CRITIC_DUTY_BY_ASSIGNMENT_SEGMENT.items()
258
+ if value == duty
259
+ )
260
+ return f"critic/{segment}"
261
+
262
+
235
263
  def _executor_worker_id(manifest: Mapping[str, Any]) -> str | None:
236
264
  team_contract = manifest.get("teamContract")
237
265
  if not isinstance(team_contract, Mapping):