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.
Files changed (134) hide show
  1. package/README.md +3 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/render-bundle.mjs +1 -1
  5. package/dist/commands/lifecycle/doctor.mjs +1 -1
  6. package/dist/lib/skill-catalog.mjs +1 -0
  7. package/dist/lib/skill-catalog.mjs.map +1 -1
  8. package/docs/architecture/storage-model.md +14 -0
  9. package/docs/architecture.md +30 -9
  10. package/docs/cli.md +26 -22
  11. package/docs/contributor-change-matrix.md +1 -1
  12. package/docs/project-structure-overview.md +15 -8
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/agents/operations/explain-flow.json +6 -0
  16. package/runtime/bin/lib/okstra/cli.sh +1 -5
  17. package/runtime/bin/lib/okstra/globals.sh +0 -2
  18. package/runtime/bin/lib/okstra/usage.sh +5 -3
  19. package/runtime/bin/okstra.sh +0 -2
  20. package/runtime/prompts/duties/business-flow-investigator.json +14 -0
  21. package/runtime/prompts/lead/context-loader.md +1 -1
  22. package/runtime/prompts/lead/convergence.md +22 -7
  23. package/runtime/prompts/lead/okstra-lead-contract.md +10 -6
  24. package/runtime/prompts/lead/report-writer.md +1 -1
  25. package/runtime/prompts/lead/team-contract.md +12 -17
  26. package/runtime/prompts/profiles/_common-contract.md +3 -3
  27. package/runtime/prompts/wizard/prompts.ko.json +0 -91
  28. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +6 -0
  29. package/runtime/python/okstra_ctl/agent/invocation.py +1 -1
  30. package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +1 -0
  31. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +1 -0
  32. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +11 -125
  33. package/runtime/python/okstra_ctl/agent/standalone.py +183 -0
  34. package/runtime/python/okstra_ctl/analysis_packet.py +39 -8
  35. package/runtime/python/okstra_ctl/assignment_resolver.py +7 -1
  36. package/runtime/python/okstra_ctl/brief_frontmatter.py +10 -0
  37. package/runtime/python/okstra_ctl/business_flow/__init__.py +4 -0
  38. package/runtime/python/okstra_ctl/business_flow/cli.py +134 -0
  39. package/runtime/python/okstra_ctl/business_flow/contracts.py +268 -0
  40. package/runtime/python/okstra_ctl/business_flow/engine.py +518 -0
  41. package/runtime/python/okstra_ctl/business_flow/hooks.py +221 -0
  42. package/runtime/python/okstra_ctl/business_flow/invocation.py +170 -0
  43. package/runtime/python/okstra_ctl/business_flow/report.py +49 -0
  44. package/runtime/python/okstra_ctl/business_flow/source.py +206 -0
  45. package/runtime/python/okstra_ctl/business_flow/store.py +388 -0
  46. package/runtime/python/okstra_ctl/convergence.py +173 -2
  47. package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +18 -0
  48. package/runtime/python/okstra_ctl/convergence_provenance.py +8 -0
  49. package/runtime/python/okstra_ctl/coverage_census.py +596 -0
  50. package/runtime/python/okstra_ctl/design_surfaces.py +4 -0
  51. package/runtime/python/okstra_ctl/direct_work.py +1 -1
  52. package/runtime/python/okstra_ctl/dispatch_core.py +7 -3
  53. package/runtime/python/okstra_ctl/doctor.py +12 -6
  54. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  55. package/runtime/python/okstra_ctl/group_context.py +5 -4
  56. package/runtime/python/okstra_ctl/legacy_model_selection.py +7 -51
  57. package/runtime/python/okstra_ctl/manager_split.py +4 -1
  58. package/runtime/python/okstra_ctl/manager_view.py +9 -3
  59. package/runtime/python/okstra_ctl/model_io/lines.py +1 -24
  60. package/runtime/python/okstra_ctl/model_io/renderers.py +54 -41
  61. package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.json +1 -1
  62. package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.md +0 -8
  63. package/runtime/python/okstra_ctl/phases/error_analysis/profile.json +1 -1
  64. package/runtime/python/okstra_ctl/phases/error_analysis/profile.md +6 -8
  65. package/runtime/python/okstra_ctl/phases/feature_analysis/profile.json +1 -1
  66. package/runtime/python/okstra_ctl/phases/feature_analysis/profile.md +0 -8
  67. package/runtime/python/okstra_ctl/phases/final_verification/profile.json +1 -1
  68. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -8
  69. package/runtime/python/okstra_ctl/phases/implementation/boundary.json +1 -1
  70. package/runtime/python/okstra_ctl/phases/implementation/profile.json +1 -1
  71. package/runtime/python/okstra_ctl/phases/implementation/profile.md +0 -6
  72. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation-input.template.md +1 -1
  73. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +4 -3
  74. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +1 -14
  75. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.json +1 -1
  76. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +5 -8
  77. package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +3 -3
  78. package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +15 -5
  79. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +9 -1
  80. package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +1 -1
  81. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +4 -2
  82. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +25 -9
  83. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.json +1 -1
  84. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +7 -10
  85. package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.json +1 -1
  86. package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.md +4 -11
  87. package/runtime/python/okstra_ctl/phases/project_analysis/profile.json +1 -1
  88. package/runtime/python/okstra_ctl/phases/project_analysis/profile.md +0 -8
  89. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +1 -1
  90. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  91. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.json +1 -1
  92. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.md +10 -8
  93. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +3 -3
  94. package/runtime/python/okstra_ctl/phases/technical_verification/profile.json +1 -1
  95. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +0 -4
  96. package/runtime/python/okstra_ctl/plan_items.py +1 -1
  97. package/runtime/python/okstra_ctl/render.py +10 -43
  98. package/runtime/python/okstra_ctl/render_final_report.py +3 -0
  99. package/runtime/python/okstra_ctl/report_assembly.py +15 -1
  100. package/runtime/python/okstra_ctl/report_finalize.py +40 -0
  101. package/runtime/python/okstra_ctl/report_html/render.py +3 -0
  102. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -2
  103. package/runtime/python/okstra_ctl/run.py +78 -409
  104. package/runtime/python/okstra_ctl/wizard/__init__.py +2 -24
  105. package/runtime/python/okstra_ctl/wizard/cli.py +3 -6
  106. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -35
  107. package/runtime/python/okstra_ctl/wizard/engine.py +2 -4
  108. package/runtime/python/okstra_ctl/wizard/ids.py +1 -88
  109. package/runtime/python/okstra_ctl/wizard/registry.py +36 -228
  110. package/runtime/python/okstra_ctl/wizard/render.py +2 -2
  111. package/runtime/python/okstra_ctl/wizard/roles.py +1 -3
  112. package/runtime/python/okstra_ctl/wizard/sources.py +9 -40
  113. package/runtime/python/okstra_ctl/wizard/state.py +36 -145
  114. package/runtime/python/okstra_ctl/wizard/statefile.py +27 -128
  115. package/runtime/python/okstra_ctl/wizard/steps_identity.py +22 -10
  116. package/runtime/python/okstra_ctl/wizard/steps_options.py +5 -4
  117. package/runtime/python/okstra_ctl/wizard/steps_roles.py +15 -565
  118. package/runtime/python/okstra_ctl/worker_prompt_policy.py +9 -2
  119. package/runtime/schemas/business-flow-v1.schema.json +847 -0
  120. package/runtime/schemas/convergence-groups-v2.0.schema.json +7 -0
  121. package/runtime/skills/okstra-explain-flow/SKILL.md +42 -0
  122. package/runtime/skills/okstra-inspect/facets/history.md +5 -5
  123. package/runtime/skills/okstra-run/SKILL.md +2 -2
  124. package/runtime/templates/manager/view.template.html +7 -4
  125. package/runtime/templates/reports/business-flow.template.md +106 -0
  126. package/runtime/templates/reports/html/base.template.html +14 -1
  127. package/runtime/templates/reports/html/business-flow.template.html +31 -0
  128. package/runtime/templates/reports/html/i18n/en.json +1 -0
  129. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  130. package/runtime/templates/worker-prompt-preamble.md +11 -2
  131. package/runtime/validators/checks/validate-prompt-metadata-01.py +10 -10
  132. package/runtime/validators/validate-run.py +70 -21
  133. package/runtime/validators/validate_analysis_report.py +21 -21
  134. 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 = slugify_task_segment(brief["brief_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 role_execution.model_ref is None or not attestation.normalized_model_ref:
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(role_execution.model_ref)
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: {role_execution.model_ref}"
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 .workers import resolve_profile_workers
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
- workers = resolve_profile_workers(_profile_path(workspace, phase))
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
- if not workers:
324
- return [_ok("worker dispatch", "no workers required")]
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
 
@@ -58,6 +58,7 @@ DUTY_ROLE_IDS = {
58
58
  "reverification-worker": "verifier",
59
59
  "code-reviewer": "verifier",
60
60
  "schedule-verifier": "verifier",
61
+ "business-flow-investigator": "analyser",
61
62
  "report-writer": "report-writer",
62
63
  "translator": "translator",
63
64
  }