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.
- package/README.md +4 -2
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/cli.md +14 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +7 -3
- package/runtime/bin/okstra-spawn-followups.py +2 -2
- package/runtime/prompts/duties/technical-verification-worker.md +44 -0
- package/runtime/prompts/launch.template.md +7 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
- package/runtime/prompts/lead/plan-body-verification.md +3 -1
- package/runtime/prompts/lead/report-writer.md +8 -2
- package/runtime/prompts/lead/team-contract.md +6 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
- package/runtime/prompts/profiles/final-verification.md +5 -0
- package/runtime/prompts/profiles/forbidden-actions.json +6 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/technical-verification.md +53 -0
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
- package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
- package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
- package/runtime/python/okstra_ctl/conformance.py +2 -20
- package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
- package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
- package/runtime/python/okstra_ctl/domain/provider.py +0 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
- package/runtime/python/okstra_ctl/implementation_options.py +58 -45
- package/runtime/python/okstra_ctl/model_pool.py +2 -5
- package/runtime/python/okstra_ctl/next_phase.py +3 -0
- package/runtime/python/okstra_ctl/plan_items.py +15 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
- package/runtime/python/okstra_ctl/qa_commands.py +30 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
- package/runtime/python/okstra_ctl/render.py +3 -0
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_assembly.py +8 -2
- package/runtime/python/okstra_ctl/report_contract.py +3 -0
- package/runtime/python/okstra_ctl/report_corrections.py +209 -93
- package/runtime/python/okstra_ctl/report_finalize.py +25 -8
- package/runtime/python/okstra_ctl/report_html/router.py +2 -0
- package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
- package/runtime/python/okstra_ctl/report_projections.py +4 -3
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +178 -26
- package/runtime/python/okstra_ctl/run.py +82 -0
- package/runtime/python/okstra_ctl/team.py +4 -1
- package/runtime/python/okstra_ctl/technical_verification.py +195 -0
- package/runtime/python/okstra_ctl/usage_identity.py +54 -0
- package/runtime/python/okstra_ctl/usage_report.py +22 -8
- package/runtime/python/okstra_ctl/verification_target.py +74 -0
- package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
- package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
- package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
- package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
- package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
- package/runtime/python/okstra_ctl/wizard/render.py +8 -55
- package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
- package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
- package/runtime/python/okstra_ctl/wizard/state.py +13 -6
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +8 -0
- package/runtime/python/okstra_ctl/write_policy.py +23 -0
- package/runtime/python/okstra_token_usage/blocks.py +50 -1
- package/runtime/python/okstra_token_usage/claude.py +42 -21
- package/runtime/python/okstra_token_usage/codex.py +17 -0
- package/runtime/python/okstra_token_usage/collect.py +299 -162
- package/runtime/python/okstra_token_usage/cursor.py +2 -3
- package/runtime/python/okstra_token_usage/report.py +35 -30
- package/runtime/python/okstra_token_usage/task_totals.py +3 -12
- package/runtime/schemas/final-report-v2.0.schema.json +298 -7
- package/runtime/schemas/final-report-v3.0.schema.json +298 -7
- package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
- package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
- package/runtime/skills/okstra-run/SKILL.md +10 -2
- package/runtime/skills/okstra-setup/SKILL.md +42 -7
- package/runtime/templates/report-writer-prompt-preamble.md +7 -3
- package/runtime/templates/reports/html/i18n/en.json +11 -0
- package/runtime/templates/reports/html/i18n/ko.json +11 -0
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
- package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
- package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
- 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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|