okstra 0.214.1 → 0.216.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 +2 -1
- package/dist/cli-registry.d.mts +14 -0
- package/dist/cli-registry.mjs +12 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.mjs +2 -56
- package/dist/commands/chat/chat.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +5 -2
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/wizard.mjs +6 -0
- package/dist/commands/execute/wizard.mjs.map +1 -1
- package/dist/commands/memory/memory.mjs +57 -6
- package/dist/commands/memory/memory.mjs.map +1 -1
- package/dist/lib/file-lock.d.mts +1 -0
- package/dist/lib/file-lock.mjs +60 -0
- package/dist/lib/file-lock.mjs.map +1 -0
- package/dist/lib/python-command.mjs +11 -1
- package/dist/lib/python-command.mjs.map +1 -1
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/dist/lib/types.d.mts +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +36 -14
- package/docs/project-structure-overview.md +6 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +9 -2
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/interactive.sh +8 -2
- package/runtime/bin/lib/okstra/usage.sh +3 -1
- package/runtime/bin/okstra.sh +7 -1
- package/runtime/prompts/duties/discussion-worker.json +32 -0
- package/runtime/prompts/lead/convergence.md +58 -136
- package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +14 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
- package/runtime/python/okstra_ctl/consumers.py +8 -2
- package/runtime/python/okstra_ctl/convergence.py +143 -29
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
- package/runtime/python/okstra_ctl/convergence_store.py +11 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/manager_cli.py +27 -1
- package/runtime/python/okstra_ctl/manager_launch.py +44 -4
- package/runtime/python/okstra_ctl/manager_sync.py +14 -1
- package/runtime/python/okstra_ctl/manager_view.py +53 -1
- package/runtime/python/okstra_ctl/next_phase.py +1 -0
- package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
- package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
- package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
- package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
- package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
- package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
- package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
- package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
- package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
- package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
- package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
- package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
- package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
- package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
- package/runtime/python/okstra_ctl/render.py +7 -20
- package/runtime/python/okstra_ctl/render_final_report.py +2 -1
- package/runtime/python/okstra_ctl/report_assembly.py +22 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -0
- package/runtime/python/okstra_ctl/report_narrative.py +13 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
- package/runtime/python/okstra_ctl/run.py +40 -7
- package/runtime/python/okstra_ctl/stage_close.py +46 -11
- package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
- package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
- package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
- package/runtime/python/okstra_ctl/wizard/render.py +4 -1
- package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
- package/runtime/python/okstra_ctl/wizard/state.py +11 -1
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
- package/runtime/python/okstra_token_usage/pricing.py +2 -0
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v3.0.schema.json +90 -1
- package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
- package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
- package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
- package/runtime/skills/_fragments/wizard-preflight.md +75 -0
- package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
- package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
- package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
- package/runtime/skills/okstra-discussion/SKILL.md +347 -0
- package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
- package/runtime/skills/okstra-run/SKILL.md +22 -2
- package/runtime/templates/manager/view.template.html +3 -0
- package/runtime/templates/reports/html/i18n/en.json +5 -0
- package/runtime/templates/reports/html/i18n/ko.json +5 -0
- package/runtime/validators/validate-brief.py +3 -2
- package/runtime/validators/validate-run.py +4 -6
|
@@ -13,7 +13,8 @@ WORKING_SCHEMA_VERSION = "1.0"
|
|
|
13
13
|
V2_WORKING_SCHEMA_VERSION = "2.0"
|
|
14
14
|
EXECUTION_IDENTITY_VERSION = 2
|
|
15
15
|
FINAL_SCHEMA_VERSION = "1.4"
|
|
16
|
-
|
|
16
|
+
# `critic` 은 발견을 내지 않고, 분석 워커 표가 원저자 표와 동률일 때만 투표한다.
|
|
17
|
+
_AUDIENCES = {"analysis", "lead", "report-writer", "critic"}
|
|
17
18
|
# 발견을 낼 수 있는 audience. 리드는 계약상 Phase 6 자체 리뷰를 수행하므로
|
|
18
19
|
# 출처가 될 수 있지만, 교차검증 표는 아니다 — 합의 수는 아래에서 분석 워커만
|
|
19
20
|
# 센다. 리드를 분석 워커로 위장 선언하는 것을 계약이 금지하는 이상, 리드의
|
|
@@ -23,6 +24,7 @@ _AUDIENCE_SOURCE_ROLES = {
|
|
|
23
24
|
"analysis": frozenset({"analyser", "designer", "planner", "verifier"}),
|
|
24
25
|
"lead": frozenset({"leader"}),
|
|
25
26
|
"report-writer": frozenset({"report-writer"}),
|
|
27
|
+
"critic": frozenset({"critic"}),
|
|
26
28
|
}
|
|
27
29
|
_VERIFICATION_MODES = {"lightweight", "full-reanalysis"}
|
|
28
30
|
# `unverified` 가 없던 시절의 산출물. `reuse-final` 이 그대로 읽으므로 그
|
|
@@ -73,6 +75,16 @@ FINALIZE_PLAN_NOTE = (
|
|
|
73
75
|
)
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
# 원저자 표를 더한 다수결이 갈리지 않은 라운드. 분류 값이 아니라 크리틱에게
|
|
79
|
+
# 넘길지 정하는 내부 표지다.
|
|
80
|
+
TIE = "tie"
|
|
81
|
+
|
|
82
|
+
_COLLABORATIVE_REMOVED = (
|
|
83
|
+
"config.adversarial is false: collaborative convergence was removed and every "
|
|
84
|
+
"phase verifies adversarially; regroup with `okstra convergence prepare-groups`"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
76
88
|
class ConvergenceContractError(ValueError):
|
|
77
89
|
"""Raised when convergence input violates the persisted-state contract."""
|
|
78
90
|
|
|
@@ -112,6 +124,12 @@ def seed_working_state(
|
|
|
112
124
|
for worker in workers
|
|
113
125
|
if worker["audience"] in _FINDING_SOURCE_AUDIENCES
|
|
114
126
|
]
|
|
127
|
+
critics = [worker["workerId"] for worker in workers if worker["audience"] == "critic"]
|
|
128
|
+
if len(critics) > 1:
|
|
129
|
+
raise ConvergenceContractError(
|
|
130
|
+
f"at most one critic worker may break ties: {', '.join(critics)}"
|
|
131
|
+
)
|
|
132
|
+
config["tieBreaker"] = critics[0] if critics else None
|
|
115
133
|
state = {
|
|
116
134
|
"schemaVersion": source["schemaVersion"],
|
|
117
135
|
"taskKey": task_key,
|
|
@@ -201,6 +219,12 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
201
219
|
workers = current.get("workers")
|
|
202
220
|
if not isinstance(workers, list):
|
|
203
221
|
raise ConvergenceContractError("working state.workers must be an array")
|
|
222
|
+
tie_breaker = config.get("tieBreaker")
|
|
223
|
+
awaiting_tie_break = [
|
|
224
|
+
finding_id
|
|
225
|
+
for finding_id in queue
|
|
226
|
+
if _finding_outcome(findings[finding_id].get("rounds") or [], tie_breaker)[1]
|
|
227
|
+
]
|
|
204
228
|
dispatches: list[dict[str, Any]] = []
|
|
205
229
|
skipped: list[dict[str, str]] = []
|
|
206
230
|
for worker in workers:
|
|
@@ -210,7 +234,8 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
210
234
|
eligible = [
|
|
211
235
|
finding_id
|
|
212
236
|
for finding_id in queue
|
|
213
|
-
if
|
|
237
|
+
if finding_id not in awaiting_tie_break
|
|
238
|
+
and _worker_is_independent_from_finding(
|
|
214
239
|
worker, findings[finding_id], workers
|
|
215
240
|
)
|
|
216
241
|
]
|
|
@@ -228,6 +253,17 @@ def plan_next_round(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
228
253
|
"reason": "no items to verify",
|
|
229
254
|
}
|
|
230
255
|
)
|
|
256
|
+
if awaiting_tie_break:
|
|
257
|
+
critic = next(
|
|
258
|
+
worker
|
|
259
|
+
for worker in workers
|
|
260
|
+
if isinstance(worker, Mapping) and worker.get("workerId") == tie_breaker
|
|
261
|
+
)
|
|
262
|
+
dispatches.append({
|
|
263
|
+
**_worker_execution_refs(critic),
|
|
264
|
+
"worker": tie_breaker,
|
|
265
|
+
"findingIds": awaiting_tie_break,
|
|
266
|
+
})
|
|
231
267
|
return {
|
|
232
268
|
"schemaVersion": WORKING_SCHEMA_VERSION,
|
|
233
269
|
"action": "dispatch",
|
|
@@ -292,25 +328,6 @@ def _worker_execution_refs(worker: Mapping[str, Any]) -> dict[str, str]:
|
|
|
292
328
|
}
|
|
293
329
|
|
|
294
330
|
|
|
295
|
-
def classify_collaborative_round(
|
|
296
|
-
votes: Mapping[str, Mapping[str, Any]],
|
|
297
|
-
) -> str | None:
|
|
298
|
-
"""Classify one collaborative round using non-error votes only."""
|
|
299
|
-
parsed = _parsed_votes(votes, adversarial=False)
|
|
300
|
-
usable = [vote for vote in parsed.values() if vote["verdict"] != "verification-error"]
|
|
301
|
-
if not usable:
|
|
302
|
-
return None
|
|
303
|
-
agreeing = sum(vote["verdict"] in {"agree", "supplement"} for vote in usable)
|
|
304
|
-
disagreeing = sum(vote["verdict"] == "disagree" for vote in usable)
|
|
305
|
-
if agreeing == len(usable):
|
|
306
|
-
return "full-consensus"
|
|
307
|
-
if disagreeing == len(usable):
|
|
308
|
-
return "worker-unique"
|
|
309
|
-
if agreeing > len(usable) / 2:
|
|
310
|
-
return "partial-consensus"
|
|
311
|
-
return None
|
|
312
|
-
|
|
313
|
-
|
|
314
331
|
def classify_adversarial_round(
|
|
315
332
|
votes: Mapping[str, Mapping[str, Any]],
|
|
316
333
|
) -> str | None:
|
|
@@ -343,6 +360,65 @@ def classify_adversarial_round(
|
|
|
343
360
|
return "partial-consensus"
|
|
344
361
|
|
|
345
362
|
|
|
363
|
+
def classify_finding_round(
|
|
364
|
+
votes: Mapping[str, Mapping[str, Any]],
|
|
365
|
+
) -> str | None:
|
|
366
|
+
"""원저자를 SURVIVES 1표로 세어 finding 의 한 라운드를 분류한다.
|
|
367
|
+
|
|
368
|
+
검증자 표만 세면 검증자 둘이 1:1 로 갈릴 때 과반이 없다. 원저자는 자기 발견을
|
|
369
|
+
유지하는 쪽이므로 그 표를 더해 다수결을 낸다. 동률은 `TIE` 다.
|
|
370
|
+
"""
|
|
371
|
+
parsed = _parsed_votes(votes, adversarial=True)
|
|
372
|
+
usable = [vote for vote in parsed.values() if vote["verdict"] != "verification-error"]
|
|
373
|
+
if not usable:
|
|
374
|
+
return None
|
|
375
|
+
disagrees = [vote for vote in usable if vote["verdict"] == "disagree"]
|
|
376
|
+
if any(vote["disagreeBasis"] == "counter-evidence" for vote in disagrees):
|
|
377
|
+
return "contested"
|
|
378
|
+
survives = 1 + len(usable) - len(disagrees)
|
|
379
|
+
if survives > len(disagrees):
|
|
380
|
+
if disagrees:
|
|
381
|
+
return "partial-consensus"
|
|
382
|
+
caveats = sum(vote["verdict"] == "supplement" for vote in usable)
|
|
383
|
+
return "partial-consensus" if caveats > len(usable) / 2 else "full-consensus"
|
|
384
|
+
if len(disagrees) > survives:
|
|
385
|
+
return "worker-unique"
|
|
386
|
+
return TIE
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _finding_outcome(
|
|
390
|
+
rounds: list[Any],
|
|
391
|
+
tie_breaker: str | None,
|
|
392
|
+
) -> tuple[str | None, bool]:
|
|
393
|
+
"""finding 의 라운드 기록이 확정한 분류와, 크리틱의 동률 판정을 기다리는지.
|
|
394
|
+
|
|
395
|
+
동률 라운드의 표는 다음 라운드(크리틱 한 표)와 합쳐 다시 센다. 크리틱이
|
|
396
|
+
로스터에 없으면 동률은 그 라운드에서 `contested` 로 끝난다 — 같은 분석
|
|
397
|
+
워커에게 다시 물어도 새 정보가 없다.
|
|
398
|
+
"""
|
|
399
|
+
rows = [
|
|
400
|
+
row
|
|
401
|
+
for row in rounds
|
|
402
|
+
if isinstance(row, Mapping) and isinstance(row.get("votes"), Mapping)
|
|
403
|
+
]
|
|
404
|
+
pending: dict[str, Any] = {}
|
|
405
|
+
for index, row in enumerate(rows):
|
|
406
|
+
votes = {**pending, **row["votes"]}
|
|
407
|
+
classification = classify_finding_round(votes)
|
|
408
|
+
if classification is None:
|
|
409
|
+
continue
|
|
410
|
+
if classification == TIE:
|
|
411
|
+
if tie_breaker is None:
|
|
412
|
+
classification = "contested"
|
|
413
|
+
else:
|
|
414
|
+
pending = votes
|
|
415
|
+
continue
|
|
416
|
+
if classification == "contested" and index < len(rows) - 1:
|
|
417
|
+
return _legacy_adversarial_history(rows), False
|
|
418
|
+
return classification, False
|
|
419
|
+
return None, bool(pending)
|
|
420
|
+
|
|
421
|
+
|
|
346
422
|
def _round_has_counter_evidence(
|
|
347
423
|
votes: Mapping[str, Mapping[str, Any]],
|
|
348
424
|
) -> bool:
|
|
@@ -356,12 +432,12 @@ def _round_has_counter_evidence(
|
|
|
356
432
|
|
|
357
433
|
def _classify_adversarial_history(
|
|
358
434
|
rounds: list[Mapping[str, Any]],
|
|
435
|
+
tie_breaker: str | None = None,
|
|
359
436
|
) -> str | None:
|
|
360
437
|
"""적대적 라운드 기록을 읽어 확정된 분류를 돌려준다.
|
|
361
438
|
|
|
362
|
-
분류를 낸 첫 라운드에서 멈춘다. `counter-evidence` 반증이 만드는
|
|
363
|
-
|
|
364
|
-
같은 표를 다시 받을 뿐이다.
|
|
439
|
+
분류를 낸 첫 라운드에서 멈춘다. `counter-evidence` 반증이 만드는 `contested`
|
|
440
|
+
도 그 라운드에서 종결이다 — 같은 검증자에게 다시 물어도 새 정보가 없다.
|
|
365
441
|
|
|
366
442
|
예외는 구 엔진이 남긴 상태 하나다. 구 엔진은 counter-evidence 를 마지막
|
|
367
443
|
라운드까지 끌고 갔고, 그 뒤로는 만장일치 반대(`worker-unique`)만 인정했다.
|
|
@@ -369,19 +445,7 @@ def _classify_adversarial_history(
|
|
|
369
445
|
지문이다 — 현 엔진에서는 거기서 큐를 떠나므로 뒤 라운드가 생기지 않는다.
|
|
370
446
|
그때만 구 규칙으로 이어 읽어, 이미 기록된 세션을 소급해 실패시키지 않는다.
|
|
371
447
|
"""
|
|
372
|
-
|
|
373
|
-
row
|
|
374
|
-
for row in rounds
|
|
375
|
-
if isinstance(row, Mapping) and isinstance(row.get("votes"), Mapping)
|
|
376
|
-
]
|
|
377
|
-
for index, row in enumerate(rows):
|
|
378
|
-
classification = classify_adversarial_round(row["votes"])
|
|
379
|
-
if classification is None:
|
|
380
|
-
continue
|
|
381
|
-
if classification == "contested" and index < len(rows) - 1:
|
|
382
|
-
return _legacy_adversarial_history(rows)
|
|
383
|
-
return classification
|
|
384
|
-
return None
|
|
448
|
+
return _finding_outcome(rounds, tie_breaker)[0]
|
|
385
449
|
|
|
386
450
|
|
|
387
451
|
def _legacy_adversarial_history(
|
|
@@ -449,7 +513,6 @@ def apply_round_results(
|
|
|
449
513
|
updated = deepcopy(dict(current))
|
|
450
514
|
queue = list(updated["queueFindingIds"])
|
|
451
515
|
findings = {row["findingId"]: row for row in updated["findings"]}
|
|
452
|
-
adversarial = bool(updated["config"].get("adversarial"))
|
|
453
516
|
resolved: list[str] = []
|
|
454
517
|
for finding_id in queue:
|
|
455
518
|
finding = findings[finding_id]
|
|
@@ -458,15 +521,13 @@ def apply_round_results(
|
|
|
458
521
|
planned_by_finding,
|
|
459
522
|
statuses,
|
|
460
523
|
votes_by_finding,
|
|
461
|
-
|
|
524
|
+
True,
|
|
462
525
|
)
|
|
463
526
|
finding["rounds"].append(
|
|
464
527
|
{"round": expected_plan["round"], "votes": round_votes}
|
|
465
528
|
)
|
|
466
|
-
classification = (
|
|
467
|
-
|
|
468
|
-
if adversarial
|
|
469
|
-
else classify_collaborative_round(round_votes)
|
|
529
|
+
classification = _classify_adversarial_history(
|
|
530
|
+
finding["rounds"], updated["config"].get("tieBreaker")
|
|
470
531
|
)
|
|
471
532
|
if classification is not None:
|
|
472
533
|
finding["classification"] = classification
|
|
@@ -659,17 +720,11 @@ def finalize_working_state(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
659
720
|
config = deepcopy(current["config"])
|
|
660
721
|
findings = deepcopy(current["findings"])
|
|
661
722
|
queue = set(current["queueFindingIds"])
|
|
662
|
-
adversarial = bool(config.get("adversarial"))
|
|
663
723
|
for finding in findings:
|
|
664
724
|
if finding["findingId"] not in queue:
|
|
665
725
|
continue
|
|
666
|
-
if _received_no_verdict(finding):
|
|
667
|
-
finding["classification"] = "unverified"
|
|
668
|
-
continue
|
|
669
726
|
finding["classification"] = (
|
|
670
|
-
"contested"
|
|
671
|
-
if adversarial
|
|
672
|
-
else _final_collaborative_classification(finding)
|
|
727
|
+
"unverified" if _received_no_verdict(finding) else "contested"
|
|
673
728
|
)
|
|
674
729
|
stop_reason = current.get("stopReason")
|
|
675
730
|
history = deepcopy(current["roundHistory"])
|
|
@@ -923,6 +978,12 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
|
|
|
923
978
|
history = []
|
|
924
979
|
errors.extend(_validate_round_history(history))
|
|
925
980
|
adversarial = bool(config.get("adversarial"))
|
|
981
|
+
if not adversarial and history:
|
|
982
|
+
errors.append(_COLLABORATIVE_REMOVED)
|
|
983
|
+
tie_breaker = config.get("tieBreaker")
|
|
984
|
+
if tie_breaker is not None and not _nonempty_string(tie_breaker):
|
|
985
|
+
errors.append("config.tieBreaker must be a worker ID or null")
|
|
986
|
+
tie_breaker = None
|
|
926
987
|
historical = version in _HISTORICAL_FINAL_VERSIONS
|
|
927
988
|
allowed_classifications = set(_CLASSIFICATION_COUNT_KEYS)
|
|
928
989
|
if historical:
|
|
@@ -949,23 +1010,25 @@ def validate_final_state(state: Mapping[str, Any]) -> list[str]:
|
|
|
949
1010
|
# 라운드 안에서 확정된 `contested`(증거 기반 반증)는 다르다 — 그때 큐를
|
|
950
1011
|
# 떠나므로 뒤 라운드 기록이 없는 것이 정상이다.
|
|
951
1012
|
settled_in_round = (
|
|
952
|
-
|
|
1013
|
+
_round_settled_classification(rounds, tie_breaker) is not None
|
|
953
1014
|
)
|
|
954
1015
|
unresolved = classification in {"contested", "unverified"}
|
|
955
1016
|
if unresolved and rounds and not settled_in_round:
|
|
956
1017
|
if rounds[-1].get("round") != len(history):
|
|
957
1018
|
errors.append(f"finding {finding_id} contested before the final round")
|
|
958
1019
|
if not unresolved:
|
|
959
|
-
errors.extend(
|
|
1020
|
+
errors.extend(
|
|
1021
|
+
_validate_no_reappearance_after_resolution(finding, tie_breaker)
|
|
1022
|
+
)
|
|
960
1023
|
expected = _expected_final_classification(
|
|
961
|
-
finding,
|
|
1024
|
+
finding, tie_breaker, historical=historical
|
|
962
1025
|
)
|
|
963
1026
|
if expected is not None and classification != expected:
|
|
964
1027
|
errors.append(
|
|
965
1028
|
f"finding {finding_id} classification {classification} "
|
|
966
1029
|
f"does not match replayed classification {expected}"
|
|
967
1030
|
)
|
|
968
|
-
errors.extend(_validate_round_ledger_counts(findings, history,
|
|
1031
|
+
errors.extend(_validate_round_ledger_counts(findings, history, tie_breaker))
|
|
969
1032
|
total_rounds = state.get("totalRounds")
|
|
970
1033
|
if total_rounds != len(history):
|
|
971
1034
|
errors.append("totalRounds does not match roundHistory length")
|
|
@@ -1852,22 +1915,15 @@ def _received_no_verdict(finding: Mapping[str, Any]) -> bool:
|
|
|
1852
1915
|
return seen
|
|
1853
1916
|
|
|
1854
1917
|
|
|
1855
|
-
def
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
if vote.get("verdict") != "verification-error":
|
|
1860
|
-
usable.append(vote)
|
|
1861
|
-
agreeing = sum(vote.get("verdict") in {"agree", "supplement"} for vote in usable)
|
|
1862
|
-
return "partial-consensus" if usable and agreeing > len(usable) / 2 else "contested"
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
def _round_settled_classification(rounds: list[Any]) -> str | None:
|
|
1918
|
+
def _round_settled_classification(
|
|
1919
|
+
rounds: list[Any],
|
|
1920
|
+
tie_breaker: str | None,
|
|
1921
|
+
) -> str | None:
|
|
1866
1922
|
"""라운드 기록만으로 확정된 적대적 분류. finalize 가 찍은 값과 구별한다."""
|
|
1867
1923
|
if not isinstance(rounds, list) or not rounds:
|
|
1868
1924
|
return None
|
|
1869
1925
|
try:
|
|
1870
|
-
return _classify_adversarial_history(rounds)
|
|
1926
|
+
return _classify_adversarial_history(rounds, tie_breaker)
|
|
1871
1927
|
except ConvergenceContractError:
|
|
1872
1928
|
# 표 자체가 계약 위반이면 그 사실은 `_validate_finding_ledger` 가 이미
|
|
1873
1929
|
# 별도 오류로 낸다. 여기서는 "라운드 안에서 확정되지 않았다"로만 읽는다.
|
|
@@ -1876,7 +1932,7 @@ def _round_settled_classification(rounds: list[Any]) -> str | None:
|
|
|
1876
1932
|
|
|
1877
1933
|
def _expected_final_classification(
|
|
1878
1934
|
finding: Mapping[str, Any],
|
|
1879
|
-
|
|
1935
|
+
tie_breaker: str | None,
|
|
1880
1936
|
*,
|
|
1881
1937
|
historical: bool = False,
|
|
1882
1938
|
) -> str | None:
|
|
@@ -1885,27 +1941,16 @@ def _expected_final_classification(
|
|
|
1885
1941
|
return "unverified"
|
|
1886
1942
|
if not isinstance(rounds, list) or not rounds:
|
|
1887
1943
|
return None
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
return "contested"
|
|
1893
|
-
for row in rounds:
|
|
1894
|
-
if not isinstance(row, Mapping) or not isinstance(row.get("votes"), Mapping):
|
|
1895
|
-
continue
|
|
1896
|
-
try:
|
|
1897
|
-
resolved = classify_collaborative_round(row["votes"])
|
|
1898
|
-
except ConvergenceContractError:
|
|
1899
|
-
continue
|
|
1900
|
-
if resolved is not None:
|
|
1901
|
-
return resolved
|
|
1902
|
-
return _final_collaborative_classification(finding)
|
|
1944
|
+
try:
|
|
1945
|
+
return _classify_adversarial_history(rounds, tie_breaker) or "contested"
|
|
1946
|
+
except ConvergenceContractError:
|
|
1947
|
+
return "contested"
|
|
1903
1948
|
|
|
1904
1949
|
|
|
1905
1950
|
def _validate_round_ledger_counts(
|
|
1906
1951
|
findings: list[Any],
|
|
1907
1952
|
history: list[Any],
|
|
1908
|
-
|
|
1953
|
+
tie_breaker: str | None,
|
|
1909
1954
|
) -> list[str]:
|
|
1910
1955
|
errors: list[str] = []
|
|
1911
1956
|
for round_number, history_row in enumerate(history, start=1):
|
|
@@ -1919,9 +1964,9 @@ def _validate_round_ledger_counts(
|
|
|
1919
1964
|
accepted = {
|
|
1920
1965
|
(len(ledgers), resolved, len(ledgers) - resolved)
|
|
1921
1966
|
for resolved in {
|
|
1922
|
-
_resolved_ledger_count(findings, round_number,
|
|
1967
|
+
_resolved_ledger_count(findings, round_number, tie_breaker),
|
|
1923
1968
|
_resolved_ledger_count(
|
|
1924
|
-
findings, round_number,
|
|
1969
|
+
findings, round_number, tie_breaker, legacy=True
|
|
1925
1970
|
),
|
|
1926
1971
|
}
|
|
1927
1972
|
}
|
|
@@ -1960,7 +2005,7 @@ def _round_ledgers(
|
|
|
1960
2005
|
def _resolved_ledger_count(
|
|
1961
2006
|
findings: list[Any],
|
|
1962
2007
|
round_number: int,
|
|
1963
|
-
|
|
2008
|
+
tie_breaker: str | None,
|
|
1964
2009
|
*,
|
|
1965
2010
|
legacy: bool = False,
|
|
1966
2011
|
) -> int:
|
|
@@ -1985,12 +2030,12 @@ def _resolved_ledger_count(
|
|
|
1985
2030
|
if not isinstance(votes, Mapping):
|
|
1986
2031
|
continue
|
|
1987
2032
|
try:
|
|
1988
|
-
if
|
|
1989
|
-
classification = classify_collaborative_round(votes)
|
|
1990
|
-
elif legacy:
|
|
2033
|
+
if legacy:
|
|
1991
2034
|
classification = _legacy_adversarial_history(current_rounds)
|
|
1992
2035
|
else:
|
|
1993
|
-
classification = _classify_adversarial_history(
|
|
2036
|
+
classification = _classify_adversarial_history(
|
|
2037
|
+
current_rounds, tie_breaker
|
|
2038
|
+
)
|
|
1994
2039
|
except ConvergenceContractError:
|
|
1995
2040
|
continue
|
|
1996
2041
|
resolved += classification is not None
|
|
@@ -2071,7 +2116,7 @@ def _validate_finding_ledger(
|
|
|
2071
2116
|
|
|
2072
2117
|
def _validate_no_reappearance_after_resolution(
|
|
2073
2118
|
finding: Mapping[str, Any],
|
|
2074
|
-
|
|
2119
|
+
tie_breaker: str | None,
|
|
2075
2120
|
) -> list[str]:
|
|
2076
2121
|
rounds = finding.get("rounds")
|
|
2077
2122
|
if not isinstance(rounds, list):
|
|
@@ -2081,10 +2126,8 @@ def _validate_no_reappearance_after_resolution(
|
|
|
2081
2126
|
if not isinstance(votes, Mapping):
|
|
2082
2127
|
continue
|
|
2083
2128
|
try:
|
|
2084
|
-
classification = (
|
|
2085
|
-
|
|
2086
|
-
if adversarial
|
|
2087
|
-
else classify_collaborative_round(votes)
|
|
2129
|
+
classification = _classify_adversarial_history(
|
|
2130
|
+
rounds[: index + 1], tie_breaker
|
|
2088
2131
|
)
|
|
2089
2132
|
except ConvergenceContractError:
|
|
2090
2133
|
continue
|
|
@@ -2438,6 +2481,8 @@ def _parse_config(value: Any) -> dict[str, Any]:
|
|
|
2438
2481
|
mode = _required_string(config, "verificationMode", "config")
|
|
2439
2482
|
if mode not in _VERIFICATION_MODES:
|
|
2440
2483
|
raise ConvergenceContractError("config.verificationMode is unsupported")
|
|
2484
|
+
if enabled and not adversarial:
|
|
2485
|
+
raise ConvergenceContractError(_COLLABORATIVE_REMOVED)
|
|
2441
2486
|
if adversarial and mode != "full-reanalysis":
|
|
2442
2487
|
raise ConvergenceContractError(
|
|
2443
2488
|
"adversarial convergence requires full-reanalysis verificationMode"
|
|
@@ -78,21 +78,6 @@ uncertain, your verdict is REFUTED with basis = burden-not-met.
|
|
|
78
78
|
Inspect ONLY the evidence each finding cites and its immediate surroundings. Do NOT
|
|
79
79
|
re-read the task brief, instruction-set, or report template."""
|
|
80
80
|
|
|
81
|
-
_COLLABORATIVE_MANDATE = """Review the following findings discovered by other workers.
|
|
82
|
-
For EACH finding, respond with exactly one verdict:
|
|
83
|
-
|
|
84
|
-
- **AGREE**: The finding is valid based on the evidence presented
|
|
85
|
-
- **DISAGREE**: The finding is incorrect or unsupported (explain briefly why)
|
|
86
|
-
- **SUPPLEMENT**: The finding is valid AND you have additional supporting evidence or context
|
|
87
|
-
- **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
|
|
88
|
-
from checking this finding. Explain the unavailable capability; do not substitute DISAGREE.
|
|
89
|
-
|
|
90
|
-
Every verdict carries an `**Explanation**`, AGREE included — name the evidence you
|
|
91
|
-
checked. A block with a verdict and no explanation is not collected and the whole
|
|
92
|
-
response is refused.
|
|
93
|
-
|
|
94
|
-
Do NOT re-analyze the original source materials. Judge based on the evidence provided."""
|
|
95
|
-
|
|
96
81
|
# 근거 접근 규칙. `**Cited evidence**` 는 리드의 요약이고, 완전한 인용은 원 워커의
|
|
97
82
|
# 결과 항목이다. 검증자가 요약만 열고 "이 근거로는 입증되지 않는다" 고 답하던
|
|
98
83
|
# 자리를 막는다.
|
|
@@ -111,11 +96,6 @@ ADVERSARIAL_RESPONSE = """### <finding-id>
|
|
|
111
96
|
SURVIVES say what you attacked and why the attack failed; for counter-evidence include
|
|
112
97
|
the file:line you found>"""
|
|
113
98
|
|
|
114
|
-
_COLLABORATIVE_RESPONSE = """### <finding-id>
|
|
115
|
-
**Verdict**: AGREE | DISAGREE | SUPPLEMENT | UNVERIFIABLE
|
|
116
|
-
**Explanation** (required for every verdict, AGREE included): <2-3 sentences>"""
|
|
117
|
-
|
|
118
|
-
|
|
119
99
|
def _nonempty_string(value: Any) -> str:
|
|
120
100
|
return value if isinstance(value, str) and value.strip() else ""
|
|
121
101
|
|
|
@@ -208,7 +188,6 @@ def reverify_prompt_body(
|
|
|
208
188
|
*,
|
|
209
189
|
task_key: str,
|
|
210
190
|
round_number: int,
|
|
211
|
-
adversarial: bool,
|
|
212
191
|
findings: Sequence[ReverifyFinding],
|
|
213
192
|
) -> str:
|
|
214
193
|
"""reverify 지시문 파일 본문. 같은 입력이면 같은 바이트를 낸다."""
|
|
@@ -216,14 +195,11 @@ def reverify_prompt_body(
|
|
|
216
195
|
raise ReverifyPromptError("convergence groups carry no taskKey")
|
|
217
196
|
if not findings:
|
|
218
197
|
raise ReverifyPromptError("no findings to verify")
|
|
219
|
-
mode = "ADVERSARIAL re-verification" if adversarial else "re-verification"
|
|
220
|
-
mandate = ADVERSARIAL_MANDATE if adversarial else _COLLABORATIVE_MANDATE
|
|
221
|
-
response = ADVERSARIAL_RESPONSE if adversarial else _COLLABORATIVE_RESPONSE
|
|
222
198
|
rows = [
|
|
223
199
|
"## Instructions\n\n",
|
|
224
200
|
f"{RENDERED_BY_LINE}\n\n",
|
|
225
|
-
f"Perform
|
|
226
|
-
|
|
201
|
+
f"Perform ADVERSARIAL re-verification for {task_key} (round {round_number}).\n\n",
|
|
202
|
+
ADVERSARIAL_MANDATE, "\n\n",
|
|
227
203
|
_EVIDENCE_ACCESS, "\n\n",
|
|
228
204
|
"## Findings to verify\n",
|
|
229
205
|
]
|
|
@@ -242,7 +218,7 @@ def reverify_prompt_body(
|
|
|
242
218
|
"Field labels are bold with the colon outside (`**Verdict**: …`); the "
|
|
243
219
|
"collector also reads `**Verdict:** …` and `- Verdict: …` as the same field.\n\n"
|
|
244
220
|
)
|
|
245
|
-
rows.append(
|
|
221
|
+
rows.append(ADVERSARIAL_RESPONSE.replace("<finding-id>", findings[0].finding_id))
|
|
246
222
|
rows.append("\n")
|
|
247
223
|
if len(findings) > 1:
|
|
248
224
|
rows.append(f"\n### {findings[1].finding_id}\n**Verdict**: ...\n")
|
|
@@ -68,6 +68,9 @@ _ACCEPTANCE_BATCH_SCHEMA = {
|
|
|
68
68
|
"candidateId": {"type": "string", "pattern": "\\S"},
|
|
69
69
|
"verdict": {"enum": ["confirmed", "downgraded"]},
|
|
70
70
|
"statement": {"type": "string"},
|
|
71
|
+
"severity": {"enum": ["critical", "major"]},
|
|
72
|
+
"originEvidence": {"type": "string"},
|
|
73
|
+
"votes": {"type": "object"},
|
|
71
74
|
},
|
|
72
75
|
},
|
|
73
76
|
},
|
|
@@ -226,7 +229,10 @@ def _validate_dynamic_verifier_request(
|
|
|
226
229
|
task_type: str,
|
|
227
230
|
dispatch_kind: str,
|
|
228
231
|
) -> None:
|
|
229
|
-
from .worker_prompt_policy import
|
|
232
|
+
from .worker_prompt_policy import (
|
|
233
|
+
is_plan_verify_dispatch_kind,
|
|
234
|
+
is_reverify_dispatch_kind,
|
|
235
|
+
)
|
|
230
236
|
|
|
231
237
|
try:
|
|
232
238
|
duty_role = role_for_duty(duty_id)
|
|
@@ -247,11 +253,12 @@ def _validate_dynamic_verifier_request(
|
|
|
247
253
|
f"dynamic verifier source role is not eligible: {source.role}"
|
|
248
254
|
)
|
|
249
255
|
if source is not None and source.role == "critic" and not (
|
|
250
|
-
task_type == "implementation-planning"
|
|
251
|
-
|
|
256
|
+
(task_type == "implementation-planning" and is_plan_verify_dispatch_kind(dispatch_kind))
|
|
257
|
+
or is_reverify_dispatch_kind(dispatch_kind)
|
|
252
258
|
):
|
|
253
259
|
raise ExecutionManifestError(
|
|
254
|
-
"critic source role is eligible only for
|
|
260
|
+
"critic source role is eligible only for a plan-body or finding "
|
|
261
|
+
"re-verification round"
|
|
255
262
|
)
|
|
256
263
|
|
|
257
264
|
|
|
@@ -144,6 +144,9 @@ from .write_policy import (
|
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
MAX_WORKER_ATTEMPTS = 2
|
|
147
|
+
# 배치 시작 실패가 그 배치의 모든 디스패치 행에 남기는 사유의 머리. 토론 단계가 이
|
|
148
|
+
# 값으로 시작 실패와 재시도 루프 뒤 실패를 가른다.
|
|
149
|
+
CLI_BATCH_LAUNCH_FAILURE = "concurrent CLI batch launch failed"
|
|
147
150
|
TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
148
151
|
# What the error log records for a wrapper the dispatcher timed out, matching
|
|
149
152
|
# the value `team-contract` prescribes for a polling-cap termination.
|
|
@@ -770,7 +773,7 @@ def _abort_cli_batch_start(
|
|
|
770
773
|
) -> None:
|
|
771
774
|
from .adapters.runtime.assembly import port_for
|
|
772
775
|
|
|
773
|
-
reason = f"
|
|
776
|
+
reason = f"{CLI_BATCH_LAUNCH_FAILURE}: {error}"
|
|
774
777
|
for handle, _attempt in active:
|
|
775
778
|
runtime = handle.runtime_handle
|
|
776
779
|
if runtime is None:
|
|
@@ -48,6 +48,7 @@ DUTY_ROLE_IDS = {
|
|
|
48
48
|
"discovery-worker": "analyser",
|
|
49
49
|
"diagnosis-worker": "analyser",
|
|
50
50
|
"technical-verification-worker": "analyser",
|
|
51
|
+
"discussion-worker": "analyser",
|
|
51
52
|
"scope-critic": "critic",
|
|
52
53
|
"acceptance-critic": "critic",
|
|
53
54
|
"direction-selection-worker": "designer",
|
|
@@ -146,7 +146,7 @@ def _render_manager_status(payload: dict) -> list[str]:
|
|
|
146
146
|
("Task ID", "taskId"), ("Progress mode", "progressMode")):
|
|
147
147
|
rows.append(line(label, manifest.get(key)))
|
|
148
148
|
summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
|
149
|
-
for key in ("planned", "missing", "running", "done", "blocked"):
|
|
149
|
+
for key in ("planned", "missing", "running", "done", "blocked", "discussion"):
|
|
150
150
|
rows.append(line(f"Summary {key}", summary.get(key)))
|
|
151
151
|
children = payload.get("children") if isinstance(payload.get("children"), list) else []
|
|
152
152
|
for index, child in enumerate(children, 1):
|
|
@@ -160,6 +160,32 @@ def _render_manager_status(payload: dict) -> list[str]:
|
|
|
160
160
|
if key in child:
|
|
161
161
|
value = child.get(key)
|
|
162
162
|
rows.append(line(f"Child {index} {label}", ("yes" if value else "no") if isinstance(value, bool) else value))
|
|
163
|
+
if isinstance(child.get("discussion"), dict):
|
|
164
|
+
rows.extend(_render_discussion(index, child["discussion"]))
|
|
165
|
+
return rows
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_discussion(index: int, discussion: dict) -> list[str]:
|
|
169
|
+
prefix = f"Child {index} discussion"
|
|
170
|
+
rows = [line(f"{prefix} state", discussion.get("state"))]
|
|
171
|
+
if discussion.get("state") == "unknown":
|
|
172
|
+
return rows + [line(f"{prefix} reason", discussion.get("reason"))]
|
|
173
|
+
rows += [line(f"{prefix} phase", discussion.get("phase")),
|
|
174
|
+
line(f"{prefix} completed revision", discussion.get("completedRevision") or "none")]
|
|
175
|
+
questions = discussion.get("pendingQuestions") or {}
|
|
176
|
+
for audience, key in (("user", "forUser"), ("participant", "forParticipants")):
|
|
177
|
+
for number, row in enumerate(questions.get(key) or [], 1):
|
|
178
|
+
rows.append(line(f"{prefix} pending {audience} question {number}",
|
|
179
|
+
f"{row.get('recordId')}: {row.get('text')}"))
|
|
180
|
+
for number, row in enumerate(discussion.get("revisions") or [], 1):
|
|
181
|
+
rows.append(line(f"{prefix} revision {number}",
|
|
182
|
+
f"{row.get('revisionId')} — {row.get('changeReason') or row.get('reason') or 'first revision'}"))
|
|
183
|
+
follow_ups = discussion.get("followUps") or {}
|
|
184
|
+
for number, row in enumerate(follow_ups.get("items") or [], 1):
|
|
185
|
+
rows.append(line(f"{prefix} follow-up {number}",
|
|
186
|
+
f"{row.get('targetTaskKey')} {row.get('state')} uses {row.get('pinnedRevision') or '-'}"))
|
|
187
|
+
if follow_ups.get("state") == "unknown":
|
|
188
|
+
rows.append(line(f"{prefix} follow-ups", f"unknown — {follow_ups.get('reason')}"))
|
|
163
189
|
return rows
|
|
164
190
|
|
|
165
191
|
|