okstra 0.200.0 → 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 (96) 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 +11 -5
  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 +181 -47
  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-synthesis-packet-v1.0.schema.json +1 -1
  87. package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
  88. package/runtime/skills/okstra-run/SKILL.md +10 -2
  89. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  90. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  91. package/runtime/templates/reports/html/i18n/en.json +11 -0
  92. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  93. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  94. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  95. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  96. package/runtime/validators/validate-run.py +9 -4
@@ -2,15 +2,17 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import copy
5
- import hashlib
6
5
  import json
7
6
  import os
8
7
  import re
9
8
  import tempfile
9
+ from collections import Counter
10
10
  from dataclasses import dataclass
11
11
  from pathlib import Path
12
12
  from typing import Any, Mapping
13
13
 
14
+ from okstra_token_usage.blocks import accounting_workers
15
+
14
16
  from .analysis_packet import reference_source_extracts
15
17
  from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
16
18
  from .implementation_options import (
@@ -54,7 +56,6 @@ class ReportSynthesisSource:
54
56
  label: str
55
57
  owner: str
56
58
  path: str
57
- digest: str
58
59
  content: str
59
60
 
60
61
  def to_dict(self) -> dict[str, str]:
@@ -62,7 +63,6 @@ class ReportSynthesisSource:
62
63
  "label": self.label,
63
64
  "owner": self.owner,
64
65
  "path": self.path,
65
- "digest": self.digest,
66
66
  "content": self.content,
67
67
  }
68
68
 
@@ -164,6 +164,17 @@ class ReportSynthesisPacket:
164
164
  return ["carried-stages-unchanged"] if self.incremental_decision else []
165
165
 
166
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
+ ]
167
178
  if self.task_type == "implementation-planning":
168
179
  return self._planning_requirement_instructions()
169
180
  if self.task_type != "implementation-option-selection":
@@ -206,6 +217,16 @@ class ReportSynthesisPacket:
206
217
  "supplied one feasibility vote, at least "
207
218
  f"{MIN_FEASIBLE_VOTES} votes are `feasible`, and both `safetyBlockers` "
208
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."),
209
230
  "Each `feasibilityVotes` row states that analyser's own verdict, "
210
231
  "rationale, and strongest counterevidence as its result gives them. "
211
232
  "Two non-`uncertain` votes with identical rationale and "
@@ -282,7 +303,6 @@ class ReportSynthesisPacket:
282
303
  "user response carry-in",
283
304
  ],
284
305
  "validationRules": [
285
- "source-digest-match",
286
306
  "writer-owned-fields-only",
287
307
  "all-defects-collected",
288
308
  *self._carry_validation_rules(),
@@ -307,7 +327,7 @@ class ReportSynthesisPacket:
307
327
  "- Runtime-owned values: session identifiers, token usage, estimated cost, "
308
328
  "user response carry-in",
309
329
  "- Accounting details stay in the JSON snapshot; assembly supplies them.",
310
- "- Validation: source digest match, writer-owned fields, all defects collected",
330
+ "- Validation: writer-owned fields, all defects collected",
311
331
  ]
312
332
  # 작성자가 실제로 읽는 것은 마크다운이다. JSON 정본에만 실으면 지시가
313
333
  # 도달하지 않는다.
@@ -315,9 +335,14 @@ class ReportSynthesisPacket:
315
335
  lines.extend(f"- {text}" for text in self._schema_instructions())
316
336
  lines.extend(f"- {text}" for text in self._task_instructions())
317
337
  lines.extend(f"- {text}" for text in self._carry_instructions())
318
- for source in self.sources:
319
- lines.extend(_source_markdown(source, self.task_type, self.sources))
320
- 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)
321
346
 
322
347
 
323
348
  _SOURCE_FIELDS = (
@@ -333,12 +358,156 @@ _SOURCE_FIELDS = (
333
358
  )
334
359
 
335
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
+
336
491
  def _source_markdown(
337
492
  source: ReportSynthesisSource, task_type: str,
338
493
  sources: tuple[ReportSynthesisSource, ...],
339
494
  ) -> list[str]:
340
495
  content = source.content.rstrip("\n")
341
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
+ ]
342
511
  if source.label == "Analysis packet":
343
512
  texts = {item.label: item.content for item in sources}
344
513
  content = reference_source_extracts(
@@ -351,32 +520,10 @@ def _source_markdown(
351
520
  if content != source.content.rstrip("\n"):
352
521
  view = [
353
522
  "- View: repeated extracts refer to frozen sources below; "
354
- "the original line index is omitted. The digest identifies "
355
- "the frozen original.",
523
+ "the original line index is omitted.",
356
524
  ]
357
525
  if source.label == "Final report schema":
358
- try:
359
- schema = json.loads(source.content)
360
- except json.JSONDecodeError:
361
- schema = {}
362
- view = ["- View: schema is not JSON; the original text is retained."]
363
- if isinstance(schema, dict) and schema.get("properties"):
364
- # 원본과 해시는 JSON 보관본에 남긴다. 저작용 읽기에는 검증기와
365
- # 같은 소유권 투영을 쓰고, 도달하지 않는 정의만 기존 발췌기로 뺀다.
366
- owned = writer_owned_schema(schema)
367
- # 부분 서사 검증에서 빠지는 루트 조건도 작성 지침에는 남긴다.
368
- # 판정별 금지 필드와 후속 작업 조건은 최종 조립에 적용된다.
369
- owned["allOf"] = schema.get("allOf", [])
370
- owned["required"] = [
371
- key for key in schema.get("required", [])
372
- if key in owned["properties"]
373
- ]
374
- excerpt = build_schema_excerpt(owned, task_type)
375
- content = json.dumps(excerpt, ensure_ascii=False, indent=2)
376
- view = [
377
- "- View: writer-owned schema excerpt; the digest identifies the "
378
- "frozen original. Task-specific requirements are listed above.",
379
- ]
526
+ content, view = _writer_schema_view(content, task_type)
380
527
  longest = max((len(run) for run in re.findall(r"`+", content)), default=0)
381
528
  fence = "`" * max(3, longest + 1)
382
529
  return [
@@ -385,7 +532,6 @@ def _source_markdown(
385
532
  "",
386
533
  f"- Owner: `{source.owner}`",
387
534
  f"- Path: `{source.path}`",
388
- f"- Digest: `{source.digest}`",
389
535
  *view,
390
536
  "",
391
537
  f"{fence}text",
@@ -628,7 +774,7 @@ def _attempt_result_specs(
628
774
 
629
775
 
630
776
  def _accounting_snapshot(team_state: Mapping[str, Any]) -> dict[str, Any]:
631
- workers = team_state.get("workers")
777
+ workers = accounting_workers(team_state)
632
778
  worker_usage = []
633
779
  if isinstance(workers, list):
634
780
  worker_usage = [
@@ -700,13 +846,11 @@ def _read_sources(
700
846
  except (OSError, UnicodeError) as exc:
701
847
  issues.append(ReportSynthesisPacketIssue(owner, label, path, str(exc)))
702
848
  continue
703
- digest = "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
704
849
  sources.append(
705
850
  ReportSynthesisSource(
706
851
  label=label,
707
852
  owner=owner,
708
853
  path=_relative(project_root, path),
709
- digest=digest,
710
854
  content=content,
711
855
  )
712
856
  )
@@ -929,18 +1073,8 @@ def verify_report_synthesis_packet_sources(
929
1073
  )
930
1074
  continue
931
1075
  try:
932
- content = path.read_text(encoding="utf-8")
1076
+ path.read_text(encoding="utf-8")
933
1077
  except (OSError, UnicodeError) as exc:
934
1078
  issues.append(ReportSynthesisPacketIssue(owner, label, path, str(exc)))
935
1079
  continue
936
- digest = "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
937
- if digest != source.get("digest"):
938
- issues.append(
939
- ReportSynthesisPacketIssue(
940
- owner,
941
- label,
942
- path,
943
- "source value changed after packet materialization",
944
- )
945
- )
946
1080
  return tuple(issues)
@@ -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