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,1677 @@
|
|
|
1
|
+
"""Hook command entry points invoked by Claude Code.
|
|
2
|
+
|
|
3
|
+
Each function is a Click command registered on the ``hooks`` group.
|
|
4
|
+
Heavy logic is delegated to submodules (verification, direct_commands, policy).
|
|
5
|
+
|
|
6
|
+
CRITICAL: Always exit 0 on errors — don't break Claude.
|
|
7
|
+
Exception: WorktreeCreate exits 1 on failure (replaces Claude's default git behavior).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from forge.core.state import FileLockTimeoutError, now_iso
|
|
23
|
+
from forge.core.workqueue import (
|
|
24
|
+
enqueue_handoff_marker,
|
|
25
|
+
enqueue_index_marker,
|
|
26
|
+
enqueue_stop_marker,
|
|
27
|
+
)
|
|
28
|
+
from forge.session.artifacts import (
|
|
29
|
+
get_artifact_paths,
|
|
30
|
+
resolve_forge_root,
|
|
31
|
+
safe_copy_file,
|
|
32
|
+
snapshot_plan_approved,
|
|
33
|
+
)
|
|
34
|
+
from forge.session.effective import compute_effective_intent
|
|
35
|
+
from forge.session.hooks import (
|
|
36
|
+
HookResult,
|
|
37
|
+
handle_session_start,
|
|
38
|
+
parse_hook_input,
|
|
39
|
+
resolve_session_store,
|
|
40
|
+
)
|
|
41
|
+
from forge.session.store import HOOK_LOCK_TIMEOUT_S
|
|
42
|
+
|
|
43
|
+
from ._group import hooks
|
|
44
|
+
from ._helpers import (
|
|
45
|
+
_append_artifact_entry,
|
|
46
|
+
_find_latest_plan_from_transcript,
|
|
47
|
+
_output_json,
|
|
48
|
+
_output_result,
|
|
49
|
+
_read_stdin_json,
|
|
50
|
+
)
|
|
51
|
+
from .direct_commands import (
|
|
52
|
+
_handle_cmd_cancel_verification,
|
|
53
|
+
_handle_cmd_clean,
|
|
54
|
+
_handle_cmd_config,
|
|
55
|
+
_handle_cmd_guard,
|
|
56
|
+
_handle_cmd_help,
|
|
57
|
+
_handle_cmd_plan,
|
|
58
|
+
_handle_cmd_proxy,
|
|
59
|
+
_handle_cmd_session,
|
|
60
|
+
_parse_direct_command,
|
|
61
|
+
)
|
|
62
|
+
from .policy import (
|
|
63
|
+
_build_action_context,
|
|
64
|
+
_derive_policy_source_label,
|
|
65
|
+
_persist_policy_state,
|
|
66
|
+
)
|
|
67
|
+
from .verification import _run_verification_check
|
|
68
|
+
|
|
69
|
+
logger = logging.getLogger(__name__)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@hooks.command(name="session-start")
|
|
73
|
+
@click.option(
|
|
74
|
+
"--cwd",
|
|
75
|
+
"-C",
|
|
76
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
77
|
+
default=None,
|
|
78
|
+
help="Working directory (defaults to current directory)",
|
|
79
|
+
)
|
|
80
|
+
def session_start(cwd: Path | None) -> None:
|
|
81
|
+
"""Handle SessionStart hook from Claude Code.
|
|
82
|
+
|
|
83
|
+
Reads JSON from stdin with session info, reconciles session state,
|
|
84
|
+
and outputs JSON result to stdout.
|
|
85
|
+
|
|
86
|
+
Expected stdin format:
|
|
87
|
+
{"session_id": "...", "transcript_path": "...", "source": "startup|resume|compact|clear"}
|
|
88
|
+
|
|
89
|
+
Always exits 0 to avoid breaking Claude. Errors are reported in JSON output.
|
|
90
|
+
"""
|
|
91
|
+
if cwd is None:
|
|
92
|
+
cwd = Path.cwd()
|
|
93
|
+
|
|
94
|
+
data, err = _read_stdin_json()
|
|
95
|
+
if data is None:
|
|
96
|
+
message = "No input received on stdin" if err == "empty" else "Invalid JSON"
|
|
97
|
+
result = HookResult(
|
|
98
|
+
success=False,
|
|
99
|
+
error="invalid_input",
|
|
100
|
+
message=message,
|
|
101
|
+
)
|
|
102
|
+
_output_result(result)
|
|
103
|
+
return
|
|
104
|
+
logger.debug("session-start: session_id=%s", str(data.get("session_id", "?"))[:12])
|
|
105
|
+
|
|
106
|
+
hook_input = parse_hook_input(data)
|
|
107
|
+
if hook_input is None:
|
|
108
|
+
result = HookResult(
|
|
109
|
+
success=False,
|
|
110
|
+
error="invalid_input",
|
|
111
|
+
message="Missing or invalid required fields: session_id, transcript_path, source",
|
|
112
|
+
)
|
|
113
|
+
_output_result(result)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
result = handle_session_start(hook_input, cwd)
|
|
117
|
+
_output_result(result)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@hooks.command(name="plan-write")
|
|
121
|
+
def plan_write() -> None:
|
|
122
|
+
"""Record latest plan file path on PostToolUse:Write.
|
|
123
|
+
|
|
124
|
+
This hook runs frequently (every Write), so it must be cheap:
|
|
125
|
+
- If the file written is not a plan file, exit successfully with no-op.
|
|
126
|
+
- If it is a plan file, store `confirmed.latest_plan_path` in the manifest.
|
|
127
|
+
|
|
128
|
+
Expected stdin keys (best-effort; we tolerate extra fields):
|
|
129
|
+
- hook_event_name: "PostToolUse"
|
|
130
|
+
- tool_input.file_path: path written (may be absolute or worktree-relative)
|
|
131
|
+
|
|
132
|
+
Always exits 0.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
data, err = _read_stdin_json()
|
|
136
|
+
if data is None:
|
|
137
|
+
message = "empty stdin" if err == "empty" else "invalid JSON"
|
|
138
|
+
_output_json({"success": False, "error": "invalid_input", "message": message})
|
|
139
|
+
return
|
|
140
|
+
logger.debug(
|
|
141
|
+
"plan-write: event=%s tool=%s",
|
|
142
|
+
data.get("hook_event_name"),
|
|
143
|
+
data.get("tool_name"),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if data.get("hook_event_name") != "PostToolUse":
|
|
147
|
+
_output_json({"success": True, "action": "skip", "reason": "wrong_event"})
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
tool_input = data.get("tool_input")
|
|
151
|
+
if not isinstance(tool_input, dict):
|
|
152
|
+
_output_json({"success": True, "action": "skip", "reason": "no_tool_input"})
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Some variants use file_path; keep a couple of fallbacks.
|
|
156
|
+
file_path = tool_input.get("file_path") or tool_input.get("path")
|
|
157
|
+
if not isinstance(file_path, str) or not file_path:
|
|
158
|
+
_output_json({"success": True, "action": "skip", "reason": "no_file_path"})
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Only record plan files
|
|
162
|
+
if "/.claude/plans/" not in file_path and not file_path.startswith(".claude/plans/"):
|
|
163
|
+
_output_json({"success": True, "action": "skip", "reason": "not_a_plan"})
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
cwd = Path.cwd().resolve()
|
|
167
|
+
store = resolve_session_store(cwd, session_id=data.get("session_id"))
|
|
168
|
+
if store is None:
|
|
169
|
+
_output_json({"success": True, "action": "skip", "reason": "no_session"})
|
|
170
|
+
return
|
|
171
|
+
try:
|
|
172
|
+
store.read()
|
|
173
|
+
except Exception as e:
|
|
174
|
+
_output_json({"success": False, "error": "manifest_read_failed", "message": str(e)})
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
# Normalize to worktree-relative if possible
|
|
178
|
+
plan_path = Path(file_path)
|
|
179
|
+
if plan_path.is_absolute():
|
|
180
|
+
try:
|
|
181
|
+
plan_path = plan_path.resolve().relative_to(cwd)
|
|
182
|
+
except Exception:
|
|
183
|
+
# If we can't make it relative, store as-is.
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
|
|
188
|
+
def _mutate(m: object) -> None:
|
|
189
|
+
# Type narrow via runtime checks.
|
|
190
|
+
from forge.session.models import SessionState
|
|
191
|
+
|
|
192
|
+
if not isinstance(m, SessionState):
|
|
193
|
+
raise TypeError(f"Expected SessionState, got {type(m)}")
|
|
194
|
+
|
|
195
|
+
m.confirmed.latest_plan_path = str(plan_path)
|
|
196
|
+
m.confirmed.confirmed_at = now_iso()
|
|
197
|
+
m.confirmed.confirmed_by = "hook:plan-write"
|
|
198
|
+
|
|
199
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
200
|
+
except FileLockTimeoutError:
|
|
201
|
+
_output_json({"success": True, "action": "skip_lock_contended"})
|
|
202
|
+
return
|
|
203
|
+
except Exception as e:
|
|
204
|
+
_output_json({"success": False, "error": "manifest_write_failed", "message": str(e)})
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
_output_json({"success": True, "action": "recorded", "latest_plan_path": str(plan_path)})
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@hooks.command(name="exit-plan-mode")
|
|
211
|
+
def exit_plan_mode() -> None:
|
|
212
|
+
"""Capture an approved plan snapshot on PreToolUse:ExitPlanMode.
|
|
213
|
+
|
|
214
|
+
This is treated as the plan approval boundary.
|
|
215
|
+
|
|
216
|
+
Expected stdin keys (best-effort):
|
|
217
|
+
- hook_event_name: "PreToolUse"
|
|
218
|
+
- transcript_path: used only as fallback to locate the most recent plan file
|
|
219
|
+
|
|
220
|
+
Always exits 0.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
data, err = _read_stdin_json()
|
|
224
|
+
if data is None:
|
|
225
|
+
message = "empty stdin" if err == "empty" else "invalid JSON"
|
|
226
|
+
_output_json({"success": False, "error": "invalid_input", "message": message})
|
|
227
|
+
return
|
|
228
|
+
logger.debug(
|
|
229
|
+
"exit-plan-mode: event=%s tool=%s",
|
|
230
|
+
data.get("hook_event_name"),
|
|
231
|
+
data.get("tool_name"),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if data.get("hook_event_name") != "PreToolUse":
|
|
235
|
+
_output_json({"success": True, "action": "skip", "reason": "wrong_event"})
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
cwd = Path.cwd().resolve()
|
|
239
|
+
store = resolve_session_store(cwd, session_id=data.get("session_id"))
|
|
240
|
+
if store is None:
|
|
241
|
+
_output_json({"success": True, "action": "skip", "reason": "no_session"})
|
|
242
|
+
return
|
|
243
|
+
try:
|
|
244
|
+
manifest = store.read()
|
|
245
|
+
except Exception as e:
|
|
246
|
+
_output_json({"success": False, "error": "manifest_read_failed", "message": str(e)})
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
source_plan_path: Path | None = None
|
|
250
|
+
if manifest.confirmed.latest_plan_path:
|
|
251
|
+
source_plan_path = cwd / manifest.confirmed.latest_plan_path
|
|
252
|
+
|
|
253
|
+
# Fallback: scan transcript for last plan write (streaming; no full read).
|
|
254
|
+
if source_plan_path is None or not source_plan_path.is_file():
|
|
255
|
+
transcript_path = data.get("transcript_path")
|
|
256
|
+
if isinstance(transcript_path, str) and transcript_path:
|
|
257
|
+
source_plan_path = _find_latest_plan_from_transcript(transcript_path, cwd)
|
|
258
|
+
|
|
259
|
+
if source_plan_path is None or not source_plan_path.is_file():
|
|
260
|
+
_output_json({"success": False, "error": "plan_not_found"})
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
# Compute artifact roots
|
|
264
|
+
project_root = resolve_forge_root(cwd)
|
|
265
|
+
paths = get_artifact_paths(project_root, manifest.name)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
snapshot_abs, snapshot_rel = snapshot_plan_approved(
|
|
269
|
+
paths=paths,
|
|
270
|
+
source_plan_path=source_plan_path,
|
|
271
|
+
)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
_output_json({"success": False, "error": "snapshot_failed", "message": str(e)})
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
source_plan_str = manifest.confirmed.latest_plan_path or str(source_plan_path)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
|
|
280
|
+
def _mutate(m: object) -> None:
|
|
281
|
+
from forge.session.models import SessionState
|
|
282
|
+
|
|
283
|
+
if not isinstance(m, SessionState):
|
|
284
|
+
raise TypeError(f"Expected SessionState, got {type(m)}")
|
|
285
|
+
|
|
286
|
+
artifacts = m.confirmed.artifacts
|
|
287
|
+
|
|
288
|
+
# Content-addressable snapshot_path makes re-approval of identical
|
|
289
|
+
# content a no-op on disk. Dedupe the audit entry too, but keep the
|
|
290
|
+
# most recently approved unique plan at the end of the list so
|
|
291
|
+
# readers that use "last approved snapshot wins" still surface the
|
|
292
|
+
# current approval after A->B->A.
|
|
293
|
+
existing = artifacts.get("plans")
|
|
294
|
+
new_snapshot_path = str(snapshot_rel)
|
|
295
|
+
if isinstance(existing, list):
|
|
296
|
+
artifacts["plans"] = [
|
|
297
|
+
entry
|
|
298
|
+
for entry in existing
|
|
299
|
+
if not (isinstance(entry, dict) and entry.get("snapshot_path") == new_snapshot_path)
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
_append_artifact_entry(
|
|
303
|
+
artifacts,
|
|
304
|
+
kind="plans",
|
|
305
|
+
entry={
|
|
306
|
+
"kind": "approved",
|
|
307
|
+
"captured_at": now_iso(),
|
|
308
|
+
"source_path": source_plan_str,
|
|
309
|
+
"snapshot_path": str(snapshot_rel),
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
m.confirmed.confirmed_at = now_iso()
|
|
314
|
+
m.confirmed.confirmed_by = "hook:exit-plan-mode"
|
|
315
|
+
|
|
316
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
317
|
+
|
|
318
|
+
except FileLockTimeoutError:
|
|
319
|
+
_output_json({"success": True, "action": "skip_lock_contended"})
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
except Exception as e:
|
|
323
|
+
_output_json({"success": False, "error": "manifest_write_failed", "message": str(e)})
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
_output_json({"success": True, "action": "snapshotted", "snapshot_path": str(snapshot_rel)})
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@hooks.command(name="stop")
|
|
330
|
+
def stop() -> None:
|
|
331
|
+
"""Capture transcript copy on Stop, with optional verification policy.
|
|
332
|
+
|
|
333
|
+
Expected stdin keys (best-effort):
|
|
334
|
+
- hook_event_name: "Stop"
|
|
335
|
+
- transcript_path
|
|
336
|
+
- session_id
|
|
337
|
+
|
|
338
|
+
Exit codes:
|
|
339
|
+
- 0: Allow Stop (normal flow, or verification passed/disabled)
|
|
340
|
+
- 2: Block Stop (verification failed)
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
data, err = _read_stdin_json()
|
|
344
|
+
if data is None:
|
|
345
|
+
message = "empty stdin" if err == "empty" else "invalid JSON"
|
|
346
|
+
_output_json({"success": False, "error": "invalid_input", "message": message})
|
|
347
|
+
return
|
|
348
|
+
logger.debug("stop: session_id=%s", str(data.get("session_id", "?"))[:12])
|
|
349
|
+
|
|
350
|
+
if data.get("hook_event_name") != "Stop":
|
|
351
|
+
_output_json({"success": True, "action": "skip", "reason": "wrong_event"})
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
cwd = Path.cwd().resolve()
|
|
355
|
+
pending_transcript_path: Path | None = None
|
|
356
|
+
raw_transcript_path = data.get("transcript_path")
|
|
357
|
+
if isinstance(raw_transcript_path, str) and raw_transcript_path:
|
|
358
|
+
candidate = Path(raw_transcript_path)
|
|
359
|
+
pending_transcript_path = candidate if candidate.is_absolute() else (cwd / candidate)
|
|
360
|
+
incoming_session_id = data.get("session_id") if isinstance(data.get("session_id"), str) else None
|
|
361
|
+
|
|
362
|
+
store = resolve_session_store(cwd, session_id=data.get("session_id"))
|
|
363
|
+
if store is None:
|
|
364
|
+
if pending_transcript_path is not None:
|
|
365
|
+
_copy_transcript_to_pending_runs(pending_transcript_path, session_id=incoming_session_id)
|
|
366
|
+
_output_json({"success": True, "action": "skip", "reason": "no_session"})
|
|
367
|
+
return
|
|
368
|
+
try:
|
|
369
|
+
manifest = store.read()
|
|
370
|
+
except Exception as e:
|
|
371
|
+
_output_json({"success": False, "error": "manifest_read_failed", "message": str(e)})
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
transcript_path = data.get("transcript_path")
|
|
375
|
+
if not isinstance(transcript_path, str) or not transcript_path:
|
|
376
|
+
transcript_path = manifest.confirmed.transcript_path
|
|
377
|
+
|
|
378
|
+
if not transcript_path:
|
|
379
|
+
_output_json({"success": False, "error": "missing_transcript_path"})
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
session_id = data.get("session_id")
|
|
383
|
+
if not isinstance(session_id, str) or not session_id:
|
|
384
|
+
session_id = manifest.confirmed.claude_session_id
|
|
385
|
+
|
|
386
|
+
if not session_id:
|
|
387
|
+
_output_json({"success": False, "error": "missing_session_id"})
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
project_root = resolve_forge_root(cwd)
|
|
391
|
+
paths = get_artifact_paths(project_root, manifest.name)
|
|
392
|
+
|
|
393
|
+
src = Path(transcript_path)
|
|
394
|
+
dst_abs = paths.transcripts_abs / f"{session_id}.jsonl"
|
|
395
|
+
dst_rel = paths.transcripts_rel / f"{session_id}.jsonl"
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
# Claude can invoke Stop repeatedly for the same session UUID as the
|
|
399
|
+
# transcript grows turn by turn. Refresh the UUID-named artifact so
|
|
400
|
+
# search/index consumers see the latest snapshot instead of the first
|
|
401
|
+
# turn that happened to be copied.
|
|
402
|
+
copied = safe_copy_file(src, dst_abs, overwrite=True)
|
|
403
|
+
except Exception as e:
|
|
404
|
+
_output_json({"success": False, "error": "copy_failed", "message": str(e)})
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
# Copy transcript to pending run directories (QA/walkthrough artifacts)
|
|
408
|
+
_copy_transcript_to_pending_runs(dst_abs, session_id=session_id)
|
|
409
|
+
|
|
410
|
+
# Track manifest update outcome (but don't return early - we still want to enqueue)
|
|
411
|
+
manifest_updated = True
|
|
412
|
+
manifest_error: str | None = None
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
|
|
416
|
+
def _mutate(m: object) -> None:
|
|
417
|
+
from forge.session.models import SessionState
|
|
418
|
+
|
|
419
|
+
if not isinstance(m, SessionState):
|
|
420
|
+
raise TypeError(f"Expected SessionState, got {type(m)}")
|
|
421
|
+
|
|
422
|
+
artifacts = m.confirmed.artifacts
|
|
423
|
+
_append_artifact_entry(
|
|
424
|
+
artifacts,
|
|
425
|
+
kind="transcripts",
|
|
426
|
+
entry={
|
|
427
|
+
"captured_at": now_iso(),
|
|
428
|
+
"reason": "stop",
|
|
429
|
+
"source_path": transcript_path,
|
|
430
|
+
"session_id": session_id,
|
|
431
|
+
"copied_path": str(dst_rel),
|
|
432
|
+
"copied": copied,
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Stop carries the live conversation identity. SessionStart can
|
|
437
|
+
# lag or report an inherited UUID for native fork launches, so
|
|
438
|
+
# reconcile from Stop before later cleanup/resume code reads it.
|
|
439
|
+
m.confirmed.claude_session_id = session_id
|
|
440
|
+
m.confirmed.transcript_path = transcript_path
|
|
441
|
+
|
|
442
|
+
# Record policy provenance (always on Stop, per design §4.1.6)
|
|
443
|
+
from forge import __version__
|
|
444
|
+
from forge.session.models import PolicyConfirmed
|
|
445
|
+
|
|
446
|
+
if m.confirmed.policy:
|
|
447
|
+
m.confirmed.policy.forge_version = __version__
|
|
448
|
+
else:
|
|
449
|
+
m.confirmed.policy = PolicyConfirmed(forge_version=__version__)
|
|
450
|
+
|
|
451
|
+
m.confirmed.confirmed_at = now_iso()
|
|
452
|
+
m.confirmed.confirmed_by = "hook:stop"
|
|
453
|
+
|
|
454
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
455
|
+
try:
|
|
456
|
+
from forge.session.index import IndexStore
|
|
457
|
+
|
|
458
|
+
IndexStore().update_uuid(manifest.name, session_id, forge_root=str(store.forge_root))
|
|
459
|
+
except Exception:
|
|
460
|
+
logger.debug("Stop hook: index UUID sync failed", exc_info=True)
|
|
461
|
+
|
|
462
|
+
except FileLockTimeoutError:
|
|
463
|
+
manifest_updated = False
|
|
464
|
+
manifest_error = "lock_contended"
|
|
465
|
+
|
|
466
|
+
except Exception as e:
|
|
467
|
+
manifest_updated = False
|
|
468
|
+
manifest_error = str(e)
|
|
469
|
+
|
|
470
|
+
# Run verification check (Ralph-Wiggum pattern)
|
|
471
|
+
# This must happen AFTER transcript copy so we have the artifact to check
|
|
472
|
+
# Re-read manifest to get latest state (may have been updated by artifact write)
|
|
473
|
+
try:
|
|
474
|
+
manifest = store.read()
|
|
475
|
+
except Exception:
|
|
476
|
+
pass # Use stale manifest for verification - better than skipping
|
|
477
|
+
|
|
478
|
+
should_allow_stop, block_message = _run_verification_check(
|
|
479
|
+
store=store,
|
|
480
|
+
manifest=manifest,
|
|
481
|
+
transcript_path=dst_abs, # Check the copied artifact, not the original
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if not should_allow_stop:
|
|
485
|
+
# Verification failed - block Stop
|
|
486
|
+
# Do NOT enqueue pending-work since we're blocking
|
|
487
|
+
click.echo(block_message, err=True)
|
|
488
|
+
sys.exit(2)
|
|
489
|
+
|
|
490
|
+
# Enqueue pending-work markers for deferred processing (best-effort)
|
|
491
|
+
# Important: enqueue even if manifest update failed - the transcript artifact
|
|
492
|
+
# exists on disk and deferred work should still be triggered.
|
|
493
|
+
# Only enqueue if verification passed (we reach here only if should_allow_stop=True)
|
|
494
|
+
effective_forge_root = str(store.forge_root) if store else None
|
|
495
|
+
queued_stop = (
|
|
496
|
+
enqueue_stop_marker(
|
|
497
|
+
session_id=session_id,
|
|
498
|
+
worktree_path=cwd,
|
|
499
|
+
session_name=manifest.name,
|
|
500
|
+
transcript_snapshot_rel=str(dst_rel),
|
|
501
|
+
forge_root=effective_forge_root,
|
|
502
|
+
)
|
|
503
|
+
is not None
|
|
504
|
+
)
|
|
505
|
+
queued_index = (
|
|
506
|
+
enqueue_index_marker(
|
|
507
|
+
session_id=session_id,
|
|
508
|
+
worktree_path=cwd,
|
|
509
|
+
session_name=manifest.name,
|
|
510
|
+
transcript_snapshot_rel=str(dst_rel),
|
|
511
|
+
forge_root=effective_forge_root,
|
|
512
|
+
)
|
|
513
|
+
is not None
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Enqueue handoff marker if auto_update is enabled (best-effort)
|
|
517
|
+
queued_handoff = False
|
|
518
|
+
try:
|
|
519
|
+
effective = compute_effective_intent(manifest)
|
|
520
|
+
if effective.memory and effective.memory.auto_update and effective.memory.auto_update.enabled:
|
|
521
|
+
queued_handoff = (
|
|
522
|
+
enqueue_handoff_marker(
|
|
523
|
+
session_id=session_id,
|
|
524
|
+
worktree_path=cwd,
|
|
525
|
+
session_name=manifest.name,
|
|
526
|
+
transcript_snapshot_rel=str(dst_rel),
|
|
527
|
+
subprocess_proxy=effective.subprocess_proxy,
|
|
528
|
+
forge_root=effective_forge_root,
|
|
529
|
+
)
|
|
530
|
+
is not None
|
|
531
|
+
)
|
|
532
|
+
except Exception:
|
|
533
|
+
pass # Best-effort: don't break stop hook on handoff enqueue failure
|
|
534
|
+
|
|
535
|
+
if not manifest_updated:
|
|
536
|
+
# Manifest failed but we still tried to enqueue
|
|
537
|
+
_output_json(
|
|
538
|
+
{
|
|
539
|
+
"success": True,
|
|
540
|
+
"action": "partial",
|
|
541
|
+
"copied_path": str(dst_rel),
|
|
542
|
+
"copied": copied,
|
|
543
|
+
"manifest_updated": False,
|
|
544
|
+
"manifest_error": manifest_error,
|
|
545
|
+
"queued": queued_stop,
|
|
546
|
+
"queued_index": queued_index,
|
|
547
|
+
"queued_handoff": queued_handoff,
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
else:
|
|
551
|
+
_output_json(
|
|
552
|
+
{
|
|
553
|
+
"success": True,
|
|
554
|
+
"action": "copied",
|
|
555
|
+
"copied_path": str(dst_rel),
|
|
556
|
+
"copied": copied,
|
|
557
|
+
"queued": queued_stop,
|
|
558
|
+
"queued_index": queued_index,
|
|
559
|
+
"queued_handoff": queued_handoff,
|
|
560
|
+
}
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@hooks.command(name="stop-failure")
|
|
565
|
+
def stop_failure() -> None:
|
|
566
|
+
"""Best-effort transcript capture on StopFailure.
|
|
567
|
+
|
|
568
|
+
When Claude's stop fails (crash, timeout, etc.), this hook fires as a
|
|
569
|
+
last-chance opportunity to capture the transcript and enqueue work markers.
|
|
570
|
+
No verification is performed (the session is already in a failed state).
|
|
571
|
+
|
|
572
|
+
Expected stdin keys (best-effort):
|
|
573
|
+
- hook_event_name: "StopFailure"
|
|
574
|
+
- transcript_path
|
|
575
|
+
- session_id
|
|
576
|
+
|
|
577
|
+
Always exits 0 (fail-open).
|
|
578
|
+
"""
|
|
579
|
+
data, err = _read_stdin_json()
|
|
580
|
+
if data is None:
|
|
581
|
+
message = "empty stdin" if err == "empty" else "invalid JSON"
|
|
582
|
+
_output_json({"success": False, "error": "invalid_input", "message": message})
|
|
583
|
+
return
|
|
584
|
+
logger.debug("stop-failure: session_id=%s", str(data.get("session_id", "?"))[:12])
|
|
585
|
+
|
|
586
|
+
if data.get("hook_event_name") != "StopFailure":
|
|
587
|
+
_output_json({"success": True, "action": "skip", "reason": "wrong_event"})
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
cwd = Path.cwd().resolve()
|
|
591
|
+
|
|
592
|
+
# Best-effort transcript copy to pending runs even without a session
|
|
593
|
+
pending_transcript_path: Path | None = None
|
|
594
|
+
raw_transcript_path = data.get("transcript_path")
|
|
595
|
+
if isinstance(raw_transcript_path, str) and raw_transcript_path:
|
|
596
|
+
candidate = Path(raw_transcript_path)
|
|
597
|
+
pending_transcript_path = candidate if candidate.is_absolute() else (cwd / candidate)
|
|
598
|
+
incoming_session_id = data.get("session_id") if isinstance(data.get("session_id"), str) else None
|
|
599
|
+
|
|
600
|
+
store = resolve_session_store(cwd, session_id=data.get("session_id"))
|
|
601
|
+
if store is None:
|
|
602
|
+
if pending_transcript_path is not None:
|
|
603
|
+
_copy_transcript_to_pending_runs(pending_transcript_path, session_id=incoming_session_id)
|
|
604
|
+
_output_json({"success": True, "action": "skip", "reason": "no_session"})
|
|
605
|
+
return
|
|
606
|
+
try:
|
|
607
|
+
manifest = store.read()
|
|
608
|
+
except Exception as e:
|
|
609
|
+
_output_json({"success": False, "error": "manifest_read_failed", "message": str(e)})
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
transcript_path = data.get("transcript_path")
|
|
613
|
+
if not isinstance(transcript_path, str) or not transcript_path:
|
|
614
|
+
transcript_path = manifest.confirmed.transcript_path
|
|
615
|
+
if not transcript_path:
|
|
616
|
+
_output_json({"success": True, "action": "skip", "reason": "no_transcript_path"})
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
session_id = data.get("session_id")
|
|
620
|
+
if not isinstance(session_id, str) or not session_id:
|
|
621
|
+
session_id = manifest.confirmed.claude_session_id
|
|
622
|
+
if not session_id:
|
|
623
|
+
_output_json({"success": True, "action": "skip", "reason": "no_session_id"})
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
project_root = resolve_forge_root(cwd)
|
|
627
|
+
paths = get_artifact_paths(project_root, manifest.name)
|
|
628
|
+
|
|
629
|
+
src = Path(transcript_path)
|
|
630
|
+
dst_abs = paths.transcripts_abs / f"{session_id}.jsonl"
|
|
631
|
+
dst_rel = paths.transcripts_rel / f"{session_id}.jsonl"
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
# Keep the session artifact aligned with the latest transcript snapshot
|
|
635
|
+
# here too; StopFailure can arrive after earlier Stop captures.
|
|
636
|
+
copied = safe_copy_file(src, dst_abs, overwrite=True)
|
|
637
|
+
except Exception:
|
|
638
|
+
copied = False
|
|
639
|
+
|
|
640
|
+
# Only fan out and enqueue if the artifact actually exists on disk.
|
|
641
|
+
# Otherwise we'd consume pending-transcript markers and create index
|
|
642
|
+
# markers that retry until poison handling kicks in.
|
|
643
|
+
if dst_abs.is_file():
|
|
644
|
+
_copy_transcript_to_pending_runs(dst_abs, session_id=session_id)
|
|
645
|
+
|
|
646
|
+
# Best-effort manifest update
|
|
647
|
+
try:
|
|
648
|
+
|
|
649
|
+
def _mutate(m: object) -> None:
|
|
650
|
+
from forge.session.models import SessionState
|
|
651
|
+
|
|
652
|
+
if not isinstance(m, SessionState):
|
|
653
|
+
raise TypeError(f"Expected SessionState, got {type(m)}")
|
|
654
|
+
|
|
655
|
+
artifacts = m.confirmed.artifacts
|
|
656
|
+
_append_artifact_entry(
|
|
657
|
+
artifacts,
|
|
658
|
+
kind="transcripts",
|
|
659
|
+
entry={
|
|
660
|
+
"captured_at": now_iso(),
|
|
661
|
+
"reason": "stop-failure",
|
|
662
|
+
"source_path": transcript_path,
|
|
663
|
+
"session_id": session_id,
|
|
664
|
+
"copied_path": str(dst_rel),
|
|
665
|
+
"copied": copied,
|
|
666
|
+
},
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# StopFailure is a last-chance transcript capture. If it carries a
|
|
670
|
+
# newer Claude session ID than SessionStart recorded, reconcile the
|
|
671
|
+
# manifest the same way Stop does.
|
|
672
|
+
m.confirmed.claude_session_id = session_id
|
|
673
|
+
m.confirmed.transcript_path = transcript_path
|
|
674
|
+
|
|
675
|
+
from forge import __version__
|
|
676
|
+
from forge.session.models import PolicyConfirmed
|
|
677
|
+
|
|
678
|
+
if m.confirmed.policy:
|
|
679
|
+
m.confirmed.policy.forge_version = __version__
|
|
680
|
+
else:
|
|
681
|
+
m.confirmed.policy = PolicyConfirmed(forge_version=__version__)
|
|
682
|
+
|
|
683
|
+
m.confirmed.confirmed_at = now_iso()
|
|
684
|
+
m.confirmed.confirmed_by = "hook:stop-failure"
|
|
685
|
+
|
|
686
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
687
|
+
try:
|
|
688
|
+
from forge.session.index import IndexStore
|
|
689
|
+
|
|
690
|
+
IndexStore().update_uuid(manifest.name, session_id, forge_root=str(store.forge_root))
|
|
691
|
+
except Exception:
|
|
692
|
+
logger.debug("StopFailure hook: index UUID sync failed", exc_info=True)
|
|
693
|
+
except Exception:
|
|
694
|
+
pass # Best-effort: never fail on StopFailure
|
|
695
|
+
|
|
696
|
+
# Only enqueue work markers if the artifact exists on disk.
|
|
697
|
+
# Enqueuing for a nonexistent artifact wastes retries until poison handling.
|
|
698
|
+
queued_stop = False
|
|
699
|
+
queued_index = False
|
|
700
|
+
effective_forge_root = str(store.forge_root) if store else None
|
|
701
|
+
if dst_abs.is_file():
|
|
702
|
+
queued_stop = (
|
|
703
|
+
enqueue_stop_marker(
|
|
704
|
+
session_id=session_id,
|
|
705
|
+
worktree_path=cwd,
|
|
706
|
+
session_name=manifest.name,
|
|
707
|
+
transcript_snapshot_rel=str(dst_rel),
|
|
708
|
+
forge_root=effective_forge_root,
|
|
709
|
+
)
|
|
710
|
+
is not None
|
|
711
|
+
)
|
|
712
|
+
queued_index = (
|
|
713
|
+
enqueue_index_marker(
|
|
714
|
+
session_id=session_id,
|
|
715
|
+
worktree_path=cwd,
|
|
716
|
+
session_name=manifest.name,
|
|
717
|
+
transcript_snapshot_rel=str(dst_rel),
|
|
718
|
+
forge_root=effective_forge_root,
|
|
719
|
+
)
|
|
720
|
+
is not None
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
_output_json(
|
|
724
|
+
{
|
|
725
|
+
"success": True,
|
|
726
|
+
"action": "copied" if copied else "attempted",
|
|
727
|
+
"copied_path": str(dst_rel),
|
|
728
|
+
"copied": copied,
|
|
729
|
+
"queued": queued_stop,
|
|
730
|
+
"queued_index": queued_index,
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@hooks.command(name="session-end")
|
|
736
|
+
def session_end() -> None:
|
|
737
|
+
"""No-op SessionEnd hook (placeholder).
|
|
738
|
+
|
|
739
|
+
Claude Code suppresses SessionEnd hook stderr output
|
|
740
|
+
(anthropics/claude-code#9090). The reconnect tip is printed from
|
|
741
|
+
the parent launcher process instead. This command is kept so the
|
|
742
|
+
hook config doesn't error on missing subcommand.
|
|
743
|
+
"""
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
@hooks.command(name="pre-compact")
|
|
747
|
+
def pre_compact() -> None:
|
|
748
|
+
"""Capture full transcript before compaction.
|
|
749
|
+
|
|
750
|
+
Fires BEFORE compaction when the uncompacted transcript is still available.
|
|
751
|
+
This is the canonical compaction snapshot — SessionStart rollover serves as
|
|
752
|
+
fallback for /clear events and defense-in-depth.
|
|
753
|
+
|
|
754
|
+
Always exits 0 (never blocks compaction). CLAUDE_CODE_AUTO_COMPACT_WINDOW
|
|
755
|
+
handles compaction window sizing in proxy mode.
|
|
756
|
+
"""
|
|
757
|
+
data, err = _read_stdin_json()
|
|
758
|
+
if data is None:
|
|
759
|
+
sys.exit(0)
|
|
760
|
+
logger.debug("pre-compact: hook_event_name=%s", data.get("hook_event_name"))
|
|
761
|
+
|
|
762
|
+
transcript_path = data.get("transcript_path")
|
|
763
|
+
session_id = data.get("session_id")
|
|
764
|
+
cwd_str = data.get("cwd", "")
|
|
765
|
+
cwd = Path(cwd_str) if cwd_str else Path.cwd()
|
|
766
|
+
|
|
767
|
+
if not transcript_path or not session_id:
|
|
768
|
+
sys.exit(0)
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
store = resolve_session_store(cwd, session_id=session_id)
|
|
772
|
+
if store is None or not store.exists():
|
|
773
|
+
sys.exit(0)
|
|
774
|
+
|
|
775
|
+
manifest = store.read()
|
|
776
|
+
if manifest is None:
|
|
777
|
+
sys.exit(0)
|
|
778
|
+
|
|
779
|
+
project_root = resolve_forge_root(cwd)
|
|
780
|
+
paths = get_artifact_paths(project_root, manifest.name)
|
|
781
|
+
|
|
782
|
+
src = Path(transcript_path)
|
|
783
|
+
timestamp = now_iso().replace(":", "-")
|
|
784
|
+
snapshot_name = f"{session_id}_pre-compact_{timestamp}.jsonl"
|
|
785
|
+
dst_abs = paths.transcripts_abs / snapshot_name
|
|
786
|
+
dst_rel = paths.transcripts_rel / snapshot_name
|
|
787
|
+
|
|
788
|
+
copied = safe_copy_file(src, dst_abs, overwrite=False)
|
|
789
|
+
|
|
790
|
+
from forge.session.models import CompactionConfirmed, SessionState
|
|
791
|
+
|
|
792
|
+
def _mutate(m: object) -> None:
|
|
793
|
+
if not isinstance(m, SessionState):
|
|
794
|
+
raise TypeError(f"Expected SessionState, got {type(m)}")
|
|
795
|
+
|
|
796
|
+
if m.confirmed.compaction is None:
|
|
797
|
+
m.confirmed.compaction = CompactionConfirmed()
|
|
798
|
+
|
|
799
|
+
m.confirmed.compaction.compact_count += 1
|
|
800
|
+
|
|
801
|
+
_append_artifact_entry(
|
|
802
|
+
m.confirmed.artifacts,
|
|
803
|
+
kind="transcripts",
|
|
804
|
+
entry={
|
|
805
|
+
"captured_at": now_iso(),
|
|
806
|
+
"reason": "pre-compact",
|
|
807
|
+
"source_path": transcript_path,
|
|
808
|
+
"snapshot_path": str(dst_rel),
|
|
809
|
+
"copied": copied,
|
|
810
|
+
},
|
|
811
|
+
)
|
|
812
|
+
m.confirmed.compaction.transcript_snapshots.append(
|
|
813
|
+
{
|
|
814
|
+
"captured_at": now_iso(),
|
|
815
|
+
"reason": "pre-compact",
|
|
816
|
+
"source_path": transcript_path,
|
|
817
|
+
"snapshot_path": str(dst_rel),
|
|
818
|
+
"copied": copied,
|
|
819
|
+
}
|
|
820
|
+
)
|
|
821
|
+
m.confirmed.confirmed_at = now_iso()
|
|
822
|
+
m.confirmed.confirmed_by = "hook:pre-compact"
|
|
823
|
+
|
|
824
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
825
|
+
logger.debug("pre-compact: transcript snapshot captured at %s", dst_rel)
|
|
826
|
+
except Exception as e:
|
|
827
|
+
# Fail-open: never block compaction
|
|
828
|
+
logger.debug("pre-compact: snapshot failed: %s", e)
|
|
829
|
+
|
|
830
|
+
sys.exit(0)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
@hooks.command(name="post-compact")
|
|
834
|
+
def post_compact() -> None:
|
|
835
|
+
"""Record compaction event in session confirmed state.
|
|
836
|
+
|
|
837
|
+
Fires AFTER compaction. Updates last_compact_at and last_compact_type.
|
|
838
|
+
compact_count is incremented by PreCompact (before compaction) so this
|
|
839
|
+
hook only records the completion timestamp.
|
|
840
|
+
|
|
841
|
+
Side-effect only -- cannot block compaction.
|
|
842
|
+
"""
|
|
843
|
+
data, err = _read_stdin_json()
|
|
844
|
+
if data is None:
|
|
845
|
+
sys.exit(0)
|
|
846
|
+
logger.debug("post-compact: hook_event_name=%s", data.get("hook_event_name"))
|
|
847
|
+
|
|
848
|
+
session_id = data.get("session_id")
|
|
849
|
+
cwd_str = data.get("cwd", "")
|
|
850
|
+
cwd = Path(cwd_str) if cwd_str else Path.cwd()
|
|
851
|
+
# PostCompact supports the same matcher values as PreCompact: "auto" | "manual"
|
|
852
|
+
compact_trigger = data.get("trigger", "unknown")
|
|
853
|
+
|
|
854
|
+
if not session_id:
|
|
855
|
+
sys.exit(0)
|
|
856
|
+
|
|
857
|
+
store = resolve_session_store(cwd, session_id=session_id)
|
|
858
|
+
if store is None or not store.exists():
|
|
859
|
+
sys.exit(0)
|
|
860
|
+
|
|
861
|
+
try:
|
|
862
|
+
from forge.session.models import CompactionConfirmed, SessionState
|
|
863
|
+
|
|
864
|
+
def _mutate(m: object) -> None:
|
|
865
|
+
if not isinstance(m, SessionState):
|
|
866
|
+
raise TypeError(f"Expected SessionState, got {type(m)}")
|
|
867
|
+
|
|
868
|
+
if m.confirmed.compaction is None:
|
|
869
|
+
m.confirmed.compaction = CompactionConfirmed()
|
|
870
|
+
|
|
871
|
+
m.confirmed.compaction.last_compact_at = now_iso()
|
|
872
|
+
m.confirmed.compaction.last_compact_type = compact_trigger
|
|
873
|
+
m.confirmed.confirmed_at = now_iso()
|
|
874
|
+
m.confirmed.confirmed_by = "hook:post-compact"
|
|
875
|
+
|
|
876
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
877
|
+
logger.debug("post-compact: compaction metadata recorded (trigger=%s)", compact_trigger)
|
|
878
|
+
except Exception as e:
|
|
879
|
+
logger.debug("post-compact: state update failed: %s", e)
|
|
880
|
+
|
|
881
|
+
sys.exit(0)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@hooks.command(name="worktree-create")
|
|
885
|
+
def worktree_create() -> None:
|
|
886
|
+
"""Create worktree with Forge extensions auto-installed.
|
|
887
|
+
|
|
888
|
+
REPLACES Claude Code's default worktree creation and .worktreeinclude
|
|
889
|
+
handling. Prints absolute worktree path to stdout on success.
|
|
890
|
+
Non-zero exit fails worktree creation.
|
|
891
|
+
|
|
892
|
+
stdout contract: ONLY the absolute worktree path. All debug goes to stderr.
|
|
893
|
+
"""
|
|
894
|
+
data, err = _read_stdin_json()
|
|
895
|
+
if data is None:
|
|
896
|
+
# Can't parse input — let Claude Code's default fail gracefully
|
|
897
|
+
sys.exit(1)
|
|
898
|
+
|
|
899
|
+
cwd_str = data.get("cwd", "")
|
|
900
|
+
cwd = Path(cwd_str) if cwd_str else Path.cwd()
|
|
901
|
+
|
|
902
|
+
# Claude Code may provide a name slug for the worktree (used by both
|
|
903
|
+
# --worktree and isolation: "worktree" for subagents). Each request
|
|
904
|
+
# must produce a distinct checkout — do NOT collapse onto session_id.
|
|
905
|
+
hook_name = data.get("name", "")
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
import subprocess
|
|
909
|
+
import uuid as _uuid
|
|
910
|
+
|
|
911
|
+
from forge.session.worktree.create import (
|
|
912
|
+
find_git_binary,
|
|
913
|
+
get_main_repo_root,
|
|
914
|
+
resolve_worktree_path,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
# Use main-repo root to avoid child-worktree resolution bugs
|
|
918
|
+
repo_root = get_main_repo_root(cwd)
|
|
919
|
+
git = find_git_binary()
|
|
920
|
+
|
|
921
|
+
# Prefer hook-provided name; fall back to unique name per request
|
|
922
|
+
if hook_name:
|
|
923
|
+
wt_name = hook_name
|
|
924
|
+
else:
|
|
925
|
+
short_uuid = _uuid.uuid4().hex[:8]
|
|
926
|
+
wt_name = f"wt-{short_uuid}"
|
|
927
|
+
branch_name = f"forge/{wt_name}"
|
|
928
|
+
|
|
929
|
+
worktree_path = resolve_worktree_path(repo_root, wt_name)
|
|
930
|
+
|
|
931
|
+
result = subprocess.run(
|
|
932
|
+
[git, "worktree", "add", str(worktree_path), "-b", branch_name],
|
|
933
|
+
cwd=str(repo_root),
|
|
934
|
+
capture_output=True,
|
|
935
|
+
text=True,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
if result.returncode != 0:
|
|
939
|
+
# Fallback: try without -b (use detached HEAD)
|
|
940
|
+
logger.debug("worktree-create: branched creation failed: %s", result.stderr.strip())
|
|
941
|
+
result = subprocess.run(
|
|
942
|
+
[git, "worktree", "add", str(worktree_path)],
|
|
943
|
+
cwd=str(repo_root),
|
|
944
|
+
capture_output=True,
|
|
945
|
+
text=True,
|
|
946
|
+
)
|
|
947
|
+
if result.returncode != 0:
|
|
948
|
+
logger.debug("worktree-create: fallback also failed: %s", result.stderr.strip())
|
|
949
|
+
sys.exit(1)
|
|
950
|
+
|
|
951
|
+
# Best-effort: copy runtime config (.env, .claude/settings.local.json,
|
|
952
|
+
# etc.) before installing extensions so the installer merges on top
|
|
953
|
+
# of existing user settings rather than creating a fresh file.
|
|
954
|
+
# Use the current checkout root (not main repo) so child worktrees
|
|
955
|
+
# inherit config from the checkout the user is actually in.
|
|
956
|
+
try:
|
|
957
|
+
from forge.session.worktree.config_copy import copy_runtime_config
|
|
958
|
+
from forge.session.worktree.create import get_repo_root
|
|
959
|
+
|
|
960
|
+
source_root = get_repo_root(cwd)
|
|
961
|
+
copy_runtime_config(source_root, worktree_path)
|
|
962
|
+
logger.debug("worktree-create: runtime config copied to %s", worktree_path)
|
|
963
|
+
except Exception as cfg_err:
|
|
964
|
+
logger.debug("worktree-create: config copy failed: %s", cfg_err)
|
|
965
|
+
|
|
966
|
+
# Best-effort: install Forge extensions in the new worktree.
|
|
967
|
+
# Suppress stdout to protect the path-only stdout contract.
|
|
968
|
+
try:
|
|
969
|
+
import contextlib
|
|
970
|
+
import os as _os
|
|
971
|
+
|
|
972
|
+
from forge.install.installer import Installer
|
|
973
|
+
from forge.install.models import InstallMode, InstallProfile, InstallScope
|
|
974
|
+
|
|
975
|
+
with open(_os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
|
|
976
|
+
installer = Installer(
|
|
977
|
+
scope=InstallScope.LOCAL,
|
|
978
|
+
project_root=worktree_path,
|
|
979
|
+
)
|
|
980
|
+
installer.init(
|
|
981
|
+
profile=InstallProfile.STANDARD,
|
|
982
|
+
mode=InstallMode.COPY,
|
|
983
|
+
)
|
|
984
|
+
logger.debug("worktree-create: extensions installed in %s", worktree_path)
|
|
985
|
+
except Exception as ext_err:
|
|
986
|
+
# Non-fatal: worktree works without Forge extensions
|
|
987
|
+
logger.debug("worktree-create: extension install failed: %s", ext_err)
|
|
988
|
+
|
|
989
|
+
# stdout contract: absolute path only
|
|
990
|
+
print(str(worktree_path.resolve()))
|
|
991
|
+
sys.exit(0)
|
|
992
|
+
|
|
993
|
+
except Exception as e:
|
|
994
|
+
logger.debug("worktree-create: failed: %s", e)
|
|
995
|
+
sys.exit(1)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
@hooks.command(name="subagent-stop")
|
|
999
|
+
def subagent_stop() -> None:
|
|
1000
|
+
"""Track subagent completion in session confirmed state.
|
|
1001
|
+
|
|
1002
|
+
Records agent type, count, transcript path, and message preview.
|
|
1003
|
+
Observe-only -- always exits 0. Blocking support preserved
|
|
1004
|
+
for future policy enforcement via exit 2.
|
|
1005
|
+
|
|
1006
|
+
Expected stdin keys:
|
|
1007
|
+
- session_id, cwd, agent_id, agent_type
|
|
1008
|
+
- agent_transcript_path, last_assistant_message
|
|
1009
|
+
"""
|
|
1010
|
+
data, err = _read_stdin_json()
|
|
1011
|
+
if data is None:
|
|
1012
|
+
sys.exit(0)
|
|
1013
|
+
logger.debug("subagent-stop: agent_type=%s", data.get("agent_type"))
|
|
1014
|
+
|
|
1015
|
+
session_id = data.get("session_id")
|
|
1016
|
+
cwd_str = data.get("cwd", "")
|
|
1017
|
+
cwd = Path(cwd_str) if cwd_str else Path.cwd()
|
|
1018
|
+
|
|
1019
|
+
if not session_id:
|
|
1020
|
+
sys.exit(0)
|
|
1021
|
+
|
|
1022
|
+
store = resolve_session_store(cwd, session_id=session_id)
|
|
1023
|
+
if store is None or not store.exists():
|
|
1024
|
+
sys.exit(0)
|
|
1025
|
+
|
|
1026
|
+
agent_id = data.get("agent_id")
|
|
1027
|
+
agent_type = data.get("agent_type", "unknown")
|
|
1028
|
+
agent_transcript_path = data.get("agent_transcript_path")
|
|
1029
|
+
last_message = data.get("last_assistant_message")
|
|
1030
|
+
|
|
1031
|
+
try:
|
|
1032
|
+
from forge.session.models import SessionState, SubagentConfirmed
|
|
1033
|
+
|
|
1034
|
+
def _mutate(m: object) -> None:
|
|
1035
|
+
if not isinstance(m, SessionState):
|
|
1036
|
+
raise TypeError(f"Expected SessionState, got {type(m)}")
|
|
1037
|
+
|
|
1038
|
+
if m.confirmed.subagents is None:
|
|
1039
|
+
m.confirmed.subagents = SubagentConfirmed()
|
|
1040
|
+
|
|
1041
|
+
sa = m.confirmed.subagents
|
|
1042
|
+
sa.total_count += 1
|
|
1043
|
+
sa.by_type[agent_type] = sa.by_type.get(agent_type, 0) + 1
|
|
1044
|
+
sa.last_agent_id = agent_id
|
|
1045
|
+
sa.last_agent_type = agent_type
|
|
1046
|
+
sa.last_stop_at = now_iso()
|
|
1047
|
+
sa.last_transcript_path = agent_transcript_path
|
|
1048
|
+
sa.last_message_preview = last_message[:200] if last_message else None
|
|
1049
|
+
m.confirmed.confirmed_at = now_iso()
|
|
1050
|
+
m.confirmed.confirmed_by = "hook:subagent-stop"
|
|
1051
|
+
|
|
1052
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
1053
|
+
logger.debug("subagent-stop: recorded agent_type=%s total=%s", agent_type, "ok")
|
|
1054
|
+
except Exception as e:
|
|
1055
|
+
logger.debug("subagent-stop: state update failed: %s", e)
|
|
1056
|
+
|
|
1057
|
+
sys.exit(0)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
@hooks.command(name="policy-check")
|
|
1061
|
+
def policy_check() -> None:
|
|
1062
|
+
"""Evaluate policies on PreToolUse:Write/Edit.
|
|
1063
|
+
|
|
1064
|
+
This hook enforces policy rules at tool invocation boundaries.
|
|
1065
|
+
Deterministic policies (TDD, coding standards) run synchronously.
|
|
1066
|
+
Semantic policies (supervisor) may invoke an LLM.
|
|
1067
|
+
|
|
1068
|
+
Exit codes:
|
|
1069
|
+
- 0: Allow (continue with tool use)
|
|
1070
|
+
- 2: Block (display stderr message to user, abort tool use)
|
|
1071
|
+
|
|
1072
|
+
Always defaults to fail-open on internal errors.
|
|
1073
|
+
"""
|
|
1074
|
+
data, err = _read_stdin_json()
|
|
1075
|
+
if data is None:
|
|
1076
|
+
# No input or parse error = allow (fail-open)
|
|
1077
|
+
sys.exit(0)
|
|
1078
|
+
logger.debug(
|
|
1079
|
+
"policy-check: event=%s tool=%s session=%s",
|
|
1080
|
+
data.get("hook_event_name"),
|
|
1081
|
+
data.get("tool_name"),
|
|
1082
|
+
str(data.get("session_id", "?"))[:12],
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
if data.get("hook_event_name") != "PreToolUse":
|
|
1086
|
+
sys.exit(0)
|
|
1087
|
+
|
|
1088
|
+
tool_name = data.get("tool_name", "")
|
|
1089
|
+
if tool_name not in ("Write", "Edit"):
|
|
1090
|
+
sys.exit(0)
|
|
1091
|
+
|
|
1092
|
+
cwd = Path.cwd().resolve()
|
|
1093
|
+
store = resolve_session_store(cwd, session_id=data.get("session_id"))
|
|
1094
|
+
if store is None:
|
|
1095
|
+
_output_json({"success": True, "action": "skip", "reason": "no_session"})
|
|
1096
|
+
return
|
|
1097
|
+
try:
|
|
1098
|
+
manifest = store.read()
|
|
1099
|
+
except Exception as e:
|
|
1100
|
+
print(f"[forge] Policy check: cannot read session manifest: {e}", file=sys.stderr)
|
|
1101
|
+
sys.exit(0)
|
|
1102
|
+
|
|
1103
|
+
try:
|
|
1104
|
+
effective = compute_effective_intent(manifest)
|
|
1105
|
+
except Exception as e:
|
|
1106
|
+
print(
|
|
1107
|
+
f"[forge] Policy check: cannot compute effective intent: {e}",
|
|
1108
|
+
file=sys.stderr,
|
|
1109
|
+
)
|
|
1110
|
+
sys.exit(0)
|
|
1111
|
+
|
|
1112
|
+
if not effective.policy or not effective.policy.enabled:
|
|
1113
|
+
sys.exit(0)
|
|
1114
|
+
|
|
1115
|
+
context = _build_action_context(data, tool_name, manifest)
|
|
1116
|
+
if context is None:
|
|
1117
|
+
print("[forge] Policy check: cannot build action context", file=sys.stderr)
|
|
1118
|
+
sys.exit(0)
|
|
1119
|
+
|
|
1120
|
+
from forge.guard.engine import build_engine
|
|
1121
|
+
from forge.guard.types import FailMode
|
|
1122
|
+
|
|
1123
|
+
fail_mode: FailMode = effective.policy.fail_mode or "open"
|
|
1124
|
+
bundles = effective.policy.bundles or []
|
|
1125
|
+
sup = effective.policy.supervisor if effective.policy else None
|
|
1126
|
+
has_supervisor = bool(sup and sup.resume_id and not sup.suspended)
|
|
1127
|
+
|
|
1128
|
+
if not bundles and not has_supervisor:
|
|
1129
|
+
sys.exit(0)
|
|
1130
|
+
|
|
1131
|
+
bundle_config: dict[str, dict[str, Any]] = {}
|
|
1132
|
+
if effective.policy and effective.policy.bundle_config:
|
|
1133
|
+
bundle_config = effective.policy.bundle_config
|
|
1134
|
+
|
|
1135
|
+
try:
|
|
1136
|
+
engine = build_engine(bundles, fail_mode=fail_mode, bundle_config=bundle_config or None)
|
|
1137
|
+
except Exception as e:
|
|
1138
|
+
print(f"[forge] Policy check: cannot build engine: {e}", file=sys.stderr)
|
|
1139
|
+
sys.exit(0)
|
|
1140
|
+
|
|
1141
|
+
# Register semantic supervisor before restore_state so cached state is restored with it.
|
|
1142
|
+
if has_supervisor:
|
|
1143
|
+
from forge.guard.semantic.supervisor import SemanticSupervisorPolicy
|
|
1144
|
+
|
|
1145
|
+
supervisor_policy = SemanticSupervisorPolicy(config=effective.policy.supervisor)
|
|
1146
|
+
engine.register(supervisor_policy)
|
|
1147
|
+
|
|
1148
|
+
existing_policy_state = None
|
|
1149
|
+
if manifest.confirmed.policy:
|
|
1150
|
+
existing_policy_state = manifest.confirmed.policy.policy_states
|
|
1151
|
+
engine.restore_state(existing_policy_state)
|
|
1152
|
+
|
|
1153
|
+
import time
|
|
1154
|
+
|
|
1155
|
+
t0 = time.monotonic()
|
|
1156
|
+
try:
|
|
1157
|
+
result = engine.evaluate(context)
|
|
1158
|
+
except Exception as e:
|
|
1159
|
+
if fail_mode == "closed":
|
|
1160
|
+
print(f"Policy evaluation failed (fail-closed): {e}", file=sys.stderr)
|
|
1161
|
+
sys.exit(2)
|
|
1162
|
+
# fail-open: allow on evaluation error
|
|
1163
|
+
sys.exit(0)
|
|
1164
|
+
elapsed = time.monotonic() - t0
|
|
1165
|
+
|
|
1166
|
+
target_label = f"{tool_name}:{context.target_path}" if context.target_path else tool_name
|
|
1167
|
+
|
|
1168
|
+
try:
|
|
1169
|
+
_persist_policy_state(
|
|
1170
|
+
store=store,
|
|
1171
|
+
engine=engine,
|
|
1172
|
+
result=result,
|
|
1173
|
+
effective=effective,
|
|
1174
|
+
context_summary=target_label,
|
|
1175
|
+
)
|
|
1176
|
+
except Exception as e:
|
|
1177
|
+
print(f"[forge] Policy state persistence failed: {e}", file=sys.stderr)
|
|
1178
|
+
|
|
1179
|
+
from forge.runtime_config import get_runtime_config
|
|
1180
|
+
|
|
1181
|
+
show_summary = get_runtime_config().policy_summary_feedback == "on"
|
|
1182
|
+
is_cached = any(getattr(d, "cached", False) for d in result.decisions)
|
|
1183
|
+
cache_label = ", cached" if is_cached else ""
|
|
1184
|
+
source_label = _derive_policy_source_label(result, effective)
|
|
1185
|
+
|
|
1186
|
+
if result.final_decision == "deny":
|
|
1187
|
+
lines = ["Policy violation(s):"]
|
|
1188
|
+
for d in result.decisions:
|
|
1189
|
+
if d.decision != "deny":
|
|
1190
|
+
continue
|
|
1191
|
+
for i, v in enumerate(d.violations):
|
|
1192
|
+
lines.append(f" [{v.rule_id}] {v.message}")
|
|
1193
|
+
if d.intent and i == 0:
|
|
1194
|
+
lines.append(f" Intent: {d.intent}")
|
|
1195
|
+
if v.suggested_fix:
|
|
1196
|
+
lines.append(f" Fix: {v.suggested_fix}")
|
|
1197
|
+
lines.append(
|
|
1198
|
+
" Note: This policy was configured by the project owner. First"
|
|
1199
|
+
" try a compliant approach that satisfies the intent above. If the"
|
|
1200
|
+
" user's request cannot be fulfilled without violating the intent,"
|
|
1201
|
+
" explain the conflict and ask how to proceed. Do not attempt"
|
|
1202
|
+
" bypasses that pass the check but defeat the goal."
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
print("\n".join(lines), file=sys.stderr)
|
|
1206
|
+
if show_summary:
|
|
1207
|
+
violation_count = sum(len(d.violations) for d in result.decisions if d.decision == "deny")
|
|
1208
|
+
if violation_count > 0:
|
|
1209
|
+
print(
|
|
1210
|
+
f"[forge] Policy: checked {target_label} against {source_label}"
|
|
1211
|
+
f" ({violation_count} violation{'s' if violation_count != 1 else ''}, blocked, {elapsed:.1f}s)",
|
|
1212
|
+
file=sys.stderr,
|
|
1213
|
+
)
|
|
1214
|
+
else:
|
|
1215
|
+
print(
|
|
1216
|
+
f"[forge] Policy: checked {target_label} against {source_label}"
|
|
1217
|
+
f" (blocked, evaluation error, {elapsed:.1f}s)",
|
|
1218
|
+
file=sys.stderr,
|
|
1219
|
+
)
|
|
1220
|
+
sys.exit(2)
|
|
1221
|
+
|
|
1222
|
+
if result.final_decision == "needs_review":
|
|
1223
|
+
lines = ["Policy review required but no semantic supervisor resolved it:"]
|
|
1224
|
+
for d in result.decisions:
|
|
1225
|
+
if d.decision == "needs_review":
|
|
1226
|
+
lines.append(f" [{d.policy_id}] requested review")
|
|
1227
|
+
if d.intent:
|
|
1228
|
+
lines.append(f" Intent: {d.intent}")
|
|
1229
|
+
lines.append(
|
|
1230
|
+
" Configure a supervisor for this session or ask the user how to proceed before making this change."
|
|
1231
|
+
)
|
|
1232
|
+
print("\n".join(lines), file=sys.stderr)
|
|
1233
|
+
if show_summary:
|
|
1234
|
+
print(
|
|
1235
|
+
f"[forge] Policy: checked {target_label} against {source_label}"
|
|
1236
|
+
f" (review required, unresolved, {elapsed:.1f}s)",
|
|
1237
|
+
file=sys.stderr,
|
|
1238
|
+
)
|
|
1239
|
+
sys.exit(2)
|
|
1240
|
+
|
|
1241
|
+
# Surface warnings before allowing (deduped to avoid spam) -- always visible
|
|
1242
|
+
if result.all_warnings:
|
|
1243
|
+
seen: set[str] = set()
|
|
1244
|
+
for warning in result.all_warnings:
|
|
1245
|
+
if warning not in seen:
|
|
1246
|
+
seen.add(warning)
|
|
1247
|
+
print(f"[forge] Policy warning: {warning}", file=sys.stderr)
|
|
1248
|
+
|
|
1249
|
+
if show_summary:
|
|
1250
|
+
if result.final_decision == "allow" and not result.all_warnings:
|
|
1251
|
+
verdict = "aligned"
|
|
1252
|
+
elif result.final_decision == "allow" and result.all_warnings:
|
|
1253
|
+
deduped_count = len(set(result.all_warnings))
|
|
1254
|
+
verdict = f"allowed, {deduped_count} warning{'s' if deduped_count != 1 else ''}"
|
|
1255
|
+
else:
|
|
1256
|
+
verdict = result.final_decision
|
|
1257
|
+
print(
|
|
1258
|
+
f"[forge] Policy: checked {target_label} against {source_label}"
|
|
1259
|
+
f" ({verdict}{cache_label}, {elapsed:.1f}s)",
|
|
1260
|
+
file=sys.stderr,
|
|
1261
|
+
)
|
|
1262
|
+
_output_json(
|
|
1263
|
+
{
|
|
1264
|
+
"hookSpecificOutput": {
|
|
1265
|
+
"hookEventName": "PreToolUse",
|
|
1266
|
+
"permissionDecision": "allow",
|
|
1267
|
+
"additionalContext": (
|
|
1268
|
+
f"Forge policy: {target_label} checked against {source_label}"
|
|
1269
|
+
f" ({verdict}{cache_label}, {elapsed:.1f}s)"
|
|
1270
|
+
),
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
)
|
|
1274
|
+
sys.exit(0)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
@hooks.command(name="user-prompt-submit")
|
|
1278
|
+
def user_prompt_submit() -> None:
|
|
1279
|
+
"""Dispatch direct user commands from UserPromptSubmit.
|
|
1280
|
+
|
|
1281
|
+
Design goal: install a single hook once, then add new `%<cmd>` handlers over time
|
|
1282
|
+
without requiring hook reinstalls.
|
|
1283
|
+
|
|
1284
|
+
This handler follows Claude Code's decision contract for UserPromptSubmit:
|
|
1285
|
+
- If we handle a command: print `{ "decision": "block", "reason": "..." }`
|
|
1286
|
+
- Otherwise: exit 0 with no output (normal Claude flow)
|
|
1287
|
+
"""
|
|
1288
|
+
|
|
1289
|
+
data, err = _read_stdin_json()
|
|
1290
|
+
if data is None:
|
|
1291
|
+
# Don't break Claude for UserPromptSubmit; just no-op.
|
|
1292
|
+
return
|
|
1293
|
+
prompt = data.get("prompt")
|
|
1294
|
+
logger.debug(
|
|
1295
|
+
"user-prompt-submit: prompt_len=%d",
|
|
1296
|
+
len(prompt) if isinstance(prompt, str) else 0,
|
|
1297
|
+
)
|
|
1298
|
+
if not isinstance(prompt, str) or not prompt.strip().startswith("%"):
|
|
1299
|
+
return
|
|
1300
|
+
|
|
1301
|
+
parsed = _parse_direct_command(prompt)
|
|
1302
|
+
if parsed is None:
|
|
1303
|
+
return
|
|
1304
|
+
|
|
1305
|
+
cmd, args = parsed
|
|
1306
|
+
|
|
1307
|
+
if cmd in ("h", "help"):
|
|
1308
|
+
_handle_cmd_help()
|
|
1309
|
+
return
|
|
1310
|
+
|
|
1311
|
+
# Shared commands (mirror CLI syntax)
|
|
1312
|
+
if cmd == "session":
|
|
1313
|
+
_handle_cmd_session(data, args)
|
|
1314
|
+
return
|
|
1315
|
+
|
|
1316
|
+
if cmd == "proxy":
|
|
1317
|
+
_handle_cmd_proxy(data, args)
|
|
1318
|
+
return
|
|
1319
|
+
|
|
1320
|
+
if cmd == "plan":
|
|
1321
|
+
_handle_cmd_plan(args)
|
|
1322
|
+
return
|
|
1323
|
+
|
|
1324
|
+
if cmd == "guard":
|
|
1325
|
+
_handle_cmd_guard(data, args)
|
|
1326
|
+
return
|
|
1327
|
+
|
|
1328
|
+
if cmd == "config":
|
|
1329
|
+
_handle_cmd_config(data, args)
|
|
1330
|
+
return
|
|
1331
|
+
|
|
1332
|
+
if cmd == "cancel-verification":
|
|
1333
|
+
_handle_cmd_cancel_verification()
|
|
1334
|
+
return
|
|
1335
|
+
|
|
1336
|
+
if cmd == "clean":
|
|
1337
|
+
_handle_cmd_clean(args)
|
|
1338
|
+
return
|
|
1339
|
+
|
|
1340
|
+
# Unknown %command: ignore for now (future expansion point).
|
|
1341
|
+
return
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
# --- Run Artifact Helpers ---
|
|
1345
|
+
|
|
1346
|
+
# Marker locations for skills that want a transcript copy in their run directory.
|
|
1347
|
+
_PENDING_TRANSCRIPT_MARKERS = ("manual-testing/qa", "manual-testing/walkthrough")
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
@dataclass(frozen=True)
|
|
1351
|
+
class _PendingTranscriptRequest:
|
|
1352
|
+
"""Validated pending-transcript marker payload."""
|
|
1353
|
+
|
|
1354
|
+
run_dir: Path
|
|
1355
|
+
session_id: str | None = None
|
|
1356
|
+
transcript_contains: str | None = None
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _load_pending_transcript_request(marker: Path, *, expected_prefix: Path) -> _PendingTranscriptRequest | None:
|
|
1360
|
+
"""Parse a pending-transcript marker.
|
|
1361
|
+
|
|
1362
|
+
Expected format:
|
|
1363
|
+
{"run_dir": "...", "session_id": "...", "transcript_contains": "..."}
|
|
1364
|
+
|
|
1365
|
+
Returns:
|
|
1366
|
+
Validated request, or None if the marker is malformed / unsafe.
|
|
1367
|
+
"""
|
|
1368
|
+
raw = marker.read_text(encoding="utf-8").strip()
|
|
1369
|
+
if not raw:
|
|
1370
|
+
logger.warning("Empty .pending-transcript marker: %s", marker)
|
|
1371
|
+
return None
|
|
1372
|
+
|
|
1373
|
+
try:
|
|
1374
|
+
payload = json.loads(raw)
|
|
1375
|
+
except json.JSONDecodeError as e:
|
|
1376
|
+
logger.warning("Invalid JSON in .pending-transcript marker %s: %s", marker, e)
|
|
1377
|
+
return None
|
|
1378
|
+
if not isinstance(payload, dict):
|
|
1379
|
+
logger.warning(
|
|
1380
|
+
"Invalid .pending-transcript marker payload (expected object): %s",
|
|
1381
|
+
marker,
|
|
1382
|
+
)
|
|
1383
|
+
return None
|
|
1384
|
+
|
|
1385
|
+
run_dir_value = payload.get("run_dir")
|
|
1386
|
+
if not isinstance(run_dir_value, str) or not run_dir_value.strip():
|
|
1387
|
+
logger.warning("Structured .pending-transcript marker missing run_dir: %s", marker)
|
|
1388
|
+
return None
|
|
1389
|
+
run_dir_str = run_dir_value.strip()
|
|
1390
|
+
|
|
1391
|
+
expected_session_id: str | None = None
|
|
1392
|
+
session_id_value = payload.get("session_id")
|
|
1393
|
+
if session_id_value is not None:
|
|
1394
|
+
if not isinstance(session_id_value, str):
|
|
1395
|
+
logger.warning(
|
|
1396
|
+
"Structured .pending-transcript marker has invalid session_id: %s",
|
|
1397
|
+
marker,
|
|
1398
|
+
)
|
|
1399
|
+
return None
|
|
1400
|
+
expected_session_id = session_id_value.strip() or None
|
|
1401
|
+
|
|
1402
|
+
transcript_contains: str | None = None
|
|
1403
|
+
transcript_value = payload.get("transcript_contains")
|
|
1404
|
+
if transcript_value is not None:
|
|
1405
|
+
if not isinstance(transcript_value, str):
|
|
1406
|
+
logger.warning(
|
|
1407
|
+
"Structured .pending-transcript marker has invalid transcript_contains: %s",
|
|
1408
|
+
marker,
|
|
1409
|
+
)
|
|
1410
|
+
return None
|
|
1411
|
+
transcript_contains = transcript_value.strip() or None
|
|
1412
|
+
|
|
1413
|
+
run_dir = Path(run_dir_str)
|
|
1414
|
+
if not run_dir.is_absolute():
|
|
1415
|
+
logger.warning("Rejected .pending-transcript: relative path %s", run_dir)
|
|
1416
|
+
return None
|
|
1417
|
+
|
|
1418
|
+
try:
|
|
1419
|
+
run_dir.resolve().relative_to(expected_prefix)
|
|
1420
|
+
except ValueError:
|
|
1421
|
+
logger.warning(
|
|
1422
|
+
"Rejected .pending-transcript: %s is not under %s",
|
|
1423
|
+
run_dir,
|
|
1424
|
+
expected_prefix,
|
|
1425
|
+
)
|
|
1426
|
+
return None
|
|
1427
|
+
|
|
1428
|
+
if not run_dir.is_dir():
|
|
1429
|
+
logger.warning("Run directory does not exist: %s", run_dir)
|
|
1430
|
+
return None
|
|
1431
|
+
|
|
1432
|
+
return _PendingTranscriptRequest(
|
|
1433
|
+
run_dir=run_dir,
|
|
1434
|
+
session_id=expected_session_id,
|
|
1435
|
+
transcript_contains=transcript_contains,
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _transcript_contains_text(transcript_path: Path, text: str) -> bool:
|
|
1440
|
+
"""Return True if the transcript file contains the given text."""
|
|
1441
|
+
try:
|
|
1442
|
+
with transcript_path.open("r", encoding="utf-8", errors="ignore") as handle:
|
|
1443
|
+
for line in handle:
|
|
1444
|
+
if text in line:
|
|
1445
|
+
return True
|
|
1446
|
+
except OSError as e:
|
|
1447
|
+
logger.warning(
|
|
1448
|
+
"Failed to scan transcript %s for pending marker text: %s",
|
|
1449
|
+
transcript_path,
|
|
1450
|
+
e,
|
|
1451
|
+
)
|
|
1452
|
+
return False
|
|
1453
|
+
return False
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _copy_transcript_to_pending_runs(transcript_path: Path, *, session_id: str | None = None) -> None:
|
|
1457
|
+
"""Copy transcript to pending skill run directories (best-effort).
|
|
1458
|
+
|
|
1459
|
+
QA and walkthrough skills write a `.pending-transcript` marker containing
|
|
1460
|
+
a structured JSON payload with additional match guards. This function copies
|
|
1461
|
+
the transcript there and removes the marker once the current Stop event
|
|
1462
|
+
satisfies those guards.
|
|
1463
|
+
|
|
1464
|
+
Never raises -- failures are logged and swallowed to avoid blocking Stop.
|
|
1465
|
+
"""
|
|
1466
|
+
from forge.core.paths import get_forge_home
|
|
1467
|
+
|
|
1468
|
+
try:
|
|
1469
|
+
forge_home = get_forge_home()
|
|
1470
|
+
except Exception:
|
|
1471
|
+
return
|
|
1472
|
+
|
|
1473
|
+
for skill in _PENDING_TRANSCRIPT_MARKERS:
|
|
1474
|
+
marker = forge_home / skill / ".pending-transcript"
|
|
1475
|
+
if not marker.is_file():
|
|
1476
|
+
continue
|
|
1477
|
+
|
|
1478
|
+
try:
|
|
1479
|
+
expected_prefix = (forge_home / skill / "runs").resolve()
|
|
1480
|
+
request = _load_pending_transcript_request(marker, expected_prefix=expected_prefix)
|
|
1481
|
+
if request is None:
|
|
1482
|
+
marker.unlink(missing_ok=True)
|
|
1483
|
+
continue
|
|
1484
|
+
|
|
1485
|
+
if request.session_id and request.session_id != session_id:
|
|
1486
|
+
logger.debug(
|
|
1487
|
+
"Pending transcript marker %s waiting for session %s (got %s)",
|
|
1488
|
+
marker,
|
|
1489
|
+
request.session_id,
|
|
1490
|
+
session_id,
|
|
1491
|
+
)
|
|
1492
|
+
continue
|
|
1493
|
+
|
|
1494
|
+
if request.transcript_contains and not _transcript_contains_text(
|
|
1495
|
+
transcript_path, request.transcript_contains
|
|
1496
|
+
):
|
|
1497
|
+
logger.debug(
|
|
1498
|
+
"Pending transcript marker %s waiting for transcript token match",
|
|
1499
|
+
marker,
|
|
1500
|
+
)
|
|
1501
|
+
continue
|
|
1502
|
+
|
|
1503
|
+
safe_copy_file(transcript_path, request.run_dir / "transcript.jsonl", overwrite=False)
|
|
1504
|
+
marker.unlink(missing_ok=True)
|
|
1505
|
+
logger.debug("Copied transcript to run dir: %s", request.run_dir)
|
|
1506
|
+
|
|
1507
|
+
except Exception as e:
|
|
1508
|
+
logger.warning("Failed to process .pending-transcript %s: %s", marker, e)
|
|
1509
|
+
try:
|
|
1510
|
+
marker.unlink(missing_ok=True)
|
|
1511
|
+
except Exception:
|
|
1512
|
+
pass
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
# --- Team Hook Handlers ---
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
@hooks.command(name="teammate-idle")
|
|
1519
|
+
def teammate_idle() -> None:
|
|
1520
|
+
"""Handle TeammateIdle hook from Claude Code.
|
|
1521
|
+
|
|
1522
|
+
Exit 0: allow teammate to go idle.
|
|
1523
|
+
Exit 2: teammate continues working (stderr = feedback).
|
|
1524
|
+
"""
|
|
1525
|
+
data, err = _read_stdin_json()
|
|
1526
|
+
if data is None:
|
|
1527
|
+
sys.exit(0)
|
|
1528
|
+
logger.debug("teammate-idle: session=%s", str(data.get("session_id", "?"))[:12])
|
|
1529
|
+
|
|
1530
|
+
try:
|
|
1531
|
+
cwd = Path.cwd().resolve()
|
|
1532
|
+
store = resolve_session_store(cwd, session_id=data.get("session_id"))
|
|
1533
|
+
if store is None:
|
|
1534
|
+
sys.exit(0)
|
|
1535
|
+
manifest = store.read()
|
|
1536
|
+
effective = compute_effective_intent(manifest)
|
|
1537
|
+
except Exception:
|
|
1538
|
+
sys.exit(0)
|
|
1539
|
+
|
|
1540
|
+
config = effective.policy.team_supervisor if effective.policy else None
|
|
1541
|
+
if not config or not config.enabled:
|
|
1542
|
+
sys.exit(0)
|
|
1543
|
+
|
|
1544
|
+
from forge.guard.team.handlers import handle_teammate_idle
|
|
1545
|
+
|
|
1546
|
+
cache_key = _safe_cache_key(data.get("session_id"))
|
|
1547
|
+
exit_code, feedback = _run_team_handler(cache_key, lambda cache: handle_teammate_idle(data, config, cache))
|
|
1548
|
+
if exit_code == 2 and feedback:
|
|
1549
|
+
print(feedback, file=sys.stderr)
|
|
1550
|
+
sys.exit(exit_code)
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
@hooks.command(name="task-completed")
|
|
1554
|
+
def task_completed() -> None:
|
|
1555
|
+
"""Handle TaskCompleted hook from Claude Code.
|
|
1556
|
+
|
|
1557
|
+
Exit 0: task marked as completed.
|
|
1558
|
+
Exit 2: task stays open (stderr = feedback to teammate).
|
|
1559
|
+
"""
|
|
1560
|
+
data, err = _read_stdin_json()
|
|
1561
|
+
if data is None:
|
|
1562
|
+
sys.exit(0)
|
|
1563
|
+
logger.debug("task-completed: session=%s", str(data.get("session_id", "?"))[:12])
|
|
1564
|
+
|
|
1565
|
+
try:
|
|
1566
|
+
cwd = Path.cwd().resolve()
|
|
1567
|
+
store = resolve_session_store(cwd, session_id=data.get("session_id"))
|
|
1568
|
+
if store is None:
|
|
1569
|
+
sys.exit(0)
|
|
1570
|
+
manifest = store.read()
|
|
1571
|
+
effective = compute_effective_intent(manifest)
|
|
1572
|
+
except Exception:
|
|
1573
|
+
sys.exit(0)
|
|
1574
|
+
|
|
1575
|
+
config = effective.policy.team_supervisor if effective.policy else None
|
|
1576
|
+
if not config or not config.enabled:
|
|
1577
|
+
sys.exit(0)
|
|
1578
|
+
|
|
1579
|
+
from forge.guard.team.handlers import handle_task_completed
|
|
1580
|
+
|
|
1581
|
+
cache_key = _safe_cache_key(data.get("session_id"))
|
|
1582
|
+
exit_code, feedback = _run_team_handler(cache_key, lambda cache: handle_task_completed(data, config, cache))
|
|
1583
|
+
if exit_code == 2 and feedback:
|
|
1584
|
+
print(feedback, file=sys.stderr)
|
|
1585
|
+
sys.exit(exit_code)
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
@hooks.command(name="read-hygiene")
|
|
1589
|
+
def read_hygiene_cmd() -> None:
|
|
1590
|
+
"""Strip extra Read params from skill instruction file reads.
|
|
1591
|
+
|
|
1592
|
+
Targets skill instruction files ({mode}.md, {mode}-{family}.md) that have
|
|
1593
|
+
a strict "file_path only" Read contract. Uses updatedInput to silently fix
|
|
1594
|
+
the call. Always exits 0 (fail-open).
|
|
1595
|
+
"""
|
|
1596
|
+
data, err = _read_stdin_json()
|
|
1597
|
+
if data is None:
|
|
1598
|
+
sys.exit(0)
|
|
1599
|
+
|
|
1600
|
+
try:
|
|
1601
|
+
from .read_hygiene import handle_read_hygiene
|
|
1602
|
+
|
|
1603
|
+
result = handle_read_hygiene(data)
|
|
1604
|
+
if result is not None:
|
|
1605
|
+
_output_json(result)
|
|
1606
|
+
except Exception:
|
|
1607
|
+
logger.debug("read-hygiene: unexpected error", exc_info=True)
|
|
1608
|
+
sys.exit(0)
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
_SAFE_CACHE_ID = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
1612
|
+
|
|
1613
|
+
# Short lock timeout for team hooks (concurrent teammates)
|
|
1614
|
+
_TEAM_CACHE_LOCK_TIMEOUT_S = 0.2
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _safe_cache_key(session_id: Any) -> str:
|
|
1618
|
+
"""Sanitize session_id for use as a cache filename.
|
|
1619
|
+
|
|
1620
|
+
Rejects path traversal chars (same pattern as workqueue SAFE_MARKER_ID).
|
|
1621
|
+
Falls back to 'default' on None, empty, or unsafe values.
|
|
1622
|
+
"""
|
|
1623
|
+
if not session_id or not isinstance(session_id, str):
|
|
1624
|
+
return "default"
|
|
1625
|
+
if not _SAFE_CACHE_ID.match(session_id):
|
|
1626
|
+
return "default"
|
|
1627
|
+
return session_id
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def _team_cache_path(cache_key: str) -> Path:
|
|
1631
|
+
"""Return the file path for a team hook cache."""
|
|
1632
|
+
from forge.core.paths import get_forge_home
|
|
1633
|
+
|
|
1634
|
+
return get_forge_home() / "team-hooks" / f"{cache_key}.json"
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def _run_team_handler(
|
|
1638
|
+
cache_key: str,
|
|
1639
|
+
handler: Callable[[dict], tuple[int, str]],
|
|
1640
|
+
) -> tuple[int, str]:
|
|
1641
|
+
"""Run a team handler with locked file-backed cache.
|
|
1642
|
+
|
|
1643
|
+
Holds a file lock around the read → handler → write cycle to prevent
|
|
1644
|
+
lost updates from concurrent teammate hooks.
|
|
1645
|
+
"""
|
|
1646
|
+
from forge.core.state import (
|
|
1647
|
+
FileLockTimeoutError,
|
|
1648
|
+
atomic_write_json,
|
|
1649
|
+
file_lock_for_target,
|
|
1650
|
+
read_json,
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
cache_path = _team_cache_path(cache_key)
|
|
1654
|
+
|
|
1655
|
+
try:
|
|
1656
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1657
|
+
with file_lock_for_target(target_path=cache_path, timeout_s=_TEAM_CACHE_LOCK_TIMEOUT_S):
|
|
1658
|
+
cache: dict = {}
|
|
1659
|
+
if cache_path.exists():
|
|
1660
|
+
try:
|
|
1661
|
+
cache = read_json(cache_path)
|
|
1662
|
+
except Exception:
|
|
1663
|
+
cache = {}
|
|
1664
|
+
|
|
1665
|
+
exit_code, feedback = handler(cache)
|
|
1666
|
+
|
|
1667
|
+
if cache:
|
|
1668
|
+
atomic_write_json(cache_path, cache, create_parents=True)
|
|
1669
|
+
|
|
1670
|
+
return exit_code, feedback
|
|
1671
|
+
|
|
1672
|
+
except FileLockTimeoutError:
|
|
1673
|
+
# Another hook has the lock — run without cache (best-effort)
|
|
1674
|
+
return handler({})
|
|
1675
|
+
except Exception:
|
|
1676
|
+
# Any other I/O error — run without cache
|
|
1677
|
+
return handler({})
|