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,544 @@
1
+ """Handoff agent for automatic memory doc updates.
2
+
3
+ The handoff agent runs after session stop (via work queue) to update
4
+ designated project memory documents. It spawns ``claude -p`` as a headless
5
+ subprocess that reads the session transcript and writes updates to
6
+ configured designated docs.
7
+
8
+ Note: this is the memory-doc maintenance agent, not the resume-context
9
+ generator. The resume handoff (parent->child context for ``forge session
10
+ resume --fresh``) is in ``handoff.py``. Despite the shared name they are
11
+ different concepts; see ``docs/end-user/handoff.md`` for the user-facing
12
+ distinction.
13
+
14
+ Supports two modes:
15
+ - **Direct update (Mode 1)**: Agent edits designated docs in-place.
16
+ - **Shadow/propose (Mode 2)**: Agent writes suggestions to a shadow file
17
+ for human review, reading the official doc first for comparison.
18
+
19
+ Each run persists its stdout to
20
+ ``<forge_root>/.forge/artifacts/<session>/handoff/review-<timestamp>.md`` so
21
+ users can inspect proposed/applied changes -- surfaced via
22
+ ``forge session handoff show``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import re
29
+ from pathlib import Path
30
+
31
+ from forge.core.reactive.routing import resolve_subprocess_routing
32
+ from forge.core.reactive.session_runner import run_claude_session
33
+ from forge.core.transcript import parse_jsonl_transcript
34
+ from forge.session.claude.invoke import is_claude_available
35
+ from forge.session.models import DesignatedDoc, HandoffConfig
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _default_timeout() -> int:
41
+ from forge.runtime_config import get_runtime_config
42
+
43
+ return get_runtime_config().handoff_timeout
44
+
45
+
46
+ # Per-doc strategy instructions.
47
+ # Mode 1 (direct update): strictly additive — no removals/rewrites.
48
+ # Mode 2 (suggested): self-prunes merged items from shadow file.
49
+ DOC_STRATEGIES: dict[str, str] = {
50
+ "project-state": (
51
+ "Update current focus, active work, recent decisions, and handoff notes. "
52
+ "Mark completed items as done rather than removing them. "
53
+ "If the file does not exist, skip it and report that it was missing."
54
+ ),
55
+ "checklist": (
56
+ "Mark completed tasks with [x]. Add newly discovered tasks. "
57
+ "Do NOT remove, rewrite, or restructure existing entries. "
58
+ "If the file does not exist, skip it and report that it was missing."
59
+ ),
60
+ "changelog": (
61
+ "Add accomplishments from this session not already recorded. "
62
+ "Follow the existing entry format. "
63
+ "Do NOT modify or remove existing entries. "
64
+ "If the file does not exist, skip it and report that it was missing."
65
+ ),
66
+ "debugging": (
67
+ "Record error causes, solutions, and workarounds encountered in this session. "
68
+ "Group entries by topic (build errors, runtime errors, test failures, etc.). "
69
+ "Do NOT duplicate entries that are already documented. "
70
+ "If the file does not exist, skip it and report that it was missing."
71
+ ),
72
+ "patterns": (
73
+ "Record architecture patterns, conventions, and recurring techniques observed "
74
+ "in this session. Include code idioms, design patterns, and naming conventions. "
75
+ "Do NOT duplicate patterns that are already documented. "
76
+ "If the file does not exist, skip it and report that it was missing."
77
+ ),
78
+ "suggested": (
79
+ "Propose additions to the official document as `- [ ]` checkboxes, each with "
80
+ "a brief rationale. Remove any checkboxes whose content has already been merged "
81
+ "into the official document (self-prune). "
82
+ "Do NOT duplicate suggestions that are already present in either file."
83
+ ),
84
+ "generic": (
85
+ "Read the file and add any NEW information from this session that is missing. "
86
+ "Do NOT duplicate, rephrase, or remove what is already documented. "
87
+ "If the file does not exist, skip it and report that it was missing."
88
+ ),
89
+ }
90
+
91
+ MULTI_DOC_PROMPT_TEMPLATE = """\
92
+ You are a project documentation agent. Your job is to update project documents \
93
+ based on a completed Claude Code session.
94
+
95
+ ## Session Information
96
+ - Session name: {session_name}
97
+ - Transcript: {transcript_path}
98
+
99
+ ## Instructions
100
+ 1. Read the session transcript at `{transcript_path}`
101
+ 2. For EACH file listed below, read the existing content first
102
+ 3. {action_instruction}
103
+
104
+ IMPORTANT: Read each file BEFORE modifying it.
105
+ Only make the minimal edits described in each file's instructions below.
106
+ Do not duplicate, rephrase, or remove content beyond what the per-file instructions specify.
107
+ If everything is already documented for a file, skip it entirely.
108
+
109
+ ## Files to Update
110
+ {file_sections}
111
+ """
112
+
113
+ MULTI_DOC_AUGMENT_INSTRUCTION = "Apply the specified updates to each file"
114
+ MULTI_DOC_REVIEW_INSTRUCTION = "Print to stdout what changes you would make to each file. Do NOT modify any files."
115
+
116
+
117
+ def build_multi_doc_prompt(
118
+ *,
119
+ session_name: str,
120
+ transcript_path: str,
121
+ mode: str = "augment",
122
+ designated_docs: list[DesignatedDoc],
123
+ ) -> str:
124
+ """Build a multi-doc prompt for the handoff agent.
125
+
126
+ Generates a single prompt that instructs ``claude -p`` to update
127
+ multiple designated documents with per-doc strategies. For shadow docs
128
+ (``doc.shadows`` is set), the prompt instructs reading the official
129
+ document first before proposing changes.
130
+
131
+ Args:
132
+ session_name: The Forge session name.
133
+ transcript_path: Absolute path to the transcript artifact.
134
+ mode: "augment" (write updates) or "review-only" (print suggestions).
135
+ designated_docs: List of DesignatedDoc entries to update.
136
+
137
+ Returns:
138
+ The complete prompt string.
139
+ """
140
+ action_instruction = MULTI_DOC_AUGMENT_INSTRUCTION if mode == "augment" else MULTI_DOC_REVIEW_INSTRUCTION
141
+
142
+ sections: list[str] = []
143
+ for doc in designated_docs:
144
+ instructions = DOC_STRATEGIES.get(doc.strategy, DOC_STRATEGIES["generic"])
145
+
146
+ if doc.shadows:
147
+ # Shadow/propose mode (Mode 2): read official doc first, then propose
148
+ section = (
149
+ f"### `{doc.path}` (proposes changes to `{doc.shadows}`)\n"
150
+ f"1. Read the OFFICIAL document at `{doc.shadows}` first.\n"
151
+ f"2. Read this shadow document at `{doc.path}` (if it exists).\n"
152
+ f"3. {instructions}"
153
+ )
154
+ else:
155
+ # Direct update mode (Mode 1)
156
+ section = f"### `{doc.path}`\n{instructions}"
157
+
158
+ sections.append(section)
159
+
160
+ file_sections = "\n\n".join(sections)
161
+
162
+ return MULTI_DOC_PROMPT_TEMPLATE.format(
163
+ session_name=session_name,
164
+ transcript_path=transcript_path,
165
+ action_instruction=action_instruction,
166
+ file_sections=file_sections,
167
+ )
168
+
169
+
170
+ def count_conversation_turns(transcript_path: Path) -> int:
171
+ """Count user-initiated conversation turns in a transcript JSONL file.
172
+
173
+ For newer format (requestId + message.role): counts unique requestId groups
174
+ that contain at least one user message.
175
+ For older format (type field): counts entries with type 'human'.
176
+
177
+ Args:
178
+ transcript_path: Path to the JSONL transcript file.
179
+
180
+ Returns:
181
+ Number of conversation turns. 0 if file is missing or empty.
182
+ """
183
+ entries = parse_jsonl_transcript(transcript_path)
184
+ if not entries:
185
+ return 0
186
+
187
+ has_request_ids = any(e.get("requestId") for e in entries)
188
+
189
+ if has_request_ids:
190
+ user_request_ids: set[str] = set()
191
+ for entry in entries:
192
+ request_id = entry.get("requestId", "")
193
+ if not request_id:
194
+ continue
195
+ message = entry.get("message", {})
196
+ if isinstance(message, dict) and message.get("role") == "user":
197
+ user_request_ids.add(request_id)
198
+ return len(user_request_ids)
199
+
200
+ return sum(1 for e in entries if e.get("type") == "human")
201
+
202
+
203
+ def resolve_handoff_base_url(
204
+ proxy_id: str | None,
205
+ confirmed_proxy_base_url: str | None = None,
206
+ env_base_url: str | None = None,
207
+ *,
208
+ direct: bool = False,
209
+ subprocess_proxy: str | None = None,
210
+ ) -> str | None:
211
+ """Resolve ANTHROPIC_BASE_URL for the handoff agent.
212
+
213
+ When direct=True, short-circuits the entire chain and returns None
214
+ (forces direct Anthropic routing regardless of session proxy).
215
+
216
+ Delegates to ``resolve_subprocess_routing()`` with fail-open semantics.
217
+ The handoff's proxy_id is soft (preferred, not strict) because handoff
218
+ is async/best-effort — using the session's confirmed proxy is better
219
+ than failing.
220
+
221
+ Priority chain (when not direct):
222
+ 1. proxy_id -> preferred_proxy (handoff config, soft)
223
+ 2. subprocess_proxy -> persisted session subprocess proxy (soft)
224
+ 3. confirmed_proxy_base_url -> session's confirmed proxy
225
+ 4. env_base_url -> current ANTHROPIC_BASE_URL
226
+ 5. None -> Anthropic direct
227
+
228
+ Args:
229
+ proxy_id: Optional proxy from HandoffConfig. Soft: falls through
230
+ on miss (unlike workflow's strict --proxy).
231
+ confirmed_proxy_base_url: Base URL from session's confirmed proxy.
232
+ env_base_url: Fallback base URL from environment.
233
+ direct: When True, force direct routing (skip all proxy resolution).
234
+ subprocess_proxy: Session-level subprocess proxy intent.
235
+
236
+ Returns:
237
+ base_url string or None.
238
+ """
239
+ if direct:
240
+ return None
241
+
242
+ for candidate in (proxy_id, subprocess_proxy):
243
+ if not candidate:
244
+ continue
245
+ result = resolve_subprocess_routing(
246
+ preferred_proxy=candidate,
247
+ require_route=False,
248
+ use_environment=False,
249
+ )
250
+
251
+ if result.base_url:
252
+ return result.base_url
253
+
254
+ return confirmed_proxy_base_url or env_base_url
255
+
256
+
257
+ # Paths with these characters are rejected to prevent prompt injection when
258
+ # interpolated into markdown headings (e.g., backticks break ```...``` blocks,
259
+ # newlines inject arbitrary prompt lines, control chars corrupt structure).
260
+ _UNSAFE_PATH_RE = re.compile(r"[`\x00-\x1f\x7f]")
261
+
262
+
263
+ def is_safe_designated_doc_path(path: str, base: Path, resolved_base: Path) -> str | None:
264
+ """Check a single path for safety. Return rejection reason or None if safe."""
265
+ if Path(path).is_absolute():
266
+ return f"absolute path: {path}"
267
+ if _UNSAFE_PATH_RE.search(path):
268
+ return f"unsafe characters: {path!r}"
269
+ abs_path = (base / path).resolve()
270
+ if not abs_path.is_relative_to(resolved_base):
271
+ return f"escapes base directory: {path}"
272
+ return None
273
+
274
+
275
+ _PERMISSION_DENIED_PATTERNS = [
276
+ re.compile(r"(?:need|require|don.t have).{0,30}(?:write|edit|permission)", re.IGNORECASE),
277
+ re.compile(r"(?:not|isn.t|aren.t).{0,20}(?:allowed|permitted).{0,20}(?:write|edit|modify)", re.IGNORECASE),
278
+ re.compile(r"cannot (?:write|edit|modify) files", re.IGNORECASE),
279
+ ]
280
+
281
+
282
+ def _stdout_indicates_permission_denied(stdout: str) -> bool:
283
+ """Detect permission-denied responses where Claude exits 0 but couldn't write."""
284
+ if not stdout:
285
+ return False
286
+ # Only check the first ~2000 chars — permission messages appear early
287
+ sample = stdout[:2000]
288
+ return any(p.search(sample) for p in _PERMISSION_DENIED_PATTERNS)
289
+
290
+
291
+ def _validate_designated_docs(
292
+ designated_docs: list[DesignatedDoc],
293
+ forge_root: Path,
294
+ ) -> list[DesignatedDoc]:
295
+ """Validate and filter designated docs.
296
+
297
+ Guards (per doc):
298
+ 1. Path safety: reject absolute, unsafe chars, traversal
299
+ (applied to both ``path`` and ``shadows``).
300
+ 2. Strategy consistency: ``suggested`` requires ``shadows``;
301
+ ``shadows`` requires ``suggested``.
302
+
303
+ Args:
304
+ designated_docs: List of docs to validate.
305
+ forge_root: Resolved worktree directory (base for path resolution).
306
+
307
+ Returns:
308
+ Filtered list containing only valid docs.
309
+ """
310
+ valid: list[DesignatedDoc] = []
311
+ resolved_base = forge_root.resolve()
312
+ for doc in designated_docs:
313
+ reason = is_safe_designated_doc_path(doc.path, forge_root, resolved_base)
314
+ if reason:
315
+ logger.warning("Skipping designated_doc (%s): %s", doc.path, reason)
316
+ continue
317
+
318
+ if doc.shadows is not None:
319
+ reason = is_safe_designated_doc_path(doc.shadows, forge_root, resolved_base)
320
+ if reason:
321
+ logger.warning("Skipping designated_doc shadows (%s): %s", doc.shadows, reason)
322
+ continue
323
+
324
+ # Strategy consistency: suggested ↔ shadows (non-empty)
325
+ if doc.strategy == "suggested" and not doc.shadows:
326
+ logger.warning(
327
+ "Skipping designated_doc %s: strategy 'suggested' requires non-empty 'shadows'",
328
+ doc.path,
329
+ )
330
+ continue
331
+ if doc.shadows is not None and doc.strategy != "suggested":
332
+ logger.warning(
333
+ "Skipping designated_doc %s: 'shadows' requires strategy 'suggested' " "(got %r)",
334
+ doc.path,
335
+ doc.strategy,
336
+ )
337
+ continue
338
+ if doc.shadows and doc.path == doc.shadows:
339
+ logger.warning(
340
+ "Skipping designated_doc %s: 'path' and 'shadows' must differ",
341
+ doc.path,
342
+ )
343
+ continue
344
+
345
+ valid.append(doc)
346
+ return valid
347
+
348
+
349
+ def run_handoff_agent(
350
+ *,
351
+ session_name: str,
352
+ forge_root: Path,
353
+ transcript_snapshot_rel: str,
354
+ config: HandoffConfig,
355
+ base_url: str | None = None,
356
+ timeout_seconds: int | None = None,
357
+ designated_docs: list[DesignatedDoc] | None = None,
358
+ ) -> bool:
359
+ """Run the handoff agent as a ``claude -p`` subprocess.
360
+
361
+ This is the main entry point called by ``forge handoff run``.
362
+
363
+ Args:
364
+ session_name: Forge session name.
365
+ forge_root: Forge project root (where .forge/ lives). Designated doc paths
366
+ resolve against this directory. Also used as cwd for the subprocess.
367
+ transcript_snapshot_rel: Forge-root-relative path to transcript artifact.
368
+ config: HandoffConfig with mode, min_turns, proxy_id.
369
+ base_url: Resolved ANTHROPIC_BASE_URL (or None for direct).
370
+ timeout_seconds: Max seconds for the agent to run.
371
+ designated_docs: List of docs to update. If None or empty, the agent
372
+ has nothing to do and returns True (skip).
373
+
374
+ Returns:
375
+ True if agent completed successfully (or skipped), False on error.
376
+ """
377
+ project_root = forge_root
378
+
379
+ # Validate transcript path (system boundary: CLI args / marker payload)
380
+ reason = is_safe_designated_doc_path(transcript_snapshot_rel, project_root, project_root.resolve())
381
+ if reason:
382
+ logger.warning("Handoff agent: unsafe transcript path (%s)", reason)
383
+ return False
384
+ transcript_abs = (project_root / transcript_snapshot_rel).resolve()
385
+
386
+ if not transcript_abs.is_file():
387
+ logger.warning("Handoff agent: transcript not found at %s", transcript_abs)
388
+ return False
389
+
390
+ turn_count = count_conversation_turns(transcript_abs)
391
+ if turn_count < config.min_turns:
392
+ logger.info(
393
+ "Handoff skipped: session %s had %d turns (min_turns=%d)",
394
+ session_name,
395
+ turn_count,
396
+ config.min_turns,
397
+ )
398
+ return True # Not a failure — just below threshold
399
+
400
+ _VALID_MODES = {"augment", "review-only"}
401
+ if config.mode not in _VALID_MODES:
402
+ logger.warning("Handoff agent: unknown mode %r (expected %s)", config.mode, _VALID_MODES)
403
+ return False
404
+
405
+ if not is_claude_available():
406
+ logger.warning("Handoff agent: claude CLI not found in PATH")
407
+ return False
408
+
409
+ if not designated_docs:
410
+ logger.info(
411
+ "No designated_docs configured; handoff agent has nothing to update " "(session %s)",
412
+ session_name,
413
+ )
414
+ return True
415
+
416
+ safe_docs = _validate_designated_docs(designated_docs, forge_root)
417
+
418
+ # Only update files that already exist — handoff never creates new files.
419
+ ready_docs: list[DesignatedDoc] = []
420
+ for doc in safe_docs:
421
+ if not (forge_root / doc.path).is_file():
422
+ logger.info("Skipping missing file: %s", doc.path)
423
+ continue
424
+ # For shadow docs, the official doc must also exist
425
+ if doc.shadows and not (forge_root / doc.shadows).is_file():
426
+ logger.info(
427
+ "Skipping shadow doc %s: official doc %s not found",
428
+ doc.path,
429
+ doc.shadows,
430
+ )
431
+ continue
432
+ ready_docs.append(doc)
433
+
434
+ if not ready_docs:
435
+ logger.info(
436
+ "No designated_docs ready after validation/existence checks (session %s)",
437
+ session_name,
438
+ )
439
+ return True
440
+
441
+ prompt = build_multi_doc_prompt(
442
+ session_name=session_name,
443
+ transcript_path=str(transcript_abs),
444
+ mode=config.mode,
445
+ designated_docs=ready_docs,
446
+ )
447
+
448
+ logger.info(
449
+ "Running handoff agent for session %s (mode=%s, turns=%d)",
450
+ session_name,
451
+ config.mode,
452
+ turn_count,
453
+ )
454
+
455
+ # Use forge_root as cwd so designated doc paths (relative) resolve
456
+ # against the correct branch content. Transcript path is absolute.
457
+ from forge.core.reactive.cost_tracking import track_verb_cost
458
+
459
+ effective_timeout = timeout_seconds if timeout_seconds is not None else _default_timeout()
460
+ tracking_url = base_url
461
+
462
+ with track_verb_cost("handoff", [tracking_url] if tracking_url else []):
463
+ result = run_claude_session(
464
+ prompt,
465
+ base_url=base_url,
466
+ direct=config.direct,
467
+ timeout_seconds=effective_timeout,
468
+ cwd=str(forge_root),
469
+ )
470
+
471
+ if not result.success:
472
+ detail = result.error or (result.stderr[:500] if result.stderr else f"exit {result.returncode}")
473
+ logger.warning("Handoff agent for %s failed: %s", session_name, detail)
474
+ return False
475
+
476
+ # Persist the agent's stdout to a per-session review file so users can
477
+ # inspect what was proposed (review-only mode) or what was applied
478
+ # (augment mode). The work-queue spawns this command detached with
479
+ # stdout/stderr -> DEVNULL, so the file is the only visible artifact.
480
+ try:
481
+ _persist_review_report(
482
+ forge_root=forge_root,
483
+ session_name=session_name,
484
+ mode=config.mode,
485
+ turn_count=turn_count,
486
+ stdout=result.stdout,
487
+ )
488
+ except OSError as e:
489
+ # Best-effort: don't fail the agent if the review file can't be written
490
+ logger.warning("Could not persist handoff review file for %s: %s", session_name, e)
491
+
492
+ # Only check for permission denial in augment mode. review-only mode
493
+ # explicitly tells Claude "Do NOT modify any files", so a compliant
494
+ # response like "I cannot modify files" is expected, not an error.
495
+ if config.mode == "augment" and _stdout_indicates_permission_denied(result.stdout):
496
+ logger.warning(
497
+ "Handoff agent for %s: Claude lacked Write/Edit permissions — no files modified. "
498
+ "Run 'forge claude preset edit' to add Write/Edit to permissions.allow.",
499
+ session_name,
500
+ )
501
+ return False
502
+
503
+ logger.info("Handoff agent completed for session %s", session_name)
504
+ return True
505
+
506
+
507
+ def review_dir(forge_root: Path, session_name: str) -> Path:
508
+ """Return the directory where handoff agent review reports live."""
509
+ return forge_root / ".forge" / "artifacts" / session_name / "handoff"
510
+
511
+
512
+ def _persist_review_report(
513
+ *,
514
+ forge_root: Path,
515
+ session_name: str,
516
+ mode: str,
517
+ turn_count: int,
518
+ stdout: str,
519
+ ) -> Path:
520
+ """Write the agent's stdout to a timestamped review file.
521
+
522
+ Returns the absolute path of the written file. The work queue spawns the
523
+ agent detached so stdout/stderr go to DEVNULL; this file is the only way
524
+ users can inspect what the agent proposed or applied. See ``forge session
525
+ handoff show``.
526
+ """
527
+ from datetime import datetime, timezone
528
+
529
+ output_dir = review_dir(forge_root, session_name)
530
+ output_dir.mkdir(parents=True, exist_ok=True)
531
+
532
+ now = datetime.now(timezone.utc)
533
+ stamp = now.strftime("%Y%m%d-%H%M%S-%f")
534
+ target = output_dir / f"review-{stamp}.md"
535
+
536
+ header = (
537
+ f"# Handoff Agent Report -- {session_name}\n\n"
538
+ f"**Mode**: {mode}\n"
539
+ f"**Timestamp**: {now.isoformat()}\n"
540
+ f"**Turns**: {turn_count}\n\n"
541
+ "---\n\n"
542
+ )
543
+ target.write_text(header + (stdout or "_(no output)_\n"), encoding="utf-8")
544
+ return target
@@ -0,0 +1,35 @@
1
+ """Hook handlers for Claude Code integration.
2
+
3
+ This package provides handlers for Claude Code hooks, enabling Forge
4
+ to reconcile session state across /compact and /clear operations.
5
+ """
6
+
7
+ from .models import HookInput, HookResult, HookSource, ResolutionContext
8
+ from .session_start import (
9
+ ENV_FORK_NAME,
10
+ ENV_PARENT_SESSION,
11
+ ENV_SESSION,
12
+ handle_session_start,
13
+ parse_hook_input,
14
+ resolve_session_for_hook,
15
+ resolve_session_name,
16
+ resolve_session_store,
17
+ )
18
+
19
+ __all__ = [
20
+ # Models
21
+ "HookInput",
22
+ "HookResult",
23
+ "HookSource",
24
+ "ResolutionContext",
25
+ # Constants
26
+ "ENV_FORK_NAME",
27
+ "ENV_PARENT_SESSION",
28
+ "ENV_SESSION",
29
+ # Functions
30
+ "handle_session_start",
31
+ "parse_hook_input",
32
+ "resolve_session_for_hook",
33
+ "resolve_session_name",
34
+ "resolve_session_store",
35
+ ]
@@ -0,0 +1,73 @@
1
+ """Dataclasses for hook input/output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ # Valid source types from Claude Code hooks
9
+ HookSource = Literal["startup", "resume", "compact", "clear"]
10
+
11
+
12
+ @dataclass
13
+ class HookInput:
14
+ """Input from Claude Code SessionStart hook.
15
+
16
+ Claude Code invokes the hook with JSON on stdin containing these fields.
17
+ """
18
+
19
+ session_id: str # Claude's session UUID
20
+ transcript_path: str # Path to transcript JSONL file
21
+ source: HookSource # What triggered the hook
22
+
23
+
24
+ @dataclass
25
+ class HookResult:
26
+ """Result returned by the hook handler.
27
+
28
+ Always exit 0 and return JSON - don't break Claude on errors.
29
+ """
30
+
31
+ success: bool
32
+ session_name: str | None = None
33
+ message: str | None = None
34
+ error: str | None = None
35
+ # Echo input fields for debugging
36
+ received_session_id: str | None = None
37
+ received_transcript_path: str | None = None
38
+ received_source: str | None = None
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ """Convert to dict for JSON serialization, excluding None values."""
42
+ result: dict[str, Any] = {"success": self.success}
43
+ if self.session_name is not None:
44
+ result["session_name"] = self.session_name
45
+ if self.message is not None:
46
+ result["message"] = self.message
47
+ if self.error is not None:
48
+ result["error"] = self.error
49
+ if self.received_session_id is not None:
50
+ result["received_session_id"] = self.received_session_id
51
+ if self.received_transcript_path is not None:
52
+ result["received_transcript_path"] = self.received_transcript_path
53
+ if self.received_source is not None:
54
+ result["received_source"] = self.received_source
55
+ return result
56
+
57
+
58
+ @dataclass
59
+ class ResolutionContext:
60
+ """Context gathered during session name resolution.
61
+
62
+ Tracks which resolution method succeeded and any errors encountered.
63
+ """
64
+
65
+ session_name: str | None = None
66
+ forge_root: str | None = None # Resolved project scope (for scoped subsequent lookups)
67
+ resolution_method: str | None = None # "fork_env", "session_env", "env_file", "uuid_lookup"
68
+ errors: list[str] = field(default_factory=list)
69
+
70
+ @property
71
+ def resolved(self) -> bool:
72
+ """Whether a session name was successfully resolved."""
73
+ return self.session_name is not None