okstra 0.221.0 → 0.223.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 +5 -5
- 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/analysis_packet.py +8 -6
- 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/phases/release_handoff/entry.py +42 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +52 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +3 -2
- package/runtime/python/okstra_ctl/phases/release_handoff/report_assets/release-handoff-input.template.md +3 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
- 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 +46 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +22 -1
- package/runtime/python/okstra_ctl/run.py +8 -5
- package/runtime/python/okstra_ctl/stage_reconcile.py +49 -1
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +3 -3
- package/runtime/python/okstra_ctl/worktree/decisions.py +42 -5
- package/runtime/python/okstra_ctl/worktree/provision.py +49 -2
- package/runtime/schemas/final-report-v2.0.schema.json +7 -2
- package/runtime/schemas/final-report-v3.0.schema.json +43 -2
- 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 +10 -6
- package/runtime/validators/validate_session_conformance.py +19 -1
|
@@ -11,7 +11,12 @@ from dataclasses import dataclass
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
from .. import worktree_registry
|
|
14
|
-
from .git_ops import
|
|
14
|
+
from .git_ops import (
|
|
15
|
+
_branch_checkout_path,
|
|
16
|
+
_branch_exists,
|
|
17
|
+
_is_inside_non_main_worktree,
|
|
18
|
+
is_git_work_tree,
|
|
19
|
+
)
|
|
15
20
|
from .naming import _safe_segment, compute_branch_name, compute_worktree_path
|
|
16
21
|
|
|
17
22
|
|
|
@@ -24,11 +29,19 @@ class WorktreeDecision:
|
|
|
24
29
|
- "reused": registry already has this task-key; existing path/branch returned
|
|
25
30
|
- "skipped-in-worktree": project_root is itself a non-main worktree
|
|
26
31
|
- "skipped-not-git": project_root has no .git
|
|
32
|
+
|
|
33
|
+
recovery (only when the active registry row no longer matches git):
|
|
34
|
+
- "restore-from-branch": status "reused"; the recorded branch survives but
|
|
35
|
+
is not checked out at the recorded path, so provision re-adds it there
|
|
36
|
+
- "replace-stale-row": status "new"; the recorded branch is gone, so
|
|
37
|
+
provision releases the row and creates a fresh worktree from the
|
|
38
|
+
requested base, or the recorded base when none is requested
|
|
27
39
|
"""
|
|
28
40
|
status: str
|
|
29
41
|
path: str # worktree path (new: prospective; reuse: existing; skip: project_root)
|
|
30
42
|
branch: str = "" # new: prospective branch; reused: existing branch
|
|
31
43
|
base_ref: str = "" # new: requested base_ref; reused: existing base
|
|
44
|
+
recovery: str = ""
|
|
32
45
|
|
|
33
46
|
|
|
34
47
|
def preview_worktree_decision(
|
|
@@ -54,11 +67,21 @@ def preview_worktree_decision(
|
|
|
54
67
|
safe_group = _safe_segment(task_group_segment)
|
|
55
68
|
safe_task = _safe_segment(task_id_segment)
|
|
56
69
|
existing = worktree_registry.lookup(safe_project, safe_group, safe_task)
|
|
70
|
+
recovery = ""
|
|
57
71
|
if existing is not None and existing.status == "active":
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
if _is_checked_out_at(project_root, existing.branch, existing.worktree_path):
|
|
73
|
+
return WorktreeDecision(
|
|
74
|
+
status="reused", path=existing.worktree_path,
|
|
75
|
+
branch=existing.branch, base_ref=existing.base_ref,
|
|
76
|
+
)
|
|
77
|
+
if _branch_exists(project_root, existing.branch):
|
|
78
|
+
return WorktreeDecision(
|
|
79
|
+
status="reused", path=existing.worktree_path,
|
|
80
|
+
branch=existing.branch, base_ref=existing.base_ref,
|
|
81
|
+
recovery="restore-from-branch",
|
|
82
|
+
)
|
|
83
|
+
recovery = "replace-stale-row"
|
|
84
|
+
base_ref = base_ref or existing.base_ref
|
|
62
85
|
return WorktreeDecision(
|
|
63
86
|
status="new",
|
|
64
87
|
path=str(compute_worktree_path(
|
|
@@ -66,9 +89,23 @@ def preview_worktree_decision(
|
|
|
66
89
|
task_id_segment=safe_task)),
|
|
67
90
|
branch=compute_branch_name(work_category=work_category, task_id_segment=safe_task),
|
|
68
91
|
base_ref=base_ref,
|
|
92
|
+
recovery=recovery,
|
|
69
93
|
)
|
|
70
94
|
|
|
71
95
|
|
|
96
|
+
def _is_checked_out_at(project_root: Path, branch: str, worktree_path: str) -> bool:
|
|
97
|
+
"""Whether git itself has `branch` checked out at `worktree_path`.
|
|
98
|
+
|
|
99
|
+
An `active` registry row only records what okstra reserved; the directory
|
|
100
|
+
can be removed by hand and the branch deleted after a merge without the
|
|
101
|
+
registry hearing about it.
|
|
102
|
+
"""
|
|
103
|
+
checkout = _branch_checkout_path(project_root, branch)
|
|
104
|
+
if not checkout or not Path(worktree_path).is_dir():
|
|
105
|
+
return False
|
|
106
|
+
return Path(checkout).resolve() == Path(worktree_path).resolve()
|
|
107
|
+
|
|
108
|
+
|
|
72
109
|
@dataclass
|
|
73
110
|
class StageWorktreeDecision:
|
|
74
111
|
"""Side-effect-free decision for one concrete implementation stage."""
|
|
@@ -141,6 +141,10 @@ def provision_task_worktree(
|
|
|
141
141
|
safe_task = _safe_segment(task_id_segment)
|
|
142
142
|
|
|
143
143
|
if decision.status == "reused":
|
|
144
|
+
restored = ""
|
|
145
|
+
if decision.recovery == "restore-from-branch":
|
|
146
|
+
_restore_task_worktree(project_root, Path(decision.path), decision.branch)
|
|
147
|
+
restored = "; restored from its branch (directory was not a worktree)"
|
|
144
148
|
worktree_registry.touch_phase(safe_project, safe_group, safe_task, task_type)
|
|
145
149
|
_seed_worktree_settings_symlink(Path(decision.path))
|
|
146
150
|
drift = _sync_key_drift_note(
|
|
@@ -155,7 +159,7 @@ def provision_task_worktree(
|
|
|
155
159
|
note=(
|
|
156
160
|
f"task worktree reused at {decision.path} on branch "
|
|
157
161
|
f"{decision.branch} (base {decision.base_ref[:12]}); phase {task_type}"
|
|
158
|
-
f"{drift}"
|
|
162
|
+
f"{restored}{drift}"
|
|
159
163
|
),
|
|
160
164
|
)
|
|
161
165
|
|
|
@@ -163,6 +167,8 @@ def provision_task_worktree(
|
|
|
163
167
|
worktree_path = Path(decision.path)
|
|
164
168
|
branch = decision.branch
|
|
165
169
|
|
|
170
|
+
if decision.recovery == "replace-stale-row":
|
|
171
|
+
_remove_settings_seed_shell(worktree_path)
|
|
166
172
|
if worktree_path.exists():
|
|
167
173
|
raise RuntimeError(
|
|
168
174
|
f"task worktree path already exists but is not in the registry: "
|
|
@@ -173,7 +179,7 @@ def provision_task_worktree(
|
|
|
173
179
|
raise RuntimeError(_branch_exists_message(project_root, branch, "task"))
|
|
174
180
|
|
|
175
181
|
main_root = main_worktree_path(project_root)
|
|
176
|
-
requested_base = (base_ref or "").strip()
|
|
182
|
+
requested_base = (decision.base_ref or "").strip()
|
|
177
183
|
if not requested_base and require_base_ref:
|
|
178
184
|
raise RuntimeError(
|
|
179
185
|
"first-phase task worktree requires an explicit base ref; "
|
|
@@ -221,8 +227,15 @@ def provision_task_worktree(
|
|
|
221
227
|
linked_parts.append(f"linked-files {', '.join(linked_files)}")
|
|
222
228
|
if snapshot_files:
|
|
223
229
|
linked_parts.append(f"snapshot {', '.join(snapshot_files)}")
|
|
230
|
+
if decision.recovery == "replace-stale-row":
|
|
231
|
+
linked_parts.append(
|
|
232
|
+
"replaced a stale registry row whose recorded branch no longer exists")
|
|
224
233
|
linked_suffix = ("; " + "; ".join(linked_parts)) if linked_parts else ""
|
|
225
234
|
|
|
235
|
+
if decision.recovery == "replace-stale-row":
|
|
236
|
+
# The recorded branch is already deleted, so freeing its slot keeps the
|
|
237
|
+
# registry's "a free branch name is physically deleted" invariant.
|
|
238
|
+
worktree_registry.release(safe_project, safe_group, safe_task)
|
|
226
239
|
try:
|
|
227
240
|
worktree_registry.reserve(
|
|
228
241
|
project_id=safe_project,
|
|
@@ -260,6 +273,40 @@ def provision_task_worktree(
|
|
|
260
273
|
)
|
|
261
274
|
|
|
262
275
|
|
|
276
|
+
def _remove_settings_seed_shell(path: Path) -> None:
|
|
277
|
+
"""Remove a directory that holds nothing but okstra's settings seed.
|
|
278
|
+
|
|
279
|
+
Earlier prepares re-seeded `.claude/settings.local.json` into the recorded
|
|
280
|
+
path of a stale registry row, which left exactly this shell behind and made
|
|
281
|
+
every later prepare refuse the path. Anything else in the directory is
|
|
282
|
+
left alone.
|
|
283
|
+
"""
|
|
284
|
+
seed = path / ".claude" / "settings.local.json"
|
|
285
|
+
if not path.is_dir() or not (seed.is_symlink() or seed.is_file()):
|
|
286
|
+
return
|
|
287
|
+
if [p.name for p in path.iterdir()] != [".claude"]:
|
|
288
|
+
return
|
|
289
|
+
if [p.name for p in (path / ".claude").iterdir()] != ["settings.local.json"]:
|
|
290
|
+
return
|
|
291
|
+
seed.unlink()
|
|
292
|
+
(path / ".claude").rmdir()
|
|
293
|
+
path.rmdir()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _restore_task_worktree(project_root: Path, path: Path, branch: str) -> None:
|
|
297
|
+
_remove_settings_seed_shell(path)
|
|
298
|
+
if path.exists():
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
f"task worktree path {path} exists but is not a worktree of branch "
|
|
301
|
+
f"{branch}; move it aside so okstra can re-add the worktree from "
|
|
302
|
+
"that branch."
|
|
303
|
+
)
|
|
304
|
+
# A directory deleted by hand stays registered in git until pruned, and
|
|
305
|
+
# `git worktree add` refuses a missing-but-registered path.
|
|
306
|
+
_git(main_worktree_path(project_root), "worktree", "prune")
|
|
307
|
+
restore_stage_worktree(project_root, str(path), branch)
|
|
308
|
+
|
|
309
|
+
|
|
263
310
|
def restore_stage_worktree(project_root: Path, path: str, branch: str) -> None:
|
|
264
311
|
target = Path(path)
|
|
265
312
|
if target.exists():
|
|
@@ -481,11 +481,12 @@
|
|
|
481
481
|
},
|
|
482
482
|
"criticGaps": {
|
|
483
483
|
"type": "object",
|
|
484
|
-
"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\").",
|
|
484
|
+
"description": "Coverage-critic gap accounting. `proposed - merged - unverified - duplicate` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. `duplicate` counts gaps the critic declared duplicates of an existing finding; they are never verified. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
|
|
485
485
|
"required": [
|
|
486
486
|
"proposed",
|
|
487
487
|
"merged",
|
|
488
|
-
"unverified"
|
|
488
|
+
"unverified",
|
|
489
|
+
"duplicate"
|
|
489
490
|
],
|
|
490
491
|
"additionalProperties": false,
|
|
491
492
|
"properties": {
|
|
@@ -500,6 +501,10 @@
|
|
|
500
501
|
"unverified": {
|
|
501
502
|
"type": "integer",
|
|
502
503
|
"minimum": 0
|
|
504
|
+
},
|
|
505
|
+
"duplicate": {
|
|
506
|
+
"type": "integer",
|
|
507
|
+
"minimum": 0
|
|
503
508
|
}
|
|
504
509
|
}
|
|
505
510
|
},
|
|
@@ -480,13 +480,50 @@
|
|
|
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
|
-
"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\").",
|
|
521
|
+
"description": "Coverage-critic gap accounting. `proposed - merged - unverified - duplicate` is the count the voters judged and rejected, so a gap that vanished shows up as a mismatch instead of as nothing. `duplicate` counts gaps the critic declared duplicates of an existing finding; they are never verified. Every `unverified` gap needs a matching `missingInformation` row with source `critic-unverified` (prompts/lead/convergence.md \"Gap verification\").",
|
|
486
522
|
"required": [
|
|
487
523
|
"proposed",
|
|
488
524
|
"merged",
|
|
489
|
-
"unverified"
|
|
525
|
+
"unverified",
|
|
526
|
+
"duplicate"
|
|
490
527
|
],
|
|
491
528
|
"additionalProperties": false,
|
|
492
529
|
"properties": {
|
|
@@ -501,6 +538,10 @@
|
|
|
501
538
|
"unverified": {
|
|
502
539
|
"type": "integer",
|
|
503
540
|
"minimum": 0
|
|
541
|
+
},
|
|
542
|
+
"duplicate": {
|
|
543
|
+
"type": "integer",
|
|
544
|
+
"minimum": 0
|
|
504
545
|
}
|
|
505
546
|
}
|
|
506
547
|
},
|
|
@@ -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": "내 답변 내보내기",
|
|
@@ -4233,12 +4233,13 @@ def _validate_unverified_critic_gaps_recorded(data: dict, failures: list[str]) -
|
|
|
4233
4233
|
|
|
4234
4234
|
proposed = critic.get("proposed")
|
|
4235
4235
|
merged = critic.get("merged")
|
|
4236
|
-
|
|
4237
|
-
|
|
4236
|
+
duplicate = critic.get("duplicate")
|
|
4237
|
+
if isinstance(proposed, int) and isinstance(merged, int) and isinstance(duplicate, int):
|
|
4238
|
+
if merged + unverified + duplicate > proposed:
|
|
4238
4239
|
failures.append(
|
|
4239
4240
|
"final-report data.json: crossVerification.criticGaps has "
|
|
4240
|
-
f"merged({merged}) + unverified({unverified})
|
|
4241
|
-
"— the accounting does not describe a real set of gaps."
|
|
4241
|
+
f"merged({merged}) + unverified({unverified}) + duplicate({duplicate}) "
|
|
4242
|
+
f"> proposed({proposed}) — the accounting does not describe a real set of gaps."
|
|
4242
4243
|
)
|
|
4243
4244
|
|
|
4244
4245
|
|
|
@@ -4854,8 +4855,10 @@ def _validate_convergence_group_provenance(run_dir, failures, suffix=None) -> No
|
|
|
4854
4855
|
# provider token (`claude` vs `claude-worker`), depending on how the roster was
|
|
4855
4856
|
# seeded — worker resolution below accepts both. `-reverify-r` never occurs
|
|
4856
4857
|
# inside a slug, so the non-greedy slug capture stops at that literal anchor.
|
|
4858
|
+
# A correction round's re-convergence puts `correction-r<N>-` before
|
|
4859
|
+
# `reverify-r`, and its plans carry the same token before `<task-type>`.
|
|
4857
4860
|
_REVERIFY_PROMPT_BASENAME_RE = re.compile(
|
|
4858
|
-
r"^(?P<slug>[a-z][a-z0-9-]*?)-reverify-r(?P<round>\d+)"
|
|
4861
|
+
r"^(?P<slug>[a-z][a-z0-9-]*?)(?:-(?P<scope>correction-r\d+))?-reverify-r(?P<round>\d+)"
|
|
4859
4862
|
r"-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
|
|
4860
4863
|
)
|
|
4861
4864
|
|
|
@@ -5318,8 +5321,9 @@ def _validate_reverify_prompt_matches_plan(run_dir, failures, suffix=None) -> No
|
|
|
5318
5321
|
if match is None:
|
|
5319
5322
|
continue
|
|
5320
5323
|
round_n = match.group("round")
|
|
5324
|
+
scope = f"{match.group('scope')}-" if match.group("scope") else ""
|
|
5321
5325
|
plan_path = state_dir / (
|
|
5322
|
-
f"convergence-round-{round_n}-plan-"
|
|
5326
|
+
f"convergence-round-{round_n}-plan-{scope}"
|
|
5323
5327
|
f"{match.group('task_type')}-{match.group('seq')}.json"
|
|
5324
5328
|
)
|
|
5325
5329
|
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",
|