okstra 0.200.1 → 0.201.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 (95) hide show
  1. package/README.md +4 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/cli.md +14 -3
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +7 -3
  8. package/runtime/bin/okstra-spawn-followups.py +2 -2
  9. package/runtime/prompts/duties/technical-verification-worker.md +44 -0
  10. package/runtime/prompts/launch.template.md +7 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
  12. package/runtime/prompts/lead/plan-body-verification.md +3 -1
  13. package/runtime/prompts/lead/report-writer.md +8 -2
  14. package/runtime/prompts/lead/team-contract.md +6 -0
  15. package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
  16. package/runtime/prompts/profiles/final-verification.md +5 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +6 -0
  18. package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  20. package/runtime/prompts/profiles/technical-verification.md +53 -0
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
  23. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
  24. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
  25. package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
  29. package/runtime/python/okstra_ctl/conformance.py +2 -20
  30. package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
  31. package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
  32. package/runtime/python/okstra_ctl/domain/provider.py +0 -1
  33. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  34. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
  35. package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
  36. package/runtime/python/okstra_ctl/implementation_options.py +58 -45
  37. package/runtime/python/okstra_ctl/model_pool.py +2 -5
  38. package/runtime/python/okstra_ctl/next_phase.py +3 -0
  39. package/runtime/python/okstra_ctl/plan_items.py +15 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
  41. package/runtime/python/okstra_ctl/qa_commands.py +30 -0
  42. package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
  43. package/runtime/python/okstra_ctl/render.py +3 -0
  44. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  45. package/runtime/python/okstra_ctl/report_assembly.py +8 -2
  46. package/runtime/python/okstra_ctl/report_contract.py +3 -0
  47. package/runtime/python/okstra_ctl/report_corrections.py +209 -93
  48. package/runtime/python/okstra_ctl/report_finalize.py +25 -8
  49. package/runtime/python/okstra_ctl/report_html/router.py +2 -0
  50. package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
  51. package/runtime/python/okstra_ctl/report_projections.py +4 -3
  52. package/runtime/python/okstra_ctl/report_synthesis_packet.py +178 -26
  53. package/runtime/python/okstra_ctl/run.py +82 -0
  54. package/runtime/python/okstra_ctl/team.py +4 -1
  55. package/runtime/python/okstra_ctl/technical_verification.py +195 -0
  56. package/runtime/python/okstra_ctl/usage_identity.py +54 -0
  57. package/runtime/python/okstra_ctl/usage_report.py +22 -8
  58. package/runtime/python/okstra_ctl/verification_target.py +74 -0
  59. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
  60. package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
  61. package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
  62. package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
  63. package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
  64. package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
  65. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
  66. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  67. package/runtime/python/okstra_ctl/wizard/render.py +8 -55
  68. package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
  69. package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
  70. package/runtime/python/okstra_ctl/wizard/state.py +13 -6
  71. package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
  72. package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  74. package/runtime/python/okstra_ctl/workflow.py +8 -0
  75. package/runtime/python/okstra_ctl/write_policy.py +23 -0
  76. package/runtime/python/okstra_token_usage/blocks.py +50 -1
  77. package/runtime/python/okstra_token_usage/claude.py +42 -21
  78. package/runtime/python/okstra_token_usage/codex.py +17 -0
  79. package/runtime/python/okstra_token_usage/collect.py +299 -162
  80. package/runtime/python/okstra_token_usage/cursor.py +2 -3
  81. package/runtime/python/okstra_token_usage/report.py +35 -30
  82. package/runtime/python/okstra_token_usage/task_totals.py +3 -12
  83. package/runtime/schemas/final-report-v2.0.schema.json +298 -7
  84. package/runtime/schemas/final-report-v3.0.schema.json +298 -7
  85. package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
  86. package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
  87. package/runtime/skills/okstra-run/SKILL.md +10 -2
  88. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  89. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  90. package/runtime/templates/reports/html/i18n/en.json +11 -0
  91. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  92. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  93. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  94. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  95. package/runtime/validators/validate-run.py +9 -4
@@ -6,10 +6,13 @@ import json
6
6
  import os
7
7
  import re
8
8
  import tempfile
9
+ from collections import Counter
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
11
12
  from typing import Any, Mapping
12
13
 
14
+ from okstra_token_usage.blocks import accounting_workers
15
+
13
16
  from .analysis_packet import reference_source_extracts
14
17
  from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
15
18
  from .implementation_options import (
@@ -161,6 +164,17 @@ class ReportSynthesisPacket:
161
164
  return ["carried-stages-unchanged"] if self.incremental_decision else []
162
165
 
163
166
  def _task_instructions(self) -> list[str]:
167
+ if self.task_type == "technical-verification":
168
+ return [
169
+ "Copy sourceReport, sourceDataSha256, scope and every fact identity "
170
+ "from the frozen technical input embedded in the Analysis packet.",
171
+ "Inspect the run-local plans and command logs cited by settled worker "
172
+ "results. These cited experiment artifacts are part of this task's "
173
+ "evidence scope. Record one check per fact and retain limitations.",
174
+ "Route only to implementation-option-selection with one matching "
175
+ "phase-continuation. Evidence does not approve adoption or replace "
176
+ "independent candidate feasibility votes.",
177
+ ]
164
178
  if self.task_type == "implementation-planning":
165
179
  return self._planning_requirement_instructions()
166
180
  if self.task_type != "implementation-option-selection":
@@ -203,6 +217,16 @@ class ReportSynthesisPacket:
203
217
  "supplied one feasibility vote, at least "
204
218
  f"{MIN_FEASIBLE_VOTES} votes are `feasible`, and both `safetyBlockers` "
205
219
  "and `unresolvedFeasibilityFacts` are empty.",
220
+ ("Classify each `unresolvedFeasibilityFacts` entry with `resolutionKind`: "
221
+ "`user-decision` requires nonempty `clarificationRefs` linking that fact "
222
+ "to actual C-NNN records, including answered records; `technical-verification` "
223
+ "requires no user question. Preserve the fact, whyItMatters, and evidence. "
224
+ "Historical unclassified facts remain readable but require classification "
225
+ "before blocked report reassembly. Saving does not lift blocked routing."),
226
+ ("After a linked decision is answered, preserve its disposition and selected "
227
+ "value. Update humanSummary.actions, verdictCard.nextStep, and selection "
228
+ "guidance to name remaining technical verification separately from unanswered "
229
+ "user decisions; never reopen an answered question to permit saving."),
206
230
  "Each `feasibilityVotes` row states that analyser's own verdict, "
207
231
  "rationale, and strongest counterevidence as its result gives them. "
208
232
  "Two non-`uncertain` votes with identical rationale and "
@@ -311,9 +335,14 @@ class ReportSynthesisPacket:
311
335
  lines.extend(f"- {text}" for text in self._schema_instructions())
312
336
  lines.extend(f"- {text}" for text in self._task_instructions())
313
337
  lines.extend(f"- {text}" for text in self._carry_instructions())
314
- for source in self.sources:
315
- lines.extend(_source_markdown(source, self.task_type, self.sources))
316
- return "\n".join(lines).rstrip() + "\n"
338
+ sections = [("Authoring Contract", "\n".join(lines[1:]).rstrip() + "\n")]
339
+ sections.extend(
340
+ (source.label, "\n".join(_source_markdown(
341
+ source, self.task_type, self.sources,
342
+ )).rstrip() + "\n")
343
+ for source in self.sources
344
+ )
345
+ return _indexed_markdown(lines[0], sections)
317
346
 
318
347
 
319
348
  _SOURCE_FIELDS = (
@@ -329,12 +358,156 @@ _SOURCE_FIELDS = (
329
358
  )
330
359
 
331
360
 
361
+ _READ_CHUNK_BYTES = 16_000
362
+ _SHARED_TEXT_MIN_BYTES = 256
363
+ _ANALYSER_OPERATION_SECTIONS = frozenset({
364
+ "Required workers", "Optional workers", "Team contract",
365
+ "Worker interaction model", "Tooling — read-only MCP availability",
366
+ "Run-scoped worker-resource lifecycle",
367
+ })
368
+
369
+
370
+ def _indexed_markdown(title: str, sections: list[tuple[str, str]]) -> str:
371
+ """긴 한 줄과 다중 바이트 문자도 손실 없이 나눌 읽기 위치를 싣는다."""
372
+ ranges: list[tuple[str, int, int]] = []
373
+ offset = 0
374
+ for label, content in sections:
375
+ raw = content.encode("utf-8")
376
+ start = 0
377
+ while start < len(raw):
378
+ end = min(start + _READ_CHUNK_BYTES, len(raw))
379
+ while end < len(raw) and raw[end] & 0xC0 == 0x80:
380
+ end -= 1
381
+ ranges.append((label, offset + start, end - start))
382
+ start = end
383
+ offset += len(raw)
384
+ prefix = f"{title}\n\n## Read Index\n\n"
385
+ prefix += (
386
+ "Read every range once, in order. Offsets are zero-based UTF-8 bytes "
387
+ "in this Markdown file; each range is at most 16000 bytes. Use "
388
+ "`dd if='<packet-path>' bs=1 skip=<start> count=<bytes> 2>/dev/null`. "
389
+ "Continue with the next range after a successful read; do not reread "
390
+ "the beginning. Source paths identify originals; the sibling "
391
+ "`.data.json` retains every frozen source verbatim.\n\n"
392
+ "| Source | Start byte | Bytes |\n|---|---:|---:|\n"
393
+ )
394
+ prefix_size = 0
395
+ while True:
396
+ index = prefix + "".join(
397
+ f"| {label.replace('|', '/')} | {start + prefix_size} | {size} |\n"
398
+ for label, start, size in ranges
399
+ ) + "\n<!-- END READ INDEX -->\n"
400
+ size = len(index.encode("utf-8"))
401
+ if size == prefix_size:
402
+ break
403
+ prefix_size = size
404
+ return index + "".join(content for _, content in sections)
405
+
406
+
407
+ def _writer_profile_view(content: str) -> str:
408
+ """저작 규칙과 미지 절을 유지하고 분석자 배치 절만 참조로 바꾼다."""
409
+ lines: list[str] = []
410
+ skipping = False
411
+ fence = ""
412
+ for number, line in enumerate(content.splitlines(), 1):
413
+ marker = re.match(r"^\s*(`{3,}|~{3,})", line)
414
+ if marker:
415
+ token = marker.group(1)
416
+ if not fence:
417
+ fence = token
418
+ elif token[0] == fence[0] and len(token) >= len(fence):
419
+ fence = ""
420
+ if not fence and line.startswith("- "):
421
+ label = line[2:].split(":", 1)[0].split(" (", 1)[0].strip("*")
422
+ skipping = label in _ANALYSER_OPERATION_SECTIONS
423
+ if skipping:
424
+ lines.append(f"- {label}: analyser operation; original source line {number}.")
425
+ elif not fence and line.startswith("#"):
426
+ skipping = False
427
+ if not skipping:
428
+ lines.append(line)
429
+ return "\n".join(lines)
430
+
431
+
432
+ def _replace_shared_text(value: Any, references: Mapping[str, str]) -> Any:
433
+ if isinstance(value, str):
434
+ return {"$sharedText": references[value]} if value in references else value
435
+ if isinstance(value, list):
436
+ return [_replace_shared_text(item, references) for item in value]
437
+ if isinstance(value, dict):
438
+ return {key: _replace_shared_text(item, references) for key, item in value.items()}
439
+ return value
440
+
441
+
442
+ def _convergence_view(content: str) -> str:
443
+ """반복된 근거 문자열만 공유하고 모든 회차·표·미지 필드는 유지한다."""
444
+ try:
445
+ state = json.loads(content)
446
+ except json.JSONDecodeError:
447
+ return content
448
+ counts: Counter[str] = Counter()
449
+ pending = [state]
450
+ while pending:
451
+ value = pending.pop()
452
+ if isinstance(value, dict):
453
+ # 원자료의 같은 모양과 새 참조를 혼동하지 않는다.
454
+ if "$sharedText" in value:
455
+ return content
456
+ pending.extend(value.values())
457
+ elif isinstance(value, list):
458
+ pending.extend(value)
459
+ elif isinstance(value, str) and len(value.encode("utf-8")) >= _SHARED_TEXT_MIN_BYTES:
460
+ counts[value] += 1
461
+ repeated = sorted(text for text, count in counts.items() if count > 1)
462
+ if not repeated:
463
+ return content
464
+ references = {text: f"T{index}" for index, text in enumerate(repeated, 1)}
465
+ view = {
466
+ "sharedText": {ref: text for text, ref in references.items()},
467
+ "state": _replace_shared_text(state, references),
468
+ }
469
+ return json.dumps(view, ensure_ascii=False, indent=1)
470
+
471
+
472
+ def _writer_schema_view(content: str, task_type: str) -> tuple[str, list[str]]:
473
+ try:
474
+ schema = json.loads(content)
475
+ except json.JSONDecodeError:
476
+ return content, ["- View: schema is not JSON; the original text is retained."]
477
+ if not isinstance(schema, dict) or not schema.get("properties"):
478
+ return content, []
479
+ owned = writer_owned_schema(schema)
480
+ # 부분 서사 검증에서 빠지는 루트 조건도 최종 조립의 저작 지침에는 남긴다.
481
+ owned["allOf"] = schema.get("allOf", [])
482
+ owned["required"] = [
483
+ key for key in schema.get("required", []) if key in owned["properties"]
484
+ ]
485
+ excerpt = build_schema_excerpt(owned, task_type)
486
+ return json.dumps(excerpt, ensure_ascii=False, indent=1), [
487
+ "- View: writer-owned schema excerpt. Task-specific requirements are listed above.",
488
+ ]
489
+
490
+
332
491
  def _source_markdown(
333
492
  source: ReportSynthesisSource, task_type: str,
334
493
  sources: tuple[ReportSynthesisSource, ...],
335
494
  ) -> list[str]:
336
495
  content = source.content.rstrip("\n")
337
496
  view = []
497
+ if source.label == "Analysis profile":
498
+ content = _writer_profile_view(content)
499
+ if source.label == "Analysis material":
500
+ brief = next((item.content.strip() for item in sources if item.label == "Task brief"), "")
501
+ if len(brief.encode("utf-8")) >= _SHARED_TEXT_MIN_BYTES:
502
+ content = content.replace(brief, 'Read the complete "Source: Task brief" above.')
503
+ if source.label == "Convergence state":
504
+ content = _convergence_view(content)
505
+ if content != source.content.rstrip("\n"):
506
+ view = [
507
+ "- View: lossless shared-text references. Replace each "
508
+ '`{"$sharedText":"Tn"}` with `sharedText.Tn` when reading `state`. '
509
+ "Every finding, vote, round, condition, dissent, and unknown field remains.",
510
+ ]
338
511
  if source.label == "Analysis packet":
339
512
  texts = {item.label: item.content for item in sources}
340
513
  content = reference_source_extracts(
@@ -350,28 +523,7 @@ def _source_markdown(
350
523
  "the original line index is omitted.",
351
524
  ]
352
525
  if source.label == "Final report schema":
353
- try:
354
- schema = json.loads(source.content)
355
- except json.JSONDecodeError:
356
- schema = {}
357
- view = ["- View: schema is not JSON; the original text is retained."]
358
- if isinstance(schema, dict) and schema.get("properties"):
359
- # 원본은 JSON 보관본에 남긴다. 저작용 읽기에는 검증기와
360
- # 같은 소유권 투영을 쓰고, 도달하지 않는 정의만 기존 발췌기로 뺀다.
361
- owned = writer_owned_schema(schema)
362
- # 부분 서사 검증에서 빠지는 루트 조건도 작성 지침에는 남긴다.
363
- # 판정별 금지 필드와 후속 작업 조건은 최종 조립에 적용된다.
364
- owned["allOf"] = schema.get("allOf", [])
365
- owned["required"] = [
366
- key for key in schema.get("required", [])
367
- if key in owned["properties"]
368
- ]
369
- excerpt = build_schema_excerpt(owned, task_type)
370
- content = json.dumps(excerpt, ensure_ascii=False, indent=2)
371
- view = [
372
- "- View: writer-owned schema excerpt. "
373
- "Task-specific requirements are listed above.",
374
- ]
526
+ content, view = _writer_schema_view(content, task_type)
375
527
  longest = max((len(run) for run in re.findall(r"`+", content)), default=0)
376
528
  fence = "`" * max(3, longest + 1)
377
529
  return [
@@ -622,7 +774,7 @@ def _attempt_result_specs(
622
774
 
623
775
 
624
776
  def _accounting_snapshot(team_state: Mapping[str, Any]) -> dict[str, Any]:
625
- workers = team_state.get("workers")
777
+ workers = accounting_workers(team_state)
626
778
  worker_usage = []
627
779
  if isinstance(workers, list):
628
780
  worker_usage = [
@@ -14,6 +14,11 @@ state passing, and are read once at the start.
14
14
  """
15
15
  from __future__ import annotations
16
16
 
17
+ from .technical_verification import (
18
+ resolve_technical_verification_input,
19
+ write_technical_verification_input,
20
+ )
21
+
17
22
  import hashlib
18
23
  from argparse import (
19
24
  Action,
@@ -529,6 +534,7 @@ class PrepareInputs:
529
534
  brief_path: Path # absolute, already resolved
530
535
  analysis_target: str = ""
531
536
  evidence_inputs_raw: str = ""
537
+ user_authorization_json: str = ""
532
538
  directive: str = ""
533
539
  workers_override: str = ""
534
540
  role_counts_raw: tuple[str, ...] = ()
@@ -1175,6 +1181,7 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
1175
1181
  ),
1176
1182
  ("--critic", inp.critic or legacy_fallbacks.get("CRITIC_CHOICE", "")),
1177
1183
  ("--related-tasks", inp.related_tasks_raw),
1184
+ ("--user-authorization-json", inp.user_authorization_json),
1178
1185
  ("--work-category", inp.work_category),
1179
1186
  ]
1180
1187
  argv: list[str] = []
@@ -3819,6 +3826,18 @@ def _write_instruction_set_sources(
3819
3826
  json.loads(ctx.get("RELATED_TASKS_JSON", "[]")),
3820
3827
  ),
3821
3828
  )
3829
+ if inp.task_type == "technical-verification":
3830
+ verification_path = write_technical_verification_input(
3831
+ json.loads(ctx["TECHNICAL_VERIFICATION_INPUT_JSON"]),
3832
+ Path(ctx["RUN_DIR"]), ctx["RUN_MANIFESTS_SEQ"],
3833
+ )
3834
+ packet += (
3835
+ "\n## Technical Verification Input\n\n"
3836
+ f"- Frozen input: `{verification_path}`\n"
3837
+ f"- Experiment copies only: `{ctx['RUN_DIR']}/experiments/{ctx['RUN_MANIFESTS_SEQ']}/<worker-id>/`\n"
3838
+ "- Read the input before planning experiments; copy each fact identity verbatim into results.\n"
3839
+ + "\n```json\n" + ctx["TECHNICAL_VERIFICATION_INPUT_JSON"] + "\n```\n"
3840
+ )
3822
3841
  if inp.task_type in ANALYSIS_TASK_TYPES:
3823
3842
  packet += (
3824
3843
  "\n## Analysis Evidence\n\n"
@@ -4662,8 +4681,54 @@ def _title_lead_pane(inp: PrepareInputs) -> None:
4662
4681
  print(f"okstra: lead pane title not applied — {reason}", file=sys.stderr)
4663
4682
 
4664
4683
 
4684
+ def _prepare_user_authorization(inp: PrepareInputs) -> dict:
4685
+ """승인 기록은 해당 실행 입력과 일치할 때만 전달하며 호스트 권한을 부여하지 않는다."""
4686
+ if not inp.user_authorization_json:
4687
+ return {}
4688
+ try:
4689
+ record = json.loads(inp.user_authorization_json)
4690
+ except (TypeError, ValueError) as exc:
4691
+ raise PrepareError("invalid user authorization JSON") from exc
4692
+ if not isinstance(record, dict) or (
4693
+ record.get("schemaVersion") != "1.0"
4694
+ or record.get("source") != "wizard-confirmation"
4695
+ or record.get("response") != "proceed"
4696
+ or not isinstance(record.get("prompt"), str)
4697
+ or not record["prompt"].strip()
4698
+ or not isinstance(record.get("scope"), dict)
4699
+ ):
4700
+ raise PrepareError("user authorization requires the displayed prompt, proceed response, and scope")
4701
+ if inp.task_type == "implementation":
4702
+ stages = record.get("stageScope") or [record["scope"].get("stage")]
4703
+ if not isinstance(stages, list) or inp.stage not in stages:
4704
+ raise PrepareError("user authorization scope does not include --stage; confirm the changed scope")
4705
+ expected = {
4706
+ "project-root": str(inp.project_root), "project-id": inp.project_id,
4707
+ "task-group": inp.task_group, "task-id": inp.task_id, "task-type": inp.task_type,
4708
+ "role-model": list(inp.role_models_raw), "role-count": list(inp.role_counts_raw),
4709
+ "directive": inp.directive, "related-tasks": inp.related_tasks_raw,
4710
+ "analysis-target": inp.analysis_target, "evidence-inputs": inp.evidence_inputs_raw,
4711
+ }
4712
+ for key, value in expected.items():
4713
+ if record["scope"].get(key) != value:
4714
+ raise PrepareError(f"user authorization scope does not match --{key}; confirm the changed scope")
4715
+ for key, path in (
4716
+ ("task-brief", inp.brief_path if inp.task_type != "release-handoff" else ""),
4717
+ ("approved-plan", inp.approved_plan_path),
4718
+ ("clarification-response", inp.clarification_response_path),
4719
+ ("selected-direction", inp.selected_direction_path),
4720
+ ):
4721
+ recorded = record["scope"].get(key, "")
4722
+ if not isinstance(recorded, str) or bool(recorded) != bool(path) or (
4723
+ recorded and (inp.project_root / recorded).resolve() != (inp.project_root / path).resolve()
4724
+ ):
4725
+ raise PrepareError(f"user authorization scope does not match --{key}; confirm the changed scope")
4726
+ return record
4727
+
4728
+
4665
4729
  def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4666
4730
  """Produce a complete okstra task bundle on disk. See module docstring."""
4731
+ user_authorization = _prepare_user_authorization(inp)
4667
4732
  workspace_root = Path(inp.workspace_root)
4668
4733
  project_root = Path(inp.project_root)
4669
4734
  lead_runtime = _normalize_lead_runtime(inp.lead_runtime)
@@ -4709,6 +4774,19 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4709
4774
  )
4710
4775
  _validate_group_context_preflight(project_root, inp.task_group)
4711
4776
  selected_direction = _resolve_planning_direction(inp)
4777
+ technical_input = None
4778
+ from .paths import task_dir
4779
+ if inp.task_type == "technical-verification":
4780
+ if not inp.clarification_response_path:
4781
+ raise PrepareError("technical-verification requires --clarification-response pointing to this task's option-selection record")
4782
+ try:
4783
+ technical_input = resolve_technical_verification_input(
4784
+ Path(inp.clarification_response_path), project_root,
4785
+ task_dir(project_root, inp.task_group, inp.task_id),
4786
+ f"{inp.project_id}:{inp.task_group}:{inp.task_id}",
4787
+ )
4788
+ except ValueError as exc:
4789
+ raise PrepareError(str(exc)) from exc
4712
4790
  if inp.task_type == "implementation":
4713
4791
  ctx_stage_map = _prepare_implementation_approved_plan(inp)
4714
4792
 
@@ -4871,6 +4949,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4871
4949
  relative_to_project_root(Path(inp.clarification_response_path), project_root)
4872
4950
  if inp.clarification_response_path else ""
4873
4951
  )
4952
+ ctx["TECHNICAL_VERIFICATION_INPUT_JSON"] = json.dumps(technical_input) if technical_input else ""
4874
4953
  selected_direction_relative = (
4875
4954
  relative_to_project_root(Path(inp.selected_direction_path), project_root)
4876
4955
  if inp.selected_direction_path else ""
@@ -4888,6 +4967,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4888
4967
  "RECOMMENDED_ANALYSERS": selected_reviewers,
4889
4968
  "HOST_RUNTIME": lead_runtime,
4890
4969
  "LEAD_RUNTIME": lead_runtime,
4970
+ "USER_AUTHORIZATION_JSON": json.dumps(user_authorization, ensure_ascii=False),
4891
4971
  "LEAD_RUNTIME_REQUEST": lead_runtime_request,
4892
4972
  "RUNTIME_RESOLUTION_JSON": runtime_resolution_json or "{}",
4893
4973
  "PR_TEMPLATE_PATH": pr_template_path_str,
@@ -5107,6 +5187,7 @@ def build_prepare_argument_parser():
5107
5187
  ),
5108
5188
  )
5109
5189
  p.add_argument("--directive", default="")
5190
+ p.add_argument("--user-authorization-json", default="")
5110
5191
  p.add_argument("--analysis-target", default="", dest="analysis_target")
5111
5192
  p.add_argument("--evidence-inputs", default="", dest="evidence_inputs_raw")
5112
5193
  p.add_argument(
@@ -5392,6 +5473,7 @@ def main(argv: list[str]) -> int:
5392
5473
  analysis_target=args.analysis_target,
5393
5474
  evidence_inputs_raw=args.evidence_inputs_raw,
5394
5475
  directive=args.directive,
5476
+ user_authorization_json=args.user_authorization_json,
5395
5477
  workers_override=args.workers_override,
5396
5478
  role_counts_raw=tuple(args.role_counts_raw),
5397
5479
  role_models_raw=tuple(args.role_models_raw),
@@ -125,7 +125,10 @@ def _add_await_parser(sub) -> None:
125
125
  _add_run_args(parser)
126
126
  parser.add_argument("--poll-interval-seconds", type=int, default=5)
127
127
  parser.add_argument("--timeout-seconds", type=int, default=None)
128
- parser.add_argument("--heartbeat-seconds", type=int, default=30)
128
+ parser.add_argument(
129
+ "--heartbeat-seconds", type=int, default=30,
130
+ help="minimum interval between changed pending-attempt summaries; 0 disables output",
131
+ )
129
132
  parser.add_argument("--json", action="store_true")
130
133
 
131
134
 
@@ -0,0 +1,195 @@
1
+ """미확정 사실의 시험 입력과 관측 결과를 연결하며 도입 승인은 만들지 않는다."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+ from typing import Any, TypedDict
10
+
11
+ from .clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
12
+ from .final_report_schema import load_schema_for_data, validate
13
+ from .implementation_direction import validate_task_artifact_path
14
+ from .json_boundary import load_owned_object_snapshot, serialize_owned_object
15
+
16
+
17
+ class VerificationFact(TypedDict):
18
+ id: str
19
+ candidateId: str
20
+ factIndex: int
21
+ fact: str
22
+ whyItMatters: str
23
+ evidence: list[str]
24
+
25
+
26
+ class VerificationInput(TypedDict):
27
+ sourceReport: str
28
+ sourceDataSha256: str
29
+ scope: str
30
+ facts: list[VerificationFact]
31
+
32
+
33
+ class TechnicalVerificationError(ValueError):
34
+ """시험 입력 또는 관측 결과가 원본 사실과 일치하지 않는다."""
35
+
36
+
37
+ def technical_verification_facts(data: Mapping[str, Any]) -> list[VerificationFact]:
38
+ """안전 차단이 없는 후보의 명시적인 기술 검증 사실만 추출한다."""
39
+ blockers = progress_blocking_ids(
40
+ data.get("clarificationItems"), USER_INPUT_BLOCKS, report_data=data
41
+ )
42
+ if blockers:
43
+ raise TechnicalVerificationError(
44
+ "unresolved user decisions: " + ", ".join(blockers)
45
+ )
46
+ selection = data.get("implementationOptionSelection") or {}
47
+ facts: list[VerificationFact] = []
48
+ for candidate in selection.get("candidateAudit") or []:
49
+ if candidate.get("safetyBlockers"):
50
+ continue
51
+ for index, fact in enumerate(candidate.get("unresolvedFeasibilityFacts") or []):
52
+ if fact.get("resolutionKind") != "technical-verification":
53
+ continue
54
+ facts.append(
55
+ {
56
+ "id": f"TV-{len(facts) + 1:03d}",
57
+ "candidateId": candidate["id"],
58
+ "factIndex": index,
59
+ "fact": fact["fact"],
60
+ "whyItMatters": fact["whyItMatters"],
61
+ "evidence": fact["evidence"],
62
+ }
63
+ )
64
+ if not facts:
65
+ raise TechnicalVerificationError("no eligible technical-verification facts")
66
+ return facts
67
+
68
+
69
+ def resolve_technical_verification_input(
70
+ report: Path, project_root: Path, task_root: Path, task_key: str
71
+ ) -> VerificationInput:
72
+ """같은 작업의 후보 비교 기록을 시험 입력으로 고정한다."""
73
+ report = report if report.is_absolute() else project_root / report
74
+ validate_task_artifact_path(report, task_root, "technical verification source")
75
+ expected_parent = task_root / "runs/implementation-option-selection/reports"
76
+ if report.parent != expected_parent or not re.fullmatch(
77
+ r"final-report-implementation-option-selection-\d{3,}\.data\.json", report.name
78
+ ):
79
+ raise TechnicalVerificationError(
80
+ "source must be this task's option-selection record"
81
+ )
82
+ snapshot = load_owned_object_snapshot(
83
+ report, artifact="technical verification source"
84
+ )
85
+ data = snapshot.value
86
+ errors = validate(data, load_schema_for_data(data))
87
+ if errors:
88
+ raise TechnicalVerificationError("invalid source report: " + "; ".join(errors))
89
+ if data["header"]["taskKey"] != task_key:
90
+ raise TechnicalVerificationError(
91
+ "source taskKey does not match verification task"
92
+ )
93
+ return {
94
+ "sourceReport": report.relative_to(project_root).as_posix(),
95
+ "sourceDataSha256": hashlib.sha256(snapshot.raw_bytes).hexdigest(),
96
+ "scope": "technical-evidence-only",
97
+ "facts": technical_verification_facts(data),
98
+ }
99
+
100
+
101
+ def write_technical_verification_input(
102
+ payload: VerificationInput, run_root: Path, seq: str
103
+ ) -> Path:
104
+ """실행 순번별 입력을 저장해 후속 실행이 이전 시험 범위를 덮지 않게 한다."""
105
+ path = run_root / "state" / f"technical-verification-input-{seq}.json"
106
+ serialized = serialize_owned_object(path, payload, artifact="technical verification input")
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ with path.open("x", encoding="utf-8") as output:
109
+ output.write(serialized)
110
+ return path
111
+
112
+
113
+ def _check_execution_evidence(
114
+ check: Mapping[str, Any], project_root: Path, run_root: Path, seq: str
115
+ ) -> list[str]:
116
+ """관측 판정은 실행 기록과 실행별 시험 디렉터리를 요구한다."""
117
+ errors = []
118
+ commands = check.get("commands") or []
119
+ if check.get("status") != "not-run" and not commands:
120
+ errors.append(f"{check['id']}: an observed result requires command evidence")
121
+ for command in commands:
122
+ try:
123
+ log = project_root / command["logPath"]
124
+ experiment_root = run_root / "experiments" / seq
125
+ validate_task_artifact_path(log, experiment_root, "verification log")
126
+ if not log.read_text(encoding="utf-8").strip():
127
+ errors.append(f"{check['id']}: verification log is empty")
128
+ cwd = project_root / command["cwd"]
129
+ cwd.resolve(strict=True).relative_to(experiment_root.resolve(strict=True))
130
+ relative = cwd.relative_to(experiment_root)
131
+ current = run_root
132
+ for part in ("experiments", seq, *relative.parts):
133
+ current = current / part
134
+ if current.is_symlink():
135
+ raise TechnicalVerificationError(
136
+ "experiment cwd contains a symlink"
137
+ )
138
+ if not cwd.is_dir() or cwd.is_symlink():
139
+ errors.append(f"{check['id']}: experiment cwd must be a directory")
140
+ except (OSError, ValueError) as exc:
141
+ errors.append(f"{check['id']}: invalid execution evidence: {exc}")
142
+ if check.get("status") == "supported" and command.get("exitCode") != 0:
143
+ errors.append(f"{check['id']}: a supported result has a failed command")
144
+ return errors
145
+
146
+
147
+ def validate_technical_verification_report(
148
+ data: Mapping[str, Any], report_path: Path, project_root: Path
149
+ ) -> list[str]:
150
+ """발행·최종 검증에서 같은 사실 집합과 실제 시험 산출물을 대조한다."""
151
+ if (data.get("header") or {}).get("taskType") != "technical-verification":
152
+ return []
153
+ match = re.fullmatch(
154
+ r"final-report-technical-verification-(\d{3,})\.data\.json", report_path.name
155
+ )
156
+ if match is None:
157
+ return ["technical verification report must use its canonical record path"]
158
+ run_root = report_path.parent.parent
159
+ path = run_root / "state" / f"technical-verification-input-{match[1]}.json"
160
+ try:
161
+ validate_task_artifact_path(path, run_root, "technical verification input")
162
+ source = load_owned_object_snapshot(
163
+ path, artifact="technical verification input"
164
+ ).value
165
+ except (OSError, ValueError) as exc:
166
+ return [str(exc)]
167
+ block = data.get("technicalVerification") or {}
168
+ errors = []
169
+ for key in ("sourceReport", "sourceDataSha256", "scope"):
170
+ if block.get(key) != source.get(key):
171
+ errors.append(f"technicalVerification.{key} must match the run input")
172
+ expected = {fact["id"]: fact for fact in source["facts"]}
173
+ checks = block.get("checks") or []
174
+ ids = [check.get("id") for check in checks]
175
+ if len(ids) != len(set(ids)) or set(ids) != set(expected):
176
+ errors.append(
177
+ "technicalVerification.checks must cover each input fact exactly once"
178
+ )
179
+ for check in checks:
180
+ fact = expected.get(check.get("id"))
181
+ if fact is None:
182
+ continue
183
+ for key in ("candidateId", "factIndex", "fact"):
184
+ if check.get(key) != fact[key]:
185
+ errors.append(f"{check['id']}: {key} must preserve the input fact")
186
+ errors.extend(
187
+ _check_execution_evidence(check, project_root, run_root, match[1])
188
+ )
189
+ if (block.get("routing") or {}).get(
190
+ "nextTaskType"
191
+ ) != "implementation-option-selection":
192
+ errors.append(
193
+ "technical verification returns only to implementation-option-selection"
194
+ )
195
+ return errors