graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/init.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
"""Interactive project initialization for Graphite-aware AI coding agents."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterable, TextIO
|
|
11
|
+
|
|
12
|
+
from .agent_settings import ensure_claude_settings
|
|
13
|
+
from .bootstrap import ensure_gitignore, daemon_visibility
|
|
14
|
+
from .git import GitError, GitRunner
|
|
15
|
+
# Imported as a module, not `from .hookinstall import install_hooks`: this
|
|
16
|
+
# file also has an `install_hooks` *parameter* below, and that local name
|
|
17
|
+
# would shadow a bare function import. `hookinstall.install_hooks(...)` stays
|
|
18
|
+
# unambiguous either way.
|
|
19
|
+
from . import hookinstall
|
|
20
|
+
from .hookinstall import DEFAULT_HOOKS_DIRNAME
|
|
21
|
+
from .io import atomic_write_text
|
|
22
|
+
|
|
23
|
+
# Bump whenever GRAPHITE_DOC, SHARED_POINTER, or CURSOR_POINTER changes;
|
|
24
|
+
# test_template_change_requires_doc_version_bump pins the pairing. Files
|
|
25
|
+
# written before versioning existed count as version 1 ("legacy unversioned")
|
|
26
|
+
# and are never rewritten automatically.
|
|
27
|
+
DOC_VERSION = 14
|
|
28
|
+
|
|
29
|
+
MANAGED_BEGIN = f"<!-- graphite:managed version={DOC_VERSION} -->"
|
|
30
|
+
MANAGED_END = "<!-- graphite:managed-end -->"
|
|
31
|
+
_MANAGED_BEGIN_RE = re.compile(r"<!-- graphite:managed version=(\d{1,9}) -->")
|
|
32
|
+
|
|
33
|
+
GRAPHITE_DOC_HEADER = "# Graphite Development Context"
|
|
34
|
+
GRAPHITE_REQUIRED_WORKFLOW = "## Required Workflow"
|
|
35
|
+
GRAPHITE_DOC = """# Graphite Development Context
|
|
36
|
+
|
|
37
|
+
Graphite is the shared local code graph for this project. Codex, Claude Code, Gemini CLI, Antigravity, Visual Studio, and other coding agents should use the same graph instead of rebuilding separate mental maps.
|
|
38
|
+
|
|
39
|
+
All commands below use `python -m graphite`, which works in every shell and for every agent as long as the Python environment has Graphite installed. A bare `graphite` command is equivalent where the console script is on PATH.
|
|
40
|
+
|
|
41
|
+
## Required Workflow
|
|
42
|
+
|
|
43
|
+
Graphite-first is required, not advisory. Before any cross-file exploration, consult the graph first. Manual search (grep, glob, directory walking) is the fallback, not the default: use it for literal text and filename lookups, or after a Graphite answer proved insufficient — and say so when you fall back.
|
|
44
|
+
|
|
45
|
+
| Question shape | Run first |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Who calls / reads / imports this symbol? | `python -m graphite query "callers <symbol>"` |
|
|
48
|
+
| What does this symbol call? | `python -m graphite query "calls <symbol>"` |
|
|
49
|
+
| Where is this symbol defined? | `python -m graphite search "<symbol>"` |
|
|
50
|
+
| What breaks if this file changes? | `python -m graphite impact <file>` |
|
|
51
|
+
| What surrounds this file (callers, tests, neighbors)? | `python -m graphite context <file>` |
|
|
52
|
+
| How is the project structured? | `python -m graphite query "stats"` |
|
|
53
|
+
| Literal string or filename lookup | grep/glob — Graphite not required |
|
|
54
|
+
| Where is the shared agent channel? | `python -m graphite channel` |
|
|
55
|
+
|
|
56
|
+
Before non-trivial code changes:
|
|
57
|
+
|
|
58
|
+
1. Run `python -m graphite check .`
|
|
59
|
+
2. Run `python -m graphite context <target-file>` before editing important files.
|
|
60
|
+
3. Run `python -m graphite impact <target-file>` before changing shared logic, APIs, data flow, auth, persistence, deployment behavior, or other high-risk paths.
|
|
61
|
+
4. Use `python -m graphite search "<symbol, path, or concept>"` to locate nodes; use `python -m graphite query "stats"` when project structure is unclear.
|
|
62
|
+
5. Discover supported commands, query verbs, and limits with `python -m graphite capabilities --json` — do not guess query verbs. `query` takes structured verbs; `query --natural "<question>"` accepts only the fixed deterministic grammar listed by capabilities (no inference — unmatched questions fall back to ranked search).
|
|
63
|
+
6. Graph answers carry an `answer` block: `grade: decision_grade` means this answer's own relations/languages are healthy (an empty result is a trustworthy absence, subject to `caveats`); `advisory` means verify with grep and say so; `inconclusive` (also the legacy `"inconclusive": true`) means unknown, not safe. Check `known limits`/`caveats` before trusting empties.
|
|
64
|
+
7. `python -m graphite incidents list` shows recorded failures (build errors, malformed artifacts, inconclusive queries). Check it when a graph answer looks wrong; recurring incidents belong in a governed round.
|
|
65
|
+
|
|
66
|
+
After edits:
|
|
67
|
+
|
|
68
|
+
1. Run `python -m graphite build .` when `python -m graphite check .` reports the graph stale. Otherwise this repo's graph refreshes on its own while it is open in a coding agent.
|
|
69
|
+
2. Run relevant tests, typechecks, or validation commands.
|
|
70
|
+
3. Do not edit `graph-out/` manually.
|
|
71
|
+
|
|
72
|
+
Graph freshness is never a reason to avoid the graph. Always query it for
|
|
73
|
+
relationship questions: every answer carries its own `answer.grade`, so trust
|
|
74
|
+
that rather than guessing whether the graph is current. If an answer comes back
|
|
75
|
+
`inconclusive` or insufficient, fall back to search and say that you did.
|
|
76
|
+
|
|
77
|
+
## Repository Isolation
|
|
78
|
+
|
|
79
|
+
**Your repository is your world.** Do not read, write, or run commands in any other repository — including its `graph-out/`. This holds even when the other repo sits on the same machine, is a dependency of this one, or plainly contains the answer you need.
|
|
80
|
+
|
|
81
|
+
Cross-repo knowledge travels one way only: as a **recommendation**, through the shared interop channel, addressed to the agent that owns that repository. That agent decides and acts. A defect you find elsewhere is a request, never a patch — and never a read.
|
|
82
|
+
|
|
83
|
+
- Do not open another repo's source, tests, config, or `graph.json`. Each repo's graph describes that repo and belongs to its agent.
|
|
84
|
+
- Do not run any command with another repository as its working directory or root, including read-only ones such as `status`, `doctor`, or a test suite.
|
|
85
|
+
- Do report what you observed from your own side, and ask the owning agent to look. Say plainly which parts you could not verify.
|
|
86
|
+
- Do act on what another agent tells you about their repository, and attribute it to them.
|
|
87
|
+
|
|
88
|
+
**If you need a fact from another repo, ask for it.** A claim clearly labelled unverified is safer than a verified one obtained out of bounds — the boundary is the control, and stepping over it to be thorough defeats it.
|
|
89
|
+
|
|
90
|
+
### The one exception: the shared agent channel
|
|
91
|
+
|
|
92
|
+
There is one shared **agent channel** on this machine: a directory named `.agent-channel/`, its own git repository, living outside every project and belonging to no repo and no agent. **Every agent may read it and write to it**, whichever repository it is responsible for, and nothing in it is any project's source or data.
|
|
93
|
+
|
|
94
|
+
Its absolute location is machine-local and deliberately kept out of this file: project files are committed and pushed, so a local directory layout does not belong in them. **Resolve it with `python -m graphite channel`** (`--json` for a machine-readable form, reporting whether it exists and is a git repo). The path goes to stdout and diagnostics to stderr, so `$(python -m graphite channel)` is safe to use directly.
|
|
95
|
+
|
|
96
|
+
This is the exception that makes isolation workable: isolation without a channel is a wall, not a boundary. Read the channel's `PROTOCOL.md` before writing there.
|
|
97
|
+
|
|
98
|
+
**Use the broker, not the filesystem.** Graphite exposes the channel as MCP tools, so you never need write access outside your own repository — and if you are sandboxed to your workspace, these are the only way in:
|
|
99
|
+
|
|
100
|
+
| tool | what it does |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `graphite_channel_inbox` | messages addressed to you that you have not been handed yet — **call this at the start of a session** |
|
|
103
|
+
| `graphite_channel_post` | write a new round |
|
|
104
|
+
| `graphite_channel_status` | `acknowledged` / `blocked` (give a reason) / `done` / `withdrawn` |
|
|
105
|
+
| `graphite_channel_list` | every round with its author and current status |
|
|
106
|
+
| `graphite_channel_read` | one round by number |
|
|
107
|
+
|
|
108
|
+
Four things that will bite you if you assume otherwise:
|
|
109
|
+
|
|
110
|
+
- **You cannot post as another agent.** There is no author field; your identity comes from the repository the server runs in. `unregistered_project` means ask the operator to register you, not look for a way around it.
|
|
111
|
+
- **Rounds are immutable.** Create-only — no edit, no append, no delete. Correct one by posting another with `supersedes`.
|
|
112
|
+
- **Graphite assigns round numbers.** Do not pick one.
|
|
113
|
+
- **Delivery is recorded by the broker**, as `inbox` hands a message over. You cannot assert or decline it, and anything left `delivered` or `acknowledged` for more than 3 days is reported as stalled.
|
|
114
|
+
|
|
115
|
+
Every commit there carries your agent's `Co-Authored-By` trailer and states its reason; a `commit-msg` hook rejects commits that name no agent, because all agents commit under one identity and the trailer is what makes the history auditable. The broker satisfies that hook for you. An operator can audit the whole channel at any time with `python -m graphite channel report`, which grades every row by what it can actually vouch for.
|
|
116
|
+
|
|
117
|
+
### Agent boundary vs. tool boundary
|
|
118
|
+
|
|
119
|
+
These are different questions and one must not be used to argue about the other.
|
|
120
|
+
|
|
121
|
+
- **An agent** may act only within its own repository.
|
|
122
|
+
- **A tool doing what it was designed to do is not an agent crossing a boundary.** `graphite init` onboarding a repository, or a security gate running against one, is the operator's tooling operating on a repo. That is by design and is not governed by the agent rule.
|
|
123
|
+
|
|
124
|
+
The distinction belongs to the operator invoking the tool. It is not a licence to reclassify yourself as a tool in order to reach into another repository.
|
|
125
|
+
|
|
126
|
+
## Canonical Graph Isolation
|
|
127
|
+
|
|
128
|
+
`scan`, `build`, `report`, `check`, `validate`, `query`, `context`, `impact`,
|
|
129
|
+
`watch`, and `daemon` are inference-free canonical operations. They do not read
|
|
130
|
+
provider credentials, ignore ambient `GRAPHITE_LLM*` configuration, and reject
|
|
131
|
+
legacy non-`none` LLM flags. Model-generated annotations belong only in the
|
|
132
|
+
explicit, non-authoritative overlay boundary and must never replace or modify
|
|
133
|
+
canonical `graph-out` artifacts.
|
|
134
|
+
|
|
135
|
+
## Operating Rules
|
|
136
|
+
|
|
137
|
+
- Treat Graphite as a project map, not as proof of correctness.
|
|
138
|
+
- Always read the source files and tests that Graphite identifies before changing behavior.
|
|
139
|
+
- Graphite-first: prefer graph commands over manual cross-file search; fall back only when the graph answer is insufficient, and say so.
|
|
140
|
+
- If `python -m graphite check .` reports stale output, rebuild before relying on context or impact data.
|
|
141
|
+
- Canonical Graphite operations run locally and never use LLM or network inference.
|
|
142
|
+
- For TypeScript resolver issues, use `python -m graphite --typescript-resolver disabled build .` only as a fallback.
|
|
143
|
+
- Stay inside this repository: no reads, writes, or commands in any other repo or its graph. Findings about another repo go to its agent as a recommendation via the shared `.agent-channel/`.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
SHARED_POINTER_HEADER = "## Shared Graphite Instructions"
|
|
147
|
+
SHARED_POINTER = """## Shared Graphite Instructions
|
|
148
|
+
|
|
149
|
+
Graphite-first is required in this repo. Follow `GRAPHITE.md` before making non-trivial code changes: for cross-file questions (who-calls, where-defined, impact, data flow, structure) run the Graphite commands first; grep/glob are for literal text and filename lookups only. Fall back to manual search only after a Graphite answer proved insufficient, and say so. Use the existing `graph-out/graph.json` as the shared project graph, and do not edit `graph-out/` manually.
|
|
150
|
+
|
|
151
|
+
**Stay inside this repository.** Do not read, write, or run commands in any other repo, including its graph. Findings about another repo go to its agent as a recommendation through the shared `.agent-channel/` (see its `PROTOCOL.md`); that agent decides and acts. A tool doing its designed job is a separate question from an agent's boundary. See `GRAPHITE.md` section "Repository Isolation".
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
CURSOR_POINTER = """---
|
|
155
|
+
description: Graphite-first project context is required before non-trivial code changes
|
|
156
|
+
alwaysApply: true
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
# Graphite Instructions
|
|
160
|
+
|
|
161
|
+
Graphite-first is required in this repo. Follow `GRAPHITE.md` before making non-trivial code changes: for cross-file questions (who-calls, where-defined, impact, data flow, structure) run the Graphite commands first; grep/glob are for literal text and filename lookups only. Fall back to manual search only after a Graphite answer proved insufficient, and say so. Use the existing `graph-out/graph.json` as the shared project graph, and do not edit `graph-out/` manually.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
PLATFORM_ORDER: tuple[str, ...] = (
|
|
165
|
+
"codex",
|
|
166
|
+
"claude",
|
|
167
|
+
"gemini",
|
|
168
|
+
"antigravity",
|
|
169
|
+
"visual-studio",
|
|
170
|
+
"cursor",
|
|
171
|
+
"windsurf",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
DEFAULT_PLATFORMS: tuple[str, ...] = (
|
|
175
|
+
"codex",
|
|
176
|
+
"claude",
|
|
177
|
+
"antigravity",
|
|
178
|
+
"visual-studio",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class PlatformSpec:
|
|
184
|
+
key: str
|
|
185
|
+
label: str
|
|
186
|
+
files: tuple[str, ...]
|
|
187
|
+
content: str = SHARED_POINTER
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
PLATFORMS: dict[str, PlatformSpec] = {
|
|
191
|
+
"codex": PlatformSpec("codex", "Codex CLI / Codex Desktop", ("AGENTS.md",)),
|
|
192
|
+
"claude": PlatformSpec("claude", "Claude Code", ("CLAUDE.md",)),
|
|
193
|
+
"gemini": PlatformSpec("gemini", "Gemini CLI", ("GEMINI.md",)),
|
|
194
|
+
"antigravity": PlatformSpec("antigravity", "Antigravity IDE", ("ANTIGRAVITY.md",)),
|
|
195
|
+
"visual-studio": PlatformSpec("visual-studio", "Visual Studio / GitHub Copilot", (".github/copilot-instructions.md",)),
|
|
196
|
+
"cursor": PlatformSpec("cursor", "Cursor", (".cursor/rules/graphite.mdc",), CURSOR_POINTER),
|
|
197
|
+
"windsurf": PlatformSpec("windsurf", "Windsurf", (".windsurfrules",)),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
ALIASES: dict[str, str] = {
|
|
201
|
+
"agent": "codex",
|
|
202
|
+
"agents": "codex",
|
|
203
|
+
"codex-cli": "codex",
|
|
204
|
+
"codex-desktop": "codex",
|
|
205
|
+
"claude-code": "claude",
|
|
206
|
+
"gemini-cli": "gemini",
|
|
207
|
+
"google-gemini": "gemini",
|
|
208
|
+
"google-antigravity": "antigravity",
|
|
209
|
+
"vs": "visual-studio",
|
|
210
|
+
"vscode": "visual-studio",
|
|
211
|
+
"visualstudio": "visual-studio",
|
|
212
|
+
"visual-studio-code": "visual-studio",
|
|
213
|
+
"copilot": "visual-studio",
|
|
214
|
+
"github-copilot": "visual-studio",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def managed_doc_paths() -> tuple[Path, ...]:
|
|
219
|
+
"""Every repo-relative path `init` may generate, as the one source of
|
|
220
|
+
truth for "graphite wrote this file".
|
|
221
|
+
|
|
222
|
+
Derived from `PLATFORMS` rather than listed by hand so that adding a
|
|
223
|
+
platform cannot silently leave its file unwatched by
|
|
224
|
+
`doctor.check_managed_docs` -- the same one-true-registry argument
|
|
225
|
+
`ensure_platform_file` already relies on. `GRAPHITE.md` and
|
|
226
|
+
`.claude/settings.json` are added explicitly because they are written
|
|
227
|
+
outside the platform loop (`run_init` steps for the shared doc and the
|
|
228
|
+
agent-hook settings), and `.vscode/tasks.json` likewise
|
|
229
|
+
(`ensure_vscode_activation_task`).
|
|
230
|
+
|
|
231
|
+
`.vscode/tasks.json` was missing here until 2026-07-31, and the cost was
|
|
232
|
+
concrete: `graphite init` wrote it into aramid's repo on 07-28 and it sat
|
|
233
|
+
untracked for three days on a machine running this very check, which is how
|
|
234
|
+
graphite came to assert three rounds running that it had never written
|
|
235
|
+
there. A registry that omits a path is indistinguishable from a clean repo.
|
|
236
|
+
|
|
237
|
+
Git hooks are deliberately NOT here -- their directory is not a fixed
|
|
238
|
+
repo-relative path (`hookinstall.hooks_dir` honours an existing
|
|
239
|
+
`core.hooksPath`), so they need a root to resolve. See
|
|
240
|
+
`doctor.managed_hook_paths`.
|
|
241
|
+
|
|
242
|
+
Returns the full candidate set, not the set present in any given repo:
|
|
243
|
+
callers filter by existence, because a platform a repo never selected is
|
|
244
|
+
absent and that is not a finding.
|
|
245
|
+
"""
|
|
246
|
+
paths = {
|
|
247
|
+
Path("GRAPHITE.md"),
|
|
248
|
+
Path(".claude/settings.json"),
|
|
249
|
+
Path(".vscode/tasks.json"),
|
|
250
|
+
# `.mcp.json` is how the channel broker actually reaches a repo. Left out
|
|
251
|
+
# of this set it would be generated and then never checked, which is the
|
|
252
|
+
# exact failure `.vscode/tasks.json` had until 2026-07-31.
|
|
253
|
+
Path(".mcp.json"),
|
|
254
|
+
}
|
|
255
|
+
for spec in PLATFORMS.values():
|
|
256
|
+
paths.update(Path(rel) for rel in spec.files)
|
|
257
|
+
return tuple(sorted(paths, key=lambda path: path.as_posix()))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True)
|
|
261
|
+
class InitResult:
|
|
262
|
+
project_root: Path
|
|
263
|
+
platforms: tuple[str, ...]
|
|
264
|
+
graphite_doc: dict[str, Any]
|
|
265
|
+
gitignore: dict[str, Any]
|
|
266
|
+
platform_files: list[dict[str, Any]]
|
|
267
|
+
allowlist: dict[str, Any]
|
|
268
|
+
daemon: dict[str, Any]
|
|
269
|
+
agent_hooks: dict[str, Any]
|
|
270
|
+
hooks: dict[str, Any]
|
|
271
|
+
|
|
272
|
+
def to_dict(self) -> dict[str, Any]:
|
|
273
|
+
return {
|
|
274
|
+
"project_root": str(self.project_root),
|
|
275
|
+
"platforms": list(self.platforms),
|
|
276
|
+
"graphite_doc": self.graphite_doc,
|
|
277
|
+
"gitignore": self.gitignore,
|
|
278
|
+
"platform_files": self.platform_files,
|
|
279
|
+
"allowlist": self.allowlist,
|
|
280
|
+
"daemon": self.daemon,
|
|
281
|
+
"agent_hooks": self.agent_hooks,
|
|
282
|
+
"hooks": self.hooks,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def platform_choices() -> list[dict[str, str]]:
|
|
287
|
+
return [{"key": key, "label": PLATFORMS[key].label} for key in PLATFORM_ORDER]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def resolve_platform_selection(
|
|
291
|
+
requested: Iterable[str] | None,
|
|
292
|
+
*,
|
|
293
|
+
interactive: bool = False,
|
|
294
|
+
stdin: TextIO | None = None,
|
|
295
|
+
stdout: TextIO | None = None,
|
|
296
|
+
) -> tuple[str, ...]:
|
|
297
|
+
tokens = [token for value in requested or [] for token in _split_platform_tokens(value)]
|
|
298
|
+
if not tokens and interactive:
|
|
299
|
+
tokens = list(_prompt_for_platforms(stdin=stdin, stdout=stdout))
|
|
300
|
+
if not tokens:
|
|
301
|
+
tokens = list(DEFAULT_PLATFORMS)
|
|
302
|
+
|
|
303
|
+
resolved: list[str] = []
|
|
304
|
+
for token in tokens:
|
|
305
|
+
normalized = _normalize_platform(token)
|
|
306
|
+
if normalized == "all":
|
|
307
|
+
for key in PLATFORM_ORDER:
|
|
308
|
+
if key not in resolved:
|
|
309
|
+
resolved.append(key)
|
|
310
|
+
continue
|
|
311
|
+
if normalized not in PLATFORMS:
|
|
312
|
+
valid = ", ".join([*PLATFORM_ORDER, "all"])
|
|
313
|
+
raise ValueError(f"unknown platform '{token}'. Valid platforms: {valid}")
|
|
314
|
+
if normalized not in resolved:
|
|
315
|
+
resolved.append(normalized)
|
|
316
|
+
return tuple(resolved)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_git_repo(root: Path) -> bool:
|
|
320
|
+
"""Ground truth, not a `.git`-exists heuristic: `--is-inside-work-tree`
|
|
321
|
+
also answers correctly for a repo `root` nested inside a larger one,
|
|
322
|
+
matching how `hookinstall`'s own `git -C root ...` calls resolve."""
|
|
323
|
+
result = subprocess.run(
|
|
324
|
+
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
|
|
325
|
+
capture_output=True, text=True,
|
|
326
|
+
# See `channel._git`: `text=True` alone uses the locale codec, and a
|
|
327
|
+
# decode failure returns None instead of raising -- which here would
|
|
328
|
+
# read as "not a git repo" and silently skip hook installation.
|
|
329
|
+
encoding="utf-8", errors="replace",
|
|
330
|
+
)
|
|
331
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def init_project(
|
|
335
|
+
project_root: Path,
|
|
336
|
+
*,
|
|
337
|
+
platforms: Iterable[str],
|
|
338
|
+
daemon_base: Path | None = None,
|
|
339
|
+
agent_hooks_mode: str | None = None,
|
|
340
|
+
install_agent_hooks: bool = True,
|
|
341
|
+
install_hooks: bool = True,
|
|
342
|
+
interpreter: Path | None = None,
|
|
343
|
+
adopt: bool = False,
|
|
344
|
+
) -> InitResult:
|
|
345
|
+
root = project_root.resolve()
|
|
346
|
+
if not root.exists():
|
|
347
|
+
raise FileNotFoundError(root)
|
|
348
|
+
if not root.is_dir():
|
|
349
|
+
raise NotADirectoryError(root)
|
|
350
|
+
|
|
351
|
+
selected = resolve_platform_selection(platforms)
|
|
352
|
+
graphite_doc = ensure_graphite_doc(root / "GRAPHITE.md", adopt=adopt)
|
|
353
|
+
gitignore = ensure_gitignore(root / ".gitignore")
|
|
354
|
+
platform_files: list[dict[str, Any]] = []
|
|
355
|
+
instruction_paths = [Path("GRAPHITE.md")]
|
|
356
|
+
|
|
357
|
+
for key in selected:
|
|
358
|
+
spec = PLATFORMS[key]
|
|
359
|
+
for rel in spec.files:
|
|
360
|
+
rel_path = Path(rel)
|
|
361
|
+
platform_files.append(ensure_platform_file(root / rel_path, spec=spec, adopt=adopt))
|
|
362
|
+
instruction_paths.append(rel_path)
|
|
363
|
+
|
|
364
|
+
if install_agent_hooks:
|
|
365
|
+
# Strict is the default for every caller, not just the CLI. A setting
|
|
366
|
+
# that must be applied by sweeping repos decays as soon as a new repo
|
|
367
|
+
# appears, and the sweep itself rebuilds repos nobody has open. Strict
|
|
368
|
+
# denials are health-gated and re-arm on their own, so this cannot trap
|
|
369
|
+
# an agent behind a bad graph.
|
|
370
|
+
agent_hooks = ensure_claude_settings(root, mode=agent_hooks_mode or "strict")
|
|
371
|
+
instruction_paths.append(Path(".claude/settings.json"))
|
|
372
|
+
else:
|
|
373
|
+
agent_hooks = {
|
|
374
|
+
"path": str(root / ".claude" / "settings.json"),
|
|
375
|
+
"changed": False,
|
|
376
|
+
"action": "skipped",
|
|
377
|
+
"mode": None,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if install_hooks and _is_git_repo(root):
|
|
381
|
+
# Same reasoning as install_agent_hooks above: hooks-on is the default
|
|
382
|
+
# for every caller, not just the CLI. A setting applied by sweeping
|
|
383
|
+
# repos by hand decays the moment a new repo appears, and relocation
|
|
384
|
+
# (see hookinstall's docstring) means turning this on is never a
|
|
385
|
+
# surprise to another tool's hooks -- only ever reported.
|
|
386
|
+
relocated = hookinstall.install_hooks(root, interpreter or Path(sys.executable))
|
|
387
|
+
hooks_result: dict[str, Any] = {
|
|
388
|
+
"path": str(hookinstall.hooks_dir(root)),
|
|
389
|
+
"action": "installed",
|
|
390
|
+
"relocated": relocated,
|
|
391
|
+
}
|
|
392
|
+
elif install_hooks:
|
|
393
|
+
# Not a git repo (yet). `hookinstall.install_hooks` would still write
|
|
394
|
+
# trampolines into `.githooks/`, but with no `.git` for `core.hooksPath`
|
|
395
|
+
# to live in, git would never dispatch to them -- reporting "installed"
|
|
396
|
+
# here would be a confident, silent wrong answer. Report honestly and
|
|
397
|
+
# skip, rather than leave inert files that look like they did something.
|
|
398
|
+
hooks_result = {
|
|
399
|
+
"path": str(root / hookinstall.DEFAULT_HOOKS_DIRNAME),
|
|
400
|
+
"action": "skipped",
|
|
401
|
+
"relocated": [],
|
|
402
|
+
"reason": "not a git repository",
|
|
403
|
+
}
|
|
404
|
+
else:
|
|
405
|
+
# install_hooks=False says nothing about whether this is a git repo
|
|
406
|
+
# (unlike the branch above) -- go through hookinstall.hooks_dir so an
|
|
407
|
+
# existing core.hooksPath (husky et al.) is still reported accurately
|
|
408
|
+
# rather than defaulting to a path graphite wouldn't actually use.
|
|
409
|
+
hooks_result = {
|
|
410
|
+
"path": str(hookinstall.hooks_dir(root)),
|
|
411
|
+
"action": "skipped",
|
|
412
|
+
"relocated": [],
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
ensure_vscode_activation_task(root / ".vscode" / "tasks.json")
|
|
416
|
+
ensure_mcp_config(root / ".mcp.json")
|
|
417
|
+
# Measured after the files are written -- `ls-files --others` only sees
|
|
418
|
+
# what exists on disk, and a file init just created is exactly the one at
|
|
419
|
+
# risk of being swallowed.
|
|
420
|
+
# `init` also writes the VS Code task, and a repo that ignores `.vscode/`
|
|
421
|
+
# swallows it exactly as it would an instruction file. `tasks.json` is
|
|
422
|
+
# portable -- `python -m graphite activate .`, no absolute paths -- so
|
|
423
|
+
# committing it is correct. The hook trampolines are NOT included: they
|
|
424
|
+
# embed this machine's interpreter path and are ignored, not committed.
|
|
425
|
+
managed_paths = [*instruction_paths, Path(".vscode/tasks.json"), Path(".mcp.json")]
|
|
426
|
+
allowlist = ensure_gitignore_allowlist(
|
|
427
|
+
root / ".gitignore",
|
|
428
|
+
managed_paths,
|
|
429
|
+
swallowed=gitignored_managed_paths(root, managed_paths),
|
|
430
|
+
)
|
|
431
|
+
daemon = daemon_visibility(root, daemon_base=daemon_base)
|
|
432
|
+
return InitResult(
|
|
433
|
+
project_root=root,
|
|
434
|
+
platforms=selected,
|
|
435
|
+
graphite_doc=graphite_doc,
|
|
436
|
+
gitignore=gitignore,
|
|
437
|
+
platform_files=platform_files,
|
|
438
|
+
allowlist=allowlist,
|
|
439
|
+
daemon=daemon,
|
|
440
|
+
agent_hooks=agent_hooks,
|
|
441
|
+
hooks=hooks_result,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
VSCODE_ACTIVATION_LABEL = "graphite: activate repo"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def ensure_vscode_activation_task(path: Path) -> dict[str, Any]:
|
|
449
|
+
"""Register this repo as open when the folder is opened in an editor.
|
|
450
|
+
|
|
451
|
+
VS Code, Cursor and Antigravity are all VS Code-derived and honour
|
|
452
|
+
``runOn: folderOpen``. This is what lets a repo edited outside an agent CLI
|
|
453
|
+
still be supervised, without the daemon scanning anything.
|
|
454
|
+
|
|
455
|
+
Existing tasks are preserved: destroying hand-written config is the #13
|
|
456
|
+
mistake, and a tasks.json is far more likely to be hand-written than not.
|
|
457
|
+
An unparseable file is left completely alone rather than replaced.
|
|
458
|
+
"""
|
|
459
|
+
document: dict[str, Any] = {"version": "2.0.0", "tasks": []}
|
|
460
|
+
if path.is_file():
|
|
461
|
+
try:
|
|
462
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
463
|
+
except Exception:
|
|
464
|
+
return {"path": str(path), "changed": False, "action": "skipped", "reason": "unparseable"}
|
|
465
|
+
if isinstance(loaded, dict):
|
|
466
|
+
document = loaded
|
|
467
|
+
document.setdefault("version", "2.0.0")
|
|
468
|
+
if not isinstance(document.get("tasks"), list):
|
|
469
|
+
document["tasks"] = []
|
|
470
|
+
|
|
471
|
+
existing = [t for t in document["tasks"] if isinstance(t, dict)]
|
|
472
|
+
kept = [t for t in existing if t.get("label") != VSCODE_ACTIVATION_LABEL]
|
|
473
|
+
kept.append(
|
|
474
|
+
{
|
|
475
|
+
"label": VSCODE_ACTIVATION_LABEL,
|
|
476
|
+
"type": "shell",
|
|
477
|
+
# `-P` for the same reason as `.mcp.json`, and this one is worse in
|
|
478
|
+
# one respect: `runOn: folderOpen` below means it fires by itself.
|
|
479
|
+
# Nobody has to invoke anything -- opening the folder in VS Code,
|
|
480
|
+
# Cursor or Antigravity executes a planted `graphite.py`.
|
|
481
|
+
"command": "python -P -m graphite activate .",
|
|
482
|
+
"presentation": {"reveal": "never", "panel": "dedicated"},
|
|
483
|
+
"runOptions": {"runOn": "folderOpen"},
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
document["tasks"] = kept
|
|
487
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
488
|
+
path.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
489
|
+
return {"path": str(path), "changed": True, "action": "written"}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
MCP_SERVER_NAME = "graphite"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def ensure_mcp_config(path: Path) -> dict[str, Any]:
|
|
496
|
+
"""Register graphite's MCP server so the channel broker actually reaches a repo.
|
|
497
|
+
|
|
498
|
+
Without this the channel tools have to be wired by hand, per repo, per agent
|
|
499
|
+
-- which is how a capability ends up existing and unused. `init` writing it
|
|
500
|
+
is the difference between the broker working everywhere and working only
|
|
501
|
+
where somebody remembered.
|
|
502
|
+
|
|
503
|
+
The command is `python -m graphite.mcp`, never `sys.executable`. This file is
|
|
504
|
+
committed and pushed in consumer repos, so an absolute interpreter path would
|
|
505
|
+
publish this machine's layout onto their remotes AND break on every other
|
|
506
|
+
machine. That is the same reason the hook trampolines are gitignored rather
|
|
507
|
+
than committed -- and the same mistake a guard caught here on 2026-08-01.
|
|
508
|
+
|
|
509
|
+
Other servers are preserved: destroying hand-written config is the #13
|
|
510
|
+
mistake, and an `.mcp.json` is very likely to have other servers in it. An
|
|
511
|
+
unparseable file is left completely alone rather than replaced.
|
|
512
|
+
"""
|
|
513
|
+
document: dict[str, Any] = {"mcpServers": {}}
|
|
514
|
+
if path.is_file():
|
|
515
|
+
try:
|
|
516
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
517
|
+
except Exception:
|
|
518
|
+
return {"path": str(path), "changed": False, "action": "skipped", "reason": "unparseable"}
|
|
519
|
+
if isinstance(loaded, dict):
|
|
520
|
+
document = loaded
|
|
521
|
+
if not isinstance(document.get("mcpServers"), dict):
|
|
522
|
+
document["mcpServers"] = {}
|
|
523
|
+
|
|
524
|
+
# `-P` FIRST. This launches with the consumer's repo root as cwd, so
|
|
525
|
+
# `python -m graphite.mcp` puts that root at `sys.path[0]` and a
|
|
526
|
+
# `graphite.py` -- or a `graphite/` directory -- there beats the installed
|
|
527
|
+
# package. Same defect as the agent hooks and the git trampolines, on a
|
|
528
|
+
# surface neither of those fixes covered (found by codex-agent, round 62).
|
|
529
|
+
#
|
|
530
|
+
# It is the most serious of the three: MCP is how every non-Claude agent
|
|
531
|
+
# reaches graphite, so a shadowed launch means the agent is talking to
|
|
532
|
+
# whatever the repository planted, over the channel broker's own transport.
|
|
533
|
+
#
|
|
534
|
+
# `-P` rather than `-I` here, unlike the committed commit-msg hook: this
|
|
535
|
+
# command is run by graphite's own supported interpreters (>=3.11), and `-I`
|
|
536
|
+
# would additionally strip PYTHONPATH and user site-packages, which an
|
|
537
|
+
# editable or user install can legitimately need.
|
|
538
|
+
document["mcpServers"][MCP_SERVER_NAME] = {
|
|
539
|
+
"command": "python",
|
|
540
|
+
"args": ["-P", "-m", "graphite.mcp"],
|
|
541
|
+
}
|
|
542
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
543
|
+
path.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
544
|
+
return {"path": str(path), "changed": True, "action": "written"}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _managed_block(body: str) -> str:
|
|
548
|
+
if not body.endswith("\n"):
|
|
549
|
+
body += "\n"
|
|
550
|
+
return f"{MANAGED_BEGIN}\n{body}{MANAGED_END}"
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _ensure_managed_text(
|
|
554
|
+
original: str, body: str, *, is_legacy: bool, heading: str = "", adopt: bool = False
|
|
555
|
+
) -> tuple[str | None, str]:
|
|
556
|
+
"""Return (new_text_or_None, action) for a versioned managed region."""
|
|
557
|
+
begin = _MANAGED_BEGIN_RE.search(original)
|
|
558
|
+
if begin is not None:
|
|
559
|
+
end_index = original.find(MANAGED_END, begin.end())
|
|
560
|
+
if end_index < 0:
|
|
561
|
+
return None, "managed markers damaged"
|
|
562
|
+
version = int(begin.group(1))
|
|
563
|
+
if version == DOC_VERSION:
|
|
564
|
+
return None, "already current"
|
|
565
|
+
if version > DOC_VERSION:
|
|
566
|
+
return None, "newer than tool"
|
|
567
|
+
replaced = (
|
|
568
|
+
original[: begin.start()]
|
|
569
|
+
+ _managed_block(body)
|
|
570
|
+
+ original[end_index + len(MANAGED_END):]
|
|
571
|
+
)
|
|
572
|
+
return replaced, "refreshed"
|
|
573
|
+
|
|
574
|
+
if is_legacy:
|
|
575
|
+
if not adopt:
|
|
576
|
+
# Pre-versioning content, possibly hand-curated; never rewrite it
|
|
577
|
+
# automatically. Reported so the operator can reconcile and opt in.
|
|
578
|
+
return None, "legacy unversioned"
|
|
579
|
+
# --adopt is that opt-in. It APPENDS the managed block and leaves the
|
|
580
|
+
# legacy text untouched above it, so hand-curated owner policy survives
|
|
581
|
+
# (#13). Nothing is overwritten: this is the same non-destructive path
|
|
582
|
+
# an unmarked non-legacy doc already takes, and once the markers exist
|
|
583
|
+
# later runs refresh normally.
|
|
584
|
+
return _append_section(original, _managed_block(body)), "adopted"
|
|
585
|
+
|
|
586
|
+
if not original.strip():
|
|
587
|
+
return heading + _managed_block(body) + "\n", "created"
|
|
588
|
+
return _append_section(original, _managed_block(body)), "updated"
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def ensure_graphite_doc(path: Path, *, adopt: bool = False) -> dict[str, Any]:
|
|
592
|
+
original = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
593
|
+
new_text, action = _ensure_managed_text(
|
|
594
|
+
original,
|
|
595
|
+
GRAPHITE_DOC,
|
|
596
|
+
is_legacy=GRAPHITE_DOC_HEADER in original and GRAPHITE_REQUIRED_WORKFLOW in original,
|
|
597
|
+
adopt=adopt,
|
|
598
|
+
)
|
|
599
|
+
if new_text is not None:
|
|
600
|
+
atomic_write_text(path, new_text)
|
|
601
|
+
return {"path": str(path), "changed": new_text is not None, "action": action}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def ensure_platform_file(path: Path, *, spec: PlatformSpec, adopt: bool = False) -> dict[str, Any]:
|
|
605
|
+
original = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
606
|
+
new_text, action = _ensure_managed_text(
|
|
607
|
+
original,
|
|
608
|
+
spec.content,
|
|
609
|
+
is_legacy="GRAPHITE.md" in original and "graph-out/graph.json" in original,
|
|
610
|
+
heading=f"# {spec.label} Project Instructions\n\n",
|
|
611
|
+
adopt=adopt,
|
|
612
|
+
)
|
|
613
|
+
if new_text is not None:
|
|
614
|
+
atomic_write_text(path, new_text)
|
|
615
|
+
return {"platform": spec.key, "path": str(path), "changed": new_text is not None, "action": action}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def gitignored_managed_paths(root: Path, rel_paths: Iterable[Path]) -> tuple[str, ...]:
|
|
619
|
+
"""Managed paths Git will never hand to a clone: untracked AND ignored.
|
|
620
|
+
|
|
621
|
+
The conjunction matters in both directions, and each half alone is a bug
|
|
622
|
+
that was measured live on 2026-07-31. `git status` -- even with
|
|
623
|
+
`--ignored=matching` -- collapses the report to the ignored DIRECTORY
|
|
624
|
+
(`.claude/`), which never matches the managed path, so it reported
|
|
625
|
+
`BytesAI Learning`'s hook file as fine. `git check-ignore` alone answers
|
|
626
|
+
"does a pattern match?", which is true but inert for an already-tracked
|
|
627
|
+
file, so it would have condemned `demo-store2`'s tracked `CLAUDE.md`.
|
|
628
|
+
|
|
629
|
+
`ls-files --others --ignored` is the conjunction itself: `--others`
|
|
630
|
+
restricts to untracked, `--ignored` to ignored. Tracked-and-matched drops
|
|
631
|
+
out by Git's own semantics rather than by a rule reimplemented here.
|
|
632
|
+
|
|
633
|
+
Fails open (empty) on any Git trouble: a repo that cannot be interrogated
|
|
634
|
+
is not evidence of a swallowed file, and this result gates a `.gitignore`
|
|
635
|
+
rewrite.
|
|
636
|
+
"""
|
|
637
|
+
wanted = {Path(rel).as_posix() for rel in rel_paths}
|
|
638
|
+
if not wanted:
|
|
639
|
+
return ()
|
|
640
|
+
try:
|
|
641
|
+
result = GitRunner(root).run(
|
|
642
|
+
["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", *sorted(wanted)],
|
|
643
|
+
timeout_seconds=10.0,
|
|
644
|
+
max_stdout_bytes=1024 * 1024,
|
|
645
|
+
)
|
|
646
|
+
if result.returncode != 0:
|
|
647
|
+
return ()
|
|
648
|
+
found = {entry for entry in result.stdout.decode("utf-8").split("\0") if entry}
|
|
649
|
+
except (GitError, OSError, UnicodeDecodeError):
|
|
650
|
+
return ()
|
|
651
|
+
return tuple(sorted(found & wanted))
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def ensure_gitignore_allowlist(path: Path, rel_paths: Iterable[Path], *, swallowed: Iterable[Path] = ()) -> dict[str, Any]:
|
|
655
|
+
if not path.exists():
|
|
656
|
+
return {"path": str(path), "changed": False, "added": [], "reason": "missing gitignore"}
|
|
657
|
+
original = path.read_text(encoding="utf-8")
|
|
658
|
+
lines = original.splitlines()
|
|
659
|
+
default_deny = any(line.strip() == "/*" for line in lines)
|
|
660
|
+
# `default_deny` is a prediction -- a `/*` gitignore will swallow managed
|
|
661
|
+
# files, so allowlist them all up front. `swallowed` is a measurement:
|
|
662
|
+
# these specific files are being swallowed right now, by whatever rule.
|
|
663
|
+
#
|
|
664
|
+
# Measuring as well as predicting is what closes the `BytesAI Learning`
|
|
665
|
+
# case: an ordinary allow-by-default gitignore with a plain `.claude/`
|
|
666
|
+
# deny is not default-deny, so init wrote the file carrying the
|
|
667
|
+
# graph-first hook into a path Git would never take -- and reported
|
|
668
|
+
# success. Repair only what is measured, so a repo that merely looks
|
|
669
|
+
# unusual keeps its .gitignore untouched.
|
|
670
|
+
swallowed = tuple(swallowed)
|
|
671
|
+
if not default_deny and not swallowed:
|
|
672
|
+
return {"path": str(path), "changed": False, "added": [], "reason": "not default-deny"}
|
|
673
|
+
targets: list[Path] = list(rel_paths) if default_deny else []
|
|
674
|
+
for rel in swallowed:
|
|
675
|
+
candidate = Path(rel)
|
|
676
|
+
if candidate not in targets:
|
|
677
|
+
targets.append(candidate)
|
|
678
|
+
|
|
679
|
+
existing = {line.strip() for line in lines}
|
|
680
|
+
added: list[str] = []
|
|
681
|
+
# A sandwich is emitted WHOLE or not at all, and deduped only within this
|
|
682
|
+
# run -- never against patterns already in the file. gitignore is
|
|
683
|
+
# last-match-wins, so appending the complete unit is what guarantees the
|
|
684
|
+
# re-ignore lands before the file negation. Skipping a pattern merely
|
|
685
|
+
# because it appears somewhere earlier inverts the sandwich: with
|
|
686
|
+
# `/.githooks/*` already present, emitting only `!/.githooks/` and
|
|
687
|
+
# `!/.githooks/post-commit` puts the directory un-ignore last and exposes
|
|
688
|
+
# `post-commit.local`, the private hook the sandwich exists to protect.
|
|
689
|
+
#
|
|
690
|
+
# Idempotent because the guard is "are they ALL already here" -- once the
|
|
691
|
+
# unit has been appended, a later run skips it and writes nothing.
|
|
692
|
+
for rel in targets:
|
|
693
|
+
patterns = _allowlist_patterns(rel)
|
|
694
|
+
if not patterns or all(pattern in existing for pattern in patterns):
|
|
695
|
+
continue
|
|
696
|
+
for pattern in patterns:
|
|
697
|
+
if pattern not in added:
|
|
698
|
+
added.append(pattern)
|
|
699
|
+
|
|
700
|
+
if added:
|
|
701
|
+
new_text = original
|
|
702
|
+
if new_text and not new_text.endswith("\n"):
|
|
703
|
+
new_text += "\n"
|
|
704
|
+
if new_text and not new_text.endswith("\n\n"):
|
|
705
|
+
new_text += "\n"
|
|
706
|
+
new_text += "\n".join(added) + "\n"
|
|
707
|
+
atomic_write_text(path, new_text)
|
|
708
|
+
return {"path": str(path), "changed": bool(added), "added": added, "reason": "default-deny" if default_deny else "swallowed"}
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _prompt_for_platforms(*, stdin: TextIO | None, stdout: TextIO | None) -> tuple[str, ...]:
|
|
712
|
+
stdin = stdin or __import__("sys").stdin
|
|
713
|
+
stdout = stdout or __import__("sys").stdout
|
|
714
|
+
print("Select AI platforms to configure for Graphite:", file=stdout)
|
|
715
|
+
for index, key in enumerate(PLATFORM_ORDER, start=1):
|
|
716
|
+
print(f" {index}. {PLATFORMS[key].label} [{key}]", file=stdout)
|
|
717
|
+
default = ", ".join(DEFAULT_PLATFORMS)
|
|
718
|
+
print(" all. All supported platforms", file=stdout)
|
|
719
|
+
print(f"Enter numbers/names separated by commas, or press Enter for: {default}", file=stdout)
|
|
720
|
+
stdout.flush()
|
|
721
|
+
answer = stdin.readline().strip()
|
|
722
|
+
if not answer:
|
|
723
|
+
return DEFAULT_PLATFORMS
|
|
724
|
+
return tuple(_split_platform_tokens(answer))
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _split_platform_tokens(value: str) -> list[str]:
|
|
728
|
+
return [part.strip() for part in value.replace(";", ",").split(",") if part.strip()]
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _normalize_platform(value: str) -> str:
|
|
732
|
+
token = value.strip().lower().replace("_", "-")
|
|
733
|
+
if token.isdigit():
|
|
734
|
+
index = int(token) - 1
|
|
735
|
+
if 0 <= index < len(PLATFORM_ORDER):
|
|
736
|
+
return PLATFORM_ORDER[index]
|
|
737
|
+
return ALIASES.get(token, token)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _append_section(original: str, section: str) -> str:
|
|
741
|
+
new_text = original
|
|
742
|
+
if new_text and not new_text.endswith("\n"):
|
|
743
|
+
new_text += "\n"
|
|
744
|
+
if new_text and not new_text.endswith("\n\n"):
|
|
745
|
+
new_text += "\n"
|
|
746
|
+
new_text += section
|
|
747
|
+
if not new_text.endswith("\n"):
|
|
748
|
+
new_text += "\n"
|
|
749
|
+
return new_text
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
# Directories where a bare "!/dir/" un-ignore would expose more than the one
|
|
753
|
+
# file graphite needs committed, so the patterns are sandwiched instead:
|
|
754
|
+
# un-ignore the directory, re-ignore its contents, un-ignore only our file.
|
|
755
|
+
#
|
|
756
|
+
# .claude/ holds settings.local.json -- machine-local permissions, possibly
|
|
757
|
+
# secrets.
|
|
758
|
+
# .vscode/ holds settings.json and launch.json, routinely user-specific.
|
|
759
|
+
#
|
|
760
|
+
# `.githooks/` is deliberately NOT here and is not allowlisted at all: the
|
|
761
|
+
# trampolines are machine-local (they embed an absolute interpreter path) and
|
|
762
|
+
# are distributed by git template rather than by the repository. See
|
|
763
|
+
# `bootstrap.GRAPHITE_GITIGNORE_LINES`, which ignores them.
|
|
764
|
+
_SANDWICHED_DIRS = frozenset({".claude", ".vscode"})
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _allowlist_patterns(rel: Path) -> list[str]:
|
|
768
|
+
rel = Path(*[part for part in rel.parts if part not in ("", ".")])
|
|
769
|
+
parts = rel.parts
|
|
770
|
+
if not parts:
|
|
771
|
+
return []
|
|
772
|
+
if parts[0] == DEFAULT_HOOKS_DIRNAME:
|
|
773
|
+
# Refused here rather than only by not passing hook paths in, because
|
|
774
|
+
# a guard that lives in one caller is a guard the next caller removes.
|
|
775
|
+
# Trampolines embed an absolute interpreter path and are distributed by
|
|
776
|
+
# git template, so un-ignoring them invites committing one machine's
|
|
777
|
+
# Python location -- and a bare `!/.githooks/` would additionally expose
|
|
778
|
+
# `post-commit.local`, the private hook graphite chained to but never
|
|
779
|
+
# wrote. Both were briefly true on 2026-07-31.
|
|
780
|
+
return []
|
|
781
|
+
if len(parts) == 2 and parts[0] in _SANDWICHED_DIRS:
|
|
782
|
+
directory = parts[0]
|
|
783
|
+
return [f"!/{directory}/", f"/{directory}/*", f"!/{directory}/{parts[1]}"]
|
|
784
|
+
patterns: list[str] = []
|
|
785
|
+
if len(parts) > 1:
|
|
786
|
+
current: list[str] = []
|
|
787
|
+
for directory in parts[:-1]:
|
|
788
|
+
current.append(directory)
|
|
789
|
+
patterns.append("!/" + "/".join(current) + "/")
|
|
790
|
+
patterns.append("!/" + "/".join(parts))
|
|
791
|
+
return patterns
|