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,544 @@
|
|
|
1
|
+
"""Handoff agent for automatic memory doc updates.
|
|
2
|
+
|
|
3
|
+
The handoff agent runs after session stop (via work queue) to update
|
|
4
|
+
designated project memory documents. It spawns ``claude -p`` as a headless
|
|
5
|
+
subprocess that reads the session transcript and writes updates to
|
|
6
|
+
configured designated docs.
|
|
7
|
+
|
|
8
|
+
Note: this is the memory-doc maintenance agent, not the resume-context
|
|
9
|
+
generator. The resume handoff (parent->child context for ``forge session
|
|
10
|
+
resume --fresh``) is in ``handoff.py``. Despite the shared name they are
|
|
11
|
+
different concepts; see ``docs/end-user/handoff.md`` for the user-facing
|
|
12
|
+
distinction.
|
|
13
|
+
|
|
14
|
+
Supports two modes:
|
|
15
|
+
- **Direct update (Mode 1)**: Agent edits designated docs in-place.
|
|
16
|
+
- **Shadow/propose (Mode 2)**: Agent writes suggestions to a shadow file
|
|
17
|
+
for human review, reading the official doc first for comparison.
|
|
18
|
+
|
|
19
|
+
Each run persists its stdout to
|
|
20
|
+
``<forge_root>/.forge/artifacts/<session>/handoff/review-<timestamp>.md`` so
|
|
21
|
+
users can inspect proposed/applied changes -- surfaced via
|
|
22
|
+
``forge session handoff show``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from forge.core.reactive.routing import resolve_subprocess_routing
|
|
32
|
+
from forge.core.reactive.session_runner import run_claude_session
|
|
33
|
+
from forge.core.transcript import parse_jsonl_transcript
|
|
34
|
+
from forge.session.claude.invoke import is_claude_available
|
|
35
|
+
from forge.session.models import DesignatedDoc, HandoffConfig
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _default_timeout() -> int:
|
|
41
|
+
from forge.runtime_config import get_runtime_config
|
|
42
|
+
|
|
43
|
+
return get_runtime_config().handoff_timeout
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Per-doc strategy instructions.
|
|
47
|
+
# Mode 1 (direct update): strictly additive — no removals/rewrites.
|
|
48
|
+
# Mode 2 (suggested): self-prunes merged items from shadow file.
|
|
49
|
+
DOC_STRATEGIES: dict[str, str] = {
|
|
50
|
+
"project-state": (
|
|
51
|
+
"Update current focus, active work, recent decisions, and handoff notes. "
|
|
52
|
+
"Mark completed items as done rather than removing them. "
|
|
53
|
+
"If the file does not exist, skip it and report that it was missing."
|
|
54
|
+
),
|
|
55
|
+
"checklist": (
|
|
56
|
+
"Mark completed tasks with [x]. Add newly discovered tasks. "
|
|
57
|
+
"Do NOT remove, rewrite, or restructure existing entries. "
|
|
58
|
+
"If the file does not exist, skip it and report that it was missing."
|
|
59
|
+
),
|
|
60
|
+
"changelog": (
|
|
61
|
+
"Add accomplishments from this session not already recorded. "
|
|
62
|
+
"Follow the existing entry format. "
|
|
63
|
+
"Do NOT modify or remove existing entries. "
|
|
64
|
+
"If the file does not exist, skip it and report that it was missing."
|
|
65
|
+
),
|
|
66
|
+
"debugging": (
|
|
67
|
+
"Record error causes, solutions, and workarounds encountered in this session. "
|
|
68
|
+
"Group entries by topic (build errors, runtime errors, test failures, etc.). "
|
|
69
|
+
"Do NOT duplicate entries that are already documented. "
|
|
70
|
+
"If the file does not exist, skip it and report that it was missing."
|
|
71
|
+
),
|
|
72
|
+
"patterns": (
|
|
73
|
+
"Record architecture patterns, conventions, and recurring techniques observed "
|
|
74
|
+
"in this session. Include code idioms, design patterns, and naming conventions. "
|
|
75
|
+
"Do NOT duplicate patterns that are already documented. "
|
|
76
|
+
"If the file does not exist, skip it and report that it was missing."
|
|
77
|
+
),
|
|
78
|
+
"suggested": (
|
|
79
|
+
"Propose additions to the official document as `- [ ]` checkboxes, each with "
|
|
80
|
+
"a brief rationale. Remove any checkboxes whose content has already been merged "
|
|
81
|
+
"into the official document (self-prune). "
|
|
82
|
+
"Do NOT duplicate suggestions that are already present in either file."
|
|
83
|
+
),
|
|
84
|
+
"generic": (
|
|
85
|
+
"Read the file and add any NEW information from this session that is missing. "
|
|
86
|
+
"Do NOT duplicate, rephrase, or remove what is already documented. "
|
|
87
|
+
"If the file does not exist, skip it and report that it was missing."
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
MULTI_DOC_PROMPT_TEMPLATE = """\
|
|
92
|
+
You are a project documentation agent. Your job is to update project documents \
|
|
93
|
+
based on a completed Claude Code session.
|
|
94
|
+
|
|
95
|
+
## Session Information
|
|
96
|
+
- Session name: {session_name}
|
|
97
|
+
- Transcript: {transcript_path}
|
|
98
|
+
|
|
99
|
+
## Instructions
|
|
100
|
+
1. Read the session transcript at `{transcript_path}`
|
|
101
|
+
2. For EACH file listed below, read the existing content first
|
|
102
|
+
3. {action_instruction}
|
|
103
|
+
|
|
104
|
+
IMPORTANT: Read each file BEFORE modifying it.
|
|
105
|
+
Only make the minimal edits described in each file's instructions below.
|
|
106
|
+
Do not duplicate, rephrase, or remove content beyond what the per-file instructions specify.
|
|
107
|
+
If everything is already documented for a file, skip it entirely.
|
|
108
|
+
|
|
109
|
+
## Files to Update
|
|
110
|
+
{file_sections}
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
MULTI_DOC_AUGMENT_INSTRUCTION = "Apply the specified updates to each file"
|
|
114
|
+
MULTI_DOC_REVIEW_INSTRUCTION = "Print to stdout what changes you would make to each file. Do NOT modify any files."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_multi_doc_prompt(
|
|
118
|
+
*,
|
|
119
|
+
session_name: str,
|
|
120
|
+
transcript_path: str,
|
|
121
|
+
mode: str = "augment",
|
|
122
|
+
designated_docs: list[DesignatedDoc],
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Build a multi-doc prompt for the handoff agent.
|
|
125
|
+
|
|
126
|
+
Generates a single prompt that instructs ``claude -p`` to update
|
|
127
|
+
multiple designated documents with per-doc strategies. For shadow docs
|
|
128
|
+
(``doc.shadows`` is set), the prompt instructs reading the official
|
|
129
|
+
document first before proposing changes.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
session_name: The Forge session name.
|
|
133
|
+
transcript_path: Absolute path to the transcript artifact.
|
|
134
|
+
mode: "augment" (write updates) or "review-only" (print suggestions).
|
|
135
|
+
designated_docs: List of DesignatedDoc entries to update.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The complete prompt string.
|
|
139
|
+
"""
|
|
140
|
+
action_instruction = MULTI_DOC_AUGMENT_INSTRUCTION if mode == "augment" else MULTI_DOC_REVIEW_INSTRUCTION
|
|
141
|
+
|
|
142
|
+
sections: list[str] = []
|
|
143
|
+
for doc in designated_docs:
|
|
144
|
+
instructions = DOC_STRATEGIES.get(doc.strategy, DOC_STRATEGIES["generic"])
|
|
145
|
+
|
|
146
|
+
if doc.shadows:
|
|
147
|
+
# Shadow/propose mode (Mode 2): read official doc first, then propose
|
|
148
|
+
section = (
|
|
149
|
+
f"### `{doc.path}` (proposes changes to `{doc.shadows}`)\n"
|
|
150
|
+
f"1. Read the OFFICIAL document at `{doc.shadows}` first.\n"
|
|
151
|
+
f"2. Read this shadow document at `{doc.path}` (if it exists).\n"
|
|
152
|
+
f"3. {instructions}"
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
# Direct update mode (Mode 1)
|
|
156
|
+
section = f"### `{doc.path}`\n{instructions}"
|
|
157
|
+
|
|
158
|
+
sections.append(section)
|
|
159
|
+
|
|
160
|
+
file_sections = "\n\n".join(sections)
|
|
161
|
+
|
|
162
|
+
return MULTI_DOC_PROMPT_TEMPLATE.format(
|
|
163
|
+
session_name=session_name,
|
|
164
|
+
transcript_path=transcript_path,
|
|
165
|
+
action_instruction=action_instruction,
|
|
166
|
+
file_sections=file_sections,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def count_conversation_turns(transcript_path: Path) -> int:
|
|
171
|
+
"""Count user-initiated conversation turns in a transcript JSONL file.
|
|
172
|
+
|
|
173
|
+
For newer format (requestId + message.role): counts unique requestId groups
|
|
174
|
+
that contain at least one user message.
|
|
175
|
+
For older format (type field): counts entries with type 'human'.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
transcript_path: Path to the JSONL transcript file.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Number of conversation turns. 0 if file is missing or empty.
|
|
182
|
+
"""
|
|
183
|
+
entries = parse_jsonl_transcript(transcript_path)
|
|
184
|
+
if not entries:
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
has_request_ids = any(e.get("requestId") for e in entries)
|
|
188
|
+
|
|
189
|
+
if has_request_ids:
|
|
190
|
+
user_request_ids: set[str] = set()
|
|
191
|
+
for entry in entries:
|
|
192
|
+
request_id = entry.get("requestId", "")
|
|
193
|
+
if not request_id:
|
|
194
|
+
continue
|
|
195
|
+
message = entry.get("message", {})
|
|
196
|
+
if isinstance(message, dict) and message.get("role") == "user":
|
|
197
|
+
user_request_ids.add(request_id)
|
|
198
|
+
return len(user_request_ids)
|
|
199
|
+
|
|
200
|
+
return sum(1 for e in entries if e.get("type") == "human")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def resolve_handoff_base_url(
|
|
204
|
+
proxy_id: str | None,
|
|
205
|
+
confirmed_proxy_base_url: str | None = None,
|
|
206
|
+
env_base_url: str | None = None,
|
|
207
|
+
*,
|
|
208
|
+
direct: bool = False,
|
|
209
|
+
subprocess_proxy: str | None = None,
|
|
210
|
+
) -> str | None:
|
|
211
|
+
"""Resolve ANTHROPIC_BASE_URL for the handoff agent.
|
|
212
|
+
|
|
213
|
+
When direct=True, short-circuits the entire chain and returns None
|
|
214
|
+
(forces direct Anthropic routing regardless of session proxy).
|
|
215
|
+
|
|
216
|
+
Delegates to ``resolve_subprocess_routing()`` with fail-open semantics.
|
|
217
|
+
The handoff's proxy_id is soft (preferred, not strict) because handoff
|
|
218
|
+
is async/best-effort — using the session's confirmed proxy is better
|
|
219
|
+
than failing.
|
|
220
|
+
|
|
221
|
+
Priority chain (when not direct):
|
|
222
|
+
1. proxy_id -> preferred_proxy (handoff config, soft)
|
|
223
|
+
2. subprocess_proxy -> persisted session subprocess proxy (soft)
|
|
224
|
+
3. confirmed_proxy_base_url -> session's confirmed proxy
|
|
225
|
+
4. env_base_url -> current ANTHROPIC_BASE_URL
|
|
226
|
+
5. None -> Anthropic direct
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
proxy_id: Optional proxy from HandoffConfig. Soft: falls through
|
|
230
|
+
on miss (unlike workflow's strict --proxy).
|
|
231
|
+
confirmed_proxy_base_url: Base URL from session's confirmed proxy.
|
|
232
|
+
env_base_url: Fallback base URL from environment.
|
|
233
|
+
direct: When True, force direct routing (skip all proxy resolution).
|
|
234
|
+
subprocess_proxy: Session-level subprocess proxy intent.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
base_url string or None.
|
|
238
|
+
"""
|
|
239
|
+
if direct:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
for candidate in (proxy_id, subprocess_proxy):
|
|
243
|
+
if not candidate:
|
|
244
|
+
continue
|
|
245
|
+
result = resolve_subprocess_routing(
|
|
246
|
+
preferred_proxy=candidate,
|
|
247
|
+
require_route=False,
|
|
248
|
+
use_environment=False,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if result.base_url:
|
|
252
|
+
return result.base_url
|
|
253
|
+
|
|
254
|
+
return confirmed_proxy_base_url or env_base_url
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# Paths with these characters are rejected to prevent prompt injection when
|
|
258
|
+
# interpolated into markdown headings (e.g., backticks break ```...``` blocks,
|
|
259
|
+
# newlines inject arbitrary prompt lines, control chars corrupt structure).
|
|
260
|
+
_UNSAFE_PATH_RE = re.compile(r"[`\x00-\x1f\x7f]")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def is_safe_designated_doc_path(path: str, base: Path, resolved_base: Path) -> str | None:
|
|
264
|
+
"""Check a single path for safety. Return rejection reason or None if safe."""
|
|
265
|
+
if Path(path).is_absolute():
|
|
266
|
+
return f"absolute path: {path}"
|
|
267
|
+
if _UNSAFE_PATH_RE.search(path):
|
|
268
|
+
return f"unsafe characters: {path!r}"
|
|
269
|
+
abs_path = (base / path).resolve()
|
|
270
|
+
if not abs_path.is_relative_to(resolved_base):
|
|
271
|
+
return f"escapes base directory: {path}"
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
_PERMISSION_DENIED_PATTERNS = [
|
|
276
|
+
re.compile(r"(?:need|require|don.t have).{0,30}(?:write|edit|permission)", re.IGNORECASE),
|
|
277
|
+
re.compile(r"(?:not|isn.t|aren.t).{0,20}(?:allowed|permitted).{0,20}(?:write|edit|modify)", re.IGNORECASE),
|
|
278
|
+
re.compile(r"cannot (?:write|edit|modify) files", re.IGNORECASE),
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _stdout_indicates_permission_denied(stdout: str) -> bool:
|
|
283
|
+
"""Detect permission-denied responses where Claude exits 0 but couldn't write."""
|
|
284
|
+
if not stdout:
|
|
285
|
+
return False
|
|
286
|
+
# Only check the first ~2000 chars — permission messages appear early
|
|
287
|
+
sample = stdout[:2000]
|
|
288
|
+
return any(p.search(sample) for p in _PERMISSION_DENIED_PATTERNS)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _validate_designated_docs(
|
|
292
|
+
designated_docs: list[DesignatedDoc],
|
|
293
|
+
forge_root: Path,
|
|
294
|
+
) -> list[DesignatedDoc]:
|
|
295
|
+
"""Validate and filter designated docs.
|
|
296
|
+
|
|
297
|
+
Guards (per doc):
|
|
298
|
+
1. Path safety: reject absolute, unsafe chars, traversal
|
|
299
|
+
(applied to both ``path`` and ``shadows``).
|
|
300
|
+
2. Strategy consistency: ``suggested`` requires ``shadows``;
|
|
301
|
+
``shadows`` requires ``suggested``.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
designated_docs: List of docs to validate.
|
|
305
|
+
forge_root: Resolved worktree directory (base for path resolution).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Filtered list containing only valid docs.
|
|
309
|
+
"""
|
|
310
|
+
valid: list[DesignatedDoc] = []
|
|
311
|
+
resolved_base = forge_root.resolve()
|
|
312
|
+
for doc in designated_docs:
|
|
313
|
+
reason = is_safe_designated_doc_path(doc.path, forge_root, resolved_base)
|
|
314
|
+
if reason:
|
|
315
|
+
logger.warning("Skipping designated_doc (%s): %s", doc.path, reason)
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
if doc.shadows is not None:
|
|
319
|
+
reason = is_safe_designated_doc_path(doc.shadows, forge_root, resolved_base)
|
|
320
|
+
if reason:
|
|
321
|
+
logger.warning("Skipping designated_doc shadows (%s): %s", doc.shadows, reason)
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
# Strategy consistency: suggested ↔ shadows (non-empty)
|
|
325
|
+
if doc.strategy == "suggested" and not doc.shadows:
|
|
326
|
+
logger.warning(
|
|
327
|
+
"Skipping designated_doc %s: strategy 'suggested' requires non-empty 'shadows'",
|
|
328
|
+
doc.path,
|
|
329
|
+
)
|
|
330
|
+
continue
|
|
331
|
+
if doc.shadows is not None and doc.strategy != "suggested":
|
|
332
|
+
logger.warning(
|
|
333
|
+
"Skipping designated_doc %s: 'shadows' requires strategy 'suggested' " "(got %r)",
|
|
334
|
+
doc.path,
|
|
335
|
+
doc.strategy,
|
|
336
|
+
)
|
|
337
|
+
continue
|
|
338
|
+
if doc.shadows and doc.path == doc.shadows:
|
|
339
|
+
logger.warning(
|
|
340
|
+
"Skipping designated_doc %s: 'path' and 'shadows' must differ",
|
|
341
|
+
doc.path,
|
|
342
|
+
)
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
valid.append(doc)
|
|
346
|
+
return valid
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def run_handoff_agent(
|
|
350
|
+
*,
|
|
351
|
+
session_name: str,
|
|
352
|
+
forge_root: Path,
|
|
353
|
+
transcript_snapshot_rel: str,
|
|
354
|
+
config: HandoffConfig,
|
|
355
|
+
base_url: str | None = None,
|
|
356
|
+
timeout_seconds: int | None = None,
|
|
357
|
+
designated_docs: list[DesignatedDoc] | None = None,
|
|
358
|
+
) -> bool:
|
|
359
|
+
"""Run the handoff agent as a ``claude -p`` subprocess.
|
|
360
|
+
|
|
361
|
+
This is the main entry point called by ``forge handoff run``.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
session_name: Forge session name.
|
|
365
|
+
forge_root: Forge project root (where .forge/ lives). Designated doc paths
|
|
366
|
+
resolve against this directory. Also used as cwd for the subprocess.
|
|
367
|
+
transcript_snapshot_rel: Forge-root-relative path to transcript artifact.
|
|
368
|
+
config: HandoffConfig with mode, min_turns, proxy_id.
|
|
369
|
+
base_url: Resolved ANTHROPIC_BASE_URL (or None for direct).
|
|
370
|
+
timeout_seconds: Max seconds for the agent to run.
|
|
371
|
+
designated_docs: List of docs to update. If None or empty, the agent
|
|
372
|
+
has nothing to do and returns True (skip).
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
True if agent completed successfully (or skipped), False on error.
|
|
376
|
+
"""
|
|
377
|
+
project_root = forge_root
|
|
378
|
+
|
|
379
|
+
# Validate transcript path (system boundary: CLI args / marker payload)
|
|
380
|
+
reason = is_safe_designated_doc_path(transcript_snapshot_rel, project_root, project_root.resolve())
|
|
381
|
+
if reason:
|
|
382
|
+
logger.warning("Handoff agent: unsafe transcript path (%s)", reason)
|
|
383
|
+
return False
|
|
384
|
+
transcript_abs = (project_root / transcript_snapshot_rel).resolve()
|
|
385
|
+
|
|
386
|
+
if not transcript_abs.is_file():
|
|
387
|
+
logger.warning("Handoff agent: transcript not found at %s", transcript_abs)
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
turn_count = count_conversation_turns(transcript_abs)
|
|
391
|
+
if turn_count < config.min_turns:
|
|
392
|
+
logger.info(
|
|
393
|
+
"Handoff skipped: session %s had %d turns (min_turns=%d)",
|
|
394
|
+
session_name,
|
|
395
|
+
turn_count,
|
|
396
|
+
config.min_turns,
|
|
397
|
+
)
|
|
398
|
+
return True # Not a failure — just below threshold
|
|
399
|
+
|
|
400
|
+
_VALID_MODES = {"augment", "review-only"}
|
|
401
|
+
if config.mode not in _VALID_MODES:
|
|
402
|
+
logger.warning("Handoff agent: unknown mode %r (expected %s)", config.mode, _VALID_MODES)
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
if not is_claude_available():
|
|
406
|
+
logger.warning("Handoff agent: claude CLI not found in PATH")
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
if not designated_docs:
|
|
410
|
+
logger.info(
|
|
411
|
+
"No designated_docs configured; handoff agent has nothing to update " "(session %s)",
|
|
412
|
+
session_name,
|
|
413
|
+
)
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
safe_docs = _validate_designated_docs(designated_docs, forge_root)
|
|
417
|
+
|
|
418
|
+
# Only update files that already exist — handoff never creates new files.
|
|
419
|
+
ready_docs: list[DesignatedDoc] = []
|
|
420
|
+
for doc in safe_docs:
|
|
421
|
+
if not (forge_root / doc.path).is_file():
|
|
422
|
+
logger.info("Skipping missing file: %s", doc.path)
|
|
423
|
+
continue
|
|
424
|
+
# For shadow docs, the official doc must also exist
|
|
425
|
+
if doc.shadows and not (forge_root / doc.shadows).is_file():
|
|
426
|
+
logger.info(
|
|
427
|
+
"Skipping shadow doc %s: official doc %s not found",
|
|
428
|
+
doc.path,
|
|
429
|
+
doc.shadows,
|
|
430
|
+
)
|
|
431
|
+
continue
|
|
432
|
+
ready_docs.append(doc)
|
|
433
|
+
|
|
434
|
+
if not ready_docs:
|
|
435
|
+
logger.info(
|
|
436
|
+
"No designated_docs ready after validation/existence checks (session %s)",
|
|
437
|
+
session_name,
|
|
438
|
+
)
|
|
439
|
+
return True
|
|
440
|
+
|
|
441
|
+
prompt = build_multi_doc_prompt(
|
|
442
|
+
session_name=session_name,
|
|
443
|
+
transcript_path=str(transcript_abs),
|
|
444
|
+
mode=config.mode,
|
|
445
|
+
designated_docs=ready_docs,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
logger.info(
|
|
449
|
+
"Running handoff agent for session %s (mode=%s, turns=%d)",
|
|
450
|
+
session_name,
|
|
451
|
+
config.mode,
|
|
452
|
+
turn_count,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Use forge_root as cwd so designated doc paths (relative) resolve
|
|
456
|
+
# against the correct branch content. Transcript path is absolute.
|
|
457
|
+
from forge.core.reactive.cost_tracking import track_verb_cost
|
|
458
|
+
|
|
459
|
+
effective_timeout = timeout_seconds if timeout_seconds is not None else _default_timeout()
|
|
460
|
+
tracking_url = base_url
|
|
461
|
+
|
|
462
|
+
with track_verb_cost("handoff", [tracking_url] if tracking_url else []):
|
|
463
|
+
result = run_claude_session(
|
|
464
|
+
prompt,
|
|
465
|
+
base_url=base_url,
|
|
466
|
+
direct=config.direct,
|
|
467
|
+
timeout_seconds=effective_timeout,
|
|
468
|
+
cwd=str(forge_root),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if not result.success:
|
|
472
|
+
detail = result.error or (result.stderr[:500] if result.stderr else f"exit {result.returncode}")
|
|
473
|
+
logger.warning("Handoff agent for %s failed: %s", session_name, detail)
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
# Persist the agent's stdout to a per-session review file so users can
|
|
477
|
+
# inspect what was proposed (review-only mode) or what was applied
|
|
478
|
+
# (augment mode). The work-queue spawns this command detached with
|
|
479
|
+
# stdout/stderr -> DEVNULL, so the file is the only visible artifact.
|
|
480
|
+
try:
|
|
481
|
+
_persist_review_report(
|
|
482
|
+
forge_root=forge_root,
|
|
483
|
+
session_name=session_name,
|
|
484
|
+
mode=config.mode,
|
|
485
|
+
turn_count=turn_count,
|
|
486
|
+
stdout=result.stdout,
|
|
487
|
+
)
|
|
488
|
+
except OSError as e:
|
|
489
|
+
# Best-effort: don't fail the agent if the review file can't be written
|
|
490
|
+
logger.warning("Could not persist handoff review file for %s: %s", session_name, e)
|
|
491
|
+
|
|
492
|
+
# Only check for permission denial in augment mode. review-only mode
|
|
493
|
+
# explicitly tells Claude "Do NOT modify any files", so a compliant
|
|
494
|
+
# response like "I cannot modify files" is expected, not an error.
|
|
495
|
+
if config.mode == "augment" and _stdout_indicates_permission_denied(result.stdout):
|
|
496
|
+
logger.warning(
|
|
497
|
+
"Handoff agent for %s: Claude lacked Write/Edit permissions — no files modified. "
|
|
498
|
+
"Run 'forge claude preset edit' to add Write/Edit to permissions.allow.",
|
|
499
|
+
session_name,
|
|
500
|
+
)
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
logger.info("Handoff agent completed for session %s", session_name)
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def review_dir(forge_root: Path, session_name: str) -> Path:
|
|
508
|
+
"""Return the directory where handoff agent review reports live."""
|
|
509
|
+
return forge_root / ".forge" / "artifacts" / session_name / "handoff"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _persist_review_report(
|
|
513
|
+
*,
|
|
514
|
+
forge_root: Path,
|
|
515
|
+
session_name: str,
|
|
516
|
+
mode: str,
|
|
517
|
+
turn_count: int,
|
|
518
|
+
stdout: str,
|
|
519
|
+
) -> Path:
|
|
520
|
+
"""Write the agent's stdout to a timestamped review file.
|
|
521
|
+
|
|
522
|
+
Returns the absolute path of the written file. The work queue spawns the
|
|
523
|
+
agent detached so stdout/stderr go to DEVNULL; this file is the only way
|
|
524
|
+
users can inspect what the agent proposed or applied. See ``forge session
|
|
525
|
+
handoff show``.
|
|
526
|
+
"""
|
|
527
|
+
from datetime import datetime, timezone
|
|
528
|
+
|
|
529
|
+
output_dir = review_dir(forge_root, session_name)
|
|
530
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
531
|
+
|
|
532
|
+
now = datetime.now(timezone.utc)
|
|
533
|
+
stamp = now.strftime("%Y%m%d-%H%M%S-%f")
|
|
534
|
+
target = output_dir / f"review-{stamp}.md"
|
|
535
|
+
|
|
536
|
+
header = (
|
|
537
|
+
f"# Handoff Agent Report -- {session_name}\n\n"
|
|
538
|
+
f"**Mode**: {mode}\n"
|
|
539
|
+
f"**Timestamp**: {now.isoformat()}\n"
|
|
540
|
+
f"**Turns**: {turn_count}\n\n"
|
|
541
|
+
"---\n\n"
|
|
542
|
+
)
|
|
543
|
+
target.write_text(header + (stdout or "_(no output)_\n"), encoding="utf-8")
|
|
544
|
+
return target
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Hook handlers for Claude Code integration.
|
|
2
|
+
|
|
3
|
+
This package provides handlers for Claude Code hooks, enabling Forge
|
|
4
|
+
to reconcile session state across /compact and /clear operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .models import HookInput, HookResult, HookSource, ResolutionContext
|
|
8
|
+
from .session_start import (
|
|
9
|
+
ENV_FORK_NAME,
|
|
10
|
+
ENV_PARENT_SESSION,
|
|
11
|
+
ENV_SESSION,
|
|
12
|
+
handle_session_start,
|
|
13
|
+
parse_hook_input,
|
|
14
|
+
resolve_session_for_hook,
|
|
15
|
+
resolve_session_name,
|
|
16
|
+
resolve_session_store,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
# Models
|
|
21
|
+
"HookInput",
|
|
22
|
+
"HookResult",
|
|
23
|
+
"HookSource",
|
|
24
|
+
"ResolutionContext",
|
|
25
|
+
# Constants
|
|
26
|
+
"ENV_FORK_NAME",
|
|
27
|
+
"ENV_PARENT_SESSION",
|
|
28
|
+
"ENV_SESSION",
|
|
29
|
+
# Functions
|
|
30
|
+
"handle_session_start",
|
|
31
|
+
"parse_hook_input",
|
|
32
|
+
"resolve_session_for_hook",
|
|
33
|
+
"resolve_session_name",
|
|
34
|
+
"resolve_session_store",
|
|
35
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Dataclasses for hook input/output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
# Valid source types from Claude Code hooks
|
|
9
|
+
HookSource = Literal["startup", "resume", "compact", "clear"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class HookInput:
|
|
14
|
+
"""Input from Claude Code SessionStart hook.
|
|
15
|
+
|
|
16
|
+
Claude Code invokes the hook with JSON on stdin containing these fields.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
session_id: str # Claude's session UUID
|
|
20
|
+
transcript_path: str # Path to transcript JSONL file
|
|
21
|
+
source: HookSource # What triggered the hook
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class HookResult:
|
|
26
|
+
"""Result returned by the hook handler.
|
|
27
|
+
|
|
28
|
+
Always exit 0 and return JSON - don't break Claude on errors.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
success: bool
|
|
32
|
+
session_name: str | None = None
|
|
33
|
+
message: str | None = None
|
|
34
|
+
error: str | None = None
|
|
35
|
+
# Echo input fields for debugging
|
|
36
|
+
received_session_id: str | None = None
|
|
37
|
+
received_transcript_path: str | None = None
|
|
38
|
+
received_source: str | None = None
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
"""Convert to dict for JSON serialization, excluding None values."""
|
|
42
|
+
result: dict[str, Any] = {"success": self.success}
|
|
43
|
+
if self.session_name is not None:
|
|
44
|
+
result["session_name"] = self.session_name
|
|
45
|
+
if self.message is not None:
|
|
46
|
+
result["message"] = self.message
|
|
47
|
+
if self.error is not None:
|
|
48
|
+
result["error"] = self.error
|
|
49
|
+
if self.received_session_id is not None:
|
|
50
|
+
result["received_session_id"] = self.received_session_id
|
|
51
|
+
if self.received_transcript_path is not None:
|
|
52
|
+
result["received_transcript_path"] = self.received_transcript_path
|
|
53
|
+
if self.received_source is not None:
|
|
54
|
+
result["received_source"] = self.received_source
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ResolutionContext:
|
|
60
|
+
"""Context gathered during session name resolution.
|
|
61
|
+
|
|
62
|
+
Tracks which resolution method succeeded and any errors encountered.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
session_name: str | None = None
|
|
66
|
+
forge_root: str | None = None # Resolved project scope (for scoped subsequent lookups)
|
|
67
|
+
resolution_method: str | None = None # "fork_env", "session_env", "env_file", "uuid_lookup"
|
|
68
|
+
errors: list[str] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def resolved(self) -> bool:
|
|
72
|
+
"""Whether a session name was successfully resolved."""
|
|
73
|
+
return self.session_name is not None
|