multi-forge 0.2.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 (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,344 @@
1
+ """Override manipulation operations.
2
+
3
+ This module provides functions for validating, parsing, and manipulating
4
+ session override values with strict schema validation.
5
+
6
+ Key validation is strict:
7
+ - Keys must be valid SessionIntent paths (derived via dataclass introspection)
8
+ - Wildcards (<top_level>.*) are supported and expanded at operation time
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from dataclasses import fields, is_dataclass
16
+ from typing import Any, get_type_hints
17
+
18
+ from forge.core.typing_helpers import unwrap_optional
19
+
20
+ from .exceptions import InvalidOverrideKeyError
21
+ from .models import SessionIntent
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Top-level manifest fields that cannot be overridden
26
+ _MANIFEST_FIELDS = frozenset(
27
+ {
28
+ "schema_version",
29
+ "name",
30
+ "created_at",
31
+ "last_accessed_at",
32
+ "parent_session",
33
+ "is_fork",
34
+ "is_incognito",
35
+ "worktree",
36
+ "intent",
37
+ "overrides",
38
+ "confirmed",
39
+ }
40
+ )
41
+
42
+ # Cache for valid intent paths (computed once)
43
+ _valid_paths_cache: set[str] | None = None
44
+
45
+
46
+ def get_valid_intent_paths() -> set[str]:
47
+ """Introspect SessionIntent dataclass to build valid dot-paths.
48
+
49
+ This function recursively walks the SessionIntent dataclass and its nested
50
+ dataclasses to build a set of valid override key paths.
51
+
52
+ Rules:
53
+ - Optional fields are included (e.g., proxy.template valid even when proxy is None)
54
+
55
+ Returns:
56
+ Set of valid dot-notation paths (e.g., {"agent", "proxy.template", ...})
57
+ """
58
+ global _valid_paths_cache
59
+ if _valid_paths_cache is not None:
60
+ return _valid_paths_cache
61
+
62
+ paths: set[str] = set()
63
+ _collect_paths(SessionIntent, "", paths)
64
+ _valid_paths_cache = paths
65
+ return paths
66
+
67
+
68
+ def _collect_paths(cls: type | Any, prefix: str, paths: set[str]) -> None:
69
+ """Recursively collect valid paths from a dataclass."""
70
+ if not is_dataclass(cls):
71
+ return
72
+
73
+ try:
74
+ hints = get_type_hints(cls)
75
+ except Exception as e:
76
+ logger.debug("Cannot get type hints for %s: %s (using field names)", cls.__name__, e)
77
+ hints = {}
78
+
79
+ for f in fields(cls):
80
+ name = f.name
81
+ if name.startswith("_"):
82
+ continue
83
+
84
+ path = f"{prefix}{name}" if prefix else name
85
+ paths.add(path)
86
+
87
+ field_type = hints.get(name, f.type)
88
+ actual_type = unwrap_optional(field_type)
89
+
90
+ if is_dataclass(actual_type):
91
+ _collect_paths(actual_type, f"{path}.", paths)
92
+
93
+
94
+ def expand_wildcard(pattern: str, valid_paths: set[str] | None = None) -> list[str]:
95
+ """Expand a wildcard pattern to matching valid paths.
96
+
97
+ Only supports <top_level_field>.* patterns (single-segment wildcard).
98
+ More complex patterns like *.tags or proxy.*.foo are not supported in v1.
99
+
100
+ Args:
101
+ pattern: Wildcard pattern (e.g., "proxy.*", "memory.*")
102
+ valid_paths: Set of valid paths (defaults to get_valid_intent_paths())
103
+
104
+ Returns:
105
+ List of concrete paths matching the pattern.
106
+
107
+ Raises:
108
+ InvalidOverrideKeyError: If pattern matches nothing or is unsupported.
109
+ """
110
+ if valid_paths is None:
111
+ valid_paths = get_valid_intent_paths()
112
+
113
+ if "*" not in pattern:
114
+ raise InvalidOverrideKeyError(pattern, "not a wildcard pattern")
115
+
116
+ parts = pattern.split(".")
117
+ if len(parts) != 2 or parts[1] != "*":
118
+ raise InvalidOverrideKeyError(
119
+ pattern,
120
+ "unsupported wildcard format",
121
+ hint="only <top_level>.* patterns supported (e.g., proxy.*, memory.*)",
122
+ )
123
+
124
+ prefix = parts[0]
125
+
126
+ if prefix == "custom":
127
+ raise InvalidOverrideKeyError(pattern, "custom.* is not supported")
128
+
129
+ matching = [p for p in valid_paths if p.startswith(f"{prefix}.")]
130
+
131
+ if not matching:
132
+ if prefix not in valid_paths:
133
+ raise InvalidOverrideKeyError(
134
+ pattern,
135
+ f"unknown field '{prefix}'",
136
+ hint=f"valid top-level fields: {', '.join(sorted(p for p in valid_paths if '.' not in p))}",
137
+ )
138
+ raise InvalidOverrideKeyError(
139
+ pattern,
140
+ f"'{prefix}' has no nested fields to expand",
141
+ )
142
+
143
+ return sorted(matching)
144
+
145
+
146
+ def validate_key(key: str) -> list[str]:
147
+ """Validate a dot-notation key and return path segments.
148
+
149
+ This performs strict validation against the SessionIntent schema:
150
+ - Rejects empty key or empty segments
151
+ - Rejects intent.* prefix (keys are relative to intent)
152
+ - Rejects confirmed.* (immutable)
153
+ - Rejects top-level manifest fields
154
+ - For other keys: validates against known SessionIntent paths
155
+
156
+ Args:
157
+ key: Dot-notation path (e.g., "agent", "proxy.template")
158
+
159
+ Returns:
160
+ List of path segments (e.g., ["proxy", "template"]).
161
+
162
+ Raises:
163
+ InvalidOverrideKeyError: If key is invalid.
164
+ """
165
+ if not key:
166
+ raise InvalidOverrideKeyError(key, "key cannot be empty")
167
+
168
+ parts = key.split(".")
169
+
170
+ # Empty segments (e.g., "foo..bar" or ".foo" or "foo.")
171
+ for part in parts:
172
+ if not part:
173
+ raise InvalidOverrideKeyError(key, "empty segment in path")
174
+
175
+ first_part = parts[0]
176
+
177
+ if first_part == "intent":
178
+ raise InvalidOverrideKeyError(
179
+ key,
180
+ "keys should be relative to intent",
181
+ hint="use 'agent' not 'intent.agent'",
182
+ )
183
+
184
+ if first_part == "confirmed":
185
+ raise InvalidOverrideKeyError(
186
+ key,
187
+ "cannot override confirmed.* fields",
188
+ hint="confirmed values are set by hooks and immutable",
189
+ )
190
+
191
+ if first_part in _MANIFEST_FIELDS:
192
+ raise InvalidOverrideKeyError(
193
+ key,
194
+ f"'{first_part}' is a manifest field, not an intent field",
195
+ hint="overrides apply to intent configuration only",
196
+ )
197
+
198
+ if first_part == "custom":
199
+ raise InvalidOverrideKeyError(key, "custom.* is not supported")
200
+
201
+ if "*" in key:
202
+ # Wildcards are handled separately by expand_wildcard
203
+ # validate_key should not receive wildcard keys directly
204
+ raise InvalidOverrideKeyError(
205
+ key,
206
+ "use expand_wildcard() for wildcard patterns",
207
+ )
208
+
209
+ valid_paths = get_valid_intent_paths()
210
+
211
+ if key in valid_paths:
212
+ return parts
213
+
214
+ # Check if it's a valid prefix (for nested access)
215
+ # e.g., "proxy" is valid even though "proxy.template" is what you'd usually set
216
+ if any(p.startswith(f"{key}.") for p in valid_paths):
217
+ return parts
218
+
219
+ similar = _find_similar_paths(key, valid_paths)
220
+ hint = None
221
+ if similar:
222
+ hint = f"did you mean: {', '.join(similar[:3])}"
223
+ else:
224
+ top_level = sorted(p for p in valid_paths if "." not in p)
225
+ hint = f"valid top-level fields: {', '.join(top_level)}"
226
+
227
+ raise InvalidOverrideKeyError(key, f"unknown field '{key}'", hint=hint)
228
+
229
+
230
+ def _find_similar_paths(key: str, valid_paths: set[str]) -> list[str]:
231
+ """Find paths similar to the given key (simple substring matching)."""
232
+ key_lower = key.lower()
233
+ similar = []
234
+ for path in valid_paths:
235
+ if key_lower in path.lower() or path.lower() in key_lower:
236
+ similar.append(path)
237
+ return sorted(similar)
238
+
239
+
240
+ def parse_value(value: str) -> Any:
241
+ """Parse a value string as JSON-first, fallback to string.
242
+
243
+ JSON-first parsing:
244
+ - "true" -> bool True
245
+ - "false" -> bool False
246
+ - "null" -> None
247
+ - "123" -> int 123
248
+ - "3.14" -> float 3.14
249
+ - '["a","b"]' -> list
250
+ - '{"key": "value"}' -> dict
251
+ - Fallback: stored as string
252
+
253
+ To force a string value, use JSON string syntax: '"true"' -> "true"
254
+
255
+ Args:
256
+ value: The value string from CLI input.
257
+
258
+ Returns:
259
+ The parsed value (could be any JSON type or string).
260
+ """
261
+ try:
262
+ return json.loads(value)
263
+ except json.JSONDecodeError:
264
+ # Fallback to string
265
+ return value
266
+
267
+
268
+ def set_override(overrides: dict[str, Any], key: str, value: Any) -> None:
269
+ """Set an override value at the given key path.
270
+
271
+ Creates intermediate dicts as needed for nested paths.
272
+ If key contains a wildcard, expands and sets each matching path.
273
+
274
+ Args:
275
+ overrides: The overrides dict to modify (mutated in place).
276
+ key: Dot-notation path or wildcard pattern (e.g., "agent", "proxy.*").
277
+ value: The value to set.
278
+ """
279
+ if "*" in key:
280
+ expanded = expand_wildcard(key)
281
+ for path in expanded:
282
+ _set_path(overrides, path.split("."), value)
283
+ return
284
+
285
+ parts = validate_key(key)
286
+ _set_path(overrides, parts, value)
287
+
288
+
289
+ def _set_path(d: dict[str, Any], parts: list[str], value: Any) -> None:
290
+ """Set a value at the given path, creating intermediate dicts."""
291
+ current = d
292
+ for part in parts[:-1]:
293
+ if part not in current:
294
+ current[part] = {}
295
+ elif not isinstance(current[part], dict):
296
+ # Overwrite non-dict intermediate
297
+ current[part] = {}
298
+ current = current[part]
299
+
300
+ current[parts[-1]] = value
301
+
302
+
303
+ def delete_override(overrides: dict[str, Any], key: str) -> bool:
304
+ """Delete an override at the given key path.
305
+
306
+ If key contains a wildcard, expands and deletes each matching path.
307
+
308
+ Args:
309
+ overrides: The overrides dict to modify (mutated in place).
310
+ key: Dot-notation path or wildcard pattern.
311
+
312
+ Returns:
313
+ True if any key was deleted, False if nothing existed to delete.
314
+ """
315
+ if "*" in key:
316
+ expanded = expand_wildcard(key)
317
+ any_deleted = False
318
+ for path in expanded:
319
+ if _delete_path(overrides, path.split(".")):
320
+ any_deleted = True
321
+ return any_deleted
322
+
323
+ # Validate key (allows us to catch invalid paths even on delete)
324
+ parts = validate_key(key)
325
+ return _delete_path(overrides, parts)
326
+
327
+
328
+ def _delete_path(d: dict[str, Any], parts: list[str]) -> bool:
329
+ """Delete a value at the given path. Returns True if deleted."""
330
+ current = d
331
+ for part in parts[:-1]:
332
+ if part not in current or not isinstance(current[part], dict):
333
+ return False
334
+ current = current[part]
335
+
336
+ if parts[-1] in current:
337
+ del current[parts[-1]]
338
+ return True
339
+ return False
340
+
341
+
342
+ def clear_overrides(overrides: dict[str, Any]) -> None:
343
+ """Clear all overrides."""
344
+ overrides.clear()
@@ -0,0 +1,286 @@
1
+ """Resolve plan info for a derived session, with one-level parent fallback.
2
+
3
+ v1 walks only the immediate parent. Extend via ``derivation.lineage`` with a
4
+ loop guard if deeper walks become necessary.
5
+
6
+ Parent sources (in order of preference):
7
+
8
+ 1. ``confirmed.derivation`` — set by ``forge session resume`` and
9
+ ``forge session fork`` (carries ``parent_forge_root`` for cross-project
10
+ pointers).
11
+ 2. ``state.parent_session`` top-level — legacy fallback for older fork
12
+ manifests that predate fork derivation metadata.
13
+
14
+ When only the top-level field is present, the parent's ``forge_root`` is looked
15
+ up via ``IndexStore``. Same-dir forks can still fall back to the caller's
16
+ ``current_forge_root`` when the parent manifest is physically present there.
17
+
18
+ Authority rule: approved plan snapshots (``confirmed.artifacts["plans"]``) are
19
+ preferred over ``latest_plan_path`` drafts — same ordering as
20
+ ``forge.session.handoff._resolve_plan_content``. Callers should render the
21
+ snapshot path when present and fall through to the draft only if absent.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Literal
29
+
30
+ from .exceptions import (
31
+ ManifestCorruptedError,
32
+ ManifestValidationError,
33
+ SessionFileNotFoundError,
34
+ )
35
+ from .models import SessionState
36
+ from .store import SessionStore
37
+
38
+ _log = logging.getLogger(__name__)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class PlanInfo:
43
+ """What plan info applies to a session, and where it came from.
44
+
45
+ ``draft_path`` is Claude-launch-root-relative (nested projects launch from
46
+ ``forge_root``; root-level worktrees launch from ``worktree.path``).
47
+ ``snapshot_path`` entries in ``approved_snapshots`` are forge-root-relative
48
+ (see artifacts.py:7-11). The ``parent_*_root`` fields carry the roots that
49
+ make those paths resolvable for inherited plans.
50
+ """
51
+
52
+ draft_path: str | None = None
53
+ approved_snapshots: list[dict[str, Any]] = field(default_factory=list)
54
+ source: Literal["self", "parent"] | None = None
55
+ parent_session: str | None = None
56
+ parent_forge_root: str | None = None
57
+ parent_launch_root: str | None = None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class DisplayedPath:
62
+ """Absolute plan path plus on-disk existence status, for user-facing display."""
63
+
64
+ path: str
65
+ exists: bool
66
+
67
+
68
+ def resolve_plan_info(state: SessionState, *, current_forge_root: str) -> PlanInfo:
69
+ """Return plan info for the given session, falling back to the immediate parent."""
70
+
71
+ confirmed = state.confirmed
72
+ self_snapshots = _plan_snapshots(confirmed.artifacts)
73
+
74
+ if confirmed.latest_plan_path or self_snapshots:
75
+ return PlanInfo(
76
+ draft_path=confirmed.latest_plan_path,
77
+ approved_snapshots=self_snapshots,
78
+ source="self",
79
+ )
80
+
81
+ parent_name, parent_fr = _resolve_parent_pointer(state, current_forge_root)
82
+ if parent_name is None or parent_fr is None:
83
+ return PlanInfo()
84
+
85
+ try:
86
+ parent_state = SessionStore(parent_fr, parent_name).read()
87
+ except (SessionFileNotFoundError, ManifestCorruptedError, ManifestValidationError) as exc:
88
+ _log.debug("Parent manifest unreadable for %s at %s: %s", parent_name, parent_fr, exc)
89
+ return PlanInfo()
90
+ except Exception as exc: # pragma: no cover - defense-in-depth for unexpected IO/permission
91
+ _log.debug("Unexpected error reading parent %s at %s: %s", parent_name, parent_fr, exc)
92
+ return PlanInfo()
93
+
94
+ parent_confirmed = parent_state.confirmed
95
+ parent_snapshots = _plan_snapshots(parent_confirmed.artifacts)
96
+ if not parent_confirmed.latest_plan_path and not parent_snapshots:
97
+ return PlanInfo()
98
+
99
+ return PlanInfo(
100
+ draft_path=parent_confirmed.latest_plan_path,
101
+ approved_snapshots=parent_snapshots,
102
+ source="parent",
103
+ parent_session=parent_name,
104
+ parent_forge_root=parent_fr,
105
+ parent_launch_root=resolve_plan_launch_root(parent_state),
106
+ )
107
+
108
+
109
+ def latest_snapshot_path(snapshots: list[dict[str, Any]]) -> str | None:
110
+ """Return the `snapshot_path` of the last approved snapshot, or None."""
111
+ if not snapshots:
112
+ return None
113
+ last = snapshots[-1]
114
+ path = last.get("snapshot_path")
115
+ return path if isinstance(path, str) else None
116
+
117
+
118
+ def preferred_plan_path(info: PlanInfo) -> str | None:
119
+ """Return the best plan path to show the user (approved snapshot > draft)."""
120
+ return latest_snapshot_path(info.approved_snapshots) or info.draft_path
121
+
122
+
123
+ def resolve_displayed_plan_path(
124
+ info: PlanInfo,
125
+ *,
126
+ current_forge_root: str,
127
+ current_launch_root: str | None = None,
128
+ current_worktree: str | None = None,
129
+ ) -> DisplayedPath | None:
130
+ """Resolve an absolute on-disk path for the preferred plan path, with existence check.
131
+
132
+ Returns ``None`` when no plan path is recorded. Otherwise returns the
133
+ absolute path (or the raw relative string if no resolution base is
134
+ available) and whether the file exists on disk.
135
+ """
136
+
137
+ snap_rel = latest_snapshot_path(info.approved_snapshots)
138
+ if snap_rel is not None:
139
+ # Snapshot is forge-root-relative (artifacts.py:7-11).
140
+ base = info.parent_forge_root if info.source == "parent" else current_forge_root
141
+ return _resolve_against(snap_rel, base)
142
+
143
+ if info.draft_path:
144
+ # Draft is Claude-launch-root-relative. Keep ``current_worktree`` as a
145
+ # backward-compatible fallback for older callers/tests.
146
+ launch_root = current_launch_root or current_worktree
147
+ base = info.parent_launch_root if info.source == "parent" else launch_root
148
+ return _resolve_against(info.draft_path, base)
149
+
150
+ return None
151
+
152
+
153
+ def resolve_plan_launch_root(state: SessionState) -> str | None:
154
+ """Return the root against which ``latest_plan_path`` should be resolved."""
155
+ if state.confirmed.claude_project_root:
156
+ return state.confirmed.claude_project_root
157
+
158
+ if not state.worktree and not state.forge_root:
159
+ return None
160
+
161
+ try:
162
+ from .claude.paths import resolve_claude_project_root
163
+
164
+ return resolve_claude_project_root(state)
165
+ except Exception: # pragma: no cover - defensive fallback for malformed state
166
+ return state.forge_root or (state.worktree.path if state.worktree else None)
167
+
168
+
169
+ def resolve_path_against(rel_or_abs: str, base: str | None) -> DisplayedPath:
170
+ """Join ``rel_or_abs`` against ``base`` unless it's already absolute; probe existence."""
171
+ from pathlib import Path
172
+
173
+ candidate = Path(rel_or_abs).expanduser()
174
+ if not candidate.is_absolute():
175
+ if base is None:
176
+ # No root to resolve against — return the bare string. Existence undecidable.
177
+ return DisplayedPath(path=rel_or_abs, exists=False)
178
+ candidate = Path(base) / candidate
179
+ try:
180
+ resolved = candidate.resolve()
181
+ except OSError:
182
+ return DisplayedPath(path=str(candidate), exists=False)
183
+ return DisplayedPath(path=str(resolved), exists=resolved.is_file())
184
+
185
+
186
+ # Internal alias retained for backward-compat within the module.
187
+ _resolve_against = resolve_path_against
188
+
189
+
190
+ def _resolve_parent_pointer(state: SessionState, current_forge_root: str) -> tuple[str | None, str | None]:
191
+ """Find the (parent_name, parent_forge_root) tuple from whichever field is set."""
192
+ derivation = state.confirmed.derivation
193
+ if derivation is not None and derivation.parent_session:
194
+ return derivation.parent_session, (derivation.parent_forge_root or current_forge_root)
195
+
196
+ if state.parent_session:
197
+ name = state.parent_session
198
+ resolved_root = _lookup_parent_forge_root(state, name, current_forge_root)
199
+ if resolved_root is not None:
200
+ return name, resolved_root
201
+
202
+ # Same-dir forks are allowed to fall back to the child's forge_root only
203
+ # when the parent manifest is actually present there.
204
+ try:
205
+ if SessionStore(current_forge_root, name).exists():
206
+ return name, current_forge_root
207
+ except Exception: # pragma: no cover - invalid path / unexpected FS failure
208
+ pass
209
+
210
+ _log.debug(
211
+ "Parent session %s referenced by %s could not be resolved from index or current forge_root %s",
212
+ name,
213
+ state.name,
214
+ current_forge_root,
215
+ )
216
+ return None, None
217
+
218
+ return None, None
219
+
220
+
221
+ def _lookup_parent_forge_root(
222
+ state: SessionState,
223
+ parent_name: str,
224
+ current_forge_root: str,
225
+ ) -> str | None:
226
+ """Resolve the parent's forge_root, scoped by the child's ``project_root``.
227
+
228
+ Forks never cross logical repos (design.md §3), so siblings in the same
229
+ ``project_root`` are the correct search space. Unscoped lookups raise
230
+ ``AmbiguousSessionError`` when the same session name exists in multiple
231
+ Forge projects, which silently falls back to ``current_forge_root`` — the
232
+ wrong answer for ``--worktree`` / ``--into`` forks.
233
+ """
234
+ try:
235
+ from .index import IndexStore
236
+
237
+ store = IndexStore()
238
+ child_entry = _child_index_entry(store, state, current_forge_root)
239
+ if child_entry is None or not child_entry.project_root:
240
+ return None
241
+
242
+ siblings = [
243
+ entry
244
+ for name, entry in store.list_sessions(
245
+ include_incognito=True,
246
+ project_root_filter=child_entry.project_root,
247
+ )
248
+ if name == parent_name
249
+ ]
250
+
251
+ # Distinguish sibling Forge projects within the same logical repo by
252
+ # preserving relative_path across worktree forks.
253
+ child_relative_path = child_entry.relative_path or "."
254
+ matching_relative_path = [entry for entry in siblings if (entry.relative_path or ".") == child_relative_path]
255
+ if len(matching_relative_path) == 1:
256
+ entry = matching_relative_path[0]
257
+ return entry.forge_root or entry.worktree_path
258
+
259
+ if len(siblings) == 1:
260
+ # If there is only one same-name session left in the logical repo,
261
+ # prefer it even when relative_path metadata doesn't line up exactly.
262
+ entry = siblings[0]
263
+ return entry.forge_root or entry.worktree_path
264
+
265
+ return None
266
+ except Exception as exc:
267
+ _log.debug("Scoped parent forge_root lookup failed for %s: %s", parent_name, exc)
268
+ return None
269
+
270
+
271
+ def _child_index_entry(store: Any, state: SessionState, current_forge_root: str) -> Any:
272
+ """Best-effort lookup of the child's own session index entry."""
273
+ scope = state.forge_root or current_forge_root
274
+ try:
275
+ return store.get_session(state.name, forge_root=scope)
276
+ except Exception as exc:
277
+ _log.debug("Child index lookup failed for %s: %s", state.name, exc)
278
+ return None
279
+
280
+
281
+ def _plan_snapshots(artifacts: dict[str, Any]) -> list[dict[str, Any]]:
282
+ """Extract approved plan snapshots from the untyped artifacts dict."""
283
+ raw = artifacts.get("plans")
284
+ if not isinstance(raw, list):
285
+ return []
286
+ return [entry for entry in raw if isinstance(entry, dict) and entry.get("kind") == "approved"]