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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. 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