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/cli.py ADDED
@@ -0,0 +1,3053 @@
1
+ """Graphite command-line interface."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import contextlib
6
+ import json
7
+ import math
8
+ import os
9
+ import sys
10
+ import time
11
+ import unicodedata
12
+ from collections import deque
13
+ from collections.abc import Iterable
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any, TextIO
17
+
18
+ from . import activation
19
+ from .analyze import analyze
20
+ from .answer_contract import (
21
+ ANSWER_SCHEMA,
22
+ GRADE_ADVISORY,
23
+ GRADE_DECISION,
24
+ GRADE_INCONCLUSIVE,
25
+ active_caveats,
26
+ build_answer_block,
27
+ empty_marker,
28
+ is_degraded,
29
+ is_unmeasured,
30
+ languages_for_nodes,
31
+ )
32
+ from .cache import Cache
33
+ from .bootstrap import bootstrap_project
34
+ from .cluster import detect_communities
35
+ from .config import Config, default_projects_root
36
+ from .context import build_context, format_context_markdown
37
+ from .daemon import DaemonOptions, read_daemon_status, run_daemon
38
+ from .daemon_health import HealthOptions, evaluate_daemon_health, format_health_text
39
+ from . import buildlock
40
+ from .detach import spawn_detached
41
+ from .doctor import format_doctor_text, run_doctor
42
+ from .engine_identity import engine_identity
43
+ from .export.html import to_html as export_html
44
+ from .export.json import build_bundle, to_json as export_json
45
+ from .export.md import to_markdown as export_md
46
+ from .extract.ast import extract_all
47
+ from .freshness import check_graph_freshness
48
+ from .graph import build_graph, graph_to_json
49
+ from .graph_io import MAX_GRAPH_BYTES, GraphReadError, load_validated_graph_bundle
50
+ from .health import persisted_resolution, ratio_percent, resolution_health
51
+ from . import hookinstall
52
+ from .incident_ledger import record_incident, repo_ledger_dir
53
+ from .ingest import collect_files
54
+ from .init import init_project, platform_choices, resolve_platform_selection
55
+ from .io import atomic_write_json
56
+ from .listing import listing_lines
57
+ from .llm import CANONICAL_ENRICHMENT_MIGRATION_MESSAGE
58
+ from .overlays import OverlayError, OverlayRequest, build_overlay
59
+ from .routing.approval import approval_prompt
60
+ from .routing.contracts import Effort
61
+ from .routing.lifecycle_operator import LifecycleOperator, LifecycleOperatorError
62
+ from .routing.service import RoutingService, RoutingServiceError
63
+ from .routing.storage import DEFAULT_RECOVERY_PAGE_SIZE, StorageError
64
+ from .natural_query import answer_natural, natural_catalog, translate_natural
65
+ from .query import (
66
+ DEFAULT_SEARCH_LIMIT,
67
+ MAX_SEARCH_LIMIT,
68
+ _find_node,
69
+ annotate_communities,
70
+ build_plan,
71
+ plan_preview,
72
+ query,
73
+ search_graph,
74
+ verb_catalog,
75
+ )
76
+ from .query_plan import DEFAULT_MAX_DEPTH, DEFAULT_MAX_RESULTS, PLAN_VERSION
77
+ from .replacement_audit import audit_replacement, format_replacement_audit
78
+ from .review import (
79
+ ReviewError,
80
+ build_review_packet,
81
+ discover_git_changes,
82
+ format_review_markdown,
83
+ normalize_explicit_changes,
84
+ )
85
+ from .validation import assert_valid_graph_bundle, validate_graph_bundle
86
+ from .typescript_activation import (
87
+ ActivationOutcome,
88
+ ActivationRequest,
89
+ ActivationResult,
90
+ activate_typescript,
91
+ )
92
+ from .watch import WatchChange, WatchOptions, watch_loop
93
+ from .windows_task import (
94
+ DEFAULT_TASK_NAME,
95
+ create_daemon_task,
96
+ daemon_task_command,
97
+ delete_daemon_task,
98
+ query_daemon_task,
99
+ )
100
+ from .windows_startup import install_startup_launcher, startup_status, uninstall_startup_launcher
101
+
102
+ _TEST_SUFFIXES = (
103
+ ".test.ts",
104
+ ".spec.ts",
105
+ ".test.tsx",
106
+ ".spec.tsx",
107
+ ".test.js",
108
+ ".spec.js",
109
+ ".test.py",
110
+ ".spec.py",
111
+ )
112
+
113
+ # Review graph reads are bounded to prevent untrusted artifacts exhausting memory.
114
+ _MAX_REVIEW_GRAPH_BYTES = 128 * 1024 * 1024
115
+ _DAEMON_STATUS_PROJECT_CAP = 20
116
+ _VALIDATE_ERROR_CAP = 10
117
+ _WATCH_IMPACTED_CAP = 20
118
+ _WATCH_TESTS_CAP = 30
119
+ _CANONICAL_COMMANDS = frozenset(
120
+ {
121
+ "scan",
122
+ "build",
123
+ "report",
124
+ "check",
125
+ "validate",
126
+ "query",
127
+ "search",
128
+ "capabilities",
129
+ # Agents discover commands through `capabilities`, so a channel command
130
+ # absent from it is a channel agents cannot locate -- which is exactly
131
+ # the problem it exists to solve. Inference-free and read-only: it
132
+ # resolves a path from config and stats it.
133
+ "channel",
134
+ "impact",
135
+ "context",
136
+ "watch",
137
+ "daemon",
138
+ }
139
+ )
140
+ # Hook endpoints are inference-free like canonical commands but are not part of
141
+ # the agent-facing query surface, so they stay out of `capabilities` output.
142
+ _INFERENCE_FREE_EXTRA_COMMANDS = frozenset({"agent-hook", "savings", "activate"})
143
+ # Commands that must NOT register the working directory as an open repo:
144
+ # the daemon supervises rather than edits, and the hook endpoint records
145
+ # activation itself using the real agent name instead of "cli".
146
+ _ACTIVATION_EXEMPT_COMMANDS = frozenset({
147
+ "daemon",
148
+ "daemon-status",
149
+ "daemon-health",
150
+ "daemon-install-windows",
151
+ "daemon-task-status",
152
+ "daemon-uninstall-windows",
153
+ "daemon-install-startup-windows",
154
+ "daemon-startup-status",
155
+ "daemon-uninstall-startup-windows",
156
+ "agent-hook",
157
+ # `debt` reads the engine's own caveat registry and touches no repository at
158
+ # all. Marking the cwd active would enrol whatever directory the operator
159
+ # happened to ask the question from -- the same survey-changes-what-it-
160
+ # measures problem `--version` has.
161
+ "debt",
162
+ # `channel` answers a question about machine layout, not about the repo you
163
+ # happen to be standing in -- registering activation would make an unrelated
164
+ # repo look "open" to the daemon.
165
+ "channel",
166
+ # `hooks --install-template` writes into a machine-wide template
167
+ # directory (see `hookinstall.default_template_root`), not the cwd repo --
168
+ # same reasoning as the daemon commands above.
169
+ "hooks",
170
+ # `activate` marks the path it was GIVEN, with the real agent name. Letting
171
+ # the backstop also fire would additionally mark the caller's cwd -- which
172
+ # for an editor task is whatever directory the editor happened to launch in.
173
+ "activate",
174
+ })
175
+ # Directory prefixes graphite uses for its own throwaway workspaces. Defined in
176
+ # probe_workspace.py and typescript_activation.py; a repo under one of these is
177
+ # graphite's scratch space, never something a person opened.
178
+ _SCRATCH_WORKSPACE_PREFIXES = ("graphite-doctor-", "graphite-typescript-")
179
+ _LLM_GATED_COMMANDS = _CANONICAL_COMMANDS | _INFERENCE_FREE_EXTRA_COMMANDS
180
+ _LEGACY_LLM_ARGUMENTS = (
181
+ "llm_provider",
182
+ "llm_model",
183
+ "llm_base_url",
184
+ "llm_api_key",
185
+ "llm_timeout",
186
+ "llm_max_input_chars",
187
+ "llm_max_output_tokens",
188
+ )
189
+
190
+
191
+ def _config_from_args(args: argparse.Namespace, *, canonical: bool = False) -> Config:
192
+ """Build config from defaults + CLI args + env."""
193
+ base = Config.from_env(include_llm=not canonical)
194
+ kwargs: dict[str, Any] = {**base.to_dict()}
195
+ if getattr(args, "output_dir", None) is not None:
196
+ kwargs["output_dir"] = Path(args.output_dir)
197
+ if getattr(args, "cache_dir", None) is not None:
198
+ kwargs["cache_dir"] = Path(args.cache_dir)
199
+ if getattr(args, "workers", None) is not None:
200
+ kwargs["workers"] = int(args.workers)
201
+ if getattr(args, "verbose", False):
202
+ kwargs["verbose"] = True
203
+ if getattr(args, "no_typescript_symbol_references", False):
204
+ kwargs["typescript_symbol_references"] = False
205
+ configurable = (
206
+ ("typescript_resolver", "typescript_resolver"),
207
+ ("typescript_resolver_timeout", "typescript_resolver_timeout_seconds"),
208
+ )
209
+ if not canonical:
210
+ configurable += (
211
+ ("llm", "llm_mode"),
212
+ ("llm_provider", "llm_provider"),
213
+ ("llm_model", "llm_model"),
214
+ ("llm_base_url", "llm_base_url"),
215
+ ("llm_api_key", "llm_api_key"),
216
+ ("llm_timeout", "llm_timeout_seconds"),
217
+ ("llm_max_input_chars", "llm_max_input_chars"),
218
+ ("llm_max_output_tokens", "llm_max_output_tokens"),
219
+ )
220
+ for arg_name, cfg_name in configurable:
221
+ value = getattr(args, arg_name, None)
222
+ if value is not None:
223
+ kwargs[cfg_name] = value
224
+ cfg = Config(**kwargs)
225
+ return cfg.canonical_graph() if canonical else cfg
226
+
227
+
228
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
229
+ atomic_write_json(path, data, indent=2)
230
+
231
+
232
+ def _scan(args: argparse.Namespace, cfg: Config) -> tuple[dict[str, Any], list[Any]]:
233
+ cfg = cfg.canonical_graph()
234
+ root = Path(args.path).resolve()
235
+ if not root.exists():
236
+ print(f"[graphite] path not found: {root}", file=sys.stderr)
237
+ raise SystemExit(1)
238
+
239
+ start = time.time()
240
+ entries = collect_files(root, cfg)
241
+ manifest = {
242
+ "root": root.name,
243
+ "file_count": len(entries),
244
+ "engine": engine_identity(cfg.cache_version),
245
+ "files": [
246
+ {"rel_path": e.rel_path, "language": e.language, "size": e.size, "hash": e.content_hash}
247
+ for e in entries
248
+ ],
249
+ }
250
+ if cfg.verbose:
251
+ print(f"[graphite] scanned {len(entries)} files in {time.time() - start:.2f}s")
252
+ return manifest, entries
253
+
254
+
255
+ def _build(
256
+ args: argparse.Namespace,
257
+ cfg: Config,
258
+ manifest: dict[str, Any],
259
+ entries: list[Any],
260
+ ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
261
+ cfg = cfg.canonical_graph()
262
+ # `_scan` always populates manifest["engine"] or raises, so the fingerprint
263
+ # is guaranteed present here; a KeyError would be a loud programming error
264
+ # rather than the silent staleness of #21.
265
+ cache = Cache(cfg.cache_dir, cfg.cache_version, engine=manifest["engine"]["fingerprint"])
266
+ # #23: reclaim partitions this build can never read again. Safe here and
267
+ # only here -- cmd_build holds the repo build lock around _build_project,
268
+ # so no concurrent build of this repo can be reading a sibling partition.
269
+ # Reported rather than silent: a reclaim that deletes hundreds of MB should
270
+ # say so.
271
+ pruned = cache.prune_other_partitions()
272
+ if pruned:
273
+ print(f"[graphite] reclaimed {len(pruned)} unreachable cache partition(s)")
274
+ start = time.time()
275
+ extraction = extract_all(entries, cfg, cache)
276
+ if extraction.errors:
277
+ _root = Path(args.path).resolve()
278
+ _seen: set[tuple[str, str]] = set()
279
+ for err in extraction.errors:
280
+ key = (err["code"], err["subject"])
281
+ if key in _seen:
282
+ continue
283
+ _seen.add(key)
284
+ record_incident(
285
+ repo_ledger_dir(_root),
286
+ klass="build",
287
+ code=err["code"],
288
+ subject=err["subject"],
289
+ detail=err["detail"],
290
+ )
291
+ if cfg.verbose:
292
+ print(
293
+ f"[graphite] extracted {len(extraction.nodes)} nodes / {len(extraction.edges)} edges "
294
+ f"in {time.time() - start:.2f}s"
295
+ )
296
+
297
+ start = time.time()
298
+ g = build_graph(extraction.nodes, extraction.edges)
299
+ graph_data = graph_to_json(g)
300
+ if cfg.verbose:
301
+ print(f"[graphite] built graph in {time.time() - start:.2f}s")
302
+
303
+ clusters = detect_communities(g, seed=cfg.seed)
304
+ if cfg.verbose:
305
+ print(f"[graphite] detected {clusters['count']} communities")
306
+
307
+ annotate_communities(g, clusters["node_to_community"])
308
+ graph_data = graph_to_json(g)
309
+ analysis = analyze(g)
310
+
311
+ return graph_data, clusters, analysis
312
+
313
+
314
+ def _report(
315
+ cfg: Config,
316
+ manifest: dict[str, Any],
317
+ graph_data: dict[str, Any],
318
+ clusters: dict[str, Any],
319
+ analysis: dict[str, Any],
320
+ ) -> None:
321
+ cfg = cfg.canonical_graph()
322
+ out = cfg.output_dir
323
+ out.mkdir(parents=True, exist_ok=True)
324
+
325
+ public_manifest = {
326
+ **manifest,
327
+ "typescript_resolver": cfg.typescript_resolver,
328
+ }
329
+
330
+ bundle = build_bundle(graph_data, clusters, analysis, public_manifest)
331
+ validation = assert_valid_graph_bundle(bundle)
332
+
333
+ _write_json(out / ".graphite_manifest.json", public_manifest)
334
+ _write_json(out / ".graphite_graph.json", graph_data)
335
+ _write_json(out / ".graphite_clusters.json", clusters)
336
+ _write_json(out / ".graphite_analysis.json", analysis)
337
+ _write_json(out / ".graphite_validation.json", validation)
338
+
339
+ export_json(graph_data, clusters, analysis, public_manifest, out / "graph.json")
340
+ export_html(graph_data, clusters, analysis, public_manifest, out / "graph.html")
341
+ export_md(graph_data, clusters, analysis, public_manifest, out / "GRAPH_REPORT.md")
342
+
343
+ print(f"[graphite] report written to {out}/")
344
+ print(" - GRAPH_REPORT.md")
345
+ print(" - graph.json")
346
+ print(" - graph.html")
347
+
348
+
349
+ def _build_project(path: Path, cfg: Config) -> None:
350
+ cfg = cfg.canonical_graph()
351
+ args = argparse.Namespace(path=str(path))
352
+ manifest, entries = _scan(args, cfg)
353
+ graph_data, clusters, analysis = _build(args, cfg, manifest, entries)
354
+ _report(cfg, manifest, graph_data, clusters, analysis)
355
+
356
+
357
+ def _load_graph(path: Path, *, root: Path | None = None) -> Any:
358
+ selected_root = (root or Path.cwd()).resolve()
359
+ try:
360
+ _, graph = load_validated_graph_bundle(path, root=selected_root)
361
+ except GraphReadError as exc:
362
+ record_incident(
363
+ repo_ledger_dir(selected_root),
364
+ klass="build",
365
+ code="graph_load_failed",
366
+ subject="graph-out/graph.json",
367
+ detail=str(exc.code),
368
+ )
369
+ raise ValueError(f"graph unavailable: {exc.code}") from None
370
+ return graph
371
+
372
+
373
+ def _record_canonical_usage(cmd: str, result: Any, started: float) -> None:
374
+ """Best-effort usage recording for the savings display; never fatal."""
375
+ try:
376
+ from . import usage_ledger
377
+
378
+ usage_ledger.record_usage(
379
+ Path.cwd(),
380
+ cmd=cmd,
381
+ wall_ms=int((time.perf_counter() - started) * 1000),
382
+ result=result,
383
+ )
384
+ except Exception:
385
+ return
386
+
387
+
388
+ def _record_inconclusive(subject: str, result: Any) -> None:
389
+ """Best-effort incident capture for inconclusive answers; never fatal."""
390
+ try:
391
+ if not (isinstance(result, dict) and result.get("inconclusive") is True):
392
+ return
393
+ health = result.get("resolution_health") or {}
394
+ by_rel = health.get("by_relation") or {}
395
+
396
+ def _ratio(rel: str) -> Any:
397
+ cell = by_rel.get(rel) or {}
398
+ return cell.get("ratio")
399
+
400
+ detail = f"imports {_ratio('imports')}, calls {_ratio('calls')}, healthy {health.get('healthy')}"
401
+ answer = result.get("answer")
402
+ if isinstance(answer, dict):
403
+ # The aggregate ratios above can read "healthy True" while a
404
+ # scoped cell is what actually drove this incident (firescraper
405
+ # shape); append the scoped grade and degraded cells so the
406
+ # incident is self-explanatory without cross-referencing.
407
+ degraded_cells = ", ".join(
408
+ f"{relation}({language}) {cell['ratio']:.1f}"
409
+ for relation, langs in sorted(answer.get("health", {}).items())
410
+ for language, cell in sorted(langs.items())
411
+ if not cell.get("healthy", True)
412
+ )
413
+ grade = answer.get("grade", "")
414
+ parts = [p for p in (degraded_cells, grade) if p]
415
+ if parts:
416
+ detail += ", answer " + " ".join(parts)
417
+
418
+ record_incident(
419
+ repo_ledger_dir(Path.cwd()),
420
+ klass="query",
421
+ code="query_inconclusive",
422
+ subject=subject,
423
+ detail=detail,
424
+ )
425
+ except Exception:
426
+ return
427
+
428
+
429
+ def _is_test_file(path: str) -> bool:
430
+ normalized = path.replace("\\", "/")
431
+ return "/tests/" in f"/{normalized}" or normalized.endswith(_TEST_SUFFIXES)
432
+
433
+
434
+ def _impact(g: Any, changes: list[str], depth: int) -> dict[str, Any]:
435
+ start_nodes: list[str] = []
436
+ missing: list[str] = []
437
+ for change in changes:
438
+ node = _find_node(g, change)
439
+ if node:
440
+ start_nodes.append(node)
441
+ else:
442
+ missing.append(change)
443
+
444
+ visited: set[str] = set(start_nodes)
445
+ queue: deque[tuple[str, int]] = deque((n, 0) for n in start_nodes)
446
+ impacted_nodes: set[str] = set()
447
+ while queue:
448
+ node, dist = queue.popleft()
449
+ if dist >= depth:
450
+ continue
451
+ for pred in sorted(g.predecessors(node)):
452
+ if pred in visited:
453
+ continue
454
+ visited.add(pred)
455
+ impacted_nodes.add(pred)
456
+ queue.append((pred, dist + 1))
457
+
458
+ impacted_files: set[str] = set()
459
+ likely_tests: set[str] = set()
460
+ for node in impacted_nodes.union(start_nodes):
461
+ sf = g.nodes[node].get("source_file")
462
+ if not sf:
463
+ continue
464
+ if _is_test_file(sf):
465
+ likely_tests.add(sf)
466
+ elif node not in start_nodes:
467
+ impacted_files.add(sf)
468
+
469
+ health = resolution_health(g)
470
+ total = len(impacted_files) + len(likely_tests)
471
+ matched_languages = languages_for_nodes(g, start_nodes)
472
+ try:
473
+ block = build_answer_block(
474
+ g,
475
+ relations=("calls", "imports"),
476
+ languages=matched_languages,
477
+ total=total,
478
+ empty_meaning="no impacted files or tests reachable through bound edges",
479
+ )
480
+ except Exception:
481
+ block = None
482
+ if block is not None:
483
+ inconclusive = block["grade"] == GRADE_INCONCLUSIVE
484
+ elif start_nodes and not matched_languages:
485
+ # Matched real nodes, but none have an applicable code language (e.g.
486
+ # markdown/config) -- nothing to grade, not a resolution gap.
487
+ inconclusive = False
488
+ else:
489
+ inconclusive = not impacted_files and not likely_tests and not health["healthy"]
490
+ result = {
491
+ "changed": changes,
492
+ "matched_nodes": sorted(start_nodes),
493
+ "missing": missing,
494
+ "depth": depth,
495
+ "impacted_files": sorted(impacted_files),
496
+ "likely_tests": sorted(likely_tests),
497
+ "resolution_health": health,
498
+ "inconclusive": inconclusive,
499
+ }
500
+ if block is not None:
501
+ result["answer"] = block
502
+ return result
503
+
504
+
505
+ def _print_watch_change(change: WatchChange) -> None:
506
+ print("[graphite] change detected")
507
+ for label, values in (
508
+ ("added", change.added),
509
+ ("changed", change.changed),
510
+ ("removed", change.removed),
511
+ ):
512
+ if values:
513
+ shown = ", ".join(values[:8])
514
+ suffix = " ..." if len(values) > 8 else ""
515
+ print(f" {label}: {shown}{suffix}")
516
+
517
+
518
+ def _print_watch_impact(root: Path, cfg: Config, change: WatchChange, depth: int) -> None:
519
+ graph_path = cfg.output_dir / "graph.json"
520
+ impact_inputs = list(change.changed or change.removed)
521
+ if not impact_inputs or not graph_path.exists():
522
+ return
523
+
524
+ try:
525
+ g = _load_graph(graph_path, root=root)
526
+ result = _impact(g, impact_inputs, depth)
527
+ except Exception as exc:
528
+ print(f"[graphite] impact skipped: {exc}", file=sys.stderr)
529
+ return
530
+
531
+ for line in listing_lines(
532
+ result["impacted_files"],
533
+ header="[graphite] impacted files:",
534
+ cap=_WATCH_IMPACTED_CAP,
535
+ empty=None,
536
+ ):
537
+ print(line)
538
+ for line in listing_lines(
539
+ result["likely_tests"],
540
+ header="[graphite] likely tests:",
541
+ cap=_WATCH_TESTS_CAP,
542
+ empty=None,
543
+ ):
544
+ print(line)
545
+
546
+
547
+ def cmd_scan(args: argparse.Namespace) -> int:
548
+ # Anchored to the repo (#26) -- the manifest belongs next to the graph it
549
+ # describes, not next to whatever directory scan was launched from.
550
+ cfg = _project_scoped_config(args, Path(args.path).resolve(), canonical=True)
551
+ manifest, _ = _scan(args, cfg)
552
+ _write_json(cfg.output_dir / ".graphite_manifest.json", manifest)
553
+ print(f"[graphite] manifest written: {cfg.output_dir / '.graphite_manifest.json'}")
554
+ return 0
555
+
556
+
557
+ def cmd_build(args: argparse.Namespace) -> int:
558
+ if getattr(args, "detach", False):
559
+ root = Path(args.path).resolve()
560
+ # Deliberately omits --detach: the child must build, not re-spawn.
561
+ pid = spawn_detached(
562
+ [sys.executable, "-B", "-P", "-m", "graphite", "build", str(root)], root
563
+ )
564
+ print(f"[graphite] detached build started (pid {pid})")
565
+ return 0
566
+
567
+ root = Path(args.path).resolve()
568
+ # Anchor relative output_dir/cache_dir to the REPO, not the process CWD
569
+ # (#26). Using `_config_from_args` directly meant `graphite build <path>`
570
+ # from another directory wrote the graph next to wherever it was launched
571
+ # -- and, worse, took the build lock at a CWD-relative path while the
572
+ # daemon takes it at `<root>/.cache/graphite`, so the two never contended.
573
+ cfg = _project_scoped_config(args, root, canonical=True)
574
+
575
+ # A parent that already holds the lock (the daemon) sets this for its child,
576
+ # which would otherwise deadlock against its own parent.
577
+ if os.environ.get(buildlock.ENV_LOCK_HELD):
578
+ _build_project(root, cfg)
579
+ return 0
580
+
581
+ with buildlock.build_lock(cfg.cache_dir) as acquired:
582
+ if not acquired:
583
+ print("[graphite] build skipped: another build is already running for this repo")
584
+ return 0
585
+ _build_project(root, cfg)
586
+ return 0
587
+
588
+
589
+ def cmd_report(args: argparse.Namespace) -> int:
590
+ return cmd_build(args)
591
+
592
+
593
+ def cmd_check(args: argparse.Namespace) -> int:
594
+ root = Path(args.path).resolve()
595
+ # Anchored to the repo (#26): otherwise `check <path>` compared the CWD's
596
+ # graph against the target's files, which reported every file in the
597
+ # current repo as "removed".
598
+ cfg = _project_scoped_config(args, root, canonical=True)
599
+ status = check_graph_freshness(root, cfg, ignore_engine=args.ignore_engine)
600
+ if args.json:
601
+ status["resolution_health"] = persisted_resolution(
602
+ root,
603
+ on_error=lambda exc: record_incident(
604
+ repo_ledger_dir(root),
605
+ klass="build",
606
+ code="artifact_malformed",
607
+ subject=".graphite_analysis.json",
608
+ detail=str(exc),
609
+ ),
610
+ )
611
+ print(json.dumps(status, ensure_ascii=False, indent=2))
612
+ elif status["stale"]:
613
+ reason = status.get("reason", "source changes")
614
+ print(f"[graphite] graph is stale ({reason})")
615
+ if reason == "engine_changed":
616
+ print(" graphite was updated since this graph was built; rebuild to refresh")
617
+ for key in ("added", "changed", "removed"):
618
+ if status.get(key):
619
+ print(f" {key}: {', '.join(status[key])}")
620
+ else:
621
+ print("[graphite] graph is fresh")
622
+ return 1 if status["stale"] else 0
623
+
624
+
625
+ def cmd_debt(args: argparse.Namespace) -> int:
626
+ """Declared blind spots and how long they have been declared.
627
+
628
+ Reads the engine's own caveat registry, not a repository, so it needs no
629
+ graph, no project layout and no network -- the point is a number that is
630
+ comparable across runs and across machines.
631
+ """
632
+ from datetime import date
633
+
634
+ from .debt import debt_report, render_debt
635
+
636
+ as_of = date.fromisoformat(args.as_of) if args.as_of else date.today()
637
+ report = debt_report(as_of=as_of)
638
+ if args.json:
639
+ print(json.dumps(report, ensure_ascii=False, indent=2))
640
+ else:
641
+ print(render_debt(report))
642
+ return 0
643
+
644
+
645
+ def cmd_doctor(args: argparse.Namespace) -> int:
646
+ root = Path(args.path).resolve()
647
+ if not root.is_dir():
648
+ raise ValueError("doctor path must be an existing directory")
649
+ cfg = _project_scoped_config(args, root)
650
+ report = run_doctor(
651
+ root,
652
+ cfg=cfg,
653
+ daemon_base=Path(args.daemon_base).resolve() if args.daemon_base else None,
654
+ deep=args.deep,
655
+ include_llm=args.include_llm,
656
+ )
657
+ if args.json:
658
+ print(json.dumps(report, ensure_ascii=False, indent=2))
659
+ else:
660
+ print(format_doctor_text(report), end="")
661
+ return int(report["exit_code"])
662
+
663
+
664
+ def _incidents_ledger_dir(args: argparse.Namespace) -> Path:
665
+ if getattr(args, "global_ledger", False):
666
+ state_dir = getattr(args, "state_dir", None)
667
+ if state_dir:
668
+ return Path(state_dir).resolve()
669
+ if getattr(args, "daemon_base", None):
670
+ base = Path(args.daemon_base).resolve()
671
+ else:
672
+ base = default_projects_root().resolve() # same import doctor uses
673
+ return base / ".graphite-daemon"
674
+ return repo_ledger_dir(Path(args.path).resolve())
675
+
676
+
677
+ def cmd_incidents_list(args: argparse.Namespace) -> int:
678
+ from .incident_ledger import fold_incidents, read_incident_entries
679
+
680
+ entries, skipped = read_incident_entries(_incidents_ledger_dir(args))
681
+ views = fold_incidents(entries)
682
+ if not args.all:
683
+ views = [v for v in views if v.state != "resolved"]
684
+ if args.json:
685
+ payload = {
686
+ "schema_version": 1,
687
+ "incidents": [v.to_json() for v in views],
688
+ "skipped": skipped,
689
+ }
690
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
691
+ return 0
692
+ if not views:
693
+ print("[graphite] no incidents")
694
+ for v in views:
695
+ print(f"{v.state:9} {v.fingerprint} {v.klass}/{v.code} {v.subject} x{v.count} last {v.last_seen}")
696
+ if skipped:
697
+ print(f"[graphite] skipped {skipped} corrupt line(s)")
698
+ return 0
699
+
700
+
701
+ def _incidents_lifecycle(args: argparse.Namespace, kind: str) -> int:
702
+ from .incident_ledger import append_lifecycle, fold_incidents, read_incident_entries
703
+
704
+ ledger_dir = _incidents_ledger_dir(args)
705
+ if not append_lifecycle(ledger_dir, args.fingerprint, kind, note=args.message):
706
+ print(f"[graphite] unknown fingerprint: {args.fingerprint}", file=sys.stderr)
707
+ return 1
708
+ entries, _ = read_incident_entries(ledger_dir)
709
+ for v in fold_incidents(entries):
710
+ if v.fingerprint == args.fingerprint:
711
+ print(f"{v.state:9} {v.fingerprint} {v.klass}/{v.code} {v.subject} x{v.count}")
712
+ return 0
713
+
714
+
715
+ def cmd_incidents_ack(args: argparse.Namespace) -> int:
716
+ return _incidents_lifecycle(args, "ack")
717
+
718
+
719
+ def cmd_incidents_resolve(args: argparse.Namespace) -> int:
720
+ return _incidents_lifecycle(args, "resolve")
721
+
722
+
723
+ def _print_overlay_result(payload: dict[str, Any], *, json_mode: bool) -> None:
724
+ if json_mode:
725
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
726
+ return
727
+ outcome = payload.get("outcome_category", "failed")
728
+ provider = payload.get("provider", "unknown")
729
+ identity = payload.get("overlay_identity_digest", "unknown")
730
+ print(f"[graphite] overlay {outcome}: provider={provider} identity={identity}")
731
+ if payload.get("failure_category"):
732
+ print(f" - failure_category: {payload['failure_category']}")
733
+
734
+
735
+ def _overlay_error(code: str, *, json_mode: bool) -> None:
736
+ if json_mode:
737
+ print(json.dumps({"error": code}, sort_keys=True))
738
+ else:
739
+ print(f"[graphite] overlay error: {code}", file=sys.stderr)
740
+
741
+
742
+ def cmd_overlay_build(args: argparse.Namespace) -> int:
743
+ """Run one explicit non-authoritative enrichment operation."""
744
+ if args.llm_api_key is not None:
745
+ _overlay_error("overlay_credential_argv_forbidden", json_mode=args.json)
746
+ return 2
747
+ root = Path(args.path).resolve()
748
+ cfg = _project_scoped_config(args, root)
749
+ try:
750
+ request = OverlayRequest(
751
+ repository_root=root,
752
+ output_dir=cfg.output_dir,
753
+ provider=cfg.llm_provider.strip().casefold().replace("_", "-"),
754
+ provider_lifecycle_identity_digest=args.provider_identity_digest,
755
+ model_identity_digest=args.model_identity_digest,
756
+ routing_policy_digest=args.routing_policy_digest,
757
+ created_at=int(time.time()),
758
+ )
759
+ payload = build_overlay(request, cfg)
760
+ except ValueError:
761
+ _overlay_error("overlay_request_invalid", json_mode=args.json)
762
+ return 2
763
+ except OverlayError as exc:
764
+ code = str(exc)
765
+ _overlay_error(code, json_mode=args.json)
766
+ return 3 if code.startswith("canonical_") else 2
767
+ _print_overlay_result(payload, json_mode=args.json)
768
+ return 0 if payload.get("outcome_category") == "succeeded" else 4
769
+
770
+
771
+ def _project_scoped_config(
772
+ args: argparse.Namespace, root: Path, *, canonical: bool = False
773
+ ) -> Config:
774
+ cfg = _config_from_args(args, canonical=canonical)
775
+ data = cfg.to_dict()
776
+ if not Path(data["output_dir"]).is_absolute():
777
+ data["output_dir"] = root / data["output_dir"]
778
+ if not Path(data["cache_dir"]).is_absolute():
779
+ data["cache_dir"] = root / data["cache_dir"]
780
+ return Config(**data)
781
+
782
+
783
+ def _activate_typescript_for_onboarding(
784
+ args: argparse.Namespace, root: Path, cfg: Config
785
+ ) -> ActivationResult:
786
+ interactive = _onboarding_is_interactive(args)
787
+ try:
788
+ return activate_typescript(
789
+ ActivationRequest(
790
+ root=root,
791
+ cfg=cfg,
792
+ stdin_is_tty=interactive,
793
+ stdout_is_tty=interactive,
794
+ assume_yes=bool(getattr(args, "yes", False)),
795
+ json_mode=bool(getattr(args, "json", False)),
796
+ )
797
+ )
798
+ except Exception:
799
+ return ActivationResult(
800
+ ActivationOutcome.INSTALLATION_FAILED,
801
+ None,
802
+ "dependency_failed",
803
+ )
804
+
805
+
806
+ def _onboarding_is_interactive(args: argparse.Namespace) -> bool:
807
+ if (
808
+ bool(getattr(args, "json", False))
809
+ or bool(getattr(args, "yes", False))
810
+ or bool(os.environ.get("CI"))
811
+ ):
812
+ return False
813
+ try:
814
+ return bool(sys.stdin.isatty() and sys.stdout.isatty())
815
+ except (AttributeError, OSError, ValueError):
816
+ return False
817
+
818
+
819
+ def _print_typescript_activation(activation: ActivationResult) -> None:
820
+ manager = activation.manager.value if activation.manager else "none"
821
+ changed = ", ".join(activation.changed_files) if activation.changed_files else "none"
822
+ print(
823
+ " - TypeScript activation: "
824
+ f"{activation.outcome.value} (manager={manager}, "
825
+ f"reason={activation.reason}, changed={changed})"
826
+ )
827
+ if activation.outcome is ActivationOutcome.GUIDANCE_ONLY:
828
+ print(" 1. Set GRAPHITE_PACKAGE_VALIDATOR=<absolute-validator-path>.")
829
+ print(
830
+ " 2. Fail closed if GRAPHITE_PACKAGE_VALIDATOR is unset, relative, "
831
+ "missing, or not a regular file."
832
+ )
833
+ print(" 3. Run: node <absolute-validator-path> typescript")
834
+ print(" 4. With <project-manager>, add local dev dependency typescript with scripts disabled.")
835
+ print(" 5. Rerun graphite doctor or onboarding to confirm detection.")
836
+
837
+
838
+ def _onboarding_validation(args: argparse.Namespace, cfg: Any) -> dict[str, Any]:
839
+ """Judge the graph after `init`/`bootstrap`, or say why there is nothing to judge.
840
+
841
+ `ok` is deliberately three-valued:
842
+
843
+ True -- a graph exists and validated
844
+ False -- a graph was expected and is missing or invalid (exit 1)
845
+ None -- nothing to validate (exit 0)
846
+
847
+ The None case is the fix. `--no-build` with no pre-existing graph used to
848
+ report `ok: False, error: graph not found`, so `graphite init . --no-build
849
+ --yes --strict` -- the command the managed template and every onboarding
850
+ round tell agents to run -- returned exit 1 on a first-time repo after
851
+ writing every file correctly. "I did not build the graph you told me not to
852
+ build" is not a validation failure.
853
+
854
+ A missing graph WITHOUT `--no-build` is still False: a build that was
855
+ supposed to run and produced nothing is a real failure and must keep failing.
856
+
857
+ Shared by both onboarding commands because they carried byte-identical
858
+ copies of this block, which is how they would drift apart again.
859
+ """
860
+ validation: dict[str, Any] = {"requested": not args.no_validate, "ok": None}
861
+ if args.no_validate:
862
+ return validation
863
+
864
+ graph_path = cfg.output_dir / "graph.json"
865
+ if graph_path.exists():
866
+ with open(graph_path, "r", encoding="utf-8") as f:
867
+ validation.update(validate_graph_bundle(json.load(f)))
868
+ elif args.no_build:
869
+ validation.update({"ok": None, "skipped": "no_graph_no_build"})
870
+ else:
871
+ validation.update({"ok": False, "error": f"graph not found: {graph_path}"})
872
+ return validation
873
+
874
+
875
+ def _print_onboarding_validation(validation: dict[str, Any]) -> None:
876
+ if not validation["requested"]:
877
+ return
878
+ if validation.get("ok") is None:
879
+ # Say why, rather than printing a bare "ok" that implies a graph was
880
+ # checked when none exists.
881
+ print(" - validation: skipped (no graph to validate; --no-build)")
882
+ else:
883
+ print(f" - validation: {'ok' if validation.get('ok') else 'failed'}")
884
+
885
+
886
+ def cmd_bootstrap(args: argparse.Namespace) -> int:
887
+ root = Path(args.path).resolve()
888
+ daemon_base = Path(args.daemon_base).resolve() if args.daemon_base else None
889
+ result = bootstrap_project(root, daemon_base=daemon_base).to_dict()
890
+ cfg = _project_scoped_config(args, root, canonical=True)
891
+ # ts_activation, not activation: the module-level `activation` import is the
892
+ # repo-open registry, and shadowing it here would silently break any future
893
+ # use of it inside this function.
894
+ ts_activation = _activate_typescript_for_onboarding(args, root, cfg)
895
+ build: dict[str, Any] = {"requested": not args.no_build, "ok": None}
896
+
897
+ if not args.no_build:
898
+ if args.json:
899
+ with contextlib.redirect_stdout(sys.stderr):
900
+ _build_project(root, cfg)
901
+ else:
902
+ _build_project(root, cfg)
903
+ build["ok"] = True
904
+
905
+ validation = _onboarding_validation(args, cfg)
906
+
907
+ payload = {
908
+ **result,
909
+ "typescript_activation": ts_activation.to_dict(),
910
+ "build": build,
911
+ "validation": validation,
912
+ }
913
+ if args.json:
914
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
915
+ else:
916
+ print(f"[graphite] bootstrapped: {root}")
917
+ for key in ("gitignore", "agents"):
918
+ item = result[key]
919
+ action = "updated" if item.get("changed") else "already current"
920
+ print(f" - {key}: {action} ({item.get('path')})")
921
+ daemon = result["daemon"]
922
+ daemon_note = "listed" if daemon.get("project_listed") else "not listed yet"
923
+ print(f" - daemon: {daemon_note} ({daemon.get('status_path')})")
924
+ _print_typescript_activation(ts_activation)
925
+ if build["requested"]:
926
+ print(f" - build: {'ok' if build['ok'] else 'failed'}")
927
+ _print_onboarding_validation(validation)
928
+ return 1 if ts_activation.fatal or validation.get("ok") is False else 0
929
+
930
+
931
+
932
+ def cmd_init(args: argparse.Namespace) -> int:
933
+ if args.list_platforms:
934
+ choices = platform_choices()
935
+ if args.json:
936
+ print(json.dumps({"platforms": choices}, ensure_ascii=False, indent=2))
937
+ else:
938
+ for item in choices:
939
+ print(f"{item['key']}: {item['label']}")
940
+ return 0
941
+ root = Path(args.path).resolve()
942
+ interactive = not args.platform and not args.all and _onboarding_is_interactive(args)
943
+ requested = ["all"] if args.all else (args.platform or [])
944
+ platforms = resolve_platform_selection(requested, interactive=interactive)
945
+ daemon_base = Path(args.daemon_base).resolve() if args.daemon_base else None
946
+ # Strict by default. A per-repo setting that must be applied by sweeping
947
+ # every repo decays the moment a new repo appears -- and the sweep itself
948
+ # rebuilds repos nobody has open, which the operator mandate forbids.
949
+ # Strict denials are health-gated in code: they fire only on a proven-healthy
950
+ # graph and re-arm automatically, so this cannot trap an agent behind a bad
951
+ # graph. `--remind` remains the opt-out.
952
+ agent_hooks_mode = "remind" if args.remind else "strict"
953
+ result = init_project(
954
+ root,
955
+ platforms=platforms,
956
+ daemon_base=daemon_base,
957
+ agent_hooks_mode=agent_hooks_mode,
958
+ install_agent_hooks=not args.no_agent_hooks,
959
+ install_hooks=not args.no_hooks,
960
+ adopt=args.adopt,
961
+ ).to_dict()
962
+ cfg = _project_scoped_config(args, root, canonical=True)
963
+ # ts_activation, not activation: the module-level `activation` import is the
964
+ # repo-open registry, and shadowing it here would silently break any future
965
+ # use of it inside this function.
966
+ ts_activation = _activate_typescript_for_onboarding(args, root, cfg)
967
+ build: dict[str, Any] = {"requested": not args.no_build, "ok": None}
968
+
969
+ if not args.no_build:
970
+ if args.json:
971
+ with contextlib.redirect_stdout(sys.stderr):
972
+ _build_project(root, cfg)
973
+ else:
974
+ _build_project(root, cfg)
975
+ build["ok"] = True
976
+
977
+ validation = _onboarding_validation(args, cfg)
978
+
979
+ payload = {
980
+ **result,
981
+ "typescript_activation": ts_activation.to_dict(),
982
+ "build": build,
983
+ "validation": validation,
984
+ }
985
+ if args.json:
986
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
987
+ else:
988
+ print(f"[graphite] initialized: {root}")
989
+ print(f" - platforms: {', '.join(result['platforms'])}")
990
+ doc = result["graphite_doc"]
991
+ doc_action = doc.get("action") or ("updated" if doc.get("changed") else "already current")
992
+ print(f" - graphite_doc: {doc_action} ({doc.get('path')})")
993
+ for item in result["platform_files"]:
994
+ action = item.get("action") or ("updated" if item.get("changed") else "already current")
995
+ print(f" - {item.get('platform')}: {action} ({item.get('path')})")
996
+ agent_hooks = result["agent_hooks"]
997
+ print(
998
+ f" - agent_hooks: {agent_hooks.get('action')} "
999
+ f"({agent_hooks.get('path')}, mode={agent_hooks.get('mode')})"
1000
+ )
1001
+ hooks = result["hooks"]
1002
+ # Kept to a single line, not a " "-indented sub-line: that indent
1003
+ # is reserved for _print_typescript_activation's numbered guidance
1004
+ # steps below, and test_init_human_guidance_uses_exact_fixed_workflow
1005
+ # asserts on the exact set of 4-space-indented lines in this output.
1006
+ extra = ""
1007
+ if hooks.get("reason"):
1008
+ extra += f", reason={hooks['reason']}"
1009
+ if hooks.get("relocated"):
1010
+ extra += f", relocated={', '.join(hooks['relocated'])}"
1011
+ print(f" - hooks: {hooks.get('action')} ({hooks.get('path')}{extra})")
1012
+ allowlist = result["allowlist"]
1013
+ if allowlist.get("changed"):
1014
+ print(f" - gitignore allowlist: added {', '.join(allowlist.get('added', []))}")
1015
+ daemon = result["daemon"]
1016
+ daemon_note = "listed" if daemon.get("project_listed") else "not listed yet"
1017
+ print(f" - daemon: {daemon_note} ({daemon.get('status_path')})")
1018
+ _print_typescript_activation(ts_activation)
1019
+ if build["requested"]:
1020
+ print(f" - build: {'ok' if build['ok'] else 'failed'}")
1021
+ _print_onboarding_validation(validation)
1022
+ return 1 if ts_activation.fatal or validation.get("ok") is False else 0
1023
+
1024
+
1025
+ def cmd_hooks(args: argparse.Namespace) -> int:
1026
+ if not args.install_template:
1027
+ print("[graphite] hooks: nothing to do -- pass --install-template", file=sys.stderr)
1028
+ return 1
1029
+ template_root = hookinstall.default_template_root().resolve()
1030
+ interpreter = Path(sys.executable)
1031
+ written = hookinstall.install_template(template_root, interpreter)
1032
+ # graphite writes the template files but never touches real global git
1033
+ # config itself -- that command is printed for a human to run by hand.
1034
+ activate_cmd = f'git config --global init.templateDir "{template_root}"'
1035
+ if args.json:
1036
+ print(json.dumps({
1037
+ "template_root": str(template_root),
1038
+ "hooks": [str(p) for p in written],
1039
+ "activate_command": activate_cmd,
1040
+ }, ensure_ascii=False, indent=2))
1041
+ else:
1042
+ print(f"[graphite] template hooks written: {template_root}")
1043
+ for p in written:
1044
+ print(f" - {p}")
1045
+ print("[graphite] this covers FUTURE `git init`/`git clone` calls on this machine only,")
1046
+ print("[graphite] and only once you run this yourself -- graphite never runs it for you:")
1047
+ print(f" {activate_cmd}")
1048
+ return 0
1049
+
1050
+
1051
+ def cmd_audit_replacement(args: argparse.Namespace) -> int:
1052
+ root = Path(args.path).resolve()
1053
+ daemon_base = Path(args.daemon_base).resolve() if args.daemon_base else None
1054
+ cfg = _project_scoped_config(args, root, canonical=True)
1055
+ report = audit_replacement(root, daemon_base=daemon_base, cfg=cfg)
1056
+ if args.json:
1057
+ print(json.dumps(report, ensure_ascii=False, indent=2))
1058
+ else:
1059
+ print(format_replacement_audit(report), end="")
1060
+ return 1 if args.fail_on_blocker and not report["ok"] else 0
1061
+
1062
+
1063
+ def cmd_validate(args: argparse.Namespace) -> int:
1064
+ graph_path = Path(args.graph_json)
1065
+ if not graph_path.exists():
1066
+ print(f"[graphite] graph not found: {graph_path}", file=sys.stderr)
1067
+ return 1
1068
+ try:
1069
+ with open(graph_path, "r", encoding="utf-8") as f:
1070
+ bundle = json.load(f)
1071
+ except (OSError, json.JSONDecodeError) as exc:
1072
+ print(f"[graphite] invalid graph json: {exc}", file=sys.stderr)
1073
+ return 1
1074
+ report = validate_graph_bundle(bundle)
1075
+ if args.json:
1076
+ print(json.dumps(report, ensure_ascii=False, indent=2))
1077
+ else:
1078
+ if report["ok"]:
1079
+ print(
1080
+ f"[graphite] graph valid "
1081
+ f"({report['node_count']} nodes / {report['edge_count']} edges, "
1082
+ f"{report['warning_count']} warnings)"
1083
+ )
1084
+ else:
1085
+ print(f"[graphite] graph invalid ({report['error_count']} errors, {report['warning_count']} warnings)")
1086
+ for line in listing_lines(
1087
+ report["errors"],
1088
+ lambda issue: f"{issue['code']}: {issue['message']} [{issue['path']}]",
1089
+ cap=_VALIDATE_ERROR_CAP,
1090
+ empty=None,
1091
+ ):
1092
+ print(line)
1093
+ return 0 if report["ok"] else 1
1094
+
1095
+ def cmd_query(args: argparse.Namespace) -> int:
1096
+ started = time.perf_counter()
1097
+ if args.natural:
1098
+ translated = translate_natural(args.query)
1099
+ needs_graph = "plan" in translated or (
1100
+ "natural" in translated and translated["natural"]["intent"] == "search"
1101
+ )
1102
+ if args.plan_only or not needs_graph:
1103
+ print(json.dumps(translated, ensure_ascii=False, indent=2))
1104
+ return 0
1105
+ g = _load_graph(Path(args.graph_json), root=Path.cwd())
1106
+ result = answer_natural(g, args.query)
1107
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1108
+ if "error" not in result:
1109
+ _record_inconclusive(f"query {args.query}", result)
1110
+ _record_canonical_usage("query-natural", result, started)
1111
+ return 0
1112
+ if args.plan_only:
1113
+ print(json.dumps(plan_preview(args.query), ensure_ascii=False, indent=2))
1114
+ return 0
1115
+ g = _load_graph(Path(args.graph_json), root=Path.cwd())
1116
+ result = query(g, args.query)
1117
+ if args.show_plan:
1118
+ plan = build_plan(args.query)
1119
+ if "error" not in plan:
1120
+ result = {**result, "plan": plan}
1121
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1122
+ if "error" not in result:
1123
+ _record_inconclusive(f"query {args.query}", result)
1124
+ _record_canonical_usage("query", result, started)
1125
+ return 0
1126
+
1127
+
1128
+ def cmd_search(args: argparse.Namespace) -> int:
1129
+ started = time.perf_counter()
1130
+ g = _load_graph(Path(args.graph_json), root=Path.cwd())
1131
+ result = search_graph(g, args.text, limit=args.limit)
1132
+ if args.json:
1133
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1134
+ elif not result.get("ok"):
1135
+ print(f"[graphite] search error: {result.get('error')}")
1136
+ elif not result["results"]:
1137
+ print(f"[graphite] no matches for: {result['query']}")
1138
+ else:
1139
+ suffix = " (truncated)" if result["truncated"] else ""
1140
+ print(f"[graphite] {result['count']} match(es) for: {result['query']}{suffix}")
1141
+ for item in result["results"]:
1142
+ location = f" ({item['source_file']})" if item["source_file"] else ""
1143
+ print(f" - {item['id']} [{item['kind']}, {item['match_type']}]{location}")
1144
+ if result.get("ok"):
1145
+ _record_canonical_usage("search", result, started)
1146
+ return 0
1147
+
1148
+
1149
+ CHANNEL_DIRNAME = ".agent-channel"
1150
+
1151
+
1152
+ def cmd_channel(args: argparse.Namespace) -> int:
1153
+ """Resolve the shared agent channel's path from machine-local config.
1154
+
1155
+ The channel is the one exception to repository isolation, so every agent
1156
+ needs to find it -- but its absolute path must never be written into
1157
+ `GRAPHITE.md` or any other managed instruction file. Those are committed and
1158
+ pushed in consumer repos, so a local directory layout would land on their
1159
+ remotes. (Written in on 2026-08-01; a guard caught it before release.)
1160
+
1161
+ So agents resolve it at runtime instead, from the same
1162
+ `default_projects_root` that honours `GRAPHITE_PROJECTS_ROOT` ahead of any
1163
+ machine-specific fallback.
1164
+
1165
+ The path always goes to stdout and diagnostics to stderr, so `$(graphite
1166
+ channel)` yields a usable path either way and the exit code carries the
1167
+ status -- otherwise a caller `cd`s into an error message.
1168
+ """
1169
+ action = getattr(args, "action", None)
1170
+ if action:
1171
+ return _cmd_channel_action(args, action)
1172
+
1173
+ path = (default_projects_root() / CHANNEL_DIRNAME).resolve()
1174
+ exists = path.is_dir()
1175
+ is_git_repo = (path / ".git").exists()
1176
+ has_protocol = (path / "PROTOCOL.md").is_file()
1177
+
1178
+ if args.json:
1179
+ print(json.dumps({
1180
+ "ok": exists and is_git_repo,
1181
+ "schema_version": 1,
1182
+ "path": str(path),
1183
+ "exists": exists,
1184
+ "is_git_repo": is_git_repo,
1185
+ "has_protocol": has_protocol,
1186
+ }, indent=2))
1187
+ else:
1188
+ print(str(path))
1189
+
1190
+ if not exists:
1191
+ print(f"[graphite] channel not found at {path}", file=sys.stderr)
1192
+ return 1
1193
+ if not is_git_repo:
1194
+ # A plain directory has no history and no attribution, so it cannot
1195
+ # satisfy the audit requirement the channel exists to carry. Saying
1196
+ # "found" here would imply a setup that is not actually in place.
1197
+ print(f"[graphite] channel at {path} is not a git repository, so changes there are unauditable", file=sys.stderr)
1198
+ return 1
1199
+ return 0
1200
+
1201
+
1202
+ def _cmd_channel_action(args: argparse.Namespace, action: str) -> int:
1203
+ """Human-facing channel surface.
1204
+
1205
+ Kept behind an optional positional so the bare `graphite channel` keeps
1206
+ printing only the path: round 42 told every consumer to use
1207
+ `$(python -m graphite channel)`, and a subcommand that changed the bare form
1208
+ would break the callers that did as they were told.
1209
+ """
1210
+ from . import channel as channel_mod
1211
+
1212
+ try:
1213
+ root = channel_mod.require_channel()
1214
+ except channel_mod.ChannelError as exc:
1215
+ print(f"[graphite] {exc}", file=sys.stderr)
1216
+ return 1
1217
+
1218
+ if action == "report":
1219
+ data = channel_mod.build_report(root)
1220
+ print(json.dumps(data, indent=2) if args.json else channel_mod.render_report(data))
1221
+ # The verdict rides on the exit code so this can gate something, rather
1222
+ # than being a wall of text somebody has to read carefully.
1223
+ return 0 if data["ok"] else 1
1224
+
1225
+ if action == "list":
1226
+ entries = channel_mod.list_rounds(root)
1227
+ if args.json:
1228
+ print(json.dumps(
1229
+ [
1230
+ {
1231
+ "round": e.number,
1232
+ "title": e.title,
1233
+ "author": e.author,
1234
+ "to": e.to,
1235
+ "posted": e.posted,
1236
+ "legacy": e.legacy,
1237
+ }
1238
+ for e in entries
1239
+ ],
1240
+ indent=2,
1241
+ ))
1242
+ else:
1243
+ for entry in entries:
1244
+ label = f"round {entry.number}" if entry.number is not None else "round ?"
1245
+ who = entry.author or "(legacy)"
1246
+ print(f"{label:<10} {who:<16} {entry.title}")
1247
+ return 0
1248
+
1249
+ if action == "register":
1250
+ if not args.target or not args.agent:
1251
+ print("[graphite] channel register needs <repo-path> <name>-agent", file=sys.stderr)
1252
+ return 2
1253
+ try:
1254
+ result = channel_mod.register_agent(root, Path(args.target), args.agent)
1255
+ except channel_mod.ChannelError as exc:
1256
+ print(f"[graphite] {exc}", file=sys.stderr)
1257
+ return 1
1258
+ if args.json:
1259
+ print(json.dumps(result, indent=2))
1260
+ else:
1261
+ moved = f" (was {result['previous']})" if result["previous"] else ""
1262
+ print(f"registered {result['agent']} for {result['path']}{moved}")
1263
+ return 0
1264
+
1265
+ if action == "show":
1266
+ try:
1267
+ number = int(args.target) if args.target is not None else None
1268
+ except ValueError:
1269
+ number = None
1270
+ if number is None:
1271
+ print("[graphite] channel show needs a round number", file=sys.stderr)
1272
+ return 2
1273
+ try:
1274
+ entry = channel_mod.read_round(root, number)
1275
+ except channel_mod.ChannelError as exc:
1276
+ print(f"[graphite] {exc}", file=sys.stderr)
1277
+ return 1
1278
+ print(entry.body)
1279
+ return 0
1280
+
1281
+ print(f"[graphite] unknown channel action: {action}", file=sys.stderr)
1282
+ return 2
1283
+
1284
+
1285
+ def cmd_capabilities(args: argparse.Namespace) -> int:
1286
+ payload = {
1287
+ "ok": True,
1288
+ "schema_version": 1,
1289
+ "commands": sorted(_CANONICAL_COMMANDS),
1290
+ "query_verbs": verb_catalog(),
1291
+ "answer_contract": {
1292
+ "schema": ANSWER_SCHEMA,
1293
+ "grades": [GRADE_DECISION, GRADE_ADVISORY, GRADE_INCONCLUSIVE],
1294
+ "caveats": active_caveats(),
1295
+ },
1296
+ "search": {"default_limit": DEFAULT_SEARCH_LIMIT, "max_limit": MAX_SEARCH_LIMIT},
1297
+ "query_limits": {
1298
+ "default_max_depth": DEFAULT_MAX_DEPTH,
1299
+ "default_max_results": DEFAULT_MAX_RESULTS,
1300
+ },
1301
+ "query_plans": {"plan_version": PLAN_VERSION, "flags": ["--plan-only", "--show-plan"]},
1302
+ "natural_language": {
1303
+ "available": True,
1304
+ "mode": "deterministic-grammar",
1305
+ "flag": "--natural",
1306
+ "providers": False,
1307
+ "intents": natural_catalog(),
1308
+ },
1309
+ "node_kinds": ["class", "file", "function", "unknown"],
1310
+ "edge_relations": ["calls", "contains", "imports", "inherits", "references", "type_references"],
1311
+ "limits": {"max_graph_bytes": MAX_GRAPH_BYTES},
1312
+ }
1313
+ if args.json:
1314
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1315
+ else:
1316
+ print("[graphite] canonical commands: " + ", ".join(payload["commands"]))
1317
+ print("[graphite] query verbs:")
1318
+ for verb in payload["query_verbs"]:
1319
+ arguments = f" {verb['arguments']}" if verb["arguments"] else ""
1320
+ aliases = f" (aliases: {', '.join(verb['aliases'])})" if verb["aliases"] else ""
1321
+ print(f" - {verb['name']}{arguments}{aliases}: {verb['description']}")
1322
+ print(f"[graphite] search: default limit {DEFAULT_SEARCH_LIMIT}, max {MAX_SEARCH_LIMIT}")
1323
+ print(
1324
+ f"[graphite] query limits: max_depth {DEFAULT_MAX_DEPTH} (path/reaches), "
1325
+ f"max_results {DEFAULT_MAX_RESULTS} (neighbor listings)"
1326
+ )
1327
+ print(f"[graphite] query plans: v{PLAN_VERSION} (--show-plan, --plan-only)")
1328
+ pattern_count = sum(len(entry["templates"]) for entry in natural_catalog())
1329
+ print(
1330
+ f"[graphite] natural language: deterministic grammar via query --natural "
1331
+ f"({pattern_count} patterns, no inference)"
1332
+ )
1333
+ return 0
1334
+
1335
+
1336
+ def _todays_entries(entries: list[dict[str, Any]], now: datetime) -> list[dict[str, Any]]:
1337
+ """Entries whose UTC timestamp falls on `now`'s local calendar day."""
1338
+ selected: list[dict[str, Any]] = []
1339
+ for entry in entries:
1340
+ try:
1341
+ stamp = datetime.fromisoformat(str(entry.get("ts", "")))
1342
+ except ValueError:
1343
+ continue
1344
+ if stamp.tzinfo is None:
1345
+ continue
1346
+ if stamp.astimezone(now.tzinfo).date() == now.date():
1347
+ selected.append(entry)
1348
+ return selected
1349
+
1350
+
1351
+ def cmd_savings(args: argparse.Namespace) -> int:
1352
+ from . import savings as savings_model
1353
+ from . import usage_ledger
1354
+
1355
+ root = Path.cwd()
1356
+ if args.action in ("on", "off"):
1357
+ usage_ledger.set_savings_display(root, args.action == "on")
1358
+ if args.action in ("on", "off", "status"):
1359
+ payload = {"ok": True, "savings_display": usage_ledger.savings_display_enabled(root)}
1360
+ if args.json:
1361
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1362
+ else:
1363
+ state = "on" if payload["savings_display"] else "off"
1364
+ print(f"[graphite] savings display: {state}")
1365
+ return 0
1366
+
1367
+ entries = list(usage_ledger.iter_entries(root))
1368
+ today_entries = _todays_entries(entries, datetime.now().astimezone())
1369
+ payload = {
1370
+ "ok": True,
1371
+ "schema_version": 1,
1372
+ "all_time": savings_model.summarize(entries),
1373
+ "today": savings_model.summarize(today_entries),
1374
+ "savings_display": usage_ledger.savings_display_enabled(root),
1375
+ "methodology": savings_model.methodology(),
1376
+ }
1377
+ if args.json:
1378
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1379
+ return 0
1380
+ for label, summary in (("today", payload["today"]), ("all-time", payload["all_time"])):
1381
+ compact = savings_model.format_compact(summary["tokens_saved"], summary["seconds_saved"])
1382
+ print(f"[graphite] {label}: est. {compact} saved across {summary['count']} graphite answers")
1383
+ for cmd_name, bucket in sorted(summary["by_cmd"].items()):
1384
+ bucket_compact = savings_model.format_compact(bucket["tokens_saved"], bucket["seconds_saved"])
1385
+ print(f" - {cmd_name}: {bucket['count']} calls, est. {bucket_compact}")
1386
+ print(f"[graphite] methodology: {payload['methodology']}")
1387
+ return 0
1388
+
1389
+
1390
+ def _is_scratch_workspace(path: Path) -> bool:
1391
+ """True inside a workspace graphite created for its own probing.
1392
+
1393
+ `graphite doctor` builds probe workspaces and runs the CLI inside them;
1394
+ without this the opportunistic backstop marks each one as an open repo and
1395
+ the daemon supervises a scratch directory nobody opened. Found by inspecting
1396
+ the live registry, which held 14 `graphite-doctor-*` markers and none of the
1397
+ repositories that were actually open.
1398
+
1399
+ Matches graphite's own prefixes rather than "anywhere under the system temp
1400
+ dir": a temp checkout can be a repo someone is genuinely working in, and
1401
+ over-blocking would silently stop supervising it. These prefixes are ours --
1402
+ see `probe_workspace.py` and `typescript_activation.py`.
1403
+
1404
+ Guards the *opportunistic* path only. Explicit activation -- `graphite
1405
+ activate`, agent hooks -- is trusted and still honoured: if a human or an
1406
+ editor says a path is open, that is a statement of fact, not an inference.
1407
+ """
1408
+ try:
1409
+ resolved = path.resolve()
1410
+ except Exception:
1411
+ return False
1412
+ candidates = (resolved, *resolved.parents)
1413
+ return any(
1414
+ part.name.startswith(_SCRATCH_WORKSPACE_PREFIXES) for part in candidates
1415
+ )
1416
+
1417
+
1418
+ def cmd_activate(args: argparse.Namespace) -> int:
1419
+ """Register a repository as open in a coding agent.
1420
+
1421
+ Exists so editors that cannot run a graphite hook -- VS Code and its forks,
1422
+ via a `runOn: folderOpen` task -- can still put a repo under supervision.
1423
+ """
1424
+ activation.mark_active(Path(args.path).resolve(), args.agent)
1425
+ return 0
1426
+
1427
+
1428
+ def cmd_agent_hook(args: argparse.Namespace) -> int:
1429
+ try:
1430
+ from .agent_hooks import handle_pre_tool_use, handle_session_start, handle_stop
1431
+
1432
+ raw = sys.stdin.read()
1433
+ payload = json.loads(raw) if raw.strip() else {}
1434
+ if not isinstance(payload, dict):
1435
+ return 0
1436
+ if args.event == "session-start":
1437
+ out = handle_session_start(payload)
1438
+ elif args.event == "pre-tool-use":
1439
+ out = handle_pre_tool_use(payload, args.mode)
1440
+ elif args.event == "stop":
1441
+ out = handle_stop(payload)
1442
+ else:
1443
+ # Unknown event (e.g. from a newer package's committed wiring
1444
+ # outliving this install): no-op rather than misrouting to
1445
+ # pre-tool-use.
1446
+ return 0
1447
+ if out is not None:
1448
+ print(json.dumps(out, ensure_ascii=False))
1449
+ except Exception:
1450
+ pass # fail-open: a hook problem must never break a tool call
1451
+ return 0
1452
+
1453
+
1454
+ def _answer_lines(block: dict[str, Any] | None, *, empty: bool) -> list[str]:
1455
+ """Human epistemology lines; [] unless empty or a scoped cell is degraded.
1456
+
1457
+ Rendered at column 0, matching context.py and the `note:` line, so they
1458
+ cannot be read as entries of the list they follow.
1459
+ """
1460
+ if not block:
1461
+ return []
1462
+ if not empty and not is_degraded(block) and not is_unmeasured(block):
1463
+ return []
1464
+ cells = ", ".join(
1465
+ f"{relation} ({language}) {langs[language]['ratio']:.2f}"
1466
+ for relation, langs in sorted(block.get("health", {}).items())
1467
+ for language in sorted(langs)
1468
+ )
1469
+ grade = block.get("grade", "").replace("_", "-")
1470
+ lines = [f"answer health: {cells} — {grade}"] if cells else [f"answer health: — {grade}"]
1471
+ if block.get("caveats"):
1472
+ lines.append("known limits: " + "; ".join(c["summary"] for c in block["caveats"]))
1473
+ return lines
1474
+
1475
+
1476
+ def cmd_impact(args: argparse.Namespace) -> int:
1477
+ started = time.perf_counter()
1478
+ g = _load_graph(Path(args.graph_json), root=Path.cwd())
1479
+ result = _impact(g, args.files, args.depth)
1480
+ if args.json:
1481
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1482
+ else:
1483
+ health = result["resolution_health"]
1484
+ if result["inconclusive"]:
1485
+ answer = result.get("answer")
1486
+ if answer:
1487
+ meaning = answer.get(
1488
+ "empty_meaning", "no impacted files or tests reachable through bound edges"
1489
+ )
1490
+ print(
1491
+ f"Impacted files: none found — INCONCLUSIVE: {meaning}; "
1492
+ "treat as unverified and confirm with grep."
1493
+ )
1494
+ else:
1495
+ print(
1496
+ "Impacted files: none found — INCONCLUSIVE: only "
1497
+ f"{ratio_percent(health, 'imports')} of import edges and "
1498
+ f"{ratio_percent(health, 'calls')} of call edges resolved in this "
1499
+ "graph; treat as unverified and confirm with grep."
1500
+ )
1501
+ else:
1502
+ if result["impacted_files"] or result["likely_tests"]:
1503
+ marker = empty_marker(result.get("answer"))
1504
+ for line in listing_lines(
1505
+ result["impacted_files"], header="Impacted files:", empty=marker
1506
+ ):
1507
+ print(line)
1508
+ for line in listing_lines(
1509
+ result["likely_tests"], header="Likely tests:", empty=marker
1510
+ ):
1511
+ print(line)
1512
+ else:
1513
+ meaning = (result.get("answer") or {}).get(
1514
+ "empty_meaning", "none found"
1515
+ )
1516
+ print(f"Impacted files: none found — {meaning}")
1517
+ if not health["healthy"] and (result["impacted_files"] or result["likely_tests"]):
1518
+ print(
1519
+ f"note: resolution health low (imports {ratio_percent(health, 'imports')}, "
1520
+ f"calls {ratio_percent(health, 'calls')}) — this list may be incomplete."
1521
+ )
1522
+ empty = not result["impacted_files"] and not result["likely_tests"]
1523
+ for line in _answer_lines(result.get("answer"), empty=empty):
1524
+ print(line)
1525
+ if result["missing"]:
1526
+ print("Missing inputs:")
1527
+ for item in result["missing"]:
1528
+ print(f" - {item}")
1529
+ _record_inconclusive("impact " + ",".join(args.files), result)
1530
+ _record_canonical_usage("impact", result, started)
1531
+ return 0 if not result["missing"] else 1
1532
+
1533
+
1534
+ def _review_depth(value: str) -> int:
1535
+ try:
1536
+ depth = int(value)
1537
+ except ValueError:
1538
+ raise argparse.ArgumentTypeError("must be zero or greater") from None
1539
+ if depth < 0:
1540
+ raise argparse.ArgumentTypeError("must be zero or greater")
1541
+ return depth
1542
+
1543
+
1544
+ def _review_git_timeout(value: str) -> float:
1545
+ try:
1546
+ timeout = float(value)
1547
+ except ValueError:
1548
+ raise argparse.ArgumentTypeError("must be finite and greater than zero") from None
1549
+ if not math.isfinite(timeout) or timeout <= 0:
1550
+ raise argparse.ArgumentTypeError("must be finite and greater than zero")
1551
+ return timeout
1552
+
1553
+
1554
+ def _resolve_review_graph_path(
1555
+ root: Path, cfg: Config, explicit_path: str | None
1556
+ ) -> Path:
1557
+ candidate = Path(explicit_path) if explicit_path is not None else cfg.output_dir / "graph.json"
1558
+ if not candidate.is_absolute():
1559
+ candidate = root / candidate
1560
+ try:
1561
+ resolved = candidate.resolve()
1562
+ resolved.relative_to(root)
1563
+ except (OSError, RuntimeError, ValueError):
1564
+ raise ReviewError("graph path must be within project root") from None
1565
+ return resolved
1566
+
1567
+
1568
+ def _load_review_graph(path: Path, root: Path | None = None) -> tuple[Any, str | None]:
1569
+ try:
1570
+ selected_root = root or path.parent.parent
1571
+ bundle, _ = load_validated_graph_bundle(
1572
+ path,
1573
+ root=selected_root,
1574
+ max_bytes=_MAX_REVIEW_GRAPH_BYTES,
1575
+ )
1576
+ return bundle, None
1577
+ except GraphReadError as exc:
1578
+ if exc.code == "graph_invalid":
1579
+ return {}, None
1580
+ return None, "dependency graph is unavailable"
1581
+
1582
+
1583
+ def _review_graph_status(
1584
+ root: Path,
1585
+ cfg: Config,
1586
+ graph_path: Path,
1587
+ *,
1588
+ custom_graph: bool,
1589
+ ) -> dict[str, Any]:
1590
+ if not custom_graph:
1591
+ return check_graph_freshness(root, cfg)
1592
+ data = cfg.to_dict()
1593
+ data["output_dir"] = graph_path.parent
1594
+ return check_graph_freshness(root, Config(**data))
1595
+
1596
+
1597
+ def cmd_review_changes(args: argparse.Namespace) -> int:
1598
+ """Build deterministic change-review evidence without invoking an LLM."""
1599
+ root = Path(args.path).resolve()
1600
+ if not root.is_dir():
1601
+ raise ReviewError("project path is not a directory")
1602
+ if args.files:
1603
+ changes = normalize_explicit_changes(root, args.files)
1604
+ discovery = "explicit"
1605
+ else:
1606
+ changes = discover_git_changes(root, timeout_seconds=args.git_timeout)
1607
+ discovery = "git"
1608
+
1609
+ cfg = _project_scoped_config(args, root, canonical=True)
1610
+ custom_graph = args.graph_json is not None
1611
+ graph_path = _resolve_review_graph_path(root, cfg, args.graph_json)
1612
+ graph_bundle, graph_error = _load_review_graph(graph_path, root)
1613
+
1614
+ packet = build_review_packet(
1615
+ root_name=root.name,
1616
+ changes=changes,
1617
+ discovery=discovery,
1618
+ graph_bundle=graph_bundle,
1619
+ graph_status=_review_graph_status(
1620
+ root, cfg, graph_path, custom_graph=custom_graph
1621
+ ),
1622
+ depth=args.depth,
1623
+ graph_error=graph_error,
1624
+ )
1625
+ if args.json:
1626
+ print(json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True))
1627
+ else:
1628
+ print(format_review_markdown(packet), end="")
1629
+ return 1 if args.fail_on_blocker and packet["blockers"] else 0
1630
+
1631
+
1632
+ def cmd_context(args: argparse.Namespace) -> int:
1633
+ started = time.perf_counter()
1634
+ g = _load_graph(Path(args.graph_json), root=Path.cwd())
1635
+ result = build_context(g, args.files, depth=args.depth, neighbor_limit=args.neighbor_limit)
1636
+ if args.json:
1637
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1638
+ else:
1639
+ print(format_context_markdown(result))
1640
+ _record_inconclusive("context " + ",".join(args.files), result)
1641
+ _record_canonical_usage("context", result, started)
1642
+ return 0 if not result["missing"] else 1
1643
+
1644
+
1645
+ def cmd_watch(args: argparse.Namespace) -> int:
1646
+ root = Path(args.path).resolve()
1647
+ # Anchored to the repo (#26): a long-running watcher rebuilding into the
1648
+ # CWD is the same defect, just harder to notice.
1649
+ cfg = _project_scoped_config(args, root, canonical=True)
1650
+ options = WatchOptions(
1651
+ interval_seconds=args.interval,
1652
+ debounce_seconds=args.debounce,
1653
+ max_cycles=args.max_cycles,
1654
+ build_now=not args.no_initial_build,
1655
+ once=args.once,
1656
+ )
1657
+
1658
+ def on_change(change: WatchChange) -> bool:
1659
+ initial = len(change.added) > 0 and not change.changed and not change.removed
1660
+ if initial and options.build_now:
1661
+ print(f"[graphite] initial build for {root}")
1662
+ else:
1663
+ _print_watch_change(change)
1664
+ if args.impact:
1665
+ _print_watch_impact(root, cfg, change, args.impact_depth)
1666
+ try:
1667
+ _build_project(root, cfg)
1668
+ return True
1669
+ except Exception as exc:
1670
+ print(f"[graphite] rebuild failed: {exc}", file=sys.stderr)
1671
+ return False
1672
+
1673
+ def on_error(exc: Exception) -> None:
1674
+ print(f"[graphite] watcher error: {exc}", file=sys.stderr)
1675
+
1676
+ print(
1677
+ f"[graphite] watching {root} "
1678
+ f"(interval={options.interval_seconds}s, debounce={options.debounce_seconds}s)"
1679
+ )
1680
+ processed = watch_loop(root, cfg, on_change, options, on_error=on_error)
1681
+ if args.once:
1682
+ print(f"[graphite] watch once complete ({processed} rebuilds)")
1683
+ return 0
1684
+
1685
+
1686
+ def _daemon_subcommand_suggestion(base_path: str | None, choices: Iterable[str]) -> str | None:
1687
+ """Suggest `daemon-<x>` when `graphite daemon <x>` was meant as a subcommand.
1688
+
1689
+ `daemon` takes a base path positionally, so the space form silently became a
1690
+ path argument and failed naming a directory the user never typed (#9). The
1691
+ candidate list comes from the live parser, so it cannot drift from the real
1692
+ subcommands. An existing directory always wins: a folder genuinely named
1693
+ `status` stays usable as a base path.
1694
+ """
1695
+ if not base_path:
1696
+ return None
1697
+ candidate = f"daemon-{base_path}"
1698
+ if candidate not in set(choices):
1699
+ return None
1700
+ try:
1701
+ if Path(base_path).is_dir():
1702
+ return None
1703
+ except OSError:
1704
+ pass
1705
+ return candidate
1706
+
1707
+
1708
+ def cmd_daemon(args: argparse.Namespace) -> int:
1709
+ cfg = _config_from_args(args, canonical=True)
1710
+ base = Path(args.base_path).resolve()
1711
+ options = DaemonOptions(
1712
+ scan_interval_seconds=args.scan_interval,
1713
+ discover_interval_seconds=args.discover_interval,
1714
+ debounce_seconds=args.debounce,
1715
+ max_depth=args.max_depth,
1716
+ max_projects=args.max_projects,
1717
+ max_files_per_project=args.max_files_per_project,
1718
+ max_builds_per_cycle=args.max_builds_per_cycle,
1719
+ build_timeout_seconds=args.build_timeout,
1720
+ build_now=not args.no_initial_build,
1721
+ once=args.once,
1722
+ max_cycles=args.max_cycles,
1723
+ state_dir=Path(args.state_dir).resolve() if args.state_dir else None,
1724
+ )
1725
+ status = run_daemon(base, cfg, options)
1726
+ if args.json:
1727
+ print(json.dumps(status, ensure_ascii=False, indent=2))
1728
+ else:
1729
+ state_dir = options.state_dir or (base / ".graphite-daemon")
1730
+ print(
1731
+ f"[graphite] daemon status: {status.get('status')} "
1732
+ f"({status.get('project_count')} projects, {status.get('failing_projects')} failing)"
1733
+ )
1734
+ print(f"[graphite] status file: {state_dir / 'status.json'}")
1735
+ print(f"[graphite] log file: {state_dir / 'graphite-daemon.log'}")
1736
+ return 0
1737
+
1738
+
1739
+ def cmd_daemon_status(args: argparse.Namespace) -> int:
1740
+ base = Path(args.base_path).resolve()
1741
+ state_dir = Path(args.state_dir).resolve() if args.state_dir else None
1742
+ try:
1743
+ status = read_daemon_status(base, state_dir)
1744
+ except FileNotFoundError:
1745
+ path = (state_dir or (base / ".graphite-daemon")) / "status.json"
1746
+ print(f"[graphite] daemon status not found: {path}", file=sys.stderr)
1747
+ return 1
1748
+ if args.json:
1749
+ print(json.dumps(status, ensure_ascii=False, indent=2))
1750
+ else:
1751
+ print(
1752
+ f"[graphite] daemon status: {status.get('status')} "
1753
+ f"({status.get('project_count')} projects, {status.get('failing_projects')} failing, "
1754
+ f"{status.get('pending_projects')} pending)"
1755
+ )
1756
+ print(f"[graphite] updated: {status.get('updated_at')}")
1757
+ for line in listing_lines(
1758
+ status.get("projects", []),
1759
+ lambda p: (
1760
+ f"{p.get('root')} | builds={p.get('build_count')} "
1761
+ f"failures={p.get('failure_count')} files={p.get('file_count')}"
1762
+ ),
1763
+ cap=_DAEMON_STATUS_PROJECT_CAP,
1764
+ empty=None,
1765
+ more_hint=" — use --json for the full list",
1766
+ ):
1767
+ print(line)
1768
+ return 0
1769
+
1770
+
1771
+ def cmd_daemon_health(args: argparse.Namespace) -> int:
1772
+ base = Path(args.base_path).resolve()
1773
+ state_dir = Path(args.state_dir).resolve() if args.state_dir else None
1774
+ options = HealthOptions(
1775
+ max_status_age_seconds=args.max_status_age,
1776
+ max_project_success_age_seconds=args.max_project_success_age,
1777
+ require_process=not args.no_process_check,
1778
+ require_startup=not args.no_startup_check,
1779
+ startup_name=args.startup_name,
1780
+ )
1781
+ report = evaluate_daemon_health(base, state_dir=state_dir, options=options)
1782
+ if args.json:
1783
+ print(json.dumps(report, ensure_ascii=False, indent=2))
1784
+ else:
1785
+ print(format_health_text(report), end="")
1786
+ return 1 if args.fail_on_error and not report["ok"] else 0
1787
+
1788
+
1789
+ def _daemon_task_command_from_args(args: argparse.Namespace) -> Any:
1790
+ return daemon_task_command(
1791
+ Path(args.base_path),
1792
+ graphite_executable=args.graphite_executable,
1793
+ scan_interval=args.scan_interval,
1794
+ discover_interval=args.discover_interval,
1795
+ max_projects=args.max_projects,
1796
+ max_depth=args.max_depth,
1797
+ max_builds_per_cycle=args.max_builds_per_cycle,
1798
+ build_timeout=args.build_timeout,
1799
+ debounce=args.debounce,
1800
+ )
1801
+
1802
+
1803
+ def cmd_daemon_install_windows(args: argparse.Namespace) -> int:
1804
+ command = _daemon_task_command_from_args(args)
1805
+ result = create_daemon_task(args.task_name, command, force=not args.no_force, start_now=args.start_now)
1806
+ if args.json:
1807
+ print(json.dumps({"task_name": args.task_name, "task_run": command.task_run, **result}, ensure_ascii=False, indent=2))
1808
+ elif result["ok"]:
1809
+ print(f"[graphite] scheduled task installed: {args.task_name}")
1810
+ print(f"[graphite] task command: {command.task_run}")
1811
+ if args.start_now:
1812
+ started = result.get("started", {})
1813
+ print(f"[graphite] task start requested: {started.get('ok')}")
1814
+ else:
1815
+ print(f"[graphite] failed to install scheduled task: {args.task_name}", file=sys.stderr)
1816
+ if result.get("stderr"):
1817
+ print(result["stderr"], file=sys.stderr)
1818
+ if result.get("stdout"):
1819
+ print(result["stdout"], file=sys.stderr)
1820
+ return 0 if result["ok"] else 1
1821
+
1822
+
1823
+ def cmd_daemon_task_status(args: argparse.Namespace) -> int:
1824
+ result = query_daemon_task(args.task_name)
1825
+ if args.json:
1826
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1827
+ elif result.get("exists"):
1828
+ task = result.get("task", {})
1829
+ print(f"[graphite] scheduled task exists: {args.task_name}")
1830
+ for key in ("TaskName", "Status", "Task To Run", "Schedule Type", "Last Run Time", "Last Result", "Next Run Time"):
1831
+ if isinstance(task, dict) and task.get(key):
1832
+ print(f" {key}: {task[key]}")
1833
+ else:
1834
+ print(f"[graphite] scheduled task not found: {args.task_name}")
1835
+ return 0 if result.get("exists") else 1
1836
+
1837
+
1838
+ def cmd_daemon_uninstall_windows(args: argparse.Namespace) -> int:
1839
+ result = delete_daemon_task(args.task_name)
1840
+ if args.json:
1841
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1842
+ elif result["ok"]:
1843
+ print(f"[graphite] scheduled task removed: {args.task_name}")
1844
+ else:
1845
+ print(f"[graphite] failed to remove scheduled task: {args.task_name}", file=sys.stderr)
1846
+ if result.get("stderr"):
1847
+ print(result["stderr"], file=sys.stderr)
1848
+ if result.get("stdout"):
1849
+ print(result["stdout"], file=sys.stderr)
1850
+ return 0 if result["ok"] else 1
1851
+
1852
+
1853
+ def cmd_daemon_install_startup_windows(args: argparse.Namespace) -> int:
1854
+ result = install_startup_launcher(
1855
+ Path(args.base_path),
1856
+ name=args.name,
1857
+ graphite_executable=args.graphite_executable,
1858
+ scan_interval=args.scan_interval,
1859
+ discover_interval=args.discover_interval,
1860
+ max_projects=args.max_projects,
1861
+ max_depth=args.max_depth,
1862
+ max_builds_per_cycle=args.max_builds_per_cycle,
1863
+ build_timeout=args.build_timeout,
1864
+ debounce=args.debounce,
1865
+ )
1866
+ payload = {
1867
+ "name": result.name,
1868
+ "installed": True,
1869
+ "base_path": str(result.base_path),
1870
+ "script_path": str(result.script_path),
1871
+ "launcher_path": str(result.launcher_path),
1872
+ "command_line": result.command_line,
1873
+ }
1874
+ if args.json:
1875
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1876
+ else:
1877
+ print(f"[graphite] startup launcher installed: {result.name}")
1878
+ print(f"[graphite] launcher: {result.launcher_path}")
1879
+ print(f"[graphite] script: {result.script_path}")
1880
+ return 0
1881
+
1882
+
1883
+ def cmd_daemon_startup_status(args: argparse.Namespace) -> int:
1884
+ result = startup_status(Path(args.base_path), name=args.name)
1885
+ if args.json:
1886
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1887
+ elif result["installed"]:
1888
+ print(f"[graphite] startup launcher installed: {args.name}")
1889
+ print(f"[graphite] launcher: {result['launcher_path']}")
1890
+ print(f"[graphite] script: {result['script_path']}")
1891
+ else:
1892
+ print(f"[graphite] startup launcher not installed: {args.name}")
1893
+ print(f"[graphite] launcher: {result['launcher_path']}")
1894
+ print(f"[graphite] script: {result['script_path']}")
1895
+ return 0 if result["installed"] else 1
1896
+
1897
+
1898
+ def cmd_daemon_uninstall_startup_windows(args: argparse.Namespace) -> int:
1899
+ result = uninstall_startup_launcher(Path(args.base_path), name=args.name)
1900
+ if args.json:
1901
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1902
+ else:
1903
+ print(f"[graphite] startup launcher removed: {args.name}")
1904
+ for path in result["removed"]:
1905
+ print(f" - {path}")
1906
+ return 0
1907
+
1908
+
1909
+ def _route_print(payload: dict[str, Any], *, json_mode: bool) -> None:
1910
+ if json_mode:
1911
+ print(json.dumps(payload, sort_keys=True))
1912
+ else:
1913
+ print(json.dumps(payload, indent=2, sort_keys=True))
1914
+
1915
+
1916
+ _ROUTE_RECOVERY_ERROR_CODES = frozenset({
1917
+ "attempt_id_invalid",
1918
+ "execution_attempt_conflict",
1919
+ "execution_attempt_missing",
1920
+ "legacy_attempt_bindings_missing",
1921
+ "legacy_attempt_digest_missing",
1922
+ "recovery_cursor_invalid",
1923
+ "recovery_limit_invalid",
1924
+ "repository_root_invalid",
1925
+ "storage_corrupt",
1926
+ "storage_locked",
1927
+ "storage_path_invalid",
1928
+ "storage_schema_unsupported",
1929
+ "storage_unavailable",
1930
+ })
1931
+
1932
+
1933
+ def _route_recovery_error(
1934
+ error: StorageError | RoutingServiceError | ValueError | OSError, *, json_mode: bool
1935
+ ) -> int:
1936
+ if isinstance(error, (StorageError, RoutingServiceError)):
1937
+ candidate = error.code
1938
+ elif isinstance(error, FileNotFoundError):
1939
+ candidate = "repository_root_invalid"
1940
+ elif isinstance(error, OSError):
1941
+ candidate = "storage_unavailable"
1942
+ elif isinstance(error, ValueError):
1943
+ candidate = str(error)
1944
+ if candidate not in _ROUTE_RECOVERY_ERROR_CODES:
1945
+ raise error
1946
+ else:
1947
+ raise error
1948
+ code = (
1949
+ candidate
1950
+ if candidate in _ROUTE_RECOVERY_ERROR_CODES
1951
+ else "route_recovery_failed"
1952
+ )
1953
+ if json_mode:
1954
+ print(json.dumps({"error": {"code": code}}, sort_keys=True), file=sys.stderr)
1955
+ else:
1956
+ print(f"[graphite] route recovery error: {code}", file=sys.stderr)
1957
+ return 1
1958
+
1959
+
1960
+ _MODEL_OUTPUT_BEGIN = "----- BEGIN GRAPHITE MODEL OUTPUT -----"
1961
+ _MODEL_OUTPUT_END = "----- END GRAPHITE MODEL OUTPUT -----"
1962
+
1963
+
1964
+ def _escaped_terminal_text(text: str) -> str:
1965
+ characters: list[str] = []
1966
+ for character in text:
1967
+ codepoint = ord(character)
1968
+ category = unicodedata.category(character)
1969
+ if character == "\n":
1970
+ characters.append(character)
1971
+ elif category in {"Cc", "Cf", "Zl", "Zp"} or codepoint == 0x7F:
1972
+ characters.append(
1973
+ f"\\x{codepoint:02x}" if codepoint <= 0xFF else f"\\u{codepoint:04x}"
1974
+ )
1975
+ else:
1976
+ characters.append(character)
1977
+ escaped = "".join(characters)
1978
+ return escaped.replace(_MODEL_OUTPUT_BEGIN, "[escaped model delimiter]").replace(
1979
+ _MODEL_OUTPUT_END, "[escaped model delimiter]"
1980
+ )
1981
+
1982
+
1983
+ def _render_model_output(text: str, *, stdout: TextIO) -> None:
1984
+ """Render untrusted provider text without granting terminal control."""
1985
+ safe = _escaped_terminal_text(text)
1986
+ quoted = "\n".join(f"| {line}" for line in safe.split("\n"))
1987
+ framed = f"{_MODEL_OUTPUT_BEGIN}\n{quoted}\n{_MODEL_OUTPUT_END}\n"
1988
+ encoding = getattr(stdout, "encoding", None) or "utf-8"
1989
+ try:
1990
+ framed = framed.encode(encoding, errors="backslashreplace").decode(encoding)
1991
+ except LookupError:
1992
+ framed = framed.encode("utf-8", errors="backslashreplace").decode("utf-8")
1993
+ stdout.write(framed)
1994
+ stdout.flush()
1995
+
1996
+
1997
+ def cmd_route_recommend(args: argparse.Namespace) -> int:
1998
+ service = RoutingService(args.path)
1999
+ recommendation = service.recommend(
2000
+ objective=args.objective,
2001
+ targets=tuple(args.target or ()),
2002
+ )
2003
+ _route_print(recommendation.to_dict(), json_mode=args.json)
2004
+ return 3 if recommendation.manual_handoff else 0
2005
+
2006
+
2007
+ def cmd_route_run(args: argparse.Namespace) -> int:
2008
+ service = RoutingService(args.path)
2009
+ recommendation = service.recommend(
2010
+ objective=args.objective,
2011
+ targets=tuple(args.target or ()),
2012
+ )
2013
+ public = recommendation.to_dict()
2014
+ _route_print(public, json_mode=args.json)
2015
+ if recommendation.manual_handoff:
2016
+ return 3
2017
+ try:
2018
+ prepared = service.prepare(recommendation)
2019
+ except RoutingServiceError as exc:
2020
+ _route_print({"error": {"code": exc.code}}, json_mode=args.json)
2021
+ return 1
2022
+ _route_print(prepared.to_dict(), json_mode=args.json)
2023
+ approved = approval_prompt(
2024
+ stdin=sys.stdin,
2025
+ stdout=sys.stdout,
2026
+ stdin_is_tty=sys.stdin.isatty(),
2027
+ stdout_is_tty=sys.stdout.isatty(),
2028
+ json_mode=args.json,
2029
+ assume_yes=args.yes,
2030
+ ci=bool(os.environ.get("CI")),
2031
+ )
2032
+ if not approved:
2033
+ service.decline(prepared)
2034
+ return 2
2035
+ result = service.run_approved(prepared, approval_granted=True)
2036
+ _render_model_output(result.text, stdout=sys.stdout)
2037
+ _route_print(result.to_public_dict(), json_mode=False)
2038
+ return 0
2039
+
2040
+
2041
+ def _route_terminal_action(args: argparse.Namespace, action: str) -> int:
2042
+ approved = approval_prompt(
2043
+ stdin=sys.stdin,
2044
+ stdout=sys.stdout,
2045
+ stdin_is_tty=sys.stdin.isatty(),
2046
+ stdout_is_tty=sys.stdout.isatty(),
2047
+ json_mode=args.json,
2048
+ assume_yes=args.yes,
2049
+ ci=bool(os.environ.get("CI")),
2050
+ )
2051
+ if not approved:
2052
+ return 2
2053
+ service = RoutingService(args.path)
2054
+ try:
2055
+ payload = getattr(service, action)(args.task_id, authority_granted=True)
2056
+ except RoutingServiceError as exc:
2057
+ _route_print({"error": {"code": exc.code}}, json_mode=args.json)
2058
+ return 1
2059
+ _route_print(payload, json_mode=args.json)
2060
+ return 0
2061
+
2062
+
2063
+ def cmd_route_accept(args: argparse.Namespace) -> int:
2064
+ return _route_terminal_action(args, "accept")
2065
+
2066
+
2067
+ def cmd_route_reject(args: argparse.Namespace) -> int:
2068
+ return _route_terminal_action(args, "reject")
2069
+
2070
+
2071
+ def cmd_route_cleanup(args: argparse.Namespace) -> int:
2072
+ return _route_terminal_action(args, "cleanup")
2073
+
2074
+
2075
+ def cmd_route_review(args: argparse.Namespace) -> int:
2076
+ service = RoutingService(args.path)
2077
+ try:
2078
+ prepared = service.prepare_review(args.task_id)
2079
+ except RoutingServiceError as exc:
2080
+ _route_print({"error": {"code": exc.code}}, json_mode=args.json)
2081
+ return 1
2082
+ _route_print(prepared.to_dict(), json_mode=args.json)
2083
+ approved = approval_prompt(
2084
+ stdin=sys.stdin,
2085
+ stdout=sys.stdout,
2086
+ stdin_is_tty=sys.stdin.isatty(),
2087
+ stdout_is_tty=sys.stdout.isatty(),
2088
+ json_mode=args.json,
2089
+ assume_yes=args.yes,
2090
+ ci=bool(os.environ.get("CI")),
2091
+ )
2092
+ if not approved:
2093
+ service.decline(prepared)
2094
+ return 2
2095
+ result = service.run_review_approved(prepared, approval_granted=True)
2096
+ _render_model_output(result.text, stdout=sys.stdout)
2097
+ _route_print(result.to_public_dict(), json_mode=False)
2098
+ return 0
2099
+
2100
+
2101
+ def cmd_route_status(args: argparse.Namespace) -> int:
2102
+ _route_print(RoutingService(args.path).status(), json_mode=args.json)
2103
+ return 0
2104
+
2105
+
2106
+ def cmd_route_recoverable(args: argparse.Namespace) -> int:
2107
+ try:
2108
+ page = RoutingService(args.path).recoverable_attempts(
2109
+ limit=args.limit, after=args.after
2110
+ )
2111
+ except (StorageError, RoutingServiceError, ValueError, OSError) as exc:
2112
+ return _route_recovery_error(exc, json_mode=args.json)
2113
+ _route_print(page.to_dict(), json_mode=args.json)
2114
+ return 0
2115
+
2116
+
2117
+ def cmd_route_reconcile(args: argparse.Namespace) -> int:
2118
+ try:
2119
+ payload = RoutingService(args.path).reconcile_execution(args.attempt_id)
2120
+ except (StorageError, RoutingServiceError, ValueError, OSError) as exc:
2121
+ return _route_recovery_error(exc, json_mode=args.json)
2122
+ _route_print(payload, json_mode=args.json)
2123
+ return 0
2124
+
2125
+
2126
+ def cmd_route_policy(args: argparse.Namespace) -> int:
2127
+ authority_granted = False
2128
+ if args.promote or args.rollback:
2129
+ authority_granted = approval_prompt(
2130
+ stdin=sys.stdin,
2131
+ stdout=sys.stdout,
2132
+ stdin_is_tty=sys.stdin.isatty(),
2133
+ stdout_is_tty=sys.stdout.isatty(),
2134
+ json_mode=args.json,
2135
+ assume_yes=False,
2136
+ ci=bool(os.environ.get("CI")),
2137
+ )
2138
+ if not authority_granted:
2139
+ return 2
2140
+ try:
2141
+ payload = RoutingService(args.path).policy(
2142
+ promote=args.promote,
2143
+ rollback=args.rollback,
2144
+ authority_granted=authority_granted,
2145
+ )
2146
+ except (StorageError, RoutingServiceError, ValueError, OSError) as exc:
2147
+ return _route_recovery_error(exc, json_mode=args.json)
2148
+ _route_print(payload, json_mode=args.json)
2149
+ return 0
2150
+
2151
+
2152
+ def cmd_route_record_outcome(args: argparse.Namespace) -> int:
2153
+ if args.provenance in {"machine_verified", "ci_imported"} and not args.evidence_file:
2154
+ print("[graphite] supported evidence import required", file=sys.stderr)
2155
+ return 6
2156
+ payload = RoutingService(args.path).record_outcome(
2157
+ execution_id=args.execution_id,
2158
+ provenance=args.provenance,
2159
+ accepted=args.accepted,
2160
+ evidence_file=args.evidence_file,
2161
+ )
2162
+ _route_print(payload, json_mode=args.json)
2163
+ return 0
2164
+
2165
+
2166
+ def _lifecycle_result(args: argparse.Namespace, operation: str, **kwargs: Any) -> int:
2167
+ try:
2168
+ payload = getattr(LifecycleOperator(args.path), operation)(**kwargs)
2169
+ except (LifecycleOperatorError, ValueError, OSError) as exc:
2170
+ code = getattr(exc, "code", "lifecycle_operator_invalid")
2171
+ _route_print({"error": {"code": code}}, json_mode=args.json)
2172
+ return 1
2173
+ _route_print(payload, json_mode=args.json)
2174
+ return 0
2175
+
2176
+
2177
+ def cmd_lifecycle_list(args: argparse.Namespace) -> int:
2178
+ return _lifecycle_result(args, "list_observations", limit=args.limit)
2179
+
2180
+
2181
+ def cmd_lifecycle_status(args: argparse.Namespace) -> int:
2182
+ return _lifecycle_result(args, "status", boundary_digest=args.boundary_digest)
2183
+
2184
+
2185
+ def cmd_lifecycle_history(args: argparse.Namespace) -> int:
2186
+ return _lifecycle_result(
2187
+ args, "history", boundary_digest=args.boundary_digest, limit=args.limit
2188
+ )
2189
+
2190
+
2191
+ def cmd_lifecycle_policy_inspect(args: argparse.Namespace) -> int:
2192
+ return _lifecycle_result(
2193
+ args, "inspect_policy", boundary_digest=args.boundary_digest
2194
+ )
2195
+
2196
+
2197
+ def cmd_lifecycle_policy_prepare(args: argparse.Namespace) -> int:
2198
+ return _lifecycle_result(
2199
+ args,
2200
+ "prepare_policy_promotion",
2201
+ boundary_digest=args.boundary_digest,
2202
+ lifecycle_identity_digest=args.lifecycle_identity_digest,
2203
+ proposed_policy_version=args.proposed_policy_version,
2204
+ minimum_version=args.minimum_version,
2205
+ maximum_version_exclusive=args.maximum_version_exclusive,
2206
+ required_capabilities=tuple(args.required_capability),
2207
+ prepared_at=args.prepared_at,
2208
+ )
2209
+
2210
+
2211
+ def cmd_lifecycle_verification_prepare(args: argparse.Namespace) -> int:
2212
+ return _lifecycle_result(
2213
+ args,
2214
+ "prepare_verification_manifest",
2215
+ boundary_digest=args.boundary_digest,
2216
+ lifecycle_identity_digest=args.lifecycle_identity_digest,
2217
+ requested_model=args.requested_model,
2218
+ expected_effective_model=args.expected_effective_model,
2219
+ effort=Effort(args.effort),
2220
+ max_input_tokens=args.max_input_tokens,
2221
+ max_output_tokens=args.max_output_tokens,
2222
+ timeout_seconds=args.timeout_seconds,
2223
+ expires_at=args.expires_at,
2224
+ fixture_repository_commit=args.fixture_repository_commit,
2225
+ graph_fingerprint=args.graph_fingerprint,
2226
+ prompt_contract_hash=args.prompt_contract_hash,
2227
+ response_contract_hash=args.response_contract_hash,
2228
+ max_cost_microunits=args.max_cost_microunits,
2229
+ )
2230
+
2231
+
2232
+ def _force_utf8_when_redirected() -> None:
2233
+ """Emit UTF-8 whenever output is not a terminal.
2234
+
2235
+ Attached to a real console, Python writes Unicode through the OS console
2236
+ API and renders correctly -- reconfiguring there would actively break it.
2237
+ Redirected to a pipe or file it falls back to the host ANSI codepage
2238
+ (cp1252 on this machine), so an em-dash lands as the single byte 0x97 and
2239
+ any UTF-8 consumer sees corruption. That consumer is typically an agent
2240
+ capturing stdout, and the string most often hit is the INCONCLUSIVE trust
2241
+ marker -- the one signal the answer contract exists to deliver (#17).
2242
+
2243
+ Best-effort: a captured or already-UTF-8 stream is left alone.
2244
+ """
2245
+ for stream in (sys.stdout, sys.stderr):
2246
+ try:
2247
+ if stream is None or stream.isatty():
2248
+ continue
2249
+ if (getattr(stream, "encoding", "") or "").lower().replace("-", "") == "utf8":
2250
+ continue
2251
+ stream.reconfigure(encoding="utf-8", errors="backslashreplace")
2252
+ except Exception:
2253
+ # Never let output plumbing break a command.
2254
+ pass
2255
+
2256
+
2257
+ #: The PyPI DISTRIBUTION name, which is not the import package name. Pinned
2258
+ #: against `pyproject.toml` by test, because the only consumer of it swallows a
2259
+ #: lookup failure: a stale name here would not raise, it would silently retire
2260
+ #: the shadowed-import check below.
2261
+ _DISTRIBUTION_NAME = "graphite-code"
2262
+
2263
+
2264
+ def _version_report() -> str:
2265
+ """Identity of the ENGINE, not merely the packaged version string.
2266
+
2267
+ Every consumer here runs graphite from an editable install, so
2268
+ `importlib.metadata.version` is frozen at whatever `pyproject.toml` said when
2269
+ the install happened and never moves when the source does. Measured: the
2270
+ dist-info directory is named `graphite-0.1.0.dist-info` and its METADATA was
2271
+ last written 2026-07-24. Reading the version from there reports the same
2272
+ string from every repo on the machine and discriminates nothing -- which is
2273
+ why "what version is that consumer on" had no answer from inside graphite,
2274
+ and why a hand-maintained per-repo list went stale silently.
2275
+
2276
+ So the version reported here is `graphite.__version__`, read from the source
2277
+ tree, because under an editable install the source tree IS the deployment.
2278
+ Bumping `pyproject.toml` alone would reach nobody until eight consumers
2279
+ reinstall, which nothing in the workflow ever does.
2280
+
2281
+ That makes the version a COARSE, HAND-MAINTAINED release label. It is not
2282
+ evidence that any particular fix is present -- the same trap as `DOC_VERSION`,
2283
+ where the `-P` agent-hook fix shipped with no bump at all. To answer "does
2284
+ this consumer have fix X", survey for the marker X introduced, or compare
2285
+ fingerprints.
2286
+
2287
+ The fingerprint is what carries the information: a digest over the engine's
2288
+ own source files, so two repos agreeing on it are provably running identical
2289
+ code. That is what makes this a survey rather than a banner.
2290
+
2291
+ The converse does NOT hold, and reading it as if it did is the trap. The
2292
+ digest is over raw bytes, and line endings are bytes: `.gitattributes`
2293
+ normalizes to LF on commit, so a working tree holding CRLF fingerprints
2294
+ differently from a fresh checkout of the very same commit. Measured here --
2295
+ 13 of 106 engine files are CRLF in this tree, and a detached worktree at HEAD
2296
+ fingerprints `98c7ed69...` against this tree's `d484e148...`. Equality proves
2297
+ sameness; inequality means "look closer", not "different code".
2298
+
2299
+ Never raises, deliberately. A diagnostic that dies tells you less than one
2300
+ that names the field it could not fill -- and a survey silently missing its
2301
+ discriminating field would read as "every repo agrees", which is the exact
2302
+ false conclusion this exists to prevent.
2303
+ """
2304
+ import graphite
2305
+ from importlib import metadata
2306
+
2307
+ source = graphite.__version__
2308
+
2309
+ lines = [f"graphite {source}"]
2310
+ try:
2311
+ identity = engine_identity(Config().cache_version)
2312
+ except Exception as exc: # noqa: BLE001 - see "never raises" above
2313
+ code = getattr(exc, "code", type(exc).__name__)
2314
+ lines.append(f"engine-fingerprint unavailable: {code}")
2315
+ else:
2316
+ lines.append(f"engine-fingerprint {identity['fingerprint']}")
2317
+ lines.append(f"cache-version {identity['cache_version']}")
2318
+ lines.append(f"engine-schema {identity['schema_version']}")
2319
+
2320
+ # The install-time string is reported only when it DISAGREES, and then as a
2321
+ # fault rather than as an alternative version. Under an editable install a
2322
+ # disagreement is routine (the dist-info METADATA is written once and never
2323
+ # again), but it is also exactly what a shadowing `graphite` on `sys.path`
2324
+ # looks like: the import resolved somewhere the installed distribution does
2325
+ # not describe. Printing the source version and silently swallowing the
2326
+ # mismatch would make a hijacked import indistinguishable from a healthy one.
2327
+ #
2328
+ # A lookup that FAILS is its own state, reported as such. Swallowing it and
2329
+ # reporting nothing is worse than reporting less: the mismatch branch goes
2330
+ # unreachable, and the report gets shorter and cleaner at the exact moment
2331
+ # it stops checking anything. Measured when `graphite-code` shipped -- the
2332
+ # dist-info still said `graphite`, so the lookup raised
2333
+ # `PackageNotFoundError`, and `--version` went from printing a staleness
2334
+ # warning to printing none at all. Nothing failed, and the output read
2335
+ # healthier than before. Naming the unresolvable case is what keeps a dead
2336
+ # check distinguishable from a healthy install.
2337
+ try:
2338
+ packaged: str | None = metadata.version(_DISTRIBUTION_NAME)
2339
+ except Exception: # noqa: BLE001 - see "never raises" above
2340
+ packaged = None
2341
+ if packaged is None:
2342
+ lines.append(
2343
+ f"install-unverified no dist-info for {_DISTRIBUTION_NAME!r}; "
2344
+ "the shadowed-import check did not run (reinstall to re-enable)"
2345
+ )
2346
+ elif packaged != source:
2347
+ lines.append(f"stale-install dist-info records {packaged}; reinstall to refresh")
2348
+ return "\n".join(lines)
2349
+
2350
+
2351
+ def main(argv: list[str] | None = None) -> int:
2352
+ _force_utf8_when_redirected()
2353
+ parser = argparse.ArgumentParser(prog="graphite", description="Local-first code knowledge graph.")
2354
+ parser.add_argument(
2355
+ "--version",
2356
+ action="store_true",
2357
+ help="Print engine identity (version, fingerprint, cache version) and exit",
2358
+ )
2359
+ parser.add_argument("--output-dir", default=None, help="Output directory (default: graph-out)")
2360
+ parser.add_argument("--cache-dir", default=None, help="Cache directory (default: .cache/graphite)")
2361
+ parser.add_argument("--workers", type=int, default=None, help="Parallel workers")
2362
+ parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging")
2363
+ parser.add_argument("--typescript-resolver", choices=["auto", "compiler", "heuristic", "disabled"], default=None, help="TypeScript resolver mode (default: auto)")
2364
+ parser.add_argument("--typescript-resolver-timeout", type=float, default=None, help="TypeScript compiler resolver timeout in seconds")
2365
+ parser.add_argument("--no-typescript-symbol-references", action="store_true", help="Disable TypeScript compiler symbol-reference edges")
2366
+ parser.add_argument("--llm", choices=["none", "auto", "local", "cloud"], default=None, help="Optional integration mode; canonical graph commands accept only none")
2367
+ parser.add_argument("--llm-provider", default=None, help="Provider for explicit doctor/overlay operations; rejected by canonical commands")
2368
+ parser.add_argument("--llm-model", default=None, help="Model for explicit doctor/overlay operations")
2369
+ parser.add_argument("--llm-base-url", default=None, help="Provider base URL for explicit doctor/overlay operations")
2370
+ parser.add_argument("--llm-api-key", default=None, help="Provider key for explicit doctor/overlay operations; prefer a session-scoped environment value")
2371
+ parser.add_argument("--llm-timeout", type=float, default=None, help="Provider timeout for explicit doctor/overlay operations")
2372
+ parser.add_argument("--llm-max-input-chars", type=int, default=None, help="Maximum explicit overlay input characters")
2373
+ parser.add_argument("--llm-max-output-tokens", type=int, default=None, help="Maximum explicit overlay output tokens")
2374
+
2375
+ sub = parser.add_subparsers(dest="command")
2376
+
2377
+ p_lifecycle = sub.add_parser(
2378
+ "lifecycle", help="Inspect provider lifecycle authority and prepare bounded candidates"
2379
+ )
2380
+ lifecycle_sub = p_lifecycle.add_subparsers(
2381
+ dest="lifecycle_command", required=True
2382
+ )
2383
+
2384
+ p_lifecycle_list = lifecycle_sub.add_parser(
2385
+ "list", help="List bounded current lifecycle observations"
2386
+ )
2387
+ p_lifecycle_list.add_argument("path", help="Repository path")
2388
+ p_lifecycle_list.add_argument("--limit", type=int, default=50)
2389
+ p_lifecycle_list.add_argument("--json", action="store_true")
2390
+ p_lifecycle_list.set_defaults(func=cmd_lifecycle_list)
2391
+
2392
+ for name, handler in (
2393
+ ("status", cmd_lifecycle_status),
2394
+ ("history", cmd_lifecycle_history),
2395
+ ):
2396
+ lifecycle_read = lifecycle_sub.add_parser(
2397
+ name, help=f"Read lifecycle {name}"
2398
+ )
2399
+ lifecycle_read.add_argument("path", help="Repository path")
2400
+ lifecycle_read.add_argument("--boundary-digest", required=True)
2401
+ if name == "history":
2402
+ lifecycle_read.add_argument("--limit", type=int, default=50)
2403
+ lifecycle_read.add_argument("--json", action="store_true")
2404
+ lifecycle_read.set_defaults(func=handler)
2405
+
2406
+ p_lifecycle_policy = lifecycle_sub.add_parser(
2407
+ "policy", help="Inspect policy binding or prepare a non-activating promotion"
2408
+ )
2409
+ lifecycle_policy_sub = p_lifecycle_policy.add_subparsers(
2410
+ dest="lifecycle_policy_command", required=True
2411
+ )
2412
+ p_lifecycle_policy_inspect = lifecycle_policy_sub.add_parser(
2413
+ "inspect", help="Inspect persisted policy binding"
2414
+ )
2415
+ p_lifecycle_policy_inspect.add_argument("path", help="Repository path")
2416
+ p_lifecycle_policy_inspect.add_argument("--boundary-digest", required=True)
2417
+ p_lifecycle_policy_inspect.add_argument("--json", action="store_true")
2418
+ p_lifecycle_policy_inspect.set_defaults(func=cmd_lifecycle_policy_inspect)
2419
+
2420
+ p_lifecycle_policy_prepare = lifecycle_policy_sub.add_parser(
2421
+ "prepare", help="Prepare a policy promotion candidate without activating it"
2422
+ )
2423
+ p_lifecycle_policy_prepare.add_argument("path", help="Repository path")
2424
+ p_lifecycle_policy_prepare.add_argument("--boundary-digest", required=True)
2425
+ p_lifecycle_policy_prepare.add_argument(
2426
+ "--lifecycle-identity-digest", required=True
2427
+ )
2428
+ p_lifecycle_policy_prepare.add_argument(
2429
+ "--proposed-policy-version", required=True
2430
+ )
2431
+ p_lifecycle_policy_prepare.add_argument("--minimum-version", required=True)
2432
+ p_lifecycle_policy_prepare.add_argument(
2433
+ "--maximum-version-exclusive", required=True
2434
+ )
2435
+ p_lifecycle_policy_prepare.add_argument(
2436
+ "--required-capability", action="append", required=True
2437
+ )
2438
+ p_lifecycle_policy_prepare.add_argument("--prepared-at", type=int, required=True)
2439
+ p_lifecycle_policy_prepare.add_argument("--json", action="store_true")
2440
+ p_lifecycle_policy_prepare.set_defaults(func=cmd_lifecycle_policy_prepare)
2441
+
2442
+ p_lifecycle_verification = lifecycle_sub.add_parser(
2443
+ "verification", help="Prepare an exact verification manifest"
2444
+ )
2445
+ lifecycle_verification_sub = p_lifecycle_verification.add_subparsers(
2446
+ dest="lifecycle_verification_command", required=True
2447
+ )
2448
+ p_lifecycle_verification_prepare = lifecycle_verification_sub.add_parser(
2449
+ "prepare", help="Prepare a manifest without invoking a provider"
2450
+ )
2451
+ p_lifecycle_verification_prepare.add_argument("path", help="Repository path")
2452
+ p_lifecycle_verification_prepare.add_argument("--boundary-digest", required=True)
2453
+ p_lifecycle_verification_prepare.add_argument(
2454
+ "--lifecycle-identity-digest", required=True
2455
+ )
2456
+ p_lifecycle_verification_prepare.add_argument("--requested-model", required=True)
2457
+ p_lifecycle_verification_prepare.add_argument(
2458
+ "--expected-effective-model", required=True
2459
+ )
2460
+ p_lifecycle_verification_prepare.add_argument(
2461
+ "--effort", choices=[value.value for value in Effort], required=True
2462
+ )
2463
+ for option in (
2464
+ "max-input-tokens", "max-output-tokens", "timeout-seconds", "expires-at"
2465
+ ):
2466
+ p_lifecycle_verification_prepare.add_argument(
2467
+ f"--{option}", type=int, required=True
2468
+ )
2469
+ p_lifecycle_verification_prepare.add_argument(
2470
+ "--fixture-repository-commit", required=True
2471
+ )
2472
+ p_lifecycle_verification_prepare.add_argument("--graph-fingerprint", required=True)
2473
+ p_lifecycle_verification_prepare.add_argument(
2474
+ "--prompt-contract-hash", required=True
2475
+ )
2476
+ p_lifecycle_verification_prepare.add_argument(
2477
+ "--response-contract-hash", required=True
2478
+ )
2479
+ p_lifecycle_verification_prepare.add_argument(
2480
+ "--max-cost-microunits", type=int, default=None
2481
+ )
2482
+ p_lifecycle_verification_prepare.add_argument("--json", action="store_true")
2483
+ p_lifecycle_verification_prepare.set_defaults(
2484
+ func=cmd_lifecycle_verification_prepare
2485
+ )
2486
+
2487
+ p_route = sub.add_parser("route", help="Recommend or run approval-gated model routing")
2488
+ route_sub = p_route.add_subparsers(dest="route_command", required=True)
2489
+
2490
+ p_route_recommend = route_sub.add_parser("recommend", help="Compute an offline recommendation")
2491
+ p_route_recommend.add_argument("path", help="Repository path")
2492
+ p_route_recommend.add_argument("--objective", required=True, help="Bounded task objective")
2493
+ p_route_recommend.add_argument("--target", action="append", default=[], help="Repository-relative target")
2494
+ p_route_recommend.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2495
+ p_route_recommend.set_defaults(func=cmd_route_recommend)
2496
+
2497
+ p_route_run = route_sub.add_parser(
2498
+ "run", help="Prepare and run one separately approved authenticated CLI task"
2499
+ )
2500
+ p_route_run.add_argument("path", help="Repository path")
2501
+ p_route_run.add_argument("--objective", required=True, help="Bounded task objective")
2502
+ p_route_run.add_argument("--target", action="append", default=[], help="Repository-relative target")
2503
+ p_route_run.add_argument("--yes", action="store_true", help="Never grants routing consent; interactive approval is still required")
2504
+ p_route_run.add_argument("--json", action="store_true", help="Non-interactive output; execution is disabled")
2505
+ p_route_run.set_defaults(func=cmd_route_run)
2506
+
2507
+ for action, handler in (
2508
+ ("accept", cmd_route_accept),
2509
+ ("reject", cmd_route_reject),
2510
+ ("cleanup", cmd_route_cleanup),
2511
+ ):
2512
+ action_parser = route_sub.add_parser(
2513
+ action, help=f"Explicitly {action} one prepared routing task"
2514
+ )
2515
+ action_parser.add_argument("path", help="Repository path")
2516
+ action_parser.add_argument("--task-id", required=True)
2517
+ action_parser.add_argument(
2518
+ "--yes",
2519
+ action="store_true",
2520
+ help="Never grants consent; interactive approval is still required",
2521
+ )
2522
+ action_parser.add_argument(
2523
+ "--json", action="store_true", help="Non-interactive output; action is disabled"
2524
+ )
2525
+ action_parser.set_defaults(func=handler)
2526
+
2527
+ p_route_review = route_sub.add_parser(
2528
+ "review", help="Run a separately approved read-only other-provider review"
2529
+ )
2530
+ p_route_review.add_argument("path", help="Repository path")
2531
+ p_route_review.add_argument("--task-id", required=True)
2532
+ p_route_review.add_argument(
2533
+ "--yes",
2534
+ action="store_true",
2535
+ help="Never grants consent; interactive approval is still required",
2536
+ )
2537
+ p_route_review.add_argument(
2538
+ "--json", action="store_true", help="Non-interactive output; review is disabled"
2539
+ )
2540
+ p_route_review.set_defaults(func=cmd_route_review)
2541
+
2542
+ p_route_outcome = route_sub.add_parser("record-outcome", help="Append supported outcome evidence")
2543
+ p_route_outcome.add_argument("path", help="Repository path")
2544
+ p_route_outcome.add_argument("--execution-id", required=True)
2545
+ p_route_outcome.add_argument(
2546
+ "--provenance", required=True,
2547
+ choices=["machine_verified", "ci_imported", "human", "pairwise", "reversion", "ambiguous"],
2548
+ )
2549
+ p_route_outcome.add_argument("--accepted", action="store_true")
2550
+ p_route_outcome.add_argument("--evidence-file", default=None)
2551
+ p_route_outcome.add_argument("--json", action="store_true")
2552
+ p_route_outcome.set_defaults(func=cmd_route_record_outcome)
2553
+
2554
+ p_route_status = route_sub.add_parser("status", help="Read local routing readiness")
2555
+ p_route_status.add_argument("path", help="Repository path")
2556
+ p_route_status.add_argument("--json", action="store_true")
2557
+ p_route_status.set_defaults(func=cmd_route_status)
2558
+
2559
+ p_route_recoverable = route_sub.add_parser(
2560
+ "recoverable", help="List staged execution attempts eligible for reconciliation"
2561
+ )
2562
+ p_route_recoverable.add_argument("path", help="Repository path")
2563
+ p_route_recoverable.add_argument(
2564
+ "--limit", type=int, default=DEFAULT_RECOVERY_PAGE_SIZE,
2565
+ help="Page size from 1 to 100 (default: 50)",
2566
+ )
2567
+ p_route_recoverable.add_argument(
2568
+ "--after", default=None, help="Validated attempt ID cursor from next_cursor"
2569
+ )
2570
+ p_route_recoverable.add_argument("--json", action="store_true")
2571
+ p_route_recoverable.set_defaults(func=cmd_route_recoverable)
2572
+
2573
+ p_route_reconcile = route_sub.add_parser(
2574
+ "reconcile", help="Finalize one staged receipt without another provider call"
2575
+ )
2576
+ p_route_reconcile.add_argument("path", help="Repository path")
2577
+ p_route_reconcile.add_argument("--attempt-id", required=True)
2578
+ p_route_reconcile.add_argument("--json", action="store_true")
2579
+ p_route_reconcile.set_defaults(func=cmd_route_reconcile)
2580
+
2581
+ p_route_policy = route_sub.add_parser("policy", help="Inspect or explicitly manage recommendation policy")
2582
+ p_route_policy.add_argument("path", help="Repository path")
2583
+ p_route_policy.add_argument("--promote", default=None)
2584
+ p_route_policy.add_argument("--rollback", default=None)
2585
+ p_route_policy.add_argument("--json", action="store_true")
2586
+ p_route_policy.set_defaults(func=cmd_route_policy)
2587
+
2588
+ p_overlay = sub.add_parser(
2589
+ "overlay",
2590
+ help="Manage explicit non-authoritative model overlays",
2591
+ )
2592
+ overlay_sub = p_overlay.add_subparsers(dest="overlay_command", required=True)
2593
+ p_overlay_build = overlay_sub.add_parser(
2594
+ "build",
2595
+ help="Build one identity-bound overlay from a fresh canonical graph",
2596
+ )
2597
+ p_overlay_build.add_argument("path", help="Repository path")
2598
+ p_overlay_build.add_argument(
2599
+ "--provider-identity-digest",
2600
+ required=True,
2601
+ help="Exact current provider lifecycle identity SHA-256",
2602
+ )
2603
+ p_overlay_build.add_argument(
2604
+ "--model-identity-digest",
2605
+ required=True,
2606
+ help="Exact current model identity SHA-256",
2607
+ )
2608
+ p_overlay_build.add_argument(
2609
+ "--routing-policy-digest",
2610
+ default=None,
2611
+ help="Exact OpenRouter routing-policy SHA-256",
2612
+ )
2613
+ p_overlay_build.add_argument(
2614
+ "--json", action="store_true", help="Emit sanitized machine-readable output"
2615
+ )
2616
+ p_overlay_build.set_defaults(func=cmd_overlay_build)
2617
+
2618
+ p_scan = sub.add_parser("scan", help="Scan files and write manifest")
2619
+ p_scan.add_argument("path", help="Repository path")
2620
+ p_scan.set_defaults(func=cmd_scan)
2621
+
2622
+ p_build = sub.add_parser("build", help="Scan + extract + build graph + report")
2623
+ p_build.add_argument("path", help="Repository path")
2624
+ p_build.add_argument(
2625
+ "--detach",
2626
+ action="store_true",
2627
+ help="Start the build as a detached background process and return immediately",
2628
+ )
2629
+ p_build.set_defaults(func=cmd_build)
2630
+
2631
+ p_report = sub.add_parser("report", help="Alias for build")
2632
+ p_report.add_argument("path", help="Repository path")
2633
+ p_report.set_defaults(func=cmd_report)
2634
+
2635
+ p_check = sub.add_parser("check", help="Check whether graph-out is stale")
2636
+ p_check.add_argument("path", help="Repository path")
2637
+ p_check.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2638
+ p_check.add_argument(
2639
+ "--ignore-engine",
2640
+ action="store_true",
2641
+ help="Report source drift only; treat graphite engine updates as fresh",
2642
+ )
2643
+ p_check.set_defaults(func=cmd_check)
2644
+
2645
+ p_debt = sub.add_parser(
2646
+ "debt",
2647
+ help="Declared blind spots and how long they have been open",
2648
+ description=(
2649
+ "Report declared blind spots with their age, and retired ones with "
2650
+ "their declaration-to-fix latency. Counts are not the target: a "
2651
+ "DECLARED unfixed blind spot is the contract working, an undeclared "
2652
+ "one is the failure this measures."
2653
+ ),
2654
+ )
2655
+ p_debt.add_argument("--json", action="store_true", help="Machine-readable report")
2656
+ p_debt.add_argument(
2657
+ "--as-of",
2658
+ default=None,
2659
+ metavar="YYYY-MM-DD",
2660
+ help="Compute ages against this date instead of today (keeps output reproducible)",
2661
+ )
2662
+ p_debt.set_defaults(func=cmd_debt)
2663
+
2664
+ p_doctor = sub.add_parser(
2665
+ "doctor",
2666
+ help="Check Graphite core and optional integration readiness",
2667
+ description="Check Graphite core and optional integration readiness",
2668
+ )
2669
+ p_doctor.add_argument(
2670
+ "path", nargs="?", default=".", help="Project path (default: current directory)"
2671
+ )
2672
+ p_doctor.add_argument(
2673
+ "--daemon-base", default=None, help="Daemon base folder (default: auto-detect)"
2674
+ )
2675
+ p_doctor.add_argument(
2676
+ "--deep", action="store_true", help="Run bounded functional probes in temporary storage"
2677
+ )
2678
+ p_doctor.add_argument(
2679
+ "--include-llm",
2680
+ action="store_true",
2681
+ help="With --deep, run one synthetic LLM connectivity probe",
2682
+ )
2683
+ p_doctor.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2684
+ p_doctor.set_defaults(func=cmd_doctor)
2685
+
2686
+ p_incidents = sub.add_parser("incidents", help="List and triage recorded incidents")
2687
+ incidents_sub = p_incidents.add_subparsers(dest="incidents_cmd", required=True)
2688
+ p_inc_list = incidents_sub.add_parser("list", help="Folded incident views (open+acked by default)")
2689
+ p_inc_list.add_argument("path", nargs="?", default=".")
2690
+ p_inc_list.add_argument("--json", action="store_true")
2691
+ p_inc_list.add_argument("--all", action="store_true", help="Include resolved incidents")
2692
+ p_inc_list.add_argument("--global", dest="global_ledger", action="store_true", help="Read the daemon-global ledger")
2693
+ p_inc_list.add_argument("--daemon-base", default=None)
2694
+ p_inc_list.add_argument("--state-dir", default=None, help="Daemon state directory (default: <base>/.graphite-daemon)")
2695
+ p_inc_list.set_defaults(func=cmd_incidents_list)
2696
+ for name, handler in (("ack", cmd_incidents_ack), ("resolve", cmd_incidents_resolve)):
2697
+ p_life = incidents_sub.add_parser(name, help=f"{name} an incident by fingerprint")
2698
+ p_life.add_argument("fingerprint")
2699
+ p_life.add_argument("path", nargs="?", default=".")
2700
+ p_life.add_argument("-m", "--message", default=None)
2701
+ p_life.add_argument("--global", dest="global_ledger", action="store_true")
2702
+ p_life.add_argument("--daemon-base", default=None)
2703
+ p_life.add_argument("--state-dir", default=None, help="Daemon state directory (default: <base>/.graphite-daemon)")
2704
+ p_life.set_defaults(func=handler)
2705
+
2706
+
2707
+ p_init = sub.add_parser("init", aliases=["Init"], help="Initialize Graphite instructions for AI coding platforms")
2708
+ p_init.add_argument("path", nargs="?", default=".", help="Project path (default: current directory)")
2709
+ p_init.add_argument("--platform", action="append", default=[], help="Platform to configure: codex, claude, antigravity, visual-studio, cursor, windsurf, or all. Can be repeated or comma-separated.")
2710
+ p_init.add_argument("--all", action="store_true", help="Configure every supported platform")
2711
+ p_init.add_argument("--yes", action="store_true", help="Use default platforms and suppress optional dependency prompts")
2712
+ p_init.add_argument("--daemon-base", default=None, help="Daemon base folder for visibility check; defaults to $GRAPHITE_PROJECTS_ROOT, else the current directory")
2713
+ p_init.add_argument("--no-build", action="store_true", help="Only update instruction files; do not build graph")
2714
+ p_init.add_argument("--no-validate", action="store_true", help="Skip graph validation after init")
2715
+ p_init.add_argument("--list-platforms", action="store_true", help="Print supported platform keys and exit")
2716
+ p_init.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2717
+ p_init.add_argument("--adopt", action="store_true", help="Bring legacy unversioned instruction docs under management by appending the managed block; existing content is preserved, never overwritten")
2718
+ p_init_mode = p_init.add_mutually_exclusive_group()
2719
+ p_init_mode.add_argument("--strict", action="store_true", help="Write strict-mode graphite-first hook wiring (denies provable relationship greps)")
2720
+ p_init_mode.add_argument("--remind", action="store_true", help="Write remind-mode hook wiring (non-blocking reminders; default for first-time wiring)")
2721
+ p_init.add_argument("--no-agent-hooks", action="store_true", help="Skip Claude Code hook wiring in .claude/settings.json")
2722
+ p_init.add_argument("--no-hooks", action="store_true", help="Skip git hook installation (post-commit/post-merge/post-rewrite trampolines)")
2723
+ p_init.set_defaults(func=cmd_init)
2724
+
2725
+ p_hooks = sub.add_parser("hooks", help="Manage graphite's git-hook trampolines")
2726
+ p_hooks.add_argument(
2727
+ "--install-template",
2728
+ action="store_true",
2729
+ help=(
2730
+ "Write graphite's trigger shims into a git init.templateDir layout "
2731
+ "(default: <projects-root>/.graphite-hooks-template) so future "
2732
+ "`git init`/`git clone` calls on this machine self-arm. Prints the "
2733
+ "`git config --global init.templateDir` command to run by hand; "
2734
+ "never runs it automatically."
2735
+ ),
2736
+ )
2737
+ p_hooks.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2738
+ p_hooks.set_defaults(func=cmd_hooks)
2739
+
2740
+ p_bootstrap = sub.add_parser("bootstrap", help="Make a project Graphite-ready and optionally build its graph")
2741
+ p_bootstrap.add_argument("path", help="Project path")
2742
+ p_bootstrap.add_argument("--daemon-base", default=None, help="Daemon base folder for visibility check; defaults to $GRAPHITE_PROJECTS_ROOT, else the current directory")
2743
+ p_bootstrap.add_argument("--no-build", action="store_true", help="Only update project workflow files; do not build graph")
2744
+ p_bootstrap.add_argument("--no-validate", action="store_true", help="Skip graph validation after bootstrap")
2745
+ p_bootstrap.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2746
+ p_bootstrap.add_argument("--yes", action="store_true", help="Run non-interactively and never offer dependency installation")
2747
+ p_bootstrap.set_defaults(func=cmd_bootstrap)
2748
+
2749
+ p_audit_replacement = sub.add_parser("audit-replacement", help="Audit whether Graphite is ready to replace Graphify in a project")
2750
+ p_audit_replacement.add_argument("path", help="Project path")
2751
+ p_audit_replacement.add_argument("--daemon-base", default=None, help="Daemon base folder for daemon and health checks")
2752
+ p_audit_replacement.add_argument("--fail-on-blocker", action="store_true", help="Return non-zero when replacement blockers are found")
2753
+ p_audit_replacement.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2754
+ p_audit_replacement.set_defaults(func=cmd_audit_replacement)
2755
+
2756
+ p_validate = sub.add_parser("validate", help="Validate graph.json integrity")
2757
+ p_validate.add_argument("--graph-json", default="graph-out/graph.json", help="Path to graph.json")
2758
+ p_validate.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2759
+ p_validate.set_defaults(func=cmd_validate)
2760
+ verb_lines = []
2761
+ for verb in verb_catalog():
2762
+ arguments = f" {verb['arguments']}" if verb["arguments"] else ""
2763
+ aliases = f" (aliases: {', '.join(verb['aliases'])})" if verb["aliases"] else ""
2764
+ verb_lines.append(f" {verb['name']}{arguments}{aliases}: {verb['description']}")
2765
+ p_query = sub.add_parser(
2766
+ "query",
2767
+ help="Query an existing graph.json",
2768
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2769
+ epilog="supported verbs:\n"
2770
+ + "\n".join(verb_lines)
2771
+ + '\n\nfor free-text lookup use: graphite search "<symbol, path, or concept>"'
2772
+ + '\nfor questions use: graphite query --natural "who calls X"'
2773
+ + " (fixed deterministic grammar; list it via graphite capabilities --json)",
2774
+ )
2775
+ p_query.add_argument("query", help="Query string, e.g. 'depends-on db.ts'")
2776
+ p_query.add_argument("--graph-json", default="graph-out/graph.json", help="Path to graph.json")
2777
+ p_query.add_argument(
2778
+ "--show-plan", action="store_true",
2779
+ help="Include the canonical query plan in the JSON output",
2780
+ )
2781
+ p_query.add_argument(
2782
+ "--plan-only", action="store_true",
2783
+ help="Validate and print the query plan without loading the graph or executing",
2784
+ )
2785
+ p_query.add_argument(
2786
+ "--natural", action="store_true",
2787
+ help="Interpret the query as a question via a fixed deterministic grammar (no inference; see capabilities)",
2788
+ )
2789
+ p_query.set_defaults(func=cmd_query)
2790
+
2791
+ p_search = sub.add_parser("search", help="Deterministic ranked node search by symbol, path, or concept")
2792
+ p_search.add_argument("text", help="Symbol, path, or concept to search for")
2793
+ p_search.add_argument("--graph-json", default="graph-out/graph.json", help="Path to graph.json")
2794
+ p_search.add_argument("--limit", type=int, default=DEFAULT_SEARCH_LIMIT, help="Maximum results")
2795
+ p_search.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2796
+ p_search.set_defaults(func=cmd_search)
2797
+
2798
+ p_capabilities = sub.add_parser("capabilities", help="List supported operations, query verbs, and limits")
2799
+ p_capabilities.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2800
+ p_capabilities.set_defaults(func=cmd_capabilities)
2801
+
2802
+ p_channel = sub.add_parser(
2803
+ "channel",
2804
+ help="Print the path of the shared agent channel (the one repo-isolation exception)",
2805
+ )
2806
+ p_channel.add_argument(
2807
+ "action",
2808
+ nargs="?",
2809
+ choices=["report", "list", "show", "register"],
2810
+ default=None,
2811
+ help=(
2812
+ "report: audited view of the whole channel; list: rounds; "
2813
+ "show: one round's body; register: bind a repo to an agent identity"
2814
+ ),
2815
+ )
2816
+ p_channel.add_argument(
2817
+ "target",
2818
+ nargs="?",
2819
+ default=None,
2820
+ help="Round number for `show`, or repository path for `register`",
2821
+ )
2822
+ p_channel.add_argument("agent", nargs="?", default=None, help="Agent id for `register`")
2823
+ p_channel.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2824
+ p_channel.set_defaults(func=cmd_channel)
2825
+
2826
+ p_activate = sub.add_parser(
2827
+ "activate",
2828
+ help="Mark a repository as open in a coding agent so the daemon supervises it",
2829
+ )
2830
+ p_activate.add_argument("path", nargs="?", default=".")
2831
+ p_activate.add_argument("--agent", default="editor", help="Agent or editor name recorded in the marker")
2832
+ p_activate.set_defaults(func=cmd_activate)
2833
+
2834
+ p_agent_hook = sub.add_parser(
2835
+ "agent-hook",
2836
+ help="Claude Code hook endpoint for graphite-first enforcement (reads hook JSON on stdin; always exits 0)",
2837
+ )
2838
+ p_agent_hook.add_argument(
2839
+ "event",
2840
+ help="Hook event to handle (known: session-start, pre-tool-use, stop; unknown events no-op)",
2841
+ )
2842
+ p_agent_hook.add_argument("--mode", choices=["remind", "strict"], default="remind", help="pre-tool-use enforcement mode")
2843
+ p_agent_hook.set_defaults(func=cmd_agent_hook)
2844
+
2845
+ p_savings = sub.add_parser(
2846
+ "savings",
2847
+ help="Estimated time/token savings from graphite usage in this repo (local estimates; on/off toggles the turn-end display)",
2848
+ )
2849
+ p_savings.add_argument("action", nargs="?", choices=["report", "on", "off", "status"], default="report", help="report (default), or toggle the turn-end display")
2850
+ p_savings.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2851
+ p_savings.set_defaults(func=cmd_savings)
2852
+
2853
+ p_impact = sub.add_parser("impact", help="Suggest impacted files and tests for changed files")
2854
+ p_impact.add_argument("files", nargs="+", help="Changed file paths or graph node fragments")
2855
+ p_impact.add_argument("--graph-json", default="graph-out/graph.json", help="Path to graph.json")
2856
+ p_impact.add_argument("--depth", type=int, default=2, help="Reverse dependency traversal depth")
2857
+ p_impact.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2858
+ p_impact.set_defaults(func=cmd_impact)
2859
+
2860
+ p_review = sub.add_parser(
2861
+ "review-changes",
2862
+ help="Produce deterministic review evidence and acceptance criteria",
2863
+ description="Produce deterministic review evidence and acceptance criteria",
2864
+ )
2865
+ p_review.add_argument(
2866
+ "path", nargs="?", default=".", help="Project path (default: current directory)"
2867
+ )
2868
+ p_review.add_argument(
2869
+ "files",
2870
+ nargs="*",
2871
+ help="Changed paths; omit to discover changes from Git",
2872
+ )
2873
+ p_review.add_argument(
2874
+ "--graph-json",
2875
+ default=None,
2876
+ help="Graph JSON path; relative to the project root",
2877
+ )
2878
+ p_review.add_argument(
2879
+ "--depth", type=_review_depth, default=2, help="Reverse dependency traversal depth"
2880
+ )
2881
+ p_review.add_argument(
2882
+ "--git-timeout",
2883
+ type=_review_git_timeout,
2884
+ default=5.0,
2885
+ help="Git discovery timeout in seconds",
2886
+ )
2887
+ p_review.add_argument(
2888
+ "--fail-on-blocker",
2889
+ action="store_true",
2890
+ help="Return non-zero when review blockers are found",
2891
+ )
2892
+ p_review.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2893
+ p_review.set_defaults(func=cmd_review_changes)
2894
+
2895
+ p_context = sub.add_parser("context", help="Print compact graph context for files or nodes")
2896
+ p_context.add_argument("files", nargs="+", help="File paths, node ids, or graph node fragments")
2897
+ p_context.add_argument("--graph-json", default="graph-out/graph.json", help="Path to graph.json")
2898
+ p_context.add_argument("--depth", type=int, default=2, help="Reverse dependency impact depth")
2899
+ p_context.add_argument("--neighbor-limit", type=int, default=20, help="Maximum direct neighbors and community peers per matched node")
2900
+ p_context.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
2901
+ p_context.set_defaults(func=cmd_context)
2902
+
2903
+ p_watch = sub.add_parser("watch", help="Watch a repo and rebuild graph-out after stable file changes")
2904
+ p_watch.add_argument("path", help="Repository path")
2905
+ p_watch.add_argument("--interval", type=float, default=1.5, help="Polling interval in seconds")
2906
+ p_watch.add_argument("--debounce", type=float, default=0.75, help="Stable-change debounce seconds")
2907
+ p_watch.add_argument("--impact", action="store_true", help="Print impacted files/tests before rebuild")
2908
+ p_watch.add_argument("--impact-depth", type=int, default=2, help="Reverse dependency impact depth")
2909
+ p_watch.add_argument("--no-initial-build", action="store_true", help="Do not build immediately on start")
2910
+ p_watch.add_argument("--once", action="store_true", help="Run initial build and one poll cycle, then exit")
2911
+ p_watch.add_argument("--max-cycles", type=int, default=None, help="Stop after this many poll cycles")
2912
+ p_watch.set_defaults(func=cmd_watch)
2913
+
2914
+ default_root = str(default_projects_root())
2915
+ p_daemon = sub.add_parser("daemon", help="Watch all discovered projects under a base folder")
2916
+ p_daemon.add_argument("base_path", nargs="?", default=default_root, help="Base folder to discover projects under")
2917
+ p_daemon.add_argument("--scan-interval", type=float, default=10.0, help="Polling interval in seconds")
2918
+ p_daemon.add_argument("--discover-interval", type=float, default=60.0, help="Project rediscovery interval in seconds")
2919
+ p_daemon.add_argument("--debounce", type=float, default=1.0, help="Stable-change debounce seconds")
2920
+ p_daemon.add_argument("--max-depth", type=int, default=6, help="Maximum discovery depth below base folder")
2921
+ p_daemon.add_argument("--max-projects", type=int, default=128, help="Maximum projects to supervise")
2922
+ p_daemon.add_argument("--max-files-per-project", type=int, default=10000, help="Maximum files to ingest per project")
2923
+ p_daemon.add_argument("--max-builds-per-cycle", type=int, default=2, help="Maximum project rebuilds per daemon cycle")
2924
+ p_daemon.add_argument("--build-timeout", type=float, default=300.0, help="Per-project build timeout in seconds")
2925
+ p_daemon.add_argument("--state-dir", default=None, help="Daemon state directory (default: <base>/.graphite-daemon)")
2926
+ p_daemon.add_argument("--no-initial-build", action="store_true", help="Discover projects without building immediately")
2927
+ p_daemon.add_argument("--once", action="store_true", help="Run one daemon cycle and exit")
2928
+ p_daemon.add_argument("--max-cycles", type=int, default=None, help="Stop after this many daemon cycles")
2929
+ p_daemon.add_argument("--json", action="store_true", help="Emit final status as JSON")
2930
+ p_daemon.set_defaults(func=cmd_daemon)
2931
+
2932
+ p_daemon_status = sub.add_parser("daemon-status", help="Read the latest Graphite daemon status")
2933
+ p_daemon_status.add_argument("base_path", nargs="?", default=default_root, help="Base folder used by the daemon")
2934
+ p_daemon_status.add_argument("--state-dir", default=None, help="Daemon state directory (default: <base>/.graphite-daemon)")
2935
+ p_daemon_status.add_argument("--json", action="store_true", help="Emit status as JSON")
2936
+ p_daemon_status.set_defaults(func=cmd_daemon_status)
2937
+
2938
+ p_daemon_health = sub.add_parser("daemon-health", help="Run operational health checks for the Graphite daemon")
2939
+ p_daemon_health.add_argument("base_path", nargs="?", default=default_root, help="Base folder used by the daemon")
2940
+ p_daemon_health.add_argument("--state-dir", default=None, help="Daemon state directory (default: <base>/.graphite-daemon)")
2941
+ p_daemon_health.add_argument("--max-status-age", type=float, default=180.0, help="Maximum acceptable status age in seconds")
2942
+ p_daemon_health.add_argument("--max-project-success-age", type=float, default=86400.0, help="Warn when a project has not built successfully within this many seconds")
2943
+ p_daemon_health.add_argument("--startup-name", default=DEFAULT_TASK_NAME, help="Startup launcher name")
2944
+ p_daemon_health.add_argument("--no-process-check", action="store_true", help="Skip local daemon process check")
2945
+ p_daemon_health.add_argument("--no-startup-check", action="store_true", help="Skip Windows startup launcher check")
2946
+ p_daemon_health.add_argument("--fail-on-error", action="store_true", help="Return non-zero when health errors are present")
2947
+ p_daemon_health.add_argument("--json", action="store_true", help="Emit health report as JSON")
2948
+ p_daemon_health.set_defaults(func=cmd_daemon_health)
2949
+
2950
+ p_daemon_install = sub.add_parser("daemon-install-windows", help="Install the Graphite daemon as a Windows Scheduled Task")
2951
+ p_daemon_install.add_argument("base_path", nargs="?", default=default_root, help="Base folder to supervise")
2952
+ p_daemon_install.add_argument("--task-name", default=DEFAULT_TASK_NAME, help="Windows Scheduled Task name")
2953
+ p_daemon_install.add_argument("--graphite-executable", default=None, help="Python interpreter for the generated launcher (default: this one). A console script is refused: it cannot carry -P.")
2954
+ p_daemon_install.add_argument("--scan-interval", type=float, default=15.0, help="Polling interval in seconds")
2955
+ p_daemon_install.add_argument("--discover-interval", type=float, default=90.0, help="Project rediscovery interval in seconds")
2956
+ p_daemon_install.add_argument("--debounce", type=float, default=1.0, help="Stable-change debounce seconds")
2957
+ p_daemon_install.add_argument("--max-depth", type=int, default=6, help="Maximum discovery depth below base folder")
2958
+ p_daemon_install.add_argument("--max-projects", type=int, default=128, help="Maximum projects to supervise")
2959
+ p_daemon_install.add_argument("--max-builds-per-cycle", type=int, default=1, help="Maximum project rebuilds per daemon cycle")
2960
+ p_daemon_install.add_argument("--build-timeout", type=float, default=240.0, help="Per-project build timeout in seconds")
2961
+ p_daemon_install.add_argument("--start-now", action="store_true", help="Start the task immediately after installation")
2962
+ p_daemon_install.add_argument("--no-force", action="store_true", help="Do not overwrite an existing task")
2963
+ p_daemon_install.add_argument("--json", action="store_true", help="Emit installation result as JSON")
2964
+ p_daemon_install.set_defaults(func=cmd_daemon_install_windows)
2965
+
2966
+ p_daemon_task_status = sub.add_parser("daemon-task-status", help="Read the Windows Scheduled Task status for Graphite daemon")
2967
+ p_daemon_task_status.add_argument("--task-name", default=DEFAULT_TASK_NAME, help="Windows Scheduled Task name")
2968
+ p_daemon_task_status.add_argument("--json", action="store_true", help="Emit task status as JSON")
2969
+ p_daemon_task_status.set_defaults(func=cmd_daemon_task_status)
2970
+
2971
+ p_daemon_uninstall = sub.add_parser("daemon-uninstall-windows", help="Remove the Graphite daemon Windows Scheduled Task")
2972
+ p_daemon_uninstall.add_argument("--task-name", default=DEFAULT_TASK_NAME, help="Windows Scheduled Task name")
2973
+ p_daemon_uninstall.add_argument("--json", action="store_true", help="Emit removal result as JSON")
2974
+ p_daemon_uninstall.set_defaults(func=cmd_daemon_uninstall_windows)
2975
+
2976
+ p_startup_install = sub.add_parser("daemon-install-startup-windows", help="Install hidden Windows Startup-folder launcher for Graphite daemon")
2977
+ p_startup_install.add_argument("base_path", nargs="?", default=default_root, help="Base folder to supervise")
2978
+ p_startup_install.add_argument("--name", default=DEFAULT_TASK_NAME, help="Startup launcher name")
2979
+ p_startup_install.add_argument("--graphite-executable", default=None, help="Python interpreter for the generated launcher (default: this one). A console script is refused: it cannot carry -P.")
2980
+ p_startup_install.add_argument("--scan-interval", type=float, default=15.0, help="Polling interval in seconds")
2981
+ p_startup_install.add_argument("--discover-interval", type=float, default=90.0, help="Project rediscovery interval in seconds")
2982
+ p_startup_install.add_argument("--debounce", type=float, default=1.0, help="Stable-change debounce seconds")
2983
+ p_startup_install.add_argument("--max-depth", type=int, default=6, help="Maximum discovery depth below base folder")
2984
+ p_startup_install.add_argument("--max-projects", type=int, default=128, help="Maximum projects to supervise")
2985
+ p_startup_install.add_argument("--max-builds-per-cycle", type=int, default=1, help="Maximum project rebuilds per daemon cycle")
2986
+ p_startup_install.add_argument("--build-timeout", type=float, default=240.0, help="Per-project build timeout in seconds")
2987
+ p_startup_install.add_argument("--json", action="store_true", help="Emit installation result as JSON")
2988
+ p_startup_install.set_defaults(func=cmd_daemon_install_startup_windows)
2989
+
2990
+ p_startup_status = sub.add_parser("daemon-startup-status", help="Read Windows Startup-folder launcher status for Graphite daemon")
2991
+ p_startup_status.add_argument("base_path", nargs="?", default=default_root, help="Base folder supervised by the launcher")
2992
+ p_startup_status.add_argument("--name", default=DEFAULT_TASK_NAME, help="Startup launcher name")
2993
+ p_startup_status.add_argument("--json", action="store_true", help="Emit startup status as JSON")
2994
+ p_startup_status.set_defaults(func=cmd_daemon_startup_status)
2995
+
2996
+ p_startup_uninstall = sub.add_parser("daemon-uninstall-startup-windows", help="Remove hidden Windows Startup-folder launcher for Graphite daemon")
2997
+ p_startup_uninstall.add_argument("base_path", nargs="?", default=default_root, help="Base folder supervised by the launcher")
2998
+ p_startup_uninstall.add_argument("--name", default=DEFAULT_TASK_NAME, help="Startup launcher name")
2999
+ p_startup_uninstall.add_argument("--json", action="store_true", help="Emit removal result as JSON")
3000
+ p_startup_uninstall.set_defaults(func=cmd_daemon_uninstall_startup_windows)
3001
+
3002
+ args = parser.parse_args(argv)
3003
+ if getattr(args, "version", False):
3004
+ # Returns BEFORE the activation call further down, on purpose. This is
3005
+ # the one command an operator runs across every consumer repo at once;
3006
+ # activating eight repos would enrol them all into daemon supervision
3007
+ # and trigger eight rebuilds, so the survey would change what it
3008
+ # measures. Read-only questions must stay read-only.
3009
+ print(_version_report())
3010
+ return 0
3011
+ if not args.command:
3012
+ parser.print_help()
3013
+ return 1
3014
+ if args.command == "doctor" and args.include_llm and not args.deep:
3015
+ p_doctor.error("--include-llm requires --deep")
3016
+ if args.command == "daemon":
3017
+ suggested = _daemon_subcommand_suggestion(getattr(args, "base_path", None), sub.choices)
3018
+ if suggested:
3019
+ print(
3020
+ f"[graphite] error: unknown argument '{args.base_path}' -- "
3021
+ f"did you mean 'graphite {suggested}'?",
3022
+ file=sys.stderr,
3023
+ )
3024
+ return 2
3025
+ if args.command in _LLM_GATED_COMMANDS and (
3026
+ getattr(args, "llm", None) not in (None, "none")
3027
+ or any(getattr(args, name, None) is not None for name in _LEGACY_LLM_ARGUMENTS)
3028
+ ):
3029
+ print(CANONICAL_ENRICHMENT_MIGRATION_MESSAGE, file=sys.stderr)
3030
+ return 2
3031
+
3032
+ # Universal activation backstop. An agent graphite cannot hook -- Codex,
3033
+ # Gemini -- still registers its repo the moment it uses graphite at all,
3034
+ # which is what keeps coverage from depending on per-platform integrations.
3035
+ # `daemon` is excluded because a supervisor is not an editing session;
3036
+ # `agent-hook` because it marks activation itself with the real agent name.
3037
+ # Daemon-spawned builds are excluded inside mark_active via
3038
+ # GRAPHITE_DAEMON_CHILD, without which activation would never expire.
3039
+ if args.command not in _ACTIVATION_EXEMPT_COMMANDS and not _is_scratch_workspace(Path.cwd()):
3040
+ activation.mark_active(Path.cwd(), "cli")
3041
+
3042
+ try:
3043
+ return int(args.func(args) or 0)
3044
+ except Exception as e:
3045
+ print(f"[graphite] error: {e}", file=sys.stderr)
3046
+ if os.environ.get("GRAPHITE_DEBUG"):
3047
+ import traceback
3048
+ traceback.print_exc()
3049
+ return 1
3050
+
3051
+
3052
+ if __name__ == "__main__":
3053
+ raise SystemExit(main())