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,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 shim_free_login_path() -> str:
383
- """The user's login PATH with cmux's per-surface CLI shims removed.
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
- Restoring the login shell's PATH and dropping the shim entries gives the
393
- wrapper what it would see in an ordinary terminal, which is what its own
394
- `command -v <cli>` check expects. Nothing here is provider-specific, so
395
- adding a provider does not touch this path.
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
- entries = _login_shell_path().split(os.pathsep)
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 entries if entry and SHIM_DIR_MARKER not in entry
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=shim_free_login_path()
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 os.environ.get("PATH", "")
962
+ return ""
958
963
  if result.returncode != 0 or not result.stdout.strip():
959
- return os.environ.get("PATH", "")
964
+ return ""
960
965
  return result.stdout.strip()
961
966
 
962
967