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.
- 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/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/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,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
|