Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ WORKSPACE_ROOT=~/code
# Path override for the graphify binary.
# ALFRED_GRAPHIFY_BIN=

# Path to the graphify graph JSON.
# Checkout-relative path to the graphify graph JSON.
# ALFRED_GRAPHIFY_GRAPH=graphify-out/graph.json

# Fallback provider when graphify is unavailable (e.g. code-memory).
Expand Down
2 changes: 1 addition & 1 deletion docs/CODE_MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ Then enable it (or tick it in `alfred batteries` / the desktop battery picker):
| `ALFRED_GRAPHIFY_MCP` | `0` (off) | Attach graphify's read-only graph MCP to firings, taking the code-graph slot. |
| `ALFRED_GRAPHIFY_FALLBACK` | unset (`code-memory` when enabled through the battery picker) | Explicit engine to use while a repo has no Graphify graph. Set `none` to leave the slot empty instead. |
| `ALFRED_GRAPHIFY_BIN` | auto | Override the `graphify-mcp` executable path. Alfred otherwise uses the installed entrypoint. Package installation happens during battery setup, never inside an agent firing. |
| `ALFRED_GRAPHIFY_GRAPH` | `graphify-out/graph.json` | Graph file passed to the MCP server, relative to each firing's repo worktree unless absolute. |
| `ALFRED_GRAPHIFY_GRAPH` | `graphify-out/graph.json` | Graph file passed to the MCP server, relative to each firing's repo worktree. Absolute paths and paths that escape the checkout are rejected. |

A firing serves the graph in its own working directory (`graphify-out/graph.json`),
so build or update the graph per repo. If that repo has no graph yet, Alfred does
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ are experimental, deep-tuning, or set by Alfred itself at runtime.
| `ALFRED_CODE_MEMORY_FETCH_TIMEOUT_S` | int | `120` | internal | Fetch timeout in seconds for the code-memory server. |
| `ALFRED_GRAPHIFY_MCP` | bool | `0` | operator | Expose the graphify MCP server. |
| `ALFRED_GRAPHIFY_BIN` | path | | operator | Path override for the graphify binary. |
| `ALFRED_GRAPHIFY_GRAPH` | path | `graphify-out/graph.json` | operator | Path to the graphify graph JSON. |
| `ALFRED_GRAPHIFY_GRAPH` | path | `graphify-out/graph.json` | operator | Checkout-relative path to the graphify graph JSON. |
| `ALFRED_GRAPHIFY_FALLBACK` | str | | operator | Fallback provider when graphify is unavailable (e.g. code-memory). |
| `ALFRED_GRAPH_DENSIFY` | bool | `1` | internal | Enable graph densification projection; on by default. |
| `ALFRED_GBRAIN_BIN` | path | | internal | Path to an external graph-brain binary. |
Expand Down
6 changes: 6 additions & 0 deletions docs/DESKTOP_CLIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ The top row should feel like an operations status strip, not a dashboard full of

Ask owns plain-language intake and the planning inbox. Plan cards show:

When one selected repository is in scope and its full GitHub slug maps to a
verified local checkout, Ask runs its interrogator from that checkout. Claude
may only read, grep, and glob, and Codex is pinned to its read-only sandbox. A
missing, bare-name, unselected, or multi-repository mapping falls back to
Alfred's state directory instead of granting filesystem scope by guesswork.

- parent issue and Slack thread
- readiness verdict
- affected repos and rollout order
Expand Down
152 changes: 134 additions & 18 deletions lib/agent_runner/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
CODEX_APPROVAL_POLICY,
CODEX_BIN,
CODEX_DEFAULT_SANDBOX,
WORKSPACE,
)
from .reliability import (
CircuitBreaker,
Expand Down Expand Up @@ -502,6 +503,85 @@ def _graphify_entrypoint_works(command: str) -> bool:
return False


def _git_common_dir(checkout: Path) -> Path | None:
"""Return the shared Git directory for a checkout or linked worktree."""

try:
completed = subprocess.run(
["git", "-C", str(checkout), "rev-parse", "--git-common-dir"],
capture_output=True,
text=True,
timeout=5,
check=False,
env={
key: value
for key, value in os.environ.items()
if key in {"HOME", "PATH", "TMPDIR", "SYSTEMROOT"}
},
)
except (OSError, subprocess.TimeoutExpired):
return None
if completed.returncode != 0:
return None
raw = (completed.stdout or "").strip()
if not raw:
return None
common = Path(raw).expanduser()
if not common.is_absolute():
common = checkout / common
try:
return common.resolve(strict=True)
except (OSError, RuntimeError):
return None


def _graphify_checkout_roots(workdir: Path) -> list[Path]:
"""Prefer the configured checkout that owns this firing worktree."""

try:
checkout = workdir.resolve(strict=True)
except (OSError, RuntimeError):
return []
roots: list[Path] = []
try:
from .github import repo_to_local_map

mappings = repo_to_local_map(os.environ)
except Exception:
mappings = {}
if mappings:
common = _git_common_dir(checkout)
if common is not None:
for raw in mappings.values():
candidate = Path(str(raw)).expanduser()
if not candidate.is_absolute():
candidate = WORKSPACE / candidate
try:
candidate = candidate.resolve(strict=True)
except (OSError, RuntimeError):
continue
if candidate in roots or _git_common_dir(candidate) != common:
continue
roots.append(candidate)
if checkout not in roots:
roots.append(checkout)
return roots


def _graphify_graph_path(workdir: Path, graph_path: Path) -> Path | None:
"""Resolve one checkout-relative graph without crossing repo boundaries."""

for root in _graphify_checkout_roots(workdir):
try:
resolved = (root / graph_path).resolve(strict=True)
resolved.relative_to(root)
except (OSError, RuntimeError, ValueError):
continue
if resolved.is_file():
return resolved
return None


def _graphify_mcp_server(workdir: Path | None = None) -> dict[str, Any] | None:
"""Return the ``mcpServers`` entry for graphify, or ``None`` when disabled
or the command is not on PATH.
Expand All @@ -518,16 +598,15 @@ def _graphify_mcp_server(workdir: Path | None = None) -> dict[str, Any] | None:
cmd, prefix = invocation
graph = os.environ.get("ALFRED_GRAPHIFY_GRAPH", "").strip() or "graphify-out/graph.json"
graph_path = Path(graph).expanduser()
resolved_graph = (
graph_path if graph_path.is_absolute() else (workdir / graph_path if workdir else None)
)
if resolved_graph is not None and not resolved_graph.is_file():
if workdir is None or graph_path.is_absolute():
return None
resolved_graph = _graphify_graph_path(workdir, graph_path)
if resolved_graph is None:
return None
graph_arg = str(resolved_graph) if resolved_graph is not None else str(graph_path)
return {
GRAPHIFY_MCP_SERVER: {
"command": cmd,
"args": [*prefix, graph_arg, "--transport", "stdio"],
"args": [*prefix, str(resolved_graph), "--transport", "stdio"],
}
}

Expand Down Expand Up @@ -871,6 +950,7 @@ def claude_invoke_streaming(
timeout: int = 1200,
resume_session: str | None = None,
model: str | None = None,
read_only_isolation: bool = False,
_auth_retry: bool = False,
) -> ClaudeResult:
"""Streaming counterpart of :func:`claude_invoke`. Same return shape.
Expand Down Expand Up @@ -901,23 +981,46 @@ def claude_invoke_streaming(
if max_turns is None:
max_turns = _CLAUDE_UNLIMITED_TURNS

memory_script = _memory_mcp_script()
memory_script = None if read_only_isolation else _memory_mcp_script()
resolved_tools = (
allowed_tools
if read_only_isolation
else _with_memory_mcp_tools(allowed_tools, memory_script, workdir)
)
cmd = [
_runtime_cli_bin("CLAUDE_BIN", CLAUDE_BIN),
"-p",
prompt,
"--allowedTools",
_with_memory_mcp_tools(allowed_tools, memory_script, workdir),
"--max-turns",
str(max_turns),
"--output-format",
"stream-json",
"--verbose",
"--permission-mode",
"bypassPermissions",
]
cmd.extend(_agent_settings_args())
cmd.extend(_memory_mcp_args(memory_script, workdir))
if read_only_isolation:
cmd.extend(["--tools", resolved_tools])
cmd.extend(
[
"--allowedTools",
resolved_tools,
"--max-turns",
str(max_turns),
"--output-format",
"stream-json",
"--verbose",
"--permission-mode",
"dontAsk" if read_only_isolation else "bypassPermissions",
]
)
if read_only_isolation:
cmd.extend(
[
"--safe-mode",
"--strict-mcp-config",
"--mcp-config",
'{"mcpServers":{}}',
"--no-session-persistence",
]
)
if not read_only_isolation:
cmd.extend(_agent_settings_args())
if memory_script is not None:
cmd.extend(_memory_mcp_args(memory_script, workdir))
if model:
cmd.extend(["--model", model])
if resume_session:
Expand Down Expand Up @@ -1065,6 +1168,7 @@ def _capture_stdout() -> None:
timeout=timeout,
resume_session=resume_session,
model=model,
read_only_isolation=read_only_isolation,
_auth_retry=True,
)
return result
Expand Down Expand Up @@ -1149,6 +1253,8 @@ def codex_invoke(
sandbox: str | None = None,
approval_policy: str | None = None,
bypass_approvals_and_sandbox: bool = False,
ignore_user_config: bool = False,
ephemeral: bool = False,
add_dirs: list[Path] | None = None,
allowed_tools: str | None = None,
max_turns: int | None = None,
Expand Down Expand Up @@ -1207,6 +1313,10 @@ def codex_invoke(
"--cd",
str(workdir),
]
if ignore_user_config:
cmd.append("--ignore-user-config")
if ephemeral:
cmd.append("--ephemeral")
resolved_sandbox = sandbox or CODEX_DEFAULT_SANDBOX
if bypass_approvals_and_sandbox:
cmd.append("--dangerously-bypass-approvals-and-sandbox")
Expand Down Expand Up @@ -1688,6 +1798,9 @@ def invoke_agent_engine(
codex_add_dirs: list[Path] | None = None,
codex_approval_policy: str | None = None,
codex_bypass_approvals_and_sandbox: bool = False,
codex_ignore_user_config: bool = False,
codex_ephemeral: bool = False,
claude_read_only_isolation: bool = False,
claude_fn: Callable[..., ClaudeResult] | None = None,
codex_fn: Callable[..., ClaudeResult] | None = None,
on_fallback: Callable[[ClaudeResult], None] | None = None,
Expand Down Expand Up @@ -1800,6 +1913,7 @@ def _invoke_claude() -> ClaudeResult:
max_turns=claude_max_turns,
timeout=timeout,
model=claude_model,
read_only_isolation=claude_read_only_isolation,
)

def _invoke_codex() -> ClaudeResult:
Expand All @@ -1813,6 +1927,8 @@ def _invoke_codex() -> ClaudeResult:
sandbox=codex_sandbox,
approval_policy=codex_approval_policy,
bypass_approvals_and_sandbox=codex_bypass_approvals_and_sandbox,
ignore_user_config=codex_ignore_user_config,
ephemeral=codex_ephemeral,
add_dirs=codex_add_dirs,
)

Expand Down
2 changes: 1 addition & 1 deletion lib/alfred_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,7 +947,7 @@ def V(
"path",
"graphify-out/graph.json",
"memory",
"Path to the graphify graph JSON.",
"Checkout-relative path to the graphify graph JSON.",
operator=True,
),
V(
Expand Down
Loading
Loading