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.
- 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/cmux.py +21 -16
- 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 +603 -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 +14 -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 +50 -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,388 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from ..json_boundary import JsonBoundaryError
|
|
8
|
+
from ..run_context import dir_flock
|
|
9
|
+
from .contracts import (
|
|
10
|
+
BusinessFact,
|
|
11
|
+
BusinessFlowError,
|
|
12
|
+
BusinessKnowledge,
|
|
13
|
+
BusinessResolution,
|
|
14
|
+
ClaimProducer,
|
|
15
|
+
FlowExecution,
|
|
16
|
+
FlowRequest,
|
|
17
|
+
SourceSnapshot,
|
|
18
|
+
StoredClaim,
|
|
19
|
+
TaskLink,
|
|
20
|
+
TaskLinkRow,
|
|
21
|
+
artifact_path,
|
|
22
|
+
fingerprint,
|
|
23
|
+
now_iso,
|
|
24
|
+
read_artifact,
|
|
25
|
+
write_artifact,
|
|
26
|
+
)
|
|
27
|
+
from .source import candidate_projects, capture_project
|
|
28
|
+
|
|
29
|
+
CONFIRMED_LEVELS = frozenset({"static", "execution-verified"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def task_link_path(project_root: Path, task_key: str) -> Path:
|
|
33
|
+
return artifact_path(
|
|
34
|
+
project_root, "task-links", fingerprint(task_key), "links.json"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_task_links(project_root: Path, task_key: str) -> TaskLink:
|
|
39
|
+
path = task_link_path(project_root, task_key)
|
|
40
|
+
return (
|
|
41
|
+
cast(TaskLink, read_artifact(path, "taskLink"))
|
|
42
|
+
if path.exists()
|
|
43
|
+
else {"schemaVersion": 1, "taskKey": task_key, "executions": []}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def record_task_execution(
|
|
48
|
+
request: FlowRequest, execution: FlowExecution, input_digest: str
|
|
49
|
+
) -> TaskLinkRow:
|
|
50
|
+
path = task_link_path(request.project_root, request.task_key)
|
|
51
|
+
row: TaskLinkRow = {
|
|
52
|
+
"id": execution["id"],
|
|
53
|
+
"mode": request.mode,
|
|
54
|
+
"status": execution["status"],
|
|
55
|
+
"inputDigest": input_digest,
|
|
56
|
+
"reports": execution.get("reports", {}),
|
|
57
|
+
"summary": execution.get("result", {}).get("summary", ""),
|
|
58
|
+
"error": execution.get("error", ""),
|
|
59
|
+
}
|
|
60
|
+
with dir_flock(path.parent, ".links.lock"):
|
|
61
|
+
links = read_task_links(request.project_root, request.task_key)
|
|
62
|
+
if not any(item["id"] == row["id"] for item in links["executions"]):
|
|
63
|
+
links["executions"].append(row)
|
|
64
|
+
write_artifact(path, links, "taskLink")
|
|
65
|
+
return row
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_knowledge(project_root: Path) -> BusinessKnowledge:
|
|
69
|
+
path = artifact_path(project_root, "knowledge.json")
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return {"schemaVersion": 1, "claims": [], "resolutions": []}
|
|
72
|
+
return cast(BusinessKnowledge, read_artifact(path, "knowledge"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def contribute_knowledge(
|
|
76
|
+
project_root: Path,
|
|
77
|
+
facts: list[BusinessFact],
|
|
78
|
+
projects: list[SourceSnapshot],
|
|
79
|
+
producer: ClaimProducer,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
path = artifact_path(project_root, "knowledge.json")
|
|
82
|
+
with dir_flock(path.parent, ".knowledge.lock"):
|
|
83
|
+
data = read_knowledge(project_root)
|
|
84
|
+
claims = [_claim(fact, projects, producer) for fact in facts]
|
|
85
|
+
by_id = {row["id"]: row for row in data["claims"]}
|
|
86
|
+
added = []
|
|
87
|
+
for claim in claims:
|
|
88
|
+
if claim["id"] in by_id:
|
|
89
|
+
existing = by_id[claim["id"]]
|
|
90
|
+
if producer not in existing["producers"]:
|
|
91
|
+
existing["producers"].append(producer)
|
|
92
|
+
for key in ("aliases", "relatedKeys", "executionEvidence"):
|
|
93
|
+
if key in claim:
|
|
94
|
+
existing[key] = list(
|
|
95
|
+
dict.fromkeys(existing.get(key, []) + claim[key])
|
|
96
|
+
)
|
|
97
|
+
continue
|
|
98
|
+
data["claims"].append(claim)
|
|
99
|
+
by_id[claim["id"]] = claim
|
|
100
|
+
added.append(claim["id"])
|
|
101
|
+
_mark_conflicts(data["claims"])
|
|
102
|
+
write_artifact(path, data, "knowledge")
|
|
103
|
+
return {
|
|
104
|
+
"added": added,
|
|
105
|
+
"conflicts": [
|
|
106
|
+
row["id"] for row in data["claims"] if row["status"] == "conflict"
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _claim(
|
|
112
|
+
fact: BusinessFact, projects: list[SourceSnapshot], producer: ClaimProducer
|
|
113
|
+
) -> StoredClaim:
|
|
114
|
+
ids = {evidence["projectId"] for evidence in fact["evidence"]}
|
|
115
|
+
states = {
|
|
116
|
+
row["projectId"]: {
|
|
117
|
+
"root": row["root"],
|
|
118
|
+
"digest": row["digest"],
|
|
119
|
+
"head": row["head"],
|
|
120
|
+
}
|
|
121
|
+
for row in projects
|
|
122
|
+
if row["projectId"] in ids
|
|
123
|
+
}
|
|
124
|
+
identity = {
|
|
125
|
+
"key": fact["key"],
|
|
126
|
+
"value": fact["value"],
|
|
127
|
+
"level": fact["level"],
|
|
128
|
+
"sourceStates": {key: row["digest"] for key, row in states.items()},
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
**fact,
|
|
132
|
+
"id": fingerprint(identity),
|
|
133
|
+
"sourceStates": states,
|
|
134
|
+
"producer": producer,
|
|
135
|
+
"producers": [producer],
|
|
136
|
+
"createdAt": now_iso(),
|
|
137
|
+
"status": "accepted",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _mark_conflicts(claims: list[StoredClaim]) -> None:
|
|
142
|
+
groups: dict[str, list[StoredClaim]] = {}
|
|
143
|
+
for claim in claims:
|
|
144
|
+
if claim["level"] not in CONFIRMED_LEVELS or claim["status"] == "superseded":
|
|
145
|
+
continue
|
|
146
|
+
if not claim["sourceStates"] or any(
|
|
147
|
+
not row["digest"] for row in claim["sourceStates"].values()
|
|
148
|
+
):
|
|
149
|
+
continue
|
|
150
|
+
state = {key: row["digest"] for key, row in claim["sourceStates"].items()}
|
|
151
|
+
groups.setdefault(
|
|
152
|
+
fingerprint({"key": claim["key"], "states": state}), []
|
|
153
|
+
).append(claim)
|
|
154
|
+
for rows in groups.values():
|
|
155
|
+
if len({row["value"] for row in rows}) > 1:
|
|
156
|
+
for row in rows:
|
|
157
|
+
row["status"] = "conflict"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_conflicts(
|
|
161
|
+
project_root: Path, resolutions: list[BusinessResolution], producer: ClaimProducer
|
|
162
|
+
) -> None:
|
|
163
|
+
path = artifact_path(project_root, "knowledge.json")
|
|
164
|
+
with dir_flock(path.parent, ".knowledge.lock"):
|
|
165
|
+
data = read_knowledge(project_root)
|
|
166
|
+
validate_resolutions(read_shared_knowledge(project_root), resolutions)
|
|
167
|
+
by_id = {row["id"]: row for row in data["claims"]}
|
|
168
|
+
for resolution in resolutions:
|
|
169
|
+
ids = resolution["claimIds"]
|
|
170
|
+
rows = [by_id[key] for key in ids if key in by_id]
|
|
171
|
+
for row in rows:
|
|
172
|
+
row["status"] = (
|
|
173
|
+
"accepted"
|
|
174
|
+
if row["id"] == resolution["acceptedClaimId"]
|
|
175
|
+
else "superseded"
|
|
176
|
+
)
|
|
177
|
+
data["resolutions"].append(
|
|
178
|
+
{**resolution, "producer": producer, "createdAt": now_iso()}
|
|
179
|
+
)
|
|
180
|
+
write_artifact(path, data, "knowledge")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def validate_resolutions(
|
|
184
|
+
data: BusinessKnowledge, resolutions: list[BusinessResolution]
|
|
185
|
+
) -> None:
|
|
186
|
+
by_id = {row["id"]: row for row in data["claims"]}
|
|
187
|
+
visited = set()
|
|
188
|
+
for resolution in resolutions:
|
|
189
|
+
ids = set(resolution["claimIds"])
|
|
190
|
+
if (
|
|
191
|
+
not ids.issubset(by_id)
|
|
192
|
+
or resolution["acceptedClaimId"] not in ids
|
|
193
|
+
or ids & visited
|
|
194
|
+
):
|
|
195
|
+
raise BusinessFlowError(
|
|
196
|
+
"conflict resolution references unknown or repeated claims"
|
|
197
|
+
)
|
|
198
|
+
rows = _resolution_rows(data["claims"], resolution)
|
|
199
|
+
state = fingerprint(
|
|
200
|
+
{key: source["digest"] for key, source in rows[0]["sourceStates"].items()}
|
|
201
|
+
)
|
|
202
|
+
group = {
|
|
203
|
+
row["id"]
|
|
204
|
+
for row in data["claims"]
|
|
205
|
+
if row["key"] == rows[0]["key"]
|
|
206
|
+
and row["status"] == "conflict"
|
|
207
|
+
and fingerprint(
|
|
208
|
+
{key: source["digest"] for key, source in row["sourceStates"].items()}
|
|
209
|
+
)
|
|
210
|
+
== state
|
|
211
|
+
}
|
|
212
|
+
if ids != group:
|
|
213
|
+
raise BusinessFlowError(
|
|
214
|
+
"conflict resolution must cover the complete active conflict"
|
|
215
|
+
)
|
|
216
|
+
visited.update(ids)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _resolution_rows(
|
|
220
|
+
claims: list[StoredClaim],
|
|
221
|
+
resolution: BusinessResolution,
|
|
222
|
+
) -> list[StoredClaim]:
|
|
223
|
+
by_id = {row["id"]: row for row in claims}
|
|
224
|
+
ids = set(resolution["claimIds"])
|
|
225
|
+
if not ids.issubset(by_id) or resolution["acceptedClaimId"] not in ids:
|
|
226
|
+
raise BusinessFlowError("conflict resolution references unknown claims")
|
|
227
|
+
rows = [by_id[key] for key in ids]
|
|
228
|
+
states = {
|
|
229
|
+
fingerprint(
|
|
230
|
+
{key: state["digest"] for key, state in row["sourceStates"].items()}
|
|
231
|
+
)
|
|
232
|
+
for row in rows
|
|
233
|
+
}
|
|
234
|
+
if len({row["key"] for row in rows}) != 1 or len(states) != 1:
|
|
235
|
+
raise BusinessFlowError("conflict resolution mixes subjects or source versions")
|
|
236
|
+
return rows
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def read_shared_knowledge(project_root: Path) -> dict[str, Any]:
|
|
240
|
+
claims, resolutions, errors = [], [], []
|
|
241
|
+
candidates = candidate_projects(FlowRequest(project_root, "business"))
|
|
242
|
+
for candidate in candidates:
|
|
243
|
+
root = Path(candidate["root"])
|
|
244
|
+
try:
|
|
245
|
+
data = read_knowledge(root)
|
|
246
|
+
claims.extend({**claim, "ownerRoot": str(root)} for claim in data["claims"])
|
|
247
|
+
resolutions.extend(data["resolutions"])
|
|
248
|
+
except (OSError, BusinessFlowError, JsonBoundaryError) as exc:
|
|
249
|
+
errors.append(f"{candidate['projectId']}: {exc}")
|
|
250
|
+
_apply_shared_resolutions(claims, resolutions, errors)
|
|
251
|
+
_mark_conflicts(claims)
|
|
252
|
+
return {"claims": claims, "errors": errors}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _apply_shared_resolutions(
|
|
256
|
+
claims: list[StoredClaim],
|
|
257
|
+
resolutions: list[BusinessResolution],
|
|
258
|
+
errors: list[str],
|
|
259
|
+
) -> None:
|
|
260
|
+
decisions: dict[frozenset[str], set[str]] = {}
|
|
261
|
+
for resolution in resolutions:
|
|
262
|
+
group = frozenset(resolution["claimIds"])
|
|
263
|
+
decisions.setdefault(group, set()).add(resolution["acceptedClaimId"])
|
|
264
|
+
disputed = {group for group, accepted in decisions.items() if len(accepted) > 1}
|
|
265
|
+
for resolution in resolutions:
|
|
266
|
+
try:
|
|
267
|
+
_resolution_rows(claims, resolution)
|
|
268
|
+
except BusinessFlowError as exc:
|
|
269
|
+
errors.append(f"Stored conflict resolution unavailable: {exc}")
|
|
270
|
+
continue
|
|
271
|
+
group = frozenset(resolution["claimIds"])
|
|
272
|
+
if group in disputed:
|
|
273
|
+
errors.append(
|
|
274
|
+
"Contradictory shared conflict resolutions: " + ", ".join(sorted(group))
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
for row in claims:
|
|
278
|
+
if row["id"] in group:
|
|
279
|
+
row["status"] = (
|
|
280
|
+
"accepted"
|
|
281
|
+
if row["id"] == resolution["acceptedClaimId"]
|
|
282
|
+
else "superseded"
|
|
283
|
+
)
|
|
284
|
+
for row in claims:
|
|
285
|
+
if any(row["id"] in group for group in disputed):
|
|
286
|
+
row["status"] = "conflict"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def query_knowledge(
|
|
290
|
+
project_root: Path, query: str, source_root: Path | None = None
|
|
291
|
+
) -> dict[str, Any]:
|
|
292
|
+
sources = {
|
|
293
|
+
row["projectId"]: row["root"]
|
|
294
|
+
for row in candidate_projects(
|
|
295
|
+
FlowRequest(project_root, query or "business", source_root=source_root)
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
tokens = set(re.findall(r"[\w-]{2,}", query.casefold()))
|
|
299
|
+
shared = read_shared_knowledge(project_root)
|
|
300
|
+
claims, errors = shared["claims"], shared["errors"]
|
|
301
|
+
selected = [
|
|
302
|
+
row
|
|
303
|
+
for row in claims
|
|
304
|
+
if any(
|
|
305
|
+
token
|
|
306
|
+
in (
|
|
307
|
+
row["business"]
|
|
308
|
+
+ " "
|
|
309
|
+
+ row["key"]
|
|
310
|
+
+ " "
|
|
311
|
+
+ row["value"]
|
|
312
|
+
+ " "
|
|
313
|
+
+ " ".join(row.get("aliases", []))
|
|
314
|
+
).casefold()
|
|
315
|
+
for token in tokens
|
|
316
|
+
)
|
|
317
|
+
]
|
|
318
|
+
keys = {row["key"] for row in selected}
|
|
319
|
+
while True:
|
|
320
|
+
related = keys | {key for row in selected for key in row.get("relatedKeys", [])}
|
|
321
|
+
expanded = [row for row in claims if row["key"] in related]
|
|
322
|
+
if {(row["ownerRoot"], row["id"]) for row in expanded} == {
|
|
323
|
+
(row["ownerRoot"], row["id"]) for row in selected
|
|
324
|
+
}:
|
|
325
|
+
break
|
|
326
|
+
selected, keys = expanded, {row["key"] for row in expanded}
|
|
327
|
+
states: dict[str, dict[str, Any]] = {}
|
|
328
|
+
for row in selected:
|
|
329
|
+
row["freshness"] = _freshness(row, states, sources)
|
|
330
|
+
row["selectionReason"] = "business terms or a connected claim key"
|
|
331
|
+
return {"claims": selected, "errors": errors, "query": query}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _freshness(
|
|
335
|
+
claim: dict[str, Any], cache: dict[str, dict[str, Any]], sources: dict[str, str]
|
|
336
|
+
) -> str:
|
|
337
|
+
for project_id, expected in claim["sourceStates"].items():
|
|
338
|
+
root = sources.get(project_id)
|
|
339
|
+
if root is None:
|
|
340
|
+
return "unverified"
|
|
341
|
+
if root not in cache:
|
|
342
|
+
cache[root] = capture_project({"projectId": project_id, "root": root})
|
|
343
|
+
current = cache[root]
|
|
344
|
+
if current["gaps"] or not current["digest"]:
|
|
345
|
+
return "unverified"
|
|
346
|
+
if current["digest"] != expected["digest"]:
|
|
347
|
+
return "historical"
|
|
348
|
+
return "current" if claim["sourceStates"] else "unverified"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def knowledge_packet(
|
|
352
|
+
project_root: Path, question: str, source_root: Path | None = None
|
|
353
|
+
) -> str:
|
|
354
|
+
try:
|
|
355
|
+
result = query_knowledge(project_root, question, source_root)
|
|
356
|
+
except (OSError, BusinessFlowError, JsonBoundaryError) as exc:
|
|
357
|
+
return f"## Shared Business Knowledge\n\nKnowledge unavailable: {exc}\n"
|
|
358
|
+
lines = [
|
|
359
|
+
"## Shared Business Knowledge",
|
|
360
|
+
"",
|
|
361
|
+
"Preserve level, freshness, conflict and provenance. Recheck historical facts before relying on them.",
|
|
362
|
+
"",
|
|
363
|
+
]
|
|
364
|
+
if not result["claims"]:
|
|
365
|
+
lines.append(
|
|
366
|
+
"No related stored claims were selected. Inspect source and use additional lookup when new business terms emerge."
|
|
367
|
+
)
|
|
368
|
+
for row in result["claims"]:
|
|
369
|
+
lines.extend(
|
|
370
|
+
[
|
|
371
|
+
f"- `{row['id']}`: {row['value']}",
|
|
372
|
+
f" - Level: {row['level']}; freshness: {row['freshness']}; status: {row['status']}",
|
|
373
|
+
f" - Owner: `{row['ownerRoot']}`; key: `{row['key']}`",
|
|
374
|
+
]
|
|
375
|
+
)
|
|
376
|
+
lines.extend(
|
|
377
|
+
f" - Evidence: `{item['projectId']}:{item['path']}:{item['line']}`"
|
|
378
|
+
for item in row["evidence"]
|
|
379
|
+
)
|
|
380
|
+
lines.extend(f"- Knowledge read error: {error}" for error in result["errors"])
|
|
381
|
+
lines.extend(
|
|
382
|
+
[
|
|
383
|
+
"",
|
|
384
|
+
"Additional lookup: `okstra explain-flow knowledge --query <business question>`",
|
|
385
|
+
"",
|
|
386
|
+
]
|
|
387
|
+
)
|
|
388
|
+
return "\n".join(lines)
|
|
@@ -379,8 +379,8 @@ def plan_worker_height_resize(
|
|
|
379
379
|
return None
|
|
380
380
|
|
|
381
381
|
|
|
382
|
-
def
|
|
383
|
-
"""The
|
|
382
|
+
def shim_free_worker_path() -> str:
|
|
383
|
+
"""The lead's inherited PATH with cmux's per-surface CLI shims removed.
|
|
384
384
|
|
|
385
385
|
A cmux pane execs its command through `login … bash --noprofile --norc`, so
|
|
386
386
|
none of the user's shell rc runs and PATH is cmux's own short list. What that
|
|
@@ -389,14 +389,23 @@ def shim_free_login_path() -> str:
|
|
|
389
389
|
CLIs — and where a provider cmux does not know has no entry at all, which is
|
|
390
390
|
a plain exit 127 inside the worker wrapper.
|
|
391
391
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
392
|
+
The base is the PATH the lead process inherited, because the cli-wrapper
|
|
393
|
+
runtime (and every automatic retry, which always runs there) spawns with it.
|
|
394
|
+
A pane that resolved PATH from the login shell instead could pick another
|
|
395
|
+
install of the same CLI: on 2026-10-04 (fontsninja-v3-site dev-11118) the
|
|
396
|
+
login shell put Homebrew's codex 0.153.4 first, which rejects `gpt-6.1-sol`,
|
|
397
|
+
while the lead's PATH found nvm's 0.160.0, so every first attempt failed and
|
|
398
|
+
every retry passed. The login shell's PATH is the fallback when the inherited
|
|
399
|
+
one is empty after filtering.
|
|
396
400
|
"""
|
|
397
|
-
|
|
401
|
+
path = _without_shims(os.environ.get("PATH", ""))
|
|
402
|
+
return path or _without_shims(_login_shell_path())
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _without_shims(path_value: str) -> str:
|
|
398
406
|
return os.pathsep.join(
|
|
399
|
-
entry for entry in
|
|
407
|
+
entry for entry in path_value.split(os.pathsep)
|
|
408
|
+
if entry and SHIM_DIR_MARKER not in entry
|
|
400
409
|
)
|
|
401
410
|
|
|
402
411
|
|
|
@@ -783,7 +792,7 @@ def _surface_from_split_echo(stdout: str) -> str:
|
|
|
783
792
|
|
|
784
793
|
def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> None:
|
|
785
794
|
line = worker_command_line(
|
|
786
|
-
cwd=cwd, argv=command, path_value=
|
|
795
|
+
cwd=cwd, argv=command, path_value=shim_free_worker_path()
|
|
787
796
|
)
|
|
788
797
|
started = run_cmux(["respawn-pane", "--surface", surface_uuid, "--command", line])
|
|
789
798
|
if started.returncode != 0:
|
|
@@ -939,11 +948,7 @@ def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
|
|
|
939
948
|
|
|
940
949
|
|
|
941
950
|
def _login_shell_path() -> str:
|
|
942
|
-
"""PATH as the user's own login shell resolves it.
|
|
943
|
-
|
|
944
|
-
Falls back to the inherited PATH: a worker started with a shim-filtered
|
|
945
|
-
inherited PATH is still better than one started with cmux's bare list.
|
|
946
|
-
"""
|
|
951
|
+
"""PATH as the user's own login shell resolves it, or '' when it cannot run."""
|
|
947
952
|
shell = os.environ.get("SHELL", "") or "/bin/sh"
|
|
948
953
|
try:
|
|
949
954
|
result = subprocess.run(
|
|
@@ -954,9 +959,9 @@ def _login_shell_path() -> str:
|
|
|
954
959
|
check=False,
|
|
955
960
|
)
|
|
956
961
|
except (OSError, subprocess.SubprocessError):
|
|
957
|
-
return
|
|
962
|
+
return ""
|
|
958
963
|
if result.returncode != 0 or not result.stdout.strip():
|
|
959
|
-
return
|
|
964
|
+
return ""
|
|
960
965
|
return result.stdout.strip()
|
|
961
966
|
|
|
962
967
|
|