okstra 0.207.0 → 0.208.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +1 -1
- package/dist/commands/lifecycle/doctor.mjs +1 -1
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/docs/architecture/storage-model.md +14 -0
- package/docs/architecture.md +30 -9
- package/docs/cli.md +26 -22
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +15 -8
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/operations/explain-flow.json +6 -0
- package/runtime/bin/lib/okstra/cli.sh +1 -5
- package/runtime/bin/lib/okstra/globals.sh +0 -2
- package/runtime/bin/lib/okstra/usage.sh +5 -3
- package/runtime/bin/okstra.sh +0 -2
- package/runtime/prompts/duties/business-flow-investigator.json +14 -0
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +22 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +10 -6
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +12 -17
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +0 -91
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +6 -0
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +11 -125
- package/runtime/python/okstra_ctl/agent/standalone.py +183 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +39 -8
- package/runtime/python/okstra_ctl/assignment_resolver.py +7 -1
- package/runtime/python/okstra_ctl/brief_frontmatter.py +10 -0
- package/runtime/python/okstra_ctl/business_flow/__init__.py +4 -0
- package/runtime/python/okstra_ctl/business_flow/cli.py +134 -0
- package/runtime/python/okstra_ctl/business_flow/contracts.py +268 -0
- package/runtime/python/okstra_ctl/business_flow/engine.py +518 -0
- package/runtime/python/okstra_ctl/business_flow/hooks.py +221 -0
- package/runtime/python/okstra_ctl/business_flow/invocation.py +170 -0
- package/runtime/python/okstra_ctl/business_flow/report.py +49 -0
- package/runtime/python/okstra_ctl/business_flow/source.py +206 -0
- package/runtime/python/okstra_ctl/business_flow/store.py +388 -0
- package/runtime/python/okstra_ctl/convergence.py +173 -2
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +18 -0
- package/runtime/python/okstra_ctl/convergence_provenance.py +8 -0
- package/runtime/python/okstra_ctl/coverage_census.py +596 -0
- package/runtime/python/okstra_ctl/design_surfaces.py +4 -0
- package/runtime/python/okstra_ctl/direct_work.py +1 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +7 -3
- package/runtime/python/okstra_ctl/doctor.py +12 -6
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/group_context.py +5 -4
- package/runtime/python/okstra_ctl/legacy_model_selection.py +7 -51
- package/runtime/python/okstra_ctl/manager_split.py +4 -1
- package/runtime/python/okstra_ctl/manager_view.py +9 -3
- package/runtime/python/okstra_ctl/model_io/lines.py +1 -24
- package/runtime/python/okstra_ctl/model_io/renderers.py +54 -41
- package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/change_impact_analysis/profile.md +0 -8
- package/runtime/python/okstra_ctl/phases/error_analysis/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/error_analysis/profile.md +6 -8
- package/runtime/python/okstra_ctl/phases/feature_analysis/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/feature_analysis/profile.md +0 -8
- package/runtime/python/okstra_ctl/phases/final_verification/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +2 -8
- package/runtime/python/okstra_ctl/phases/implementation/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/profile.md +0 -6
- package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation-input.template.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +4 -3
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +1 -14
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +5 -8
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/spec.md +3 -3
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +15 -5
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +9 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +4 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +25 -9
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +7 -10
- package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/improvement_discovery/profile.md +4 -11
- package/runtime/python/okstra_ctl/phases/project_analysis/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/project_analysis/profile.md +0 -8
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/profile.md +10 -8
- package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +3 -3
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.json +1 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +0 -4
- package/runtime/python/okstra_ctl/plan_items.py +1 -1
- package/runtime/python/okstra_ctl/render.py +10 -43
- package/runtime/python/okstra_ctl/render_final_report.py +3 -0
- package/runtime/python/okstra_ctl/report_assembly.py +15 -1
- package/runtime/python/okstra_ctl/report_finalize.py +40 -0
- package/runtime/python/okstra_ctl/report_html/render.py +3 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -2
- package/runtime/python/okstra_ctl/run.py +78 -409
- package/runtime/python/okstra_ctl/wizard/__init__.py +2 -24
- package/runtime/python/okstra_ctl/wizard/cli.py +3 -6
- package/runtime/python/okstra_ctl/wizard/confirmation.py +3 -35
- package/runtime/python/okstra_ctl/wizard/engine.py +2 -4
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -88
- package/runtime/python/okstra_ctl/wizard/registry.py +36 -228
- package/runtime/python/okstra_ctl/wizard/render.py +2 -2
- package/runtime/python/okstra_ctl/wizard/roles.py +1 -3
- package/runtime/python/okstra_ctl/wizard/sources.py +9 -40
- package/runtime/python/okstra_ctl/wizard/state.py +36 -145
- package/runtime/python/okstra_ctl/wizard/statefile.py +27 -128
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +22 -10
- package/runtime/python/okstra_ctl/wizard/steps_options.py +5 -4
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +15 -565
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +9 -2
- package/runtime/schemas/business-flow-v1.schema.json +847 -0
- package/runtime/schemas/convergence-groups-v2.0.schema.json +7 -0
- package/runtime/skills/okstra-explain-flow/SKILL.md +42 -0
- package/runtime/skills/okstra-inspect/facets/history.md +5 -5
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/templates/manager/view.template.html +7 -4
- package/runtime/templates/reports/business-flow.template.md +106 -0
- package/runtime/templates/reports/html/base.template.html +14 -1
- package/runtime/templates/reports/html/business-flow.template.html +31 -0
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/templates/worker-prompt-preamble.md +11 -2
- package/runtime/validators/checks/validate-prompt-metadata-01.py +10 -10
- package/runtime/validators/validate-run.py +70 -21
- package/runtime/validators/validate_analysis_report.py +21 -21
- package/runtime/python/okstra_ctl/workers.py +0 -133
|
@@ -0,0 +1,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
|