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
@@ -32,6 +32,10 @@ class MutationSnapshot:
32
32
  scratch_digests: Mapping[str, str]
33
33
  git_projection: Mapping[str, Any]
34
34
  digest: str
35
+ # 오케스트레이터가 쓰는 경로. 파일 하나를 싣거나 디렉터리를 실어 그 트리
36
+ # 전체를 덮을 수 있다 — 한 배치가 무엇을 쓸지 파일 단위로 미리 셀 수 없기
37
+ # 때문이다(다른 라운드의 재시도 프롬프트, 로그, 상태 사이드카가 그 창에
38
+ # 들어온다).
35
39
  orchestrator_paths: tuple[str, ...] = ()
36
40
 
37
41
  def to_payload(self) -> dict[str, Any]:
@@ -445,7 +449,7 @@ def _source_changes(
445
449
  snapshot.artifact_root == snapshot.root
446
450
  and any(_is_within(path, item) for item in artifacts)
447
451
  )
448
- and path not in orchestrator
452
+ and not any(_is_within(path, item) for item in orchestrator)
449
453
  and not _excluded(Path(path), generated)
450
454
  }
451
455
 
@@ -464,7 +468,7 @@ def _artifact_policy_failures(
464
468
  unauthorized = {
465
469
  path for path in changed
466
470
  if not any(_is_within(path, item) for item in allowed)
467
- and path not in orchestrator
471
+ and not any(_is_within(path, item) for item in orchestrator)
468
472
  }
469
473
  if snapshot.artifact_root == snapshot.root:
470
474
  return []
@@ -1,11 +1,11 @@
1
- """Mini JSON Schema validator for the final-report data.json.
1
+ """Mini JSON Schema validator for versioned final-report data.json records.
2
2
 
3
3
  This is a deliberately narrow JSON Schema implementation that supports
4
4
  exactly the keywords used by ``schemas/final-report-v2.0.schema.json``:
5
5
 
6
6
  type, required, properties, additionalProperties, enum, const, pattern,
7
7
  minLength, minItems, maxItems, uniqueItems, items, minimum, maximum, $ref ($defs),
8
- oneOf, allOf, if/then/else, contains, not
8
+ oneOf, allOf, if/then/else, contains, minContains, maxContains, not
9
9
 
10
10
  We do NOT depend on the ``jsonschema`` PyPI package because its
11
11
  dependency tree (``referencing``, ``rpds-py``) includes a Rust C
@@ -13,8 +13,8 @@ extension which we would have to vendor or ship pre-built per platform.
13
13
  The ~250 lines below cover everything the final-report schema needs;
14
14
  strict spec compliance is not a goal — strict validation of OUR schema is.
15
15
 
16
- Error messages include the JSON pointer of the failing field so the
17
- report-writer can fix the exact data.json cell.
16
+ Error messages include the JSON pointer of the failing field so report
17
+ assembly can return the defect to the input owner.
18
18
  """
19
19
  from __future__ import annotations
20
20
 
@@ -23,9 +23,6 @@ import re
23
23
  from pathlib import Path
24
24
  from typing import Any
25
25
 
26
- from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION
27
-
28
-
29
26
  class SchemaError(ValueError):
30
27
  """Raised when a schema itself is malformed (e.g. a $ref points
31
28
  nowhere). Distinct from a data validation failure.
@@ -204,10 +201,20 @@ class _Validator:
204
201
  for i, value in enumerate(instance):
205
202
  self.validate(value, items, path + (i,))
206
203
  contains = schema.get("contains")
207
- if contains is not None and not any(
208
- self._matches(value, contains) for value in instance
209
- ):
210
- self._err(path, f"array does not contain any item matching {self._summarise(contains)}")
204
+ if contains is not None:
205
+ matches = sum(self._matches(value, contains) for value in instance)
206
+ minimum = schema.get("minContains", 1)
207
+ maximum = schema.get("maxContains")
208
+ if matches < minimum:
209
+ self._err(
210
+ path,
211
+ f"array contains {matches} matching item(s), below minContains {minimum}",
212
+ )
213
+ if maximum is not None and matches > maximum:
214
+ self._err(
215
+ path,
216
+ f"array contains {matches} matching item(s), above maxContains {maximum}",
217
+ )
211
218
 
212
219
  def _validate_conditional(self, instance: Any, schema: dict, path: tuple[str | int, ...]) -> None:
213
220
  if_schema = schema.get("if")
@@ -263,6 +270,7 @@ def validate(data: Any, schema: dict) -> list[str]:
263
270
 
264
271
  SCHEMA_FILENAMES = {
265
272
  "2.0": "final-report-v2.0.schema.json",
273
+ "3.0": "final-report-v3.0.schema.json",
266
274
  }
267
275
 
268
276
 
@@ -295,10 +303,11 @@ def load_schema_for_data(data: dict, start: Path | None = None) -> dict:
295
303
 
296
304
 
297
305
  def load_schema(schema_path: Path | None = None) -> dict:
298
- """Load the final-report schema. If ``schema_path`` is None, locate
299
- ``schemas/final-report-v2.0.schema.json`` relative to this file's
300
- repo root.
306
+ """Load the compatibility schema used by historical direct callers.
307
+
308
+ New write paths select contract 3.0 explicitly. Readers select from the
309
+ record's ``schemaVersion`` through :func:`load_schema_for_data`.
301
310
  """
302
311
  if schema_path is not None:
303
312
  return json.loads(Path(schema_path).read_text(encoding="utf-8"))
304
- return load_schema_version(CURRENT_REPORT_SCHEMA_VERSION)
313
+ return load_schema_version("2.0")
@@ -7,9 +7,15 @@ import json
7
7
  import re
8
8
  import sys
9
9
  from pathlib import Path
10
+ from typing import Any, Mapping
11
+
12
+ from .convergence_store import write_json_atomic
13
+ from .final_report_schema import load_schema_version
14
+ from .report_narrative import parse_narrative, writer_owned_data
10
15
 
11
16
 
12
17
  PREP_VERDICT_RE = re.compile(r"^P-Prep-S([1-9][0-9]*)-")
18
+ STEP_VERDICT_RE = re.compile(r"^P-Step-([1-9][0-9]*)\.")
13
19
 
14
20
 
15
21
  class CarryError(Exception):
@@ -290,6 +296,78 @@ def merge_carried_forward(
290
296
  return cur
291
297
 
292
298
 
299
+ def _plan_item_stage(item: Mapping[str, Any]) -> int | None:
300
+ item_id = str(item.get("id") or "")
301
+ for pattern in (PREP_VERDICT_RE, STEP_VERDICT_RE):
302
+ match = pattern.match(item_id)
303
+ if match:
304
+ return int(match.group(1))
305
+ return None
306
+
307
+
308
+ def _writer_stage_rows(data: Mapping[str, Any], *, snapshot: str) -> dict[int, dict]:
309
+ planning = _planning(dict(writer_owned_data(data)))
310
+ return _stage_rows(planning, snapshot=snapshot)
311
+
312
+
313
+ def merge_v3_plan_state(
314
+ prev: dict[str, Any],
315
+ narrative: dict[str, Any],
316
+ state: dict[str, Any],
317
+ *,
318
+ prev_seq: str,
319
+ carry_stages: set[int],
320
+ reverify_stages: set[int],
321
+ ) -> dict[str, Any]:
322
+ """이전 판정 중 이월 스테이지 소유분만 v3 수렴 상태에 복사한다."""
323
+ prev_stages = _writer_stage_rows(prev, snapshot="prior")
324
+ cur_stages = _stage_rows(_planning(narrative), snapshot="current")
325
+ _validate_stage_sets(prev_stages, cur_stages, carry_stages, reverify_stages)
326
+ for stage in sorted(carry_stages):
327
+ current = cur_stages.get(stage)
328
+ if current is None:
329
+ raise CarryError(f"carry stage {stage} is missing from current narrative")
330
+ if _canonical(prev_stages[stage]) != _canonical(current):
331
+ raise CarryError(f"carry stage {stage} changed in current narrative")
332
+
333
+ prev_items = _indexed_rows(
334
+ _planning(prev).get("planBodyVerification", {}).get("planItems", []),
335
+ key="id",
336
+ label="prior planItems",
337
+ )
338
+ state_items = _indexed_rows(
339
+ state.get("planBodyVerification", {}).get("planItems", []),
340
+ key="id",
341
+ label="current state planItems",
342
+ )
343
+ for item_id, current in state_items.items():
344
+ stage = _plan_item_stage(current)
345
+ if stage not in carry_stages:
346
+ continue
347
+ previous = prev_items.get(item_id)
348
+ if previous is None:
349
+ raise CarryError(f"carry-owned plan item {item_id} is missing from prior report")
350
+ for field in ("subject", "sourceSection"):
351
+ if current.get(field) != previous.get(field):
352
+ raise CarryError(f"carry-owned plan item {item_id} changed {field}")
353
+ carried = copy.deepcopy(previous)
354
+ carried["carriedForwardFromSeq"] = str(prev_seq)
355
+ state_items[item_id] = carried
356
+
357
+ prior_carried_ids = {
358
+ item_id
359
+ for item_id, item in prev_items.items()
360
+ if _plan_item_stage(item) in carry_stages
361
+ }
362
+ missing = sorted(prior_carried_ids - set(state_items))
363
+ if missing:
364
+ raise CarryError(f"current state omitted carry-owned plan items: {missing}")
365
+ state["planBodyVerification"]["planItems"] = [
366
+ state_items[item_id] for item_id in sorted(state_items)
367
+ ]
368
+ return state
369
+
370
+
293
371
  def _parse_stage_csv(value: str | None, *, option: str) -> set[int] | None:
294
372
  if value is None:
295
373
  return None
@@ -317,33 +395,67 @@ def _load_data(path: str, *, option: str) -> dict:
317
395
  def main(argv: list[str]) -> int:
318
396
  ap = argparse.ArgumentParser(prog="okstra incremental-carry")
319
397
  ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
320
- ap.add_argument("--cur-data", required=True, help="current incremental re-run data.json")
398
+ current = ap.add_mutually_exclusive_group(required=True)
399
+ current.add_argument("--cur-data", help="historical v2 current data.json")
400
+ current.add_argument("--cur-narrative", help="contract v3 current narrative Markdown")
401
+ ap.add_argument("--state", help="contract v3 convergence-owned plan state")
321
402
  ap.add_argument("--prev-seq", required=True, help="prior run seq, tagged onto carried items")
322
403
  ap.add_argument("--carry-stages", default=None, help="comma-separated carried stage numbers")
323
404
  ap.add_argument("--reverify-stages", default=None, help="comma-separated reverified stage numbers")
324
- ap.add_argument("--out", required=True, help="path to write the merged data.json")
405
+ ap.add_argument("--out", help="historical v2 merged data.json path")
406
+ ap.add_argument("--out-state", help="contract v3 merged plan state path")
325
407
  args = ap.parse_args(argv)
326
408
 
327
409
  try:
328
410
  prev = _load_data(args.prev_data, option="--prev-data")
329
- cur = _load_data(args.cur_data, option="--cur-data")
330
- merged = merge_carried_forward(
331
- prev,
332
- cur,
333
- prev_seq=args.prev_seq,
334
- carry_stages=_parse_stage_csv(args.carry_stages, option="--carry-stages"),
335
- reverify_stages=_parse_stage_csv(
336
- args.reverify_stages, option="--reverify-stages",
337
- ),
411
+ carry_stages = _parse_stage_csv(args.carry_stages, option="--carry-stages")
412
+ reverify_stages = _parse_stage_csv(
413
+ args.reverify_stages, option="--reverify-stages",
338
414
  )
339
- except CarryError as exc:
415
+ if args.cur_narrative:
416
+ if not args.state or not args.out_state or args.out:
417
+ raise CarryError(
418
+ "--cur-narrative requires --state and --out-state, and forbids --out"
419
+ )
420
+ if carry_stages is None or reverify_stages is None:
421
+ raise CarryError("contract v3 carry requires both stage sets")
422
+ try:
423
+ narrative = parse_narrative(
424
+ Path(args.cur_narrative).read_text(encoding="utf-8"),
425
+ load_schema_version("3.0"),
426
+ )
427
+ except (OSError, UnicodeError, ValueError) as exc:
428
+ raise CarryError(f"--cur-narrative could not be read: {exc}") from exc
429
+ state = _load_data(args.state, option="--state")
430
+ merged = merge_v3_plan_state(
431
+ prev,
432
+ narrative,
433
+ state,
434
+ prev_seq=args.prev_seq,
435
+ carry_stages=carry_stages,
436
+ reverify_stages=reverify_stages,
437
+ )
438
+ output = Path(args.out_state)
439
+ else:
440
+ if not args.out or args.out_state or args.state:
441
+ raise CarryError(
442
+ "--cur-data requires --out, and forbids --state and --out-state"
443
+ )
444
+ cur = _load_data(args.cur_data, option="--cur-data")
445
+ merged = merge_carried_forward(
446
+ prev,
447
+ cur,
448
+ prev_seq=args.prev_seq,
449
+ carry_stages=carry_stages,
450
+ reverify_stages=reverify_stages,
451
+ )
452
+ output = Path(args.out)
453
+ except (CarryError, KeyError, TypeError) as exc:
340
454
  print(f"carry refused: {exc}", file=sys.stderr)
341
455
  return 1
342
456
 
343
457
  try:
344
- Path(args.out).write_text(
345
- json.dumps(merged, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
346
- )
458
+ write_json_atomic(output, merged)
347
459
  except OSError as exc:
348
460
  print(f"carry refused: --out could not be written: {exc}", file=sys.stderr)
349
461
  return 1
@@ -354,7 +466,7 @@ def main(argv: list[str]) -> int:
354
466
  .get("planItems", [])
355
467
  if item.get("carriedForwardFromSeq") == str(args.prev_seq)
356
468
  )
357
- print(f"merged {carried} carried-forward plan item(s) from seq {args.prev_seq} -> {args.out}")
469
+ print(f"merged {carried} carried-forward plan item(s) from seq {args.prev_seq} -> {output}")
358
470
  return 0
359
471
 
360
472
 
@@ -207,7 +207,10 @@ def stages_for_clarification(data: dict, clarification_id: str) -> set[int]:
207
207
  )
208
208
  stages = _stages_blocked_on(planning.get("requirementCoverage"), clarification_id)
209
209
  for item in plan_items if isinstance(plan_items, list) else []:
210
- if isinstance(item, dict) and item.get("clarificationId") == clarification_id:
210
+ if isinstance(item, dict) and (
211
+ item.get("clarificationId") == clarification_id
212
+ or clarification_id in (item.get("clarificationRefs") or [])
213
+ ):
211
214
  stages |= _plan_item_stages(item)
212
215
  return stages
213
216
 
@@ -439,6 +439,10 @@ def _run_files(
439
439
  "codex_worker_result": worker_results / f"codex-worker{suffixes['worker_results']}.md",
440
440
  "antigravity_worker_result": worker_results / f"antigravity-worker{suffixes['worker_results']}.md",
441
441
  "report_writer_worker_result": worker_results / f"report-writer-worker{suffixes['worker_results']}.md",
442
+ "report_writer_narrative": worker_results / f"report-writer-narrative{suffixes['worker_results']}.md",
443
+ "approval_decisions": run_state / f"approval-decisions-{task_type_segment}-{sequences['state']}.json",
444
+ "design_preparation": run_state / f"design-preparation-{task_type_segment}-{sequences['state']}.json",
445
+ "plan_body_verification": run_state / f"plan-body-verification-{task_type_segment}-{sequences['state']}.json",
442
446
  "run_errors_log": run_logs / f"errors-{task_type_segment}-{sequences['state']}.jsonl",
443
447
  "claude_worker_errors_sidecar": worker_results / f"claude-worker-errors{suffixes['worker_results']}.json",
444
448
  "codex_worker_errors_sidecar": worker_results / f"codex-worker-errors{suffixes['worker_results']}.json",
@@ -570,6 +574,10 @@ def _absolute_worker_fields(paths: Mapping[str, Any]) -> dict[str, str]:
570
574
  "CODEX_WORKER_RESULT_FILE": str(paths["codex_worker_result"]),
571
575
  "ANTIGRAVITY_WORKER_RESULT_FILE": str(paths["antigravity_worker_result"]),
572
576
  "REPORT_WRITER_WORKER_RESULT_FILE": str(paths["report_writer_worker_result"]),
577
+ "REPORT_WRITER_NARRATIVE_FILE": str(paths["report_writer_narrative"]),
578
+ "APPROVAL_DECISIONS_PATH": str(paths["approval_decisions"]),
579
+ "DESIGN_PREPARATION_PATH": str(paths["design_preparation"]),
580
+ "PLAN_BODY_VERIFICATION_STATE_PATH": str(paths["plan_body_verification"]),
573
581
  "CLAUDE_WORKER_ERRORS_SIDECAR_PATH": str(paths["claude_worker_errors_sidecar"]),
574
582
  "CODEX_WORKER_ERRORS_SIDECAR_PATH": str(paths["codex_worker_errors_sidecar"]),
575
583
  "ANTIGRAVITY_WORKER_ERRORS_SIDECAR_PATH": str(paths["antigravity_worker_errors_sidecar"]),
@@ -670,6 +678,10 @@ def _relative_file_fields(project_root: Path, paths: Mapping[str, Any]) -> dict[
670
678
  "CODEX_WORKER_RESULT_RELATIVE_PATH": _rel(project_root, paths["codex_worker_result"]),
671
679
  "ANTIGRAVITY_WORKER_RESULT_RELATIVE_PATH": _rel(project_root, paths["antigravity_worker_result"]),
672
680
  "REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH": _rel(project_root, paths["report_writer_worker_result"]),
681
+ "REPORT_WRITER_NARRATIVE_RELATIVE_PATH": _rel(project_root, paths["report_writer_narrative"]),
682
+ "APPROVAL_DECISIONS_RELATIVE_PATH": _rel(project_root, paths["approval_decisions"]),
683
+ "DESIGN_PREPARATION_RELATIVE_PATH": _rel(project_root, paths["design_preparation"]),
684
+ "PLAN_BODY_VERIFICATION_STATE_RELATIVE_PATH": _rel(project_root, paths["plan_body_verification"]),
673
685
  "RUN_ERRORS_LOG_RELATIVE_PATH": _rel(project_root, paths["run_errors_log"]),
674
686
  "CLAUDE_WORKER_ERRORS_SIDECAR_RELATIVE_PATH": _rel(project_root, paths["claude_worker_errors_sidecar"]),
675
687
  "CODEX_WORKER_ERRORS_SIDECAR_RELATIVE_PATH": _rel(project_root, paths["codex_worker_errors_sidecar"]),
@@ -562,6 +562,10 @@ def compute_run_paths(
562
562
  codex_worker_result = worker_results / f"codex-worker{suffixes['worker_results']}.md"
563
563
  antigravity_worker_result = worker_results / f"antigravity-worker{suffixes['worker_results']}.md"
564
564
  report_writer_worker_result = worker_results / f"report-writer-worker{suffixes['worker_results']}.md"
565
+ report_writer_narrative = worker_results / f"report-writer-narrative{suffixes['worker_results']}.md"
566
+ approval_decisions = run_state / f"approval-decisions-{task_type_segment}-{seqs['state']}.json"
567
+ design_preparation = run_state / f"design-preparation-{task_type_segment}-{seqs['state']}.json"
568
+ plan_body_verification = run_state / f"plan-body-verification-{task_type_segment}-{seqs['state']}.json"
565
569
 
566
570
  run_errors_log = run_logs / f"errors-{task_type_segment}-{seqs['state']}.jsonl"
567
571
  runtime_home = okstra_home()
@@ -667,6 +671,10 @@ def compute_run_paths(
667
671
  "CODEX_WORKER_RESULT_FILE": str(codex_worker_result),
668
672
  "ANTIGRAVITY_WORKER_RESULT_FILE": str(antigravity_worker_result),
669
673
  "REPORT_WRITER_WORKER_RESULT_FILE": str(report_writer_worker_result),
674
+ "REPORT_WRITER_NARRATIVE_FILE": str(report_writer_narrative),
675
+ "APPROVAL_DECISIONS_PATH": str(approval_decisions),
676
+ "DESIGN_PREPARATION_PATH": str(design_preparation),
677
+ "PLAN_BODY_VERIFICATION_STATE_PATH": str(plan_body_verification),
670
678
  "RUN_ERRORS_LOG_PATH": str(run_errors_log),
671
679
  "CLAUDE_WORKER_ERRORS_SIDECAR_PATH": str(claude_worker_errors_sidecar),
672
680
  "CODEX_WORKER_ERRORS_SIDECAR_PATH": str(codex_worker_errors_sidecar),
@@ -737,6 +745,10 @@ def compute_run_paths(
737
745
  ("CODEX_WORKER_RESULT_RELATIVE_PATH", codex_worker_result),
738
746
  ("ANTIGRAVITY_WORKER_RESULT_RELATIVE_PATH", antigravity_worker_result),
739
747
  ("REPORT_WRITER_WORKER_RESULT_RELATIVE_PATH", report_writer_worker_result),
748
+ ("REPORT_WRITER_NARRATIVE_RELATIVE_PATH", report_writer_narrative),
749
+ ("APPROVAL_DECISIONS_RELATIVE_PATH", approval_decisions),
750
+ ("DESIGN_PREPARATION_RELATIVE_PATH", design_preparation),
751
+ ("PLAN_BODY_VERIFICATION_STATE_RELATIVE_PATH", plan_body_verification),
740
752
  ("RUN_ERRORS_LOG_RELATIVE_PATH", run_errors_log),
741
753
  ("CLAUDE_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", claude_worker_errors_sidecar),
742
754
  ("CODEX_WORKER_ERRORS_SIDECAR_RELATIVE_PATH", codex_worker_errors_sidecar),
@@ -16,6 +16,8 @@ from typing import Any
16
16
  from .convergence_store import write_json_atomic
17
17
  from .plan_derivations import extract_tokens, find_derivations
18
18
  from .plan_items import PlanItemContractError, extract_plan_items
19
+ from .final_report_schema import load_schema_version
20
+ from .report_narrative import parse_narrative
19
21
  from .user_response import parse_user_response_entries
20
22
  from .verdict_blocks import (
21
23
  PLAN_ITEM_VERDICTS,
@@ -57,10 +59,10 @@ def _parser() -> argparse.ArgumentParser:
57
59
  )
58
60
  commands = parser.add_subparsers(dest="command", required=True)
59
61
  extract = commands.add_parser("extract")
60
- extract.add_argument("--data", type=Path, required=True)
62
+ _add_plan_source(extract)
61
63
  extract.add_argument("--output", type=Path, required=True)
62
64
  validate = commands.add_parser("validate")
63
- validate.add_argument("--data", type=Path, required=True)
65
+ _add_plan_source(validate)
64
66
  validate.add_argument("--items", type=Path, required=True)
65
67
  collect = commands.add_parser(
66
68
  "collect-verdicts",
@@ -84,12 +86,15 @@ def _parser() -> argparse.ArgumentParser:
84
86
  "seed",
85
87
  help="create the planBodyVerification.planItems[] rows a round lands in",
86
88
  )
87
- seed.add_argument("--data", type=Path, required=True)
89
+ _add_plan_source(seed)
90
+ seed.add_argument("--state", type=Path)
88
91
  apply_verdicts = commands.add_parser(
89
92
  "apply-verdicts",
90
- help="overwrite planBodyVerification.planItems[].verdicts in data.json",
93
+ help="overwrite planBodyVerification.planItems[].verdicts in owned state",
91
94
  )
92
- apply_verdicts.add_argument("--data", type=Path, required=True)
95
+ target = apply_verdicts.add_mutually_exclusive_group(required=True)
96
+ target.add_argument("--data", type=Path)
97
+ target.add_argument("--state", type=Path)
93
98
  apply_verdicts.add_argument("--verdicts", type=Path, required=True)
94
99
  apply_verdicts.add_argument(
95
100
  "--round", type=int, required=True, dest="round_number",
@@ -99,14 +104,35 @@ def _parser() -> argparse.ArgumentParser:
99
104
  return parser
100
105
 
101
106
 
107
+ def _add_plan_source(parser: argparse.ArgumentParser) -> None:
108
+ source = parser.add_mutually_exclusive_group(required=True)
109
+ source.add_argument("--data", type=Path)
110
+ source.add_argument("--narrative", type=Path)
111
+
112
+
113
+ def _plan_source(args: argparse.Namespace) -> dict[str, Any]:
114
+ if args.data is not None:
115
+ return _load_json_object(args.data)
116
+ try:
117
+ markdown = args.narrative.read_text(encoding="utf-8")
118
+ except (OSError, UnicodeError) as exc:
119
+ raise PlanItemContractError(
120
+ f"cannot read report narrative {args.narrative}: {exc}"
121
+ ) from exc
122
+ try:
123
+ return parse_narrative(markdown, load_schema_version("3.0"))
124
+ except ValueError as exc:
125
+ raise PlanItemContractError(f"invalid report narrative: {exc}") from exc
126
+
127
+
102
128
  def _extract(args: argparse.Namespace) -> dict[str, Any]:
103
- envelope = _envelope(_load_json_object(args.data))
129
+ envelope = _envelope(_plan_source(args))
104
130
  write_json_atomic(args.output, envelope)
105
131
  return {"ok": True, "operation": "extract", "path": str(args.output)}
106
132
 
107
133
 
108
134
  def _validate(args: argparse.Namespace) -> dict[str, Any]:
109
- expected = _envelope(_load_json_object(args.data))
135
+ expected = _envelope(_plan_source(args))
110
136
  actual = _load_json_object(args.items)
111
137
  if actual != expected:
112
138
  raise PlanItemContractError("plan items envelope does not match deterministic extraction")
@@ -229,6 +255,45 @@ def _plan_body_items(data: dict[str, Any], data_path: Path) -> list[dict[str, An
229
255
  return items
230
256
 
231
257
 
258
+ def _state_plan_body_items(
259
+ state: dict[str, Any], state_path: Path,
260
+ ) -> list[dict[str, Any]]:
261
+ if state.get("owner") != "convergence":
262
+ raise PlanItemContractError(
263
+ f"plan-body state owner must be convergence: {state_path}"
264
+ )
265
+ verification = state.get("planBodyVerification")
266
+ if not isinstance(verification, Mapping):
267
+ raise PlanItemContractError(
268
+ f"planBodyVerification must be an object: {state_path}"
269
+ )
270
+ items = verification.get("planItems")
271
+ if not isinstance(items, list):
272
+ raise PlanItemContractError(
273
+ f"planBodyVerification.planItems must be an array: {state_path}"
274
+ )
275
+ return items
276
+
277
+
278
+ def _new_v3_state() -> dict[str, Any]:
279
+ return {
280
+ "schemaVersion": "1.1",
281
+ "owner": "convergence",
282
+ "planItems": [],
283
+ "roundHistory": [],
284
+ "selfFixRoundsApplied": 0,
285
+ "planBodyVerification": {
286
+ "roundCount": 0,
287
+ "gateResult": "passed",
288
+ "gateBlockedBy": [],
289
+ "selfFixRoundsApplied": 0,
290
+ "selfFixStopReason": "not-attempted",
291
+ "planItems": [],
292
+ "dissentLog": [],
293
+ },
294
+ }
295
+
296
+
232
297
  def _derivations(args: argparse.Namespace) -> dict[str, Any]:
233
298
  """Candidate statements each answered clarification may have falsified.
234
299
 
@@ -283,9 +348,17 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
283
348
  already applied, `carriedForwardFromSeq`, `selfFixNote` — because a re-seed
284
349
  between rounds must not erase the round before it.
285
350
  """
286
- extracted = _envelope(_load_json_object(args.data))["items"]
287
- data = _load_json_object(args.data)
288
- recorded = _plan_body_items(data, args.data)
351
+ extracted = _envelope(_plan_source(args))["items"]
352
+ if args.state is not None:
353
+ data = _load_json_object(args.state) if args.state.is_file() else _new_v3_state()
354
+ recorded = _state_plan_body_items(data, args.state)
355
+ target = args.state
356
+ else:
357
+ if args.data is None:
358
+ raise PlanItemContractError("--narrative requires --state")
359
+ data = _load_json_object(args.data)
360
+ recorded = _plan_body_items(data, args.data)
361
+ target = args.data
289
362
  known = {
290
363
  item.get("id")
291
364
  for item in recorded
@@ -306,18 +379,38 @@ def _seed(args: argparse.Namespace) -> dict[str, Any]:
306
379
  if item["id"] not in known
307
380
  ]
308
381
  recorded.extend(added)
309
- write_json_atomic(args.data, data)
382
+ if args.state is not None:
383
+ audit_rows = data.get("planItems")
384
+ if not isinstance(audit_rows, list):
385
+ raise PlanItemContractError("state planItems must be an array")
386
+ audited = {
387
+ item.get("id") for item in audit_rows if isinstance(item, Mapping)
388
+ }
389
+ audit_rows.extend(
390
+ {
391
+ "id": item["id"],
392
+ "subject": item["subject"],
393
+ "sourceSection": item["sourceSection"],
394
+ "ticketId": item.get("ticketId") or "unknown",
395
+ "rounds": [],
396
+ "clarificationId": None,
397
+ }
398
+ for item in extracted
399
+ if item["id"] not in audited
400
+ )
401
+ write_json_atomic(target, data)
310
402
  return {
311
403
  "ok": True,
312
404
  "operation": "seed",
313
- "path": str(args.data),
405
+ "path": str(target),
314
406
  "seeded": len(added),
315
407
  "existing": len(known),
316
408
  }
317
409
 
318
410
 
319
411
  def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
320
- data = _load_json_object(args.data)
412
+ target = args.state or args.data
413
+ data = _load_json_object(target)
321
414
  incoming = _load_json_object(args.verdicts).get("planItems")
322
415
  if not isinstance(incoming, list):
323
416
  raise PlanItemContractError("verdicts envelope has no `planItems` array")
@@ -326,7 +419,11 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
326
419
  for item in incoming
327
420
  if isinstance(item, Mapping) and isinstance(item.get("id"), str)
328
421
  }
329
- recorded = _plan_body_items(data, args.data)
422
+ recorded = (
423
+ _state_plan_body_items(data, target)
424
+ if args.state is not None
425
+ else _plan_body_items(data, target)
426
+ )
330
427
  known = {item.get("id") for item in recorded if isinstance(item, Mapping)}
331
428
  missing = sorted(item_id for item_id in rows if item_id not in known)
332
429
  if missing:
@@ -351,8 +448,8 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
351
448
  item["verdicts"] = [
352
449
  {**row, "round": args.round_number} for row in rows[item["id"]]
353
450
  ]
354
- write_json_atomic(args.data, data)
355
- return {"ok": True, "operation": "apply-verdicts", "path": str(args.data)}
451
+ write_json_atomic(target, data)
452
+ return {"ok": True, "operation": "apply-verdicts", "path": str(target)}
356
453
 
357
454
 
358
455
  _HANDLERS = {
@@ -15,7 +15,8 @@ class WorkerDispatchRequest:
15
15
  workspace_root: Path | None = None
16
16
  okstra_bin: Path | None = None
17
17
  requested_workers: tuple[str, ...] = ()
18
- idle_timeout_seconds: int = 600
18
+ # 기본은 "정하지 않음" 이다. 그래야 엔트리포인트가 역할의 예산을 쓴다.
19
+ idle_timeout_seconds: int | None = None
19
20
  dispatch_kind: str = "initial"
20
21
  jobs_file: Path | None = None
21
22