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,128 @@
|
|
|
1
|
+
"""Path layout for resume/fork context files (prev_sessions).
|
|
2
|
+
|
|
3
|
+
Centralizes the on-disk layout so process_handoff, SessionManager, fork paths,
|
|
4
|
+
and GC stay in sync.
|
|
5
|
+
|
|
6
|
+
Layout::
|
|
7
|
+
|
|
8
|
+
<forge_root>/.forge/prev_sessions/
|
|
9
|
+
+-- <parent>/
|
|
10
|
+
+-- generated.md # Strategy output (regeneratable cache)
|
|
11
|
+
+-- children/
|
|
12
|
+
+-- <child>.md # Per-child authoritative context (durable)
|
|
13
|
+
|
|
14
|
+
The split exists so that regenerating the parent cache (re-running resume
|
|
15
|
+
against the same parent) never disturbs an existing child file. Once
|
|
16
|
+
``children/<child>.md`` exists, it is the authoritative context that gets
|
|
17
|
+
appended to the child session's system prompt.
|
|
18
|
+
|
|
19
|
+
Legacy note: pre-0.2.0, this was a single flat
|
|
20
|
+
``<forge_root>/.forge/prev_sessions/<parent>.md`` (parent-scoped, overwritten
|
|
21
|
+
by every resume/fork). New code never reads or writes the flat layout. GC
|
|
22
|
+
treats any remaining flat ``*.md`` files at the top level of
|
|
23
|
+
``prev_sessions/`` as orphans (see ``iter_legacy_flat_files``).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import shutil
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
PREV_SESSIONS_DIR = "prev_sessions"
|
|
33
|
+
GENERATED_FILENAME = "generated.md"
|
|
34
|
+
CHILDREN_DIR = "children"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def prev_sessions_root(forge_root: Path) -> Path:
|
|
38
|
+
return forge_root / ".forge" / PREV_SESSIONS_DIR
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parent_dir(forge_root: Path, parent_name: str) -> Path:
|
|
42
|
+
return prev_sessions_root(forge_root) / parent_name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def generated_path(forge_root: Path, parent_name: str) -> Path:
|
|
46
|
+
return parent_dir(forge_root, parent_name) / GENERATED_FILENAME
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def children_dir(forge_root: Path, parent_name: str) -> Path:
|
|
50
|
+
return parent_dir(forge_root, parent_name) / CHILDREN_DIR
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def child_path(forge_root: Path, parent_name: str, child_name: str) -> Path:
|
|
54
|
+
return children_dir(forge_root, parent_name) / f"{child_name}.md"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def generated_path_rel(parent_name: str) -> str:
|
|
58
|
+
"""Return the forge-root-relative path to ``generated.md``."""
|
|
59
|
+
return f".forge/{PREV_SESSIONS_DIR}/{parent_name}/{GENERATED_FILENAME}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def child_path_rel(parent_name: str, child_name: str) -> str:
|
|
63
|
+
"""Return the forge-root-relative path to ``children/<child>.md``."""
|
|
64
|
+
return f".forge/{PREV_SESSIONS_DIR}/{parent_name}/{CHILDREN_DIR}/{child_name}.md"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ensure_child(forge_root: Path, parent_name: str, child_name: str) -> Path:
|
|
68
|
+
"""Create ``children/<child>.md`` as a copy of ``generated.md`` if absent.
|
|
69
|
+
|
|
70
|
+
Idempotent: if the child file already exists (user has curated it, or it
|
|
71
|
+
was created by a previous resume), leave it alone. This is the durability
|
|
72
|
+
guarantee: once a child file exists, regenerating the parent cache does
|
|
73
|
+
not affect it.
|
|
74
|
+
|
|
75
|
+
Raises ``FileNotFoundError`` if neither the child file nor the parent
|
|
76
|
+
cache exists -- the caller is responsible for running ``process_handoff``
|
|
77
|
+
first.
|
|
78
|
+
"""
|
|
79
|
+
target = child_path(forge_root, parent_name, child_name)
|
|
80
|
+
if target.exists():
|
|
81
|
+
return target
|
|
82
|
+
|
|
83
|
+
source = generated_path(forge_root, parent_name)
|
|
84
|
+
if not source.is_file():
|
|
85
|
+
raise FileNotFoundError(
|
|
86
|
+
f"Cannot copy parent cache to child: {source} does not exist. " "Run process_handoff() first."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
shutil.copyfile(source, target)
|
|
91
|
+
return target
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def iter_parents(forge_root: Path) -> Iterator[Path]:
|
|
95
|
+
"""Yield each ``<parent>/`` directory under ``prev_sessions/``.
|
|
96
|
+
|
|
97
|
+
Skips legacy flat ``.md`` files at the top level (see
|
|
98
|
+
``iter_legacy_flat_files``).
|
|
99
|
+
"""
|
|
100
|
+
root = prev_sessions_root(forge_root)
|
|
101
|
+
if not root.is_dir():
|
|
102
|
+
return
|
|
103
|
+
for entry in root.iterdir():
|
|
104
|
+
if entry.is_dir():
|
|
105
|
+
yield entry
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def iter_children(forge_root: Path, parent_name: str) -> Iterator[Path]:
|
|
109
|
+
"""Yield each ``<child>.md`` under ``<parent>/children/``."""
|
|
110
|
+
target = children_dir(forge_root, parent_name)
|
|
111
|
+
if not target.is_dir():
|
|
112
|
+
return
|
|
113
|
+
for entry in target.iterdir():
|
|
114
|
+
if entry.is_file() and entry.suffix == ".md":
|
|
115
|
+
yield entry
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def iter_legacy_flat_files(forge_root: Path) -> Iterator[Path]:
|
|
119
|
+
"""Yield top-level ``<parent>.md`` files (legacy pre-0.2.0 layout).
|
|
120
|
+
|
|
121
|
+
These are orphan candidates for GC; new code never writes here.
|
|
122
|
+
"""
|
|
123
|
+
root = prev_sessions_root(forge_root)
|
|
124
|
+
if not root.is_dir():
|
|
125
|
+
return
|
|
126
|
+
for entry in root.iterdir():
|
|
127
|
+
if entry.is_file() and entry.suffix == ".md":
|
|
128
|
+
yield entry
|
forge/session/store.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Per-session manifest storage.
|
|
2
|
+
|
|
3
|
+
Path: <forge_root>/.forge/sessions/<session_name>/forge.session.json
|
|
4
|
+
|
|
5
|
+
Schema: v1 only (no migration).
|
|
6
|
+
|
|
7
|
+
Session manifests are treated as a strict contract:
|
|
8
|
+
- No schema migration
|
|
9
|
+
- No unknown field preservation
|
|
10
|
+
- Invalid manifests fail fast on read
|
|
11
|
+
|
|
12
|
+
Writes always produce schema v1.
|
|
13
|
+
|
|
14
|
+
Invariant: session names are globally unique across all worktrees (enforced by
|
|
15
|
+
IndexStore.add_session). The directory name IS the session name.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import shutil
|
|
22
|
+
from dataclasses import fields, is_dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, get_origin, get_type_hints
|
|
25
|
+
|
|
26
|
+
import dacite
|
|
27
|
+
|
|
28
|
+
from forge.core.state import atomic_write_json, now_iso
|
|
29
|
+
from forge.core.state.lock import file_lock_for_target
|
|
30
|
+
from forge.core.typing_helpers import unwrap_optional
|
|
31
|
+
|
|
32
|
+
from .exceptions import (
|
|
33
|
+
ManifestCorruptedError,
|
|
34
|
+
ManifestValidationError,
|
|
35
|
+
SessionFileNotFoundError,
|
|
36
|
+
)
|
|
37
|
+
from .models import SCHEMA_VERSION, SessionState, session_state_to_dict
|
|
38
|
+
from .validation import validate_name
|
|
39
|
+
|
|
40
|
+
_SUPPORTED_SCHEMA_VERSIONS = {1}
|
|
41
|
+
|
|
42
|
+
MANIFEST_FILENAME = "forge.session.json"
|
|
43
|
+
MANIFEST_DIR = ".forge"
|
|
44
|
+
SESSIONS_DIR = "sessions"
|
|
45
|
+
|
|
46
|
+
HOOK_LOCK_TIMEOUT_S = 0.2
|
|
47
|
+
CLI_LOCK_TIMEOUT_S = 5.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- Free functions — use these for path construction everywhere (avoid drift) ---
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_sessions_dir(forge_root: str | Path) -> Path:
|
|
54
|
+
"""Return the sessions directory for a Forge project.
|
|
55
|
+
|
|
56
|
+
Returns: <forge_root>/.forge/sessions/
|
|
57
|
+
"""
|
|
58
|
+
return Path(forge_root) / MANIFEST_DIR / SESSIONS_DIR
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_manifest_path(forge_root: str | Path, session_name: str) -> Path:
|
|
62
|
+
"""Return the manifest path for a specific session.
|
|
63
|
+
|
|
64
|
+
Returns: <forge_root>/.forge/sessions/<session_name>/forge.session.json
|
|
65
|
+
"""
|
|
66
|
+
return Path(forge_root) / MANIFEST_DIR / SESSIONS_DIR / session_name / MANIFEST_FILENAME
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SessionStore:
|
|
70
|
+
"""Read/write session state to per-session manifest directory.
|
|
71
|
+
|
|
72
|
+
Each session has its own directory under <forge_root>/.forge/sessions/<name>/.
|
|
73
|
+
Multiple sessions can coexist in the same Forge project.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, forge_root: str, session_name: str) -> None:
|
|
77
|
+
"""Initialize store for a specific session in a Forge project.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
forge_root: Absolute path to the Forge project root (where .forge/ lives).
|
|
81
|
+
session_name: Session name (must be valid per validate_name).
|
|
82
|
+
"""
|
|
83
|
+
self._forge_root = Path(forge_root).resolve()
|
|
84
|
+
self._session_name = session_name
|
|
85
|
+
self._manifest_path = get_manifest_path(self._forge_root, session_name)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def manifest_path(self) -> Path:
|
|
89
|
+
"""Return the full path to the manifest file."""
|
|
90
|
+
return self._manifest_path
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def forge_root(self) -> Path:
|
|
94
|
+
"""Return the Forge project root."""
|
|
95
|
+
return self._forge_root
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def worktree_path(self) -> Path:
|
|
99
|
+
"""Deprecated alias for forge_root (kept for transition)."""
|
|
100
|
+
return self._forge_root
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def session_name(self) -> str:
|
|
104
|
+
"""Return the session name."""
|
|
105
|
+
return self._session_name
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def session_dir(self) -> Path:
|
|
109
|
+
"""Return the session directory (parent of manifest file)."""
|
|
110
|
+
return self._manifest_path.parent
|
|
111
|
+
|
|
112
|
+
def exists(self) -> bool:
|
|
113
|
+
"""Check if a manifest exists in this worktree."""
|
|
114
|
+
return self._manifest_path.is_file()
|
|
115
|
+
|
|
116
|
+
def read_raw(self) -> dict[str, Any] | None:
|
|
117
|
+
"""Read manifest as raw JSON dict, skipping validation/deserialization.
|
|
118
|
+
|
|
119
|
+
For best-effort field extraction when full parsing fails (e.g. force-delete
|
|
120
|
+
needs confirmed.claude_session_id from a schema-mismatched manifest).
|
|
121
|
+
|
|
122
|
+
Returns None if the file doesn't exist or isn't valid JSON.
|
|
123
|
+
"""
|
|
124
|
+
if not self.exists():
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
with open(self._manifest_path, encoding="utf-8") as f:
|
|
128
|
+
return json.load(f)
|
|
129
|
+
except (json.JSONDecodeError, OSError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def read(self) -> SessionState:
|
|
133
|
+
"""Read and parse the session manifest.
|
|
134
|
+
|
|
135
|
+
Schema v1 only. No migration, no unknown field preservation.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
SessionFileNotFoundError: If manifest doesn't exist.
|
|
139
|
+
ManifestCorruptedError: If manifest cannot be parsed.
|
|
140
|
+
ManifestValidationError: If manifest is missing required fields.
|
|
141
|
+
"""
|
|
142
|
+
if not self.exists():
|
|
143
|
+
raise SessionFileNotFoundError(str(self._manifest_path))
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
with open(self._manifest_path, encoding="utf-8") as f:
|
|
147
|
+
data = json.load(f)
|
|
148
|
+
except json.JSONDecodeError as e:
|
|
149
|
+
raise ManifestCorruptedError(str(self._manifest_path), f"invalid JSON: {e}")
|
|
150
|
+
except OSError as e:
|
|
151
|
+
raise ManifestCorruptedError(str(self._manifest_path), f"read error: {e}")
|
|
152
|
+
|
|
153
|
+
self._validate_data(data)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
manifest = dacite.from_dict(
|
|
157
|
+
data_class=SessionState,
|
|
158
|
+
data=data,
|
|
159
|
+
config=dacite.Config(strict=True),
|
|
160
|
+
)
|
|
161
|
+
except (dacite.DaciteError, TypeError, KeyError, ValueError) as e:
|
|
162
|
+
raise ManifestCorruptedError(str(self._manifest_path), f"deserialization error: {e}")
|
|
163
|
+
|
|
164
|
+
return manifest
|
|
165
|
+
|
|
166
|
+
def write(self, manifest: SessionState) -> None:
|
|
167
|
+
"""Write the session manifest atomically under lock.
|
|
168
|
+
|
|
169
|
+
Uses atomic write pattern via core.state.atomic_write_json.
|
|
170
|
+
Creates session directory if it doesn't exist.
|
|
171
|
+
Acquires the same file lock as update() to prevent CLI write + hook
|
|
172
|
+
update lost-update races (D10).
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
manifest: The manifest to write.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
InvalidSessionNameError: If manifest name is invalid.
|
|
179
|
+
"""
|
|
180
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
|
|
182
|
+
with file_lock_for_target(target_path=self._manifest_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
183
|
+
self._write_unlocked(manifest)
|
|
184
|
+
|
|
185
|
+
def _write_unlocked(self, manifest: SessionState) -> None:
|
|
186
|
+
"""Write manifest without acquiring lock (caller must hold it)."""
|
|
187
|
+
validate_name(manifest.name)
|
|
188
|
+
|
|
189
|
+
# Enforce invariant: directory name == manifest name (Issue A).
|
|
190
|
+
if manifest.name != self._session_name:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"Manifest name '{manifest.name}' does not match store session "
|
|
193
|
+
f"name '{self._session_name}'. This would create a directory/name mismatch."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
data = session_state_to_dict(manifest)
|
|
197
|
+
data["schema_version"] = SCHEMA_VERSION
|
|
198
|
+
atomic_write_json(self._manifest_path, data)
|
|
199
|
+
|
|
200
|
+
def delete(self) -> bool:
|
|
201
|
+
"""Delete the session directory and its contents.
|
|
202
|
+
|
|
203
|
+
Uses shutil.rmtree since the session directory is entirely session-owned.
|
|
204
|
+
Leaves the parent sessions/ directory in place even if empty (D12).
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
True if directory was removed, False if it didn't exist.
|
|
208
|
+
"""
|
|
209
|
+
session_dir = self.session_dir
|
|
210
|
+
if session_dir.is_dir():
|
|
211
|
+
shutil.rmtree(session_dir, ignore_errors=True)
|
|
212
|
+
return True
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
def update_last_accessed(self) -> SessionState:
|
|
216
|
+
"""Update last_accessed_at timestamp and return updated manifest."""
|
|
217
|
+
|
|
218
|
+
return self.update(
|
|
219
|
+
timeout_s=CLI_LOCK_TIMEOUT_S,
|
|
220
|
+
mutate=lambda m: setattr(m, "last_accessed_at", now_iso()),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def update(self, *, timeout_s: float, mutate: Callable[[SessionState], None]) -> SessionState:
|
|
224
|
+
"""Update a manifest via a locked read-modify-write cycle.
|
|
225
|
+
|
|
226
|
+
This prevents lost updates when multiple processes (CLI + hooks) mutate
|
|
227
|
+
different sections of the manifest concurrently.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
timeout_s: How long to wait for the manifest lock.
|
|
231
|
+
mutate: Callback that mutates the loaded manifest in-place.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The updated manifest after persistence.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
FileLockTimeoutError: If lock cannot be acquired within timeout.
|
|
238
|
+
SessionFileNotFoundError / ManifestCorruptedError / ManifestValidationError: On read failures.
|
|
239
|
+
InvalidSessionNameError: On write failures.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
with file_lock_for_target(target_path=self._manifest_path, timeout_s=timeout_s):
|
|
243
|
+
manifest = self.read()
|
|
244
|
+
mutate(manifest)
|
|
245
|
+
self._write_unlocked(manifest)
|
|
246
|
+
return manifest
|
|
247
|
+
|
|
248
|
+
def _validate_data(self, data: dict[str, Any]) -> None:
|
|
249
|
+
"""Validate required fields for schema v1.
|
|
250
|
+
|
|
251
|
+
The manifest is treated as a strict contract:
|
|
252
|
+
- schema_version must be supported
|
|
253
|
+
- required fields must be present
|
|
254
|
+
- overrides must target valid SessionIntent fields only
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
ManifestCorruptedError: If schema version is unsupported or types are invalid.
|
|
258
|
+
ManifestValidationError: If required fields are missing.
|
|
259
|
+
"""
|
|
260
|
+
missing: list[str] = []
|
|
261
|
+
|
|
262
|
+
# Check schema version
|
|
263
|
+
if "schema_version" not in data:
|
|
264
|
+
missing.append("schema_version")
|
|
265
|
+
elif data["schema_version"] not in _SUPPORTED_SCHEMA_VERSIONS:
|
|
266
|
+
raise ManifestCorruptedError(
|
|
267
|
+
str(self._manifest_path),
|
|
268
|
+
f"incompatible schema version {data['schema_version']} "
|
|
269
|
+
f"(this Forge expects {sorted(_SUPPORTED_SCHEMA_VERSIONS)}). "
|
|
270
|
+
f"Delete this session and recreate it.",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Overrides are required (empty dict allowed)
|
|
274
|
+
if "overrides" not in data:
|
|
275
|
+
missing.append("overrides")
|
|
276
|
+
elif not isinstance(data["overrides"], dict):
|
|
277
|
+
raise ManifestCorruptedError(str(self._manifest_path), "overrides must be an object")
|
|
278
|
+
|
|
279
|
+
if "name" not in data:
|
|
280
|
+
missing.append("name")
|
|
281
|
+
|
|
282
|
+
if "created_at" not in data:
|
|
283
|
+
missing.append("created_at")
|
|
284
|
+
if "last_accessed_at" not in data:
|
|
285
|
+
missing.append("last_accessed_at")
|
|
286
|
+
|
|
287
|
+
if "intent" not in data:
|
|
288
|
+
missing.append("intent")
|
|
289
|
+
intent: dict[str, Any] = {}
|
|
290
|
+
else:
|
|
291
|
+
intent_obj = data.get("intent")
|
|
292
|
+
if intent_obj is None or not isinstance(intent_obj, dict):
|
|
293
|
+
raise ManifestCorruptedError(str(self._manifest_path), "intent must be an object")
|
|
294
|
+
intent = intent_obj
|
|
295
|
+
# Check intent.proxy fields (optional; but if present must be complete)
|
|
296
|
+
proxy = intent.get("proxy")
|
|
297
|
+
if proxy is not None:
|
|
298
|
+
if not isinstance(proxy, dict):
|
|
299
|
+
raise ManifestCorruptedError(str(self._manifest_path), "intent.proxy must be an object")
|
|
300
|
+
|
|
301
|
+
if "template" not in proxy:
|
|
302
|
+
missing.append("intent.proxy.template")
|
|
303
|
+
if "base_url" not in proxy:
|
|
304
|
+
missing.append("intent.proxy.base_url")
|
|
305
|
+
|
|
306
|
+
# Strict overrides schema: keys must be valid SessionIntent paths
|
|
307
|
+
overrides = data.get("overrides")
|
|
308
|
+
if isinstance(overrides, dict):
|
|
309
|
+
_validate_overrides_schema(overrides, str(self._manifest_path))
|
|
310
|
+
|
|
311
|
+
# Optional: confirmed.started_with_proxy (B2.1.6)
|
|
312
|
+
confirmed = data.get("confirmed", {})
|
|
313
|
+
started_with_proxy = confirmed.get("started_with_proxy")
|
|
314
|
+
if started_with_proxy is not None:
|
|
315
|
+
if not isinstance(started_with_proxy, dict):
|
|
316
|
+
raise ManifestCorruptedError(
|
|
317
|
+
str(self._manifest_path),
|
|
318
|
+
"confirmed.started_with_proxy must be an object",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
base_url = started_with_proxy.get("base_url")
|
|
322
|
+
if not isinstance(base_url, str) or not base_url:
|
|
323
|
+
raise ManifestCorruptedError(
|
|
324
|
+
str(self._manifest_path),
|
|
325
|
+
"confirmed.started_with_proxy.base_url is required",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
proxy_id = started_with_proxy.get("proxy_id")
|
|
329
|
+
if proxy_id is not None and not isinstance(proxy_id, str):
|
|
330
|
+
raise ManifestCorruptedError(
|
|
331
|
+
str(self._manifest_path),
|
|
332
|
+
"confirmed.started_with_proxy.proxy_id must be a string",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
template = started_with_proxy.get("template")
|
|
336
|
+
if template is not None and not isinstance(template, str):
|
|
337
|
+
raise ManifestCorruptedError(
|
|
338
|
+
str(self._manifest_path),
|
|
339
|
+
"confirmed.started_with_proxy.template must be a string",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
port = started_with_proxy.get("port")
|
|
343
|
+
if port is not None and not isinstance(port, int):
|
|
344
|
+
raise ManifestCorruptedError(
|
|
345
|
+
str(self._manifest_path),
|
|
346
|
+
"confirmed.started_with_proxy.port must be an integer",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if missing:
|
|
350
|
+
raise ManifestValidationError(str(self._manifest_path), missing)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _is_dict_type(tp: Any) -> bool:
|
|
354
|
+
"""Check if a type annotation is a dict type (dict, Dict, dict[...])."""
|
|
355
|
+
return get_origin(tp) is dict or tp is dict
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _collect_dataclass_field_names(cls: type[Any]) -> set[str]:
|
|
359
|
+
return {f.name for f in fields(cls) if not f.name.startswith("_")}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _collect_dataclass_field_types(cls: type[Any]) -> dict[str, Any]:
|
|
363
|
+
# Use get_type_hints so forward refs and Optional are resolved.
|
|
364
|
+
return get_type_hints(cls)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _validate_overrides_schema(overrides: dict[str, Any], manifest_path: str) -> None:
|
|
368
|
+
"""Validate that override keys target only real SessionIntent fields.
|
|
369
|
+
|
|
370
|
+
SessionState.overrides is a dict, so dacite cannot enforce its schema.
|
|
371
|
+
We validate it manually against the SessionIntent dataclass structure.
|
|
372
|
+
|
|
373
|
+
Rules:
|
|
374
|
+
- No unknown keys at any level
|
|
375
|
+
- No `custom` namespace
|
|
376
|
+
- Only nested dataclass fields may contain nested dict overrides
|
|
377
|
+
"""
|
|
378
|
+
from .models import SessionIntent
|
|
379
|
+
|
|
380
|
+
_validate_overrides_dict_against_dataclass(
|
|
381
|
+
overrides=overrides,
|
|
382
|
+
cls=SessionIntent,
|
|
383
|
+
path_prefix="overrides",
|
|
384
|
+
manifest_path=manifest_path,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _validate_overrides_dict_against_dataclass(
|
|
389
|
+
overrides: dict[str, Any],
|
|
390
|
+
cls: Any,
|
|
391
|
+
path_prefix: str,
|
|
392
|
+
manifest_path: str,
|
|
393
|
+
) -> None:
|
|
394
|
+
if not is_dataclass(cls):
|
|
395
|
+
raise ManifestCorruptedError(manifest_path, f"internal error: {cls} is not a dataclass")
|
|
396
|
+
|
|
397
|
+
if not isinstance(cls, type):
|
|
398
|
+
raise ManifestCorruptedError(manifest_path, f"internal error: {cls} is not a type")
|
|
399
|
+
|
|
400
|
+
valid_fields = _collect_dataclass_field_names(cls)
|
|
401
|
+
type_hints = _collect_dataclass_field_types(cls)
|
|
402
|
+
|
|
403
|
+
for key, value in overrides.items():
|
|
404
|
+
if key == "custom":
|
|
405
|
+
raise ManifestCorruptedError(manifest_path, "overrides.custom is not supported")
|
|
406
|
+
|
|
407
|
+
if key not in valid_fields:
|
|
408
|
+
raise ManifestCorruptedError(manifest_path, f"unknown override key: {path_prefix}.{key}")
|
|
409
|
+
|
|
410
|
+
field_type = type_hints.get(key)
|
|
411
|
+
if field_type is None:
|
|
412
|
+
# Should not happen for normal dataclasses; treat as schema error.
|
|
413
|
+
raise ManifestCorruptedError(manifest_path, f"missing type hint for {path_prefix}.{key}")
|
|
414
|
+
|
|
415
|
+
actual_type = unwrap_optional(field_type)
|
|
416
|
+
|
|
417
|
+
# Nested dict overrides are only allowed for nested dataclasses or dict-typed fields.
|
|
418
|
+
if isinstance(value, dict):
|
|
419
|
+
if is_dataclass(actual_type):
|
|
420
|
+
_validate_overrides_dict_against_dataclass(
|
|
421
|
+
overrides=value,
|
|
422
|
+
cls=actual_type,
|
|
423
|
+
path_prefix=f"{path_prefix}.{key}",
|
|
424
|
+
manifest_path=manifest_path,
|
|
425
|
+
)
|
|
426
|
+
elif not _is_dict_type(actual_type):
|
|
427
|
+
raise ManifestCorruptedError(
|
|
428
|
+
manifest_path,
|
|
429
|
+
f"{path_prefix}.{key} does not support nested override keys",
|
|
430
|
+
)
|
|
431
|
+
# else: dict-typed field — accept any dict value without schema validation
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Session name validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .exceptions import InvalidSessionNameError
|
|
8
|
+
|
|
9
|
+
# Constants
|
|
10
|
+
MIN_NAME_LENGTH = 2
|
|
11
|
+
MAX_NAME_LENGTH = 64
|
|
12
|
+
|
|
13
|
+
# Regex: lowercase alphanumeric, hyphens allowed in middle, no consecutive hyphens
|
|
14
|
+
# Must start and end with alphanumeric
|
|
15
|
+
_NAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_name(name: str) -> None:
|
|
19
|
+
"""Validate a session name.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
InvalidSessionNameError: If name is invalid, with specific reason.
|
|
23
|
+
|
|
24
|
+
Rules:
|
|
25
|
+
- Length: 2-64 characters
|
|
26
|
+
- Characters: lowercase alphanumeric + hyphens
|
|
27
|
+
- Must start with alphanumeric
|
|
28
|
+
- Must end with alphanumeric
|
|
29
|
+
- No consecutive hyphens
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
Valid: "auth-feature", "bugfix-123", "a1"
|
|
33
|
+
Invalid: "-invalid", "invalid-", "in--valid", "UPPERCASE"
|
|
34
|
+
"""
|
|
35
|
+
if len(name) < MIN_NAME_LENGTH:
|
|
36
|
+
raise InvalidSessionNameError(f"name must be at least {MIN_NAME_LENGTH} characters")
|
|
37
|
+
|
|
38
|
+
if len(name) > MAX_NAME_LENGTH:
|
|
39
|
+
raise InvalidSessionNameError(f"name must be at most {MAX_NAME_LENGTH} characters")
|
|
40
|
+
|
|
41
|
+
if not _NAME_PATTERN.match(name):
|
|
42
|
+
raise InvalidSessionNameError(
|
|
43
|
+
"name must be lowercase alphanumeric with hyphens, starting and ending with alphanumeric"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if "--" in name:
|
|
47
|
+
raise InvalidSessionNameError("name cannot contain consecutive hyphens")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Git worktree utilities for session isolation.
|
|
2
|
+
|
|
3
|
+
This module provides functions for creating, configuring, and cleaning up
|
|
4
|
+
git worktrees for Forge sessions. Each session can have its own worktree,
|
|
5
|
+
enabling parallel work without manifest conflicts.
|
|
6
|
+
|
|
7
|
+
Key safety features:
|
|
8
|
+
- Never overwrites tracked files during config copy
|
|
9
|
+
- Never deletes tracked files during cleanup
|
|
10
|
+
- Uses refs/heads/ for branch checks (avoids tag false positives)
|
|
11
|
+
- Validates explicit --branch names
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .cleanup import (
|
|
15
|
+
CleanupResult,
|
|
16
|
+
cleanup_worktree,
|
|
17
|
+
delete_branch,
|
|
18
|
+
is_worktree_dirty,
|
|
19
|
+
remove_config_files,
|
|
20
|
+
remove_worktree,
|
|
21
|
+
)
|
|
22
|
+
from .config_copy import (
|
|
23
|
+
ConfigCopyResult,
|
|
24
|
+
DEFAULT_CONFIG_ALLOWLIST,
|
|
25
|
+
copy_runtime_config,
|
|
26
|
+
get_copied_config_files,
|
|
27
|
+
is_file_tracked,
|
|
28
|
+
)
|
|
29
|
+
from .create import (
|
|
30
|
+
WorktreeResult,
|
|
31
|
+
branch_exists,
|
|
32
|
+
create_worktree,
|
|
33
|
+
find_git_binary,
|
|
34
|
+
get_main_repo_root,
|
|
35
|
+
get_repo_root,
|
|
36
|
+
resolve_worktree_path,
|
|
37
|
+
sanitize_branch_name,
|
|
38
|
+
validate_branch_name,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
# create.py
|
|
43
|
+
"WorktreeResult",
|
|
44
|
+
"find_git_binary",
|
|
45
|
+
"get_repo_root",
|
|
46
|
+
"get_main_repo_root",
|
|
47
|
+
"branch_exists",
|
|
48
|
+
"validate_branch_name",
|
|
49
|
+
"sanitize_branch_name",
|
|
50
|
+
"resolve_worktree_path",
|
|
51
|
+
"create_worktree",
|
|
52
|
+
# config_copy.py
|
|
53
|
+
"ConfigCopyResult",
|
|
54
|
+
"DEFAULT_CONFIG_ALLOWLIST",
|
|
55
|
+
"is_file_tracked",
|
|
56
|
+
"copy_runtime_config",
|
|
57
|
+
"get_copied_config_files",
|
|
58
|
+
# cleanup.py
|
|
59
|
+
"CleanupResult",
|
|
60
|
+
"is_worktree_dirty",
|
|
61
|
+
"remove_config_files",
|
|
62
|
+
"remove_worktree",
|
|
63
|
+
"delete_branch",
|
|
64
|
+
"cleanup_worktree",
|
|
65
|
+
]
|