okstra 0.207.1 → 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 (132) 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/model_io/lines.py +1 -24
  59. package/runtime/python/okstra_ctl/model_io/renderers.py +54 -41
  60. package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.json +1 -1
  61. package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.md +0 -8
  62. package/runtime/python/okstra_ctl/phases/error_analysis/profile.json +1 -1
  63. package/runtime/python/okstra_ctl/phases/error_analysis/profile.md +6 -8
  64. package/runtime/python/okstra_ctl/phases/feature_analysis/profile.json +1 -1
  65. package/runtime/python/okstra_ctl/phases/feature_analysis/profile.md +0 -8
  66. package/runtime/python/okstra_ctl/phases/final_verification/profile.json +1 -1
  67. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -8
  68. package/runtime/python/okstra_ctl/phases/implementation/boundary.json +1 -1
  69. package/runtime/python/okstra_ctl/phases/implementation/profile.json +1 -1
  70. package/runtime/python/okstra_ctl/phases/implementation/profile.md +0 -6
  71. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation-input.template.md +1 -1
  72. package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +4 -3
  73. package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +1 -14
  74. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.json +1 -1
  75. package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +5 -8
  76. package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +3 -3
  77. package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +15 -5
  78. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +9 -1
  79. package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +1 -1
  80. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +4 -2
  81. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +25 -9
  82. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.json +1 -1
  83. package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +7 -10
  84. package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.json +1 -1
  85. package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.md +4 -11
  86. package/runtime/python/okstra_ctl/phases/project_analysis/profile.json +1 -1
  87. package/runtime/python/okstra_ctl/phases/project_analysis/profile.md +0 -8
  88. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +1 -1
  89. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
  90. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.json +1 -1
  91. package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.md +10 -8
  92. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +3 -3
  93. package/runtime/python/okstra_ctl/phases/technical_verification/profile.json +1 -1
  94. package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +0 -4
  95. package/runtime/python/okstra_ctl/plan_items.py +1 -1
  96. package/runtime/python/okstra_ctl/render.py +10 -43
  97. package/runtime/python/okstra_ctl/render_final_report.py +3 -0
  98. package/runtime/python/okstra_ctl/report_assembly.py +15 -1
  99. package/runtime/python/okstra_ctl/report_finalize.py +40 -0
  100. package/runtime/python/okstra_ctl/report_html/render.py +3 -0
  101. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -2
  102. package/runtime/python/okstra_ctl/run.py +78 -409
  103. package/runtime/python/okstra_ctl/wizard/__init__.py +2 -24
  104. package/runtime/python/okstra_ctl/wizard/cli.py +3 -6
  105. package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -35
  106. package/runtime/python/okstra_ctl/wizard/engine.py +2 -4
  107. package/runtime/python/okstra_ctl/wizard/ids.py +1 -88
  108. package/runtime/python/okstra_ctl/wizard/registry.py +36 -228
  109. package/runtime/python/okstra_ctl/wizard/render.py +2 -2
  110. package/runtime/python/okstra_ctl/wizard/roles.py +1 -3
  111. package/runtime/python/okstra_ctl/wizard/sources.py +9 -40
  112. package/runtime/python/okstra_ctl/wizard/state.py +36 -145
  113. package/runtime/python/okstra_ctl/wizard/statefile.py +27 -128
  114. package/runtime/python/okstra_ctl/wizard/steps_identity.py +22 -10
  115. package/runtime/python/okstra_ctl/wizard/steps_options.py +5 -4
  116. package/runtime/python/okstra_ctl/wizard/steps_roles.py +15 -565
  117. package/runtime/python/okstra_ctl/worker_prompt_policy.py +9 -2
  118. package/runtime/schemas/business-flow-v1.schema.json +847 -0
  119. package/runtime/schemas/convergence-groups-v2.0.schema.json +7 -0
  120. package/runtime/skills/okstra-explain-flow/SKILL.md +42 -0
  121. package/runtime/skills/okstra-inspect/facets/history.md +5 -5
  122. package/runtime/skills/okstra-run/SKILL.md +2 -2
  123. package/runtime/templates/reports/business-flow.template.md +106 -0
  124. package/runtime/templates/reports/html/base.template.html +14 -1
  125. package/runtime/templates/reports/html/business-flow.template.html +31 -0
  126. package/runtime/templates/reports/html/i18n/en.json +1 -0
  127. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  128. package/runtime/templates/worker-prompt-preamble.md +11 -2
  129. package/runtime/validators/checks/validate-prompt-metadata-01.py +10 -10
  130. package/runtime/validators/validate-run.py +70 -21
  131. package/runtime/validators/validate_analysis_report.py +21 -21
  132. package/runtime/python/okstra_ctl/workers.py +0 -133
@@ -0,0 +1,518 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import asdict, replace
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+ from uuid import uuid4
10
+
11
+ from ..agent.invocation import (
12
+ AgentInvocationError,
13
+ materialize_standalone_result,
14
+ publish_standalone_completion,
15
+ verify_standalone_completion,
16
+ )
17
+ from ..json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
18
+ from .contracts import (
19
+ OPERATION,
20
+ BusinessFact,
21
+ BusinessFlowError,
22
+ BusinessResult,
23
+ FlowExecution,
24
+ FlowRequest,
25
+ FlowRequestPayload,
26
+ SourceSnapshot,
27
+ artifact_path,
28
+ now_iso,
29
+ read_artifact,
30
+ schema_definition,
31
+ write_artifact,
32
+ )
33
+ from .invocation import FlowExecutor, PreparedCall, ProviderFlowExecutor
34
+ from .source import (
35
+ candidate_projects,
36
+ capture_projects,
37
+ changed_projects,
38
+ source_changes,
39
+ validate_evidence,
40
+ )
41
+ from .store import (
42
+ contribute_knowledge,
43
+ query_knowledge,
44
+ read_shared_knowledge,
45
+ record_task_execution,
46
+ resolve_conflicts,
47
+ validate_resolutions,
48
+ )
49
+
50
+
51
+ def explain_flow(
52
+ request: FlowRequest, executor: FlowExecutor | None = None
53
+ ) -> FlowExecution:
54
+ request.validate()
55
+ request = replace(
56
+ request,
57
+ project_root=request.project_root.resolve(),
58
+ source_root=request.source_root.resolve() if request.source_root else None,
59
+ source_artifacts=tuple(
60
+ str(Path(path).resolve()) for path in request.source_artifacts
61
+ ),
62
+ )
63
+ execution_id = "flow-" + uuid4().hex
64
+ path = artifact_path(
65
+ request.project_root, "executions", execution_id, "execution.json"
66
+ )
67
+ execution: FlowExecution = {
68
+ "schemaVersion": 1,
69
+ "id": execution_id,
70
+ "createdAt": now_iso(),
71
+ "request": _request_payload(request),
72
+ "projects": [],
73
+ "status": "prepared",
74
+ "readOnlyAudit": {
75
+ "policy": "source-readonly",
76
+ "boundary": "none",
77
+ "coverage": "partial",
78
+ "method": "before/after Git HEAD, index and source file fingerprints",
79
+ "unobserved": [
80
+ "ignored files",
81
+ "external services",
82
+ "writes outside candidate source roots",
83
+ "transient writes restored before the audit",
84
+ ],
85
+ },
86
+ }
87
+ write_artifact(path, execution, "execution")
88
+ try:
89
+ candidates = candidate_projects(request)
90
+ execution["projects"] = capture_projects(candidates)
91
+ execution["sourceArtifactDigests"] = {
92
+ name: hashlib.sha256(Path(name).read_bytes()).hexdigest()
93
+ for name in request.source_artifacts
94
+ }
95
+ _investigate(request, execution, path, executor or ProviderFlowExecutor())
96
+ except (OSError, ValueError, AgentInvocationError, JsonBoundaryError) as exc:
97
+ execution.update(status="failed", error=str(exc))
98
+ write_artifact(path, execution, "execution")
99
+ return execution
100
+
101
+
102
+ def _request_payload(request: FlowRequest) -> FlowRequestPayload:
103
+ payload = asdict(request)
104
+ payload["project_root"] = str(request.project_root)
105
+ payload["source_root"] = str(request.source_root) if request.source_root else None
106
+ payload["source_artifacts"] = list(request.source_artifacts)
107
+ return cast(FlowRequestPayload, payload)
108
+
109
+
110
+ def _investigate(
111
+ request: FlowRequest, execution: FlowExecution, path: Path, executor: FlowExecutor
112
+ ) -> None:
113
+ baseline = (
114
+ load_execution(request.project_root, request.baseline)
115
+ if request.baseline
116
+ else None
117
+ )
118
+ execution["changes"] = source_changes(
119
+ baseline["projects"] if baseline else [], execution["projects"]
120
+ )
121
+ call = executor.prepare(
122
+ request, execution["id"], _instructions(request, execution, baseline)
123
+ )
124
+ execution.update(status="running", metadataPath=str(call.metadata_path))
125
+ write_artifact(path, execution, "execution")
126
+ returned = executor.invoke(call)
127
+ verified = _accept_return(call, returned)
128
+ result = _parse_result(verified, path)
129
+ _validate_investigation_result(request, execution, result)
130
+ execution["result"] = result
131
+ execution["status"] = "partial" if result["gaps"] else "complete"
132
+ producer = {
133
+ "executionId": execution["id"],
134
+ "taskKey": request.task_key,
135
+ "mode": request.mode,
136
+ "metadataPath": str(call.metadata_path),
137
+ }
138
+ execution["contribution"] = contribute_knowledge(
139
+ request.project_root, result["facts"], execution["projects"], producer
140
+ )
141
+ if result["resolutions"]:
142
+ resolve_conflicts(request.project_root, result["resolutions"], producer)
143
+ write_artifact(path, execution, "execution")
144
+ _reinvestigate_conflicts(request, execution, path, executor)
145
+ if request.mode != "contribute":
146
+ from .report import render_explanation
147
+
148
+ execution["reports"] = render_explanation(request.project_root, execution)
149
+ write_artifact(path, execution, "execution")
150
+
151
+
152
+ def _validate_investigation_result(
153
+ request: FlowRequest, execution: FlowExecution, result: BusinessResult
154
+ ) -> None:
155
+ changed = changed_projects(
156
+ execution["projects"], capture_projects(candidate_projects(request))
157
+ )
158
+ if changed:
159
+ raise BusinessFlowError(
160
+ "source changed during read-only investigation: " + ", ".join(changed)
161
+ )
162
+ if any(
163
+ hashlib.sha256(Path(name).read_bytes()).hexdigest() != digest
164
+ for name, digest in execution["sourceArtifactDigests"].items()
165
+ ):
166
+ raise BusinessFlowError("supplied source artifact changed during investigation")
167
+ _validate_result(result, execution["projects"], request)
168
+ unavailable = {
169
+ row["projectId"] for row in execution["changes"] if not row["baselineAvailable"]
170
+ }
171
+ if request.mode == "post" and any(
172
+ step["before"] and unavailable.intersection(step["projectIds"])
173
+ for step in result["steps"]
174
+ ):
175
+ raise BusinessFlowError(
176
+ "before-state assertion references a project without a captured baseline"
177
+ )
178
+ if result["resolutions"]:
179
+ _validate_reconciliation(result, request, execution["projects"])
180
+
181
+
182
+ def _accept_return(call: PreparedCall, returned: bytes) -> str:
183
+ materialize_standalone_result(
184
+ project_root=call.project_root,
185
+ purpose=OPERATION,
186
+ metadata_path=call.metadata_path,
187
+ returned_body=returned,
188
+ )
189
+ completion = publish_standalone_completion(
190
+ project_root=call.project_root,
191
+ purpose=OPERATION,
192
+ metadata_path=call.metadata_path,
193
+ completed_at=datetime.now(timezone.utc),
194
+ )
195
+ return verify_standalone_completion(
196
+ completion, project_root=call.project_root, expected_purpose=OPERATION
197
+ ).returned_body
198
+
199
+
200
+ def _parse_result(body: str, path: Path) -> BusinessResult:
201
+ text = body.strip()
202
+ if text.startswith("```json\n") and text.endswith("\n```"):
203
+ text = text[8:-4]
204
+ try:
205
+ result = json.loads(text)
206
+ except json.JSONDecodeError as exc:
207
+ raise BusinessFlowError(
208
+ f"business investigator returned invalid JSON: {exc}"
209
+ ) from exc
210
+ serialize_owned_object(
211
+ path,
212
+ result,
213
+ artifact="business investigator response",
214
+ schema=schema_definition("result"),
215
+ )
216
+ return cast(BusinessResult, result)
217
+
218
+
219
+ def _validate_result(
220
+ result: BusinessResult, projects: list[SourceSnapshot], request: FlowRequest
221
+ ) -> None:
222
+ ids = {row["projectId"] for row in projects}
223
+ if not set(result["inspectedProjects"]).issubset(ids):
224
+ raise BusinessFlowError("inspection references an unregistered candidate")
225
+ result["gaps"].extend(
226
+ f"Candidate not inspected: {key}"
227
+ for key in sorted(ids - set(result["inspectedProjects"]))
228
+ )
229
+ result["gaps"].extend(
230
+ f"{row['projectId']}: {gap}" for row in projects for gap in row["gaps"]
231
+ )
232
+ if request.mode == "post" and not request.baseline:
233
+ result["gaps"].append(
234
+ "No pre-implementation baseline: before-state cannot be established"
235
+ )
236
+ if any(step["before"] for step in result["steps"]):
237
+ raise BusinessFlowError(
238
+ "before-state assertions require a captured baseline"
239
+ )
240
+ keys = {row["key"] for row in result["facts"]}
241
+ if len(keys) != len(result["facts"]) or len(
242
+ {step["id"] for step in result["steps"]}
243
+ ) != len(result["steps"]):
244
+ raise BusinessFlowError(
245
+ "business claim keys and step identifiers must be unique"
246
+ )
247
+ _validate_fact_evidence(result, projects, request)
248
+ for step in result["steps"]:
249
+ if not set(step["projectIds"]).issubset(ids) or not set(
250
+ step["factKeys"]
251
+ ).issubset(keys):
252
+ raise BusinessFlowError(
253
+ "business step references an unknown project or claim"
254
+ )
255
+ _validate_relationships(result, ids, keys)
256
+ if not result["steps"] and request.mode not in {"contribute", "reconcile"}:
257
+ result["gaps"].append(
258
+ "Business steps could not be established from the available sources"
259
+ )
260
+
261
+
262
+ def _validate_relationships(
263
+ result: BusinessResult, ids: set[str], keys: set[str]
264
+ ) -> None:
265
+ for relationship in result["relationships"]:
266
+ if not {relationship["from"], relationship["to"]}.issubset(ids) or not set(
267
+ relationship["factKeys"]
268
+ ).issubset(keys):
269
+ raise BusinessFlowError(
270
+ "business relationship references an unknown project or claim"
271
+ )
272
+ evidence_projects = {
273
+ e["projectId"]
274
+ for fact in result["facts"]
275
+ if fact["key"] in relationship["factKeys"]
276
+ for e in fact["evidence"]
277
+ }
278
+ if not {relationship["from"], relationship["to"]}.issubset(evidence_projects):
279
+ result["gaps"].append(
280
+ f"Relationship endpoint evidence incomplete: {relationship['from']} / {relationship['to']}"
281
+ )
282
+
283
+
284
+ def _validate_fact_evidence(
285
+ result: BusinessResult, projects: list[SourceSnapshot], request: FlowRequest
286
+ ) -> None:
287
+ for fact in result["facts"]:
288
+ if fact["level"] in {"static", "execution-verified"} and not fact["evidence"]:
289
+ raise BusinessFlowError(
290
+ "confirmed business claims require inspected code evidence"
291
+ )
292
+ for evidence in fact["evidence"]:
293
+ validate_evidence(evidence, projects)
294
+ if evidence["projectId"] not in result["inspectedProjects"]:
295
+ raise BusinessFlowError(
296
+ "claim evidence references an uninspected candidate"
297
+ )
298
+ if fact["level"] == "execution-verified":
299
+ _validate_execution_receipts(fact, request, projects)
300
+
301
+
302
+ def _validate_execution_receipts(
303
+ fact: BusinessFact, request: FlowRequest, projects: list[SourceSnapshot]
304
+ ) -> None:
305
+ receipts = fact.get("executionEvidence", [])
306
+ if not receipts or not set(receipts).issubset(request.source_artifacts):
307
+ raise BusinessFlowError(
308
+ "execution verification must cite a supplied existing verification record"
309
+ )
310
+ states = {row["projectId"]: row["digest"] for row in projects}
311
+ for receipt in receipts:
312
+ record = load_owned_object(
313
+ Path(receipt),
314
+ artifact="existing business verification receipt",
315
+ schema=schema_definition("verificationReceipt"),
316
+ )
317
+ start, end = (
318
+ datetime.fromisoformat(record["startedAt"]),
319
+ datetime.fromisoformat(record["endedAt"]),
320
+ )
321
+ if (
322
+ start.tzinfo is None
323
+ or end.tzinfo is None
324
+ or end < start
325
+ or end > datetime.now(timezone.utc)
326
+ ):
327
+ raise BusinessFlowError(
328
+ "verification receipt has invalid execution timestamps"
329
+ )
330
+ needed = {row["projectId"] for row in fact["evidence"]}
331
+ if not needed.issubset(record["sourceStates"]) or any(
332
+ states.get(key) != digest for key, digest in record["sourceStates"].items()
333
+ ):
334
+ raise BusinessFlowError(
335
+ "verification receipt does not match the captured source version"
336
+ )
337
+
338
+
339
+ def _validate_reconciliation(
340
+ result: BusinessResult, request: FlowRequest, projects: list[SourceSnapshot]
341
+ ) -> None:
342
+ if request.mode != "reconcile":
343
+ raise BusinessFlowError(
344
+ "conflict resolutions require a dedicated reinvestigation"
345
+ )
346
+ knowledge = read_shared_knowledge(request.project_root)
347
+ validate_resolutions(knowledge, result["resolutions"])
348
+ claims = {row["id"]: row for row in knowledge["claims"]}
349
+ states = {row["projectId"]: row["digest"] for row in projects}
350
+ for resolution in result["resolutions"]:
351
+ claim = claims[resolution["acceptedClaimId"]]
352
+ if any(
353
+ states.get(key) != value["digest"]
354
+ for key, value in claim["sourceStates"].items()
355
+ ):
356
+ raise BusinessFlowError(
357
+ "conflict source changed; preserve historical conflict"
358
+ )
359
+ if not any(
360
+ fact["key"] == claim["key"]
361
+ and fact["value"] == claim["value"]
362
+ and fact["level"] in {"static", "execution-verified"}
363
+ for fact in result["facts"]
364
+ ):
365
+ raise BusinessFlowError(
366
+ "accepted conflict claim lacks reinspected confirming evidence"
367
+ )
368
+
369
+
370
+ def _instructions(
371
+ request: FlowRequest, execution: FlowExecution, baseline: FlowExecution | None
372
+ ) -> str:
373
+ context = _investigation_context(request, execution, baseline)
374
+ schema = schema_definition("result")
375
+ return "\n".join(
376
+ [
377
+ "# Business Flow Investigation",
378
+ "",
379
+ "Return only one JSON object matching the schema below.",
380
+ "Read the listed source roots and source artifacts only. Do not edit files, execute tests, start services, deploy or migrate.",
381
+ "Inspect inbound AND outbound dependencies across all candidates, including indirect call/event/queue/shared-data/file/batch relationships. Name every uninspected candidate or missing branch in gaps.",
382
+ "Explain the product business from start to finish for a new developer: purpose, rules, input/output, states, failure, retry and recovery. Link each step and relationship to factKeys.",
383
+ "Use stable keys in business/subject/attribute form. Facts are source-backed assertions; expected changes remain expected. Cite exact inspected excerpts with one-based line/endLine. Tests are static evidence unless a supplied existing verification record supports the execution claim.",
384
+ "For post mode, use the supplied project-specific baseline. Never invent missing before-state. Describe the actual delta and distinguish confirmed side effects from risks.",
385
+ "For contribute mode, extract newly observed business facts from the supplied validated task record and inspect source to confirm them. Empty facts are valid. Do not copy the report narrative as established truth.",
386
+ "For reconcile mode, reinspect every conflicting source and condition. Return explicit resolutions only with evidence-backed reasoning and original claimIds/acceptedClaimId. Preserve genuinely unresolved policy decisions in questions. Other modes return resolutions=[].",
387
+ "Keep fact keys, business identifiers and fact values canonical in English; preserve original business terms in aliases. Do not create conflicts by translating the same fact. Read source evidence before accepting stored knowledge, including other projects' claims.",
388
+ "execution-verified requires a supplied JSON verificationReceipt with command, startedAt, endedAt, exitCode=0, output and matching projectId-to-digest sourceStates. A final report alone is not an execution receipt.",
389
+ f"Write report titles, summaries, terms, steps, relationship data, gaps and questions in {request.report_language}; preserve paths, identifiers and enum values.",
390
+ "",
391
+ "## Source Material and Captured Baseline",
392
+ json.dumps(context, ensure_ascii=False, indent=2),
393
+ "",
394
+ "## Result Schema",
395
+ json.dumps(schema, ensure_ascii=False, indent=2),
396
+ "",
397
+ ]
398
+ )
399
+
400
+
401
+ def _investigation_context(
402
+ request: FlowRequest, execution: FlowExecution, baseline: FlowExecution | None
403
+ ) -> dict[str, Any]:
404
+ compact = [
405
+ {
406
+ "projectId": row["projectId"],
407
+ "root": row["root"],
408
+ "head": row["head"],
409
+ "digest": row["digest"],
410
+ "sourceFileCount": len(row["files"]),
411
+ "dirtyPaths": list(row["dirty"]),
412
+ "gaps": row["gaps"],
413
+ }
414
+ for row in execution["projects"]
415
+ ]
416
+ return {
417
+ "request": execution["request"],
418
+ "projects": compact,
419
+ "snapshotPath": str(
420
+ artifact_path(
421
+ request.project_root, "executions", execution["id"], "execution.json"
422
+ )
423
+ ),
424
+ "changes": execution["changes"],
425
+ "baseline": baseline.get("result") if baseline else None,
426
+ "baselinePath": str(
427
+ artifact_path(
428
+ request.project_root, "executions", request.baseline, "execution.json"
429
+ )
430
+ )
431
+ if baseline
432
+ else "",
433
+ "knowledge": query_knowledge(
434
+ request.project_root, request.question, request.source_root
435
+ ),
436
+ }
437
+
438
+
439
+ def _reinvestigate_conflicts(
440
+ request: FlowRequest, execution: FlowExecution, path: Path, executor: FlowExecutor
441
+ ) -> None:
442
+ if request.mode == "reconcile":
443
+ return
444
+ conflict_keys = {
445
+ row["key"]
446
+ for row in query_knowledge(
447
+ request.project_root, request.question, request.source_root
448
+ )["claims"]
449
+ if row["status"] == "conflict"
450
+ }
451
+ if not conflict_keys:
452
+ return
453
+ child = explain_flow(
454
+ replace(
455
+ request,
456
+ mode="reconcile",
457
+ question="\n".join(sorted(conflict_keys)),
458
+ baseline="",
459
+ ),
460
+ executor,
461
+ )
462
+ execution["reconciliation"] = {
463
+ "executionId": child["id"],
464
+ "status": child["status"],
465
+ "error": child.get("error", ""),
466
+ "questions": child.get("result", {}).get("questions", []),
467
+ }
468
+ unresolved = [
469
+ row["key"]
470
+ for row in query_knowledge(
471
+ request.project_root, request.question, request.source_root
472
+ )["claims"]
473
+ if row["status"] == "conflict"
474
+ ]
475
+ if unresolved:
476
+ execution["result"]["questions"].append(
477
+ "Unresolved shared business conflicts: "
478
+ + ", ".join(sorted(set(unresolved)))
479
+ )
480
+ execution["status"] = "partial"
481
+ write_artifact(path, execution, "execution")
482
+
483
+
484
+ def load_execution(project_root: Path, execution_id: str) -> FlowExecution:
485
+ if not execution_id or any(
486
+ char not in "abcdefghijklmnopqrstuvwxyz0123456789-" for char in execution_id
487
+ ):
488
+ raise BusinessFlowError("invalid business explanation execution identifier")
489
+ return cast(
490
+ FlowExecution,
491
+ read_artifact(
492
+ artifact_path(project_root, "executions", execution_id, "execution.json"),
493
+ "execution",
494
+ ),
495
+ )
496
+
497
+
498
+ def rerun_explanation(
499
+ project_root: Path,
500
+ execution_id: str,
501
+ executor: FlowExecutor | None = None,
502
+ *,
503
+ source_root: Path | None = None,
504
+ ) -> FlowExecution:
505
+ old = load_execution(project_root, execution_id)
506
+ payload = dict(old["request"])
507
+ payload["project_root"] = project_root
508
+ payload["source_root"] = (
509
+ Path(payload["source_root"]) if payload["source_root"] else None
510
+ )
511
+ payload["source_artifacts"] = tuple(payload["source_artifacts"])
512
+ if source_root is not None:
513
+ payload["source_root"] = source_root
514
+ request = FlowRequest(**payload)
515
+ execution = explain_flow(request, executor)
516
+ if request.task_key:
517
+ record_task_execution(request, execution, execution["id"])
518
+ return execution