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,388 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any, cast
6
+
7
+ from ..json_boundary import JsonBoundaryError
8
+ from ..run_context import dir_flock
9
+ from .contracts import (
10
+ BusinessFact,
11
+ BusinessFlowError,
12
+ BusinessKnowledge,
13
+ BusinessResolution,
14
+ ClaimProducer,
15
+ FlowExecution,
16
+ FlowRequest,
17
+ SourceSnapshot,
18
+ StoredClaim,
19
+ TaskLink,
20
+ TaskLinkRow,
21
+ artifact_path,
22
+ fingerprint,
23
+ now_iso,
24
+ read_artifact,
25
+ write_artifact,
26
+ )
27
+ from .source import candidate_projects, capture_project
28
+
29
+ CONFIRMED_LEVELS = frozenset({"static", "execution-verified"})
30
+
31
+
32
+ def task_link_path(project_root: Path, task_key: str) -> Path:
33
+ return artifact_path(
34
+ project_root, "task-links", fingerprint(task_key), "links.json"
35
+ )
36
+
37
+
38
+ def read_task_links(project_root: Path, task_key: str) -> TaskLink:
39
+ path = task_link_path(project_root, task_key)
40
+ return (
41
+ cast(TaskLink, read_artifact(path, "taskLink"))
42
+ if path.exists()
43
+ else {"schemaVersion": 1, "taskKey": task_key, "executions": []}
44
+ )
45
+
46
+
47
+ def record_task_execution(
48
+ request: FlowRequest, execution: FlowExecution, input_digest: str
49
+ ) -> TaskLinkRow:
50
+ path = task_link_path(request.project_root, request.task_key)
51
+ row: TaskLinkRow = {
52
+ "id": execution["id"],
53
+ "mode": request.mode,
54
+ "status": execution["status"],
55
+ "inputDigest": input_digest,
56
+ "reports": execution.get("reports", {}),
57
+ "summary": execution.get("result", {}).get("summary", ""),
58
+ "error": execution.get("error", ""),
59
+ }
60
+ with dir_flock(path.parent, ".links.lock"):
61
+ links = read_task_links(request.project_root, request.task_key)
62
+ if not any(item["id"] == row["id"] for item in links["executions"]):
63
+ links["executions"].append(row)
64
+ write_artifact(path, links, "taskLink")
65
+ return row
66
+
67
+
68
+ def read_knowledge(project_root: Path) -> BusinessKnowledge:
69
+ path = artifact_path(project_root, "knowledge.json")
70
+ if not path.exists():
71
+ return {"schemaVersion": 1, "claims": [], "resolutions": []}
72
+ return cast(BusinessKnowledge, read_artifact(path, "knowledge"))
73
+
74
+
75
+ def contribute_knowledge(
76
+ project_root: Path,
77
+ facts: list[BusinessFact],
78
+ projects: list[SourceSnapshot],
79
+ producer: ClaimProducer,
80
+ ) -> dict[str, Any]:
81
+ path = artifact_path(project_root, "knowledge.json")
82
+ with dir_flock(path.parent, ".knowledge.lock"):
83
+ data = read_knowledge(project_root)
84
+ claims = [_claim(fact, projects, producer) for fact in facts]
85
+ by_id = {row["id"]: row for row in data["claims"]}
86
+ added = []
87
+ for claim in claims:
88
+ if claim["id"] in by_id:
89
+ existing = by_id[claim["id"]]
90
+ if producer not in existing["producers"]:
91
+ existing["producers"].append(producer)
92
+ for key in ("aliases", "relatedKeys", "executionEvidence"):
93
+ if key in claim:
94
+ existing[key] = list(
95
+ dict.fromkeys(existing.get(key, []) + claim[key])
96
+ )
97
+ continue
98
+ data["claims"].append(claim)
99
+ by_id[claim["id"]] = claim
100
+ added.append(claim["id"])
101
+ _mark_conflicts(data["claims"])
102
+ write_artifact(path, data, "knowledge")
103
+ return {
104
+ "added": added,
105
+ "conflicts": [
106
+ row["id"] for row in data["claims"] if row["status"] == "conflict"
107
+ ],
108
+ }
109
+
110
+
111
+ def _claim(
112
+ fact: BusinessFact, projects: list[SourceSnapshot], producer: ClaimProducer
113
+ ) -> StoredClaim:
114
+ ids = {evidence["projectId"] for evidence in fact["evidence"]}
115
+ states = {
116
+ row["projectId"]: {
117
+ "root": row["root"],
118
+ "digest": row["digest"],
119
+ "head": row["head"],
120
+ }
121
+ for row in projects
122
+ if row["projectId"] in ids
123
+ }
124
+ identity = {
125
+ "key": fact["key"],
126
+ "value": fact["value"],
127
+ "level": fact["level"],
128
+ "sourceStates": {key: row["digest"] for key, row in states.items()},
129
+ }
130
+ return {
131
+ **fact,
132
+ "id": fingerprint(identity),
133
+ "sourceStates": states,
134
+ "producer": producer,
135
+ "producers": [producer],
136
+ "createdAt": now_iso(),
137
+ "status": "accepted",
138
+ }
139
+
140
+
141
+ def _mark_conflicts(claims: list[StoredClaim]) -> None:
142
+ groups: dict[str, list[StoredClaim]] = {}
143
+ for claim in claims:
144
+ if claim["level"] not in CONFIRMED_LEVELS or claim["status"] == "superseded":
145
+ continue
146
+ if not claim["sourceStates"] or any(
147
+ not row["digest"] for row in claim["sourceStates"].values()
148
+ ):
149
+ continue
150
+ state = {key: row["digest"] for key, row in claim["sourceStates"].items()}
151
+ groups.setdefault(
152
+ fingerprint({"key": claim["key"], "states": state}), []
153
+ ).append(claim)
154
+ for rows in groups.values():
155
+ if len({row["value"] for row in rows}) > 1:
156
+ for row in rows:
157
+ row["status"] = "conflict"
158
+
159
+
160
+ def resolve_conflicts(
161
+ project_root: Path, resolutions: list[BusinessResolution], producer: ClaimProducer
162
+ ) -> None:
163
+ path = artifact_path(project_root, "knowledge.json")
164
+ with dir_flock(path.parent, ".knowledge.lock"):
165
+ data = read_knowledge(project_root)
166
+ validate_resolutions(read_shared_knowledge(project_root), resolutions)
167
+ by_id = {row["id"]: row for row in data["claims"]}
168
+ for resolution in resolutions:
169
+ ids = resolution["claimIds"]
170
+ rows = [by_id[key] for key in ids if key in by_id]
171
+ for row in rows:
172
+ row["status"] = (
173
+ "accepted"
174
+ if row["id"] == resolution["acceptedClaimId"]
175
+ else "superseded"
176
+ )
177
+ data["resolutions"].append(
178
+ {**resolution, "producer": producer, "createdAt": now_iso()}
179
+ )
180
+ write_artifact(path, data, "knowledge")
181
+
182
+
183
+ def validate_resolutions(
184
+ data: BusinessKnowledge, resolutions: list[BusinessResolution]
185
+ ) -> None:
186
+ by_id = {row["id"]: row for row in data["claims"]}
187
+ visited = set()
188
+ for resolution in resolutions:
189
+ ids = set(resolution["claimIds"])
190
+ if (
191
+ not ids.issubset(by_id)
192
+ or resolution["acceptedClaimId"] not in ids
193
+ or ids & visited
194
+ ):
195
+ raise BusinessFlowError(
196
+ "conflict resolution references unknown or repeated claims"
197
+ )
198
+ rows = _resolution_rows(data["claims"], resolution)
199
+ state = fingerprint(
200
+ {key: source["digest"] for key, source in rows[0]["sourceStates"].items()}
201
+ )
202
+ group = {
203
+ row["id"]
204
+ for row in data["claims"]
205
+ if row["key"] == rows[0]["key"]
206
+ and row["status"] == "conflict"
207
+ and fingerprint(
208
+ {key: source["digest"] for key, source in row["sourceStates"].items()}
209
+ )
210
+ == state
211
+ }
212
+ if ids != group:
213
+ raise BusinessFlowError(
214
+ "conflict resolution must cover the complete active conflict"
215
+ )
216
+ visited.update(ids)
217
+
218
+
219
+ def _resolution_rows(
220
+ claims: list[StoredClaim],
221
+ resolution: BusinessResolution,
222
+ ) -> list[StoredClaim]:
223
+ by_id = {row["id"]: row for row in claims}
224
+ ids = set(resolution["claimIds"])
225
+ if not ids.issubset(by_id) or resolution["acceptedClaimId"] not in ids:
226
+ raise BusinessFlowError("conflict resolution references unknown claims")
227
+ rows = [by_id[key] for key in ids]
228
+ states = {
229
+ fingerprint(
230
+ {key: state["digest"] for key, state in row["sourceStates"].items()}
231
+ )
232
+ for row in rows
233
+ }
234
+ if len({row["key"] for row in rows}) != 1 or len(states) != 1:
235
+ raise BusinessFlowError("conflict resolution mixes subjects or source versions")
236
+ return rows
237
+
238
+
239
+ def read_shared_knowledge(project_root: Path) -> dict[str, Any]:
240
+ claims, resolutions, errors = [], [], []
241
+ candidates = candidate_projects(FlowRequest(project_root, "business"))
242
+ for candidate in candidates:
243
+ root = Path(candidate["root"])
244
+ try:
245
+ data = read_knowledge(root)
246
+ claims.extend({**claim, "ownerRoot": str(root)} for claim in data["claims"])
247
+ resolutions.extend(data["resolutions"])
248
+ except (OSError, BusinessFlowError, JsonBoundaryError) as exc:
249
+ errors.append(f"{candidate['projectId']}: {exc}")
250
+ _apply_shared_resolutions(claims, resolutions, errors)
251
+ _mark_conflicts(claims)
252
+ return {"claims": claims, "errors": errors}
253
+
254
+
255
+ def _apply_shared_resolutions(
256
+ claims: list[StoredClaim],
257
+ resolutions: list[BusinessResolution],
258
+ errors: list[str],
259
+ ) -> None:
260
+ decisions: dict[frozenset[str], set[str]] = {}
261
+ for resolution in resolutions:
262
+ group = frozenset(resolution["claimIds"])
263
+ decisions.setdefault(group, set()).add(resolution["acceptedClaimId"])
264
+ disputed = {group for group, accepted in decisions.items() if len(accepted) > 1}
265
+ for resolution in resolutions:
266
+ try:
267
+ _resolution_rows(claims, resolution)
268
+ except BusinessFlowError as exc:
269
+ errors.append(f"Stored conflict resolution unavailable: {exc}")
270
+ continue
271
+ group = frozenset(resolution["claimIds"])
272
+ if group in disputed:
273
+ errors.append(
274
+ "Contradictory shared conflict resolutions: " + ", ".join(sorted(group))
275
+ )
276
+ continue
277
+ for row in claims:
278
+ if row["id"] in group:
279
+ row["status"] = (
280
+ "accepted"
281
+ if row["id"] == resolution["acceptedClaimId"]
282
+ else "superseded"
283
+ )
284
+ for row in claims:
285
+ if any(row["id"] in group for group in disputed):
286
+ row["status"] = "conflict"
287
+
288
+
289
+ def query_knowledge(
290
+ project_root: Path, query: str, source_root: Path | None = None
291
+ ) -> dict[str, Any]:
292
+ sources = {
293
+ row["projectId"]: row["root"]
294
+ for row in candidate_projects(
295
+ FlowRequest(project_root, query or "business", source_root=source_root)
296
+ )
297
+ }
298
+ tokens = set(re.findall(r"[\w-]{2,}", query.casefold()))
299
+ shared = read_shared_knowledge(project_root)
300
+ claims, errors = shared["claims"], shared["errors"]
301
+ selected = [
302
+ row
303
+ for row in claims
304
+ if any(
305
+ token
306
+ in (
307
+ row["business"]
308
+ + " "
309
+ + row["key"]
310
+ + " "
311
+ + row["value"]
312
+ + " "
313
+ + " ".join(row.get("aliases", []))
314
+ ).casefold()
315
+ for token in tokens
316
+ )
317
+ ]
318
+ keys = {row["key"] for row in selected}
319
+ while True:
320
+ related = keys | {key for row in selected for key in row.get("relatedKeys", [])}
321
+ expanded = [row for row in claims if row["key"] in related]
322
+ if {(row["ownerRoot"], row["id"]) for row in expanded} == {
323
+ (row["ownerRoot"], row["id"]) for row in selected
324
+ }:
325
+ break
326
+ selected, keys = expanded, {row["key"] for row in expanded}
327
+ states: dict[str, dict[str, Any]] = {}
328
+ for row in selected:
329
+ row["freshness"] = _freshness(row, states, sources)
330
+ row["selectionReason"] = "business terms or a connected claim key"
331
+ return {"claims": selected, "errors": errors, "query": query}
332
+
333
+
334
+ def _freshness(
335
+ claim: dict[str, Any], cache: dict[str, dict[str, Any]], sources: dict[str, str]
336
+ ) -> str:
337
+ for project_id, expected in claim["sourceStates"].items():
338
+ root = sources.get(project_id)
339
+ if root is None:
340
+ return "unverified"
341
+ if root not in cache:
342
+ cache[root] = capture_project({"projectId": project_id, "root": root})
343
+ current = cache[root]
344
+ if current["gaps"] or not current["digest"]:
345
+ return "unverified"
346
+ if current["digest"] != expected["digest"]:
347
+ return "historical"
348
+ return "current" if claim["sourceStates"] else "unverified"
349
+
350
+
351
+ def knowledge_packet(
352
+ project_root: Path, question: str, source_root: Path | None = None
353
+ ) -> str:
354
+ try:
355
+ result = query_knowledge(project_root, question, source_root)
356
+ except (OSError, BusinessFlowError, JsonBoundaryError) as exc:
357
+ return f"## Shared Business Knowledge\n\nKnowledge unavailable: {exc}\n"
358
+ lines = [
359
+ "## Shared Business Knowledge",
360
+ "",
361
+ "Preserve level, freshness, conflict and provenance. Recheck historical facts before relying on them.",
362
+ "",
363
+ ]
364
+ if not result["claims"]:
365
+ lines.append(
366
+ "No related stored claims were selected. Inspect source and use additional lookup when new business terms emerge."
367
+ )
368
+ for row in result["claims"]:
369
+ lines.extend(
370
+ [
371
+ f"- `{row['id']}`: {row['value']}",
372
+ f" - Level: {row['level']}; freshness: {row['freshness']}; status: {row['status']}",
373
+ f" - Owner: `{row['ownerRoot']}`; key: `{row['key']}`",
374
+ ]
375
+ )
376
+ lines.extend(
377
+ f" - Evidence: `{item['projectId']}:{item['path']}:{item['line']}`"
378
+ for item in row["evidence"]
379
+ )
380
+ lines.extend(f"- Knowledge read error: {error}" for error in result["errors"])
381
+ lines.extend(
382
+ [
383
+ "",
384
+ "Additional lookup: `okstra explain-flow knowledge --query <business question>`",
385
+ "",
386
+ ]
387
+ )
388
+ return "\n".join(lines)
@@ -60,6 +60,14 @@ from .convergence_store import (
60
60
  write_final_state_atomic,
61
61
  write_json_atomic,
62
62
  )
63
+ from .coverage_census import (
64
+ CensusError,
65
+ audit_census,
66
+ audit_filename,
67
+ census_filename,
68
+ gapfill_prompt_body,
69
+ )
70
+ from .path_resolve import relative_to_project_root
63
71
  from .execution_identity import ExecutionManifest
64
72
  from .execution_manifest import read_execution_manifest_view
65
73
  from .final_report_schema import load_named_schema, validate as validate_schema
@@ -323,6 +331,10 @@ _CLI_EPILOG = r"""Usage:
323
331
  okstra convergence reverify-prompt --run-manifest <path> --plan <path> \
324
332
  --worker <worker-id>
325
333
  okstra convergence apply-critic-gaps --work-state <path> --results <path>
334
+ okstra convergence census-audit --run-manifest <path> \
335
+ --result <worker>=<path>... [--gapfill <worker>=<path>...]
336
+ okstra convergence census-gapfill-prompt --run-manifest <path> \
337
+ --worker <worker-id>
326
338
  okstra convergence apply-acceptance-critic --work-state <path> \
327
339
  --results <path>
328
340
  okstra convergence finalize --work-state <path> --output <path>
@@ -475,6 +487,51 @@ def _parser() -> argparse.ArgumentParser:
475
487
  )
476
488
  critic_verify_prompt.add_argument("--worker", required=True)
477
489
 
490
+ census_audit = subparsers.add_parser(
491
+ "census-audit",
492
+ help="audit each analyser's coverage-census verdicts",
493
+ description=(
494
+ "Read every analysis worker's `Coverage Verdicts` against this run's "
495
+ "coverage census and write state/coverage-census-audit-<task-type>-"
496
+ "<seq>.json: per worker the cells still without a usable verdict, "
497
+ "the cell x worker matrix, the cells one worker judged `finding` and "
498
+ "another `clean`, and per-cell grouping suggestions. Run it after the "
499
+ "initial batch, and again with `--gapfill` after the one gap-fill "
500
+ "dispatch. Cells left unjudged are recorded as warnings; they never "
501
+ "change the exit code. A run whose profile has no census writes "
502
+ "nothing and exits 0."
503
+ ),
504
+ formatter_class=argparse.RawDescriptionHelpFormatter,
505
+ )
506
+ census_audit.add_argument("--run-manifest", type=Path, required=True)
507
+ census_audit.add_argument(
508
+ "--result", action="append", default=[], required=True,
509
+ metavar="<worker>=<path>",
510
+ help="one analysis worker's initial result file (repeatable); a path "
511
+ "that does not exist counts as a missing result",
512
+ )
513
+ census_audit.add_argument(
514
+ "--gapfill", action="append", default=[], metavar="<worker>=<path>",
515
+ help="that worker's census-gapfill result file (repeatable); a path that "
516
+ "does not exist records a failed gap-fill",
517
+ )
518
+
519
+ census_gapfill_prompt = subparsers.add_parser(
520
+ "census-gapfill-prompt",
521
+ help="render one analyser's census gap-fill instructions to stdout",
522
+ description=(
523
+ "Print the census gap-fill instruction body for one analysis worker "
524
+ "from the latest `census-audit`: only the cells that worker left "
525
+ "without a usable verdict, grouped by why. The lead writes it "
526
+ "verbatim to the file the prompt materializer's `--instruction` "
527
+ "takes, with `--dispatch-kind census-gapfill`. Refused for a worker "
528
+ "with no unjudged cell or one that already had its single gap-fill."
529
+ ),
530
+ formatter_class=argparse.RawDescriptionHelpFormatter,
531
+ )
532
+ census_gapfill_prompt.add_argument("--run-manifest", type=Path, required=True)
533
+ census_gapfill_prompt.add_argument("--worker", required=True)
534
+
478
535
  apply_critic = subparsers.add_parser(
479
536
  "apply-critic-gaps",
480
537
  help="apply one coverage-critic verification batch",
@@ -1325,7 +1382,7 @@ def _group_from_fields(
1325
1382
  origin_worker, separator, origin_item = scalar.get("origin", "").partition(":")
1326
1383
  if not separator or discovered.get(origin_worker, {}).get("itemId") != origin_item:
1327
1384
  raise ConvergenceContractError("Origin must identify one Source item")
1328
- return {
1385
+ group = {
1329
1386
  "findingId": finding_id, "summary": scalar.get("summary", ""),
1330
1387
  "category": scalar.get("category", ""),
1331
1388
  "ticketIds": [item.strip() for item in scalar.get("tickets", "").split(",") if item.strip()],
@@ -1334,6 +1391,10 @@ def _group_from_fields(
1334
1391
  "discoveredBy": discovered,
1335
1392
  "sourceItems": source_items,
1336
1393
  }
1394
+ cells = [cell.strip() for cell in scalar.get("cells", "").split(",") if cell.strip()]
1395
+ if cells:
1396
+ group["cellRefs"] = list(dict.fromkeys(cells))
1397
+ return group
1337
1398
 
1338
1399
 
1339
1400
  def _group_source_provenance(
@@ -1579,6 +1640,7 @@ def _critic_verify_prompt(args: argparse.Namespace) -> str:
1579
1640
  result_path, audit_path = critic_result_paths(
1580
1641
  groups, critic_provider=provider,
1581
1642
  project_root=authority.project_root, run_dir=authority.run_dir,
1643
+ recorded_result=_settled_critic_result(authority.payload),
1582
1644
  )
1583
1645
  return critic_verify_prompt_body(
1584
1646
  task_key=task_key,
@@ -1589,6 +1651,109 @@ def _critic_verify_prompt(args: argparse.Namespace) -> str:
1589
1651
  )
1590
1652
 
1591
1653
 
1654
+ def _worker_paths(raw_pairs: list[str], label: str) -> dict[str, Path]:
1655
+ paths: dict[str, Path] = {}
1656
+ for raw in raw_pairs:
1657
+ worker, value = _split_pair(raw, label)
1658
+ if worker in paths:
1659
+ raise ConvergenceContractError(f"{label} names `{worker}` twice")
1660
+ paths[worker] = Path(value)
1661
+ return paths
1662
+
1663
+
1664
+ def _read_if_present(path: Path) -> str | None:
1665
+ return path.read_text(encoding="utf-8") if path.is_file() else None
1666
+
1667
+
1668
+ def _census_audit(args: argparse.Namespace) -> dict[str, Any]:
1669
+ """Audit the census verdicts. Unjudged cells are data, never an error."""
1670
+ authority = validated_run_authority(args.run_manifest)
1671
+ state_dir = authority.run_dir / "state"
1672
+ census_path = state_dir / census_filename(
1673
+ authority.task_type, authority.state_sequence
1674
+ )
1675
+ summary: dict[str, Any] = {"ok": True, "operation": "census-audit"}
1676
+ if not census_path.is_file():
1677
+ return {**summary, "census": "absent"}
1678
+ census = load_owned_json_object(census_path)
1679
+ result_paths = _worker_paths(args.result, "--result")
1680
+ gapfill_paths = _worker_paths(args.gapfill, "--gapfill")
1681
+ unrostered = sorted(set(gapfill_paths) - set(result_paths))
1682
+ if unrostered:
1683
+ raise ConvergenceContractError(
1684
+ f"--gapfill names {unrostered}, which no --result names"
1685
+ )
1686
+ audit = audit_census(
1687
+ census,
1688
+ {worker: _read_if_present(path) for worker, path in result_paths.items()},
1689
+ {worker: _read_if_present(path) for worker, path in gapfill_paths.items()},
1690
+ )
1691
+ for row in audit["workers"]:
1692
+ row["resultPath"] = relative_to_project_root(
1693
+ result_paths[row["workerId"]], authority.project_root
1694
+ )
1695
+ if row["workerId"] in gapfill_paths:
1696
+ row["gapfillPath"] = relative_to_project_root(
1697
+ gapfill_paths[row["workerId"]], authority.project_root
1698
+ )
1699
+ output = state_dir / audit_filename(authority.task_type, authority.state_sequence)
1700
+ write_json_atomic(output, audit)
1701
+ return {
1702
+ **summary,
1703
+ "census": "present",
1704
+ "path": str(output),
1705
+ "crossCheck": audit["crossCheck"],
1706
+ "gapfillNeeded": audit["gapfillNeeded"],
1707
+ "unverdicted": {
1708
+ row["workerId"]: len(row["unverdicted"]) for row in audit["workers"]
1709
+ },
1710
+ "disagreements": len(audit["disagreements"]),
1711
+ }
1712
+
1713
+
1714
+ def _census_gapfill_prompt(args: argparse.Namespace) -> str:
1715
+ authority = validated_run_authority(args.run_manifest)
1716
+ state_dir = authority.run_dir / "state"
1717
+ audit_path = state_dir / audit_filename(authority.task_type, authority.state_sequence)
1718
+ if not audit_path.is_file():
1719
+ raise ConvergenceContractError(
1720
+ "the gap-fill prompt reads the census audit; run "
1721
+ f"`okstra convergence census-audit` first: {audit_path}"
1722
+ )
1723
+ census_path = state_dir / census_filename(authority.task_type, authority.state_sequence)
1724
+ return gapfill_prompt_body(
1725
+ load_owned_json_object(audit_path),
1726
+ args.worker,
1727
+ task_key=authority.task_key,
1728
+ analysis_packet_path=_manifest_authority_string(
1729
+ authority.payload, "analysisPacketPath"
1730
+ ),
1731
+ census_path=relative_to_project_root(census_path, authority.project_root),
1732
+ )
1733
+
1734
+
1735
+ def _settled_critic_result(manifest: Mapping[str, Any]) -> str:
1736
+ """critic dispatch 의 가장 최근 `ok` attempt 가 기록한 resultPath. 없으면 ''."""
1737
+ invocations = manifest.get("invocations")
1738
+ attempts = manifest.get("attempts")
1739
+ if not isinstance(invocations, list) or not isinstance(attempts, list):
1740
+ return ""
1741
+ critic_refs = {
1742
+ row.get("invocationRef") for row in invocations
1743
+ if isinstance(row, Mapping) and row.get("dispatchKind") == "critic"
1744
+ }
1745
+ settled = [
1746
+ row for row in attempts
1747
+ if isinstance(row, Mapping) and row.get("invocationRef") in critic_refs
1748
+ and row.get("status") == "ok"
1749
+ and isinstance(row.get("resultPath"), str) and row["resultPath"]
1750
+ ]
1751
+ if not settled:
1752
+ return ""
1753
+ latest = max(settled, key=lambda row: (str(row.get("finishedAt") or ""), row.get("attempt") or 0))
1754
+ return latest["resultPath"]
1755
+
1756
+
1592
1757
  def _execute(args: argparse.Namespace) -> tuple[str, Path]:
1593
1758
  operations: dict[str, Any] = {
1594
1759
  "prepare-groups": _prepare_groups,
@@ -1626,9 +1791,15 @@ def main(argv: list[str] | None = None) -> int:
1626
1791
  if args.operation == "critic-verify-prompt":
1627
1792
  print(_critic_verify_prompt(args), end="")
1628
1793
  return 0
1794
+ if args.operation == "census-audit":
1795
+ print(json.dumps(_census_audit(args), ensure_ascii=False))
1796
+ return 0
1797
+ if args.operation == "census-gapfill-prompt":
1798
+ print(_census_gapfill_prompt(args), end="")
1799
+ return 0
1629
1800
  action, path = _execute(args)
1630
1801
  except (ConvergenceContractError, VerdictBlockError, ReverifyPromptError,
1631
- CriticVerifyPromptError,
1802
+ CriticVerifyPromptError, CensusError,
1632
1803
  json.JSONDecodeError, ValueError) as exc:
1633
1804
  print(f"error: {exc}", file=sys.stderr)
1634
1805
  return 2
@@ -77,6 +77,7 @@ def critic_result_paths(
77
77
  critic_provider: str,
78
78
  project_root: Path,
79
79
  run_dir: Path,
80
+ recorded_result: str = "",
80
81
  ) -> tuple[str, str]:
81
82
  """critic 결과 파일과 그 감사 사이드카의 프로젝트 상대 경로.
82
83
 
@@ -85,7 +86,24 @@ def critic_result_paths(
85
86
  §"Coverage critic pass"). 접미사는 분석자 결과와 같은 규칙으로 그룹의
86
87
  `runManifestPath` 에서 읽는다. 파일이 없으면 critic 결과가 아직 수집되지
87
88
  않은 것이라 거절한다 — gap 검증은 critic 결과 뒤에만 온다.
89
+
90
+ `recorded_result` 는 매니페스트 attempt 원장이 critic 의 마지막 `ok` attempt
91
+ 에 적은 경로다. 있으면 이름 규칙보다 먼저 쓴다 — 리드가 run seq 로 이름을
92
+ 붙여도(실측 2026-10-03, fontsninja-v3-site dev-11118 requirements-discovery
93
+ 002: prompts 002, workerResults 001) 실제로 수집된 파일을 가리킨다.
88
94
  """
95
+ if recorded_result:
96
+ recorded = Path(recorded_result)
97
+ result = recorded if recorded.is_absolute() else Path(project_root) / recorded
98
+ if not result.is_file():
99
+ raise CriticVerifyPromptError(
100
+ f"the critic attempt recorded {result} but the file is missing"
101
+ )
102
+ result_rel = _project_relative(project_root, result)
103
+ try:
104
+ return result_rel, audit_sidecar_rel(result_rel)
105
+ except WorkerArtifactPathError as exc:
106
+ raise CriticVerifyPromptError(str(exc)) from exc
89
107
  if not _nonempty_string(critic_provider):
90
108
  raise CriticVerifyPromptError("run manifest has no critic assignment provider")
91
109
  suffix = worker_result_suffix(Path(run_dir), groups)
@@ -26,6 +26,7 @@ from okstra_ctl.execution_manifest import read_execution_manifest_view
26
26
  from okstra_ctl.json_boundary import (
27
27
  load_owned_object,
28
28
  )
29
+ from okstra_ctl.worker_prompt_policy import CENSUS_GAPFILL_DISPATCH_KIND
29
30
 
30
31
  GROUPS_BASENAME_RE = re.compile(
31
32
  r"^convergence-groups-(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
@@ -223,6 +224,13 @@ def provenance_errors(
223
224
  text = contents[worker]
224
225
  if text is None or id_occurs_wordbounded(text, item_id):
225
226
  continue
227
+ gapfill = read_canonical_worker_result(
228
+ worker_results_dir, worker, f"{CENSUS_GAPFILL_DISPATCH_KIND}-{suffix}"
229
+ )
230
+ # A finding the census gap-fill raised lives in that worker's gap-fill
231
+ # result, not in its first one.
232
+ if gapfill is not None and id_occurs_wordbounded(gapfill, item_id):
233
+ continue
226
234
  errors.append(
227
235
  f"convergence groups `{groups_label}` group {finding_id} "
228
236
  f"cites source item `{worker}:{item_id}`, but that ID does not "