tradingcodex 0.1.0__py3-none-any.whl

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 (254) hide show
  1. apps/__init__.py +1 -0
  2. apps/audit/__init__.py +1 -0
  3. apps/audit/admin.py +6 -0
  4. apps/audit/apps.py +8 -0
  5. apps/audit/migrations/0001_initial.py +35 -0
  6. apps/audit/migrations/__init__.py +0 -0
  7. apps/audit/models.py +22 -0
  8. apps/harness/__init__.py +1 -0
  9. apps/harness/admin.py +6 -0
  10. apps/harness/apps.py +8 -0
  11. apps/harness/migrations/0001_initial.py +35 -0
  12. apps/harness/migrations/__init__.py +0 -0
  13. apps/harness/models.py +22 -0
  14. apps/harness/templatetags/__init__.py +1 -0
  15. apps/integrations/__init__.py +1 -0
  16. apps/integrations/admin.py +6 -0
  17. apps/integrations/apps.py +8 -0
  18. apps/integrations/migrations/0001_initial.py +29 -0
  19. apps/integrations/migrations/__init__.py +0 -0
  20. apps/integrations/models.py +16 -0
  21. apps/integrations/services.py +31 -0
  22. apps/mcp/__init__.py +1 -0
  23. apps/mcp/admin.py +20 -0
  24. apps/mcp/apps.py +8 -0
  25. apps/mcp/migrations/0001_initial.py +168 -0
  26. apps/mcp/migrations/__init__.py +0 -0
  27. apps/mcp/models.py +154 -0
  28. apps/mcp/services.py +327 -0
  29. apps/orders/__init__.py +1 -0
  30. apps/orders/admin.py +6 -0
  31. apps/orders/apps.py +8 -0
  32. apps/orders/migrations/0001_initial.py +79 -0
  33. apps/orders/migrations/__init__.py +0 -0
  34. apps/orders/models.py +66 -0
  35. apps/orders/services.py +107 -0
  36. apps/policy/__init__.py +1 -0
  37. apps/policy/admin.py +6 -0
  38. apps/policy/apps.py +8 -0
  39. apps/policy/migrations/0001_initial.py +75 -0
  40. apps/policy/migrations/__init__.py +0 -0
  41. apps/policy/models.py +61 -0
  42. apps/policy/services.py +110 -0
  43. apps/portfolio/__init__.py +1 -0
  44. apps/portfolio/admin.py +6 -0
  45. apps/portfolio/apps.py +8 -0
  46. apps/portfolio/migrations/0001_initial.py +67 -0
  47. apps/portfolio/migrations/__init__.py +0 -0
  48. apps/portfolio/models.py +53 -0
  49. apps/research/__init__.py +1 -0
  50. apps/research/admin.py +1 -0
  51. apps/research/apps.py +8 -0
  52. apps/research/migrations/__init__.py +0 -0
  53. apps/research/models.py +1 -0
  54. apps/workflows/__init__.py +1 -0
  55. apps/workflows/admin.py +6 -0
  56. apps/workflows/apps.py +8 -0
  57. apps/workflows/migrations/0001_initial.py +51 -0
  58. apps/workflows/migrations/__init__.py +0 -0
  59. apps/workflows/models.py +44 -0
  60. tradingcodex-0.1.0.dist-info/METADATA +337 -0
  61. tradingcodex-0.1.0.dist-info/RECORD +254 -0
  62. tradingcodex-0.1.0.dist-info/WHEEL +5 -0
  63. tradingcodex-0.1.0.dist-info/entry_points.txt +2 -0
  64. tradingcodex-0.1.0.dist-info/licenses/LICENSE +202 -0
  65. tradingcodex-0.1.0.dist-info/licenses/NOTICE +24 -0
  66. tradingcodex-0.1.0.dist-info/top_level.txt +4 -0
  67. tradingcodex_cli/__init__.py +1 -0
  68. tradingcodex_cli/__main__.py +124 -0
  69. tradingcodex_cli/commands/__init__.py +2 -0
  70. tradingcodex_cli/commands/bootstrap.py +157 -0
  71. tradingcodex_cli/commands/db.py +36 -0
  72. tradingcodex_cli/commands/doctor.py +230 -0
  73. tradingcodex_cli/commands/mcp.py +186 -0
  74. tradingcodex_cli/commands/orders.py +89 -0
  75. tradingcodex_cli/commands/policy.py +21 -0
  76. tradingcodex_cli/commands/profile.py +110 -0
  77. tradingcodex_cli/commands/research.py +76 -0
  78. tradingcodex_cli/commands/skills.py +93 -0
  79. tradingcodex_cli/commands/strategies.py +67 -0
  80. tradingcodex_cli/commands/subagents.py +106 -0
  81. tradingcodex_cli/commands/utils.py +134 -0
  82. tradingcodex_cli/commands/workspaces.py +53 -0
  83. tradingcodex_cli/generator.py +234 -0
  84. tradingcodex_cli/mcp_stdio.py +26 -0
  85. tradingcodex_cli/service_autostart.py +127 -0
  86. tradingcodex_service/__init__.py +5 -0
  87. tradingcodex_service/admin.py +1 -0
  88. tradingcodex_service/api.py +486 -0
  89. tradingcodex_service/application/__init__.py +2 -0
  90. tradingcodex_service/application/agents.py +1470 -0
  91. tradingcodex_service/application/audit.py +71 -0
  92. tradingcodex_service/application/common.py +88 -0
  93. tradingcodex_service/application/components.py +299 -0
  94. tradingcodex_service/application/harness.py +747 -0
  95. tradingcodex_service/application/markdown_preview.py +179 -0
  96. tradingcodex_service/application/orders.py +404 -0
  97. tradingcodex_service/application/policy.py +150 -0
  98. tradingcodex_service/application/portfolio.py +166 -0
  99. tradingcodex_service/application/research.py +356 -0
  100. tradingcodex_service/application/runtime.py +321 -0
  101. tradingcodex_service/asgi.py +6 -0
  102. tradingcodex_service/mcp_http.py +59 -0
  103. tradingcodex_service/mcp_runtime.py +565 -0
  104. tradingcodex_service/settings.py +91 -0
  105. tradingcodex_service/templates/web/activity.html +24 -0
  106. tradingcodex_service/templates/web/agent_skills.html +111 -0
  107. tradingcodex_service/templates/web/agents.html +121 -0
  108. tradingcodex_service/templates/web/base.html +150 -0
  109. tradingcodex_service/templates/web/dashboard.html +109 -0
  110. tradingcodex_service/templates/web/fragments/role_inspector.html +81 -0
  111. tradingcodex_service/templates/web/fragments/starter_prompt.html +24 -0
  112. tradingcodex_service/templates/web/fragments/topology_canvas.html +85 -0
  113. tradingcodex_service/templates/web/harness.html +87 -0
  114. tradingcodex_service/templates/web/mcp_router.html +250 -0
  115. tradingcodex_service/templates/web/orders.html +68 -0
  116. tradingcodex_service/templates/web/policy.html +81 -0
  117. tradingcodex_service/templates/web/portfolio.html +52 -0
  118. tradingcodex_service/templates/web/research.html +73 -0
  119. tradingcodex_service/templates/web/starter_prompt.html +40 -0
  120. tradingcodex_service/templates/web/strategies.html +74 -0
  121. tradingcodex_service/urls.py +42 -0
  122. tradingcodex_service/version.py +1 -0
  123. tradingcodex_service/web.py +885 -0
  124. tradingcodex_service/wsgi.py +6 -0
  125. workspace_templates/__init__.py +1 -0
  126. workspace_templates/modules/audit/files/.tradingcodex/audit/README.md +5 -0
  127. workspace_templates/modules/audit/files/trading/audit/.gitkeep +1 -0
  128. workspace_templates/modules/audit/module.json +16 -0
  129. workspace_templates/modules/codex-base/files/.codex/config.toml +272 -0
  130. workspace_templates/modules/codex-base/files/.codex/hooks/tradingcodex_hook.py +173 -0
  131. workspace_templates/modules/codex-base/files/.codex/hooks.json +105 -0
  132. workspace_templates/modules/codex-base/files/.codex/prompts/base_instructions/head-manager.md +129 -0
  133. workspace_templates/modules/codex-base/files/.codex/rules/tradingcodex.rules +50 -0
  134. workspace_templates/modules/codex-base/files/.tradingcodex/capabilities.yaml +56 -0
  135. workspace_templates/modules/codex-base/files/.tradingcodex/cli.py +16 -0
  136. workspace_templates/modules/codex-base/files/.tradingcodex/config.yaml +53 -0
  137. workspace_templates/modules/codex-base/files/.tradingcodex/policies/policy-bindings.yaml +16 -0
  138. workspace_templates/modules/codex-base/files/.tradingcodex/policies/principals.yaml +17 -0
  139. workspace_templates/modules/codex-base/files/.tradingcodex/policies/roles.yaml +27 -0
  140. workspace_templates/modules/codex-base/files/AGENTS.md +56 -0
  141. workspace_templates/modules/codex-base/files/pyproject.toml +13 -0
  142. workspace_templates/modules/codex-base/files/tcx +35 -0
  143. workspace_templates/modules/codex-base/module.json +17 -0
  144. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/policies/access-policies.yaml +39 -0
  145. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/policies/restricted-list.yaml +6 -0
  146. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/approval_receipt.schema.json +14 -0
  147. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/audit_event.schema.json +11 -0
  148. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/evidence_pack.schema.json +16 -0
  149. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/execution_result.schema.json +12 -0
  150. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/fundamental_report.schema.json +15 -0
  151. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/news_report.schema.json +15 -0
  152. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/order_intent.schema.json +30 -0
  153. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/portfolio_review.schema.json +15 -0
  154. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/postmortem_report.schema.json +14 -0
  155. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/risk_report.schema.json +15 -0
  156. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/technical_report.schema.json +15 -0
  157. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/thesis.schema.json +15 -0
  158. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/valuation.schema.json +15 -0
  159. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/scripts/validate-order-intent.py +15 -0
  160. workspace_templates/modules/enforcement-guardrails/module.json +19 -0
  161. workspace_templates/modules/fixed-subagents/files/.codex/agents/execution-operator.toml +71 -0
  162. workspace_templates/modules/fixed-subagents/files/.codex/agents/fundamental-analyst.toml +62 -0
  163. workspace_templates/modules/fixed-subagents/files/.codex/agents/instrument-analyst.toml +64 -0
  164. workspace_templates/modules/fixed-subagents/files/.codex/agents/macro-analyst.toml +64 -0
  165. workspace_templates/modules/fixed-subagents/files/.codex/agents/news-analyst.toml +63 -0
  166. workspace_templates/modules/fixed-subagents/files/.codex/agents/portfolio-manager.toml +62 -0
  167. workspace_templates/modules/fixed-subagents/files/.codex/agents/risk-manager.toml +65 -0
  168. workspace_templates/modules/fixed-subagents/files/.codex/agents/technical-analyst.toml +63 -0
  169. workspace_templates/modules/fixed-subagents/files/.codex/agents/valuation-analyst.toml +59 -0
  170. workspace_templates/modules/fixed-subagents/files/.tradingcodex/mainagent/head-manager.yaml +66 -0
  171. workspace_templates/modules/fixed-subagents/files/.tradingcodex/mainagent/skill-change-proposals/.gitkeep +1 -0
  172. workspace_templates/modules/fixed-subagents/files/.tradingcodex/mainagent/subagent-registry.yaml +56 -0
  173. workspace_templates/modules/fixed-subagents/module.json +23 -0
  174. workspace_templates/modules/guidance-guardrails/files/.tradingcodex/guidance/guardrails.md +17 -0
  175. workspace_templates/modules/guidance-guardrails/files/.tradingcodex/guidance/task-quality-checklist.md +37 -0
  176. workspace_templates/modules/guidance-guardrails/module.json +18 -0
  177. workspace_templates/modules/information-barriers/files/.tradingcodex/policies/information-barriers.yaml +211 -0
  178. workspace_templates/modules/information-barriers/files/.tradingcodex/secrets.md +9 -0
  179. workspace_templates/modules/information-barriers/files/trading/approvals/.gitkeep +1 -0
  180. workspace_templates/modules/information-barriers/files/trading/market-data/.gitkeep +1 -0
  181. workspace_templates/modules/information-barriers/files/trading/orders/approved/.gitkeep +1 -0
  182. workspace_templates/modules/information-barriers/files/trading/orders/draft/.gitkeep +1 -0
  183. workspace_templates/modules/information-barriers/files/trading/orders/executed/.gitkeep +1 -0
  184. workspace_templates/modules/information-barriers/files/trading/orders/rejected/.gitkeep +1 -0
  185. workspace_templates/modules/information-barriers/files/trading/portfolio/.gitkeep +1 -0
  186. workspace_templates/modules/information-barriers/files/trading/reports/fundamental/.gitkeep +1 -0
  187. workspace_templates/modules/information-barriers/files/trading/reports/instrument/.gitkeep +1 -0
  188. workspace_templates/modules/information-barriers/files/trading/reports/macro/.gitkeep +1 -0
  189. workspace_templates/modules/information-barriers/files/trading/reports/news/.gitkeep +1 -0
  190. workspace_templates/modules/information-barriers/files/trading/reports/policy/.gitkeep +1 -0
  191. workspace_templates/modules/information-barriers/files/trading/reports/portfolio/.gitkeep +1 -0
  192. workspace_templates/modules/information-barriers/files/trading/reports/postmortem/.gitkeep +1 -0
  193. workspace_templates/modules/information-barriers/files/trading/reports/risk/.gitkeep +1 -0
  194. workspace_templates/modules/information-barriers/files/trading/reports/technical/.gitkeep +1 -0
  195. workspace_templates/modules/information-barriers/files/trading/reports/valuation/.gitkeep +1 -0
  196. workspace_templates/modules/information-barriers/files/trading/research/.gitkeep +1 -0
  197. workspace_templates/modules/information-barriers/module.json +19 -0
  198. workspace_templates/modules/paper-trading/files/.tradingcodex/mcp/adapters/paper-trading.py +4 -0
  199. workspace_templates/modules/paper-trading/module.json +16 -0
  200. workspace_templates/modules/postmortem/files/.tradingcodex/workflows/postmortem.yaml +12 -0
  201. workspace_templates/modules/postmortem/module.json +16 -0
  202. workspace_templates/modules/repo-skills/files/.agents/skills/investment-workflow-map/SKILL.md +106 -0
  203. workspace_templates/modules/repo-skills/files/.agents/skills/investment-workflow-map/agents/openai.yaml +6 -0
  204. workspace_templates/modules/repo-skills/files/.agents/skills/manage-optional-skills/SKILL.md +101 -0
  205. workspace_templates/modules/repo-skills/files/.agents/skills/manage-optional-skills/agents/openai.yaml +6 -0
  206. workspace_templates/modules/repo-skills/files/.agents/skills/manage-subagents/SKILL.md +140 -0
  207. workspace_templates/modules/repo-skills/files/.agents/skills/manage-subagents/agents/openai.yaml +6 -0
  208. workspace_templates/modules/repo-skills/files/.agents/skills/orchestrate-workflow/SKILL.md +140 -0
  209. workspace_templates/modules/repo-skills/files/.agents/skills/orchestrate-workflow/agents/openai.yaml +6 -0
  210. workspace_templates/modules/repo-skills/files/.agents/skills/postmortem/SKILL.md +31 -0
  211. workspace_templates/modules/repo-skills/files/.agents/skills/postmortem/agents/openai.yaml +6 -0
  212. workspace_templates/modules/repo-skills/files/.agents/skills/scenario-quality-gates/SKILL.md +138 -0
  213. workspace_templates/modules/repo-skills/files/.agents/skills/scenario-quality-gates/agents/openai.yaml +6 -0
  214. workspace_templates/modules/repo-skills/files/.agents/skills/strategy-creator/SKILL.md +109 -0
  215. workspace_templates/modules/repo-skills/files/.agents/skills/strategy-creator/agents/openai.yaml +6 -0
  216. workspace_templates/modules/repo-skills/files/.agents/skills/synthesize-decision/SKILL.md +54 -0
  217. workspace_templates/modules/repo-skills/files/.agents/skills/synthesize-decision/agents/openai.yaml +6 -0
  218. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/execution-operator/execute-paper-order/SKILL.md +35 -0
  219. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/execution-operator/execute-paper-order/agents/openai.yaml +6 -0
  220. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/fundamental-analyst/fundamental-analysis/SKILL.md +46 -0
  221. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/fundamental-analyst/fundamental-analysis/agents/openai.yaml +6 -0
  222. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/instrument-analyst/instrument-analysis/SKILL.md +40 -0
  223. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/instrument-analyst/instrument-analysis/agents/openai.yaml +6 -0
  224. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/macro-analyst/macro-analysis/SKILL.md +40 -0
  225. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/macro-analyst/macro-analysis/agents/openai.yaml +6 -0
  226. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/news-analyst/news-analysis/SKILL.md +43 -0
  227. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/news-analyst/news-analysis/agents/openai.yaml +6 -0
  228. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/portfolio-manager/create-order-intent/SKILL.md +46 -0
  229. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/portfolio-manager/create-order-intent/agents/openai.yaml +6 -0
  230. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/portfolio-manager/portfolio-review/SKILL.md +44 -0
  231. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/portfolio-manager/portfolio-review/agents/openai.yaml +6 -0
  232. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/approve-order/SKILL.md +38 -0
  233. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/approve-order/agents/openai.yaml +6 -0
  234. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/policy-review/SKILL.md +43 -0
  235. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/policy-review/agents/openai.yaml +6 -0
  236. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/review-risk/SKILL.md +45 -0
  237. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/review-risk/agents/openai.yaml +6 -0
  238. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/shared/collect-evidence/SKILL.md +46 -0
  239. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/shared/collect-evidence/agents/openai.yaml +6 -0
  240. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/shared/external-data-source-gate/SKILL.md +66 -0
  241. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/shared/external-data-source-gate/agents/openai.yaml +6 -0
  242. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/technical-analyst/technical-analysis/SKILL.md +43 -0
  243. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/technical-analyst/technical-analysis/agents/openai.yaml +6 -0
  244. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/valuation-analyst/valuation-review/SKILL.md +47 -0
  245. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/valuation-analyst/valuation-review/agents/openai.yaml +6 -0
  246. workspace_templates/modules/repo-skills/module.json +37 -0
  247. workspace_templates/modules/stub-execution/files/.tradingcodex/mcp/adapters/stub-execution.py +4 -0
  248. workspace_templates/modules/stub-execution/module.json +15 -0
  249. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/adapters/live-adapter.contract.md +25 -0
  250. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/enforcer/README.md +5 -0
  251. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/gateway/README.md +8 -0
  252. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/server.py +27 -0
  253. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/smoke-call.py +18 -0
  254. workspace_templates/modules/tradingcodex-mcp/module.json +24 -0
@@ -0,0 +1,1470 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import shutil
7
+ from dataclasses import asdict, dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from tradingcodex_service.application.common import _safe_read, now_iso, read_json, sanitize_id, stable_hash, write_json
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SkillSpec:
17
+ id: str
18
+ label: str
19
+ owner_roles: tuple[str, ...]
20
+ risk_tags: tuple[str, ...] = ()
21
+ user_visible: bool = False
22
+ scope: str = "mainagent"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AgentSpec:
27
+ role: str
28
+ label: str
29
+ group: str
30
+ builtin_skills: tuple[str, ...]
31
+ permission_profile: str
32
+ mcp_allowlist: tuple[str, ...] = ()
33
+ forbidden_skill_tags: tuple[str, ...] = ()
34
+
35
+
36
+ RESEARCH_ROLES = (
37
+ "fundamental-analyst",
38
+ "technical-analyst",
39
+ "news-analyst",
40
+ "macro-analyst",
41
+ "instrument-analyst",
42
+ "valuation-analyst",
43
+ )
44
+
45
+ HEAD_MANAGER_SKILLS = (
46
+ "orchestrate-workflow",
47
+ "investment-workflow-map",
48
+ "scenario-quality-gates",
49
+ "manage-subagents",
50
+ "manage-optional-skills",
51
+ "strategy-creator",
52
+ "synthesize-decision",
53
+ "postmortem",
54
+ )
55
+
56
+ AGENT_SPECS: dict[str, AgentSpec] = {
57
+ "head-manager": AgentSpec(
58
+ role="head-manager",
59
+ label="Head Manager",
60
+ group="coordination",
61
+ builtin_skills=HEAD_MANAGER_SKILLS,
62
+ permission_profile="tradingcodex",
63
+ mcp_allowlist=(
64
+ "get_tradingcodex_status",
65
+ "simulate_policy",
66
+ "get_order_status",
67
+ "get_positions",
68
+ "get_portfolio_snapshot",
69
+ "list_workflow_artifacts",
70
+ "create_research_artifact",
71
+ "get_research_artifact",
72
+ "list_research_artifacts",
73
+ "search_research_artifacts",
74
+ "append_research_artifact_version",
75
+ "export_research_artifact_md",
76
+ "record_source_snapshot",
77
+ "record_audit_event",
78
+ ),
79
+ ),
80
+ "fundamental-analyst": AgentSpec(
81
+ role="fundamental-analyst",
82
+ label="Fundamental Analyst",
83
+ group="research",
84
+ builtin_skills=("external-data-source-gate", "collect-evidence", "fundamental-analysis"),
85
+ permission_profile="tradingcodex-fundamental",
86
+ mcp_allowlist=(
87
+ "list_workflow_artifacts",
88
+ "create_research_artifact",
89
+ "get_research_artifact",
90
+ "list_research_artifacts",
91
+ "search_research_artifacts",
92
+ "append_research_artifact_version",
93
+ "export_research_artifact_md",
94
+ "record_source_snapshot",
95
+ "record_audit_event",
96
+ ),
97
+ forbidden_skill_tags=("approval", "execution", "order", "secret"),
98
+ ),
99
+ "technical-analyst": AgentSpec(
100
+ role="technical-analyst",
101
+ label="Technical Analyst",
102
+ group="research",
103
+ builtin_skills=("external-data-source-gate", "collect-evidence", "technical-analysis"),
104
+ permission_profile="tradingcodex-technical",
105
+ mcp_allowlist=(
106
+ "list_workflow_artifacts",
107
+ "create_research_artifact",
108
+ "get_research_artifact",
109
+ "list_research_artifacts",
110
+ "search_research_artifacts",
111
+ "append_research_artifact_version",
112
+ "export_research_artifact_md",
113
+ "record_source_snapshot",
114
+ "record_audit_event",
115
+ ),
116
+ forbidden_skill_tags=("approval", "execution", "order", "secret"),
117
+ ),
118
+ "news-analyst": AgentSpec(
119
+ role="news-analyst",
120
+ label="News Analyst",
121
+ group="research",
122
+ builtin_skills=("external-data-source-gate", "collect-evidence", "news-analysis"),
123
+ permission_profile="tradingcodex-news",
124
+ mcp_allowlist=(
125
+ "list_workflow_artifacts",
126
+ "create_research_artifact",
127
+ "get_research_artifact",
128
+ "list_research_artifacts",
129
+ "search_research_artifacts",
130
+ "append_research_artifact_version",
131
+ "export_research_artifact_md",
132
+ "record_source_snapshot",
133
+ "record_audit_event",
134
+ ),
135
+ forbidden_skill_tags=("approval", "execution", "order", "secret"),
136
+ ),
137
+ "macro-analyst": AgentSpec(
138
+ role="macro-analyst",
139
+ label="Macro Analyst",
140
+ group="research",
141
+ builtin_skills=("external-data-source-gate", "collect-evidence", "macro-analysis"),
142
+ permission_profile="tradingcodex-macro",
143
+ mcp_allowlist=(
144
+ "list_workflow_artifacts",
145
+ "create_research_artifact",
146
+ "get_research_artifact",
147
+ "list_research_artifacts",
148
+ "search_research_artifacts",
149
+ "append_research_artifact_version",
150
+ "export_research_artifact_md",
151
+ "record_source_snapshot",
152
+ "record_audit_event",
153
+ ),
154
+ forbidden_skill_tags=("approval", "execution", "order", "secret"),
155
+ ),
156
+ "instrument-analyst": AgentSpec(
157
+ role="instrument-analyst",
158
+ label="Instrument Analyst",
159
+ group="research",
160
+ builtin_skills=("external-data-source-gate", "collect-evidence", "instrument-analysis"),
161
+ permission_profile="tradingcodex-instrument",
162
+ mcp_allowlist=(
163
+ "list_workflow_artifacts",
164
+ "create_research_artifact",
165
+ "get_research_artifact",
166
+ "list_research_artifacts",
167
+ "search_research_artifacts",
168
+ "append_research_artifact_version",
169
+ "export_research_artifact_md",
170
+ "record_source_snapshot",
171
+ "record_audit_event",
172
+ ),
173
+ forbidden_skill_tags=("approval", "execution", "order", "secret"),
174
+ ),
175
+ "valuation-analyst": AgentSpec(
176
+ role="valuation-analyst",
177
+ label="Valuation Analyst",
178
+ group="research",
179
+ builtin_skills=("external-data-source-gate", "valuation-review"),
180
+ permission_profile="tradingcodex-valuation",
181
+ mcp_allowlist=(
182
+ "list_workflow_artifacts",
183
+ "create_research_artifact",
184
+ "get_research_artifact",
185
+ "list_research_artifacts",
186
+ "search_research_artifacts",
187
+ "append_research_artifact_version",
188
+ "export_research_artifact_md",
189
+ "record_source_snapshot",
190
+ "record_audit_event",
191
+ ),
192
+ forbidden_skill_tags=("approval", "execution", "order", "secret"),
193
+ ),
194
+ "portfolio-manager": AgentSpec(
195
+ role="portfolio-manager",
196
+ label="Portfolio Manager",
197
+ group="portfolio",
198
+ builtin_skills=("portfolio-review", "create-order-intent"),
199
+ permission_profile="tradingcodex-portfolio",
200
+ mcp_allowlist=("list_workflow_artifacts", "get_positions", "get_portfolio_snapshot", "record_audit_event"),
201
+ forbidden_skill_tags=("execution", "secret"),
202
+ ),
203
+ "risk-manager": AgentSpec(
204
+ role="risk-manager",
205
+ label="Risk Manager",
206
+ group="risk",
207
+ builtin_skills=("review-risk", "policy-review", "approve-order"),
208
+ permission_profile="tradingcodex-risk",
209
+ mcp_allowlist=(
210
+ "simulate_policy",
211
+ "validate_order_intent",
212
+ "validate_approval_receipt",
213
+ "create_approval_receipt",
214
+ "list_workflow_artifacts",
215
+ "record_audit_event",
216
+ ),
217
+ forbidden_skill_tags=("execution", "secret"),
218
+ ),
219
+ "execution-operator": AgentSpec(
220
+ role="execution-operator",
221
+ label="Execution Operator",
222
+ group="execution",
223
+ builtin_skills=("execute-paper-order",),
224
+ permission_profile="tradingcodex-execution",
225
+ mcp_allowlist=(
226
+ "simulate_policy",
227
+ "validate_order_intent",
228
+ "validate_approval_receipt",
229
+ "submit_approved_order",
230
+ "cancel_approved_order",
231
+ "get_order_status",
232
+ "get_positions",
233
+ "get_portfolio_snapshot",
234
+ "list_workflow_artifacts",
235
+ "record_audit_event",
236
+ ),
237
+ forbidden_skill_tags=("approval", "secret"),
238
+ ),
239
+ }
240
+
241
+
242
+ SKILL_SPECS: dict[str, SkillSpec] = {
243
+ "orchestrate-workflow": SkillSpec("orchestrate-workflow", "Orchestrate Workflow", ("head-manager",), user_visible=True),
244
+ "investment-workflow-map": SkillSpec("investment-workflow-map", "Investment Workflow Map", ("head-manager",)),
245
+ "scenario-quality-gates": SkillSpec("scenario-quality-gates", "Scenario Quality Gates", ("head-manager",)),
246
+ "external-data-source-gate": SkillSpec("external-data-source-gate", "External Data Source Gate", RESEARCH_ROLES, scope="subagent_shared"),
247
+ "manage-subagents": SkillSpec("manage-subagents", "Manage Subagents", ("head-manager",)),
248
+ "manage-optional-skills": SkillSpec("manage-optional-skills", "Manage Optional Skills", ("head-manager",)),
249
+ "strategy-creator": SkillSpec("strategy-creator", "Strategy Creator", ("head-manager",), user_visible=True),
250
+ "synthesize-decision": SkillSpec("synthesize-decision", "Synthesize Decision", ("head-manager",)),
251
+ "postmortem": SkillSpec("postmortem", "Postmortem", ("head-manager",), user_visible=True),
252
+ "collect-evidence": SkillSpec("collect-evidence", "Collect Evidence", RESEARCH_ROLES, scope="subagent_shared"),
253
+ "fundamental-analysis": SkillSpec("fundamental-analysis", "Fundamental Analysis", ("fundamental-analyst",), scope="subagent_role"),
254
+ "technical-analysis": SkillSpec("technical-analysis", "Technical Analysis", ("technical-analyst",), scope="subagent_role"),
255
+ "news-analysis": SkillSpec("news-analysis", "News Analysis", ("news-analyst",), scope="subagent_role"),
256
+ "macro-analysis": SkillSpec("macro-analysis", "Macro Analysis", ("macro-analyst",), scope="subagent_role"),
257
+ "instrument-analysis": SkillSpec("instrument-analysis", "Instrument Analysis", ("instrument-analyst",), scope="subagent_role"),
258
+ "valuation-review": SkillSpec("valuation-review", "Valuation Review", ("valuation-analyst",), scope="subagent_role"),
259
+ "portfolio-review": SkillSpec("portfolio-review", "Portfolio Review", ("portfolio-manager",), scope="subagent_role"),
260
+ "create-order-intent": SkillSpec("create-order-intent", "Create Order Intent", ("portfolio-manager",), risk_tags=("order",), scope="subagent_role"),
261
+ "review-risk": SkillSpec("review-risk", "Review Risk", ("risk-manager",), scope="subagent_role"),
262
+ "policy-review": SkillSpec("policy-review", "Policy Review", ("risk-manager",), risk_tags=("approval",), scope="subagent_role"),
263
+ "approve-order": SkillSpec("approve-order", "Approve Order", ("risk-manager",), risk_tags=("approval", "order"), scope="subagent_role"),
264
+ "execute-paper-order": SkillSpec("execute-paper-order", "Execute Paper Order", ("execution-operator",), risk_tags=("execution", "order"), scope="subagent_role"),
265
+ }
266
+
267
+
268
+ ROLE_SKILL_MAP: dict[str, list[str]] = {role: list(spec.builtin_skills) for role, spec in AGENT_SPECS.items()}
269
+ USER_VISIBLE_SKILLS = [skill.id for skill in SKILL_SPECS.values() if skill.user_visible]
270
+ EXPECTED_SUBAGENTS = [role for role in AGENT_SPECS if role != "head-manager"]
271
+ EXPECTED_SKILLS = sorted(SKILL_SPECS)
272
+ ROLE_PERMISSION_PROFILES = {role: spec.permission_profile for role, spec in AGENT_SPECS.items() if role != "head-manager"}
273
+
274
+ PROPOSAL_DIR = Path(".tradingcodex/mainagent/skill-change-proposals")
275
+ MAINAGENT_SKILL_DIR = Path(".agents/skills")
276
+ STRATEGY_SKILL_DIR = Path(".tradingcodex/strategies")
277
+ SUBAGENT_SKILL_DIR = Path(".tradingcodex/subagents/skills")
278
+ SUBAGENT_SHARED_SKILL_DIR = SUBAGENT_SKILL_DIR / "shared"
279
+ OPTIONAL_SKILL_STATUS_FILE = Path("agents/tradingcodex.json")
280
+ ADDITIONAL_INSTRUCTION_DIR = Path(".tradingcodex/agent-instructions")
281
+ ADDITIONAL_INSTRUCTION_START = "## BEGIN TradingCodex additional instructions"
282
+ ADDITIONAL_INSTRUCTION_END = "## END TradingCodex additional instructions"
283
+ GENERATED_DIR = Path(".tradingcodex/generated")
284
+ MANIFEST_PATH = GENERATED_DIR / "projection-manifest.json"
285
+ AGENT_INDEX_PATH = GENERATED_DIR / "agent-index.json"
286
+ SKILL_INDEX_PATH = GENERATED_DIR / "skill-index.json"
287
+ STRATEGY_SKILL_PREFIX = "strategy-"
288
+ STRATEGY_ROOT_CONFIG_START = "# BEGIN TradingCodex strategy skills"
289
+ STRATEGY_ROOT_CONFIG_END = "# END TradingCodex strategy skills"
290
+ STRATEGY_REQUIRED_FRONTMATTER = {
291
+ "name",
292
+ "description",
293
+ "type",
294
+ "status",
295
+ "language",
296
+ "managed_by",
297
+ "owner",
298
+ "last_reviewed",
299
+ }
300
+ STRATEGY_REQUIRED_SECTIONS = (
301
+ "## Thesis",
302
+ "## Eligible Universe",
303
+ "## Preferred Setups",
304
+ "## Entry Criteria",
305
+ "## Exit Criteria",
306
+ "## Evidence Requirements",
307
+ "## Decision-Ready Standard",
308
+ "## Sizing Guidance",
309
+ "## Block Conditions",
310
+ "## Portfolio And Risk Handoff",
311
+ "## Change Log",
312
+ )
313
+ OPTIONAL_SKILL_STATUSES = {"draft", "active", "archived"}
314
+ SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{2,63}$")
315
+ OPTIONAL_SKILL_RISK_PATTERNS = {
316
+ "approval": re.compile(r"\b(approve|approval|approval receipt|receipt)\b", re.I),
317
+ "execution": re.compile(r"\b(execute|execution|submit order|adapter submission|broker)\b", re.I),
318
+ "order": re.compile(r"\b(order|order intent|buy|sell|short|long|trade|trading)\b", re.I),
319
+ "secret": re.compile(r"\b(secret|credential|token|api key|password|\\.env)\b", re.I),
320
+ }
321
+ OPTIONAL_SKILL_LOCKED_SURFACE_PATTERN = re.compile(
322
+ r"\b("
323
+ r"mcp allowlist|permission profile|raw broker|live broker|direct broker|"
324
+ r"bypass|ignore policy|disable guardrail|weaken guardrail|self-approve|"
325
+ r"change policy|change capability|read secret|secret access"
326
+ r")\b",
327
+ re.I,
328
+ )
329
+
330
+
331
+ def registry_summary() -> dict[str, Any]:
332
+ return {
333
+ "source": "tradingcodex_service.application.agents",
334
+ "agents": {role: _agent_spec_payload(spec) for role, spec in AGENT_SPECS.items()},
335
+ "skills": {skill_id: asdict(spec) for skill_id, spec in SKILL_SPECS.items()},
336
+ "expected_subagents": EXPECTED_SUBAGENTS,
337
+ "expected_skills": EXPECTED_SKILLS,
338
+ }
339
+
340
+
341
+ def inspect_agent_configuration(root: Path | str, role: str) -> dict[str, Any]:
342
+ root = Path(root).resolve()
343
+ if role not in AGENT_SPECS:
344
+ raise ValueError(f"Unknown subagent or role: {role}")
345
+ state = build_projection_state(root)
346
+ return state["agents"][role]
347
+
348
+
349
+ def list_optional_role_skills(root: Path | str, role: str | None = None, include_archived: bool = True) -> dict[str, Any]:
350
+ root = Path(root).resolve()
351
+ records = read_optional_skill_records(root, role=role, include_archived=include_archived)
352
+ return {
353
+ "status": "ok",
354
+ "source": "file-native-optional-skills",
355
+ "read_only": True,
356
+ "optional_skills": records,
357
+ "roles": sorted({record["role"] for record in records}),
358
+ }
359
+
360
+
361
+ def list_user_visible_skills(root: Path | str) -> list[str]:
362
+ root = Path(root).resolve()
363
+ if not (root / MAINAGENT_SKILL_DIR).exists():
364
+ return list(USER_VISIBLE_SKILLS)
365
+ installed = _installed_skill_index(root)
366
+ visible = [skill for skill in USER_VISIBLE_SKILLS if installed.get(skill, {}).get("installed")]
367
+ visible.extend(skill["name"] for skill in read_strategy_skill_records(root, active_only=True))
368
+ return list(dict.fromkeys(visible))
369
+
370
+
371
+ def read_strategy_skill_records(root: Path | str, *, active_only: bool = False) -> list[dict[str, Any]]:
372
+ root = Path(root).resolve()
373
+ records: list[dict[str, Any]] = []
374
+ primary_root = root / STRATEGY_SKILL_DIR
375
+ if primary_root.exists():
376
+ for skill_path in sorted(primary_root.glob(f"{STRATEGY_SKILL_PREFIX}*/SKILL.md")):
377
+ record = _strategy_record_payload(root, skill_path)
378
+ if active_only and not record["active"]:
379
+ continue
380
+ records.append(record)
381
+ return records
382
+
383
+
384
+ def read_optional_skill_records(root: Path | str, role: str | None = None, include_archived: bool = True) -> list[dict[str, Any]]:
385
+ root = Path(root).resolve()
386
+ records: list[dict[str, Any]] = []
387
+ base = root / SUBAGENT_SKILL_DIR
388
+ if base.exists():
389
+ for path in sorted(base.glob("*/*/SKILL.md")):
390
+ scope_name = path.parent.parent.name
391
+ name = path.parent.name
392
+ if name in SKILL_SPECS:
393
+ continue
394
+ metadata_path = path.parent / OPTIONAL_SKILL_STATUS_FILE
395
+ record = read_json(metadata_path, {}) or {}
396
+ roles = _optional_record_roles(record, scope_name)
397
+ for target_role in roles:
398
+ if role and target_role != role:
399
+ continue
400
+ candidate = {
401
+ **record,
402
+ "role": target_role,
403
+ "name": name,
404
+ "scope": "shared" if scope_name == "shared" else "role",
405
+ "source_file": _relative_path(root, path),
406
+ "metadata_file": _relative_path(root, path.parent / "agents" / "openai.yaml"),
407
+ "status_file": _relative_path(root, metadata_path),
408
+ }
409
+ payload = _optional_record_payload(root, candidate)
410
+ if not include_archived and payload.get("status") == "archived":
411
+ continue
412
+ records.append(payload)
413
+ return records
414
+
415
+
416
+ def read_agent_additional_instructions(root: Path | str, role: str) -> dict[str, Any]:
417
+ root = Path(root).resolve()
418
+ if role not in AGENT_SPECS:
419
+ raise ValueError(f"Unknown subagent or role: {role}")
420
+ path = _additional_instruction_path(root, role)
421
+ body = _safe_read(path)
422
+ return {
423
+ "role": role,
424
+ "body": body,
425
+ "installed": path.exists(),
426
+ "source_file": _relative_path(root, path),
427
+ "source_file_hash": _file_hash(path),
428
+ "line_count": len(body.splitlines()) if body else 0,
429
+ "char_count": len(body),
430
+ }
431
+
432
+
433
+ def write_agent_additional_instructions(
434
+ root: Path | str,
435
+ role: str,
436
+ body: str,
437
+ *,
438
+ actor: str = "local",
439
+ ) -> dict[str, Any]:
440
+ root = Path(root).resolve()
441
+ if role not in AGENT_SPECS:
442
+ raise ValueError(f"Unknown subagent or role: {role}")
443
+ path = _additional_instruction_path(root, role)
444
+ cleaned = str(body or "").strip()
445
+ if cleaned:
446
+ _atomic_write_text(path, cleaned + "\n")
447
+ else:
448
+ path.unlink(missing_ok=True)
449
+ project_agent_configuration(root, role=role, applied_by=actor)
450
+ return read_agent_additional_instructions(root, role)
451
+
452
+
453
+ def validate_optional_skill_payload(role: str, name: str, description: str = "", body: str = "") -> dict[str, Any]:
454
+ errors: list[str] = []
455
+ if role not in EXPECTED_SUBAGENTS:
456
+ errors.append(f"optional skills can target fixed subagents only: {role}")
457
+ if name in SKILL_SPECS:
458
+ errors.append(f"core skill cannot be overwritten: {name}")
459
+ if name.startswith(STRATEGY_SKILL_PREFIX):
460
+ errors.append("optional skill name cannot use reserved strategy- prefix")
461
+ if not SKILL_NAME_PATTERN.match(name):
462
+ errors.append("optional skill name must be lowercase hyphen-case, 3-64 characters")
463
+ combined = "\n".join([name, description, body])
464
+ if OPTIONAL_SKILL_LOCKED_SURFACE_PATTERN.search(combined):
465
+ errors.append("optional skills cannot change locked harness surfaces")
466
+ risk_tags = infer_optional_skill_risk_tags(combined)
467
+ agent = AGENT_SPECS.get(role)
468
+ if agent:
469
+ blocked_tags = sorted(set(agent.forbidden_skill_tags).intersection(risk_tags))
470
+ if blocked_tags:
471
+ errors.append(f"{role} cannot receive {name}; blocked risk tags: {', '.join(blocked_tags)}")
472
+ return {"status": "blocked" if errors else "valid", "errors": errors, "risk_tags": risk_tags}
473
+
474
+
475
+ def infer_optional_skill_risk_tags(text: str) -> list[str]:
476
+ return sorted(tag for tag, pattern in OPTIONAL_SKILL_RISK_PATTERNS.items() if pattern.search(text))
477
+
478
+
479
+ def normalize_optional_skill_name(raw: str) -> str:
480
+ return sanitize_id(raw).strip("-").lower()
481
+
482
+
483
+ def create_or_update_strategy_skill(
484
+ root: Path | str,
485
+ name: str,
486
+ *,
487
+ description: str = "",
488
+ body: str = "",
489
+ language: str = "unknown",
490
+ status: str = "draft",
491
+ actor: str = "local",
492
+ ) -> dict[str, Any]:
493
+ root = Path(root).resolve()
494
+ name = normalize_strategy_skill_name(name)
495
+ if status not in {"draft", "active", "archived"}:
496
+ raise ValueError(f"unknown strategy status: {status}")
497
+ if (root / MAINAGENT_SKILL_DIR / name).exists():
498
+ raise ValueError(f"strategy skill must be managed under .tradingcodex/strategies: {name}")
499
+ skill_dir = root / STRATEGY_SKILL_DIR / name
500
+ skill_path = skill_dir / "SKILL.md"
501
+ current_fields = _read_frontmatter_fields(skill_path)
502
+ current_body = _read_markdown_body(skill_path)
503
+ current_name = current_fields.get("name") or name
504
+ if current_name != name:
505
+ raise ValueError(f"strategy skill name must match its directory: {name}")
506
+ description = description or current_fields.get("description") or f"Apply the {name} strategy."
507
+ language = language or current_fields.get("language") or "unknown"
508
+ body = body if body.strip() else current_body or _default_strategy_body(name)
509
+ text = _render_strategy_skill_markdown(name, description, body, language, status)
510
+ _atomic_write_text(skill_path, text)
511
+ _atomic_write_text(skill_dir / "agents" / "openai.yaml", _render_openai_yaml(_strategy_display_name(name, body), description, f"Use ${name} to apply this user-approved strategy."))
512
+ record = _strategy_record_payload(root, skill_path)
513
+ if status == "active" and record["validation_errors"]:
514
+ raise ValueError("; ".join(record["validation_errors"]))
515
+ project_agent_configuration(root, applied_by=actor)
516
+ return _strategy_record_payload(root, skill_path)
517
+
518
+
519
+ def set_strategy_skill_status(root: Path | str, name: str, status: str, *, actor: str = "local") -> dict[str, Any]:
520
+ root = Path(root).resolve()
521
+ name = normalize_strategy_skill_name(name)
522
+ record = get_strategy_skill_record(root, name)
523
+ fields = dict(record.get("frontmatter") or {})
524
+ body = _read_markdown_body(root / str(record["source_file"]))
525
+ updated = create_or_update_strategy_skill(
526
+ root,
527
+ name,
528
+ description=fields.get("description", ""),
529
+ body=body,
530
+ language=fields.get("language", "unknown"),
531
+ status=status,
532
+ actor=actor,
533
+ )
534
+ return updated
535
+
536
+
537
+ def delete_strategy_skill(root: Path | str, name: str, *, force: bool = False, actor: str = "local") -> dict[str, Any]:
538
+ root = Path(root).resolve()
539
+ name = normalize_strategy_skill_name(name)
540
+ record = get_strategy_skill_record(root, name)
541
+ if record.get("status") == "active" and not force:
542
+ return set_strategy_skill_status(root, name, "archived", actor=actor)
543
+ shutil.rmtree(root / STRATEGY_SKILL_DIR / name, ignore_errors=True)
544
+ project_agent_configuration(root, applied_by=actor)
545
+ return {"name": name, "status": "deleted", "active": False}
546
+
547
+
548
+ def get_strategy_skill_record(root: Path | str, name: str) -> dict[str, Any]:
549
+ name = normalize_strategy_skill_name(name)
550
+ for record in read_strategy_skill_records(root, active_only=False):
551
+ if record["name"] == name:
552
+ return record
553
+ raise ValueError(f"unknown strategy: {name}")
554
+
555
+
556
+ def normalize_strategy_skill_name(raw: str) -> str:
557
+ name = sanitize_id(raw).strip("-").lower()
558
+ if not name.startswith(STRATEGY_SKILL_PREFIX):
559
+ raise ValueError("strategy skill name must start with strategy-")
560
+ if not SKILL_NAME_PATTERN.match(name):
561
+ raise ValueError("strategy skill name must be lowercase hyphen-case, 3-64 characters")
562
+ return name
563
+
564
+
565
+ def create_or_update_optional_skill(
566
+ root: Path | str,
567
+ role: str,
568
+ name: str,
569
+ *,
570
+ description: str = "",
571
+ body: str = "",
572
+ status: str = "draft",
573
+ actor: str = "local",
574
+ ) -> dict[str, Any]:
575
+ root = Path(root).resolve()
576
+ if role not in EXPECTED_SUBAGENTS:
577
+ raise ValueError(f"optional skills can target fixed subagents only: {role}")
578
+ name = normalize_optional_skill_name(name)
579
+ if status not in OPTIONAL_SKILL_STATUSES:
580
+ raise ValueError(f"unknown optional skill status: {status}")
581
+ if name in SKILL_SPECS or (root / MAINAGENT_SKILL_DIR / name).exists():
582
+ raise ValueError(f"core or project-scope skill cannot be overwritten: {name}")
583
+ skill_dir = root / SUBAGENT_SKILL_DIR / role / name
584
+ skill_path = skill_dir / "SKILL.md"
585
+ current_fields = _read_frontmatter_fields(skill_path)
586
+ current_body = _read_markdown_body(skill_path)
587
+ current_name = current_fields.get("name") or name
588
+ if current_name != name:
589
+ raise ValueError(f"optional skill name must match its directory: {name}")
590
+ description = description or current_fields.get("description") or f"Use the {name} optional procedure."
591
+ body = body if body.strip() else current_body or f"# {_skill_display_name(name, '')}\n\nDescribe the optional role-local procedure.\n"
592
+ validation = validate_optional_skill_payload(role, name, description, body)
593
+ if status == "active" and validation["errors"]:
594
+ raise ValueError("; ".join(validation["errors"]))
595
+ _atomic_write_text(skill_path, _render_basic_skill_markdown(name, description, body))
596
+ _atomic_write_text(skill_dir / "agents" / "openai.yaml", _render_openai_yaml(_skill_display_name(name, body), description, f"Use ${name} for the {role} optional procedure."))
597
+ metadata = {
598
+ "role": role,
599
+ "name": name,
600
+ "scope": "role",
601
+ "status": status,
602
+ "updated_by": actor,
603
+ "updated_at": now_iso(),
604
+ }
605
+ status_path = skill_dir / OPTIONAL_SKILL_STATUS_FILE
606
+ existing = read_json(status_path, {}) or {}
607
+ if existing.get("created_at"):
608
+ metadata["created_at"] = existing["created_at"]
609
+ metadata["created_by"] = existing.get("created_by", actor)
610
+ else:
611
+ metadata["created_at"] = metadata["updated_at"]
612
+ metadata["created_by"] = actor
613
+ _atomic_write_json(status_path, metadata)
614
+ project_agent_configuration(root, role=role, applied_by=actor)
615
+ return next(record for record in read_optional_skill_records(root, role=role, include_archived=True) if record["name"] == name)
616
+
617
+
618
+ def set_optional_skill_status(root: Path | str, role: str, name: str, status: str, *, actor: str = "local") -> dict[str, Any]:
619
+ root = Path(root).resolve()
620
+ name = normalize_optional_skill_name(name)
621
+ record = get_optional_skill_record(root, role, name)
622
+ body = _read_markdown_body(root / str(record["source_file"]))
623
+ return create_or_update_optional_skill(
624
+ root,
625
+ role,
626
+ name,
627
+ description=str(record.get("description") or ""),
628
+ body=body,
629
+ status=status,
630
+ actor=actor,
631
+ )
632
+
633
+
634
+ def delete_optional_skill(root: Path | str, role: str, name: str, *, force: bool = False, actor: str = "local") -> dict[str, Any]:
635
+ root = Path(root).resolve()
636
+ name = normalize_optional_skill_name(name)
637
+ record = get_optional_skill_record(root, role, name)
638
+ if record.get("status") == "active" and not force:
639
+ return set_optional_skill_status(root, role, name, "archived", actor=actor)
640
+ source = root / str(record["source_file"])
641
+ shutil.rmtree(source.parent, ignore_errors=True)
642
+ project_agent_configuration(root, role=role, applied_by=actor)
643
+ return {"role": role, "name": name, "status": "deleted"}
644
+
645
+
646
+ def get_optional_skill_record(root: Path | str, role: str, name: str) -> dict[str, Any]:
647
+ name = normalize_optional_skill_name(name)
648
+ for record in read_optional_skill_records(root, role=role, include_archived=True):
649
+ if record["name"] == name:
650
+ return record
651
+ raise ValueError(f"unknown optional skill for {role}: {name}")
652
+
653
+
654
+ def _optional_records_by_role(records: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
655
+ by_role: dict[str, list[dict[str, Any]]] = {role: [] for role in AGENT_SPECS}
656
+ for record in records:
657
+ by_role.setdefault(str(record.get("role") or ""), []).append(record)
658
+ return by_role
659
+
660
+
661
+ def diff_agent_configuration(root: Path | str, role: str) -> dict[str, Any]:
662
+ root = Path(root).resolve()
663
+ if role not in AGENT_SPECS:
664
+ raise ValueError(f"Unknown subagent or role: {role}")
665
+ state = build_projection_state(root)
666
+ agent = state["agents"][role]
667
+ builtin = set(agent["builtin_skills"])
668
+ current = set(agent["projected_skills"])
669
+ effective = set(agent["effective_skills"])
670
+ return {
671
+ "role": role,
672
+ "builtin_skills": agent["builtin_skills"],
673
+ "projected_skills": agent["projected_skills"],
674
+ "effective_skills": agent["effective_skills"],
675
+ "pending_proposals": agent["pending_proposals"],
676
+ "applied_proposals": agent["applied_proposals"],
677
+ "missing_from_projected": sorted(effective - current),
678
+ "extra_projected": sorted(current - effective),
679
+ "pending_additions": sorted(effective - builtin),
680
+ "validation_errors": agent["validation_errors"],
681
+ "codex_file": agent["codex_file"],
682
+ "projection_manifest": state["projection_manifest"],
683
+ }
684
+
685
+
686
+ def project_agent_configuration(
687
+ root: Path | str,
688
+ *,
689
+ role: str | None = None,
690
+ proposal_path: Path | str | None = None,
691
+ applied_by: str = "local",
692
+ generated_at: str | None = None,
693
+ ) -> dict[str, Any]:
694
+ root = Path(root).resolve()
695
+ selected_role = role
696
+ proposal_record: dict[str, Any] | None = None
697
+ if proposal_path:
698
+ proposal = Path(proposal_path)
699
+ proposal = proposal if proposal.is_absolute() else root / proposal
700
+ proposal_record = read_skill_proposal(proposal, root)
701
+ selected_role = selected_role or proposal_record.get("target")
702
+ validation_errors = validate_skill_assignment(str(proposal_record.get("target", "")), str(proposal_record.get("skill", "")))
703
+ if validation_errors:
704
+ _rewrite_skill_proposal(root, proposal_record, proposal, "blocked", applied_by, validation_errors)
705
+ raise ValueError("; ".join(validation_errors))
706
+ _rewrite_skill_proposal(root, proposal_record, proposal, "applied", applied_by, [])
707
+ proposal_record = read_skill_proposal(proposal, root)
708
+
709
+ if selected_role and selected_role not in AGENT_SPECS:
710
+ raise ValueError(f"Unknown subagent or role: {selected_role}")
711
+
712
+ state = build_projection_state(root)
713
+ if selected_role:
714
+ roles_to_project = [selected_role] if selected_role != "head-manager" else []
715
+ else:
716
+ roles_to_project = [role_id for role_id in EXPECTED_SUBAGENTS]
717
+ for role_id in roles_to_project:
718
+ _project_agent_toml(
719
+ root,
720
+ role_id,
721
+ state["agents"][role_id]["effective_skills"],
722
+ state["agents"][role_id]["additional_instructions"]["body"],
723
+ )
724
+ if selected_role in {None, "head-manager"}:
725
+ _project_head_manager_prompt(root, state["agents"]["head-manager"]["additional_instructions"]["body"])
726
+ _project_root_strategy_skills(root)
727
+
728
+ refreshed = build_projection_state(root)
729
+ generated_at = generated_at or now_iso()
730
+ _write_projection_indexes(root, refreshed, applied_by, generated_at, proposal_record)
731
+ return build_projection_state(root)
732
+
733
+
734
+ def write_skill_proposal_file(root: Path | str, type_: str, target: str, skill: str) -> dict[str, Any]:
735
+ root = Path(root).resolve()
736
+ now = datetime.now(timezone.utc)
737
+ proposal_id = f"skill-{type_}-{target}-{skill}-{now.strftime('%Y%m%dT%H%M%S%fZ')}"
738
+ path = root / PROPOSAL_DIR / f"{sanitize_id(proposal_id)}.yaml"
739
+ validation_errors = validate_skill_assignment(target, skill)
740
+ status = "blocked" if validation_errors else "proposed"
741
+ fields = {
742
+ "id": proposal_id,
743
+ "type": type_,
744
+ "target": target,
745
+ "skill": skill,
746
+ "created_at": now.isoformat().replace("+00:00", "Z"),
747
+ "requires_validation": "true",
748
+ "status": status,
749
+ "validation_status": "blocked" if validation_errors else "valid",
750
+ }
751
+ if validation_errors:
752
+ fields["validation_error"] = "; ".join(validation_errors)
753
+ _write_simple_yaml(path, fields)
754
+ result = {"status": status, "id": proposal_id, "path": path.relative_to(root).as_posix(), "validation_errors": validation_errors}
755
+ return result
756
+
757
+
758
+ def read_skill_proposals(root: Path | str) -> list[dict[str, Any]]:
759
+ root = Path(root).resolve()
760
+ proposals: list[dict[str, Any]] = []
761
+ for path in sorted((root / PROPOSAL_DIR).glob("*.yaml")):
762
+ proposals.append(read_skill_proposal(path, root))
763
+ return proposals
764
+
765
+
766
+ def read_skill_proposal(path: Path, root: Path | None = None) -> dict[str, Any]:
767
+ root = root.resolve() if root else None
768
+ data = _read_simple_yaml(path)
769
+ data["path"] = path.relative_to(root).as_posix() if root and path.is_relative_to(root) else str(path)
770
+ data["source_file_hash"] = _file_hash(path)
771
+ return data
772
+
773
+
774
+ def validate_skill_assignment(role: str, skill: str) -> list[str]:
775
+ errors: list[str] = []
776
+ agent = AGENT_SPECS.get(role)
777
+ skill_spec = SKILL_SPECS.get(skill)
778
+ if not agent:
779
+ errors.append(f"unknown role: {role}")
780
+ if not skill_spec:
781
+ errors.append(f"unknown skill: {skill}")
782
+ if errors or not agent or not skill_spec:
783
+ return errors
784
+ if role != "head-manager" and skill_spec.scope == "mainagent":
785
+ errors.append(f"{role} cannot receive project-scope mainagent skill: {skill}")
786
+ blocked_tags = sorted(set(agent.forbidden_skill_tags).intersection(skill_spec.risk_tags))
787
+ if blocked_tags:
788
+ errors.append(f"{role} cannot receive {skill}; blocked risk tags: {', '.join(blocked_tags)}")
789
+ if skill_spec.owner_roles and role not in skill_spec.owner_roles:
790
+ errors.append(f"{role} is not an owner role for {skill}")
791
+ return errors
792
+
793
+
794
+ def skills_for_role(root: Path | str, role: str) -> list[str]:
795
+ return inspect_agent_configuration(root, role)["effective_skills"]
796
+
797
+
798
+ def build_projection_state(root: Path | str) -> dict[str, Any]:
799
+ root = Path(root).resolve()
800
+ manifest = read_json(root / MANIFEST_PATH, {}) or {}
801
+ applied_by_role: dict[str, list[dict[str, Any]]] = {role: [] for role in AGENT_SPECS}
802
+ pending_by_role: dict[str, list[dict[str, Any]]] = {role: [] for role in AGENT_SPECS}
803
+ blocked_by_role: dict[str, list[dict[str, Any]]] = {role: [] for role in AGENT_SPECS}
804
+ for proposal in read_skill_proposals(root):
805
+ target = str(proposal.get("target", ""))
806
+ if target not in AGENT_SPECS:
807
+ continue
808
+ status = str(proposal.get("status", "proposed"))
809
+ if status == "applied":
810
+ applied_by_role[target].append(proposal)
811
+ elif status == "blocked":
812
+ blocked_by_role[target].append(proposal)
813
+ else:
814
+ pending_by_role[target].append(proposal)
815
+
816
+ agents: dict[str, dict[str, Any]] = {}
817
+ skill_root_exists = (root / MAINAGENT_SKILL_DIR).exists()
818
+ optional_records = read_optional_skill_records(root, include_archived=True)
819
+ optional_by_role = _optional_records_by_role(optional_records)
820
+ for role, spec in AGENT_SPECS.items():
821
+ applied_skills = [str(item.get("skill")) for item in applied_by_role[role] if item.get("skill")]
822
+ active_optional = [
823
+ str(record.get("name"))
824
+ for record in optional_by_role.get(role, [])
825
+ if record.get("status") == "active" and not record.get("validation_errors")
826
+ ]
827
+ effective = _unique_existing(root, [*spec.builtin_skills, *applied_skills, *active_optional], role=role)
828
+ agent_file = _agent_config_path(root, role)
829
+ projected_skills = _parse_toml_skill_paths(agent_file.read_text(encoding="utf-8")) if agent_file.exists() else []
830
+ validation_errors: list[str] = []
831
+ for skill in effective:
832
+ if skill in SKILL_SPECS:
833
+ validation_errors.extend(validate_skill_assignment(role, skill))
834
+ for optional in optional_by_role.get(role, []):
835
+ validation_errors.extend(optional.get("validation_errors") or [])
836
+ additional = read_agent_additional_instructions(root, role)
837
+ agents[role] = {
838
+ **_agent_spec_payload(spec),
839
+ "codex_file": _relative_path(root, agent_file) if agent_file else "",
840
+ "codex_file_hash": _file_hash(agent_file) if agent_file else None,
841
+ "builtin_skills": list(spec.builtin_skills)
842
+ if not skill_root_exists
843
+ else [skill for skill in spec.builtin_skills if _skill_path(root, skill, role=role).exists()],
844
+ "effective_skills": effective,
845
+ "projected_skills": projected_skills,
846
+ "pending_proposals": [_proposal_summary(proposal) for proposal in pending_by_role[role]],
847
+ "applied_proposals": [_proposal_summary(proposal) for proposal in applied_by_role[role]],
848
+ "blocked_proposals": [_proposal_summary(proposal) for proposal in blocked_by_role[role]],
849
+ "optional_skills": optional_by_role.get(role, []),
850
+ "optional_skill_count": len([record for record in optional_by_role.get(role, []) if record.get("status") == "active"]),
851
+ "additional_instructions": additional,
852
+ "additional_instruction_count": additional["line_count"],
853
+ "validation_errors": sorted(set(validation_errors)),
854
+ "permission_profile": spec.permission_profile,
855
+ "mcp_allowlist": list(spec.mcp_allowlist),
856
+ }
857
+
858
+ skills = _installed_skill_index(root, optional_records)
859
+ projection_input = {
860
+ "agents": {
861
+ role: {
862
+ "effective_skills": agent["effective_skills"],
863
+ "codex_file_hash": agent["codex_file_hash"],
864
+ "additional_instructions_hash": agent["additional_instructions"]["source_file_hash"],
865
+ }
866
+ for role, agent in agents.items()
867
+ },
868
+ "skills": {skill_id: item["source_file_hash"] for skill_id, item in skills.items()},
869
+ "applied_proposals": {
870
+ role: [proposal["source_file_hash"] for proposal in agent["applied_proposals"]]
871
+ for role, agent in agents.items()
872
+ },
873
+ }
874
+ return {
875
+ "root": str(root),
876
+ "registry": "tradingcodex_service.application.agents",
877
+ "agents": agents,
878
+ "skills": skills,
879
+ "projection_hash": stable_hash(projection_input),
880
+ "projection_manifest": manifest,
881
+ }
882
+
883
+
884
+ def _write_projection_indexes(
885
+ root: Path,
886
+ state: dict[str, Any],
887
+ applied_by: str,
888
+ generated_at: str,
889
+ proposal_record: dict[str, Any] | None,
890
+ ) -> None:
891
+ agent_index = {
892
+ "generated_at": generated_at,
893
+ "source": "tradingcodex_service.application.agents",
894
+ "projection_hash": state["projection_hash"],
895
+ "agents": state["agents"],
896
+ }
897
+ skill_index = {
898
+ "generated_at": generated_at,
899
+ "source": "workspace-files",
900
+ "projection_hash": state["projection_hash"],
901
+ "skills": state["skills"],
902
+ }
903
+ manifest_roles = []
904
+ for role, agent in state["agents"].items():
905
+ manifest_roles.append(
906
+ {
907
+ "role": role,
908
+ "codex_file": agent["codex_file"],
909
+ "source_file_hash": agent["codex_file_hash"],
910
+ "effective_skills": [
911
+ {
912
+ "skill": skill,
913
+ "source_file": state["skills"].get(skill, {}).get("source_file", ""),
914
+ "source_file_hash": state["skills"].get(skill, {}).get("source_file_hash"),
915
+ }
916
+ for skill in agent["effective_skills"]
917
+ ],
918
+ "additional_instructions": {
919
+ "source_file": agent["additional_instructions"]["source_file"],
920
+ "source_file_hash": agent["additional_instructions"]["source_file_hash"],
921
+ "line_count": agent["additional_instructions"]["line_count"],
922
+ },
923
+ }
924
+ )
925
+ manifest = {
926
+ "generated_at": generated_at,
927
+ "applied_by": applied_by,
928
+ "projection_hash": state["projection_hash"],
929
+ "source": "file-native-agent-skill-projection",
930
+ "proposal": _proposal_summary(proposal_record) if proposal_record else None,
931
+ "roles": manifest_roles,
932
+ }
933
+ write_json(root / AGENT_INDEX_PATH, agent_index)
934
+ write_json(root / SKILL_INDEX_PATH, skill_index)
935
+ write_json(root / MANIFEST_PATH, manifest)
936
+
937
+
938
+ def _optional_record_payload(root: Path, record: dict[str, Any]) -> dict[str, Any]:
939
+ role = str(record.get("role") or "")
940
+ name = normalize_optional_skill_name(str(record.get("name") or ""))
941
+ source_file = str(record.get("source_file") or "")
942
+ metadata_file = str(record.get("metadata_file") or "")
943
+ status_file = str(record.get("status_file") or "")
944
+ skill_path = root / source_file if source_file else _skill_path(root, name, role=role)
945
+ metadata_path = root / metadata_file if metadata_file else skill_path.parent / "agents" / "openai.yaml"
946
+ status_path = root / status_file if status_file else skill_path.parent / OPTIONAL_SKILL_STATUS_FILE
947
+ fields = _read_frontmatter_fields(skill_path)
948
+ body = _safe_read(skill_path)
949
+ validation = validate_optional_skill_payload(
950
+ role,
951
+ name,
952
+ str(fields.get("description") or ""),
953
+ body,
954
+ )
955
+ if "name" not in fields:
956
+ validation["errors"].append("missing optional skill frontmatter: name")
957
+ elif fields.get("name") != name:
958
+ validation["errors"].append("optional skill frontmatter name must match directory name")
959
+ if "description" not in fields:
960
+ validation["errors"].append("missing optional skill frontmatter: description")
961
+ status = str(record.get("status") or "active")
962
+ if status not in OPTIONAL_SKILL_STATUSES:
963
+ validation["errors"].append(f"unknown optional skill status: {status}")
964
+ payload = {
965
+ "role": role,
966
+ "name": name,
967
+ "description": fields.get("description") or "",
968
+ "status": status,
969
+ "source": "optional",
970
+ "scope": str(record.get("scope") or "role"),
971
+ "core": False,
972
+ "installed": skill_path.exists(),
973
+ "source_file": _relative_path(root, skill_path),
974
+ "source_file_hash": _file_hash(skill_path),
975
+ "metadata_file": _relative_path(root, metadata_path),
976
+ "metadata_file_hash": _file_hash(metadata_path),
977
+ "status_file": _relative_path(root, status_path),
978
+ "status_file_hash": _file_hash(status_path),
979
+ "validation_status": "blocked" if validation["errors"] else "valid",
980
+ "validation_errors": validation["errors"],
981
+ "risk_tags": validation["risk_tags"],
982
+ "frontmatter": fields,
983
+ }
984
+ for key in ("created_by", "created_at", "updated_by", "updated_at", "roles"):
985
+ if key in record:
986
+ payload[key] = record[key]
987
+ return payload
988
+
989
+
990
+ def _strategy_record_payload(root: Path, skill_path: Path) -> dict[str, Any]:
991
+ name = skill_path.parent.name
992
+ metadata_path = skill_path.parent / "agents" / "openai.yaml"
993
+ fields = _read_frontmatter_fields(skill_path)
994
+ missing = sorted(STRATEGY_REQUIRED_FRONTMATTER - set(fields))
995
+ validation_errors: list[str] = []
996
+ if missing:
997
+ validation_errors.append(f"missing strategy frontmatter: {', '.join(missing)}")
998
+ if not name.startswith(STRATEGY_SKILL_PREFIX):
999
+ validation_errors.append("strategy skill name must start with strategy-")
1000
+ if fields.get("name") and fields.get("name") != name:
1001
+ validation_errors.append("strategy skill frontmatter name must match directory name")
1002
+ if fields.get("type") and fields.get("type") != "strategy":
1003
+ validation_errors.append("strategy skill frontmatter type must be strategy")
1004
+ if fields.get("managed_by") and fields.get("managed_by") != "strategy-creator":
1005
+ validation_errors.append("strategy skill frontmatter managed_by must be strategy-creator")
1006
+ body = _safe_read(skill_path)
1007
+ missing_sections = [section for section in STRATEGY_REQUIRED_SECTIONS if section not in body]
1008
+ if missing_sections:
1009
+ validation_errors.append(f"missing strategy sections: {', '.join(missing_sections)}")
1010
+ status = fields.get("status") or "unknown"
1011
+ active = status == "active" and not validation_errors
1012
+ return {
1013
+ "name": name,
1014
+ "description": fields.get("description") or "",
1015
+ "label": name,
1016
+ "owner_roles": ["head-manager"],
1017
+ "risk_tags": ["strategy"],
1018
+ "user_visible": active,
1019
+ "source": "strategy",
1020
+ "scope": "strategy",
1021
+ "core": False,
1022
+ "status": status,
1023
+ "active": active,
1024
+ "installed": skill_path.exists(),
1025
+ "source_file": _relative_path(root, skill_path),
1026
+ "source_file_hash": _file_hash(skill_path),
1027
+ "metadata_file": _relative_path(root, metadata_path),
1028
+ "metadata_file_hash": _file_hash(metadata_path),
1029
+ "validation_status": "blocked" if validation_errors else "valid",
1030
+ "validation_errors": validation_errors,
1031
+ "frontmatter": fields,
1032
+ }
1033
+
1034
+
1035
+ def _project_agent_toml(root: Path, role: str, skills: list[str], additional_instructions: str = "") -> None:
1036
+ path = _agent_config_path(root, role)
1037
+ if not path.exists():
1038
+ return
1039
+ text = path.read_text(encoding="utf-8")
1040
+ marker = "[[skills.config]]"
1041
+ body = text[: text.find(marker)].rstrip() if marker in text else text.rstrip()
1042
+ body = _replace_developer_instructions(body, additional_instructions)
1043
+ rendered = body + "\n\n" + _render_skill_config_blocks(root, skills, role=role)
1044
+ path.write_text(rendered.rstrip() + "\n", encoding="utf-8")
1045
+
1046
+
1047
+ def _project_head_manager_prompt(root: Path, additional_instructions: str = "") -> None:
1048
+ path = root / ".codex" / "prompts" / "base_instructions" / "head-manager.md"
1049
+ if not path.exists():
1050
+ return
1051
+ text = _strip_additional_instruction_block(path.read_text(encoding="utf-8")).rstrip()
1052
+ if additional_instructions.strip():
1053
+ text += "\n\n" + _render_additional_instruction_block(additional_instructions).rstrip()
1054
+ path.write_text(text.rstrip() + "\n", encoding="utf-8")
1055
+
1056
+
1057
+ def _replace_developer_instructions(text: str, additional_instructions: str) -> str:
1058
+ pattern = re.compile(r'developer_instructions\s*=\s*"""(?P<body>.*?)"""', re.S)
1059
+ match = pattern.search(text)
1060
+ if not match:
1061
+ return text
1062
+ base = _strip_additional_instruction_block(match.group("body")).strip("\n")
1063
+ if additional_instructions.strip():
1064
+ block = _escape_toml_multiline_basic(_render_additional_instruction_block(additional_instructions).rstrip())
1065
+ base += "\n\n" + block
1066
+ rendered = 'developer_instructions = """\n' + base + '\n"""'
1067
+ return text[: match.start()] + rendered + text[match.end() :]
1068
+
1069
+
1070
+ def _render_additional_instruction_block(additional_instructions: str) -> str:
1071
+ return "\n".join(
1072
+ [
1073
+ ADDITIONAL_INSTRUCTION_START,
1074
+ "",
1075
+ "These project-local instructions are appended after the generated default role instructions.",
1076
+ "",
1077
+ additional_instructions.strip(),
1078
+ "",
1079
+ ADDITIONAL_INSTRUCTION_END,
1080
+ "",
1081
+ ]
1082
+ )
1083
+
1084
+
1085
+ def _strip_additional_instruction_block(text: str) -> str:
1086
+ pattern = re.compile(
1087
+ rf"\n*{re.escape(ADDITIONAL_INSTRUCTION_START)}.*?{re.escape(ADDITIONAL_INSTRUCTION_END)}\n*",
1088
+ re.S,
1089
+ )
1090
+ return pattern.sub("\n", text)
1091
+
1092
+
1093
+ def _escape_toml_multiline_basic(text: str) -> str:
1094
+ return text.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
1095
+
1096
+
1097
+ def _project_root_strategy_skills(root: Path) -> None:
1098
+ path = _agent_config_path(root, "head-manager")
1099
+ if not path.exists():
1100
+ return
1101
+ text = path.read_text(encoding="utf-8")
1102
+ strategy_records = read_strategy_skill_records(root, active_only=True)
1103
+ rendered = "\n".join(
1104
+ f'[[skills.config]]\npath = "{(root / str(record["source_file"])).as_posix()}"\nenabled = true'
1105
+ for record in strategy_records
1106
+ )
1107
+ block = f"{STRATEGY_ROOT_CONFIG_START}\n{rendered}\n{STRATEGY_ROOT_CONFIG_END}".replace("\n\n", "\n")
1108
+ if STRATEGY_ROOT_CONFIG_START in text and STRATEGY_ROOT_CONFIG_END in text:
1109
+ pattern = re.compile(
1110
+ rf"{re.escape(STRATEGY_ROOT_CONFIG_START)}.*?{re.escape(STRATEGY_ROOT_CONFIG_END)}",
1111
+ re.S,
1112
+ )
1113
+ updated = pattern.sub(block, text)
1114
+ else:
1115
+ permissions_index = text.find("\n[permissions.")
1116
+ if permissions_index >= 0:
1117
+ updated = text[:permissions_index].rstrip() + "\n\n" + block + "\n" + text[permissions_index:]
1118
+ else:
1119
+ updated = text.rstrip() + "\n\n" + block + "\n"
1120
+ if updated != text:
1121
+ path.write_text(updated.rstrip() + "\n", encoding="utf-8")
1122
+
1123
+
1124
+ def _render_skill_config_blocks(root: Path, skills: list[str], *, role: str | None = None) -> str:
1125
+ blocks = []
1126
+ for skill in skills:
1127
+ skill_path = _skill_path(root, skill, role=role)
1128
+ blocks.append(f'[[skills.config]]\npath = "{skill_path.as_posix()}"\nenabled = true')
1129
+ return "\n\n".join(blocks) + ("\n" if blocks else "")
1130
+
1131
+
1132
+ def _rewrite_skill_proposal(root: Path, proposal: dict[str, Any], path: Path, status: str, applied_by: str, errors: list[str]) -> None:
1133
+ fields = {
1134
+ "id": proposal.get("id", path.stem),
1135
+ "type": proposal.get("type", "add"),
1136
+ "target": proposal.get("target", ""),
1137
+ "skill": proposal.get("skill", ""),
1138
+ "created_at": proposal.get("created_at", ""),
1139
+ "requires_validation": proposal.get("requires_validation", "true"),
1140
+ "status": status,
1141
+ "validation_status": "blocked" if errors else "valid",
1142
+ "applied_by": applied_by,
1143
+ "applied_at": now_iso(),
1144
+ }
1145
+ if errors:
1146
+ fields["validation_error"] = "; ".join(errors)
1147
+ _write_simple_yaml(path, fields)
1148
+
1149
+
1150
+ def _installed_skill_index(root: Path, optional_records: list[dict[str, Any]] | None = None) -> dict[str, dict[str, Any]]:
1151
+ skills: dict[str, dict[str, Any]] = {}
1152
+ for skill_id, spec in SKILL_SPECS.items():
1153
+ skill_path = _skill_path(root, skill_id)
1154
+ meta_path = skill_path.parent / "agents" / "openai.yaml"
1155
+ skills[skill_id] = {
1156
+ "id": skill_id,
1157
+ "label": spec.label,
1158
+ "owner_roles": list(spec.owner_roles),
1159
+ "risk_tags": list(spec.risk_tags),
1160
+ "user_visible": spec.user_visible,
1161
+ "source": "core",
1162
+ "scope": spec.scope,
1163
+ "core": True,
1164
+ "installed": skill_path.exists(),
1165
+ "source_file": _relative_path(root, skill_path),
1166
+ "source_file_hash": _file_hash(skill_path),
1167
+ "metadata_file": _relative_path(root, meta_path),
1168
+ "metadata_file_hash": _file_hash(meta_path),
1169
+ }
1170
+ for record in read_strategy_skill_records(root):
1171
+ skill_id = str(record.get("name") or "")
1172
+ if not skill_id or skill_id in skills:
1173
+ continue
1174
+ skills[skill_id] = record
1175
+ for record in optional_records or read_optional_skill_records(root, include_archived=True):
1176
+ skill_name = str(record.get("name") or "")
1177
+ if not skill_name or skill_name in skills:
1178
+ continue
1179
+ skills[skill_name] = {
1180
+ "name": skill_name,
1181
+ "label": skill_name,
1182
+ "description": str(record.get("description") or ""),
1183
+ "owner_roles": [record.get("role")],
1184
+ "risk_tags": list(record.get("risk_tags") or []),
1185
+ "user_visible": False,
1186
+ "source": "optional",
1187
+ "scope": record.get("scope", "role"),
1188
+ "core": False,
1189
+ "status": record.get("status"),
1190
+ "installed": bool(record.get("installed")),
1191
+ "source_file": record.get("source_file", ""),
1192
+ "source_file_hash": record.get("source_file_hash"),
1193
+ "metadata_file": record.get("metadata_file", ""),
1194
+ "metadata_file_hash": record.get("metadata_file_hash"),
1195
+ "status_file": record.get("status_file", ""),
1196
+ "status_file_hash": record.get("status_file_hash"),
1197
+ "validation_status": record.get("validation_status"),
1198
+ "validation_errors": record.get("validation_errors", []),
1199
+ }
1200
+ return skills
1201
+
1202
+
1203
+ def _agent_spec_payload(spec: AgentSpec) -> dict[str, Any]:
1204
+ return {
1205
+ "role": spec.role,
1206
+ "label": spec.label,
1207
+ "group": spec.group,
1208
+ "permission_profile": spec.permission_profile,
1209
+ "builtin_skills": list(spec.builtin_skills),
1210
+ "forbidden_skill_tags": list(spec.forbidden_skill_tags),
1211
+ "mcp_allowlist": list(spec.mcp_allowlist),
1212
+ }
1213
+
1214
+
1215
+ def _agent_config_path(root: Path, role: str) -> Path:
1216
+ if role == "head-manager":
1217
+ return root / ".codex" / "config.toml"
1218
+ return root / ".codex" / "agents" / f"{role}.toml"
1219
+
1220
+
1221
+ def _additional_instruction_path(root: Path, role: str) -> Path:
1222
+ return root / ADDITIONAL_INSTRUCTION_DIR / f"{role}.md"
1223
+
1224
+
1225
+ def _skill_path(root: Path, skill: str, *, role: str | None = None) -> Path:
1226
+ spec = SKILL_SPECS.get(skill)
1227
+ if spec:
1228
+ if spec.scope == "subagent_shared":
1229
+ return root / SUBAGENT_SHARED_SKILL_DIR / skill / "SKILL.md"
1230
+ if spec.scope == "subagent_role":
1231
+ target_role = role or (spec.owner_roles[0] if spec.owner_roles else "")
1232
+ return root / SUBAGENT_SKILL_DIR / target_role / skill / "SKILL.md"
1233
+ return root / MAINAGENT_SKILL_DIR / skill / "SKILL.md"
1234
+ if role:
1235
+ role_path = root / SUBAGENT_SKILL_DIR / role / skill / "SKILL.md"
1236
+ if role_path.exists():
1237
+ return role_path
1238
+ shared_path = root / SUBAGENT_SHARED_SKILL_DIR / skill / "SKILL.md"
1239
+ if shared_path.exists():
1240
+ return shared_path
1241
+ return role_path
1242
+ for candidate in sorted((root / SUBAGENT_SKILL_DIR).glob(f"*/{skill}/SKILL.md")):
1243
+ return candidate
1244
+ return root / MAINAGENT_SKILL_DIR / skill / "SKILL.md"
1245
+
1246
+
1247
+ def _parse_toml_skill_paths(text: str) -> list[str]:
1248
+ skills: list[str] = []
1249
+ patterns = [
1250
+ r'path\s*=\s*"[^"]+/\.agents/skills/([^"/]+)/SKILL\.md"',
1251
+ r'path\s*=\s*"[^"]+/\.tradingcodex/subagents/skills/(?:shared|[^"/]+)/([^"/]+)/SKILL\.md"',
1252
+ r'path\s*=\s*"[^"]+/\.tradingcodex/strategies/([^"/]+)/SKILL\.md"',
1253
+ ]
1254
+ for pattern in patterns:
1255
+ for match in re.finditer(pattern, text):
1256
+ skills.append(match.group(1))
1257
+ return list(dict.fromkeys(skills))
1258
+
1259
+
1260
+ def _proposal_summary(proposal: dict[str, Any] | None) -> dict[str, Any] | None:
1261
+ if proposal is None:
1262
+ return None
1263
+ return {
1264
+ "id": proposal.get("id"),
1265
+ "type": proposal.get("type"),
1266
+ "target": proposal.get("target"),
1267
+ "skill": proposal.get("skill"),
1268
+ "status": proposal.get("status"),
1269
+ "path": proposal.get("path"),
1270
+ "source_file_hash": proposal.get("source_file_hash"),
1271
+ }
1272
+
1273
+
1274
+ def _file_hash(path: Path | None) -> str | None:
1275
+ if path is None or not path.exists() or not path.is_file():
1276
+ return None
1277
+ return hashlib.sha256(path.read_bytes()).hexdigest()
1278
+
1279
+
1280
+ def _relative_path(root: Path, path: Path) -> str:
1281
+ try:
1282
+ return path.relative_to(root).as_posix()
1283
+ except ValueError:
1284
+ return str(path)
1285
+
1286
+
1287
+ def _optional_record_roles(record: dict[str, Any], scope_name: str) -> list[str]:
1288
+ if scope_name != "shared":
1289
+ return [scope_name]
1290
+ roles = record.get("roles")
1291
+ if isinstance(roles, list):
1292
+ return [str(role) for role in roles if str(role) in EXPECTED_SUBAGENTS]
1293
+ if isinstance(roles, str):
1294
+ parsed = [item.strip() for item in roles.split(",") if item.strip()]
1295
+ return [item for item in parsed if item in EXPECTED_SUBAGENTS]
1296
+ role = str(record.get("role") or "")
1297
+ if role in EXPECTED_SUBAGENTS:
1298
+ return [role]
1299
+ return list(EXPECTED_SUBAGENTS)
1300
+
1301
+
1302
+ def _unique_existing(root: Path, skills: list[str], *, role: str | None = None) -> list[str]:
1303
+ unique = list(dict.fromkeys(skills))
1304
+ if not (root / MAINAGENT_SKILL_DIR).exists() and not (root / SUBAGENT_SKILL_DIR).exists():
1305
+ return unique
1306
+ return [skill for skill in unique if _skill_path(root, skill, role=role).exists()]
1307
+
1308
+
1309
+ def _atomic_write_text(path: Path, text: str) -> None:
1310
+ path.parent.mkdir(parents=True, exist_ok=True)
1311
+ temp_path = path.with_name(f".{path.name}.tmp")
1312
+ temp_path.write_text(text, encoding="utf-8")
1313
+ temp_path.replace(path)
1314
+
1315
+
1316
+ def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
1317
+ _atomic_write_text(path, json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
1318
+
1319
+
1320
+ def _read_markdown_body(path: Path) -> str:
1321
+ try:
1322
+ text = path.read_text(encoding="utf-8")
1323
+ except Exception:
1324
+ return ""
1325
+ if not text.startswith("---\n"):
1326
+ return text
1327
+ end = text.find("\n---", 4)
1328
+ if end < 0:
1329
+ return text
1330
+ body_start = text.find("\n", end + 4)
1331
+ return text[body_start + 1 :] if body_start >= 0 else ""
1332
+
1333
+
1334
+ def _render_basic_skill_markdown(name: str, description: str, body: str) -> str:
1335
+ frontmatter = {
1336
+ "name": name,
1337
+ "description": description or name.replace("-", " ").title(),
1338
+ }
1339
+ return _render_frontmatter(frontmatter) + body.strip() + "\n"
1340
+
1341
+
1342
+ def _render_strategy_skill_markdown(name: str, description: str, body: str, language: str, status: str) -> str:
1343
+ frontmatter = {
1344
+ "name": name,
1345
+ "description": description,
1346
+ "type": "strategy",
1347
+ "status": status,
1348
+ "language": language or "unknown",
1349
+ "managed_by": "strategy-creator",
1350
+ "owner": "user",
1351
+ "last_reviewed": now_iso()[:10],
1352
+ }
1353
+ return _render_frontmatter(frontmatter) + _ensure_strategy_sections(name, body).strip() + "\n"
1354
+
1355
+
1356
+ def _render_frontmatter(fields: dict[str, Any]) -> str:
1357
+ lines = ["---"]
1358
+ for key, value in fields.items():
1359
+ lines.append(f"{key}: {_yaml_scalar(value)}")
1360
+ lines.append("---")
1361
+ lines.append("")
1362
+ return "\n".join(lines)
1363
+
1364
+
1365
+ def _ensure_strategy_sections(name: str, body: str) -> str:
1366
+ text = body.strip() or _default_strategy_body(name)
1367
+ if not text.startswith("# "):
1368
+ text = f"# {_strategy_display_name(name, text)}\n\n{text}"
1369
+ for section in STRATEGY_REQUIRED_SECTIONS:
1370
+ if section not in text:
1371
+ text = text.rstrip() + f"\n\n{section}\nnot specified\n"
1372
+ return text
1373
+
1374
+
1375
+ def _default_strategy_body(name: str) -> str:
1376
+ sections = "\n\n".join(f"{section}\nnot specified" for section in STRATEGY_REQUIRED_SECTIONS)
1377
+ return f"# {_strategy_display_name(name, '')}\n\n{sections}\n"
1378
+
1379
+
1380
+ def _strategy_display_name(name: str, body: str) -> str:
1381
+ return _skill_display_name(name.removeprefix(STRATEGY_SKILL_PREFIX), body) if name.startswith(STRATEGY_SKILL_PREFIX) else _skill_display_name(name, body)
1382
+
1383
+
1384
+ def _skill_display_name(name: str, body: str) -> str:
1385
+ for line in body.splitlines():
1386
+ stripped = line.strip()
1387
+ if stripped.startswith("# "):
1388
+ heading = stripped[2:].strip()
1389
+ if heading:
1390
+ return heading
1391
+ return name.replace("-", " ").title() or name
1392
+
1393
+
1394
+ def _render_openai_yaml(display_name: str, short_description: str, default_prompt: str) -> str:
1395
+ short = re.sub(r"\s+", " ", short_description or display_name).strip()
1396
+ if len(short) < 25:
1397
+ short = (short + " for TradingCodex workflows").strip()
1398
+ if len(short) > 64:
1399
+ short = short[:64].rstrip()
1400
+ return "\n".join(
1401
+ [
1402
+ "interface:",
1403
+ f" display_name: {_yaml_scalar(display_name)}",
1404
+ f" short_description: {_yaml_scalar(short)}",
1405
+ f" default_prompt: {_yaml_scalar(default_prompt)}",
1406
+ "policy:",
1407
+ " allow_implicit_invocation: true",
1408
+ "",
1409
+ ]
1410
+ )
1411
+
1412
+
1413
+ def _write_simple_yaml(path: Path, fields: dict[str, Any]) -> None:
1414
+ path.parent.mkdir(parents=True, exist_ok=True)
1415
+ lines = []
1416
+ for key, value in fields.items():
1417
+ if value is None:
1418
+ continue
1419
+ lines.append(f"{key}: {_yaml_scalar(value)}")
1420
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
1421
+
1422
+
1423
+ def _read_simple_yaml(path: Path) -> dict[str, str]:
1424
+ data: dict[str, str] = {}
1425
+ try:
1426
+ text = path.read_text(encoding="utf-8")
1427
+ except Exception:
1428
+ return data
1429
+ for line in text.splitlines():
1430
+ stripped = line.strip()
1431
+ if not stripped or stripped.startswith("#") or ":" not in stripped:
1432
+ continue
1433
+ key, raw_value = stripped.split(":", 1)
1434
+ data[key.strip()] = _unquote_yaml_scalar(raw_value.strip())
1435
+ return data
1436
+
1437
+
1438
+ def _read_frontmatter_fields(path: Path) -> dict[str, str]:
1439
+ try:
1440
+ text = path.read_text(encoding="utf-8")
1441
+ except Exception:
1442
+ return {}
1443
+ lines = text.splitlines()
1444
+ if not lines or lines[0].strip() != "---":
1445
+ return {}
1446
+ fields: dict[str, str] = {}
1447
+ for line in lines[1:]:
1448
+ stripped = line.strip()
1449
+ if stripped == "---":
1450
+ break
1451
+ if not stripped or stripped.startswith("#") or ":" not in stripped:
1452
+ continue
1453
+ key, raw_value = stripped.split(":", 1)
1454
+ fields[key.strip()] = _unquote_yaml_scalar(raw_value.strip())
1455
+ return fields
1456
+
1457
+
1458
+ def _yaml_scalar(value: Any) -> str:
1459
+ text = str(value)
1460
+ if text in {"true", "false"}:
1461
+ return text
1462
+ if re.fullmatch(r"[A-Za-z0-9._/@:-]+", text):
1463
+ return text
1464
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
1465
+
1466
+
1467
+ def _unquote_yaml_scalar(value: str) -> str:
1468
+ if len(value) >= 2 and value[0] == value[-1] == '"':
1469
+ return value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
1470
+ return value