okstra 0.207.0 → 0.208.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 +3 -2
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +1 -1
- package/dist/commands/lifecycle/doctor.mjs +1 -1
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/docs/architecture/storage-model.md +14 -0
- package/docs/architecture.md +30 -9
- package/docs/cli.md +26 -22
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +15 -8
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/operations/explain-flow.json +6 -0
- package/runtime/bin/lib/okstra/cli.sh +1 -5
- package/runtime/bin/lib/okstra/globals.sh +0 -2
- package/runtime/bin/lib/okstra/usage.sh +5 -3
- package/runtime/bin/okstra.sh +0 -2
- package/runtime/prompts/duties/business-flow-investigator.json +14 -0
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +22 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +10 -6
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +12 -17
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +0 -91
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +6 -0
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +11 -125
- package/runtime/python/okstra_ctl/agent/standalone.py +183 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +39 -8
- package/runtime/python/okstra_ctl/assignment_resolver.py +7 -1
- package/runtime/python/okstra_ctl/brief_frontmatter.py +10 -0
- package/runtime/python/okstra_ctl/business_flow/__init__.py +4 -0
- package/runtime/python/okstra_ctl/business_flow/cli.py +134 -0
- package/runtime/python/okstra_ctl/business_flow/contracts.py +268 -0
- package/runtime/python/okstra_ctl/business_flow/engine.py +518 -0
- package/runtime/python/okstra_ctl/business_flow/hooks.py +221 -0
- package/runtime/python/okstra_ctl/business_flow/invocation.py +170 -0
- package/runtime/python/okstra_ctl/business_flow/report.py +49 -0
- package/runtime/python/okstra_ctl/business_flow/source.py +206 -0
- package/runtime/python/okstra_ctl/business_flow/store.py +388 -0
- package/runtime/python/okstra_ctl/convergence.py +173 -2
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +18 -0
- package/runtime/python/okstra_ctl/convergence_provenance.py +8 -0
- package/runtime/python/okstra_ctl/coverage_census.py +596 -0
- package/runtime/python/okstra_ctl/design_surfaces.py +4 -0
- package/runtime/python/okstra_ctl/direct_work.py +1 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +7 -3
- package/runtime/python/okstra_ctl/doctor.py +12 -6
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/group_context.py +5 -4
- package/runtime/python/okstra_ctl/legacy_model_selection.py +7 -51
- package/runtime/python/okstra_ctl/manager_split.py +4 -1
- package/runtime/python/okstra_ctl/manager_view.py +9 -3
- package/runtime/python/okstra_ctl/model_io/lines.py +1 -24
- package/runtime/python/okstra_ctl/model_io/renderers.py +54 -41
- package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.md +0 -8
- package/runtime/python/okstra_ctl/phases/error_analysis/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/error_analysis/profile.md +6 -8
- package/runtime/python/okstra_ctl/phases/feature_analysis/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/feature_analysis/profile.md +0 -8
- package/runtime/python/okstra_ctl/phases/final_verification/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -8
- package/runtime/python/okstra_ctl/phases/implementation/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/profile.md +0 -6
- package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation-input.template.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +4 -3
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +1 -14
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +5 -8
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +3 -3
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +15 -5
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +9 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +4 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +25 -9
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +7 -10
- package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.md +4 -11
- package/runtime/python/okstra_ctl/phases/project_analysis/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/project_analysis/profile.md +0 -8
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.md +10 -8
- package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +3 -3
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +0 -4
- package/runtime/python/okstra_ctl/plan_items.py +1 -1
- package/runtime/python/okstra_ctl/render.py +10 -43
- package/runtime/python/okstra_ctl/render_final_report.py +3 -0
- package/runtime/python/okstra_ctl/report_assembly.py +15 -1
- package/runtime/python/okstra_ctl/report_finalize.py +40 -0
- package/runtime/python/okstra_ctl/report_html/render.py +3 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -2
- package/runtime/python/okstra_ctl/run.py +78 -409
- package/runtime/python/okstra_ctl/wizard/__init__.py +2 -24
- package/runtime/python/okstra_ctl/wizard/cli.py +3 -6
- package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -35
- package/runtime/python/okstra_ctl/wizard/engine.py +2 -4
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -88
- package/runtime/python/okstra_ctl/wizard/registry.py +36 -228
- package/runtime/python/okstra_ctl/wizard/render.py +2 -2
- package/runtime/python/okstra_ctl/wizard/roles.py +1 -3
- package/runtime/python/okstra_ctl/wizard/sources.py +9 -40
- package/runtime/python/okstra_ctl/wizard/state.py +36 -145
- package/runtime/python/okstra_ctl/wizard/statefile.py +27 -128
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +22 -10
- package/runtime/python/okstra_ctl/wizard/steps_options.py +5 -4
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +15 -565
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +9 -2
- package/runtime/schemas/business-flow-v1.schema.json +847 -0
- package/runtime/schemas/convergence-groups-v2.0.schema.json +7 -0
- package/runtime/skills/okstra-explain-flow/SKILL.md +42 -0
- package/runtime/skills/okstra-inspect/facets/history.md +5 -5
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/templates/manager/view.template.html +7 -4
- package/runtime/templates/reports/business-flow.template.md +106 -0
- package/runtime/templates/reports/html/base.template.html +14 -1
- package/runtime/templates/reports/html/business-flow.template.html +31 -0
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/worker-prompt-preamble.md +11 -2
- package/runtime/validators/checks/validate-prompt-metadata-01.py +10 -10
- package/runtime/validators/validate-run.py +70 -21
- package/runtime/validators/validate_analysis_report.py +21 -21
- package/runtime/python/okstra_ctl/workers.py +0 -133
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Coverage census: the fixed worklist every analysis worker judges cell by cell.
|
|
2
|
+
|
|
3
|
+
A worker used to decide for itself what to look at, so two runs of the same
|
|
4
|
+
model on the same input differed by 12-30% in the findings they raised. The
|
|
5
|
+
census fixes the worklist in code: the phase profile's `Census aspects` block
|
|
6
|
+
names the questions, the brief's end-state ids (and, per phase, the changed
|
|
7
|
+
files or the improvement lenses) name the subjects, and every analyser returns
|
|
8
|
+
one verdict per cell. A worker cannot add, drop, or reinterpret a cell.
|
|
9
|
+
|
|
10
|
+
Nothing here can stop a run. A profile without the block has no census, a brief
|
|
11
|
+
without end-state ids has no requirement cells, and a cell a worker leaves
|
|
12
|
+
without a verdict becomes a warning, never a failure.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Mapping, Sequence
|
|
20
|
+
|
|
21
|
+
from .convergence_provenance import id_occurs_wordbounded
|
|
22
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
23
|
+
from .phases.improvement_discovery.lenses import LENSES
|
|
24
|
+
from .scope_provenance import brief_end_state_id_sequence
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
CENSUS_SCHEMA_VERSION = "1.0"
|
|
28
|
+
CENSUS_ASPECTS_LINE = "- Census aspects:"
|
|
29
|
+
SCOPES = ("requirement", "task", "file", "lens")
|
|
30
|
+
_ASPECT_RE = re.compile(
|
|
31
|
+
r"^ - (?P<scope>[a-z]+) (?P<slug>[a-z0-9]+(?:-[a-z0-9]+)*):\s*(?P<question>\S.*)$"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CensusError(ValueError):
|
|
36
|
+
"""A profile's `Census aspects` block cannot be read."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Aspect:
|
|
41
|
+
scope: str
|
|
42
|
+
slug: str
|
|
43
|
+
question: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_census_aspects(profile_text: str) -> tuple[Aspect, ...]:
|
|
47
|
+
"""The aspects a profile declares, in order. No block means no census.
|
|
48
|
+
|
|
49
|
+
The block is a top-level bullet followed by two-space nested bullets, the
|
|
50
|
+
same layout `analysis_packet._extract_bullet_sections` reads.
|
|
51
|
+
"""
|
|
52
|
+
lines = profile_text.splitlines()
|
|
53
|
+
try:
|
|
54
|
+
start = next(
|
|
55
|
+
index for index, line in enumerate(lines)
|
|
56
|
+
if line.rstrip() == CENSUS_ASPECTS_LINE
|
|
57
|
+
)
|
|
58
|
+
except StopIteration:
|
|
59
|
+
# expected-miss: a phase without the block runs without a census.
|
|
60
|
+
return ()
|
|
61
|
+
aspects: list[Aspect] = []
|
|
62
|
+
for line in lines[start + 1:]:
|
|
63
|
+
if not line.strip():
|
|
64
|
+
continue
|
|
65
|
+
if not line.startswith(" "):
|
|
66
|
+
break
|
|
67
|
+
match = _ASPECT_RE.match(line.rstrip())
|
|
68
|
+
if match is None:
|
|
69
|
+
raise CensusError(f"census aspect line is malformed: {line.strip()!r}")
|
|
70
|
+
scope = match.group("scope")
|
|
71
|
+
if scope not in SCOPES:
|
|
72
|
+
raise CensusError(
|
|
73
|
+
f"census aspect scope {scope!r} is not one of {', '.join(SCOPES)}"
|
|
74
|
+
)
|
|
75
|
+
aspects.append(Aspect(scope, match.group("slug"), match.group("question").strip()))
|
|
76
|
+
keys = [(aspect.scope, aspect.slug) for aspect in aspects]
|
|
77
|
+
if len(keys) != len(set(keys)):
|
|
78
|
+
raise CensusError("census aspects repeat a scope and slug pair")
|
|
79
|
+
if sum(aspect.scope == "lens" for aspect in aspects) > 1:
|
|
80
|
+
# A lens cell id carries the lens alone (`C-L-<lens>`), so a second lens
|
|
81
|
+
# aspect would mint the same ids twice.
|
|
82
|
+
raise CensusError("census aspects declare more than one lens aspect")
|
|
83
|
+
return tuple(aspects)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_census(
|
|
87
|
+
*,
|
|
88
|
+
task_type: str,
|
|
89
|
+
aspects: Sequence[Aspect],
|
|
90
|
+
requirement_ids: Sequence[str],
|
|
91
|
+
changed_files: Sequence[str] = (),
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
"""Every cell for this run, in the order a worker should walk them."""
|
|
94
|
+
cells: list[dict[str, str]] = []
|
|
95
|
+
|
|
96
|
+
def add(cell_id: str, aspect: Aspect, subject: str) -> None:
|
|
97
|
+
cells.append({
|
|
98
|
+
"id": cell_id,
|
|
99
|
+
"scope": aspect.scope,
|
|
100
|
+
"aspect": aspect.slug,
|
|
101
|
+
"subject": subject,
|
|
102
|
+
"question": aspect.question,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
by_scope = {
|
|
106
|
+
scope: [aspect for aspect in aspects if aspect.scope == scope]
|
|
107
|
+
for scope in SCOPES
|
|
108
|
+
}
|
|
109
|
+
for requirement in dict.fromkeys(requirement_ids):
|
|
110
|
+
for aspect in by_scope["requirement"]:
|
|
111
|
+
add(f"C-{requirement}-{aspect.slug}", aspect, requirement)
|
|
112
|
+
for aspect in by_scope["task"]:
|
|
113
|
+
add(f"C-T-{aspect.slug}", aspect, "task")
|
|
114
|
+
for index, path in enumerate(sorted(set(filter(None, changed_files))), start=1):
|
|
115
|
+
for aspect in by_scope["file"]:
|
|
116
|
+
add(f"C-F-{index:03d}-{aspect.slug}", aspect, path)
|
|
117
|
+
for aspect in by_scope["lens"]:
|
|
118
|
+
for lens in LENSES:
|
|
119
|
+
add(f"C-L-{lens}", aspect, lens)
|
|
120
|
+
return {
|
|
121
|
+
"schemaVersion": CENSUS_SCHEMA_VERSION,
|
|
122
|
+
"taskType": task_type,
|
|
123
|
+
"cells": cells,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def census_for_run(
|
|
128
|
+
*,
|
|
129
|
+
task_type: str,
|
|
130
|
+
profile_text: str,
|
|
131
|
+
brief_path: Path,
|
|
132
|
+
changed_files: Sequence[str] = (),
|
|
133
|
+
) -> dict[str, Any] | None:
|
|
134
|
+
"""The run's census, or ``None`` when the phase profile declares none."""
|
|
135
|
+
aspects = parse_census_aspects(profile_text)
|
|
136
|
+
if not aspects:
|
|
137
|
+
return None
|
|
138
|
+
return build_census(
|
|
139
|
+
task_type=task_type,
|
|
140
|
+
aspects=aspects,
|
|
141
|
+
requirement_ids=brief_end_state_id_sequence(brief_path),
|
|
142
|
+
changed_files=changed_files,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def census_filename(task_type: str, state_sequence: str) -> str:
|
|
147
|
+
return f"coverage-census-{task_type}-{state_sequence}.json"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def census_state_paths(
|
|
151
|
+
project_root: Path, manifest: Mapping[str, Any],
|
|
152
|
+
) -> tuple[Path, Path] | None:
|
|
153
|
+
"""This run's (census, audit) paths, or ``None`` when the manifest cannot
|
|
154
|
+
name them. Either file may still be absent."""
|
|
155
|
+
run_dir = manifest.get("runDirectoryPath")
|
|
156
|
+
task_type = manifest.get("taskType")
|
|
157
|
+
sequences = manifest.get("runSequencesByCategory")
|
|
158
|
+
sequence = sequences.get("state") if isinstance(sequences, Mapping) else None
|
|
159
|
+
if not all(isinstance(value, str) and value for value in (run_dir, task_type, sequence)):
|
|
160
|
+
return None
|
|
161
|
+
state_dir = Path(project_root) / str(run_dir) / "state"
|
|
162
|
+
return (
|
|
163
|
+
state_dir / census_filename(str(task_type), str(sequence)),
|
|
164
|
+
state_dir / audit_filename(str(task_type), str(sequence)),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _cell_text(value: str) -> str:
|
|
169
|
+
return value.replace("|", "\\|")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def render_census_section(census: Mapping[str, Any], census_rel_path: str) -> str:
|
|
173
|
+
"""The packet's `## Coverage Census` block. Empty when there is no cell."""
|
|
174
|
+
cells = [cell for cell in census.get("cells") or [] if isinstance(cell, Mapping)]
|
|
175
|
+
if not cells:
|
|
176
|
+
return ""
|
|
177
|
+
rows = [
|
|
178
|
+
"## Coverage Census",
|
|
179
|
+
"",
|
|
180
|
+
"The fixed worklist for this run. okstra built it from the phase "
|
|
181
|
+
"profile's `Census aspects` and the brief's end-state ids; do not add, "
|
|
182
|
+
"drop, rename, or reinterpret a cell. Judge every cell and return one "
|
|
183
|
+
"line per cell under `Coverage Verdicts` in your result (worker preamble "
|
|
184
|
+
"§\"Worker output sections\"). A cell with no line is sent back to you "
|
|
185
|
+
"once, alone.",
|
|
186
|
+
"",
|
|
187
|
+
f"- Cell count: {len(cells)}",
|
|
188
|
+
f"- Canonical copy: `{census_rel_path}`",
|
|
189
|
+
"",
|
|
190
|
+
"| Cell | Scope | Subject | Question |",
|
|
191
|
+
"|---|---|---|---|",
|
|
192
|
+
]
|
|
193
|
+
for cell in cells:
|
|
194
|
+
rows.append(
|
|
195
|
+
f"| `{cell['id']}` | {cell['scope']} | `{_cell_text(str(cell['subject']))}` "
|
|
196
|
+
f"| {_cell_text(str(cell['question']))} |"
|
|
197
|
+
)
|
|
198
|
+
return "\n".join(rows)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_VERDICTS_HEADING_RE = re.compile(
|
|
202
|
+
r"^#{1,6}\s+(?:\d+\.\s*)?Coverage Verdicts\b", re.IGNORECASE
|
|
203
|
+
)
|
|
204
|
+
_HEADING_RE = re.compile(r"^#{1,6}\s")
|
|
205
|
+
_VERDICT_LINE_RE = re.compile(
|
|
206
|
+
r"^\s*[-*]\s+`?(?P<cell>C-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)`?\s*:\s*"
|
|
207
|
+
r"(?P<kind>clean|finding|n/a)(?![A-Za-z0-9/])(?P<rest>.*)$",
|
|
208
|
+
re.IGNORECASE,
|
|
209
|
+
)
|
|
210
|
+
# Some models write the section as a table (`| cell | verdict |`); a cited
|
|
211
|
+
# verdict must not be dropped for its layout.
|
|
212
|
+
_VERDICT_ROW_RE = re.compile(
|
|
213
|
+
r"^\s*\|\s*`?(?P<cell>C-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)`?\s*\|\s*"
|
|
214
|
+
r"(?P<kind>clean|finding|n/a)(?![A-Za-z0-9/])(?P<rest>.*?)\|?\s*$",
|
|
215
|
+
re.IGNORECASE,
|
|
216
|
+
)
|
|
217
|
+
_SEPARATORS = " \t—–-:|"
|
|
218
|
+
_CITATION_RE = re.compile(r"[^\s`]+:\d+")
|
|
219
|
+
_ITEM_ID_RE = re.compile(r"[`\[]?(?P<id>[A-Za-z0-9][A-Za-z0-9._-]*?)[`\]]?(?=$|[\s,;.)])")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class CellVerdicts:
|
|
224
|
+
"""One result's reading of its census cells.
|
|
225
|
+
|
|
226
|
+
``verdicts`` maps a judged cell to ``(kind, detail)``; ``missing`` maps an
|
|
227
|
+
unjudged cell to why it counts as unjudged; ``unknown`` lists cell ids the
|
|
228
|
+
result named that the census does not carry.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
verdicts: dict[str, tuple[str, str]]
|
|
232
|
+
missing: dict[str, str]
|
|
233
|
+
unknown: tuple[str, ...]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _split_verdict_section(text: str) -> tuple[list[str], str]:
|
|
237
|
+
"""The Coverage Verdicts lines, and the rest of the result."""
|
|
238
|
+
lines = text.splitlines()
|
|
239
|
+
start = next(
|
|
240
|
+
(index for index, line in enumerate(lines) if _VERDICTS_HEADING_RE.match(line)),
|
|
241
|
+
None,
|
|
242
|
+
)
|
|
243
|
+
if start is None:
|
|
244
|
+
return [], text
|
|
245
|
+
end = next(
|
|
246
|
+
(index for index in range(start + 1, len(lines)) if _HEADING_RE.match(lines[index])),
|
|
247
|
+
len(lines),
|
|
248
|
+
)
|
|
249
|
+
return lines[start + 1:end], "\n".join(lines[:start] + lines[end:])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _judge(kind: str, rest: str, body_texts: Sequence[str]) -> tuple[str, str] | str:
|
|
253
|
+
"""``(kind, detail)`` for a usable verdict, otherwise why it is unusable."""
|
|
254
|
+
detail = rest.strip().lstrip(_SEPARATORS).strip()
|
|
255
|
+
if kind == "clean":
|
|
256
|
+
if not _CITATION_RE.search(detail):
|
|
257
|
+
return "clean without a path:line citation"
|
|
258
|
+
return kind, detail
|
|
259
|
+
if kind == "n/a":
|
|
260
|
+
if not detail:
|
|
261
|
+
return "n/a without a reason"
|
|
262
|
+
return kind, detail
|
|
263
|
+
match = _ITEM_ID_RE.match(detail)
|
|
264
|
+
if match is None:
|
|
265
|
+
return "finding without an item id"
|
|
266
|
+
item_id = match.group("id")
|
|
267
|
+
if not any(id_occurs_wordbounded(text, item_id) for text in body_texts):
|
|
268
|
+
return f"finding cites `{item_id}`, which the result does not contain"
|
|
269
|
+
return kind, item_id
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def parse_cell_verdicts(
|
|
273
|
+
result_text: str,
|
|
274
|
+
cell_ids: Sequence[str],
|
|
275
|
+
*,
|
|
276
|
+
reference_texts: Sequence[str] = (),
|
|
277
|
+
) -> CellVerdicts:
|
|
278
|
+
"""Read a result's `Coverage Verdicts` section against ``cell_ids``.
|
|
279
|
+
|
|
280
|
+
``reference_texts`` are other results a `finding` may point into: a
|
|
281
|
+
gap-fill result can cite an item its worker's first result already raised.
|
|
282
|
+
The first usable line for a cell wins.
|
|
283
|
+
"""
|
|
284
|
+
section, body = _split_verdict_section(result_text)
|
|
285
|
+
wanted = list(dict.fromkeys(cell_ids))
|
|
286
|
+
known = set(wanted)
|
|
287
|
+
verdicts: dict[str, tuple[str, str]] = {}
|
|
288
|
+
reasons: dict[str, str] = {}
|
|
289
|
+
unknown: list[str] = []
|
|
290
|
+
for line in section:
|
|
291
|
+
match = _VERDICT_LINE_RE.match(line) or _VERDICT_ROW_RE.match(line)
|
|
292
|
+
if match is None:
|
|
293
|
+
continue
|
|
294
|
+
cell = match.group("cell")
|
|
295
|
+
if cell not in known:
|
|
296
|
+
if cell not in unknown:
|
|
297
|
+
unknown.append(cell)
|
|
298
|
+
continue
|
|
299
|
+
if cell in verdicts:
|
|
300
|
+
continue
|
|
301
|
+
judged = _judge(
|
|
302
|
+
match.group("kind").lower(), match.group("rest"), (body, *reference_texts)
|
|
303
|
+
)
|
|
304
|
+
if isinstance(judged, tuple):
|
|
305
|
+
verdicts[cell] = judged
|
|
306
|
+
reasons.pop(cell, None)
|
|
307
|
+
else:
|
|
308
|
+
reasons.setdefault(cell, judged)
|
|
309
|
+
no_section = "no Coverage Verdicts section" if not section else "no verdict line"
|
|
310
|
+
missing = {
|
|
311
|
+
cell: reasons.get(cell, no_section)
|
|
312
|
+
for cell in wanted if cell not in verdicts
|
|
313
|
+
}
|
|
314
|
+
return CellVerdicts(verdicts, missing, tuple(unknown))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
AUDIT_SCHEMA_VERSION = "1.0"
|
|
318
|
+
UNVERDICTED = "unverdicted"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def audit_filename(task_type: str, state_sequence: str) -> str:
|
|
322
|
+
return f"coverage-census-audit-{task_type}-{state_sequence}.json"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def audit_census(
|
|
326
|
+
census: Mapping[str, Any],
|
|
327
|
+
results: Mapping[str, str | None],
|
|
328
|
+
gapfills: Mapping[str, str | None] | None = None,
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
"""Per-worker coverage of ``census``, the cell x worker matrix, and the
|
|
331
|
+
cells two workers judged in opposite directions.
|
|
332
|
+
|
|
333
|
+
``results`` maps each analysis worker to its result text (``None``: the
|
|
334
|
+
file is missing). ``gapfills`` does the same for the one gap-fill dispatch;
|
|
335
|
+
a worker absent from it was not sent one. Whatever stays unjudged after
|
|
336
|
+
both is ``unverdicted`` — recorded, never raised.
|
|
337
|
+
"""
|
|
338
|
+
gapfills = gapfills or {}
|
|
339
|
+
cell_ids = [str(cell["id"]) for cell in census.get("cells") or []]
|
|
340
|
+
workers: list[dict[str, Any]] = []
|
|
341
|
+
judged: dict[str, dict[str, tuple[str, str]]] = {}
|
|
342
|
+
for worker, text in results.items():
|
|
343
|
+
first = parse_cell_verdicts(text or "", cell_ids)
|
|
344
|
+
verdicts = dict(first.verdicts)
|
|
345
|
+
missing = dict(first.missing)
|
|
346
|
+
if text is None:
|
|
347
|
+
missing = dict.fromkeys(cell_ids, "result file is missing")
|
|
348
|
+
gapfill_status = "not-sent"
|
|
349
|
+
if worker in gapfills:
|
|
350
|
+
gapfill_text = gapfills[worker]
|
|
351
|
+
gapfill_status = "missing" if gapfill_text is None else "read"
|
|
352
|
+
if gapfill_text is not None and missing:
|
|
353
|
+
second = parse_cell_verdicts(
|
|
354
|
+
gapfill_text, list(missing), reference_texts=(text or "",)
|
|
355
|
+
)
|
|
356
|
+
verdicts.update(second.verdicts)
|
|
357
|
+
missing = {
|
|
358
|
+
cell: second.missing.get(cell, reason)
|
|
359
|
+
for cell, reason in missing.items() if cell not in second.verdicts
|
|
360
|
+
}
|
|
361
|
+
judged[worker] = verdicts
|
|
362
|
+
workers.append({
|
|
363
|
+
"workerId": worker,
|
|
364
|
+
"result": "missing" if text is None else "read",
|
|
365
|
+
"gapfill": gapfill_status,
|
|
366
|
+
"verdictCount": len(verdicts),
|
|
367
|
+
UNVERDICTED: list(missing),
|
|
368
|
+
"unverdictedReasons": missing,
|
|
369
|
+
"unknownCellIds": list(first.unknown),
|
|
370
|
+
})
|
|
371
|
+
matrix = [
|
|
372
|
+
{
|
|
373
|
+
"cellId": cell,
|
|
374
|
+
"verdicts": {
|
|
375
|
+
worker: judged[worker][cell][0] if cell in judged[worker] else UNVERDICTED
|
|
376
|
+
for worker in judged
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
for cell in cell_ids
|
|
380
|
+
]
|
|
381
|
+
disagreements = []
|
|
382
|
+
suggestions = []
|
|
383
|
+
for cell in cell_ids:
|
|
384
|
+
findings = [
|
|
385
|
+
{"worker": worker, "itemId": verdicts[cell][1]}
|
|
386
|
+
for worker, verdicts in judged.items()
|
|
387
|
+
if verdicts.get(cell, ("",))[0] == "finding"
|
|
388
|
+
]
|
|
389
|
+
clean = [
|
|
390
|
+
worker for worker, verdicts in judged.items()
|
|
391
|
+
if verdicts.get(cell, ("",))[0] == "clean"
|
|
392
|
+
]
|
|
393
|
+
if findings:
|
|
394
|
+
suggestions.append({"cellId": cell, "items": findings})
|
|
395
|
+
if findings and clean:
|
|
396
|
+
disagreements.append({"cellId": cell, "finding": findings, "clean": clean})
|
|
397
|
+
return {
|
|
398
|
+
"schemaVersion": AUDIT_SCHEMA_VERSION,
|
|
399
|
+
"taskType": census.get("taskType"),
|
|
400
|
+
"cellCount": len(cell_ids),
|
|
401
|
+
# A single analyser still gets its matrix; there is just no second
|
|
402
|
+
# model to disagree with, and the report says so instead of the run
|
|
403
|
+
# being refused.
|
|
404
|
+
"crossCheck": "cell" if len(results) > 1 else "none",
|
|
405
|
+
"workers": workers,
|
|
406
|
+
"gapfillNeeded": [
|
|
407
|
+
row["workerId"] for row in workers
|
|
408
|
+
if row[UNVERDICTED] and row["gapfill"] == "not-sent"
|
|
409
|
+
],
|
|
410
|
+
"matrix": matrix,
|
|
411
|
+
"disagreements": disagreements,
|
|
412
|
+
"cellSuggestions": suggestions,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
PROMPT_DELIVERY_MODE = "eager-include"
|
|
417
|
+
_IDS_PER_LINE = 6
|
|
418
|
+
|
|
419
|
+
_GAPFILL_MANDATE = """
|
|
420
|
+
Your first result left the cells below without a usable verdict. Judge each
|
|
421
|
+
one now against its question in the packet's `## Coverage Census` table. This
|
|
422
|
+
is the only gap-fill pass: a cell left unjudged here is reported as
|
|
423
|
+
unverdicted.
|
|
424
|
+
|
|
425
|
+
Write your result with the frontmatter and headers the worker preamble
|
|
426
|
+
requires, and only these two sections:
|
|
427
|
+
|
|
428
|
+
- `## 1. Findings` — the new items a `finding` verdict below points to, each
|
|
429
|
+
with a worker-local ID your first result does not use and file:line
|
|
430
|
+
evidence. Write `- none` when there is none. A `finding` may also cite an
|
|
431
|
+
item ID from your first result.
|
|
432
|
+
- `## 6. Coverage Verdicts` — one line per cell listed below, in the
|
|
433
|
+
preamble's form: `clean — <path:line>`, `finding <ID>`, or `n/a — <reason>`.
|
|
434
|
+
|
|
435
|
+
Do not judge cells that are not listed, and do not repeat your first result.
|
|
436
|
+
""".strip()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def gapfill_prompt_body(
|
|
440
|
+
audit: Mapping[str, Any],
|
|
441
|
+
worker: str,
|
|
442
|
+
*,
|
|
443
|
+
task_key: str,
|
|
444
|
+
analysis_packet_path: str,
|
|
445
|
+
census_path: str,
|
|
446
|
+
) -> str:
|
|
447
|
+
"""The `census-gapfill` instruction body for one worker. Same input, same bytes."""
|
|
448
|
+
row = next(
|
|
449
|
+
(row for row in audit.get("workers") or [] if row.get("workerId") == worker),
|
|
450
|
+
None,
|
|
451
|
+
)
|
|
452
|
+
if row is None:
|
|
453
|
+
raise CensusError(f"the census audit has no worker `{worker}`")
|
|
454
|
+
if row.get("gapfill") != "not-sent":
|
|
455
|
+
raise CensusError(
|
|
456
|
+
f"`{worker}` already had its one census gap-fill; the retry is fixed at one"
|
|
457
|
+
)
|
|
458
|
+
missing: Mapping[str, str] = row.get("unverdictedReasons") or {}
|
|
459
|
+
if not missing:
|
|
460
|
+
raise CensusError(f"`{worker}` left no census cell unjudged")
|
|
461
|
+
if not analysis_packet_path.endswith("analysis-packet.md"):
|
|
462
|
+
raise CensusError(
|
|
463
|
+
"run manifest analysisPacketPath must name analysis-packet.md; "
|
|
464
|
+
f"found `{analysis_packet_path}`"
|
|
465
|
+
)
|
|
466
|
+
by_reason: dict[str, list[str]] = {}
|
|
467
|
+
for cell, reason in missing.items():
|
|
468
|
+
by_reason.setdefault(reason, []).append(cell)
|
|
469
|
+
rows = [
|
|
470
|
+
f"**Prompt Delivery Mode:** {PROMPT_DELIVERY_MODE}",
|
|
471
|
+
"",
|
|
472
|
+
f"# Coverage census gap-fill — {task_key}",
|
|
473
|
+
"",
|
|
474
|
+
"## Inputs",
|
|
475
|
+
f"- Primary analysis packet: `{analysis_packet_path}`",
|
|
476
|
+
f"- Coverage census: `{census_path}`",
|
|
477
|
+
]
|
|
478
|
+
if row.get("resultPath"):
|
|
479
|
+
rows.append(f"- Your first result: `{row['resultPath']}`")
|
|
480
|
+
rows.extend(["", "## Mandate", "", _GAPFILL_MANDATE, "", "## Cells to judge", ""])
|
|
481
|
+
rows.append(f"- Cell count: {len(missing)}")
|
|
482
|
+
for reason, cells in by_reason.items():
|
|
483
|
+
rows.append(f"- Why unjudged: {reason}")
|
|
484
|
+
for start in range(0, len(cells), _IDS_PER_LINE):
|
|
485
|
+
chunk = cells[start:start + _IDS_PER_LINE]
|
|
486
|
+
rows.append(" - " + ", ".join(f"`{cell}`" for cell in chunk))
|
|
487
|
+
return "\n".join(rows) + "\n"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
UNVERDICTED_SOURCE = "census-unverdicted"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@dataclass(frozen=True)
|
|
494
|
+
class CensusState:
|
|
495
|
+
"""A finished run's census audit as the report and the validator see it.
|
|
496
|
+
|
|
497
|
+
``audit`` is ``None`` when the census exists but its audit cannot be read;
|
|
498
|
+
``problem`` then says why.
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
audit: Mapping[str, Any] | None
|
|
502
|
+
problem: str
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def read_census_state(
|
|
506
|
+
project_root: Path, manifest: Mapping[str, Any],
|
|
507
|
+
) -> CensusState | None:
|
|
508
|
+
"""``None`` when this run has no census at all."""
|
|
509
|
+
paths = census_state_paths(project_root, manifest)
|
|
510
|
+
if paths is None or not paths[0].is_file():
|
|
511
|
+
return None
|
|
512
|
+
audit_path = paths[1]
|
|
513
|
+
if not audit_path.is_file():
|
|
514
|
+
return CensusState(None, f"the census audit was not run ({audit_path.name} is missing)")
|
|
515
|
+
try:
|
|
516
|
+
audit = load_owned_object(audit_path, artifact="coverage census audit")
|
|
517
|
+
except JsonBoundaryError as exc:
|
|
518
|
+
return CensusState(None, f"the census audit is unreadable ({exc})")
|
|
519
|
+
return CensusState(audit, "")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _unverdicted_by_worker(audit: Mapping[str, Any]) -> dict[str, list[str]]:
|
|
523
|
+
return {
|
|
524
|
+
str(row.get("workerId")): [str(cell) for cell in row.get(UNVERDICTED) or []]
|
|
525
|
+
for row in audit.get("workers") or []
|
|
526
|
+
if isinstance(row, Mapping)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def census_warning_rows(state: CensusState, *, ticket_id: str) -> list[dict[str, str]]:
|
|
531
|
+
"""`missingInformation` rows, without ids: the assembler numbers them."""
|
|
532
|
+
audit = state.audit
|
|
533
|
+
if audit is None:
|
|
534
|
+
return []
|
|
535
|
+
rows: list[dict[str, str]] = []
|
|
536
|
+
cell_count = audit.get("cellCount")
|
|
537
|
+
for worker, cells in _unverdicted_by_worker(audit).items():
|
|
538
|
+
if not cells:
|
|
539
|
+
continue
|
|
540
|
+
rows.append({
|
|
541
|
+
"ticketId": ticket_id,
|
|
542
|
+
"item": (
|
|
543
|
+
f"Coverage census: `{worker}` left {len(cells)} of {cell_count} "
|
|
544
|
+
f"cells without a verdict: {', '.join(cells)}"
|
|
545
|
+
),
|
|
546
|
+
"risk": (
|
|
547
|
+
"This worker did not judge these cells, so their coverage rests "
|
|
548
|
+
"on the other analysers' verdicts alone, or on none where every "
|
|
549
|
+
"analyser missed them."
|
|
550
|
+
),
|
|
551
|
+
"owner": "Okstra lead",
|
|
552
|
+
"source": UNVERDICTED_SOURCE,
|
|
553
|
+
})
|
|
554
|
+
return rows
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def census_warnings(state: CensusState) -> dict[str, Any]:
|
|
558
|
+
"""The one-line summary the lead reports at completion."""
|
|
559
|
+
if state.audit is None:
|
|
560
|
+
return {"auditProblem": state.problem}
|
|
561
|
+
unverdicted = _unverdicted_by_worker(state.audit)
|
|
562
|
+
return {
|
|
563
|
+
"cellCount": state.audit.get("cellCount"),
|
|
564
|
+
"unverdicted": {worker: len(cells) for worker, cells in unverdicted.items()},
|
|
565
|
+
"crossCheck": state.audit.get("crossCheck"),
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def census_advisories(
|
|
570
|
+
state: CensusState, missing_information: Sequence[Any],
|
|
571
|
+
) -> list[str]:
|
|
572
|
+
"""`validate-run` failures for the census. `blocking_checks` carries no
|
|
573
|
+
fragment for any of them, so every one stays advisory."""
|
|
574
|
+
if state.audit is None:
|
|
575
|
+
return [f"coverage census: {state.problem}; no cell verdicts were recorded"]
|
|
576
|
+
advisories: list[str] = []
|
|
577
|
+
unverdicted = {
|
|
578
|
+
worker: cells for worker, cells in _unverdicted_by_worker(state.audit).items()
|
|
579
|
+
if cells
|
|
580
|
+
}
|
|
581
|
+
for worker, cells in unverdicted.items():
|
|
582
|
+
advisories.append(
|
|
583
|
+
f"coverage census: `{worker}` left {len(cells)} cell(s) unverdicted: "
|
|
584
|
+
f"{', '.join(cells)}"
|
|
585
|
+
)
|
|
586
|
+
rows = [
|
|
587
|
+
row for row in missing_information
|
|
588
|
+
if isinstance(row, Mapping) and row.get("source") == UNVERDICTED_SOURCE
|
|
589
|
+
]
|
|
590
|
+
if len(rows) != len(unverdicted):
|
|
591
|
+
advisories.append(
|
|
592
|
+
f"coverage census: the audit has {len(unverdicted)} worker(s) with "
|
|
593
|
+
f"unverdicted cells but the report carries {len(rows)} "
|
|
594
|
+
f"`{UNVERDICTED_SOURCE}` row(s)"
|
|
595
|
+
)
|
|
596
|
+
return advisories
|
|
@@ -319,6 +319,10 @@ def _stages_for_selected_path(
|
|
|
319
319
|
seen: set[int] = set()
|
|
320
320
|
for part in declared:
|
|
321
321
|
hits = _stages_touching_path(part, stages)
|
|
322
|
+
if not hits and "qa/scripts/" in part:
|
|
323
|
+
# 적합성 스크립트는 executor 가 역할 권한으로 쓴다(write_policy
|
|
324
|
+
# `_role_qa_artifact_paths`). 계획 step 의 files 칸에는 원래 없다.
|
|
325
|
+
continue
|
|
322
326
|
if not hits:
|
|
323
327
|
raise DesignSurfaceError(
|
|
324
328
|
f"selected option path {part!r} is not mapped to a stage"
|
|
@@ -36,7 +36,7 @@ def find_brief_tasks(project_root: Path, token: str, task_group: str) -> list[di
|
|
|
36
36
|
group = frontmatter.get("task-group") or folder.name
|
|
37
37
|
if slugify_task_segment(group) != folder.name:
|
|
38
38
|
raise ValueError(f"Brief task-group does not match its directory: {brief['brief']}")
|
|
39
|
-
task_id =
|
|
39
|
+
task_id = brief["task_id"]
|
|
40
40
|
root = task_dir(project_root, slugify_task_segment(group), task_id)
|
|
41
41
|
matches.append({
|
|
42
42
|
"_briefVerified": True,
|
|
@@ -221,18 +221,22 @@ def verify_served_model(
|
|
|
221
221
|
pool: ModelPool,
|
|
222
222
|
) -> ServedModelAttestation:
|
|
223
223
|
"""Reject observed substitution while preserving unobservable attempts."""
|
|
224
|
+
return verify_served_model_ref(role_execution.model_ref, attestation, pool=pool)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def verify_served_model_ref(model_ref: str | None, attestation: ServedModelAttestation, *, pool: ModelPool) -> ServedModelAttestation:
|
|
224
228
|
if attestation.level == "unknown":
|
|
225
229
|
return attestation
|
|
226
|
-
if
|
|
230
|
+
if model_ref is None or not attestation.normalized_model_ref:
|
|
227
231
|
raise DispatchError("served model differs from selected model")
|
|
228
232
|
# An unregistered ref and a genuine substitution are different failures.
|
|
229
233
|
# Folding both into "differs from selected" hid which one happened, and a
|
|
230
234
|
# catalog gap reads as a provider swapping the model out from under us.
|
|
231
235
|
try:
|
|
232
|
-
selected = pool.resolve(
|
|
236
|
+
selected = pool.resolve(model_ref)
|
|
233
237
|
except ValueError as exc:
|
|
234
238
|
raise DispatchError(
|
|
235
|
-
f"selected model is not in the catalog: {
|
|
239
|
+
f"selected model is not in the catalog: {model_ref}"
|
|
236
240
|
) from exc
|
|
237
241
|
try:
|
|
238
242
|
observed = pool.resolve(attestation.normalized_model_ref)
|
|
@@ -13,9 +13,9 @@ from okstra_project.resolver import resolve_review_rule_packs
|
|
|
13
13
|
|
|
14
14
|
from . import worktree_registry
|
|
15
15
|
from .phases.improvement_discovery import lenses as improvement_lenses
|
|
16
|
-
from .models import provider_wrappers
|
|
16
|
+
from .models import provider_ids, provider_wrappers
|
|
17
17
|
from .phases.catalog import PhaseAssetError, UnknownTaskType, profile_markdown
|
|
18
|
-
from .
|
|
18
|
+
from .role_requirements import RoleProfileError, load_role_profile
|
|
19
19
|
from .worktree import is_git_work_tree, main_worktree_path
|
|
20
20
|
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
21
21
|
|
|
@@ -317,11 +317,17 @@ def _worker_dispatch_checks(
|
|
|
317
317
|
host_runtime: str,
|
|
318
318
|
) -> list[DoctorCheck]:
|
|
319
319
|
try:
|
|
320
|
-
|
|
321
|
-
except PhaseAssetError as exc:
|
|
320
|
+
profile = load_role_profile(_profile_path(workspace, phase))
|
|
321
|
+
except (PhaseAssetError, RoleProfileError) as exc:
|
|
322
322
|
return [_fail("worker dispatch", str(exc))]
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
roles = {requirement.role for requirement in profile.roles}
|
|
324
|
+
if not roles:
|
|
325
|
+
return [_ok("worker dispatch", "profile declares no worker roles")]
|
|
326
|
+
# The user picks the providers when the run starts, so every provider that
|
|
327
|
+
# can fill an analysis role must be dispatchable.
|
|
328
|
+
workers = list(provider_ids("analyser"))
|
|
329
|
+
if "report-writer" in roles:
|
|
330
|
+
workers.append("report-writer")
|
|
325
331
|
if not host_runtime:
|
|
326
332
|
return [_fail("worker dispatch", "resolved host runtime is required")]
|
|
327
333
|
|