okstra 0.197.0 → 0.198.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/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/cli.md +4 -2
- package/docs/project-structure-overview.md +1 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +19 -4
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +4 -3
- package/runtime/prompts/profiles/implementation-option-selection.md +4 -0
- package/runtime/prompts/wizard/prompts.ko.json +6 -2
- package/runtime/python/okstra_ctl/conformance.py +26 -5
- package/runtime/python/okstra_ctl/convergence.py +58 -3
- package/runtime/python/okstra_ctl/convergence_engine.py +71 -0
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +14 -3
- package/runtime/python/okstra_ctl/convergence_store.py +36 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +10 -3
- package/runtime/python/okstra_ctl/dispatch_state.py +30 -4
- package/runtime/python/okstra_ctl/group_context.py +96 -3
- package/runtime/python/okstra_ctl/implementation_options.py +123 -0
- package/runtime/python/okstra_ctl/option_votes.py +194 -0
- package/runtime/python/okstra_ctl/report_assembly.py +10 -0
- package/runtime/python/okstra_ctl/run.py +23 -0
- package/runtime/python/okstra_ctl/set_work_status.py +30 -1
- package/runtime/python/okstra_ctl/verdict_blocks.py +27 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +4 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +8 -0
- package/runtime/python/okstra_ctl/wizard/steps_options.py +37 -1
- package/runtime/python/okstra_ctl/worker_audit_check.py +38 -16
- package/runtime/python/okstra_ctl/worker_liveness.py +48 -2
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/skills/okstra-brief-gen/SKILL.md +24 -7
- package/runtime/skills/okstra-inspect/facets/recap.md +17 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -0
- package/runtime/templates/reports/group-context.template.md +1 -1
- package/runtime/validators/validate-run.py +77 -3
|
@@ -61,12 +61,17 @@ is wrong, overstated, or unproven. Then respond with exactly one verdict:
|
|
|
61
61
|
- counter-evidence — you found contradicting evidence (give file:line or log line), OR
|
|
62
62
|
- burden-not-met — you re-inspected the cited evidence and could neither confirm
|
|
63
63
|
nor refute it (the claim has not proven itself).
|
|
64
|
-
- **SURVIVES**: You actively tried to refute it and failed — the claim withstood the
|
|
64
|
+
- **SURVIVES**: You actively tried to refute it and failed — the claim withstood the
|
|
65
|
+
attack. Name the attack you tried and why it failed.
|
|
65
66
|
- **SURVIVES-WITH-CAVEAT**: It holds, but a scope limit / extra condition / missing
|
|
66
67
|
precondition exists (state it).
|
|
67
68
|
- **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
|
|
68
69
|
from opening or reproducing the cited evidence. Do not use REFUTED as a substitute.
|
|
69
70
|
|
|
71
|
+
Every verdict carries an `**Explanation**`, SURVIVES included — it is what you did,
|
|
72
|
+
not what the verdict already says. Only `**Basis**` is conditional. A block with a
|
|
73
|
+
verdict and no explanation is not collected and the whole response is refused.
|
|
74
|
+
|
|
70
75
|
The burden of proof is on the claim. If after inspecting the cited evidence you remain
|
|
71
76
|
uncertain, your verdict is REFUTED with basis = burden-not-met.
|
|
72
77
|
|
|
@@ -82,6 +87,10 @@ For EACH finding, respond with exactly one verdict:
|
|
|
82
87
|
- **UNVERIFIABLE**: Capability, credential, network, or service state prevents you
|
|
83
88
|
from checking this finding. Explain the unavailable capability; do not substitute DISAGREE.
|
|
84
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
|
+
|
|
85
94
|
Do NOT re-analyze the original source materials. Judge based on the evidence provided."""
|
|
86
95
|
|
|
87
96
|
# 근거 접근 규칙. `**Cited evidence**` 는 리드의 요약이고, 완전한 인용은 원 워커의
|
|
@@ -98,11 +107,13 @@ the summary line alone."""
|
|
|
98
107
|
ADVERSARIAL_RESPONSE = """### <finding-id>
|
|
99
108
|
**Verdict**: REFUTED | SURVIVES | SURVIVES-WITH-CAVEAT | UNVERIFIABLE
|
|
100
109
|
**Basis** (only if REFUTED): counter-evidence | burden-not-met
|
|
101
|
-
**Explanation
|
|
110
|
+
**Explanation** (required for every verdict, SURVIVES included): <2-3 sentences; for
|
|
111
|
+
SURVIVES say what you attacked and why the attack failed; for counter-evidence include
|
|
112
|
+
the file:line you found>"""
|
|
102
113
|
|
|
103
114
|
_COLLABORATIVE_RESPONSE = """### <finding-id>
|
|
104
115
|
**Verdict**: AGREE | DISAGREE | SUPPLEMENT | UNVERIFIABLE
|
|
105
|
-
**Explanation
|
|
116
|
+
**Explanation** (required for every verdict, AGREE included): <2-3 sentences>"""
|
|
106
117
|
|
|
107
118
|
|
|
108
119
|
def _nonempty_string(value: Any) -> str:
|
|
@@ -42,6 +42,38 @@ _CRITIC_BATCH_SCHEMA = {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
# acceptance 모드는 coverage 의 gap 어휘를 쓰지 않는다 — 후보 하나에 대한 판정이
|
|
46
|
+
# `confirmed`/`downgraded` 둘뿐이고, 회계는 이 배치를 읽는 쪽이 센다.
|
|
47
|
+
_ACCEPTANCE_BATCH_SCHEMA = {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"required": [
|
|
50
|
+
"schemaVersion", "taskKey", "mode", "provider",
|
|
51
|
+
"modelExecutionValue", "candidates",
|
|
52
|
+
],
|
|
53
|
+
"additionalProperties": False,
|
|
54
|
+
"properties": {
|
|
55
|
+
"schemaVersion": {"const": "1.0"},
|
|
56
|
+
"taskKey": {"type": "string", "pattern": "\\S"},
|
|
57
|
+
"mode": {"const": "acceptance-devils-advocate"},
|
|
58
|
+
"provider": {"type": "string", "pattern": "\\S"},
|
|
59
|
+
"modelExecutionValue": {"type": "string", "pattern": "\\S"},
|
|
60
|
+
"candidates": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["candidateId", "verdict"],
|
|
65
|
+
"additionalProperties": False,
|
|
66
|
+
"properties": {
|
|
67
|
+
"candidateId": {"type": "string", "pattern": "\\S"},
|
|
68
|
+
"verdict": {"enum": ["confirmed", "downgraded"]},
|
|
69
|
+
"statement": {"type": "string"},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
45
77
|
def load_owned_json_object(path: Path) -> dict[str, Any]:
|
|
46
78
|
try:
|
|
47
79
|
return load_owned_object(path, artifact="convergence artifact")
|
|
@@ -71,6 +103,10 @@ def load_convergence_critic_batch(path: Path) -> dict[str, Any]:
|
|
|
71
103
|
return _load_convergence_result(path, schema=_CRITIC_BATCH_SCHEMA)
|
|
72
104
|
|
|
73
105
|
|
|
106
|
+
def load_acceptance_critic_batch(path: Path) -> dict[str, Any]:
|
|
107
|
+
return _load_convergence_result(path, schema=_ACCEPTANCE_BATCH_SCHEMA)
|
|
108
|
+
|
|
109
|
+
|
|
74
110
|
def load_json_object(path: Path) -> dict[str, Any]:
|
|
75
111
|
"""Compatibility alias for convergence artifacts owned by okstra."""
|
|
76
112
|
return load_owned_json_object(path)
|
|
@@ -1988,7 +1988,9 @@ def _outcome_from_completed(handle: WorkerHandle) -> WorkerOutcome:
|
|
|
1988
1988
|
|
|
1989
1989
|
|
|
1990
1990
|
def _artifact_defects(job: WorkerJob) -> tuple[str, ...]:
|
|
1991
|
-
defect = unusable_result_defect(
|
|
1991
|
+
defect = unusable_result_defect(
|
|
1992
|
+
job.worker_id, job.result_path, job.dispatch_kind,
|
|
1993
|
+
)
|
|
1992
1994
|
return (defect,) if defect else ()
|
|
1993
1995
|
|
|
1994
1996
|
|
|
@@ -3228,9 +3230,12 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
|
|
|
3228
3230
|
worker_result = Path(_string_value(record.get("workerResultPath")))
|
|
3229
3231
|
missing: list[Path] = []
|
|
3230
3232
|
worker_id = _dispatch_worker_key(record)
|
|
3233
|
+
dispatch_kind = _string_value(record.get("dispatchKind"))
|
|
3231
3234
|
for path in _record_completion_paths(record):
|
|
3232
3235
|
if path.is_file():
|
|
3233
|
-
if path == result_path and unusable_result_defect(
|
|
3236
|
+
if path == result_path and unusable_result_defect(
|
|
3237
|
+
worker_id, path, dispatch_kind,
|
|
3238
|
+
):
|
|
3234
3239
|
missing.append(path)
|
|
3235
3240
|
continue
|
|
3236
3241
|
if path in {result_path, worker_result} and any(alias.is_file() for alias in aliases):
|
|
@@ -3241,7 +3246,9 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
|
|
|
3241
3246
|
|
|
3242
3247
|
def _record_artifact_defects(record: Mapping[str, Any]) -> tuple[str, ...]:
|
|
3243
3248
|
defect = unusable_result_defect(
|
|
3244
|
-
_dispatch_worker_key(record),
|
|
3249
|
+
_dispatch_worker_key(record),
|
|
3250
|
+
Path(_string_value(record.get("resultPath"))),
|
|
3251
|
+
_string_value(record.get("dispatchKind")),
|
|
3245
3252
|
)
|
|
3246
3253
|
return (defect,) if defect else ()
|
|
3247
3254
|
|
|
@@ -54,6 +54,7 @@ from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
|
|
|
54
54
|
from .final_report_paths import final_report_data_path
|
|
55
55
|
from .report_inputs import report_narrative_path, uses_report_contract_v3
|
|
56
56
|
from .report_narrative import narrative_structure_defect
|
|
57
|
+
from .verdict_blocks import finding_vote_defect
|
|
57
58
|
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
58
59
|
from .worker_prompt_contract import (
|
|
59
60
|
PromptRecord,
|
|
@@ -1642,16 +1643,39 @@ def dispatch_mode(jobs: Sequence[WorkerJob]) -> str:
|
|
|
1642
1643
|
return BACKEND_MIXED
|
|
1643
1644
|
|
|
1644
1645
|
|
|
1645
|
-
def unusable_result_defect(
|
|
1646
|
-
|
|
1646
|
+
def unusable_result_defect(
|
|
1647
|
+
worker_id: str, result_path: Path, dispatch_kind: str = "",
|
|
1648
|
+
) -> str | None:
|
|
1649
|
+
"""산출물이 있어도 소비자가 읽을 수 없으면 없는 것이다 — 서사와 재검증 표.
|
|
1647
1650
|
|
|
1648
1651
|
report-writer 의 서사가 줄 문법을 어기면(frontmatter·헤딩으로 된 보통
|
|
1649
1652
|
보고서) 조립이 Phase 7 에서 거절하고, 그때는 배치의 재시도가 이미 지나
|
|
1650
1653
|
리드가 손으로 재저작을 띄워야 한다 — 실측(2026-09-09, jobs implementation
|
|
1651
1654
|
stage-2)에서 리드는 그것을 하지 않고 run 을 닫았다. 수집 시점에 "없는
|
|
1652
1655
|
산출물" 로 세면 `_should_retry` 가 같은 배치 안에서 다시 띄운다.
|
|
1656
|
+
|
|
1657
|
+
재검증(`reverify-r<N>`) 결과도 같은 자리에 있다. `okstra convergence
|
|
1658
|
+
collect-results` 는 표로 읽히지 않는 결과를 거절하는데, 원장은 그 attempt 를
|
|
1659
|
+
`ok` 로 닫아 두므로 그 워커를 빼고 수집하면 `apply-round` 가 "missing vote
|
|
1660
|
+
for completed worker" 로 막는다. 즉 리드에게 남는 수가 없다 — 실측
|
|
1661
|
+
(2026-09-10, fontsninja-v3-site dev-10631 implementation-option-selection):
|
|
1662
|
+
antigravity 가 35건 중 34건의 `**Explanation**` 을 빼먹었고 run 이 그 자리에
|
|
1663
|
+
멈췄다. 여기서 결함으로 세면 재시도가 배치 안에서 돌고, 그마저 실패하면
|
|
1664
|
+
attempt 가 실패로 닫혀 `collect-results` 가 그 워커를 `error` 로 적는다 —
|
|
1665
|
+
엔진은 그 표를 `verification-error` 로 기록하고 라운드는 진행한다.
|
|
1653
1666
|
"""
|
|
1654
|
-
if
|
|
1667
|
+
if not result_path.is_file():
|
|
1668
|
+
return None
|
|
1669
|
+
if dispatch_kind.startswith("reverify-r"):
|
|
1670
|
+
try:
|
|
1671
|
+
text = result_path.read_text(encoding="utf-8")
|
|
1672
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1673
|
+
return f"reverify result is unreadable: {exc}"
|
|
1674
|
+
defect = finding_vote_defect(text)
|
|
1675
|
+
if defect is None:
|
|
1676
|
+
return None
|
|
1677
|
+
return f"reverify result does not parse: {defect}"
|
|
1678
|
+
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1655
1679
|
return None
|
|
1656
1680
|
try:
|
|
1657
1681
|
text = result_path.read_text(encoding="utf-8")
|
|
@@ -1667,7 +1691,9 @@ def missing_completion_paths(job: WorkerJob) -> tuple[Path, ...]:
|
|
|
1667
1691
|
missing: list[Path] = []
|
|
1668
1692
|
for path in job.completion_paths:
|
|
1669
1693
|
if path.is_file():
|
|
1670
|
-
if path == job.result_path and unusable_result_defect(
|
|
1694
|
+
if path == job.result_path and unusable_result_defect(
|
|
1695
|
+
job.worker_id, path, job.dispatch_kind,
|
|
1696
|
+
):
|
|
1671
1697
|
missing.append(path)
|
|
1672
1698
|
continue
|
|
1673
1699
|
# reports seq 와 workerResults seq 가 갈라지면 워커는 다른 쪽
|
|
@@ -70,7 +70,8 @@ _NONE_MARKERS = {"_(none)_", ""}
|
|
|
70
70
|
MEMORY_BEGIN = "<!-- okstra:task-memory:begin -->"
|
|
71
71
|
MEMORY_END = "<!-- okstra:task-memory:end -->"
|
|
72
72
|
MEMORY_NOTE = (
|
|
73
|
-
"<!-- okstra
|
|
73
|
+
"<!-- okstra redraws this region after every report-finalize, at "
|
|
74
|
+
"`okstra set-work-status`, and before each run copies this file. "
|
|
74
75
|
"Edit the sections above it, not this one. -->"
|
|
75
76
|
)
|
|
76
77
|
MEMORY_HEADING = "## Task Memory"
|
|
@@ -323,6 +324,32 @@ def record_task_memory(
|
|
|
323
324
|
return target, queue
|
|
324
325
|
|
|
325
326
|
|
|
327
|
+
def refresh_group_queue(project_root: Path, task_group: str) -> Path | None:
|
|
328
|
+
"""그룹 문서의 시작 순서만 지금 값으로 다시 그린다. 문서가 없으면 아무것도 안 한다.
|
|
329
|
+
|
|
330
|
+
기록된 항목과 사람 절은 바이트 그대로 두고 큐만 다시 계산한다. 이 투영을
|
|
331
|
+
쓰는 곳은 원래 report-finalize 의 `record-group-memory` 하나뿐이라, 리포트
|
|
332
|
+
없이 끝난 run 이나 사람이 손으로 바꾼 상태는 다음 finalize 까지 문서에
|
|
333
|
+
닿지 않았다(2026-09-10 실측, cache 그룹 dev-10635: `workStatus` 를 done 으로
|
|
334
|
+
적었는데 큐는 `[in progress] — requirements-discovery (blocked)` 그대로).
|
|
335
|
+
같은 그룹의 finalize 와 겹칠 수 있으므로 같은 flock 아래에서 읽고-쓴다.
|
|
336
|
+
"""
|
|
337
|
+
target = group_context_file(project_root, task_group)
|
|
338
|
+
if not target.is_file():
|
|
339
|
+
return None
|
|
340
|
+
with dir_flock(target.parent, LOCK_FILENAME):
|
|
341
|
+
text = target.read_text(encoding="utf-8")
|
|
342
|
+
before, region, after = split_memory_region(text)
|
|
343
|
+
if not region:
|
|
344
|
+
return None
|
|
345
|
+
entries = parse_memory_entries(region)
|
|
346
|
+
queue = group_queue(project_root, task_group, entries)
|
|
347
|
+
updated = before + render_memory_region(entries, queue) + after
|
|
348
|
+
if updated != text:
|
|
349
|
+
target.write_text(updated, encoding="utf-8")
|
|
350
|
+
return target
|
|
351
|
+
|
|
352
|
+
|
|
326
353
|
# --- 시작 순서 ----------------------------------------------------------------
|
|
327
354
|
#
|
|
328
355
|
# 그룹의 task 는 순서가 있을 수 있다. 순서는 brief-gen 이 매긴 브리프 순번
|
|
@@ -332,6 +359,11 @@ def record_task_memory(
|
|
|
332
359
|
# 15번 가드 티켓이 1번을 `blocks` 한다고 적었지만 사용자는 1번을 먼저 끝냈다.
|
|
333
360
|
# 큐는 영역 머리에 실리고, 이 task 가 끝나는 closeout 은 큐의 다음 task 를 이름한다.
|
|
334
361
|
|
|
362
|
+
# `okstra set-work-status` 가 task-manifest 에 적는 값 중 큐 상태를 덮는 것.
|
|
363
|
+
# 나머지(`todo` / `in-progress` / `blocked`)는 큐의 세 상태로 옮길 때 파생값보다
|
|
364
|
+
# 나은 정보가 없어 덮지 않는다.
|
|
365
|
+
WORK_STATUS_DONE = "done"
|
|
366
|
+
|
|
335
367
|
QUEUE_DONE = "done"
|
|
336
368
|
QUEUE_IN_PROGRESS = "in progress"
|
|
337
369
|
QUEUE_NOT_STARTED = "not started"
|
|
@@ -354,6 +386,9 @@ class QueueRow:
|
|
|
354
386
|
status: str # QUEUE_*
|
|
355
387
|
progress: str # "<task-type> #<seq>" for a recorded task, else ""
|
|
356
388
|
waits_for: tuple[str, ...] = () # ticket ids the graph says come first and are not done
|
|
389
|
+
# 같은 `brief-id` 를 가진 다른 브리프 파일들. 하나의 task 를 두 파일이
|
|
390
|
+
# 주장하는 상태이므로 큐는 정본 한 줄만 싣고 나머지를 여기 이름한다.
|
|
391
|
+
duplicate_briefs: tuple[str, ...] = ()
|
|
357
392
|
|
|
358
393
|
|
|
359
394
|
def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
|
|
@@ -417,6 +452,15 @@ def catalog_progress(project_root: Path, task_group: str) -> dict[str, tuple[str
|
|
|
417
452
|
state = str(entry.get("currentPhaseState") or entry.get("latestRunStatus") or "")
|
|
418
453
|
progress = f"{phase} ({state})" if phase and state else phase
|
|
419
454
|
status = QUEUE_DONE if pointer.get("status") == STATUS_TERMINAL else QUEUE_IN_PROGRESS
|
|
455
|
+
# 사용자가 `okstra set-work-status <task> done` 으로 끝났다고 선언한 task
|
|
456
|
+
# 는 끝난 것이다. 파생 포인터만 보면 리포트 없이 끝난 run 이 영원히
|
|
457
|
+
# `[in progress]` 로 남는다 — 실측(2026-09-10, fontsninja-v3-site cache
|
|
458
|
+
# 그룹 dev-10635): 사람이 AWS 콘솔에서 작업을 마치고 `done` 을 적었는데,
|
|
459
|
+
# 그 run 이 리포트 조립 실패로 끝나 포인터가 `blocked` 에 멈춰 있었다.
|
|
460
|
+
# 파생 표기는 지우지 않는다 — 왜 done 인지가 그 차이에 남는다.
|
|
461
|
+
if str(entry.get("workStatus") or "") == WORK_STATUS_DONE:
|
|
462
|
+
status = QUEUE_DONE
|
|
463
|
+
progress = f"{progress} · marked done" if progress else "marked done"
|
|
420
464
|
out[task_id] = (status, progress)
|
|
421
465
|
return out
|
|
422
466
|
|
|
@@ -439,6 +483,7 @@ def group_queue(
|
|
|
439
483
|
for brief in briefs:
|
|
440
484
|
for ticket, before in brief["waits_for"].items():
|
|
441
485
|
waits.setdefault(ticket, set()).update((before & by_ticket.keys()) - {ticket})
|
|
486
|
+
briefs, duplicates = _fold_duplicate_briefs(briefs)
|
|
442
487
|
ordered = sorted(briefs, key=lambda b: (b["ordinal"] is None, b["ordinal"] or 0, b["brief_id"]))
|
|
443
488
|
by_task = {entry.task_id: entry for entry in entries}
|
|
444
489
|
recorded = catalog_progress(project_root, task_group)
|
|
@@ -464,10 +509,50 @@ def group_queue(
|
|
|
464
509
|
rows.append(QueueRow(
|
|
465
510
|
slugify_task_segment(brief["brief_id"]), brief["brief_id"], brief["ticket_id"],
|
|
466
511
|
brief["brief"], status, progress, open_waits,
|
|
512
|
+
duplicates.get(brief["brief_id"], ()),
|
|
467
513
|
))
|
|
468
514
|
return rows
|
|
469
515
|
|
|
470
516
|
|
|
517
|
+
def _fold_duplicate_briefs(
|
|
518
|
+
briefs: list[dict[str, Any]],
|
|
519
|
+
) -> tuple[list[dict[str, Any]], dict[str, tuple[str, ...]]]:
|
|
520
|
+
"""같은 `brief-id` 를 주장하는 브리프들을 정본 하나로 접는다.
|
|
521
|
+
|
|
522
|
+
`brief-id` 는 task 디렉터리 이름(`task_id`)의 출처라, 두 파일이 같은 id 를
|
|
523
|
+
달면 하나의 task 를 둘이 주장하는 상태다. 접지 않으면 큐가 같은 task 를 두
|
|
524
|
+
줄로 싣고 번호가 브리프 수보다 커진다 — `next_in_group` 과 closeout 도 같은
|
|
525
|
+
task 를 두 번 가리킨다(2026-09-10 실측, fontsninja-v3-site `cache` 그룹:
|
|
526
|
+
개정 전 브리프를 `.superseded-<날짜>.md` 로 같은 디렉터리에 남겨 15개 그룹의
|
|
527
|
+
큐가 16번까지 갔다).
|
|
528
|
+
|
|
529
|
+
정본은 파일 이름이 곧 `brief-id` 인 파일이다 — brief-gen 이 쓰는 이름이고,
|
|
530
|
+
같은 id 를 단 다른 이름은 사본이다. 그런 파일이 없거나 여럿이면 경로 순서로
|
|
531
|
+
첫 번째다. 거절하지 않는 이유는 이 큐를 report-finalize 와 위저드가 읽기
|
|
532
|
+
때문이다: 브리프 디렉터리 정리가 안 됐다고 run 발행을 막을 일은 아니다.
|
|
533
|
+
대신 남은 사본을 `duplicate_briefs` 로 실어 큐가 그 사실을 말한다.
|
|
534
|
+
"""
|
|
535
|
+
by_id: dict[str, list[dict[str, Any]]] = {}
|
|
536
|
+
for brief in briefs:
|
|
537
|
+
by_id.setdefault(brief["brief_id"], []).append(brief)
|
|
538
|
+
kept: list[dict[str, Any]] = []
|
|
539
|
+
duplicates: dict[str, tuple[str, ...]] = {}
|
|
540
|
+
for brief_id, group in by_id.items():
|
|
541
|
+
if len(group) == 1:
|
|
542
|
+
kept.append(group[0])
|
|
543
|
+
continue
|
|
544
|
+
ordered_group = sorted(group, key=lambda b: b["brief"])
|
|
545
|
+
canonical = next(
|
|
546
|
+
(b for b in ordered_group if Path(b["brief"]).stem == brief_id),
|
|
547
|
+
ordered_group[0],
|
|
548
|
+
)
|
|
549
|
+
kept.append(canonical)
|
|
550
|
+
duplicates[brief_id] = tuple(
|
|
551
|
+
b["brief"] for b in ordered_group if b is not canonical
|
|
552
|
+
)
|
|
553
|
+
return kept, duplicates
|
|
554
|
+
|
|
555
|
+
|
|
471
556
|
def next_in_group(queue: list[QueueRow]) -> QueueRow | None:
|
|
472
557
|
"""시작 순서에서 아직 시작하지 않은 첫 task."""
|
|
473
558
|
return next((row for row in queue if row.status == QUEUE_NOT_STARTED), None)
|
|
@@ -482,6 +567,13 @@ def render_queue(queue: list[QueueRow]) -> str:
|
|
|
482
567
|
if row.waits_for:
|
|
483
568
|
tail += f" (waits for {', '.join(row.waits_for)})"
|
|
484
569
|
lines.append(f"{number}. [{row.status}] {row.task_id}{tail}")
|
|
570
|
+
# 사본을 이름한다. 이 줄이 없으면 큐가 조용히 한 파일을 무시하고, 그
|
|
571
|
+
# 파일을 고친 사람은 자기 편집이 어디로 갔는지 알 길이 없다.
|
|
572
|
+
for extra in row.duplicate_briefs:
|
|
573
|
+
lines.append(
|
|
574
|
+
f" - ignored duplicate of this brief-id: `{extra}` — one task, "
|
|
575
|
+
"two brief files. Move the copy out of the briefs directory."
|
|
576
|
+
)
|
|
485
577
|
return "\n".join(lines) + "\n"
|
|
486
578
|
|
|
487
579
|
|
|
@@ -657,8 +749,9 @@ def _init_command(args: argparse.Namespace) -> int:
|
|
|
657
749
|
"line remains; delete the file if the group needs no context."
|
|
658
750
|
)
|
|
659
751
|
print(
|
|
660
|
-
f"The trailing `{MEMORY_HEADING}` region is
|
|
661
|
-
"report-finalize
|
|
752
|
+
f"The trailing `{MEMORY_HEADING}` region is okstra's — redrawn after every "
|
|
753
|
+
"report-finalize, at `okstra set-work-status`, and before each run copies "
|
|
754
|
+
"this file; leave it alone."
|
|
662
755
|
)
|
|
663
756
|
return 0
|
|
664
757
|
|
|
@@ -6,9 +6,14 @@ import hashlib
|
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
8
|
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass
|
|
9
10
|
from collections.abc import Mapping, Sequence
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
13
|
+
from .clarification_items.dispositions import (
|
|
14
|
+
USER_INPUT_BLOCKS,
|
|
15
|
+
progress_blocking_ids,
|
|
16
|
+
)
|
|
12
17
|
from .exact_coverage import ExactCoverageError, calculate_exact_coverage
|
|
13
18
|
|
|
14
19
|
|
|
@@ -361,6 +366,124 @@ def _validate_option_count_and_routing(
|
|
|
361
366
|
errors.append("recommendedOptionId must name the first ranked option")
|
|
362
367
|
|
|
363
368
|
|
|
369
|
+
@dataclass(frozen=True)
|
|
370
|
+
class VoteGap:
|
|
371
|
+
"""전원 투표만 모자란 후보 하나와, 표를 받아야 할 분석자."""
|
|
372
|
+
|
|
373
|
+
option_id: str
|
|
374
|
+
missing: tuple[str, ...]
|
|
375
|
+
feasible_votes: int
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def vote_gaps(
|
|
379
|
+
selection: Mapping[str, object],
|
|
380
|
+
participating_analysers: Sequence[str],
|
|
381
|
+
) -> list[VoteGap]:
|
|
382
|
+
"""표만 채우면 살아날 후보 — 유효성 규칙을 거꾸로 읽는다.
|
|
383
|
+
|
|
384
|
+
1라운드의 설계자들은 병렬로 돌아 서로의 후보를 보지 못한다. 그래서 자기가
|
|
385
|
+
낸 후보에만 표를 남기고, 병합된 집합에는 분석자마다 다른 구멍이 생긴다.
|
|
386
|
+
`_validate_option_feasibility` 의 전원 투표 조항은 그 상태를 조립 시점에
|
|
387
|
+
거절할 뿐 메우지 못한다 — 실측(2026-09-10, dev-10629-4): 설계자 3명 로스터에서
|
|
388
|
+
IO-001·IO-002·IO-003 이 각각 `feasible` 2표를 받고도 빠진 분석자가 하나씩
|
|
389
|
+
달라 전부 탈락했고, 그 run 은 후보 0건으로 차단됐다.
|
|
390
|
+
|
|
391
|
+
여기서 세는 것은 **표만 모자란** 후보다. `safetyBlockers` 나
|
|
392
|
+
`unresolvedFeasibilityFacts` 가 있거나 베낀 표가 있으면 표를 더 받아도
|
|
393
|
+
유효해지지 않으므로 제외한다. 남은 표를 다 받아도 `feasible` 이
|
|
394
|
+
`MIN_FEASIBLE_VOTES` 에 못 미치는 후보도 제외한다 — 부쳐 봐야 결과가
|
|
395
|
+
같다.
|
|
396
|
+
"""
|
|
397
|
+
roster = list(dict.fromkeys(str(name) for name in participating_analysers))
|
|
398
|
+
gaps: list[VoteGap] = []
|
|
399
|
+
for candidate in (
|
|
400
|
+
row
|
|
401
|
+
for key in ("rankedOptions", "candidateAudit")
|
|
402
|
+
for row in (selection.get(key) or ())
|
|
403
|
+
if isinstance(row, Mapping)
|
|
404
|
+
):
|
|
405
|
+
if candidate.get("safetyBlockers") or candidate.get(
|
|
406
|
+
"unresolvedFeasibilityFacts"
|
|
407
|
+
):
|
|
408
|
+
continue
|
|
409
|
+
votes = candidate.get("feasibilityVotes") or ()
|
|
410
|
+
if _copied_votes(votes):
|
|
411
|
+
continue
|
|
412
|
+
voted = [str(vote.get("worker")) for vote in votes if isinstance(vote, Mapping)]
|
|
413
|
+
if len(voted) != len(set(voted)):
|
|
414
|
+
continue
|
|
415
|
+
missing = tuple(name for name in roster if name not in set(voted))
|
|
416
|
+
if not missing or set(voted) - set(roster):
|
|
417
|
+
continue
|
|
418
|
+
feasible = sum(
|
|
419
|
+
isinstance(vote, Mapping) and vote.get("verdict") == "feasible"
|
|
420
|
+
for vote in votes
|
|
421
|
+
)
|
|
422
|
+
if feasible + len(missing) < MIN_FEASIBLE_VOTES:
|
|
423
|
+
continue
|
|
424
|
+
gaps.append(
|
|
425
|
+
VoteGap(
|
|
426
|
+
option_id=str(candidate.get("id") or "?"),
|
|
427
|
+
missing=missing,
|
|
428
|
+
feasible_votes=feasible,
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
return gaps
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def validate_blocked_answer_channel(
|
|
435
|
+
report_data: Mapping[str, object],
|
|
436
|
+
) -> list[str]:
|
|
437
|
+
"""차단된 run 이 사용자가 답할 자리를 남겼는지.
|
|
438
|
+
|
|
439
|
+
`routing: blocked` 는 목적지가 없는 유일한 종료 상태다. 그 차단이
|
|
440
|
+
`unresolvedFeasibilityFacts` 때문이면 — 값이 미정이다, 계약이 없다,
|
|
441
|
+
리포터 확인이 필요하다 — 푸는 사람은 사용자인데, 답변 채널은
|
|
442
|
+
`clarificationItems[]` 하나뿐이다. 조립은 그 배열을 승인 결정 원장에서만
|
|
443
|
+
읽고(`report_assembly._clarifications`), `okstra user-response` 는 발행된
|
|
444
|
+
리포트의 그 배열만 읽는다(`user_response._record_clarification_rows`).
|
|
445
|
+
그래서 미해결 사실이 산문으로만 남으면 run 은 "사용자를 기다림" 상태로
|
|
446
|
+
발행되고 사용자에게는 답할 항목이 0건으로 보인다(2026-09-10 실측,
|
|
447
|
+
dev-10630: 후보 5개 전부 미해결 사실을 달고 원장은 빈 배열).
|
|
448
|
+
|
|
449
|
+
앞으로 가는 길은 원장에 행을 여는 것이다 —
|
|
450
|
+
`okstra approval-decision open --ledger <approvalDecisionsPath>` 를 행마다
|
|
451
|
+
한 번. 미해결 사실이 없는 차단(워커가 결과를 못 냈다 같은 실행 차단)은
|
|
452
|
+
사용자가 답할 것이 없으므로 이 검사에 걸리지 않는다.
|
|
453
|
+
|
|
454
|
+
`clarificationItems[]` 는 리드 소유라 작성자 서사에는 없다. 그래서 이
|
|
455
|
+
검사는 조립이 끝난 리포트 레코드 전체를 받고
|
|
456
|
+
`validate_implementation_option_selection`(작성자 소유 의미론) 과 따로
|
|
457
|
+
선다 — 교정 루프의 semantic validator 에 묶으면 원장에 행이 있어도 매번
|
|
458
|
+
발화한다.
|
|
459
|
+
"""
|
|
460
|
+
selection = report_data.get("implementationOptionSelection")
|
|
461
|
+
if not isinstance(selection, Mapping):
|
|
462
|
+
return []
|
|
463
|
+
if selection.get("routing") != NO_VALID_OPTIONS_ROUTING:
|
|
464
|
+
return []
|
|
465
|
+
unresolved = [
|
|
466
|
+
str(candidate.get("id"))
|
|
467
|
+
for key in ("rankedOptions", "candidateAudit")
|
|
468
|
+
for candidate in (selection.get(key) or ())
|
|
469
|
+
if isinstance(candidate, Mapping)
|
|
470
|
+
and candidate.get("unresolvedFeasibilityFacts")
|
|
471
|
+
]
|
|
472
|
+
if not unresolved:
|
|
473
|
+
return []
|
|
474
|
+
if progress_blocking_ids(
|
|
475
|
+
report_data.get("clarificationItems"), USER_INPUT_BLOCKS
|
|
476
|
+
):
|
|
477
|
+
return []
|
|
478
|
+
return [
|
|
479
|
+
"blocked routing leaves the user no answer channel: "
|
|
480
|
+
f"{', '.join(unresolved)} carry unresolvedFeasibilityFacts and no open "
|
|
481
|
+
"clarification row asks them — open one decision row per answerable "
|
|
482
|
+
"fact with `okstra approval-decision open --ledger "
|
|
483
|
+
"<approvalDecisionsPath>`, then reassemble"
|
|
484
|
+
]
|
|
485
|
+
|
|
486
|
+
|
|
364
487
|
def _validate_candidate_audit(
|
|
365
488
|
data: Mapping[str, object],
|
|
366
489
|
options: Sequence[Mapping[str, object]],
|