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
forge/core/state/io.py ADDED
@@ -0,0 +1,140 @@
1
+ """Atomic file operations for Forge state files.
2
+
3
+ All write operations use the tempfile + os.replace() pattern for atomicity.
4
+ This ensures that readers never see partial writes.
5
+
6
+ Durability policy: No fsync. We rely on the filesystem's default behavior.
7
+ This is a deliberate simplicity choice - if crash safety is needed later,
8
+ add fsync before os.replace() and optionally fsync the directory.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import tempfile
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from .exceptions import StateCorruptedError, StateNotFoundError
20
+
21
+
22
+ def open_secure_append(path: Path) -> Any:
23
+ """Open a file for append with 0600 permissions (owner read/write only).
24
+
25
+ Used for log files that may contain sensitive payloads (request bodies,
26
+ tool inputs, error messages). Creates the file with 0600 if missing;
27
+ chmods to 0600 if it already exists.
28
+
29
+ The post-open chmod has a tiny TOCTOU window for pre-existing files but
30
+ closes it on every subsequent write. New files are created with 0600
31
+ atomically (subject to umask, which only clears bits we already want clear).
32
+ """
33
+ fd = os.open(str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
34
+ try:
35
+ os.fchmod(fd, 0o600)
36
+ except OSError:
37
+ pass # best-effort: some filesystems (e.g., CIFS) may not support fchmod
38
+ return os.fdopen(fd, "a", encoding="utf-8")
39
+
40
+
41
+ def atomic_write_text(
42
+ path: Path,
43
+ content: str,
44
+ *,
45
+ create_parents: bool = True,
46
+ ) -> None:
47
+ """Write text to a file atomically.
48
+
49
+ Uses tempfile + os.replace() pattern to ensure readers never see
50
+ partial writes. The temp file is created in the same directory as
51
+ the target to ensure atomic rename works (same filesystem).
52
+
53
+ Args:
54
+ path: Target file path.
55
+ content: Text content to write.
56
+ create_parents: Create parent directories if they don't exist.
57
+
58
+ Raises:
59
+ OSError: If the write or rename fails.
60
+ """
61
+ if create_parents:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+
64
+ # Create temp file in same directory for atomic rename
65
+ fd, temp_path = tempfile.mkstemp(
66
+ dir=str(path.parent),
67
+ prefix=f".{path.stem}.",
68
+ suffix=".tmp",
69
+ )
70
+ try:
71
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
72
+ f.write(content)
73
+ os.replace(temp_path, str(path))
74
+ except Exception:
75
+ # Clean up temp file on failure
76
+ try:
77
+ os.unlink(temp_path)
78
+ except OSError:
79
+ pass
80
+ raise
81
+
82
+
83
+ def atomic_write_json(
84
+ path: Path,
85
+ data: dict[str, Any],
86
+ *,
87
+ indent: int = 2,
88
+ create_parents: bool = True,
89
+ ) -> None:
90
+ """Write JSON to a file atomically.
91
+
92
+ Serializes the dict to JSON and writes it atomically using
93
+ tempfile + os.replace(). Adds a trailing newline for git-friendliness.
94
+
95
+ Args:
96
+ path: Target file path.
97
+ data: Dict to serialize as JSON.
98
+ indent: JSON indentation level (default 2).
99
+ create_parents: Create parent directories if they don't exist.
100
+
101
+ Raises:
102
+ OSError: If the write or rename fails.
103
+ TypeError: If data contains non-serializable values.
104
+ """
105
+ content = json.dumps(data, indent=indent)
106
+ content += "\n" # Trailing newline
107
+ atomic_write_text(path, content, create_parents=create_parents)
108
+
109
+
110
+ def read_json(path: Path) -> dict[str, Any]:
111
+ """Read and parse a JSON file.
112
+
113
+ Args:
114
+ path: Path to JSON file.
115
+
116
+ Returns:
117
+ Parsed JSON as a dict.
118
+
119
+ Raises:
120
+ StateNotFoundError: If the file does not exist.
121
+ StateCorruptedError: If the file contains invalid JSON or is not a JSON object.
122
+ """
123
+ if not path.exists():
124
+ raise StateNotFoundError(str(path))
125
+
126
+ try:
127
+ with open(path, encoding="utf-8") as f:
128
+ data = json.load(f)
129
+ except json.JSONDecodeError as e:
130
+ raise StateCorruptedError(str(path), f"invalid JSON: {e}") from e
131
+ except OSError as e:
132
+ raise StateCorruptedError(str(path), f"read error: {e}") from e
133
+
134
+ if not isinstance(data, dict):
135
+ raise StateCorruptedError(
136
+ str(path),
137
+ f"expected JSON object, got {type(data).__name__}",
138
+ )
139
+
140
+ return data
@@ -0,0 +1,99 @@
1
+ """Cross-process file locking for Forge state.
2
+
3
+ Forge state files are written atomically via write-temp + os.replace.
4
+ That prevents torn reads, but it does NOT prevent concurrent *read-modify-write*
5
+ flows from overwriting each other.
6
+
7
+ This module provides a small, advisory lock primitive to serialize those RMW
8
+ operations across processes.
9
+
10
+ Design notes:
11
+ - Locks are implemented using `fcntl.flock` (macOS/Linux).
12
+ - We always lock a **separate lock file** (e.g., "index.json.lock"), not the
13
+ target file itself, because the target file inode changes on atomic replace.
14
+ - This is intended to be best-effort. Callers should choose appropriate
15
+ timeouts (hooks: short/fail-open; CLI: longer).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from contextlib import contextmanager
22
+ from pathlib import Path
23
+ from typing import Iterator
24
+
25
+ from .exceptions import StateError
26
+
27
+
28
+ class FileLockTimeoutError(StateError):
29
+ """Raised when a lock cannot be acquired within the timeout."""
30
+
31
+ def __init__(self, lock_path: Path, timeout_s: float) -> None:
32
+ self.lock_path = lock_path
33
+ self.timeout_s = timeout_s
34
+ super().__init__(f"timed out acquiring lock '{lock_path}' after {timeout_s:.3f}s")
35
+
36
+
37
+ def get_lock_path_for_target(target_path: Path) -> Path:
38
+ """Return the lock file path for a target state file.
39
+
40
+ Example:
41
+ target: /home/user/.forge/sessions/index.json
42
+ lock: /home/user/.forge/sessions/index.json.lock
43
+ """
44
+
45
+ return target_path.parent / f"{target_path.name}.lock"
46
+
47
+
48
+ @contextmanager
49
+ def file_lock(*, lock_path: Path, timeout_s: float, poll_s: float = 0.05) -> Iterator[None]:
50
+ """Acquire an exclusive advisory lock for the duration of the context.
51
+
52
+ Args:
53
+ lock_path: Path to the lock file.
54
+ timeout_s: Maximum time to wait for acquisition.
55
+ poll_s: Sleep interval between non-blocking retries.
56
+
57
+ Raises:
58
+ FileLockTimeoutError: If the lock cannot be acquired in time.
59
+ OSError: If the lock file cannot be created/opened.
60
+ """
61
+
62
+ # Local import: avoids importing fcntl on platforms where it may not exist.
63
+ import fcntl
64
+
65
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ deadline = time.monotonic() + timeout_s
68
+
69
+ # Keep the fd open for the duration of the lock.
70
+ with lock_path.open("a+", encoding="utf-8") as f:
71
+ while True:
72
+ try:
73
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
74
+ break
75
+ except BlockingIOError:
76
+ if time.monotonic() >= deadline:
77
+ raise FileLockTimeoutError(lock_path=lock_path, timeout_s=timeout_s)
78
+ time.sleep(poll_s)
79
+
80
+ try:
81
+ yield
82
+ finally:
83
+ try:
84
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
85
+ except OSError:
86
+ # Best-effort cleanup; fd close will also release.
87
+ pass
88
+
89
+
90
+ @contextmanager
91
+ def file_lock_for_target(*, target_path: Path, timeout_s: float, poll_s: float = 0.05) -> Iterator[None]:
92
+ """Convenience wrapper to lock a target file by deriving its lock path."""
93
+
94
+ with file_lock(
95
+ lock_path=get_lock_path_for_target(target_path),
96
+ timeout_s=timeout_s,
97
+ poll_s=poll_s,
98
+ ):
99
+ yield
@@ -0,0 +1,60 @@
1
+ """Timestamp utilities for Forge state files.
2
+
3
+ All timestamps are stored as ISO8601 strings for JSON compatibility.
4
+ Uses UTC exclusively for consistent timestamps across time zones.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+
11
+
12
+ def now_iso() -> str:
13
+ """Return current UTC time as ISO8601 string.
14
+
15
+ Format: '2024-01-15T10:30:00+00:00'
16
+
17
+ Returns:
18
+ ISO8601 formatted string with UTC timezone (+00:00 suffix).
19
+ """
20
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
21
+
22
+
23
+ def parse_iso(s: str) -> datetime:
24
+ """Parse ISO8601 string to timezone-aware datetime in UTC.
25
+
26
+ Handles common ISO8601 formats:
27
+ - With 'Z' suffix: '2024-01-15T10:30:00Z'
28
+ - With offset: '2024-01-15T10:30:00+00:00'
29
+
30
+ Args:
31
+ s: ISO8601 formatted string.
32
+
33
+ Returns:
34
+ Timezone-aware datetime normalized to UTC.
35
+
36
+ Raises:
37
+ ValueError: If the string is not valid ISO8601 or lacks timezone info.
38
+ """
39
+ normalized = s.replace("Z", "+00:00")
40
+ dt = datetime.fromisoformat(normalized)
41
+
42
+ if dt.tzinfo is None:
43
+ raise ValueError(f"ISO8601 string must include timezone info, got naive datetime: '{s}'")
44
+
45
+ return dt.astimezone(UTC)
46
+
47
+
48
+ def iso_to_timestamp(iso_str: str) -> float:
49
+ """Convert ISO8601 string to Unix timestamp.
50
+
51
+ Args:
52
+ iso_str: ISO8601 formatted string with timezone.
53
+
54
+ Returns:
55
+ Unix timestamp as float (seconds since epoch).
56
+
57
+ Raises:
58
+ ValueError: If the string is not valid ISO8601 or lacks timezone info.
59
+ """
60
+ return parse_iso(iso_str).timestamp()
@@ -0,0 +1,78 @@
1
+ """Shared JSONL transcript parsing primitives.
2
+
3
+ Low-level parsing of Claude Code transcript files. Used by:
4
+ - forge.search.extractor (content extraction for search indexing)
5
+ - forge.session.handoff (context assembly for session resume)
6
+
7
+ Only parsing primitives live here — extraction/summarization logic stays
8
+ in each consumer module since they produce different output formats.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def parse_jsonl_transcript(path: Path) -> list[dict[str, Any]]:
22
+ """Parse a Claude transcript JSONL file, sorted by timestamp.
23
+
24
+ Handles both formats:
25
+ - requestId/message.role (newer Claude Code)
26
+ - entry.type (older Claude Code)
27
+
28
+ Entries without "message" or "type" keys are silently skipped.
29
+ Malformed lines are skipped with a debug log.
30
+
31
+ Returns:
32
+ List of parsed entries sorted by timestamp. Empty list on read errors.
33
+ """
34
+ entries: list[dict[str, Any]] = []
35
+
36
+ if not path.is_file():
37
+ return entries
38
+
39
+ try:
40
+ with path.open(encoding="utf-8") as f:
41
+ for line_num, line in enumerate(f, 1):
42
+ line = line.strip()
43
+ if not line:
44
+ continue
45
+ try:
46
+ entry = json.loads(line)
47
+ except json.JSONDecodeError:
48
+ logger.debug("Skipping malformed JSON at line %d in %s", line_num, path)
49
+ continue
50
+
51
+ if "message" not in entry and "type" not in entry:
52
+ continue
53
+
54
+ entries.append(entry)
55
+ except Exception as e:
56
+ logger.warning("Error reading transcript %s: %s", path, e)
57
+ return []
58
+
59
+ entries.sort(key=_get_timestamp)
60
+ return entries
61
+
62
+
63
+ def _get_timestamp(entry: dict[str, Any]) -> str:
64
+ """Extract timestamp from a transcript entry for sorting.
65
+
66
+ Checks top-level "timestamp" first, then "message.timestamp".
67
+ """
68
+ ts = entry.get("timestamp", "")
69
+ if not ts and "message" in entry:
70
+ ts = entry.get("message", {}).get("timestamp", "")
71
+ return ts if isinstance(ts, str) else ""
72
+
73
+
74
+ def truncate(text: str, max_chars: int) -> str:
75
+ """Truncate text to max_chars, appending '...' if truncated."""
76
+ if len(text) <= max_chars:
77
+ return text
78
+ return text[:max_chars] + "..."
@@ -0,0 +1,24 @@
1
+ """Typing utility helpers shared across Forge modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, get_args, get_origin
6
+
7
+
8
+ def unwrap_optional(tp: Any) -> Any:
9
+ """Unwrap Optional[T] (i.e., Union[T, None]) to get T.
10
+
11
+ Returns the original type unchanged if it is not Optional.
12
+ """
13
+ origin = get_origin(tp)
14
+ if origin is None:
15
+ return tp
16
+
17
+ # Handle Union types (Optional is Union[T, None])
18
+ args = get_args(tp)
19
+ if args:
20
+ non_none = [a for a in args if a is not type(None)]
21
+ if len(non_none) == 1:
22
+ return non_none[0]
23
+
24
+ return tp
@@ -0,0 +1,67 @@
1
+ """Forge async work queue — general-purpose deferred processing primitive.
2
+
3
+ Provides a file-based queue where producers enqueue markers and CLI startup
4
+ processes them opportunistically. Markers are dispatched to handlers by kind.
5
+
6
+ Quick Start:
7
+ from forge.core.workqueue import enqueue, process_pending_work, enqueue_stop_marker
8
+
9
+ # Enqueue a generic marker
10
+ enqueue(kind="index", marker_id="session-123", payload={"path": "..."})
11
+
12
+ # Enqueue a stop marker (convenience)
13
+ enqueue_stop_marker(session_id="uuid", worktree_path=Path(...), ...)
14
+
15
+ # Process with explicit handlers
16
+ def handle_index(marker):
17
+ index_session(marker.payload["path"])
18
+
19
+ process_pending_work(handlers={"index": handle_index})
20
+ """
21
+
22
+ from .queue import (
23
+ MARKER_LOCK_TIMEOUT_S,
24
+ PROCESSOR_LOCK_TIMEOUT_S,
25
+ SAFE_MARKER_ID,
26
+ enqueue,
27
+ enqueue_handoff_marker,
28
+ enqueue_index_marker,
29
+ enqueue_stop_marker,
30
+ marker_path,
31
+ pending_work_dir,
32
+ process_pending_work,
33
+ )
34
+ from .types import (
35
+ FAILED_WORK_DIR,
36
+ MARKER_SCHEMA_VERSION,
37
+ MAX_ATTEMPTS,
38
+ MAX_ERROR_LENGTH,
39
+ PENDING_WORK_DIR,
40
+ Marker,
41
+ ProcessResult,
42
+ WorkHandler,
43
+ )
44
+
45
+ __all__ = [
46
+ # Queue operations
47
+ "enqueue",
48
+ "enqueue_handoff_marker",
49
+ "enqueue_index_marker",
50
+ "enqueue_stop_marker",
51
+ "marker_path",
52
+ "pending_work_dir",
53
+ "process_pending_work",
54
+ # Types
55
+ "Marker",
56
+ "ProcessResult",
57
+ "WorkHandler",
58
+ # Constants
59
+ "MARKER_SCHEMA_VERSION",
60
+ "MAX_ATTEMPTS",
61
+ "MAX_ERROR_LENGTH",
62
+ "PENDING_WORK_DIR",
63
+ "FAILED_WORK_DIR",
64
+ "MARKER_LOCK_TIMEOUT_S",
65
+ "PROCESSOR_LOCK_TIMEOUT_S",
66
+ "SAFE_MARKER_ID",
67
+ ]