okstra 0.207.1 → 0.209.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 (133) 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/cmux.py +21 -16
  47. package/runtime/python/okstra_ctl/convergence.py +173 -2
  48. package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +18 -0
  49. package/runtime/python/okstra_ctl/convergence_provenance.py +8 -0
  50. package/runtime/python/okstra_ctl/coverage_census.py +603 -0
  51. package/runtime/python/okstra_ctl/design_surfaces.py +4 -0
  52. package/runtime/python/okstra_ctl/direct_work.py +1 -1
  53. package/runtime/python/okstra_ctl/dispatch_core.py +7 -3
  54. package/runtime/python/okstra_ctl/doctor.py +12 -6
  55. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  56. package/runtime/python/okstra_ctl/group_context.py +5 -4
  57. package/runtime/python/okstra_ctl/legacy_model_selection.py +7 -51
  58. package/runtime/python/okstra_ctl/manager_split.py +4 -1
  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 +14 -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 +50 -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/reports/business-flow.template.md +106 -0
  125. package/runtime/templates/reports/html/base.template.html +14 -1
  126. package/runtime/templates/reports/html/business-flow.template.html +31 -0
  127. package/runtime/templates/reports/html/i18n/en.json +1 -0
  128. package/runtime/templates/reports/html/i18n/ko.json +1 -0
  129. package/runtime/templates/worker-prompt-preamble.md +11 -2
  130. package/runtime/validators/checks/validate-prompt-metadata-01.py +10 -10
  131. package/runtime/validators/validate-run.py +70 -21
  132. package/runtime/validators/validate_analysis_report.py +21 -21
  133. package/runtime/python/okstra_ctl/workers.py +0 -133
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from ..json_boundary import JsonBoundaryError, load_owned_object
7
+ from ..report_language import resolve_report_language
8
+ from .contracts import (
9
+ BusinessFlowError,
10
+ FlowRequest,
11
+ artifact_path,
12
+ business_root,
13
+ fingerprint,
14
+ read_artifact,
15
+ write_artifact,
16
+ )
17
+ from .engine import explain_flow, load_execution
18
+ from .invocation import FlowExecutor
19
+ from .source import candidate_projects, capture_projects, changed_projects
20
+ from .store import knowledge_packet, read_task_links, record_task_execution
21
+
22
+
23
+ def prepare_task_knowledge(
24
+ project_root: Path,
25
+ instruction_set: Path,
26
+ ctx: dict,
27
+ question: str,
28
+ executor: FlowExecutor | None = None,
29
+ ) -> str:
30
+ source = (
31
+ ctx.get("VERIFICATION_WORKTREE_PATH")
32
+ or ctx.get("EXECUTOR_WORKTREE_PATH")
33
+ or str(project_root)
34
+ )
35
+ try:
36
+ business_root(project_root)
37
+ request = {
38
+ "taskKey": ctx["TASK_KEY"],
39
+ "taskType": ctx["TASK_TYPE"],
40
+ "question": question,
41
+ "sourceRoot": source,
42
+ "reportLanguage": resolve_report_language(project_root, {}, ctx),
43
+ "hostRuntime": ctx.get("HOST_RUNTIME")
44
+ or ctx.get("LEAD_RUNTIME", "claude-code"),
45
+ }
46
+ manifest = ctx.get("RUN_MANIFEST_RELATIVE_PATH")
47
+ path = (
48
+ artifact_path(
49
+ project_root, "task-requests", fingerprint(manifest) + ".json"
50
+ )
51
+ if manifest
52
+ else instruction_set / "business-flow-request.json"
53
+ )
54
+ if not path.resolve().is_relative_to((project_root / ".okstra").resolve()):
55
+ raise BusinessFlowError("business request path escapes project artifacts")
56
+ write_artifact(path, request, "taskRequest")
57
+ if ctx["TASK_TYPE"] == "implementation":
58
+ _ensure_pre_explanation(project_root, request, executor)
59
+ except (OSError, ValueError, JsonBoundaryError) as exc:
60
+ return f"## Shared Business Knowledge\n\nBusiness preparation failed: {exc}\n"
61
+ return knowledge_packet(project_root, question, Path(source))
62
+
63
+
64
+ def _flow_request(
65
+ project_root: Path,
66
+ request: dict[str, Any],
67
+ mode: str,
68
+ baseline: str = "",
69
+ artifacts: tuple[str, ...] = (),
70
+ ) -> FlowRequest:
71
+ return FlowRequest(
72
+ project_root=project_root,
73
+ question=request["question"],
74
+ mode=mode,
75
+ task_key=request["taskKey"],
76
+ baseline=baseline,
77
+ source_root=Path(request["sourceRoot"]),
78
+ report_language=request["reportLanguage"],
79
+ source_artifacts=artifacts,
80
+ host_runtime=request["hostRuntime"],
81
+ )
82
+
83
+
84
+ def _ensure_pre_explanation(
85
+ project_root: Path, request: dict[str, Any], executor: FlowExecutor | None
86
+ ) -> dict[str, Any]:
87
+ links = read_task_links(project_root, request["taskKey"])["executions"]
88
+ prior = next((row for row in reversed(links) if row["mode"] == "pre"), None)
89
+ flow_request = _flow_request(project_root, request, "pre")
90
+ if prior:
91
+ execution = load_execution(project_root, prior["id"])
92
+ current = capture_projects(candidate_projects(flow_request))
93
+ if not changed_projects(execution["projects"], current):
94
+ return prior
95
+ execution = explain_flow(flow_request, executor)
96
+ return record_task_execution(flow_request, execution, fingerprint(request))
97
+
98
+
99
+ def finalize_business_flow(
100
+ project_root: Path,
101
+ manifest: dict,
102
+ data_path: Path,
103
+ executor: FlowExecutor | None = None,
104
+ ) -> dict[str, Any] | None:
105
+ instruction_root = manifest.get("instructionSetPath", "")
106
+ if not instruction_root:
107
+ return None
108
+ manifest_path = manifest.get("runManifestPath")
109
+ request_path = (
110
+ artifact_path(
111
+ project_root, "task-requests", fingerprint(manifest_path) + ".json"
112
+ )
113
+ if manifest_path
114
+ else project_root / instruction_root / "business-flow-request.json"
115
+ )
116
+ if (
117
+ not request_path.resolve().is_relative_to(project_root.resolve())
118
+ or not request_path.exists()
119
+ ):
120
+ return None
121
+ try:
122
+ request = read_artifact(request_path, "taskRequest")
123
+ if request["taskKey"] != manifest.get("taskKey") or (
124
+ manifest.get("taskType") and request["taskType"] != manifest["taskType"]
125
+ ):
126
+ raise BusinessFlowError(
127
+ "business request belongs to a different task or phase"
128
+ )
129
+ record = load_owned_object(
130
+ data_path, artifact="validated business contribution source"
131
+ )
132
+ digest = fingerprint({"record": record, "request": request})
133
+ links = read_task_links(project_root, request["taskKey"])
134
+ previous = [row for row in links["executions"] if row["inputDigest"] == digest]
135
+ if not previous:
136
+ _complete_task_effects(project_root, request, data_path, digest, executor)
137
+ links = read_task_links(project_root, request["taskKey"])
138
+ sidecar = data_path.with_name(data_path.name + ".business-flow.json")
139
+ write_artifact(sidecar, links, "taskLink")
140
+ return links
141
+ except (OSError, ValueError, JsonBoundaryError) as exc:
142
+ links = _failed_task_link(manifest.get("taskKey", ""), str(exc))
143
+ write_artifact(
144
+ data_path.with_name(data_path.name + ".business-flow.json"),
145
+ links,
146
+ "taskLink",
147
+ )
148
+ return links
149
+
150
+
151
+ def _failed_task_link(task_key: str, error: str) -> dict[str, Any]:
152
+ failure = {
153
+ "id": "",
154
+ "mode": "unknown",
155
+ "status": "failed",
156
+ "inputDigest": "",
157
+ "reports": {},
158
+ "summary": "",
159
+ "error": error,
160
+ }
161
+ links = {
162
+ "schemaVersion": 1,
163
+ "taskKey": task_key,
164
+ "executions": [failure],
165
+ }
166
+ return links
167
+
168
+
169
+ def _complete_task_effects(
170
+ project_root: Path,
171
+ request: dict[str, Any],
172
+ data_path: Path,
173
+ digest: str,
174
+ executor: FlowExecutor | None,
175
+ ) -> None:
176
+ artifacts = (str(data_path),)
177
+ phase = request["taskType"]
178
+ if phase.endswith("-analysis") or phase in {
179
+ "requirements-discovery",
180
+ "improvement-discovery",
181
+ "technical-verification",
182
+ }:
183
+ mode = "pre"
184
+ elif phase == "final-verification":
185
+ mode = "post"
186
+ else:
187
+ mode = "contribute"
188
+ prior = read_task_links(project_root, request["taskKey"])["executions"]
189
+ baseline = next((row["id"] for row in reversed(prior) if row["mode"] == "pre"), "")
190
+ flow_request = _flow_request(
191
+ project_root, request, mode, baseline if mode == "post" else "", artifacts
192
+ )
193
+ execution = explain_flow(flow_request, executor)
194
+ record_task_execution(flow_request, execution, digest)
195
+
196
+
197
+ def report_links(data_path: Path) -> list[dict[str, Any]]:
198
+ sidecar = data_path.with_name(data_path.name + ".business-flow.json")
199
+ if not sidecar.exists():
200
+ return []
201
+ try:
202
+ return read_artifact(sidecar, "taskLink")["executions"]
203
+ except (OSError, ValueError, JsonBoundaryError) as exc:
204
+ return _failed_task_link("", str(exc))["executions"]
205
+
206
+
207
+ def markdown_report_links(data_path: Path) -> str:
208
+ rows = report_links(data_path)
209
+ if not rows:
210
+ return ""
211
+ lines = ["", "## Business Flow Explanations", ""]
212
+ for row in rows:
213
+ lines.append(f"- {row['mode']}: {row['status']}. {row['summary']}")
214
+ lines.extend(f" - [{kind}]({path})" for kind, path in row["reports"].items())
215
+ if row["error"]:
216
+ lines.append(f" - Explanation error: {row['error']}")
217
+ if row["id"]:
218
+ lines.append(
219
+ f" - Retry: `okstra explain-flow rerun --execution {row['id']}`"
220
+ )
221
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import io
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from ..agent.invocation import verify_agent_invocation
10
+ from ..agent.standalone import (
11
+ StandaloneInvocationRequest,
12
+ prepare_standalone_invocation,
13
+ )
14
+ from ..assignment_environment import load_assignment_context
15
+ from ..dispatch_core import DispatchError, verify_served_model_ref
16
+ from ..domain.provider import ServedModelAttestation
17
+ from ..domain.worker_exec import WorkerExecRequest
18
+ from ..domain.worker_role import role_spec
19
+ from ..json_boundary import load_owned_object
20
+ from ..model_pool import ModelPool
21
+ from ..operation_invocation import resolve_operation_slots
22
+ from ..paths import find_asset_root
23
+ from ..registry.provider_registry import default_provider_registry
24
+ from ..worker_request import build_request
25
+ from ..worker_runner import QUIET, run_worker
26
+ from ..write_policy import build_write_policy, derive_write_enforcement
27
+ from .contracts import OPERATION, BusinessFlowError, FlowRequest
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class PreparedCall:
32
+ project_root: Path
33
+ prompt_path: Path
34
+ metadata_path: Path
35
+
36
+
37
+ class FlowExecutor(Protocol):
38
+ def prepare(
39
+ self, request: FlowRequest, execution_id: str, instructions: str
40
+ ) -> PreparedCall: ...
41
+ def invoke(self, call: PreparedCall) -> bytes: ...
42
+
43
+
44
+ class ProviderFlowExecutor:
45
+ def prepare(
46
+ self, request: FlowRequest, execution_id: str, instructions: str
47
+ ) -> PreparedCall:
48
+ contract_root = find_asset_root(("agents", "operations", OPERATION + ".json"))
49
+ if contract_root is None:
50
+ raise BusinessFlowError(
51
+ "business investigation operation contract is unavailable"
52
+ )
53
+ pool = ModelPool.from_registry(default_provider_registry())
54
+ slot = resolve_operation_slots(
55
+ OPERATION, contract_root=contract_root, pool=pool
56
+ )[0]
57
+ root = request.project_root / ".okstra" / "agent-invocations" / OPERATION
58
+ if not root.resolve().is_relative_to(request.project_root.resolve()):
59
+ raise BusinessFlowError("invocation root escapes project")
60
+ root.mkdir(parents=True, exist_ok=True)
61
+ instruction = root / f"{execution_id}.instructions.md"
62
+ instruction.write_text(instructions, encoding="utf-8")
63
+ context = load_assignment_context(
64
+ host_runtime=request.host_runtime,
65
+ terminal_backend="cli-wrapper",
66
+ execution_provider_ids=(slot.provider_id,),
67
+ include_native_provider=False,
68
+ )
69
+ prepared = prepare_standalone_invocation(
70
+ StandaloneInvocationRequest(
71
+ project_root=request.project_root,
72
+ invocation_id=execution_id,
73
+ audience="business-flow-investigator",
74
+ purpose=OPERATION,
75
+ context=context,
76
+ provider=slot.provider_id,
77
+ model_role=slot.role_id,
78
+ model=slot.model_ref,
79
+ instruction_path=instruction,
80
+ prompt_path=root / f"{execution_id}.prompt.md",
81
+ requested_runner="cli-wrapper",
82
+ )
83
+ )
84
+ return PreparedCall(
85
+ request.project_root,
86
+ prepared.prompt_path,
87
+ prepared.metadata_path,
88
+ )
89
+
90
+ def invoke(self, call: PreparedCall) -> bytes:
91
+ violations = verify_agent_invocation(
92
+ metadata_path=call.metadata_path,
93
+ project_root=call.project_root,
94
+ expected_run_manifest_path=None,
95
+ expected_audience="business-flow-investigator",
96
+ )
97
+ if violations:
98
+ raise BusinessFlowError(
99
+ "business invocation verification failed: " + "; ".join(violations)
100
+ )
101
+ metadata = load_owned_object(
102
+ call.metadata_path, artifact="business flow invocation"
103
+ )
104
+ assignment = metadata["modelAssignment"]
105
+ if assignment["runner"] != "cli-wrapper":
106
+ raise BusinessFlowError(
107
+ "automatic business investigation requires a CLI execution binding"
108
+ )
109
+ registry = default_provider_registry()
110
+ provider = registry.resolve(assignment["provider"])
111
+ pool = ModelPool.from_registry(registry)
112
+ selected = pool.resolve_alias(assignment["provider"], assignment["model"])
113
+ request = _read_only_worker_request(call, assignment)
114
+ output, errors = io.StringIO(), io.StringIO()
115
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
116
+ code = run_worker(
117
+ provider.exec_strategy,
118
+ request,
119
+ presentation=QUIET,
120
+ log_path=call.prompt_path.with_suffix(".log"),
121
+ status_path=call.prompt_path.with_suffix(".status.json"),
122
+ status_extra={
123
+ "writePolicy": request.policy.write_policy.to_payload(),
124
+ "writeEnforcement": request.policy.write_enforcement.to_payload(),
125
+ },
126
+ served_model_normalizer=provider.served_model_normalizer,
127
+ served_model_verifier=lambda attestation: _served_model_failure(
128
+ str(selected.model_ref), attestation, pool
129
+ ),
130
+ )
131
+ if code:
132
+ raise BusinessFlowError(
133
+ f"business investigator exited {code}: {errors.getvalue().strip()}"
134
+ )
135
+ return output.getvalue().encode("utf-8")
136
+
137
+
138
+ def _read_only_worker_request(
139
+ call: PreparedCall, assignment: dict[str, str]
140
+ ) -> WorkerExecRequest:
141
+ policy = build_write_policy(
142
+ {
143
+ "role": "analyser",
144
+ "projectRoot": str(call.project_root),
145
+ "artifactPaths": [str(call.prompt_path.relative_to(call.project_root))],
146
+ },
147
+ {"workerWriteCapability": {"maxBoundaryPrecision": "none"}},
148
+ None,
149
+ )
150
+ enforcement = derive_write_enforcement(policy, "none", role="analyser")
151
+ return build_request(
152
+ prompt_text=call.prompt_path.read_text(encoding="utf-8"),
153
+ model=assignment["modelExecutionValue"],
154
+ project_root=call.project_root,
155
+ worktree_path=None,
156
+ role="analyser",
157
+ idle_timeout_seconds=role_spec("analyser").idle_timeout_seconds,
158
+ write_policy=policy,
159
+ write_enforcement=enforcement,
160
+ )
161
+
162
+
163
+ def _served_model_failure(
164
+ model_ref: str, attestation: ServedModelAttestation, pool: ModelPool
165
+ ) -> str:
166
+ try:
167
+ verify_served_model_ref(model_ref, attestation, pool=pool)
168
+ except DispatchError as exc:
169
+ return str(exc)
170
+ return ""
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from okstra_vendor.jinja2 import Environment, StrictUndefined, select_autoescape
6
+
7
+ from ..md_table import to_cell_text
8
+ from ..paths import find_asset_root
9
+ from ..report_template_loader import report_template_loader
10
+ from .contracts import BusinessFlowError, FlowExecution, artifact_path
11
+ from .store import read_knowledge
12
+
13
+
14
+ def render_explanation(project_root: Path, execution: FlowExecution) -> dict[str, str]:
15
+ root = find_asset_root(("templates", "reports", "business-flow.template.md"))
16
+ if root is None:
17
+ raise BusinessFlowError("business flow report templates are unavailable")
18
+ templates = root / "templates" / "reports"
19
+ env = Environment(
20
+ loader=report_template_loader(templates),
21
+ autoescape=select_autoescape(("html",)),
22
+ undefined=StrictUndefined,
23
+ )
24
+ env.filters["cell"] = to_cell_text
25
+ context = {
26
+ "execution": execution,
27
+ "flow": execution["result"],
28
+ "claims": [
29
+ row
30
+ for row in read_knowledge(project_root)["claims"]
31
+ if row["key"] in {fact["key"] for fact in execution["result"]["facts"]}
32
+ ],
33
+ "css": (templates / "html" / "assets" / "base.css").read_text(encoding="utf-8"),
34
+ }
35
+ reports = {}
36
+ for kind, template in (
37
+ ("markdown", "business-flow.template.md"),
38
+ ("html", "html/business-flow.template.html"),
39
+ ):
40
+ suffix = ".md" if kind == "markdown" else ".html"
41
+ path = artifact_path(
42
+ project_root, "executions", execution["id"], "report" + suffix
43
+ )
44
+ rendered = env.get_template(template).render(**context)
45
+ temporary = path.with_suffix(suffix + ".tmp")
46
+ temporary.write_text(rendered, encoding="utf-8")
47
+ temporary.replace(path)
48
+ reports[kind] = str(path)
49
+ return reports
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..json_boundary import load_owned_object
11
+ from ..manager_store import discover_projects
12
+ from ..paths import okstra_home
13
+ from .contracts import (
14
+ BusinessEvidence,
15
+ BusinessFlowError,
16
+ FlowRequest,
17
+ ProjectCandidate,
18
+ SourceSnapshot,
19
+ fingerprint,
20
+ )
21
+
22
+
23
+ def candidate_projects(request: FlowRequest) -> list[ProjectCandidate]:
24
+ root = request.project_root.resolve()
25
+ config = root / ".okstra" / "project.json"
26
+ project_id = root.name
27
+ if config.is_file():
28
+ project_id = str(
29
+ load_owned_object(config, artifact="project configuration").get("projectId")
30
+ or project_id
31
+ )
32
+ candidates = {
33
+ row["projectId"]: {"projectId": row["projectId"], "root": row["projectRoot"]}
34
+ for row in discover_projects(okstra_home())
35
+ }
36
+ candidates[project_id] = {
37
+ "projectId": project_id,
38
+ "root": str(request.source_root or root),
39
+ }
40
+ return sorted(candidates.values(), key=lambda row: row["projectId"])
41
+
42
+
43
+ def git_output(root: Path, *args: str) -> bytes:
44
+ result = subprocess.run(
45
+ ["git", "-C", str(root), *args], capture_output=True, check=False
46
+ )
47
+ if result.returncode:
48
+ raise BusinessFlowError(
49
+ f"Git source inspection failed at {root}: {result.stderr.decode(errors='replace').strip()}"
50
+ )
51
+ return result.stdout
52
+
53
+
54
+ def source_inventory(root: Path) -> list[str]:
55
+ names = git_output(
56
+ root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"
57
+ )
58
+ return sorted(
59
+ {
60
+ os.fsdecode(name)
61
+ for name in names.split(b"\0")
62
+ if name and ".okstra" not in Path(os.fsdecode(name)).parts
63
+ }
64
+ )
65
+
66
+
67
+ def capture_project(candidate: ProjectCandidate) -> SourceSnapshot:
68
+ root = Path(candidate["root"])
69
+ state: SourceSnapshot = {
70
+ **candidate,
71
+ "head": "",
72
+ "digest": "",
73
+ "files": {},
74
+ "dirty": {},
75
+ "gaps": [],
76
+ }
77
+ try:
78
+ root = root.resolve(strict=True)
79
+ before = git_output(root, "status", "--porcelain=v1", "-z")
80
+ state["head"] = git_output(root, "rev-parse", "HEAD").decode().strip()
81
+ dirty = {
82
+ os.fsdecode(name)
83
+ for name in git_output(
84
+ root, "diff", "--no-ext-diff", "--name-only", "-z", "HEAD"
85
+ ).split(b"\0")
86
+ if name
87
+ }
88
+ tracked = {
89
+ os.fsdecode(name)
90
+ for name in git_output(root, "ls-files", "-z").split(b"\0")
91
+ if name
92
+ }
93
+ for name in source_inventory(root):
94
+ _capture_file(root, name, name in dirty or name not in tracked, state)
95
+ index = git_output(
96
+ root, "diff", "--no-ext-diff", "--no-textconv", "--cached", "--binary"
97
+ )
98
+ state["indexDigest"] = hashlib.sha256(index).hexdigest()
99
+ state["indexPatch"] = base64.b64encode(index).decode("ascii")
100
+ state["statusDigest"] = hashlib.sha256(before).hexdigest()
101
+ if before != git_output(root, "status", "--porcelain=v1", "-z"):
102
+ state["gaps"].append("Source status changed while collecting the baseline")
103
+ state["digest"] = fingerprint(
104
+ {
105
+ "head": state["head"],
106
+ "files": state["files"],
107
+ "index": state["indexDigest"],
108
+ }
109
+ )
110
+ except (OSError, BusinessFlowError) as exc:
111
+ state["gaps"].append(str(exc))
112
+ return state
113
+
114
+
115
+ def _capture_file(root: Path, name: str, dirty: bool, state: SourceSnapshot) -> None:
116
+ path = root / name
117
+ if path.is_symlink():
118
+ state["gaps"].append(f"Symbolic link source was not followed: {name}")
119
+ state["files"][name] = fingerprint({"symlink": os.readlink(path)})
120
+ return
121
+ if not path.exists():
122
+ state["files"][name] = "deleted"
123
+ return
124
+ if not path.resolve().is_relative_to(root):
125
+ raise BusinessFlowError(f"source path escapes candidate: {name}")
126
+ body = path.read_bytes()
127
+ state["files"][name] = hashlib.sha256(body).hexdigest()
128
+ if dirty:
129
+ state["dirty"][name] = base64.b64encode(body).decode("ascii")
130
+
131
+
132
+ def capture_projects(candidates: list[ProjectCandidate]) -> list[SourceSnapshot]:
133
+ return [capture_project(candidate) for candidate in candidates]
134
+
135
+
136
+ def validate_evidence(
137
+ evidence: BusinessEvidence, projects: list[SourceSnapshot]
138
+ ) -> None:
139
+ project = next(
140
+ (row for row in projects if row["projectId"] == evidence["projectId"]), None
141
+ )
142
+ if project is None or evidence["path"] not in project["files"]:
143
+ raise BusinessFlowError(
144
+ f"evidence is outside the captured source inventory: {evidence['path']}"
145
+ )
146
+ path = Path(project["root"]) / evidence["path"]
147
+ if path.is_symlink() or not path.resolve().is_relative_to(
148
+ Path(project["root"]).resolve()
149
+ ):
150
+ raise BusinessFlowError("evidence follows an uncaptured symbolic link")
151
+ body = path.read_bytes()
152
+ if hashlib.sha256(body).hexdigest() != project["files"][evidence["path"]]:
153
+ raise BusinessFlowError(
154
+ f"evidence changed after the baseline: {evidence['path']}"
155
+ )
156
+ lines = body.decode("utf-8").splitlines()
157
+ start, end = evidence["line"], evidence["endLine"]
158
+ if end < start or end > len(lines):
159
+ raise BusinessFlowError("evidence line range is invalid")
160
+ if evidence["excerpt"] != "\n".join(lines[start - 1 : end]):
161
+ raise BusinessFlowError(
162
+ f"evidence excerpt does not match source: {evidence['path']}:{start}"
163
+ )
164
+
165
+
166
+ def changed_projects(
167
+ before: list[SourceSnapshot], after: list[SourceSnapshot]
168
+ ) -> list[str]:
169
+ prior = {row["projectId"]: row["digest"] for row in before}
170
+ current = {row["projectId"]: row["digest"] for row in after}
171
+ return sorted(
172
+ key
173
+ for key in prior.keys() | current.keys()
174
+ if prior.get(key) != current.get(key)
175
+ )
176
+
177
+
178
+ def source_changes(
179
+ baseline: list[SourceSnapshot], current: list[SourceSnapshot]
180
+ ) -> list[dict[str, Any]]:
181
+ prior = {row["projectId"]: row for row in baseline}
182
+ changes = []
183
+ for project in current:
184
+ old = prior.get(project["projectId"])
185
+ if old is None or not old["digest"]:
186
+ changes.append(
187
+ {
188
+ "projectId": project["projectId"],
189
+ "baselineAvailable": False,
190
+ "paths": [],
191
+ }
192
+ )
193
+ continue
194
+ paths = sorted(
195
+ path
196
+ for path in old["files"].keys() | project["files"].keys()
197
+ if old["files"].get(path) != project["files"].get(path)
198
+ )
199
+ changes.append(
200
+ {
201
+ "projectId": project["projectId"],
202
+ "baselineAvailable": True,
203
+ "paths": paths,
204
+ }
205
+ )
206
+ return changes