witan-code 0.2.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.
witan_code/config.py ADDED
@@ -0,0 +1,65 @@
1
+ import os
2
+ import re
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ # Bundled query files, resolved relative to this file.
7
+ _QUERIES_DIR = Path(__file__).parent / "queries"
8
+ _SCHEMA_FILE = Path(__file__).parent / "schema" / "code-schema.pg"
9
+ _BRIDGE_SCHEMA_FILE = Path(__file__).parent / "schema" / "bridge-schema.pg"
10
+ _DEFAULT_CODE_DIR = Path.home() / ".local" / "share" / "witan" / "code"
11
+
12
+ # Filename of the single shared cross-repo bridge store, a sibling of the
13
+ # per-repo `<slug>.omni` stores in code_dir. Not routed through sanitize_slug
14
+ # (whose .strip("_") would eat the leading underscore); no real repo slug
15
+ # resolves to this name.
16
+ BRIDGE_STORE_NAME = "_bridge.omni"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Config:
21
+ code_dir: Path
22
+ """Directory holding per-repo code stores (one ``<slug>.omni`` each)."""
23
+
24
+ author: str
25
+ """Attribution string (carried for parity with Layer 1; unused on inserts)."""
26
+
27
+ queries_dir: Path
28
+ """Directory containing code_read.gq, code_mutations.gq, delete.gq."""
29
+
30
+ schema_file: Path
31
+ """Path to code-schema.pg, used to lazily init a per-repo store."""
32
+
33
+ bridge_schema_file: Path
34
+ """Path to bridge-schema.pg, used to lazily init the shared bridge store."""
35
+
36
+
37
+ def load() -> Config:
38
+ """Load config from environment. All variables are optional with defaults."""
39
+ return Config(
40
+ code_dir=Path(os.environ.get("WITAN_CODE_DIR", str(_DEFAULT_CODE_DIR))),
41
+ author=os.environ.get(
42
+ "WITAN_AUTHOR",
43
+ os.environ.get("USER", "unknown"),
44
+ ),
45
+ queries_dir=_QUERIES_DIR,
46
+ schema_file=_SCHEMA_FILE,
47
+ bridge_schema_file=_BRIDGE_SCHEMA_FILE,
48
+ )
49
+
50
+
51
+ def sanitize_slug(slug: str) -> str:
52
+ """Make a repo slug safe for use as a filename component."""
53
+ return re.sub(r"[/:]+", "_", slug).strip("_")
54
+
55
+
56
+ def store_path(slug: str, code_dir: Path | None = None) -> Path:
57
+ """Resolve the per-repo store path for ``slug``."""
58
+ base = code_dir or load().code_dir
59
+ return base / f"{sanitize_slug(slug)}.omni"
60
+
61
+
62
+ def bridge_store_path(code_dir: Path | None = None) -> Path:
63
+ """Resolve the shared cross-repo bridge store path."""
64
+ base = code_dir or load().code_dir
65
+ return base / BRIDGE_STORE_NAME
witan_code/context.py ADDED
@@ -0,0 +1,98 @@
1
+ """Self-contained UserPromptSubmit status block for witan-code.
2
+
3
+ Mirrors the shape of ``witan``'s own ``inject-context`` hook, but is a
4
+ deliberately independent implementation (no cross-package import — see
5
+ ``graph.py``'s docstring) so witan-code stays fully usable, and self-announcing,
6
+ when installed standalone without ``witan``.
7
+
8
+ Tells the agent whether the current repo has a code graph ready to query (and
9
+ its rough size/freshness) — without this, an agent has no signal that
10
+ ``code_*`` tools exist or are populated, short of trying one and seeing what
11
+ comes back.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import os
18
+ from pathlib import Path
19
+
20
+ from . import config as cfg_module
21
+ from . import repo as repo_module
22
+ from .cli import _code_store_stats, _dir_stats
23
+
24
+ # Matches the lock directory hooks.session_init() creates around a background
25
+ # SessionStart index, so this hook can report "indexing in progress" instead
26
+ # of a misleadingly empty/stale store. Keyed on a hash of the project
27
+ # directory (not the raw sanitized path) so two distinct paths can't collide
28
+ # on the same lock file (e.g. "/tmp/a/b" and "/tmp/a_b" both sanitizing to
29
+ # "_tmp_a_b") and so a deep/long checkout path can't blow past a filesystem's
30
+ # filename length limit and silently fail the `mkdir`.
31
+ _LOCK_PREFIX = "codegraph-init-"
32
+
33
+
34
+ def _lock_digest(project_dir: Path) -> str:
35
+ return hashlib.sha256(str(project_dir).encode()).hexdigest()[:16]
36
+
37
+
38
+ def _project_dir() -> Path:
39
+ try:
40
+ cwd = os.getcwd()
41
+ except OSError: # e.g. the cwd was deleted out from under this process
42
+ cwd = "/"
43
+ return Path(os.environ.get("CLAUDE_PROJECT_DIR", cwd))
44
+
45
+
46
+ def _lock_path(project_dir: Path) -> Path:
47
+ tmp = Path(os.environ.get("TMPDIR", "/tmp"))
48
+ return tmp / f"{_LOCK_PREFIX}{_lock_digest(project_dir)}.lock"
49
+
50
+
51
+ def indexing_in_progress() -> bool:
52
+ """Whether hooks.session_init()'s background index is still running."""
53
+ return _lock_path(_project_dir()).is_dir()
54
+
55
+
56
+ def inject_context() -> str:
57
+ """A short markdown status block, or "" when there's nothing worth saying.
58
+
59
+ Silent when the repo has neither a store nor an index in flight (nothing
60
+ to report), so this hook adds no noise for repos that don't use witan-code.
61
+ """
62
+ cfg = cfg_module.load()
63
+ slug = repo_module.detect()
64
+ if slug is None:
65
+ return ""
66
+
67
+ store = cfg_module.store_path(slug, cfg.code_dir)
68
+ in_progress = indexing_in_progress()
69
+
70
+ if not store.exists():
71
+ if not in_progress:
72
+ return ""
73
+ return (
74
+ "## Code Graph\n\n"
75
+ f"Indexing `{slug}` for the first time in the background — "
76
+ "`code_*` tools may return partial or empty results until it "
77
+ "finishes.\n"
78
+ )
79
+
80
+ repo_uri, file_count = _code_store_stats(store)
81
+ try:
82
+ _, last_indexed = _dir_stats(store)
83
+ freshness = f", last updated {last_indexed}"
84
+ except OSError: # e.g. a file vanished mid-walk — degrade, don't blank the block
85
+ freshness = ""
86
+ lines = [
87
+ "## Code Graph",
88
+ "",
89
+ f"`{repo_uri}` is indexed: {file_count} files{freshness}.",
90
+ ]
91
+ if in_progress:
92
+ lines.append("A background reindex is currently running.")
93
+ lines.append(
94
+ "Prefer `code_search_symbol` / `code_find_definition` / "
95
+ "`code_find_references` / `code_callers` / `code_impact` over grep "
96
+ "for symbol lookups, call graphs, and change-impact analysis."
97
+ )
98
+ return "\n".join(lines) + "\n"
witan_code/edges.py ADDED
@@ -0,0 +1,171 @@
1
+ """Typed cross-repo edge precision tiers (docs/EDGE_PRECISION_TIERS.md).
2
+
3
+ Kythe-inspired ``ref/call/direct`` vs ``ref/call`` pattern: replace the
4
+ previous binary consumer/provider model with typed edges that carry the
5
+ precision of *how* a cross-repo link was established, so callers filter by a
6
+ minimum trust floor instead of a confidence threshold they have to pick
7
+ themselves.
8
+
9
+ Tiers, in decreasing trust order:
10
+ * ``precise`` — Stage 2: canonical symbol string join (``stitch``).
11
+ * ``heuristic`` — Stage 3: the pre-existing ``(kind, key_norm)`` binding
12
+ grouping, confidence-scored (``bridge_extractors.adjust_confidence``,
13
+ ``visualize.cross_repo_edges``).
14
+ * ``fuzzy`` — future: embedding/BM25 route similarity (tracked
15
+ separately: "Research: REST-route cross-service matching via embedding
16
+ similarity"). No fuzzy edges exist yet, so this tier is currently
17
+ identical to ``heuristic``.
18
+
19
+ ``min_precision`` is a FLOOR: ``"precise"`` returns only the precise tier;
20
+ ``"heuristic"`` (the default, preserving pre-existing behavior) returns
21
+ precise + heuristic; ``"fuzzy"`` returns everything. A heuristic edge is
22
+ suppressed whenever a precise edge already covers the same
23
+ ``(consumer_repo, provider_repo, kind, key_norm)`` triple, so the same
24
+ logical link never appears twice at two precision tiers.
25
+ """
26
+
27
+ from dataclasses import dataclass
28
+
29
+ from . import stitch
30
+ from .visualize import cross_repo_edges as _confidence_filter
31
+
32
+ PRECISION_TIERS = ("precise", "heuristic", "fuzzy")
33
+ _TIER_ORDER = {tier: i for i, tier in enumerate(PRECISION_TIERS)}
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TypedEdge:
38
+ precision: str # "precise" | "heuristic" | "fuzzy"
39
+ consumer_repo: str
40
+ provider_repo: str
41
+ kind: str
42
+ key_norm: str
43
+ canonical_symbol: str | None # the consumer's symbol; precise tier only
44
+ confidence: float
45
+ evidence: tuple[dict, ...]
46
+
47
+ def as_dict(self) -> dict:
48
+ return {
49
+ "precision": self.precision,
50
+ "consumer_repo": self.consumer_repo,
51
+ "provider_repo": self.provider_repo,
52
+ "kind": self.kind,
53
+ "key_norm": self.key_norm,
54
+ "canonical_symbol": self.canonical_symbol,
55
+ "confidence": self.confidence,
56
+ "evidence": list(self.evidence),
57
+ }
58
+
59
+
60
+ def _pair(edge) -> tuple:
61
+ return (edge.consumer_repo, edge.provider_repo, edge.kind, edge.key_norm)
62
+
63
+
64
+ def _precise_edges(repo_symbol_rows: list[dict]) -> list[TypedEdge]:
65
+ precise, _ = stitch.resolve(repo_symbol_rows)
66
+ return [
67
+ TypedEdge(
68
+ precision="precise",
69
+ consumer_repo=e.consumer_repo,
70
+ provider_repo=e.provider_repo,
71
+ kind=e.kind,
72
+ key_norm=e.key_norm,
73
+ canonical_symbol=e.consumer_symbol,
74
+ confidence=1.0,
75
+ evidence=e.evidence,
76
+ )
77
+ for e in precise
78
+ ]
79
+
80
+
81
+ def _heuristic_edges(
82
+ binding_rows: list[dict], *, min_confidence: float
83
+ ) -> list[TypedEdge]:
84
+ """Group raw ``InterfaceBinding`` rows into (consumer, provider) edges.
85
+
86
+ Mirrors ``visualize.build_graph``'s grouping/self-providing/service-anchor
87
+ rules but keeps per-occurrence evidence instead of collapsing into a
88
+ render-oriented ``DepGraph``.
89
+ """
90
+ filtered = _confidence_filter(binding_rows, min_confidence=min_confidence)
91
+
92
+ groups: dict[tuple[str, str], dict] = {}
93
+ for b in filtered:
94
+ if b["kind"] == "service":
95
+ continue # service anchors aren't repo-to-repo edges (see visualize.build_graph)
96
+ group = groups.setdefault((b["kind"], b["key_norm"]), {"p": {}, "c": {}})
97
+ bucket = group["p"] if b["role"] == "provider" else group["c"]
98
+ repo_bucket = bucket.setdefault(b["repo"], {"confidence": None, "evidence": []})
99
+ if b["role"] != "provider":
100
+ conf = float(b["confidence"]) if b.get("confidence") is not None else 1.0
101
+ if repo_bucket["confidence"] is None or conf > repo_bucket["confidence"]:
102
+ repo_bucket["confidence"] = conf
103
+ if b.get("file"):
104
+ repo_bucket["evidence"].append(
105
+ {"repo": b["repo"], "file": b["file"], "line": b.get("line")}
106
+ )
107
+
108
+ out: list[TypedEdge] = []
109
+ for (kind, key_norm), group in groups.items():
110
+ for cons_repo, cons in group["c"].items():
111
+ if cons_repo in group["p"]:
112
+ continue # self-providing: the repo serves its own route
113
+ for prov_repo in group["p"]:
114
+ if cons_repo == prov_repo:
115
+ continue
116
+ out.append(
117
+ TypedEdge(
118
+ precision="heuristic",
119
+ consumer_repo=cons_repo,
120
+ provider_repo=prov_repo,
121
+ kind=kind,
122
+ key_norm=key_norm,
123
+ canonical_symbol=None,
124
+ confidence=(
125
+ cons["confidence"]
126
+ if cons["confidence"] is not None
127
+ else 1.0
128
+ ),
129
+ evidence=tuple(cons["evidence"]),
130
+ )
131
+ )
132
+ return out
133
+
134
+
135
+ def cross_repo_edges(
136
+ repo_symbol_rows: list[dict],
137
+ binding_rows: list[dict],
138
+ *,
139
+ min_precision: str = "heuristic",
140
+ min_confidence: float = 0.5,
141
+ ) -> list[TypedEdge]:
142
+ """Merge Stage 2 precise + Stage 3 heuristic edges into one typed, filterable list.
143
+
144
+ ``repo_symbol_rows`` is a full ``RepoSymbol`` dump (``all_repo_symbols``);
145
+ ``binding_rows`` is a full ``InterfaceBinding`` dump (``all_bindings``).
146
+ See the module docstring for tier semantics.
147
+ """
148
+ if min_precision not in _TIER_ORDER:
149
+ raise ValueError(f"min_precision must be one of {PRECISION_TIERS!r}")
150
+
151
+ precise = _precise_edges(repo_symbol_rows)
152
+ out = list(precise)
153
+ if _TIER_ORDER[min_precision] >= _TIER_ORDER["heuristic"]:
154
+ covered = {_pair(e) for e in precise}
155
+ heuristic = _heuristic_edges(binding_rows, min_confidence=min_confidence)
156
+ out.extend(e for e in heuristic if _pair(e) not in covered)
157
+ return out
158
+
159
+
160
+ def precise_pairs(repo_symbol_rows: list[dict]) -> frozenset[tuple[str, str, str, str]]:
161
+ """``(consumer_repo, provider_repo, kind, key_norm)`` covered by a Stage-2 join.
162
+
163
+ A cheap membership-test helper for tools that need to know whether a
164
+ *specific* binding participates in a precise edge without building the
165
+ full merged/typed edge list — e.g. filtering ``code_interface_*`` rows by
166
+ ``min_precision="precise"``.
167
+ """
168
+ precise, _ = stitch.resolve(repo_symbol_rows)
169
+ return frozenset(
170
+ (e.consumer_repo, e.provider_repo, e.kind, e.key_norm) for e in precise
171
+ )
witan_code/elicit.py ADDED
@@ -0,0 +1,92 @@
1
+ """Additive MCP elicitation helpers for the witan-code tools.
2
+
3
+ FastMCP 3.4.3 supports ``ctx.elicit``, but the connected client may not, and
4
+ several ``code_*`` tools also run under headless automation (background
5
+ indexers, other MCP clients without elicitation support). These helpers keep
6
+ elicitation strictly *additive*: when the client can't elicit — or ``ctx`` is
7
+ absent — they return the caller's ``default`` so behavior is exactly what it
8
+ was before elicitation existed. Only an *explicit* user decline changes the
9
+ outcome.
10
+
11
+ Call sites in ``server.py``:
12
+
13
+ - ``code_symbols_in_file`` offers to elicit a repo URI when none can be
14
+ detected (``text``).
15
+ - ``code_find_references``/``code_callers``/``code_impact``/
16
+ ``code_symbols_in_file`` and the bridge-backed interface/cross-repo tools
17
+ offer to index now when the relevant store is missing (``confirm``, via the
18
+ ``_confirm_and_reindex``/``_confirm_and_reindex_bridge`` helpers in
19
+ ``server.py``).
20
+ - ``code_find_definition`` offers to narrow a multi-repo name match down to
21
+ one repo (``choose_repo``).
22
+
23
+ Never call these from a tool that must stay non-interactive under automation
24
+ — ``code_reindex`` never takes a ``Context`` (see its docstring), and
25
+ ``code_search_symbol`` returns many results by design, not an error case.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import TYPE_CHECKING
31
+
32
+ from fastmcp.server.elicitation import AcceptedElicitation
33
+
34
+ if TYPE_CHECKING:
35
+ from fastmcp import Context
36
+
37
+
38
+ async def confirm(
39
+ ctx: Context | None, message: str, *, default_when_unsupported: bool
40
+ ) -> bool:
41
+ """Ask a yes/no question.
42
+
43
+ ``accept`` → the chosen bool; ``decline``/``cancel`` → ``False``; and when
44
+ elicitation is unsupported or errors (headless client, no ``ctx``) →
45
+ ``default_when_unsupported`` — pick that so the non-interactive path keeps
46
+ today's behavior (here, always ``False``: never index without being asked).
47
+ """
48
+ if ctx is None:
49
+ return default_when_unsupported
50
+ try:
51
+ result = await ctx.elicit(message, response_type=bool)
52
+ except Exception: # noqa: BLE001 — any elicit failure means "can't ask"
53
+ return default_when_unsupported
54
+ if isinstance(result, AcceptedElicitation):
55
+ return bool(result.data)
56
+ return False
57
+
58
+
59
+ async def text(ctx: Context | None, message: str, *, default: str) -> str:
60
+ """Ask for a line of text. A non-empty accepted value is returned; a
61
+ decline/cancel, an empty value, an unsupported client, or no ``ctx`` all
62
+ fall back to ``default``."""
63
+ if ctx is None:
64
+ return default
65
+ try:
66
+ result = await ctx.elicit(message, response_type=str)
67
+ except Exception: # noqa: BLE001
68
+ return default
69
+ if isinstance(result, AcceptedElicitation) and (result.data or "").strip():
70
+ return result.data.strip()
71
+ return default
72
+
73
+
74
+ async def choose_repo(
75
+ ctx: Context | None, message: str, repos: list[str]
76
+ ) -> str | None:
77
+ """Ask the caller to narrow a multi-repo match down to one of ``repos``.
78
+
79
+ Reuses ``text`` for the primitive. The answer is matched against ``repos``
80
+ case-insensitively and whitespace-stripped; an exact match returns that
81
+ repo, and anything else — empty, declined, unsupported, or a value that
82
+ doesn't match any candidate — returns ``None``, meaning "keep every
83
+ match" (today's behavior, unchanged).
84
+ """
85
+ answer = await text(ctx, message, default="")
86
+ if not answer:
87
+ return None
88
+ normalized = answer.strip().casefold()
89
+ for repo in repos:
90
+ if repo.casefold() == normalized:
91
+ return repo
92
+ return None
@@ -0,0 +1,121 @@
1
+ /**
2
+ * witan-code — Pi extension
3
+ *
4
+ * Pi equivalent of the four Claude Code code-graph hooks:
5
+ * - session_start : seed/refresh the whole repo's Layer-2 code graph in
6
+ * the background (first session builds it, later
7
+ * sessions re-hash and skip unchanged files).
8
+ * - edit / write : incrementally re-index the edited file after the
9
+ * tool runs.
10
+ * - before_agent_start: report whether the code graph is indexed (file
11
+ * count, last-updated) or still being built, with a
12
+ * nudge toward code_* tools over grep.
13
+ * - session_shutdown : opportunistically compact the current repo's store
14
+ * and the shared cross-repo bridge store (throttled;
15
+ * see witan_code.maintenance) — no session-id
16
+ * dependency, unlike workflow-session-checkpoint, so
17
+ * this one *is* mirrored under Pi.
18
+ *
19
+ * Best-effort and non-blocking: a missing CLI, non-git dir, or parse failure
20
+ * never disrupts the session. Requires `witan-code` on PATH
21
+ * (`witan-code setup --agent pi`, or `uv tool install --editable
22
+ * mcp/servers/witan-code`); otherwise it silently no-ops.
23
+ *
24
+ * Install: `witan-code setup --agent pi`, or symlink into
25
+ * ~/.pi/agent/extensions/ (see configs/pi/README.md).
26
+ */
27
+
28
+ import { execSync, spawn, spawnSync } from "node:child_process";
29
+ import { existsSync } from "node:fs";
30
+ import { isAbsolute, resolve } from "node:path";
31
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
32
+
33
+ const SRC_EXT = /\.(py|pyi|ts|tsx|js|jsx|mjs|cjs)$/;
34
+ const EDIT_TOOLS = new Set(["edit", "write"]);
35
+
36
+ function inGitRepo(cwd: string): boolean {
37
+ try {
38
+ execSync("git rev-parse --is-inside-work-tree", {
39
+ cwd,
40
+ stdio: ["ignore", "ignore", "ignore"],
41
+ });
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** Run a witan-code subcommand detached in the background; ignore all failures. */
49
+ function runInBackground(args: string[], cwd?: string): void {
50
+ try {
51
+ const child = spawn("witan-code", args, {
52
+ detached: true,
53
+ stdio: "ignore",
54
+ ...(cwd ? { cwd } : {}),
55
+ });
56
+ child.on("error", () => {}); // CLI not installed, etc.
57
+ child.unref();
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ }
62
+
63
+ function editedPath(event: any, cwd: string): string | null {
64
+ const input = event?.input ?? {};
65
+ const raw = input.path ?? input.file_path ?? input.filename;
66
+ if (typeof raw !== "string" || raw.length === 0) return null;
67
+ const abs = isAbsolute(raw) ? raw : resolve(cwd, raw);
68
+ return SRC_EXT.test(abs) ? abs : null;
69
+ }
70
+
71
+ export default function codegraphExtension(pi: ExtensionAPI): void {
72
+ // Paths captured at tool_call, consumed at tool_result (FIFO) — covers Pi
73
+ // builds where tool_result carries no input.
74
+ const pending: string[] = [];
75
+
76
+ // Seed / refresh the whole repo once per session (incremental).
77
+ pi.on("session_start", async (_event, ctx) => {
78
+ if (inGitRepo(ctx.cwd)) runInBackground(["index", ctx.cwd]);
79
+ });
80
+
81
+ // Push one entry per edit tool_call (path or "") so the FIFO stays paired
82
+ // 1:1 with tool_result, which always shifts exactly one.
83
+ pi.on("tool_call", (event: any, ctx) => {
84
+ if (!EDIT_TOOLS.has(event?.toolName)) return;
85
+ pending.push(editedPath(event, ctx.cwd) ?? "");
86
+ });
87
+
88
+ // After a successful edit/write, re-index just that file.
89
+ pi.on("tool_result", (event: any, ctx) => {
90
+ if (!EDIT_TOOLS.has(event?.toolName)) return;
91
+ const queued = pending.shift() ?? "";
92
+ if (event?.isError) return;
93
+ const p = editedPath(event, ctx.cwd) ?? queued;
94
+ if (p && existsSync(p)) runInBackground(["index", p]);
95
+ });
96
+
97
+ // Report code-graph readiness before each turn (mirrors the Claude
98
+ // `witan-code inject-context` UserPromptSubmit hook).
99
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
100
+ try {
101
+ const r = spawnSync("witan-code", ["inject-context"], {
102
+ encoding: "utf8",
103
+ timeout: 5000,
104
+ cwd: ctx?.cwd,
105
+ });
106
+ const text = (r.stdout ?? "").trim();
107
+ if (r.status !== 0 || !text) return;
108
+ return { systemPrompt: `${event.systemPrompt ?? ""}\n\n${text}` };
109
+ } catch {
110
+ return;
111
+ }
112
+ });
113
+
114
+ // Opportunistically compact the store(s) on session end (mirrors the
115
+ // Claude `witan-code checkpoint` Stop hook). Detached and non-blocking,
116
+ // like session_start's index — session_shutdown fires before teardown,
117
+ // not after, so this must not wait on the child process.
118
+ pi.on("session_shutdown", async (_event, ctx: any) => {
119
+ if (inGitRepo(ctx?.cwd)) runInBackground(["checkpoint"], ctx.cwd);
120
+ });
121
+ }