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,344 @@
|
|
|
1
|
+
"""Override manipulation operations.
|
|
2
|
+
|
|
3
|
+
This module provides functions for validating, parsing, and manipulating
|
|
4
|
+
session override values with strict schema validation.
|
|
5
|
+
|
|
6
|
+
Key validation is strict:
|
|
7
|
+
- Keys must be valid SessionIntent paths (derived via dataclass introspection)
|
|
8
|
+
- Wildcards (<top_level>.*) are supported and expanded at operation time
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import fields, is_dataclass
|
|
16
|
+
from typing import Any, get_type_hints
|
|
17
|
+
|
|
18
|
+
from forge.core.typing_helpers import unwrap_optional
|
|
19
|
+
|
|
20
|
+
from .exceptions import InvalidOverrideKeyError
|
|
21
|
+
from .models import SessionIntent
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Top-level manifest fields that cannot be overridden
|
|
26
|
+
_MANIFEST_FIELDS = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"schema_version",
|
|
29
|
+
"name",
|
|
30
|
+
"created_at",
|
|
31
|
+
"last_accessed_at",
|
|
32
|
+
"parent_session",
|
|
33
|
+
"is_fork",
|
|
34
|
+
"is_incognito",
|
|
35
|
+
"worktree",
|
|
36
|
+
"intent",
|
|
37
|
+
"overrides",
|
|
38
|
+
"confirmed",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Cache for valid intent paths (computed once)
|
|
43
|
+
_valid_paths_cache: set[str] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_valid_intent_paths() -> set[str]:
|
|
47
|
+
"""Introspect SessionIntent dataclass to build valid dot-paths.
|
|
48
|
+
|
|
49
|
+
This function recursively walks the SessionIntent dataclass and its nested
|
|
50
|
+
dataclasses to build a set of valid override key paths.
|
|
51
|
+
|
|
52
|
+
Rules:
|
|
53
|
+
- Optional fields are included (e.g., proxy.template valid even when proxy is None)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Set of valid dot-notation paths (e.g., {"agent", "proxy.template", ...})
|
|
57
|
+
"""
|
|
58
|
+
global _valid_paths_cache
|
|
59
|
+
if _valid_paths_cache is not None:
|
|
60
|
+
return _valid_paths_cache
|
|
61
|
+
|
|
62
|
+
paths: set[str] = set()
|
|
63
|
+
_collect_paths(SessionIntent, "", paths)
|
|
64
|
+
_valid_paths_cache = paths
|
|
65
|
+
return paths
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _collect_paths(cls: type | Any, prefix: str, paths: set[str]) -> None:
|
|
69
|
+
"""Recursively collect valid paths from a dataclass."""
|
|
70
|
+
if not is_dataclass(cls):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
hints = get_type_hints(cls)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.debug("Cannot get type hints for %s: %s (using field names)", cls.__name__, e)
|
|
77
|
+
hints = {}
|
|
78
|
+
|
|
79
|
+
for f in fields(cls):
|
|
80
|
+
name = f.name
|
|
81
|
+
if name.startswith("_"):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
path = f"{prefix}{name}" if prefix else name
|
|
85
|
+
paths.add(path)
|
|
86
|
+
|
|
87
|
+
field_type = hints.get(name, f.type)
|
|
88
|
+
actual_type = unwrap_optional(field_type)
|
|
89
|
+
|
|
90
|
+
if is_dataclass(actual_type):
|
|
91
|
+
_collect_paths(actual_type, f"{path}.", paths)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def expand_wildcard(pattern: str, valid_paths: set[str] | None = None) -> list[str]:
|
|
95
|
+
"""Expand a wildcard pattern to matching valid paths.
|
|
96
|
+
|
|
97
|
+
Only supports <top_level_field>.* patterns (single-segment wildcard).
|
|
98
|
+
More complex patterns like *.tags or proxy.*.foo are not supported in v1.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
pattern: Wildcard pattern (e.g., "proxy.*", "memory.*")
|
|
102
|
+
valid_paths: Set of valid paths (defaults to get_valid_intent_paths())
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
List of concrete paths matching the pattern.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
InvalidOverrideKeyError: If pattern matches nothing or is unsupported.
|
|
109
|
+
"""
|
|
110
|
+
if valid_paths is None:
|
|
111
|
+
valid_paths = get_valid_intent_paths()
|
|
112
|
+
|
|
113
|
+
if "*" not in pattern:
|
|
114
|
+
raise InvalidOverrideKeyError(pattern, "not a wildcard pattern")
|
|
115
|
+
|
|
116
|
+
parts = pattern.split(".")
|
|
117
|
+
if len(parts) != 2 or parts[1] != "*":
|
|
118
|
+
raise InvalidOverrideKeyError(
|
|
119
|
+
pattern,
|
|
120
|
+
"unsupported wildcard format",
|
|
121
|
+
hint="only <top_level>.* patterns supported (e.g., proxy.*, memory.*)",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
prefix = parts[0]
|
|
125
|
+
|
|
126
|
+
if prefix == "custom":
|
|
127
|
+
raise InvalidOverrideKeyError(pattern, "custom.* is not supported")
|
|
128
|
+
|
|
129
|
+
matching = [p for p in valid_paths if p.startswith(f"{prefix}.")]
|
|
130
|
+
|
|
131
|
+
if not matching:
|
|
132
|
+
if prefix not in valid_paths:
|
|
133
|
+
raise InvalidOverrideKeyError(
|
|
134
|
+
pattern,
|
|
135
|
+
f"unknown field '{prefix}'",
|
|
136
|
+
hint=f"valid top-level fields: {', '.join(sorted(p for p in valid_paths if '.' not in p))}",
|
|
137
|
+
)
|
|
138
|
+
raise InvalidOverrideKeyError(
|
|
139
|
+
pattern,
|
|
140
|
+
f"'{prefix}' has no nested fields to expand",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return sorted(matching)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_key(key: str) -> list[str]:
|
|
147
|
+
"""Validate a dot-notation key and return path segments.
|
|
148
|
+
|
|
149
|
+
This performs strict validation against the SessionIntent schema:
|
|
150
|
+
- Rejects empty key or empty segments
|
|
151
|
+
- Rejects intent.* prefix (keys are relative to intent)
|
|
152
|
+
- Rejects confirmed.* (immutable)
|
|
153
|
+
- Rejects top-level manifest fields
|
|
154
|
+
- For other keys: validates against known SessionIntent paths
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
key: Dot-notation path (e.g., "agent", "proxy.template")
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
List of path segments (e.g., ["proxy", "template"]).
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
InvalidOverrideKeyError: If key is invalid.
|
|
164
|
+
"""
|
|
165
|
+
if not key:
|
|
166
|
+
raise InvalidOverrideKeyError(key, "key cannot be empty")
|
|
167
|
+
|
|
168
|
+
parts = key.split(".")
|
|
169
|
+
|
|
170
|
+
# Empty segments (e.g., "foo..bar" or ".foo" or "foo.")
|
|
171
|
+
for part in parts:
|
|
172
|
+
if not part:
|
|
173
|
+
raise InvalidOverrideKeyError(key, "empty segment in path")
|
|
174
|
+
|
|
175
|
+
first_part = parts[0]
|
|
176
|
+
|
|
177
|
+
if first_part == "intent":
|
|
178
|
+
raise InvalidOverrideKeyError(
|
|
179
|
+
key,
|
|
180
|
+
"keys should be relative to intent",
|
|
181
|
+
hint="use 'agent' not 'intent.agent'",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if first_part == "confirmed":
|
|
185
|
+
raise InvalidOverrideKeyError(
|
|
186
|
+
key,
|
|
187
|
+
"cannot override confirmed.* fields",
|
|
188
|
+
hint="confirmed values are set by hooks and immutable",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if first_part in _MANIFEST_FIELDS:
|
|
192
|
+
raise InvalidOverrideKeyError(
|
|
193
|
+
key,
|
|
194
|
+
f"'{first_part}' is a manifest field, not an intent field",
|
|
195
|
+
hint="overrides apply to intent configuration only",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if first_part == "custom":
|
|
199
|
+
raise InvalidOverrideKeyError(key, "custom.* is not supported")
|
|
200
|
+
|
|
201
|
+
if "*" in key:
|
|
202
|
+
# Wildcards are handled separately by expand_wildcard
|
|
203
|
+
# validate_key should not receive wildcard keys directly
|
|
204
|
+
raise InvalidOverrideKeyError(
|
|
205
|
+
key,
|
|
206
|
+
"use expand_wildcard() for wildcard patterns",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
valid_paths = get_valid_intent_paths()
|
|
210
|
+
|
|
211
|
+
if key in valid_paths:
|
|
212
|
+
return parts
|
|
213
|
+
|
|
214
|
+
# Check if it's a valid prefix (for nested access)
|
|
215
|
+
# e.g., "proxy" is valid even though "proxy.template" is what you'd usually set
|
|
216
|
+
if any(p.startswith(f"{key}.") for p in valid_paths):
|
|
217
|
+
return parts
|
|
218
|
+
|
|
219
|
+
similar = _find_similar_paths(key, valid_paths)
|
|
220
|
+
hint = None
|
|
221
|
+
if similar:
|
|
222
|
+
hint = f"did you mean: {', '.join(similar[:3])}"
|
|
223
|
+
else:
|
|
224
|
+
top_level = sorted(p for p in valid_paths if "." not in p)
|
|
225
|
+
hint = f"valid top-level fields: {', '.join(top_level)}"
|
|
226
|
+
|
|
227
|
+
raise InvalidOverrideKeyError(key, f"unknown field '{key}'", hint=hint)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _find_similar_paths(key: str, valid_paths: set[str]) -> list[str]:
|
|
231
|
+
"""Find paths similar to the given key (simple substring matching)."""
|
|
232
|
+
key_lower = key.lower()
|
|
233
|
+
similar = []
|
|
234
|
+
for path in valid_paths:
|
|
235
|
+
if key_lower in path.lower() or path.lower() in key_lower:
|
|
236
|
+
similar.append(path)
|
|
237
|
+
return sorted(similar)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_value(value: str) -> Any:
|
|
241
|
+
"""Parse a value string as JSON-first, fallback to string.
|
|
242
|
+
|
|
243
|
+
JSON-first parsing:
|
|
244
|
+
- "true" -> bool True
|
|
245
|
+
- "false" -> bool False
|
|
246
|
+
- "null" -> None
|
|
247
|
+
- "123" -> int 123
|
|
248
|
+
- "3.14" -> float 3.14
|
|
249
|
+
- '["a","b"]' -> list
|
|
250
|
+
- '{"key": "value"}' -> dict
|
|
251
|
+
- Fallback: stored as string
|
|
252
|
+
|
|
253
|
+
To force a string value, use JSON string syntax: '"true"' -> "true"
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
value: The value string from CLI input.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The parsed value (could be any JSON type or string).
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
return json.loads(value)
|
|
263
|
+
except json.JSONDecodeError:
|
|
264
|
+
# Fallback to string
|
|
265
|
+
return value
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def set_override(overrides: dict[str, Any], key: str, value: Any) -> None:
|
|
269
|
+
"""Set an override value at the given key path.
|
|
270
|
+
|
|
271
|
+
Creates intermediate dicts as needed for nested paths.
|
|
272
|
+
If key contains a wildcard, expands and sets each matching path.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
overrides: The overrides dict to modify (mutated in place).
|
|
276
|
+
key: Dot-notation path or wildcard pattern (e.g., "agent", "proxy.*").
|
|
277
|
+
value: The value to set.
|
|
278
|
+
"""
|
|
279
|
+
if "*" in key:
|
|
280
|
+
expanded = expand_wildcard(key)
|
|
281
|
+
for path in expanded:
|
|
282
|
+
_set_path(overrides, path.split("."), value)
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
parts = validate_key(key)
|
|
286
|
+
_set_path(overrides, parts, value)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _set_path(d: dict[str, Any], parts: list[str], value: Any) -> None:
|
|
290
|
+
"""Set a value at the given path, creating intermediate dicts."""
|
|
291
|
+
current = d
|
|
292
|
+
for part in parts[:-1]:
|
|
293
|
+
if part not in current:
|
|
294
|
+
current[part] = {}
|
|
295
|
+
elif not isinstance(current[part], dict):
|
|
296
|
+
# Overwrite non-dict intermediate
|
|
297
|
+
current[part] = {}
|
|
298
|
+
current = current[part]
|
|
299
|
+
|
|
300
|
+
current[parts[-1]] = value
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def delete_override(overrides: dict[str, Any], key: str) -> bool:
|
|
304
|
+
"""Delete an override at the given key path.
|
|
305
|
+
|
|
306
|
+
If key contains a wildcard, expands and deletes each matching path.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
overrides: The overrides dict to modify (mutated in place).
|
|
310
|
+
key: Dot-notation path or wildcard pattern.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
True if any key was deleted, False if nothing existed to delete.
|
|
314
|
+
"""
|
|
315
|
+
if "*" in key:
|
|
316
|
+
expanded = expand_wildcard(key)
|
|
317
|
+
any_deleted = False
|
|
318
|
+
for path in expanded:
|
|
319
|
+
if _delete_path(overrides, path.split(".")):
|
|
320
|
+
any_deleted = True
|
|
321
|
+
return any_deleted
|
|
322
|
+
|
|
323
|
+
# Validate key (allows us to catch invalid paths even on delete)
|
|
324
|
+
parts = validate_key(key)
|
|
325
|
+
return _delete_path(overrides, parts)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _delete_path(d: dict[str, Any], parts: list[str]) -> bool:
|
|
329
|
+
"""Delete a value at the given path. Returns True if deleted."""
|
|
330
|
+
current = d
|
|
331
|
+
for part in parts[:-1]:
|
|
332
|
+
if part not in current or not isinstance(current[part], dict):
|
|
333
|
+
return False
|
|
334
|
+
current = current[part]
|
|
335
|
+
|
|
336
|
+
if parts[-1] in current:
|
|
337
|
+
del current[parts[-1]]
|
|
338
|
+
return True
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def clear_overrides(overrides: dict[str, Any]) -> None:
|
|
343
|
+
"""Clear all overrides."""
|
|
344
|
+
overrides.clear()
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Resolve plan info for a derived session, with one-level parent fallback.
|
|
2
|
+
|
|
3
|
+
v1 walks only the immediate parent. Extend via ``derivation.lineage`` with a
|
|
4
|
+
loop guard if deeper walks become necessary.
|
|
5
|
+
|
|
6
|
+
Parent sources (in order of preference):
|
|
7
|
+
|
|
8
|
+
1. ``confirmed.derivation`` — set by ``forge session resume`` and
|
|
9
|
+
``forge session fork`` (carries ``parent_forge_root`` for cross-project
|
|
10
|
+
pointers).
|
|
11
|
+
2. ``state.parent_session`` top-level — legacy fallback for older fork
|
|
12
|
+
manifests that predate fork derivation metadata.
|
|
13
|
+
|
|
14
|
+
When only the top-level field is present, the parent's ``forge_root`` is looked
|
|
15
|
+
up via ``IndexStore``. Same-dir forks can still fall back to the caller's
|
|
16
|
+
``current_forge_root`` when the parent manifest is physically present there.
|
|
17
|
+
|
|
18
|
+
Authority rule: approved plan snapshots (``confirmed.artifacts["plans"]``) are
|
|
19
|
+
preferred over ``latest_plan_path`` drafts — same ordering as
|
|
20
|
+
``forge.session.handoff._resolve_plan_content``. Callers should render the
|
|
21
|
+
snapshot path when present and fall through to the draft only if absent.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any, Literal
|
|
29
|
+
|
|
30
|
+
from .exceptions import (
|
|
31
|
+
ManifestCorruptedError,
|
|
32
|
+
ManifestValidationError,
|
|
33
|
+
SessionFileNotFoundError,
|
|
34
|
+
)
|
|
35
|
+
from .models import SessionState
|
|
36
|
+
from .store import SessionStore
|
|
37
|
+
|
|
38
|
+
_log = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class PlanInfo:
|
|
43
|
+
"""What plan info applies to a session, and where it came from.
|
|
44
|
+
|
|
45
|
+
``draft_path`` is Claude-launch-root-relative (nested projects launch from
|
|
46
|
+
``forge_root``; root-level worktrees launch from ``worktree.path``).
|
|
47
|
+
``snapshot_path`` entries in ``approved_snapshots`` are forge-root-relative
|
|
48
|
+
(see artifacts.py:7-11). The ``parent_*_root`` fields carry the roots that
|
|
49
|
+
make those paths resolvable for inherited plans.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
draft_path: str | None = None
|
|
53
|
+
approved_snapshots: list[dict[str, Any]] = field(default_factory=list)
|
|
54
|
+
source: Literal["self", "parent"] | None = None
|
|
55
|
+
parent_session: str | None = None
|
|
56
|
+
parent_forge_root: str | None = None
|
|
57
|
+
parent_launch_root: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class DisplayedPath:
|
|
62
|
+
"""Absolute plan path plus on-disk existence status, for user-facing display."""
|
|
63
|
+
|
|
64
|
+
path: str
|
|
65
|
+
exists: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_plan_info(state: SessionState, *, current_forge_root: str) -> PlanInfo:
|
|
69
|
+
"""Return plan info for the given session, falling back to the immediate parent."""
|
|
70
|
+
|
|
71
|
+
confirmed = state.confirmed
|
|
72
|
+
self_snapshots = _plan_snapshots(confirmed.artifacts)
|
|
73
|
+
|
|
74
|
+
if confirmed.latest_plan_path or self_snapshots:
|
|
75
|
+
return PlanInfo(
|
|
76
|
+
draft_path=confirmed.latest_plan_path,
|
|
77
|
+
approved_snapshots=self_snapshots,
|
|
78
|
+
source="self",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
parent_name, parent_fr = _resolve_parent_pointer(state, current_forge_root)
|
|
82
|
+
if parent_name is None or parent_fr is None:
|
|
83
|
+
return PlanInfo()
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
parent_state = SessionStore(parent_fr, parent_name).read()
|
|
87
|
+
except (SessionFileNotFoundError, ManifestCorruptedError, ManifestValidationError) as exc:
|
|
88
|
+
_log.debug("Parent manifest unreadable for %s at %s: %s", parent_name, parent_fr, exc)
|
|
89
|
+
return PlanInfo()
|
|
90
|
+
except Exception as exc: # pragma: no cover - defense-in-depth for unexpected IO/permission
|
|
91
|
+
_log.debug("Unexpected error reading parent %s at %s: %s", parent_name, parent_fr, exc)
|
|
92
|
+
return PlanInfo()
|
|
93
|
+
|
|
94
|
+
parent_confirmed = parent_state.confirmed
|
|
95
|
+
parent_snapshots = _plan_snapshots(parent_confirmed.artifacts)
|
|
96
|
+
if not parent_confirmed.latest_plan_path and not parent_snapshots:
|
|
97
|
+
return PlanInfo()
|
|
98
|
+
|
|
99
|
+
return PlanInfo(
|
|
100
|
+
draft_path=parent_confirmed.latest_plan_path,
|
|
101
|
+
approved_snapshots=parent_snapshots,
|
|
102
|
+
source="parent",
|
|
103
|
+
parent_session=parent_name,
|
|
104
|
+
parent_forge_root=parent_fr,
|
|
105
|
+
parent_launch_root=resolve_plan_launch_root(parent_state),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def latest_snapshot_path(snapshots: list[dict[str, Any]]) -> str | None:
|
|
110
|
+
"""Return the `snapshot_path` of the last approved snapshot, or None."""
|
|
111
|
+
if not snapshots:
|
|
112
|
+
return None
|
|
113
|
+
last = snapshots[-1]
|
|
114
|
+
path = last.get("snapshot_path")
|
|
115
|
+
return path if isinstance(path, str) else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def preferred_plan_path(info: PlanInfo) -> str | None:
|
|
119
|
+
"""Return the best plan path to show the user (approved snapshot > draft)."""
|
|
120
|
+
return latest_snapshot_path(info.approved_snapshots) or info.draft_path
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_displayed_plan_path(
|
|
124
|
+
info: PlanInfo,
|
|
125
|
+
*,
|
|
126
|
+
current_forge_root: str,
|
|
127
|
+
current_launch_root: str | None = None,
|
|
128
|
+
current_worktree: str | None = None,
|
|
129
|
+
) -> DisplayedPath | None:
|
|
130
|
+
"""Resolve an absolute on-disk path for the preferred plan path, with existence check.
|
|
131
|
+
|
|
132
|
+
Returns ``None`` when no plan path is recorded. Otherwise returns the
|
|
133
|
+
absolute path (or the raw relative string if no resolution base is
|
|
134
|
+
available) and whether the file exists on disk.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
snap_rel = latest_snapshot_path(info.approved_snapshots)
|
|
138
|
+
if snap_rel is not None:
|
|
139
|
+
# Snapshot is forge-root-relative (artifacts.py:7-11).
|
|
140
|
+
base = info.parent_forge_root if info.source == "parent" else current_forge_root
|
|
141
|
+
return _resolve_against(snap_rel, base)
|
|
142
|
+
|
|
143
|
+
if info.draft_path:
|
|
144
|
+
# Draft is Claude-launch-root-relative. Keep ``current_worktree`` as a
|
|
145
|
+
# backward-compatible fallback for older callers/tests.
|
|
146
|
+
launch_root = current_launch_root or current_worktree
|
|
147
|
+
base = info.parent_launch_root if info.source == "parent" else launch_root
|
|
148
|
+
return _resolve_against(info.draft_path, base)
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_plan_launch_root(state: SessionState) -> str | None:
|
|
154
|
+
"""Return the root against which ``latest_plan_path`` should be resolved."""
|
|
155
|
+
if state.confirmed.claude_project_root:
|
|
156
|
+
return state.confirmed.claude_project_root
|
|
157
|
+
|
|
158
|
+
if not state.worktree and not state.forge_root:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
from .claude.paths import resolve_claude_project_root
|
|
163
|
+
|
|
164
|
+
return resolve_claude_project_root(state)
|
|
165
|
+
except Exception: # pragma: no cover - defensive fallback for malformed state
|
|
166
|
+
return state.forge_root or (state.worktree.path if state.worktree else None)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def resolve_path_against(rel_or_abs: str, base: str | None) -> DisplayedPath:
|
|
170
|
+
"""Join ``rel_or_abs`` against ``base`` unless it's already absolute; probe existence."""
|
|
171
|
+
from pathlib import Path
|
|
172
|
+
|
|
173
|
+
candidate = Path(rel_or_abs).expanduser()
|
|
174
|
+
if not candidate.is_absolute():
|
|
175
|
+
if base is None:
|
|
176
|
+
# No root to resolve against — return the bare string. Existence undecidable.
|
|
177
|
+
return DisplayedPath(path=rel_or_abs, exists=False)
|
|
178
|
+
candidate = Path(base) / candidate
|
|
179
|
+
try:
|
|
180
|
+
resolved = candidate.resolve()
|
|
181
|
+
except OSError:
|
|
182
|
+
return DisplayedPath(path=str(candidate), exists=False)
|
|
183
|
+
return DisplayedPath(path=str(resolved), exists=resolved.is_file())
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Internal alias retained for backward-compat within the module.
|
|
187
|
+
_resolve_against = resolve_path_against
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _resolve_parent_pointer(state: SessionState, current_forge_root: str) -> tuple[str | None, str | None]:
|
|
191
|
+
"""Find the (parent_name, parent_forge_root) tuple from whichever field is set."""
|
|
192
|
+
derivation = state.confirmed.derivation
|
|
193
|
+
if derivation is not None and derivation.parent_session:
|
|
194
|
+
return derivation.parent_session, (derivation.parent_forge_root or current_forge_root)
|
|
195
|
+
|
|
196
|
+
if state.parent_session:
|
|
197
|
+
name = state.parent_session
|
|
198
|
+
resolved_root = _lookup_parent_forge_root(state, name, current_forge_root)
|
|
199
|
+
if resolved_root is not None:
|
|
200
|
+
return name, resolved_root
|
|
201
|
+
|
|
202
|
+
# Same-dir forks are allowed to fall back to the child's forge_root only
|
|
203
|
+
# when the parent manifest is actually present there.
|
|
204
|
+
try:
|
|
205
|
+
if SessionStore(current_forge_root, name).exists():
|
|
206
|
+
return name, current_forge_root
|
|
207
|
+
except Exception: # pragma: no cover - invalid path / unexpected FS failure
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
_log.debug(
|
|
211
|
+
"Parent session %s referenced by %s could not be resolved from index or current forge_root %s",
|
|
212
|
+
name,
|
|
213
|
+
state.name,
|
|
214
|
+
current_forge_root,
|
|
215
|
+
)
|
|
216
|
+
return None, None
|
|
217
|
+
|
|
218
|
+
return None, None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _lookup_parent_forge_root(
|
|
222
|
+
state: SessionState,
|
|
223
|
+
parent_name: str,
|
|
224
|
+
current_forge_root: str,
|
|
225
|
+
) -> str | None:
|
|
226
|
+
"""Resolve the parent's forge_root, scoped by the child's ``project_root``.
|
|
227
|
+
|
|
228
|
+
Forks never cross logical repos (design.md §3), so siblings in the same
|
|
229
|
+
``project_root`` are the correct search space. Unscoped lookups raise
|
|
230
|
+
``AmbiguousSessionError`` when the same session name exists in multiple
|
|
231
|
+
Forge projects, which silently falls back to ``current_forge_root`` — the
|
|
232
|
+
wrong answer for ``--worktree`` / ``--into`` forks.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
from .index import IndexStore
|
|
236
|
+
|
|
237
|
+
store = IndexStore()
|
|
238
|
+
child_entry = _child_index_entry(store, state, current_forge_root)
|
|
239
|
+
if child_entry is None or not child_entry.project_root:
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
siblings = [
|
|
243
|
+
entry
|
|
244
|
+
for name, entry in store.list_sessions(
|
|
245
|
+
include_incognito=True,
|
|
246
|
+
project_root_filter=child_entry.project_root,
|
|
247
|
+
)
|
|
248
|
+
if name == parent_name
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
# Distinguish sibling Forge projects within the same logical repo by
|
|
252
|
+
# preserving relative_path across worktree forks.
|
|
253
|
+
child_relative_path = child_entry.relative_path or "."
|
|
254
|
+
matching_relative_path = [entry for entry in siblings if (entry.relative_path or ".") == child_relative_path]
|
|
255
|
+
if len(matching_relative_path) == 1:
|
|
256
|
+
entry = matching_relative_path[0]
|
|
257
|
+
return entry.forge_root or entry.worktree_path
|
|
258
|
+
|
|
259
|
+
if len(siblings) == 1:
|
|
260
|
+
# If there is only one same-name session left in the logical repo,
|
|
261
|
+
# prefer it even when relative_path metadata doesn't line up exactly.
|
|
262
|
+
entry = siblings[0]
|
|
263
|
+
return entry.forge_root or entry.worktree_path
|
|
264
|
+
|
|
265
|
+
return None
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
_log.debug("Scoped parent forge_root lookup failed for %s: %s", parent_name, exc)
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _child_index_entry(store: Any, state: SessionState, current_forge_root: str) -> Any:
|
|
272
|
+
"""Best-effort lookup of the child's own session index entry."""
|
|
273
|
+
scope = state.forge_root or current_forge_root
|
|
274
|
+
try:
|
|
275
|
+
return store.get_session(state.name, forge_root=scope)
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
_log.debug("Child index lookup failed for %s: %s", state.name, exc)
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _plan_snapshots(artifacts: dict[str, Any]) -> list[dict[str, Any]]:
|
|
282
|
+
"""Extract approved plan snapshots from the untyped artifacts dict."""
|
|
283
|
+
raw = artifacts.get("plans")
|
|
284
|
+
if not isinstance(raw, list):
|
|
285
|
+
return []
|
|
286
|
+
return [entry for entry in raw if isinstance(entry, dict) and entry.get("kind") == "approved"]
|