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/cli/guards.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""CWD validation guards for session commands.
|
|
2
|
+
|
|
3
|
+
Enforces two invariants:
|
|
4
|
+
1. CWD must be a git repo root OR a Forge project root (where .forge/ lives)
|
|
5
|
+
2. CWD must be the main repo root (not a child worktree) — for --worktree commands
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from forge.core.paths import display_path
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def require_repo_root() -> Path:
|
|
21
|
+
"""Verify CWD is a git repository root or a Forge project root.
|
|
22
|
+
|
|
23
|
+
Accepts CWD at a nested Forge project root (where .forge/ lives) for
|
|
24
|
+
monorepo support. Falls back to git root check for non-Forge directories.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The validated CWD on success.
|
|
28
|
+
"""
|
|
29
|
+
from forge.core.ops.context import find_forge_root
|
|
30
|
+
from forge.session.claude.paths import find_project_root
|
|
31
|
+
|
|
32
|
+
cwd = Path.cwd().resolve()
|
|
33
|
+
|
|
34
|
+
# Accept CWD at a Forge project root (nested or top-level)
|
|
35
|
+
forge_root = find_forge_root(cwd)
|
|
36
|
+
if forge_root is not None and forge_root == cwd:
|
|
37
|
+
return cwd
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
repo_root = find_project_root().resolve()
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
console.print("[red]Error:[/red] Not in a git repository")
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
if cwd != repo_root:
|
|
46
|
+
hint = str(forge_root) if forge_root else str(repo_root)
|
|
47
|
+
console.print(
|
|
48
|
+
f"[red]Error:[/red] Must run from the repository root ({display_path(repo_root)}), " f"not a subdirectory"
|
|
49
|
+
)
|
|
50
|
+
console.print(f"\n[dim]Tip: cd {display_path(hint)}[/dim]")
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
return cwd
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def require_main_repo_root() -> Path:
|
|
57
|
+
"""Verify CWD is the main git repo root (or Forge project root), not a child worktree.
|
|
58
|
+
|
|
59
|
+
Accepts CWD at a nested Forge project root for monorepo support.
|
|
60
|
+
For --worktree commands, also checks that we're not inside a child worktree.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The validated CWD on success.
|
|
64
|
+
"""
|
|
65
|
+
from forge.core.ops.context import find_forge_root
|
|
66
|
+
from forge.session.claude.paths import find_project_root
|
|
67
|
+
from forge.session.exceptions import GitNotFoundError, GitWorktreeError
|
|
68
|
+
from forge.session.worktree import get_main_repo_root
|
|
69
|
+
|
|
70
|
+
cwd = Path.cwd().resolve()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
repo_root = find_project_root().resolve()
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
console.print("[red]Error:[/red] Not in a git repository")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
# Resolve main repo root before any error so the tip is always correct
|
|
79
|
+
try:
|
|
80
|
+
main_root = get_main_repo_root(repo_root).resolve()
|
|
81
|
+
except (GitWorktreeError, GitNotFoundError):
|
|
82
|
+
main_root = repo_root
|
|
83
|
+
|
|
84
|
+
if repo_root != main_root:
|
|
85
|
+
# Any location inside a child worktree (root or subfolder)
|
|
86
|
+
console.print(
|
|
87
|
+
"[red]Error:[/red] Cannot create worktrees from inside a child worktree. "
|
|
88
|
+
f"Run from the main repository root ({display_path(main_root)})"
|
|
89
|
+
)
|
|
90
|
+
console.print(f"\n[dim]Tip: cd {display_path(main_root)}[/dim]")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
# Accept CWD at a Forge project root (nested or top-level)
|
|
94
|
+
forge_root = find_forge_root(cwd)
|
|
95
|
+
if forge_root is not None and forge_root == cwd:
|
|
96
|
+
return cwd
|
|
97
|
+
|
|
98
|
+
if cwd != repo_root:
|
|
99
|
+
# Subfolder of the main repo without .forge/
|
|
100
|
+
console.print(
|
|
101
|
+
f"[red]Error:[/red] Must run from the repository root ({display_path(repo_root)}), " f"not a subdirectory"
|
|
102
|
+
)
|
|
103
|
+
console.print(f"\n[dim]Tip: cd {display_path(repo_root)}[/dim]")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
return cwd
|
forge/cli/handoff.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Handoff agent CLI commands.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
- forge handoff run: Execute the handoff agent for a session (background process)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group(hidden=True)
|
|
19
|
+
def handoff() -> None:
|
|
20
|
+
"""Manage handoff agent operations."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@handoff.command("run")
|
|
24
|
+
@click.option("--session-name", required=True, help="Forge session name")
|
|
25
|
+
@click.option(
|
|
26
|
+
"--worktree-path",
|
|
27
|
+
required=True,
|
|
28
|
+
type=click.Path(exists=True),
|
|
29
|
+
help="Absolute path to the worktree",
|
|
30
|
+
)
|
|
31
|
+
@click.option(
|
|
32
|
+
"--transcript-rel",
|
|
33
|
+
required=True,
|
|
34
|
+
help="Repo-relative path to transcript artifact",
|
|
35
|
+
)
|
|
36
|
+
@click.option("--timeout", default=None, type=int, help="Max seconds for agent to run")
|
|
37
|
+
@click.option("--subprocess-proxy", default=None, hidden=True, help="Stop-time subprocess proxy snapshot")
|
|
38
|
+
@click.option("--root", "forge_root", default=None, type=click.Path(), hidden=True, help="Explicit Forge project root")
|
|
39
|
+
def run_cmd(
|
|
40
|
+
session_name: str,
|
|
41
|
+
worktree_path: str,
|
|
42
|
+
transcript_rel: str,
|
|
43
|
+
timeout: int | None,
|
|
44
|
+
subprocess_proxy: str | None,
|
|
45
|
+
forge_root: str | None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Run the handoff agent for a completed session.
|
|
48
|
+
|
|
49
|
+
This is typically invoked by the work queue handler as a background process,
|
|
50
|
+
not directly by users. It reads the session manifest, checks if handoff is
|
|
51
|
+
enabled, and spawns claude -p to update project memory documents.
|
|
52
|
+
"""
|
|
53
|
+
worktree = Path(worktree_path).resolve()
|
|
54
|
+
effective_root = Path(forge_root).resolve() if forge_root else worktree
|
|
55
|
+
|
|
56
|
+
# We use SessionStore directly (not resolve_session_store) because this
|
|
57
|
+
# runs as a detached background process without FORGE_SESSION env var set.
|
|
58
|
+
# The marker payload carries session_name explicitly.
|
|
59
|
+
try:
|
|
60
|
+
from forge.session.effective import compute_effective_intent
|
|
61
|
+
from forge.session.store import SessionStore
|
|
62
|
+
|
|
63
|
+
store = SessionStore(str(effective_root), session_name)
|
|
64
|
+
if not store.exists():
|
|
65
|
+
logger.info("No session manifest for %s in %s", session_name, worktree)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
manifest = store.read()
|
|
69
|
+
effective = compute_effective_intent(manifest)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.warning("Failed to read session manifest for %s: %s", session_name, e)
|
|
72
|
+
raise SystemExit(1)
|
|
73
|
+
|
|
74
|
+
if not effective.memory or not effective.memory.auto_update:
|
|
75
|
+
logger.info("Handoff not configured for session %s", session_name)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
config = effective.memory.auto_update
|
|
79
|
+
if not config.enabled:
|
|
80
|
+
logger.info("Handoff disabled for session %s", session_name)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
from forge.session.handoff_agent import resolve_handoff_base_url, run_handoff_agent
|
|
84
|
+
|
|
85
|
+
confirmed_proxy_url = None
|
|
86
|
+
if manifest.confirmed.started_with_proxy:
|
|
87
|
+
confirmed_proxy_url = manifest.confirmed.started_with_proxy.base_url
|
|
88
|
+
|
|
89
|
+
base_url = resolve_handoff_base_url(
|
|
90
|
+
proxy_id=config.proxy,
|
|
91
|
+
confirmed_proxy_base_url=confirmed_proxy_url,
|
|
92
|
+
env_base_url=os.environ.get("ANTHROPIC_BASE_URL"),
|
|
93
|
+
direct=config.direct,
|
|
94
|
+
subprocess_proxy=subprocess_proxy or effective.subprocess_proxy,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
designated_docs = effective.memory.designated_docs if effective.memory else []
|
|
98
|
+
|
|
99
|
+
success = run_handoff_agent(
|
|
100
|
+
session_name=session_name,
|
|
101
|
+
forge_root=effective_root,
|
|
102
|
+
transcript_snapshot_rel=transcript_rel,
|
|
103
|
+
config=config,
|
|
104
|
+
base_url=base_url,
|
|
105
|
+
timeout_seconds=timeout,
|
|
106
|
+
designated_docs=designated_docs,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if not success:
|
|
110
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""CLI hook commands for Claude Code integration.
|
|
2
|
+
|
|
3
|
+
This package was decomposed from a single 2,490-line ``hooks.py`` module (L7).
|
|
4
|
+
Each submodule owns a distinct concern:
|
|
5
|
+
|
|
6
|
+
- ``_group``: Click group definition
|
|
7
|
+
- ``commands``: Hook entry points (session-start, plan-write, stop, etc.)
|
|
8
|
+
- ``verification``: Ralph-Wiggum verification logic
|
|
9
|
+
- ``direct_commands``: ``%`` command dispatcher and handlers
|
|
10
|
+
- ``policy``: Policy check helpers
|
|
11
|
+
- ``install``: Hook enable/disable
|
|
12
|
+
- ``_helpers``: Shared I/O helpers
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
# The Click group — imported by cli/main.py
|
|
18
|
+
from ._group import hooks
|
|
19
|
+
from . import commands as _commands # noqa: F401 — registers @hooks.command() decorators
|
|
20
|
+
from .install import FORGE_HOOK_CONFIG, SETTINGS_FILENAME, enable, disable
|
|
21
|
+
from .verification import (
|
|
22
|
+
_get_last_assistant_text_for_verification,
|
|
23
|
+
_run_verification_check,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Register enable/disable as subcommands of the hooks group
|
|
27
|
+
hooks.add_command(enable)
|
|
28
|
+
hooks.add_command(disable)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"hooks",
|
|
32
|
+
"FORGE_HOOK_CONFIG",
|
|
33
|
+
"SETTINGS_FILENAME",
|
|
34
|
+
"_run_verification_check",
|
|
35
|
+
"_get_last_assistant_text_for_verification",
|
|
36
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Click group for Forge hook commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group(name="hook", hidden=True)
|
|
9
|
+
@click.pass_context
|
|
10
|
+
def hooks(ctx: click.Context) -> None:
|
|
11
|
+
"""Hook handlers invoked by Claude Code.
|
|
12
|
+
|
|
13
|
+
Most subcommands are invoked automatically by Claude Code hooks
|
|
14
|
+
configured in .claude/settings.local.json. The 'enable' and
|
|
15
|
+
'disable' subcommands are user-facing.
|
|
16
|
+
"""
|
|
17
|
+
from forge.core.logging import configure_debug_logging
|
|
18
|
+
|
|
19
|
+
hook_name = ctx.invoked_subcommand or "hook"
|
|
20
|
+
configure_debug_logging(component=hook_name, subdirectory="hooks")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Shared I/O helpers used across hook command modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from forge.session.hooks import HookResult
|
|
13
|
+
from forge.session.models import SessionState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_latest_plan_from_transcript(transcript_path: str, cwd: Path) -> Path | None:
|
|
17
|
+
"""Streaming scan for last plan file write.
|
|
18
|
+
|
|
19
|
+
This is a fallback only; it avoids loading the entire transcript into memory.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
path = Path(transcript_path)
|
|
23
|
+
if not path.is_file():
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
latest: Path | None = None
|
|
27
|
+
try:
|
|
28
|
+
with path.open(encoding="utf-8") as f:
|
|
29
|
+
for line in f:
|
|
30
|
+
line = line.strip()
|
|
31
|
+
if not line:
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
entry = json.loads(line)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
if entry.get("type") != "assistant":
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
message = entry.get("message")
|
|
42
|
+
if not isinstance(message, dict):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
content = message.get("content")
|
|
46
|
+
if not isinstance(content, list):
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
for block in content:
|
|
50
|
+
if not isinstance(block, dict):
|
|
51
|
+
continue
|
|
52
|
+
if block.get("type") != "tool_use":
|
|
53
|
+
continue
|
|
54
|
+
if block.get("name") != "Write":
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
tool_input = block.get("input")
|
|
58
|
+
if not isinstance(tool_input, dict):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
fp = tool_input.get("file_path")
|
|
62
|
+
if not isinstance(fp, str) or not fp:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if "/.claude/plans/" not in fp and not fp.startswith(".claude/plans/"):
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
candidate = Path(fp)
|
|
69
|
+
if candidate.is_absolute():
|
|
70
|
+
try:
|
|
71
|
+
candidate = candidate.resolve().relative_to(cwd)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
latest = cwd / candidate
|
|
76
|
+
except Exception:
|
|
77
|
+
return latest
|
|
78
|
+
|
|
79
|
+
return latest
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _output_json(data: dict[str, Any]) -> None:
|
|
83
|
+
"""Output hook result as JSON to stdout.
|
|
84
|
+
|
|
85
|
+
For non-SessionStart hooks, we return a small JSON payload for debugging,
|
|
86
|
+
but avoid any UI-facing `systemMessage`.
|
|
87
|
+
"""
|
|
88
|
+
click.echo(json.dumps(data, indent=2))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _output_result(result: HookResult) -> None:
|
|
92
|
+
"""Output SessionStart hook result as JSON to stdout."""
|
|
93
|
+
_output_json(result.to_dict())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_stdin_json() -> tuple[dict[str, Any] | None, str | None]:
|
|
97
|
+
"""Read hook JSON payload from stdin.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
(parsed_dict, error)
|
|
101
|
+
|
|
102
|
+
- If input is empty/whitespace: (None, "empty")
|
|
103
|
+
- If JSON is invalid or not an object: (None, "invalid_json")
|
|
104
|
+
- If valid: (dict, None)
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
stdin_data = sys.stdin.read()
|
|
108
|
+
if not stdin_data.strip():
|
|
109
|
+
return None, "empty"
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
parsed = json.loads(stdin_data)
|
|
113
|
+
except Exception:
|
|
114
|
+
return None, "invalid_json"
|
|
115
|
+
|
|
116
|
+
if not isinstance(parsed, dict):
|
|
117
|
+
return None, "invalid_json"
|
|
118
|
+
|
|
119
|
+
return parsed, None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _print_session_tip(manifest: SessionState) -> None:
|
|
123
|
+
"""No-op: SessionEnd hook output is suppressed by Claude Code.
|
|
124
|
+
|
|
125
|
+
See anthropics/claude-code#9090. The reconnect tip is printed from
|
|
126
|
+
the parent launcher process instead (_print_post_exit_tip in session.py).
|
|
127
|
+
Kept as a stub so the session-end hook doesn't break if called.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _append_artifact_entry(
|
|
132
|
+
confirmed_artifacts: dict[str, Any],
|
|
133
|
+
*,
|
|
134
|
+
kind: str,
|
|
135
|
+
entry: dict[str, Any],
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Append an artifact record under confirmed.artifacts in a stable shape."""
|
|
138
|
+
|
|
139
|
+
items = confirmed_artifacts.get(kind)
|
|
140
|
+
if items is None:
|
|
141
|
+
confirmed_artifacts[kind] = [entry]
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
if not isinstance(items, list):
|
|
145
|
+
# If the field was corrupted or mis-typed, clobber to a list.
|
|
146
|
+
confirmed_artifacts[kind] = [entry]
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
items.append(entry)
|