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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- 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: ...
|
forge/guard/__init__.py
ADDED
|
@@ -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
|
+
]
|