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
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
|
forge/core/state/lock.py
ADDED
|
@@ -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()
|
forge/core/transcript.py
ADDED
|
@@ -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
|
+
]
|