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,552 @@
1
+ """Async work queue for deferred processing.
2
+
3
+ A general-purpose, file-based queue for work items that producers enqueue
4
+ and CLI startup processes opportunistically.
5
+
6
+ Design goals:
7
+ - Best-effort semantics: enqueue failures are non-fatal, processing is opportunistic
8
+ - Fast path: no-op when queue is empty (cheap directory scan)
9
+ - Concurrent-safe: per-marker advisory locks prevent corruption
10
+ - Exactly-once-ish: markers deleted on successful processing
11
+ - Poison marker protection: markers exceeding MAX_ATTEMPTS moved to failed/
12
+
13
+ Queue location: ~/.forge/pending-work/ (respects FORGE_HOME)
14
+
15
+ Marker schema:
16
+ {
17
+ "schema_version": 1,
18
+ "kind": "stop",
19
+ "marker_id": "uuid-123",
20
+ "forge_version": "<from forge.__version__>",
21
+ "created_at": "2026-01-07T12:00:00Z",
22
+ "payload": {
23
+ "session_id": "...",
24
+ "worktree_path": "/abs/path",
25
+ ...
26
+ },
27
+ "attempt_count": 0,
28
+ "last_attempt_at": null,
29
+ "last_error": null
30
+ }
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ import re
38
+ import shutil
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from forge import __version__
43
+ from forge.core.paths import get_forge_home
44
+ from forge.core.state import (
45
+ FileLockTimeoutError,
46
+ atomic_write_json,
47
+ file_lock_for_target,
48
+ now_iso,
49
+ read_json,
50
+ )
51
+
52
+ from .types import (
53
+ FAILED_WORK_DIR,
54
+ MARKER_SCHEMA_VERSION,
55
+ MAX_ATTEMPTS,
56
+ MAX_ERROR_LENGTH,
57
+ PENDING_WORK_DIR,
58
+ Marker,
59
+ ProcessResult,
60
+ WorkHandler,
61
+ )
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ # Lock timeouts
66
+ MARKER_LOCK_TIMEOUT_S = 0.1 # 100ms for hooks (must be fast)
67
+ PROCESSOR_LOCK_TIMEOUT_S = 0.05 # 50ms per marker during startup processing
68
+
69
+ # Regex for safe marker IDs (prevents path traversal)
70
+ # Allow alphanumeric, hyphens, underscores, dots (typical UUID/session ID chars)
71
+ SAFE_MARKER_ID = re.compile(r"^[A-Za-z0-9._-]+$")
72
+
73
+
74
+ def pending_work_dir() -> Path:
75
+ """Get the pending-work queue directory.
76
+
77
+ Returns:
78
+ Path to ~/.forge/pending-work/ (respects FORGE_HOME).
79
+ """
80
+ return get_forge_home() / PENDING_WORK_DIR
81
+
82
+
83
+ def _failed_work_dir() -> Path:
84
+ """Get the failed-work directory for poison markers.
85
+
86
+ Returns:
87
+ Path to ~/.forge/pending-work/failed/ (respects FORGE_HOME).
88
+ """
89
+ return get_forge_home() / FAILED_WORK_DIR
90
+
91
+
92
+ def marker_path(marker_id: str) -> Path:
93
+ """Get the marker file path for a marker ID.
94
+
95
+ Args:
96
+ marker_id: The marker identifier (used as filename).
97
+
98
+ Returns:
99
+ Path to <pending_work_dir>/<marker_id>.json
100
+
101
+ Raises:
102
+ ValueError: If marker_id is invalid (empty, contains path traversal chars).
103
+ """
104
+ if not marker_id or not SAFE_MARKER_ID.match(marker_id):
105
+ raise ValueError(f"Invalid marker_id: {marker_id!r}")
106
+
107
+ return pending_work_dir() / f"{marker_id}.json"
108
+
109
+
110
+ def enqueue(
111
+ *,
112
+ kind: str,
113
+ marker_id: str,
114
+ payload: dict[str, Any],
115
+ ) -> Path | None:
116
+ """Enqueue a work marker for deferred processing.
117
+
118
+ Best-effort semantics: returns marker path on success, None on failure.
119
+ Failures are logged but not raised.
120
+
121
+ Args:
122
+ kind: The marker kind (determines which handler processes it).
123
+ marker_id: The marker identifier (used as filename; must be a safe filename).
124
+ payload: Kind-specific data to include in the marker.
125
+
126
+ Returns:
127
+ Path to created marker, or None if enqueue failed.
128
+ """
129
+ try:
130
+ path = marker_path(marker_id)
131
+ except ValueError as e:
132
+ logger.warning("Failed to enqueue marker: %s", e)
133
+ return None
134
+
135
+ marker_data: dict[str, Any] = {
136
+ "schema_version": MARKER_SCHEMA_VERSION,
137
+ "kind": kind,
138
+ "marker_id": marker_id,
139
+ "forge_version": __version__,
140
+ "created_at": now_iso(),
141
+ "payload": payload,
142
+ "attempt_count": 0,
143
+ "last_attempt_at": None,
144
+ "last_error": None,
145
+ }
146
+
147
+ try:
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+
150
+ with file_lock_for_target(target_path=path, timeout_s=MARKER_LOCK_TIMEOUT_S):
151
+ atomic_write_json(path, marker_data)
152
+
153
+ logger.debug("Enqueued %s marker: %s", kind, path)
154
+ return path
155
+
156
+ except FileLockTimeoutError:
157
+ logger.warning("Lock contention while enqueuing marker for %s", marker_id)
158
+ return None
159
+ except Exception as e:
160
+ logger.warning("Failed to enqueue %s marker for %s: %s", kind, marker_id, e)
161
+ return None
162
+
163
+
164
+ def enqueue_stop_marker(
165
+ *,
166
+ session_id: str,
167
+ worktree_path: Path,
168
+ session_name: str,
169
+ transcript_snapshot_rel: str,
170
+ forge_root: str | None = None,
171
+ ) -> Path | None:
172
+ """Enqueue a Stop marker for deferred processing.
173
+
174
+ Convenience wrapper around enqueue() for stop hook callers.
175
+
176
+ Args:
177
+ session_id: The Claude session ID (used as marker_id).
178
+ worktree_path: Absolute path to the worktree.
179
+ session_name: Forge session name.
180
+ transcript_snapshot_rel: Repo-relative path to transcript artifact.
181
+ forge_root: Explicit Forge project root (for nested projects where
182
+ forge_root != worktree_path).
183
+
184
+ Returns:
185
+ Path to created marker, or None if enqueue failed.
186
+ """
187
+ payload = {
188
+ "session_id": session_id,
189
+ "worktree_path": str(worktree_path),
190
+ "session_name": session_name,
191
+ "transcript_snapshot_rel": transcript_snapshot_rel,
192
+ }
193
+ if forge_root:
194
+ payload["forge_root"] = forge_root
195
+ return enqueue(kind="stop", marker_id=session_id, payload=payload)
196
+
197
+
198
+ def enqueue_index_marker(
199
+ *,
200
+ session_id: str,
201
+ worktree_path: Path,
202
+ session_name: str,
203
+ transcript_snapshot_rel: str,
204
+ forge_root: str | None = None,
205
+ ) -> Path | None:
206
+ """Enqueue an Index marker for deferred search indexing.
207
+
208
+ Convenience wrapper around enqueue() for stop hook callers.
209
+ Uses marker_id="idx-<session_id>" to avoid collision with the stop marker
210
+ (which uses marker_id=session_id).
211
+
212
+ Args:
213
+ session_id: The Claude session ID.
214
+ worktree_path: Absolute path to the worktree.
215
+ session_name: Forge session name.
216
+ transcript_snapshot_rel: Repo-relative path to transcript artifact.
217
+ forge_root: Explicit Forge project root (for nested projects).
218
+
219
+ Returns:
220
+ Path to created marker, or None if enqueue failed.
221
+ """
222
+ payload = {
223
+ "session_id": session_id,
224
+ "worktree_path": str(worktree_path),
225
+ "session_name": session_name,
226
+ "transcript_snapshot_rel": transcript_snapshot_rel,
227
+ }
228
+ if forge_root:
229
+ payload["forge_root"] = forge_root
230
+ return enqueue(kind="index", marker_id=f"idx-{session_id}", payload=payload)
231
+
232
+
233
+ def enqueue_handoff_marker(
234
+ *,
235
+ session_id: str,
236
+ worktree_path: Path,
237
+ session_name: str,
238
+ transcript_snapshot_rel: str,
239
+ subprocess_proxy: str | None = None,
240
+ forge_root: str | None = None,
241
+ ) -> Path | None:
242
+ """Enqueue a Handoff marker for background memory doc update.
243
+
244
+ Convenience wrapper around enqueue() for stop hook callers.
245
+ Uses marker_id="handoff-<session_id>" to avoid collision with the stop marker
246
+ (which uses marker_id=session_id) and the index marker (idx-<session_id>).
247
+
248
+ Args:
249
+ session_id: The Claude session ID.
250
+ worktree_path: Absolute path to the worktree.
251
+ session_name: Forge session name.
252
+ transcript_snapshot_rel: Repo-relative path to transcript artifact.
253
+ subprocess_proxy: Optional Stop-time subprocess proxy intent.
254
+
255
+ Returns:
256
+ Path to created marker, or None if enqueue failed.
257
+ """
258
+ payload = {
259
+ "session_id": session_id,
260
+ "worktree_path": str(worktree_path),
261
+ "session_name": session_name,
262
+ "transcript_snapshot_rel": transcript_snapshot_rel,
263
+ }
264
+ if subprocess_proxy:
265
+ payload["subprocess_proxy"] = subprocess_proxy
266
+ if forge_root:
267
+ payload["forge_root"] = forge_root
268
+
269
+ return enqueue(
270
+ kind="handoff",
271
+ marker_id=f"handoff-{session_id}",
272
+ payload=payload,
273
+ )
274
+
275
+
276
+ def process_pending_work(
277
+ *,
278
+ max_items: int = 25,
279
+ timeout_s: float = PROCESSOR_LOCK_TIMEOUT_S,
280
+ handlers: dict[str, WorkHandler] | None = None,
281
+ ) -> ProcessResult:
282
+ """Process pending-work markers opportunistically.
283
+
284
+ Fast path: if pending dir doesn't exist or is empty, returns immediately.
285
+
286
+ For each marker (up to max_items):
287
+ - Acquires per-marker lock (short timeout)
288
+ - Validates marker schema
289
+ - Dispatches to handler by kind (if handler exists)
290
+ - Deletes marker on success
291
+ - On failure: keeps marker, updates attempt_count/last_error
292
+ - On poison (attempt_count >= MAX_ATTEMPTS): moves to failed/
293
+
294
+ Args:
295
+ max_items: Maximum markers to process in one invocation.
296
+ timeout_s: Lock timeout per marker (default 50ms).
297
+ handlers: Dict mapping kind -> handler function. If None, uses empty dict
298
+ (markers with no handler are left in place).
299
+
300
+ Returns:
301
+ ProcessResult with counts and any error messages.
302
+ """
303
+ if handlers is None:
304
+ handlers = {}
305
+
306
+ result = ProcessResult()
307
+
308
+ queue_dir = pending_work_dir()
309
+ if not queue_dir.is_dir():
310
+ return result
311
+
312
+ try:
313
+ markers = sorted(queue_dir.glob("*.json"))
314
+ except OSError as e:
315
+ result.errors.append(f"Failed to list markers: {e}")
316
+ return result
317
+
318
+ if not markers:
319
+ return result
320
+
321
+ for marker_file in markers[:max_items]:
322
+ outcome = _process_single_marker(marker_file, timeout_s=timeout_s, handlers=handlers)
323
+ if outcome is None:
324
+ result.processed += 1
325
+ elif outcome == "skipped":
326
+ result.skipped += 1
327
+ elif outcome == "failed":
328
+ result.failed += 1
329
+ else:
330
+ result.errors.append(outcome)
331
+
332
+ return result
333
+
334
+
335
+ def _process_single_marker(
336
+ marker_file: Path,
337
+ *,
338
+ timeout_s: float,
339
+ handlers: dict[str, WorkHandler],
340
+ ) -> str | None:
341
+ """Process a single marker file.
342
+
343
+ Args:
344
+ marker_file: Path to the marker JSON file.
345
+ timeout_s: Lock timeout.
346
+ handlers: Dict mapping kind -> handler function.
347
+
348
+ Returns:
349
+ None on success, "skipped" if lock contention, "failed" if moved to failed/,
350
+ error message on validation failure.
351
+ """
352
+ try:
353
+ with file_lock_for_target(target_path=marker_file, timeout_s=timeout_s):
354
+ # Another process may have deleted this between the glob and lock acquisition
355
+ if not marker_file.is_file():
356
+ return None # Already processed
357
+
358
+ try:
359
+ data = read_json(marker_file)
360
+ except Exception as e:
361
+ # Unrecoverable: marker isn't valid JSON, retrying won't help.
362
+ # Move directly to failed/ rather than leaving it stuck forever.
363
+ _move_corrupted_to_failed(marker_file, f"read error: {e}")
364
+ return "failed"
365
+
366
+ # Clean up old-shape markers (session_id/work, no marker_id) on sight
367
+ # rather than letting them consume the per-run processing budget
368
+ if "marker_id" not in data and ("session_id" in data or "work" in data):
369
+ logger.info("Cleaning up old-shape marker: %s", marker_file.name)
370
+ marker_file.unlink()
371
+ return None
372
+
373
+ error = _validate_marker(data)
374
+ if error:
375
+ attempt_count = _try_write_error(marker_file, error)
376
+ if attempt_count is not None and attempt_count >= MAX_ATTEMPTS:
377
+ return _move_invalid_to_failed(marker_file, error, attempt_count)
378
+ return f"Invalid marker {marker_file.name}: {error}"
379
+
380
+ marker = Marker(
381
+ schema_version=data["schema_version"],
382
+ kind=data["kind"],
383
+ marker_id=data["marker_id"],
384
+ forge_version=data.get("forge_version", "unknown"),
385
+ created_at=data.get("created_at", ""),
386
+ payload=data.get("payload", {}),
387
+ attempt_count=data.get("attempt_count", 0),
388
+ last_attempt_at=data.get("last_attempt_at"),
389
+ last_error=data.get("last_error"),
390
+ )
391
+
392
+ # Check for poison marker (too many failures)
393
+ if marker.attempt_count >= MAX_ATTEMPTS:
394
+ return _move_to_failed(marker_file, marker)
395
+
396
+ handler = handlers.get(marker.kind)
397
+ if handler is None:
398
+ # No handler registered — leave in place, don't count as error
399
+ logger.debug(
400
+ "No handler for kind=%s, leaving marker %s in place",
401
+ marker.kind,
402
+ marker_file.name,
403
+ )
404
+ return "skipped"
405
+
406
+ try:
407
+ handler(marker)
408
+ except Exception as e:
409
+ error_text = f"handler error: {e}"
410
+ attempt_count = _try_write_error(marker_file, error_text)
411
+ if attempt_count is not None and attempt_count >= MAX_ATTEMPTS:
412
+ marker.attempt_count = attempt_count
413
+ marker.last_attempt_at = now_iso()
414
+ marker.last_error = (
415
+ error_text[:MAX_ERROR_LENGTH] if len(error_text) > MAX_ERROR_LENGTH else error_text
416
+ )
417
+ return _move_to_failed(marker_file, marker)
418
+ logger.warning(
419
+ "Handler failed for %s marker %s: %s",
420
+ marker.kind,
421
+ marker_file.name,
422
+ e,
423
+ )
424
+ return f"Handler error for {marker_file.name}: {e}"
425
+
426
+ logger.debug(
427
+ "Processed marker: marker_id=%s, kind=%s",
428
+ marker.marker_id,
429
+ marker.kind,
430
+ )
431
+ marker_file.unlink()
432
+ return None
433
+
434
+ except FileLockTimeoutError:
435
+ return "skipped"
436
+
437
+ except Exception as e:
438
+ return f"Error processing {marker_file.name}: {e}"
439
+
440
+
441
+ def _validate_marker(data: dict[str, Any]) -> str | None:
442
+ """Validate marker data.
443
+
444
+ Returns:
445
+ None if valid, error message if invalid.
446
+ """
447
+ schema_version = data.get("schema_version")
448
+ if schema_version != MARKER_SCHEMA_VERSION:
449
+ return f"unsupported schema_version: {schema_version}"
450
+
451
+ kind = data.get("kind")
452
+ if not kind or not isinstance(kind, str):
453
+ return "missing or invalid kind"
454
+
455
+ marker_id = data.get("marker_id")
456
+ if not marker_id or not isinstance(marker_id, str):
457
+ return "missing or invalid marker_id"
458
+
459
+ if not SAFE_MARKER_ID.match(marker_id):
460
+ return f"unsafe marker_id: {marker_id!r}"
461
+
462
+ return None
463
+
464
+
465
+ def _move_corrupted_to_failed(marker_file: Path, error: str) -> None:
466
+ """Move a corrupted (unparseable) marker to the failed/ directory.
467
+
468
+ Unlike _move_to_failed(), this handles markers that can't be parsed as JSON.
469
+ Without this, corrupted markers stay in the queue permanently because
470
+ _try_write_error() can't increment attempt_count on invalid JSON.
471
+
472
+ IMPORTANT: Caller must hold the per-marker lock.
473
+ """
474
+ try:
475
+ failed_dir = _failed_work_dir()
476
+ failed_dir.mkdir(parents=True, exist_ok=True)
477
+ dest = failed_dir / marker_file.name
478
+ shutil.move(str(marker_file), str(dest))
479
+ logger.warning(
480
+ "Moved corrupted marker to failed/: %s (%s)",
481
+ marker_file.name,
482
+ error,
483
+ )
484
+ except Exception as e:
485
+ logger.warning("Failed to move corrupted marker %s: %s", marker_file.name, e)
486
+
487
+
488
+ def _move_to_failed(marker_file: Path, marker: Marker) -> str:
489
+ """Move a poison marker to the failed/ directory.
490
+
491
+ Preserves the marker for debugging. Returns "failed" status string.
492
+
493
+ IMPORTANT: Caller must hold the per-marker lock.
494
+ """
495
+ try:
496
+ failed_dir = _failed_work_dir()
497
+ failed_dir.mkdir(parents=True, exist_ok=True)
498
+ dest = failed_dir / marker_file.name
499
+ shutil.move(str(marker_file), str(dest))
500
+ logger.warning(
501
+ "Moved poison marker to failed/ after %d attempts: %s (kind=%s, last_error=%s)",
502
+ marker.attempt_count,
503
+ marker_file.name,
504
+ marker.kind,
505
+ marker.last_error,
506
+ )
507
+ except Exception as e:
508
+ logger.warning("Failed to move poison marker %s: %s", marker_file.name, e)
509
+ return "failed"
510
+
511
+
512
+ def _move_invalid_to_failed(marker_file: Path, error: str, attempt_count: int) -> str:
513
+ """Move a parseable but schema-invalid marker to failed/ after retries."""
514
+ try:
515
+ failed_dir = _failed_work_dir()
516
+ failed_dir.mkdir(parents=True, exist_ok=True)
517
+ dest = failed_dir / marker_file.name
518
+ shutil.move(str(marker_file), str(dest))
519
+ logger.warning(
520
+ "Moved invalid marker to failed/ after %d attempts: %s (%s)",
521
+ attempt_count,
522
+ marker_file.name,
523
+ error,
524
+ )
525
+ except Exception as e:
526
+ logger.warning("Failed to move invalid marker %s: %s", marker_file.name, e)
527
+ return "failed"
528
+
529
+
530
+ def _try_write_error(marker_file: Path, error: str) -> int | None:
531
+ """Best-effort write last_error to marker.
532
+
533
+ IMPORTANT: This function assumes the caller already holds the per-marker lock.
534
+ It does not acquire any locks itself. Only call from within a file_lock_for_target
535
+ context on the marker file.
536
+
537
+ Returns the updated attempt_count, or None if the write failed.
538
+ """
539
+ try:
540
+ with open(marker_file, encoding="utf-8") as f:
541
+ data = json.load(f)
542
+
543
+ data["attempt_count"] = data.get("attempt_count", 0) + 1
544
+ data["last_attempt_at"] = now_iso()
545
+ # Truncate error to avoid bloating
546
+ data["last_error"] = error[:MAX_ERROR_LENGTH] if len(error) > MAX_ERROR_LENGTH else error
547
+
548
+ atomic_write_json(marker_file, data)
549
+ return data["attempt_count"]
550
+ except Exception:
551
+ # Best-effort; swallow errors
552
+ return None
@@ -0,0 +1,63 @@
1
+ """Types for the Forge async work queue.
2
+
3
+ Defines the marker dataclass, processing result, and handler protocol.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Protocol
10
+
11
+ # Schema versioning
12
+ MARKER_SCHEMA_VERSION = 1
13
+
14
+ # Poison marker limit: after this many failures, move to failed/
15
+ MAX_ATTEMPTS = 5
16
+
17
+ # Queue directory name under FORGE_HOME
18
+ PENDING_WORK_DIR = "pending-work"
19
+ FAILED_WORK_DIR = "pending-work/failed"
20
+
21
+ # Maximum error message length stored in markers
22
+ MAX_ERROR_LENGTH = 500
23
+
24
+
25
+ @dataclass
26
+ class Marker:
27
+ """A work queue marker representing deferred work.
28
+
29
+ Each marker is a single work unit identified by (kind, marker_id).
30
+ The kind determines which handler processes it.
31
+ The marker_id determines the filename (must be a safe filename).
32
+ """
33
+
34
+ schema_version: int
35
+ kind: str
36
+ marker_id: str
37
+ forge_version: str
38
+ created_at: str
39
+ payload: dict[str, Any]
40
+ attempt_count: int = 0
41
+ last_attempt_at: str | None = None
42
+ last_error: str | None = None
43
+
44
+
45
+ @dataclass
46
+ class ProcessResult:
47
+ """Result of processing the work queue."""
48
+
49
+ processed: int = 0
50
+ skipped: int = 0
51
+ failed: int = 0 # Markers that exceeded MAX_ATTEMPTS (moved to failed/)
52
+ errors: list[str] = field(default_factory=list)
53
+
54
+
55
+ class WorkHandler(Protocol):
56
+ """Protocol for work queue handlers.
57
+
58
+ Handlers are called with a Marker and should raise on failure.
59
+ On success, the marker is deleted. On failure, the marker is kept
60
+ with attempt_count incremented.
61
+ """
62
+
63
+ def __call__(self, marker: Marker) -> None: ...
@@ -0,0 +1,26 @@
1
+ """Forge Guard: Policy enforcement engine.
2
+
3
+ This module provides policy enforcement at Claude Code hook boundaries,
4
+ supporting both deterministic policies (TDD, coding standards) and
5
+ semantic policies (LLM-based supervisor).
6
+ """
7
+
8
+ from forge.guard.types import (
9
+ ActionContext,
10
+ CompositeDecision,
11
+ DecisionType,
12
+ FailMode,
13
+ PolicyDecision,
14
+ Severity,
15
+ Violation,
16
+ )
17
+
18
+ __all__ = [
19
+ "ActionContext",
20
+ "CompositeDecision",
21
+ "DecisionType",
22
+ "FailMode",
23
+ "PolicyDecision",
24
+ "Severity",
25
+ "Violation",
26
+ ]
@@ -0,0 +1,26 @@
1
+ """Deterministic policies for the Policy Engine.
2
+
3
+ Deterministic policies are fast, stateless (or simply stateful) checks
4
+ that run synchronously without LLM invocation. They include:
5
+
6
+ - TDD bundle: tests-before-impl, no-skip-tests
7
+ - Coding standards bundle: no-TYPE_CHECKING, no-backward-compat
8
+ """
9
+
10
+ from forge.guard.deterministic.coding_standards import (
11
+ NoBackwardCompatPolicy,
12
+ NoTypeCheckingPolicy,
13
+ )
14
+ from forge.guard.deterministic.registry import get_bundle_policies
15
+ from forge.guard.deterministic.tdd import (
16
+ NoSkipTestsPolicy,
17
+ TDDEnforcementPolicy,
18
+ )
19
+
20
+ __all__ = [
21
+ "NoBackwardCompatPolicy",
22
+ "NoSkipTestsPolicy",
23
+ "NoTypeCheckingPolicy",
24
+ "TDDEnforcementPolicy",
25
+ "get_bundle_policies",
26
+ ]