okstra 0.221.0 → 0.222.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/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +4 -4
- package/docs/cli.md +7 -7
- package/docs/project-structure-overview.md +4 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/phase-routing.md +1 -1
- package/runtime/python/okstra_ctl/business_flow/engine.py +1 -0
- package/runtime/python/okstra_ctl/convergence.py +155 -19
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +3 -2
- package/runtime/python/okstra_ctl/convergence_migration.py +10 -1
- package/runtime/python/okstra_ctl/convergence_provenance.py +69 -24
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -1
- package/runtime/python/okstra_ctl/correction_cli.py +7 -7
- package/runtime/python/okstra_ctl/correction_round.py +40 -0
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -1
- package/runtime/python/okstra_ctl/git_reconcile.py +62 -8
- package/runtime/python/okstra_ctl/next_phase.py +22 -9
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +4 -2
- package/runtime/python/okstra_ctl/phases/implementation/entry.py +6 -2
- package/runtime/python/okstra_ctl/phases/implementation/host-rules.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +2 -2
- package/runtime/python/okstra_ctl/process_group.py +4 -0
- package/runtime/python/okstra_ctl/report_assembly.py +50 -13
- package/runtime/python/okstra_ctl/report_html/common.py +3 -1
- package/runtime/python/okstra_ctl/report_projections.py +45 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
- package/runtime/python/okstra_ctl/run.py +7 -5
- package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
- package/runtime/schemas/final-report-v3.0.schema.json +36 -0
- package/runtime/skills/okstra-run/SKILL.md +1 -0
- package/runtime/templates/reports/html/base.template.html +6 -1
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/validators/validate-run.py +5 -2
- package/runtime/validators/validate_session_conformance.py +19 -1
|
@@ -18,6 +18,7 @@ from .clarification_items import (
|
|
|
18
18
|
)
|
|
19
19
|
from .final_report_schema import load_schema_version, validate
|
|
20
20
|
from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
|
|
21
|
+
from .correction_round import correction_convergence_states, correction_round
|
|
21
22
|
from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
|
|
22
23
|
from .report_narrative import parse_narrative, task_narrative_errors
|
|
23
24
|
from .report_synthesis_packet import (
|
|
@@ -98,6 +99,32 @@ def _read_json(row: ReportInputPath) -> dict[str, Any]:
|
|
|
98
99
|
return value
|
|
99
100
|
|
|
100
101
|
|
|
102
|
+
def _correction_round_convergence(
|
|
103
|
+
initial: ReportInputPath, cross: Mapping[str, Any],
|
|
104
|
+
) -> tuple[list[dict[str, Any]], list[tuple[str, Mapping[str, Any]]]]:
|
|
105
|
+
"""Each correction round's re-convergence, projected like the initial one, and the
|
|
106
|
+
evidence it promotes labelled `convergence <scope>`."""
|
|
107
|
+
rows: list[dict[str, Any]] = []
|
|
108
|
+
promoted: list[tuple[str, Mapping[str, Any]]] = []
|
|
109
|
+
consensus = len(cross["consensus"])
|
|
110
|
+
differences = len(cross["differences"])
|
|
111
|
+
for scope, path in correction_convergence_states(initial.path):
|
|
112
|
+
row = ReportInputPath(initial.key, initial.owner, path)
|
|
113
|
+
try:
|
|
114
|
+
result = project_convergence(
|
|
115
|
+
_read_json(row),
|
|
116
|
+
first_consensus=consensus + 1, first_difference=differences + 1,
|
|
117
|
+
)
|
|
118
|
+
except ReportProjectionError as exc:
|
|
119
|
+
_fail(row.owner, path, "$", exc)
|
|
120
|
+
projected = result["crossVerification"]
|
|
121
|
+
consensus += len(projected["consensus"])
|
|
122
|
+
differences += len(projected["differences"])
|
|
123
|
+
rows.append({"round": correction_round(scope), **projected})
|
|
124
|
+
promoted.extend((f"convergence {scope}", item) for item in result["promotedEvidence"])
|
|
125
|
+
return rows, promoted
|
|
126
|
+
|
|
127
|
+
|
|
101
128
|
def _input_map(project_root: Path, manifest: Mapping[str, Any]) -> dict[str, ReportInputPath]:
|
|
102
129
|
try:
|
|
103
130
|
return {row.key: row for row in report_input_paths(project_root, manifest)}
|
|
@@ -488,6 +515,11 @@ def _compose(
|
|
|
488
515
|
data = {"schemaVersion": "3.0", **narrative, **execution}
|
|
489
516
|
data["tokenUsage"] = usage
|
|
490
517
|
data["crossVerification"] = convergence_data["crossVerification"]
|
|
518
|
+
rounds, round_evidence = _correction_round_convergence(
|
|
519
|
+
inputs["convergence"], data["crossVerification"]
|
|
520
|
+
)
|
|
521
|
+
if rounds:
|
|
522
|
+
data["crossVerification"]["correctionRounds"] = rounds
|
|
491
523
|
data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
|
|
492
524
|
# 작성자의 사실 분류와 리드 원장의 질문 연결은 양쪽 입력을 합친 뒤 검사한다.
|
|
493
525
|
for reason in validate_blocked_answer_channel(data):
|
|
@@ -504,8 +536,13 @@ def _compose(
|
|
|
504
536
|
if scope:
|
|
505
537
|
data["verificationScope"] = scope
|
|
506
538
|
data["agentActivity"] = activities
|
|
507
|
-
data["evidence"] = {"primary": _promoted_evidence(
|
|
508
|
-
|
|
539
|
+
data["evidence"] = {"primary": _promoted_evidence([
|
|
540
|
+
*(("convergence", item) for item in convergence_data["promotedEvidence"]),
|
|
541
|
+
*round_evidence,
|
|
542
|
+
])}
|
|
543
|
+
data["missingInformation"] = _missing_information(
|
|
544
|
+
project_root, manifest, convergence_data["criticUnverified"],
|
|
545
|
+
)
|
|
509
546
|
# 스냅샷 복사가 _attach_metadata 보다 먼저다 — frontmatter 가
|
|
510
547
|
# selectedDirectionRef.snapshotPath 를 읽는다.
|
|
511
548
|
_apply_selected_direction_snapshot(data, project_root, manifest)
|
|
@@ -529,16 +566,16 @@ def _attach_discussion(data: dict[str, Any], project_root: Path, manifest: Mappi
|
|
|
529
566
|
_fail("discussion-record", store.records_path, "discussion", exc)
|
|
530
567
|
|
|
531
568
|
|
|
532
|
-
def
|
|
533
|
-
project_root: Path, manifest: Mapping[str, Any],
|
|
569
|
+
def _missing_information(
|
|
570
|
+
project_root: Path, manifest: Mapping[str, Any], critic_unverified: list[dict[str, Any]],
|
|
534
571
|
) -> list[dict[str, Any]]:
|
|
535
|
-
"""Coverage-census warnings are the only rows this
|
|
536
|
-
v3 contract, so they number from `R-001`."""
|
|
537
|
-
state = read_census_state(project_root, manifest)
|
|
538
|
-
if state is None:
|
|
539
|
-
return []
|
|
572
|
+
"""Coverage-census warnings and unjudged critic gaps are the only rows this
|
|
573
|
+
field carries under the v3 contract, so they number from `R-001`."""
|
|
540
574
|
_project_id, _task_group, task_id = _identity(manifest)
|
|
541
|
-
|
|
575
|
+
ticket_id = task_id or "unknown"
|
|
576
|
+
state = read_census_state(project_root, manifest)
|
|
577
|
+
rows = [] if state is None else census_warning_rows(state, ticket_id=ticket_id)
|
|
578
|
+
rows += [{**row, "ticketId": row["ticketId"] or ticket_id} for row in critic_unverified]
|
|
542
579
|
return [{"id": f"R-{index:03d}", **row} for index, row in enumerate(rows, start=1)]
|
|
543
580
|
|
|
544
581
|
|
|
@@ -717,16 +754,16 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
|
|
|
717
754
|
}
|
|
718
755
|
|
|
719
756
|
|
|
720
|
-
def _promoted_evidence(
|
|
757
|
+
def _promoted_evidence(rows: list[tuple[str, Mapping[str, Any]]]) -> list[dict[str, Any]]:
|
|
721
758
|
return [
|
|
722
759
|
{
|
|
723
760
|
"id": f"E-{index:03d}",
|
|
724
761
|
"ticketId": row["ticketId"],
|
|
725
762
|
"evidence": row["evidence"],
|
|
726
763
|
"sourceItems": row["sourceItems"],
|
|
727
|
-
"source":
|
|
764
|
+
"source": source,
|
|
728
765
|
}
|
|
729
|
-
for index, row in enumerate(
|
|
766
|
+
for index, (source, row) in enumerate(rows, start=1)
|
|
730
767
|
]
|
|
731
768
|
|
|
732
769
|
|
|
@@ -403,8 +403,10 @@ def anchor_index(
|
|
|
403
403
|
for row_id in sorted(found)
|
|
404
404
|
if isinstance(row_id, str) and _anchorable(row_id)
|
|
405
405
|
}
|
|
406
|
+
cross = data.get("crossVerification") if isinstance(data.get("crossVerification"), dict) else {}
|
|
407
|
+
rounds = [cross, *(r for r in cross.get("correctionRounds") or [] if isinstance(r, dict))]
|
|
406
408
|
for block in ("consensus", "differences"):
|
|
407
|
-
for row in
|
|
409
|
+
for row in (row for scope in rounds for row in scope.get(block) or []):
|
|
408
410
|
row_id = row.get("id") if isinstance(row, dict) else None
|
|
409
411
|
if isinstance(row_id, str) and _anchorable(row_id) and row_id not in index:
|
|
410
412
|
index[row_id] = f"id-xv-{row_id}"
|
|
@@ -261,8 +261,14 @@ def _source_items(finding: Mapping[str, Any]) -> list[str]:
|
|
|
261
261
|
return [f"{_text(worker, 'worker').removesuffix('-worker')}:{finding_id}" for worker in workers]
|
|
262
262
|
|
|
263
263
|
|
|
264
|
-
def project_convergence(
|
|
265
|
-
|
|
264
|
+
def project_convergence(
|
|
265
|
+
state: Mapping[str, Any], *, first_consensus: int = 1, first_difference: int = 1,
|
|
266
|
+
) -> dict[str, Any]:
|
|
267
|
+
"""수렴 상태에서 독자용 합의·이견과 공개 근거만 승격한다.
|
|
268
|
+
|
|
269
|
+
정정 라운드 재수렴은 초기 행 다음 번호부터 매긴다 — 행 id 가 리포트 앵커라 한 리포트
|
|
270
|
+
안에서 겹치면 안 된다.
|
|
271
|
+
"""
|
|
266
272
|
consensus = []
|
|
267
273
|
differences = []
|
|
268
274
|
promoted = []
|
|
@@ -274,7 +280,7 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
274
280
|
ticket_id = _text(ticket_ids[0] if ticket_ids else "unknown", "unknown")
|
|
275
281
|
if finding.get("classification") in {"full-consensus", "partial-consensus"}:
|
|
276
282
|
row = {
|
|
277
|
-
"id": f"CV-{len(consensus) +
|
|
283
|
+
"id": f"CV-{len(consensus) + first_consensus:03d}",
|
|
278
284
|
"ticketId": ticket_id,
|
|
279
285
|
"statement": _text(finding.get("summary"), "Converged finding"),
|
|
280
286
|
"sourceItems": _source_items(finding),
|
|
@@ -298,7 +304,7 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
298
304
|
"position": "origin" if worker == finding.get("originWorker") else "dissent",
|
|
299
305
|
})
|
|
300
306
|
differences.append({
|
|
301
|
-
"id": f"D-{len(differences) +
|
|
307
|
+
"id": f"D-{len(differences) + first_difference:03d}",
|
|
302
308
|
"ticketId": ticket_id,
|
|
303
309
|
"disagreement": _text(finding.get("summary"), "Unresolved difference"),
|
|
304
310
|
"workersPosition": positions or [{"worker": "unknown", "itemId": "", "position": "unresolved"}],
|
|
@@ -311,5 +317,40 @@ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
311
317
|
"differences": differences,
|
|
312
318
|
},
|
|
313
319
|
"promotedEvidence": promoted,
|
|
320
|
+
"criticUnverified": _critic_unverified(state),
|
|
314
321
|
}
|
|
322
|
+
critic = (state.get("config") or {}).get("critic")
|
|
323
|
+
if isinstance(critic, Mapping):
|
|
324
|
+
result["crossVerification"]["criticGaps"] = {
|
|
325
|
+
"proposed": critic.get("gapsProposed", 0),
|
|
326
|
+
"merged": critic.get("gapsMerged", 0),
|
|
327
|
+
"unverified": critic.get("gapsUnverified", 0),
|
|
328
|
+
}
|
|
315
329
|
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _critic_unverified(state: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
333
|
+
"""미판정 gap 하나당 Missing Information 행 하나 — id 와 기본 ticket 은 조립이 채운다."""
|
|
334
|
+
ledger = (state.get("criticVerification") or {}).get("gaps") or []
|
|
335
|
+
tickets = {
|
|
336
|
+
gap.get("gapId"): (gap.get("ticketIds") or [None])[0]
|
|
337
|
+
for gap in ledger
|
|
338
|
+
if isinstance(gap, Mapping)
|
|
339
|
+
}
|
|
340
|
+
rows = []
|
|
341
|
+
for gap in state.get("unverifiedGaps") or []:
|
|
342
|
+
if not isinstance(gap, Mapping):
|
|
343
|
+
continue
|
|
344
|
+
gap_id = _text(gap.get("gapId"), "unknown")
|
|
345
|
+
rows.append({
|
|
346
|
+
"ticketId": tickets.get(gap_id),
|
|
347
|
+
"item": f"Coverage critic gap `{gap_id}` was never verified: "
|
|
348
|
+
f"{_text(gap.get('summary'), 'no summary')}",
|
|
349
|
+
"risk": (
|
|
350
|
+
f"Verification did not complete ({_text(gap.get('reason'), 'unknown reason')}), "
|
|
351
|
+
"so no analyser has judged this gap; it is neither a finding nor a rejection."
|
|
352
|
+
),
|
|
353
|
+
"owner": "Okstra lead",
|
|
354
|
+
"source": "critic-unverified",
|
|
355
|
+
})
|
|
356
|
+
return rows
|
|
@@ -14,6 +14,7 @@ from typing import Any, Mapping
|
|
|
14
14
|
from okstra_token_usage.blocks import accounting_workers
|
|
15
15
|
|
|
16
16
|
from .analysis_packet import reference_source_extracts
|
|
17
|
+
from .correction_round import correction_convergence_states
|
|
17
18
|
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
18
19
|
from .phases.implementation_option_selection.authoring import (
|
|
19
20
|
requirement_instructions as selection_requirement_instructions,
|
|
@@ -251,6 +252,17 @@ class ReportSynthesisPacket:
|
|
|
251
252
|
]
|
|
252
253
|
if self.task_type == "implementation-planning":
|
|
253
254
|
return self._planning_requirement_instructions()
|
|
255
|
+
if self.task_type == "implementation" and any(
|
|
256
|
+
source.label.startswith(f"{CONVERGENCE_STATE_LABEL} (correction-r")
|
|
257
|
+
for source in self.sources
|
|
258
|
+
):
|
|
259
|
+
return [
|
|
260
|
+
"Each `Convergence state (correction-r<N>)` source is correction round N's "
|
|
261
|
+
"re-convergence of the verifiers' re-verification; the plain `Convergence "
|
|
262
|
+
"state` is the initial round's. Describe the stage's open findings from the "
|
|
263
|
+
"last round's re-convergence, not the initial one; report what each round "
|
|
264
|
+
"fixed through `implementation.correctionRounds`.",
|
|
265
|
+
]
|
|
254
266
|
if self.task_type != "implementation-option-selection":
|
|
255
267
|
return []
|
|
256
268
|
return selection_requirement_instructions(
|
|
@@ -371,6 +383,7 @@ _SOURCE_FIELDS = (
|
|
|
371
383
|
("Final report schema", "orchestrator", "instructionSet", "finalReportSchemaPath", True),
|
|
372
384
|
("Convergence state", "convergence", "run", "convergenceStatePath", True),
|
|
373
385
|
)
|
|
386
|
+
CONVERGENCE_STATE_LABEL = "Convergence state"
|
|
374
387
|
|
|
375
388
|
|
|
376
389
|
_READ_CHUNK_BYTES = 16_000
|
|
@@ -514,7 +527,7 @@ def _source_markdown(
|
|
|
514
527
|
brief = next((item.content.strip() for item in sources if item.label == "Task brief"), "")
|
|
515
528
|
if len(brief.encode("utf-8")) >= _SHARED_TEXT_MIN_BYTES:
|
|
516
529
|
content = content.replace(brief, 'Read the complete "Source: Task brief" above.')
|
|
517
|
-
if source.label
|
|
530
|
+
if source.label.startswith(CONVERGENCE_STATE_LABEL):
|
|
518
531
|
content = _convergence_view(content)
|
|
519
532
|
if content != source.content.rstrip("\n"):
|
|
520
533
|
view = [
|
|
@@ -815,6 +828,14 @@ def _source_specs(
|
|
|
815
828
|
path = str(Path(instruction_set) / "final-report-schema.json")
|
|
816
829
|
if path or required:
|
|
817
830
|
specs.append((label, owner, path, required))
|
|
831
|
+
if label == CONVERGENCE_STATE_LABEL and path:
|
|
832
|
+
specs.extend(
|
|
833
|
+
(f"{CONVERGENCE_STATE_LABEL} ({scope})", owner,
|
|
834
|
+
_relative(project_root, round_final), True)
|
|
835
|
+
for scope, round_final in correction_convergence_states(
|
|
836
|
+
_resolve(project_root, path)
|
|
837
|
+
)
|
|
838
|
+
)
|
|
818
839
|
if carried_source:
|
|
819
840
|
specs.append((CARRIED_STAGES_LABEL, "orchestrator", carried_source, True))
|
|
820
841
|
specs.extend(_user_response_specs(project_root, narrative_path))
|
|
@@ -3370,10 +3370,11 @@ def _maybe_open_fix_cycle(inp: PrepareInputs, task_root: Path, existing: dict,
|
|
|
3370
3370
|
f"{', '.join(fix_cycles.FIX_CYCLE_ENTRY_PHASES)})에서만 허용됩니다: "
|
|
3371
3371
|
f"{inp.task_type}")
|
|
3372
3372
|
workflow = existing.get("workflow") or {}
|
|
3373
|
-
if
|
|
3373
|
+
if not fix_cycles.delivery_completed(workflow):
|
|
3374
3374
|
raise PrepareError(
|
|
3375
|
-
"--fix-cycle yes 는 release-handoff
|
|
3376
|
-
"(workflow.lastCompletedPhase
|
|
3375
|
+
"--fix-cycle yes 는 release-handoff 로 전달까지 끝난 task 에만 허용됩니다 "
|
|
3376
|
+
"(workflow.lastCompletedPhase 가 release-handoff 이고, skip·미전달 stage 로 "
|
|
3377
|
+
"다음 phase 가 release-handoff 로 남아 있지 않아야 함)")
|
|
3377
3378
|
brief_text = Path(inp.brief_path).read_text(encoding="utf-8")
|
|
3378
3379
|
fix_cycles.append_opened(
|
|
3379
3380
|
task_root, target_report=str(existing.get("latestReportRecordPath", "")),
|
|
@@ -3385,7 +3386,8 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
|
|
|
3385
3386
|
"""fix-cycles.jsonl 의 lazy-close → opened → run 기록. cycle id 반환.
|
|
3386
3387
|
|
|
3387
3388
|
- lazy-close: open cycle 에 release-handoff run 이 부착됐고 디스크 manifest 의
|
|
3388
|
-
lastCompletedPhase 가 release-handoff 면 닫는다.
|
|
3389
|
+
lastCompletedPhase 가 release-handoff 면 닫는다. 다음 phase 포인터가 다시
|
|
3390
|
+
release-handoff 를 가리키면(skip·미전달 stage) 닫지 않는다. manifest 는 prepare 가
|
|
3389
3391
|
이번 run 으로 재작성하기 *전* 값이어야 하므로 finalize 직전에 호출한다.
|
|
3390
3392
|
- opened: --fix-cycle yes 일 때만. 완료 task + entry phase 미충족이면
|
|
3391
3393
|
PrepareError.
|
|
@@ -3398,7 +3400,7 @@ def _record_fix_cycle_events(inp: PrepareInputs, ctx: dict) -> str:
|
|
|
3398
3400
|
|
|
3399
3401
|
open_c = fix_cycles.open_cycle(fix_cycles.read_rows(task_root))
|
|
3400
3402
|
|
|
3401
|
-
if open_c and
|
|
3403
|
+
if open_c and fix_cycles.delivery_completed(workflow):
|
|
3402
3404
|
rows = fix_cycles.read_rows(task_root)
|
|
3403
3405
|
handoff_attached = any(
|
|
3404
3406
|
r.get("event") == "run" and r.get("cycle") == open_c["cycle"]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Git reconciliation shared by stage prepare flows."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import sys
|
|
@@ -31,3 +31,51 @@ def auto_reconcile_best_effort(inp: Any, plan_run_root: Path) -> None:
|
|
|
31
31
|
)
|
|
32
32
|
except Exception as exc:
|
|
33
33
|
print(f"git-reconcile skipped: {exc}", file=sys.stderr)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StaleStageRecordsError(RuntimeError):
|
|
37
|
+
"""A recorded stage SHA no longer matches git and only the user can say which commit counts."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_reconciled_stages(
|
|
41
|
+
inp: Any, plan_run_root: Path, *, stage: int | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Apply auto items, then refuse while a confirm item remains.
|
|
44
|
+
|
|
45
|
+
A prepare that restores, rebases or merges stage branches calls this first:
|
|
46
|
+
building on a recorded commit the branch no longer carries integrates work
|
|
47
|
+
nobody verified. ``stage`` narrows the check to that stage's done row.
|
|
48
|
+
"""
|
|
49
|
+
from .git_reconcile import _git, classify_task, guidance
|
|
50
|
+
|
|
51
|
+
auto_reconcile_best_effort(inp, plan_run_root)
|
|
52
|
+
# A project prepared without git (worktree status skipped-not-git) has no
|
|
53
|
+
# history the recorded SHAs could disagree with.
|
|
54
|
+
if _git(Path(inp.project_root), "rev-parse", "--git-dir").returncode != 0:
|
|
55
|
+
return
|
|
56
|
+
coordinates = dict(
|
|
57
|
+
project_id=inp.project_id,
|
|
58
|
+
task_group=inp.task_group,
|
|
59
|
+
task_id=inp.task_id,
|
|
60
|
+
work_category=inp.work_category,
|
|
61
|
+
)
|
|
62
|
+
pending = [
|
|
63
|
+
item
|
|
64
|
+
for item in classify_task(
|
|
65
|
+
project_root=Path(inp.project_root),
|
|
66
|
+
plan_run_root=plan_run_root,
|
|
67
|
+
**coordinates,
|
|
68
|
+
)
|
|
69
|
+
if item.classification == "confirm"
|
|
70
|
+
and (stage is None or item.stage == stage)
|
|
71
|
+
]
|
|
72
|
+
if not pending:
|
|
73
|
+
return
|
|
74
|
+
lines = "\n".join(
|
|
75
|
+
f" {f'stage {item.stage}' if item.kind == 'done' else 'anchor'}: "
|
|
76
|
+
f"{item.reason} (recorded {item.recorded})"
|
|
77
|
+
for item in pending
|
|
78
|
+
)
|
|
79
|
+
raise StaleStageRecordsError(
|
|
80
|
+
f"{lines}\n{guidance(plan_run_root=plan_run_root, **coordinates)}"
|
|
81
|
+
)
|
|
@@ -215,14 +215,14 @@ def _stage_plan_for_confirmation(
|
|
|
215
215
|
|
|
216
216
|
|
|
217
217
|
def _fix_cycle_confirm_required(state: WizardState) -> bool:
|
|
218
|
-
"""
|
|
219
|
-
cycle 이 없을 때만 묻는다."""
|
|
218
|
+
"""전달까지 끝난(release-handoff) task 에 entry phase 로 재진입하고, 아직
|
|
219
|
+
열린 fix cycle 이 없을 때만 묻는다."""
|
|
220
220
|
if state.task_type not in fix_cycles.FIX_CYCLE_ENTRY_PHASES:
|
|
221
221
|
return False
|
|
222
222
|
task_root = task_dir(Path(state.project_root),
|
|
223
223
|
state.task_group, state.task_id)
|
|
224
224
|
workflow = (read_task_manifest(task_root) or {}).get("workflow") or {}
|
|
225
|
-
if
|
|
225
|
+
if not fix_cycles.delivery_completed(workflow):
|
|
226
226
|
return False
|
|
227
227
|
return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None
|
|
228
228
|
|
|
@@ -480,6 +480,42 @@
|
|
|
480
480
|
}
|
|
481
481
|
]
|
|
482
482
|
},
|
|
483
|
+
"correctionRounds": {
|
|
484
|
+
"type": "array",
|
|
485
|
+
"maxItems": 2,
|
|
486
|
+
"description": "Each implementation correction round's re-convergence of the verifiers' re-verification, projected from `convergence-correction-r<N>-<task-type>-<seq>.json`. Row ids continue the initial round's numbering so every row keeps its own anchor.",
|
|
487
|
+
"items": {
|
|
488
|
+
"type": "object",
|
|
489
|
+
"required": [
|
|
490
|
+
"round",
|
|
491
|
+
"consensus",
|
|
492
|
+
"differences"
|
|
493
|
+
],
|
|
494
|
+
"additionalProperties": false,
|
|
495
|
+
"properties": {
|
|
496
|
+
"round": {
|
|
497
|
+
"type": "integer",
|
|
498
|
+
"minimum": 1,
|
|
499
|
+
"maximum": 2
|
|
500
|
+
},
|
|
501
|
+
"roundHistory": {
|
|
502
|
+
"$ref": "#/properties/crossVerification/properties/roundHistory"
|
|
503
|
+
},
|
|
504
|
+
"consensus": {
|
|
505
|
+
"type": "array",
|
|
506
|
+
"items": {
|
|
507
|
+
"$ref": "#/$defs/ConsensusRow"
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
"differences": {
|
|
511
|
+
"type": "array",
|
|
512
|
+
"items": {
|
|
513
|
+
"$ref": "#/$defs/DifferenceRow"
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
},
|
|
483
519
|
"criticGaps": {
|
|
484
520
|
"type": "object",
|
|
485
521
|
"description": "Coverage-critic gap accounting. `proposed - merged - unverified` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
|
|
@@ -439,6 +439,7 @@ If `render-bundle` fails with a `PrepareError` containing `Recorded stage SHAs n
|
|
|
439
439
|
- **Enter a different ref directly** — the user names a commit/branch/tag.
|
|
440
440
|
- **Abort** — stop the run without recovering.
|
|
441
441
|
3. Run `okstra git-reconcile … --apply --stage <N> --use-ref <ref>` with the chosen ref, then retry the failed `render-bundle` with the same arguments.
|
|
442
|
+
If that output carries a `Warning` naming the implementation anchor, the chosen head no longer contains the anchor: show the warning to the user and run its printed `--reset-anchor` command only after they confirm the new base, before the retry.
|
|
442
443
|
|
|
443
444
|
If the anchor (`implementation_base_commit`) is reported unresolvable, run the same command's `--reset-anchor <ref>` after user confirmation. Correcting a confirm item without the picker is forbidden — the runtime also rejects a confirm correction without `--use-ref`.
|
|
444
445
|
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
</section>
|
|
71
71
|
{% endif %}
|
|
72
72
|
{% set no_cross_model_check = taskType != "release-handoff" and (crossVerification.get("roundHistory") or {}).get("round2SkippedReason") == "single-analyser-only" %}
|
|
73
|
-
{% if crossVerification.get("consensus") or crossVerification.get("differences") or no_cross_model_check %}
|
|
73
|
+
{% if crossVerification.get("consensus") or crossVerification.get("differences") or crossVerification.get("correctionRounds") or no_cross_model_check %}
|
|
74
74
|
<section data-report-section="cross-check">
|
|
75
75
|
<h2>{{ t('base.cross-check') }}</h2>
|
|
76
76
|
{% if no_cross_model_check %}<p class="section-lede">{{ t('base.no-cross-model-check') }}</p>{% endif %}
|
|
@@ -82,6 +82,11 @@
|
|
|
82
82
|
<h3>{{ t('base.workers-disagreed') }}</h3>
|
|
83
83
|
<div class="summary-grid">{% for row in crossVerification.differences %}<article class="summary-card tone-important" id="id-xv-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }}</p><h3>{{ row.disagreement | inline_code }}</h3><p>{% for pos in row.get("workersPosition", []) %}{{ pos.worker | inline_code }} · {{ pos.position | inline_code }}{% if not loop.last %} · {% endif %}{% endfor %}</p><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p></article>{% endfor %}</div>
|
|
84
84
|
{% endif %}
|
|
85
|
+
{% for correction in crossVerification.get("correctionRounds", []) %}
|
|
86
|
+
<h3>{{ t('base.cross-check-correction-round') | replace('{round}', correction.round | string) }}</h3>
|
|
87
|
+
{% if correction.consensus %}<div class="summary-grid">{% for row in correction.consensus %}<article class="summary-card" id="id-xv-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }} · {{ t('base.agreed-across-workers') }}</p><h3>{{ row.statement | inline_code }}</h3><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p></article>{% endfor %}</div>{% endif %}
|
|
88
|
+
{% if correction.differences %}<div class="summary-grid">{% for row in correction.differences %}<article class="summary-card tone-important" id="id-xv-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }} · {{ t('base.workers-disagreed') }}</p><h3>{{ row.disagreement | inline_code }}</h3><p>{% for pos in row.get("workersPosition", []) %}{{ pos.worker | inline_code }} · {{ pos.position | inline_code }}{% if not loop.last %} · {% endif %}{% endfor %}</p><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p></article>{% endfor %}</div>{% endif %}
|
|
89
|
+
{% endfor %}
|
|
85
90
|
</section>
|
|
86
91
|
{% endif %}
|
|
87
92
|
{% if agentActivity %}
|
|
@@ -242,6 +242,7 @@
|
|
|
242
242
|
"no-cross-model-check": "No cross-model check ran: fewer than two analysis workers were selected, so no other model verified these findings.",
|
|
243
243
|
"agreed-across-workers": "Agreed across workers",
|
|
244
244
|
"workers-disagreed": "Workers disagreed",
|
|
245
|
+
"cross-check-correction-round": "Re-converged after correction round {round}",
|
|
245
246
|
"source": "Source",
|
|
246
247
|
"confidence": "Confidence",
|
|
247
248
|
"export-my-answers": "Export my answers",
|
|
@@ -242,6 +242,7 @@
|
|
|
242
242
|
"no-cross-model-check": "교차 모델 검증을 하지 않았습니다. 분석 작업자를 두 명 미만으로 골라 다른 모델이 이 결과를 검증하지 않았습니다.",
|
|
243
243
|
"agreed-across-workers": "작업자 간 합의",
|
|
244
244
|
"workers-disagreed": "작업자 간 이견",
|
|
245
|
+
"cross-check-correction-round": "수정 라운드 {round} 뒤 재수렴",
|
|
245
246
|
"source": "출처",
|
|
246
247
|
"confidence": "확신도",
|
|
247
248
|
"export-my-answers": "내 답변 내보내기",
|
|
@@ -4854,8 +4854,10 @@ def _validate_convergence_group_provenance(run_dir, failures, suffix=None) -> No
|
|
|
4854
4854
|
# provider token (`claude` vs `claude-worker`), depending on how the roster was
|
|
4855
4855
|
# seeded — worker resolution below accepts both. `-reverify-r` never occurs
|
|
4856
4856
|
# inside a slug, so the non-greedy slug capture stops at that literal anchor.
|
|
4857
|
+
# A correction round's re-convergence puts `correction-r<N>-` before
|
|
4858
|
+
# `reverify-r`, and its plans carry the same token before `<task-type>`.
|
|
4857
4859
|
_REVERIFY_PROMPT_BASENAME_RE = re.compile(
|
|
4858
|
-
r"^(?P<slug>[a-z][a-z0-9-]*?)-reverify-r(?P<round>\d+)"
|
|
4860
|
+
r"^(?P<slug>[a-z][a-z0-9-]*?)(?:-(?P<scope>correction-r\d+))?-reverify-r(?P<round>\d+)"
|
|
4859
4861
|
r"-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
|
|
4860
4862
|
)
|
|
4861
4863
|
|
|
@@ -5318,8 +5320,9 @@ def _validate_reverify_prompt_matches_plan(run_dir, failures, suffix=None) -> No
|
|
|
5318
5320
|
if match is None:
|
|
5319
5321
|
continue
|
|
5320
5322
|
round_n = match.group("round")
|
|
5323
|
+
scope = f"{match.group('scope')}-" if match.group("scope") else ""
|
|
5321
5324
|
plan_path = state_dir / (
|
|
5322
|
-
f"convergence-round-{round_n}-plan-"
|
|
5325
|
+
f"convergence-round-{round_n}-plan-{scope}"
|
|
5323
5326
|
f"{match.group('task_type')}-{match.group('seq')}.json"
|
|
5324
5327
|
)
|
|
5325
5328
|
if not plan_path.is_file():
|
|
@@ -298,6 +298,18 @@ def _analysis_workers(team_state: Mapping[str, Any]) -> list[dict]:
|
|
|
298
298
|
return [worker for worker in workers if not _is_report_writer(worker)]
|
|
299
299
|
|
|
300
300
|
|
|
301
|
+
def _critic_worker_ids(team_state: Mapping[str, Any]) -> set[str]:
|
|
302
|
+
"""`critic/*` 배정이 로스터에 남기는 행의 workerId (`render._critic_role_rows`)."""
|
|
303
|
+
assignments = team_state.get("invocationAssignments")
|
|
304
|
+
if not isinstance(assignments, Mapping):
|
|
305
|
+
return set()
|
|
306
|
+
return {
|
|
307
|
+
str(ref).rsplit("/", 1)[-1]
|
|
308
|
+
for ref in assignments
|
|
309
|
+
if str(ref).startswith("critic/")
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
301
313
|
def _scan_one_jsonl(
|
|
302
314
|
path: Path, since: str | None, until: str | None
|
|
303
315
|
) -> tuple[list[tuple[str, str, str]], dict[str, list[str]], str | None]:
|
|
@@ -1686,7 +1698,13 @@ def _check_progress_checkpoints(
|
|
|
1686
1698
|
"`using implicit team` line, or the "
|
|
1687
1699
|
"`skipped (concurrent-run)` variant in the concurrent-run path",
|
|
1688
1700
|
)
|
|
1689
|
-
|
|
1701
|
+
# critic 은 `critic` dispatch 로만 뜨고 `phase-5.6-critic` 으로 알린다.
|
|
1702
|
+
critic_ids = _critic_worker_ids(team_state)
|
|
1703
|
+
_check_worker_checkpoint_lines(
|
|
1704
|
+
by_phase,
|
|
1705
|
+
[w for w in analysis_workers if str(w.get("workerId", "")) not in critic_ids],
|
|
1706
|
+
narration,
|
|
1707
|
+
)
|
|
1690
1708
|
convergence_ran = _convergence_rounds_ran(run_dir, suffix)
|
|
1691
1709
|
require(
|
|
1692
1710
|
"phase-5.5-convergence",
|