okstra 0.158.1 → 0.160.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 +1 -1
- package/docs/architecture/storage-model.md +2 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +8 -3
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +3 -0
- package/docs/for-ai/skills/okstra-run.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +5 -5
- package/docs/project-structure-overview.md +5 -1
- package/docs/task-process/implementation.md +28 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-claude-exec.sh +4 -1
- package/runtime/prompts/host-orchestration/README.md +18 -0
- package/runtime/prompts/host-orchestration/implementation.md +57 -0
- package/runtime/prompts/launch.template.md +10 -1
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/context-loader.md +5 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +21 -2
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +2 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +3 -0
- package/runtime/python/okstra_ctl/clarification_items.py +9 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -2
- package/runtime/python/okstra_ctl/error_issue.py +640 -0
- package/runtime/python/okstra_ctl/error_report.py +56 -0
- package/runtime/python/okstra_ctl/error_zip.py +23 -10
- package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
- package/runtime/python/okstra_ctl/issue_signals.py +186 -0
- package/runtime/python/okstra_ctl/paths.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
- package/runtime/python/okstra_ctl/profile_show.py +134 -0
- package/runtime/python/okstra_ctl/recap.py +63 -0
- package/runtime/python/okstra_ctl/render_final_report.py +11 -62
- package/runtime/python/okstra_ctl/report_html/filters.py +6 -1
- package/runtime/python/okstra_ctl/report_html/render.py +9 -8
- package/runtime/python/okstra_ctl/report_html/run_usage.py +110 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +69 -16
- package/runtime/python/okstra_ctl/report_html/visualizations.py +107 -14
- package/runtime/python/okstra_ctl/report_translation.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +7 -3
- package/runtime/python/okstra_ctl/run.py +41 -2
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/usage_cells.py +47 -0
- package/runtime/python/okstra_ctl/user_response.py +25 -10
- package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
- package/runtime/python/okstra_ctl/wizard.py +64 -10
- package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
- package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/schemas/final-report-v2.0.schema.json +56 -2
- package/runtime/skills/okstra-inspect/SKILL.md +3 -1
- package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
- package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
- package/runtime/skills/okstra-run/SKILL.md +28 -10
- package/runtime/skills/okstra-user-response/SKILL.md +18 -18
- package/runtime/templates/reports/final-report.template.md +4 -0
- package/runtime/templates/reports/html/assets/base.css +14 -1
- package/runtime/templates/reports/html/base.template.html +42 -0
- package/runtime/templates/reports/html/i18n/en.json +30 -1
- package/runtime/templates/reports/html/i18n/ko.json +30 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/macros/visualizations.html +3 -2
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
- package/runtime/templates/reports/i18n/en.json +2 -0
- package/runtime/validators/validate-run.py +331 -208
- package/runtime/validators/validate_session_conformance.py +102 -32
- package/src/cli-registry.mjs +34 -0
- package/src/commands/execute/incremental-scope.mjs +10 -0
- package/src/commands/execute/worker-audit-check.mjs +35 -0
- package/src/commands/inspect/error-issue.mjs +27 -0
- package/src/commands/inspect/profile-show.mjs +29 -0
- package/src/commands/inspect/run-audit.mjs +26 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Parser for the worker verdict block that plan-body and convergence share.
|
|
2
|
+
|
|
3
|
+
The response shape is fixed by contract (`prompts/lead/plan-body-verification.md`
|
|
4
|
+
§"Response format"), so the parser belongs here rather than in each lead. A
|
|
5
|
+
per-round ad-hoc regex makes the round's fidelity depend on whoever wrote it,
|
|
6
|
+
and its failure mode is silence: dev-10400 lost 19 of 37 assigned items to a
|
|
7
|
+
no-match that nothing reported. Every shape this module cannot read is an error.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
VERDICT_TOKENS = frozenset({
|
|
15
|
+
"AGREE", "DISAGREE", "SUPPLEMENT", "UNVERIFIABLE", "VERIFICATION-ERROR",
|
|
16
|
+
})
|
|
17
|
+
FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
|
|
18
|
+
|
|
19
|
+
# The convergence reverify prompts use their own vocabularies. Collaborative and
|
|
20
|
+
# full-reanalysis rounds speak the schema's own words; the adversarial round asks
|
|
21
|
+
# the verifier to break the finding, so it answers in break/survive terms that
|
|
22
|
+
# have to be translated back (`prompts/lead/convergence.md`
|
|
23
|
+
# §"Adversarial Re-verification Prompt").
|
|
24
|
+
COLLABORATIVE_VERDICTS = {
|
|
25
|
+
"AGREE": "agree",
|
|
26
|
+
"DISAGREE": "disagree",
|
|
27
|
+
"SUPPLEMENT": "supplement",
|
|
28
|
+
"UNVERIFIABLE": "unverifiable",
|
|
29
|
+
"VERIFICATION-ERROR": "verification-error",
|
|
30
|
+
}
|
|
31
|
+
ADVERSARIAL_VERDICTS = {
|
|
32
|
+
"SURVIVES": "agree",
|
|
33
|
+
"SURVIVES-WITH-CAVEAT": "supplement",
|
|
34
|
+
"REFUTED": "disagree",
|
|
35
|
+
# A verifier that looked and could not check is not a verifier that failed.
|
|
36
|
+
# `verification-error` drops the vote from the participating count, which
|
|
37
|
+
# shrinks the roster without saying so.
|
|
38
|
+
"UNVERIFIABLE": "unverifiable",
|
|
39
|
+
"VERIFICATION-ERROR": "verification-error",
|
|
40
|
+
}
|
|
41
|
+
DISAGREE_BASES = frozenset({"counter-evidence", "burden-not-met"})
|
|
42
|
+
|
|
43
|
+
_ITEM_RE = re.compile(r"^###[ \t]+(?P<id>[^\s:]+)[ \t]*:?.*$", re.MULTILINE)
|
|
44
|
+
# The contract writes some labels with a parenthetical qualifier —
|
|
45
|
+
# `**Fixability** (only when DISAGREE):` — so the colon may trail a `(...)`.
|
|
46
|
+
_FIELD_RE = re.compile(
|
|
47
|
+
r"^\*\*(?P<key>Verdict|Fixability|Note|Explanation|Prior dissent|Basis"
|
|
48
|
+
r"|Your evidence)\*\*"
|
|
49
|
+
r"(?:[ \t]*\([^)]*\))?[ \t]*:[ \t]*(?P<value>.*)$",
|
|
50
|
+
re.MULTILINE,
|
|
51
|
+
)
|
|
52
|
+
_VERDICT_RE = re.compile(r"^(?P<token>[A-Z-]+)(?:\((?P<kind>[a-f])\))?$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class VerdictBlockError(ValueError):
|
|
56
|
+
"""Raised when a worker response does not match the contract shape."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class FindingVote:
|
|
61
|
+
"""One worker's vote on one convergence finding, in schema vocabulary."""
|
|
62
|
+
|
|
63
|
+
finding_id: str
|
|
64
|
+
verdict: str
|
|
65
|
+
disagree_basis: str | None
|
|
66
|
+
explanation: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _scan_blocks(text: str) -> dict[str, dict[str, str]]:
|
|
70
|
+
"""Every `### <id>` block in *text*, as id → field map, in document order."""
|
|
71
|
+
matches = list(_ITEM_RE.finditer(text))
|
|
72
|
+
blocks: dict[str, dict[str, str]] = {}
|
|
73
|
+
for index, match in enumerate(matches):
|
|
74
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
|
75
|
+
item_id = match.group("id")
|
|
76
|
+
if item_id in blocks:
|
|
77
|
+
raise VerdictBlockError(f"item `{item_id}` appears twice in one response")
|
|
78
|
+
body = text[match.end():end]
|
|
79
|
+
blocks[item_id] = {
|
|
80
|
+
m.group("key"): m.group("value").strip() for m in _FIELD_RE.finditer(body)
|
|
81
|
+
}
|
|
82
|
+
return blocks
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_finding_votes(text: str, *, adversarial: bool) -> dict[str, FindingVote]:
|
|
86
|
+
"""Convergence reverify votes in *text*, keyed by finding id.
|
|
87
|
+
|
|
88
|
+
*adversarial* selects the vocabulary; guessing it from the content would make
|
|
89
|
+
an unfamiliar token silently read as a different verdict.
|
|
90
|
+
"""
|
|
91
|
+
vocabulary = ADVERSARIAL_VERDICTS if adversarial else COLLABORATIVE_VERDICTS
|
|
92
|
+
return {
|
|
93
|
+
finding_id: _finding_vote(finding_id, fields, vocabulary)
|
|
94
|
+
for finding_id, fields in _scan_blocks(text).items()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _finding_vote(
|
|
99
|
+
finding_id: str, fields: dict[str, str], vocabulary: dict[str, str]
|
|
100
|
+
) -> FindingVote:
|
|
101
|
+
raw = fields.get("Verdict", "")
|
|
102
|
+
token = raw.strip().strip("`").strip().upper()
|
|
103
|
+
if token not in vocabulary:
|
|
104
|
+
raise VerdictBlockError(
|
|
105
|
+
f"finding `{finding_id}` has an unknown verdict: {raw or '(missing)'} "
|
|
106
|
+
f"— expected one of {sorted(vocabulary)}"
|
|
107
|
+
)
|
|
108
|
+
verdict = vocabulary[token]
|
|
109
|
+
explanation = fields.get("Explanation", "")
|
|
110
|
+
if not explanation:
|
|
111
|
+
raise VerdictBlockError(
|
|
112
|
+
f"finding `{finding_id}` has no `**Explanation**:` line — every vote "
|
|
113
|
+
f"the classifier reads carries one"
|
|
114
|
+
)
|
|
115
|
+
basis = fields.get("Basis", "").strip() or None
|
|
116
|
+
if verdict != "disagree":
|
|
117
|
+
basis = None
|
|
118
|
+
elif basis is not None and basis not in DISAGREE_BASES:
|
|
119
|
+
raise VerdictBlockError(
|
|
120
|
+
f"finding `{finding_id}` has basis `{basis}` — expected one of "
|
|
121
|
+
f"{sorted(DISAGREE_BASES)}"
|
|
122
|
+
)
|
|
123
|
+
return FindingVote(finding_id, verdict, basis, explanation)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class VerdictBlock:
|
|
128
|
+
"""One worker's verdict on one item."""
|
|
129
|
+
|
|
130
|
+
item_id: str
|
|
131
|
+
verdict: str
|
|
132
|
+
breakage_kind: str
|
|
133
|
+
fixability: str
|
|
134
|
+
note: str
|
|
135
|
+
explanation: str
|
|
136
|
+
prior_dissent: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_verdict_blocks(text: str) -> dict[str, VerdictBlock]:
|
|
140
|
+
"""Every `### <item-id>` plan-body verdict block in *text*, keyed by item id."""
|
|
141
|
+
return {
|
|
142
|
+
item_id: _block(item_id, fields)
|
|
143
|
+
for item_id, fields in _scan_blocks(text).items()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _verdict_token(item_id: str, raw: str) -> tuple[str, str]:
|
|
148
|
+
parsed = _VERDICT_RE.match(raw.strip().strip("`").strip())
|
|
149
|
+
if parsed is None or parsed.group("token") not in VERDICT_TOKENS:
|
|
150
|
+
raise VerdictBlockError(
|
|
151
|
+
f"item `{item_id}` has an unknown verdict: {raw or '(missing)'}"
|
|
152
|
+
)
|
|
153
|
+
return parsed.group("token"), parsed.group("kind") or ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
|
|
157
|
+
raw = fields.get("Verdict", "")
|
|
158
|
+
if not raw:
|
|
159
|
+
raise VerdictBlockError(f"item `{item_id}` has no `**Verdict**:` line")
|
|
160
|
+
token, kind = _verdict_token(item_id, raw)
|
|
161
|
+
fixability = fields.get("Fixability", "")
|
|
162
|
+
if token == "DISAGREE":
|
|
163
|
+
if not kind:
|
|
164
|
+
raise VerdictBlockError(
|
|
165
|
+
f"item `{item_id}` is DISAGREE with no breakage kind — the gate "
|
|
166
|
+
f"reads the kind to decide whether one vote blocks, so a bare "
|
|
167
|
+
f"`DISAGREE` cannot be scored"
|
|
168
|
+
)
|
|
169
|
+
if fixability not in FIXABILITY_VALUES:
|
|
170
|
+
raise VerdictBlockError(
|
|
171
|
+
f"item `{item_id}` is DISAGREE with fixability "
|
|
172
|
+
f"`{fixability or '(missing)'}` — expected one of "
|
|
173
|
+
f"{sorted(FIXABILITY_VALUES)}"
|
|
174
|
+
)
|
|
175
|
+
return VerdictBlock(
|
|
176
|
+
item_id=item_id,
|
|
177
|
+
verdict=token,
|
|
178
|
+
breakage_kind=kind,
|
|
179
|
+
fixability=fixability,
|
|
180
|
+
note=fields.get("Note", ""),
|
|
181
|
+
explanation=fields.get("Explanation", ""),
|
|
182
|
+
prior_dissent=fields.get("Prior dissent", ""),
|
|
183
|
+
)
|
|
@@ -53,8 +53,10 @@ from okstra_ctl.lead_runtime import lead_runtime_info
|
|
|
53
53
|
from okstra_ctl.runner_resolution import native_provider_for_host
|
|
54
54
|
from okstra_ctl.clarification_items import (
|
|
55
55
|
scan_approval_gate,
|
|
56
|
+
sidecar_answers,
|
|
56
57
|
user_response_sidecars,
|
|
57
58
|
)
|
|
59
|
+
from okstra_ctl.incremental_scope import preview_link_availability_for_report
|
|
58
60
|
from okstra_ctl.design_prep import (
|
|
59
61
|
DesignPrepError,
|
|
60
62
|
load_design_prep_items,
|
|
@@ -94,6 +96,7 @@ from okstra_ctl.workers import (
|
|
|
94
96
|
from okstra_ctl.workflow import PHASE_SEQUENCE
|
|
95
97
|
from okstra_ctl.wizard_stage_intent import (
|
|
96
98
|
WHOLE_TASK_STAGE,
|
|
99
|
+
WizardStageIntent,
|
|
97
100
|
WizardStageIntentError,
|
|
98
101
|
resolve_wizard_stage_intent,
|
|
99
102
|
wizard_stage_confirmation_label,
|
|
@@ -4570,6 +4573,23 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
4570
4573
|
return {"echo": echo or "", "next": prompt_payload(state, nxt)}
|
|
4571
4574
|
|
|
4572
4575
|
|
|
4576
|
+
def _stage_intent(state: WizardState) -> WizardStageIntent:
|
|
4577
|
+
"""This state's stage selection, resolved once for every consumer.
|
|
4578
|
+
|
|
4579
|
+
`render_args` needs the single stage this run prepares; `wizard_outcome`
|
|
4580
|
+
needs the chain the skill drives. Deriving them separately would let the two
|
|
4581
|
+
disagree about which stage the run is for.
|
|
4582
|
+
"""
|
|
4583
|
+
try:
|
|
4584
|
+
return resolve_wizard_stage_intent(
|
|
4585
|
+
task_type=state.task_type,
|
|
4586
|
+
selected_stage=state.selected_stage,
|
|
4587
|
+
selected_stages=state.selected_stages,
|
|
4588
|
+
)
|
|
4589
|
+
except WizardStageIntentError as exc:
|
|
4590
|
+
raise WizardError(str(exc)) from exc
|
|
4591
|
+
|
|
4592
|
+
|
|
4573
4593
|
def render_args(state: WizardState) -> dict[str, str]:
|
|
4574
4594
|
"""Convert finalized state into ``okstra render-bundle`` argument map."""
|
|
4575
4595
|
if state.aborted:
|
|
@@ -4584,14 +4604,7 @@ def render_args(state: WizardState) -> dict[str, str]:
|
|
|
4584
4604
|
if state.reuse_worktree or state.task_type == "final-verification"
|
|
4585
4605
|
else state.base_ref
|
|
4586
4606
|
)
|
|
4587
|
-
|
|
4588
|
-
stage_intent = resolve_wizard_stage_intent(
|
|
4589
|
-
task_type=state.task_type,
|
|
4590
|
-
selected_stage=state.selected_stage,
|
|
4591
|
-
selected_stages=state.selected_stages,
|
|
4592
|
-
)
|
|
4593
|
-
except WizardStageIntentError as exc:
|
|
4594
|
-
raise WizardError(str(exc)) from exc
|
|
4607
|
+
stage_intent = _stage_intent(state)
|
|
4595
4608
|
pr_template = (
|
|
4596
4609
|
state.pr_template_path
|
|
4597
4610
|
if state.task_type == "release-handoff"
|
|
@@ -4624,7 +4637,6 @@ def render_args(state: WizardState) -> dict[str, str]:
|
|
|
4624
4637
|
"approved-plan": state.approved_plan_path,
|
|
4625
4638
|
"stage": stage_intent.stage,
|
|
4626
4639
|
"stages": state.handoff_stages,
|
|
4627
|
-
"chain-stages": stage_intent.chain_stages,
|
|
4628
4640
|
"base-ref": base_ref,
|
|
4629
4641
|
"workers": workers,
|
|
4630
4642
|
"directive": state.directive,
|
|
@@ -4643,6 +4655,37 @@ def render_args(state: WizardState) -> dict[str, str]:
|
|
|
4643
4655
|
}
|
|
4644
4656
|
|
|
4645
4657
|
|
|
4658
|
+
def _reverify_scope_line(state: WizardState) -> Optional[str]:
|
|
4659
|
+
"""이번 clarification 재실행이 좁혀질지 — 확인 단계에서 미리 보여주는 줄.
|
|
4660
|
+
|
|
4661
|
+
이 판정의 절반(답변된 id 가 stage 로 되짚어지는지)은 base SHA 없이 결정되고
|
|
4662
|
+
직전 리포트만 있으면 이미 정해져 있다. 그런데 지금까지는 `okstra recap
|
|
4663
|
+
assemble` 을 따로 돌려야만 보였고, run 이 시작된 뒤에 full 로 밝혀지면 두
|
|
4664
|
+
시간을 물린 뒤였다. 확인 단계는 그 전에 되돌릴 수 있는 마지막 지점이다.
|
|
4665
|
+
"""
|
|
4666
|
+
if state.task_type != "implementation-planning":
|
|
4667
|
+
return None
|
|
4668
|
+
if not state.clarification_response_path or not state.project_root:
|
|
4669
|
+
return None
|
|
4670
|
+
report = _resolve_path(
|
|
4671
|
+
state.clarification_response_path, Path(state.project_root)
|
|
4672
|
+
)
|
|
4673
|
+
if not report.is_file():
|
|
4674
|
+
return None
|
|
4675
|
+
preview = preview_link_availability_for_report(
|
|
4676
|
+
report, set(sidecar_answers(report))
|
|
4677
|
+
)
|
|
4678
|
+
if not preview["wouldForceFull"]:
|
|
4679
|
+
return _msg(state.workspace_root, "confirmation",
|
|
4680
|
+
"reverify_scope_incremental")
|
|
4681
|
+
if preview["unlinkedIds"]:
|
|
4682
|
+
return _msg(state.workspace_root, "confirmation",
|
|
4683
|
+
"reverify_scope_unlinked",
|
|
4684
|
+
ids=", ".join(preview["unlinkedIds"]))
|
|
4685
|
+
return _msg(state.workspace_root, "confirmation", "reverify_scope_full",
|
|
4686
|
+
reason=preview["reason"])
|
|
4687
|
+
|
|
4688
|
+
|
|
4646
4689
|
def confirmation_block(state: WizardState) -> str:
|
|
4647
4690
|
"""Human-readable echo of the resolved selections (for the Confirm step)."""
|
|
4648
4691
|
header = _msg(state.workspace_root, "confirmation", "header")
|
|
@@ -4727,6 +4770,9 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
4727
4770
|
lines.append(f" stage : {stage}")
|
|
4728
4771
|
if state.clarification_response_path:
|
|
4729
4772
|
lines.append(f" clarification : {state.clarification_response_path}")
|
|
4773
|
+
reverify_line = _reverify_scope_line(state)
|
|
4774
|
+
if reverify_line is not None:
|
|
4775
|
+
lines.append(reverify_line)
|
|
4730
4776
|
if state.task_type == "release-handoff" and state.handoff_mode:
|
|
4731
4777
|
scope = (
|
|
4732
4778
|
_msg(state.workspace_root, "confirmation",
|
|
@@ -4760,13 +4806,21 @@ def _wizard_persist_actions(state: WizardState) -> list[dict[str, str]]:
|
|
|
4760
4806
|
|
|
4761
4807
|
|
|
4762
4808
|
def wizard_outcome(state: WizardState) -> dict[str, Any]:
|
|
4763
|
-
"""Public outcome for callers that need launch data and follow-up writes.
|
|
4809
|
+
"""Public outcome for callers that need launch data and follow-up writes.
|
|
4810
|
+
|
|
4811
|
+
`renderArgs` carries only what `okstra render-bundle` accepts, so a caller
|
|
4812
|
+
can pass every entry through unfiltered — which is exactly what the
|
|
4813
|
+
okstra-run skill is told to do. Signals the skill consumes itself, like the
|
|
4814
|
+
unattended stage chain, live under `orchestration`; mixing them into
|
|
4815
|
+
`renderArgs` made the renderer reject the wizard's own output.
|
|
4816
|
+
"""
|
|
4764
4817
|
if state.aborted:
|
|
4765
4818
|
raise WizardError("wizard was aborted by the user — outcome is unavailable")
|
|
4766
4819
|
if state.confirmed is not True:
|
|
4767
4820
|
raise WizardError("wizard is not complete — outcome is unavailable")
|
|
4768
4821
|
return {
|
|
4769
4822
|
"renderArgs": render_args(state),
|
|
4823
|
+
"orchestration": {"chainStages": _stage_intent(state).chain_stages},
|
|
4770
4824
|
"persistActions": _wizard_persist_actions(state),
|
|
4771
4825
|
"confirmationText": confirmation_block(state),
|
|
4772
4826
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI adapter for the worker audit-sidecar contract (`okstra worker-audit-check`).
|
|
2
|
+
|
|
3
|
+
Phase 7 runs the same rules through `validate-run.py`, but by then the worker
|
|
4
|
+
session is gone and the only remedies left are a retroactive edit — which breaks
|
|
5
|
+
the audit chain — or a failed run. Called the moment a worker returns, the same
|
|
6
|
+
rules cost one message to a worker that is still listening.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from okstra_ctl.worker_audit_ledger import check_worker_results_audit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="okstra worker-audit-check",
|
|
21
|
+
description="Check one run's worker audit sidecars (read-only).",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--run-dir", type=Path, required=True,
|
|
24
|
+
help="runs/<task-type>/ for this run")
|
|
25
|
+
parser.add_argument("--task-type", required=True)
|
|
26
|
+
parser.add_argument("--seq", required=True,
|
|
27
|
+
help="this run's 3-digit seq")
|
|
28
|
+
parser.add_argument("--worker", default=None,
|
|
29
|
+
help="check only this worker id (default: every worker)")
|
|
30
|
+
return parser
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(argv: list[str] | None = None) -> int:
|
|
34
|
+
args = _parser().parse_args(argv)
|
|
35
|
+
failures = check_worker_results_audit(
|
|
36
|
+
args.run_dir, args.task_type, args.seq, worker=args.worker
|
|
37
|
+
)
|
|
38
|
+
print(json.dumps({"ok": not failures, "failures": failures},
|
|
39
|
+
ensure_ascii=False, indent=2))
|
|
40
|
+
return 2 if failures else 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The worker audit-sidecar contract, shared by Phase 7 and the mid-run check.
|
|
2
|
+
|
|
3
|
+
Phase 7 has always enforced this post-hoc, but by then the worker session is
|
|
4
|
+
gone and the only remedies left are editing the result after the fact — which
|
|
5
|
+
breaks the audit chain — or failing the run. `okstra worker-audit-check` runs
|
|
6
|
+
the same rules the moment a worker returns, while the worker is still listening
|
|
7
|
+
and can fix its own citation. Both consumers must agree on what a violation is,
|
|
8
|
+
so the rules live here rather than inside either one — the same split
|
|
9
|
+
`worker_heartbeat` makes for the heartbeat cadence.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from okstra_ctl.worker_prompt_headers import EVIDENCE_LEDGER_HEADER
|
|
17
|
+
|
|
18
|
+
# Worker-results filename pattern: `<worker-role>-<task-type>-<seq>.md`.
|
|
19
|
+
# Every analysis-worker role name ends in `-worker` (`claude-worker`,
|
|
20
|
+
# `codex-worker`, `antigravity-worker`, `report-writer-worker`), so anchor the
|
|
21
|
+
# split on that suffix — otherwise `antigravity-worker-error-analysis-001.md`
|
|
22
|
+
# ambiguously parses as `worker=antigravity, task=worker-error-analysis`.
|
|
23
|
+
# Audit sidecars (`*-audit-*`) and errors sidecars (`.json`) are not matched here.
|
|
24
|
+
_WORKER_RESULT_BASENAME_RE = re.compile(
|
|
25
|
+
r"^(?P<worker>[a-z][a-z0-9-]*-worker)-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
READING_CONFIRMATION_HEADING_RE = re.compile(
|
|
29
|
+
r"^##[ \t]+0\.[ \t]+Reading Confirmation\b", re.MULTILINE
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_EVIDENCE_READ_RE = re.compile(
|
|
33
|
+
r"^- Evidence read: `(?P<path>[^`\n]+)`\s*$",
|
|
34
|
+
re.MULTILINE,
|
|
35
|
+
)
|
|
36
|
+
_FILE_LINE_CITATION_RE = re.compile(
|
|
37
|
+
r"`(?P<path>(?!https?://)[^`\n]+?):(?P<line>\d+(?:-\d+)?)`"
|
|
38
|
+
)
|
|
39
|
+
_EXTENSIONLESS_SOURCE_FILENAMES = frozenset(
|
|
40
|
+
{"Dockerfile", "Justfile", "Makefile", "Procfile", "Rakefile"}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _looks_like_file_path(path: str) -> bool:
|
|
45
|
+
if (
|
|
46
|
+
not path
|
|
47
|
+
or path.startswith(("-", "$"))
|
|
48
|
+
or any(char.isspace() for char in path)
|
|
49
|
+
):
|
|
50
|
+
return False
|
|
51
|
+
if re.fullmatch(r"[0-9a-fA-F]{7,64}", path):
|
|
52
|
+
return False
|
|
53
|
+
return (
|
|
54
|
+
"/" in path
|
|
55
|
+
or "." in Path(path).name
|
|
56
|
+
or Path(path).name in _EXTENSIONLESS_SOURCE_FILENAMES
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cited_file_paths(content: str) -> set[str]:
|
|
61
|
+
paths: set[str] = set()
|
|
62
|
+
for match in _FILE_LINE_CITATION_RE.finditer(content):
|
|
63
|
+
path = match.group("path")
|
|
64
|
+
if _looks_like_file_path(path):
|
|
65
|
+
paths.add(path)
|
|
66
|
+
return paths
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _audit_evidence_read_paths(content: str) -> set[str]:
|
|
70
|
+
return {
|
|
71
|
+
match.group("path")
|
|
72
|
+
for match in _EVIDENCE_READ_RE.finditer(content)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _worker_prompt_path(
|
|
77
|
+
run_dir: Path,
|
|
78
|
+
worker_role: str,
|
|
79
|
+
task_type: str,
|
|
80
|
+
seq: str,
|
|
81
|
+
) -> Path:
|
|
82
|
+
return run_dir / "prompts" / f"{worker_role}-prompt-{task_type}-{seq}.md"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _evidence_read_ledger_failures(
|
|
86
|
+
*,
|
|
87
|
+
run_dir: Path,
|
|
88
|
+
worker_role: str,
|
|
89
|
+
task_type: str,
|
|
90
|
+
seq: str,
|
|
91
|
+
result_name: str,
|
|
92
|
+
result_content: str,
|
|
93
|
+
audit_path: Path,
|
|
94
|
+
) -> list[str]:
|
|
95
|
+
if worker_role == "report-writer-worker":
|
|
96
|
+
return []
|
|
97
|
+
prompt_path = _worker_prompt_path(run_dir, worker_role, task_type, seq)
|
|
98
|
+
try:
|
|
99
|
+
prompt_content = prompt_path.read_text(encoding="utf-8")
|
|
100
|
+
except OSError:
|
|
101
|
+
return []
|
|
102
|
+
if EVIDENCE_LEDGER_HEADER not in prompt_content.splitlines():
|
|
103
|
+
return []
|
|
104
|
+
try:
|
|
105
|
+
audit_content = audit_path.read_text(encoding="utf-8")
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
return [f"worker audit sidecar unreadable: {audit_path.name} ({exc})"]
|
|
108
|
+
|
|
109
|
+
missing_paths = sorted(
|
|
110
|
+
_cited_file_paths(result_content) - _audit_evidence_read_paths(audit_content)
|
|
111
|
+
)
|
|
112
|
+
return [
|
|
113
|
+
f"worker `{worker_role}` result `{result_name}` cites "
|
|
114
|
+
f"`{missing_path}:line` without an Evidence read row for "
|
|
115
|
+
f"`{missing_path}` in `{audit_path.name}`"
|
|
116
|
+
for missing_path in missing_paths
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _result_files(run_dir: Path, task_type: str, seq: str | None, worker: str | None):
|
|
121
|
+
"""Every worker-results file in *run_dir* this check owns, in name order."""
|
|
122
|
+
for path in sorted((run_dir / "worker-results").glob("*.md")):
|
|
123
|
+
if "-audit-" in path.name:
|
|
124
|
+
continue
|
|
125
|
+
match = _WORKER_RESULT_BASENAME_RE.match(path.name)
|
|
126
|
+
if match is None:
|
|
127
|
+
# Files that don't match the canonical pattern (e.g. ad-hoc notes
|
|
128
|
+
# left by the operator) are out of contract scope.
|
|
129
|
+
continue
|
|
130
|
+
if match.group("task_type") != task_type:
|
|
131
|
+
# Cross-phase artifacts shouldn't appear here; skip rather than
|
|
132
|
+
# fail to keep the check focused on the current phase.
|
|
133
|
+
continue
|
|
134
|
+
if seq is not None and match.group("seq") != seq:
|
|
135
|
+
# A prior run's artifact. Its contract was judged when it ran.
|
|
136
|
+
continue
|
|
137
|
+
if worker is not None and match.group("worker") != worker:
|
|
138
|
+
continue
|
|
139
|
+
yield path, match.group("worker"), match.group("seq")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_worker_results_audit(
|
|
143
|
+
run_dir: Path,
|
|
144
|
+
task_type: str,
|
|
145
|
+
seq: str | None,
|
|
146
|
+
*,
|
|
147
|
+
worker: str | None = None,
|
|
148
|
+
) -> list[str]:
|
|
149
|
+
"""Every audit-sidecar contract failure among this run's worker results.
|
|
150
|
+
|
|
151
|
+
*run_dir* is `runs/<task-type>/`; `worker-results/` and `prompts/` hang off
|
|
152
|
+
it. *seq* scopes the check to one run — `worker-results/` accumulates every
|
|
153
|
+
run's artifacts, so scanning the whole directory judged a run by files it
|
|
154
|
+
did not produce. Pass ``None`` only when the seq is genuinely unknown, which
|
|
155
|
+
falls back to not filtering rather than silently checking nothing.
|
|
156
|
+
|
|
157
|
+
For each result file this checks that it carries no `## 0. Reading
|
|
158
|
+
Confirmation` heading (that block moved to the sidecar), that the matching
|
|
159
|
+
sidecar exists, and — for prompts carrying the required-v1 evidence-ledger
|
|
160
|
+
marker — that every backticked `path:line` citation has an Evidence read row.
|
|
161
|
+
"""
|
|
162
|
+
failures: list[str] = []
|
|
163
|
+
if not (run_dir / "worker-results").is_dir():
|
|
164
|
+
# No worker-results directory means no analysis workers ran (e.g.
|
|
165
|
+
# `release-handoff`, which is single-lead). Nothing to enforce.
|
|
166
|
+
return failures
|
|
167
|
+
|
|
168
|
+
for path, worker_role, result_seq in _result_files(run_dir, task_type, seq, worker):
|
|
169
|
+
rel = path.name
|
|
170
|
+
try:
|
|
171
|
+
content = path.read_text()
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
failures.append(f"worker-results file unreadable: {rel} ({exc})")
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if READING_CONFIRMATION_HEADING_RE.search(content) is not None:
|
|
177
|
+
failures.append(
|
|
178
|
+
f"worker-results file `{rel}` contains a `## 0. Reading "
|
|
179
|
+
f"Confirmation` heading — that block moved to the audit "
|
|
180
|
+
f"sidecar (`{worker_role}-audit-{task_type}-{result_seq}.md`). "
|
|
181
|
+
f"Remove the §0 heading + body from the main file and "
|
|
182
|
+
f"write a fresh sidecar."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
audit_path = (
|
|
186
|
+
run_dir / "worker-results"
|
|
187
|
+
/ f"{worker_role}-audit-{task_type}-{result_seq}.md"
|
|
188
|
+
)
|
|
189
|
+
if not audit_path.exists():
|
|
190
|
+
failures.append(
|
|
191
|
+
f"worker `{worker_role}` produced `{rel}` but no audit sidecar "
|
|
192
|
+
f"at `{audit_path.name}` — the sidecar must carry the Reading "
|
|
193
|
+
f"Confirmation block (one short line per input file). Workers "
|
|
194
|
+
f"write this in the same step as the main worker-results file."
|
|
195
|
+
)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
failures.extend(_evidence_read_ledger_failures(
|
|
199
|
+
run_dir=run_dir,
|
|
200
|
+
worker_role=worker_role,
|
|
201
|
+
task_type=task_type,
|
|
202
|
+
seq=result_seq,
|
|
203
|
+
result_name=rel,
|
|
204
|
+
result_content=content,
|
|
205
|
+
audit_path=audit_path,
|
|
206
|
+
))
|
|
207
|
+
return failures
|
|
@@ -18,6 +18,10 @@ HEARTBEAT_LINE_RE = re.compile(
|
|
|
18
18
|
r"^-[ \t]*PROGRESS:[ \t]*(?P<stage>\S+)[ \t]+(?P<ts>\S+)[ \t]*$", re.MULTILINE
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
+
# 한 단계가 cadence 보다 길어질 때 워커가 append 하는 진행 라인의 접두사
|
|
22
|
+
# (claude-worker.md "Heartbeat"). 뒤에 붙는 stage 이름은 원래 단계 그대로다.
|
|
23
|
+
IN_STAGE_PREFIX = "in-stage:"
|
|
24
|
+
|
|
21
25
|
# 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
|
|
22
26
|
# 고정 grace 60초를 더한다.
|
|
23
27
|
HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
|
|
@@ -48,10 +52,12 @@ def max_gap_seconds_after(stage: str) -> int:
|
|
|
48
52
|
"""*stage* 를 알린 뒤 다음 하트비트까지 허용되는 최대 공백(초).
|
|
49
53
|
|
|
50
54
|
간격이 재는 것은 직전에 선언된 단계의 작업 시간이므로, 예산은 언제나
|
|
51
|
-
구간을 *여는* 단계에서 고른다.
|
|
52
|
-
|
|
55
|
+
구간을 *여는* 단계에서 고른다. `in-stage:<X>` 는 아직 X 안에 있다는 뜻이라
|
|
56
|
+
그 라인이 여는 구간도 여전히 X 의 일부다 — 접두사를 떼고 X 의 예산을 쓴다."""
|
|
57
|
+
opener = stage.removeprefix(IN_STAGE_PREFIX)
|
|
58
|
+
if opener in SINGLE_WRITE_STAGES:
|
|
53
59
|
return SINGLE_WRITE_MAX_GAP_SECONDS
|
|
54
|
-
if
|
|
60
|
+
if opener in SYNTHESIS_STAGES:
|
|
55
61
|
return SYNTHESIS_MAX_GAP_SECONDS
|
|
56
62
|
return HEARTBEAT_MAX_GAP_SECONDS
|
|
57
63
|
|