graphite-code 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/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())
|