@memorax/memorax-code 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +7 -4
  2. package/bin/memorax-code-setup.mjs +136 -34
  3. package/bin/memorax-code-trae.mjs +4 -0
  4. package/docs/configuration.md +111 -54
  5. package/docs/troubleshooting.md +50 -8
  6. package/lib/memorax-code-backend/dist/clients/claude/transcript-turn.js +2 -2
  7. package/lib/memorax-code-backend/dist/clients/codex/rollout-turn.js +1 -1
  8. package/lib/memorax-code-backend/dist/clients/trae/lifecycle.js +53 -0
  9. package/lib/memorax-code-backend/dist/clients/trae/memory-hook-runtime.js +302 -0
  10. package/lib/memorax-code-backend/dist/clients/trae/turn-id.js +16 -0
  11. package/lib/memorax-code-backend/dist/config/memorax-code.js +14 -1
  12. package/lib/memorax-code-backend/dist/entrypoints/backend-cli.js +22 -4
  13. package/lib/memorax-code-backend/dist/lifecycle/active-clients.js +3 -0
  14. package/lib/memorax-code-backend/dist/lifecycle/client-plugin-removal.js +14 -2
  15. package/lib/memorax-code-backend/dist/lifecycle/client-selection.js +5 -3
  16. package/lib/memorax-code-backend/dist/lifecycle/orchestrator.js +62 -9
  17. package/lib/memorax-code-backend/dist/memory/hook-command.js +34 -2
  18. package/lib/memorax-code-backend/dist/memory/reminder-trace-recorder.js +5 -1
  19. package/lib/memorax-code-backend/dist/memory/service.js +12 -0
  20. package/lib/memorax-code-backend/dist/trace/config.js +12 -1
  21. package/lib/memorax-code-backend/dist/trace/context.js +26 -1
  22. package/lib/memorax-code-backend/package.json +1 -1
  23. package/lib/memorax-code-claude-adapter/.claude-plugin/plugin.json +1 -1
  24. package/lib/memorax-code-claude-adapter/hooks/runtime-shell.json +1 -1
  25. package/lib/memorax-code-claude-adapter/package.json +1 -1
  26. package/lib/memorax-code-claude-adapter/skills/memorax-code/SKILL.md +3 -1
  27. package/lib/memorax-code-claude-adapter/skills/memorax-code/references/memorax-add.md +16 -3
  28. package/lib/memorax-code-claude-adapter/skills/memorax-code/references/memorax-search.md +15 -2
  29. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/.claude-plugin/plugin.json +1 -1
  30. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/hooks/runtime-shell.json +1 -1
  31. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/package.json +1 -1
  32. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/skills/memorax-code/SKILL.md +3 -1
  33. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/skills/memorax-code/references/memorax-add.md +16 -3
  34. package/lib/memorax-code-claude-marketplace/plugins/memorax-code-claude-adapter/skills/memorax-code/references/memorax-search.md +15 -2
  35. package/lib/memorax-code-codebuddy-adapter/.codebuddy-plugin/plugin.json +1 -1
  36. package/lib/memorax-code-codebuddy-adapter/hooks/runtime-hook.mjs +11 -1
  37. package/lib/memorax-code-codebuddy-adapter/package.json +1 -1
  38. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/SKILL.md +3 -1
  39. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/memorax-add.md +16 -3
  40. package/lib/memorax-code-codebuddy-adapter/skills/memorax-code/references/memorax-search.md +15 -2
  41. package/lib/memorax-code-codebuddy-adapter/src/config.mjs +86 -23
  42. package/lib/memorax-code-codex-adapter/.codex-plugin/plugin.json +1 -1
  43. package/lib/memorax-code-codex-adapter/hooks/runtime-shell.json +1 -1
  44. package/lib/memorax-code-codex-adapter/package.json +1 -1
  45. package/lib/memorax-code-codex-adapter/skills/memorax-code/SKILL.md +3 -1
  46. package/lib/memorax-code-codex-adapter/skills/memorax-code/references/memorax-add.md +16 -3
  47. package/lib/memorax-code-codex-adapter/skills/memorax-code/references/memorax-search.md +15 -2
  48. package/lib/memorax-code-dsh-adapter/package.json +1 -1
  49. package/lib/memorax-code-dsh-adapter/skills/memorax-code/SKILL.md +3 -1
  50. package/lib/memorax-code-dsh-adapter/skills/memorax-code/references/memorax-add.md +16 -3
  51. package/lib/memorax-code-dsh-adapter/skills/memorax-code/references/memorax-search.md +15 -2
  52. package/lib/memorax-code-opencode-adapter/package.json +1 -1
  53. package/lib/memorax-code-opencode-adapter/skills/memorax-code/SKILL.md +3 -1
  54. package/lib/memorax-code-opencode-adapter/skills/memorax-code/references/memorax-add.md +16 -3
  55. package/lib/memorax-code-opencode-adapter/skills/memorax-code/references/memorax-search.md +15 -2
  56. package/lib/memorax-code-trae-adapter/hooks/runtime-hook.mjs +340 -0
  57. package/lib/memorax-code-trae-adapter/package.json +9 -0
  58. package/lib/memorax-code-trae-adapter/skills/memorax-code/SKILL.md +87 -0
  59. package/lib/memorax-code-trae-adapter/skills/memorax-code/agents/claude.yaml +10 -0
  60. package/lib/memorax-code-trae-adapter/skills/memorax-code/agents/openai.yaml +7 -0
  61. package/lib/memorax-code-trae-adapter/skills/memorax-code/defaults.json +12 -0
  62. package/lib/memorax-code-trae-adapter/skills/memorax-code/references/memorax-add.md +101 -0
  63. package/lib/memorax-code-trae-adapter/skills/memorax-code/references/memorax-search.md +106 -0
  64. package/lib/memorax-code-trae-adapter/skills/memorax-code/references/personal-read.md +46 -0
  65. package/lib/memorax-code-trae-adapter/skills/memorax-code/references/personal-write.md +120 -0
  66. package/lib/memorax-code-trae-adapter/skills/memorax-code/references/repo-build.md +319 -0
  67. package/lib/memorax-code-trae-adapter/skills/memorax-code/references/repo-read.md +103 -0
  68. package/lib/memorax-code-trae-adapter/skills/memorax-code/references/repo-templates.md +390 -0
  69. package/lib/memorax-code-trae-adapter/skills/memorax-code/references/repo-update.md +127 -0
  70. package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/collect_all.py +579 -0
  71. package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/detect_updates.py +919 -0
  72. package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/git_commit_facets.py +222 -0
  73. package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/github_resource_facets.py +512 -0
  74. package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/gitlab_resource_facets.py +517 -0
  75. package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/prepare_repo_memory.py +411 -0
  76. package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/user_profile_memory.py +528 -0
  77. package/lib/memorax-code-trae-adapter/skills/memorax-code/scripts/validate_memory.py +248 -0
  78. package/lib/memorax-code-trae-adapter/src/adapter-paths.mjs +69 -0
  79. package/lib/memorax-code-trae-adapter/src/cli.mjs +59 -0
  80. package/lib/memorax-code-trae-adapter/src/config.mjs +630 -0
  81. package/lib/memorax-code-trae-adapter/src/runtime-observation.mjs +55 -0
  82. package/lib/resolve-codebuddy-command.mjs +15 -4
  83. package/lib/run-entrypoint.mjs +6 -0
  84. package/package.json +5 -3
@@ -0,0 +1,919 @@
1
+ #!/usr/bin/env python3
2
+ """Detect incremental repo-memory updates from git and optional provider evidence."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urlparse
16
+
17
+
18
+ SCRIPT_DIR = Path(__file__).resolve().parent
19
+ SKILL_DIR = SCRIPT_DIR.parent
20
+ BUILDER_SCRIPTS_DIR = SCRIPT_DIR
21
+ BUILDER_DEFAULTS_PATH = SKILL_DIR / "defaults.json"
22
+ BUILDER_SKILL_DIR = SKILL_DIR
23
+ BUILDER_HELPER_FILES = {
24
+ "defaults": BUILDER_DEFAULTS_PATH,
25
+ "validate_memory": BUILDER_SCRIPTS_DIR / "validate_memory.py",
26
+ "github_resource_facets": BUILDER_SCRIPTS_DIR / "github_resource_facets.py",
27
+ "gitlab_resource_facets": BUILDER_SCRIPTS_DIR / "gitlab_resource_facets.py",
28
+ }
29
+ PREFERRED_REMOTE_NAMES = ("upstream", "origin")
30
+ HISTORY_MODES = {"none", "commits-only", "local-only", "provider", "provider-required"}
31
+ FALLBACK_DEFAULTS = {
32
+ "repoHistory": {
33
+ "mode": "provider",
34
+ "limits": {
35
+ "prs": 30,
36
+ "issues": 30,
37
+ },
38
+ },
39
+ # Kept for compatibility with defaults.json v1.
40
+ "limits": {
41
+ "prs": 30,
42
+ "issues": 30,
43
+ },
44
+ "summaryChars": 4000,
45
+ }
46
+
47
+
48
+ def run(cmd: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
49
+ return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True)
50
+
51
+
52
+ def git(repo: Path, args: list[str], message: str) -> str:
53
+ if shutil.which("git") is None:
54
+ raise SystemExit("git is required for repo-memory incremental update detection")
55
+ result = run(["git", "-C", str(repo), *args], repo)
56
+ if result.returncode != 0:
57
+ raise SystemExit(f"{message}:\n{result.stderr.strip()}")
58
+ return result.stdout.strip()
59
+
60
+
61
+ def load_json(path: Path, fallback: Any) -> Any:
62
+ if not path.exists():
63
+ return fallback
64
+ try:
65
+ return json.loads(path.read_text(encoding="utf-8"))
66
+ except json.JSONDecodeError as exc:
67
+ raise SystemExit(f"{path}: invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc
68
+
69
+
70
+ def default_int(settings: dict[str, Any], path: list[str], fallback: int) -> int:
71
+ value: Any = settings
72
+ for key in path:
73
+ value = value.get(key) if isinstance(value, dict) else None
74
+ if value is None:
75
+ return fallback
76
+ if isinstance(value, bool) or not isinstance(value, int):
77
+ raise ValueError(f"{BUILDER_DEFAULTS_PATH}: {'.'.join(path)} must be an integer")
78
+ return value
79
+
80
+
81
+ def default_limit(settings: dict[str, Any], key: str) -> int:
82
+ history = settings.get("repoHistory")
83
+ if isinstance(history, dict) and isinstance(history.get("limits"), dict) and history["limits"].get(key) is not None:
84
+ return default_int(settings, ["repoHistory", "limits", key], FALLBACK_DEFAULTS["repoHistory"]["limits"][key])
85
+ return default_int(settings, ["limits", key], FALLBACK_DEFAULTS["limits"][key])
86
+
87
+
88
+ def default_history_mode(settings: dict[str, Any]) -> str:
89
+ history = settings.get("repoHistory")
90
+ if not isinstance(history, dict):
91
+ return "provider"
92
+ mode = history.get("mode", "provider")
93
+ if not isinstance(mode, str) or mode not in HISTORY_MODES:
94
+ raise ValueError(f"{BUILDER_DEFAULTS_PATH}: repoHistory.mode must be one of {', '.join(sorted(HISTORY_MODES))}")
95
+ return mode
96
+
97
+
98
+ def history_collect(mode: str) -> dict[str, bool]:
99
+ return {
100
+ "commits": mode in {"commits-only", "local-only", "provider", "provider-required"},
101
+ "provider": mode in {"provider", "provider-required"},
102
+ }
103
+
104
+
105
+ def load_default_settings() -> tuple[dict[str, Any], str]:
106
+ if not BUILDER_DEFAULTS_PATH.exists():
107
+ return FALLBACK_DEFAULTS, "hardcoded_fallback"
108
+ data = load_json(BUILDER_DEFAULTS_PATH, FALLBACK_DEFAULTS)
109
+ if not isinstance(data, dict):
110
+ raise ValueError(f"{BUILDER_DEFAULTS_PATH}: expected a JSON object")
111
+ return data, str(BUILDER_DEFAULTS_PATH)
112
+
113
+
114
+ def validate_range(parser: argparse.ArgumentParser, name: str, value: int, minimum: int, maximum: int) -> None:
115
+ if value < minimum or value > maximum:
116
+ parser.error(f"{name} must be from {minimum} to {maximum}")
117
+
118
+
119
+ def apply_effective_settings(args: argparse.Namespace, parser: argparse.ArgumentParser) -> argparse.Namespace:
120
+ try:
121
+ defaults, source = load_default_settings()
122
+ default_mode = default_history_mode(defaults)
123
+ default_pr_limit = default_limit(defaults, "prs")
124
+ default_issue_limit = default_limit(defaults, "issues")
125
+ default_summary_chars = default_int(defaults, ["summaryChars"], FALLBACK_DEFAULTS["summaryChars"])
126
+ except ValueError as exc:
127
+ parser.error(str(exc))
128
+
129
+ history_mode = args.history_mode or default_mode
130
+ collect = history_collect(history_mode)
131
+ if args.provider_mode == "off":
132
+ if history_mode == "provider-required":
133
+ parser.error("--provider-mode off cannot be combined with --history-mode provider-required")
134
+ collect = {**collect, "provider": False}
135
+ pr_limit = args.pr_limit if args.pr_limit is not None else default_pr_limit
136
+ issue_limit = args.issue_limit if args.issue_limit is not None else default_issue_limit
137
+ summary_chars = args.summary_chars if args.summary_chars is not None else default_summary_chars
138
+
139
+ validate_range(parser, "--pr-limit", pr_limit, 1, 100)
140
+ validate_range(parser, "--issue-limit", issue_limit, 1, 100)
141
+ if summary_chars < 100:
142
+ parser.error("--summary-chars must be at least 100")
143
+
144
+ overrides: dict[str, Any] = {}
145
+ if args.history_mode is not None:
146
+ overrides["history_mode"] = args.history_mode
147
+ if args.pr_limit is not None:
148
+ overrides["pr_limit"] = args.pr_limit
149
+ if args.issue_limit is not None:
150
+ overrides["issue_limit"] = args.issue_limit
151
+ if args.summary_chars is not None:
152
+ overrides["summary_chars"] = args.summary_chars
153
+
154
+ args.history_mode = history_mode
155
+ args.history_collect = collect
156
+ args.pr_limit = pr_limit
157
+ args.issue_limit = issue_limit
158
+ args.summary_chars = summary_chars
159
+ args.effective_settings = {
160
+ "history": {
161
+ "mode": history_mode,
162
+ "collect": collect,
163
+ },
164
+ "limits": {
165
+ "prs": pr_limit,
166
+ "issues": issue_limit,
167
+ },
168
+ "summary_chars": summary_chars,
169
+ "source": source,
170
+ "overrides": overrides,
171
+ }
172
+ return args
173
+
174
+
175
+ def file_fingerprint(path: Path) -> dict[str, Any]:
176
+ item: dict[str, Any] = {
177
+ "path": str(path),
178
+ "exists": path.exists(),
179
+ }
180
+ if path.exists():
181
+ stat = path.stat()
182
+ item["mtime_ns"] = stat.st_mtime_ns
183
+ item["size_bytes"] = stat.st_size
184
+ return item
185
+
186
+
187
+ def builder_helper_report() -> dict[str, Any]:
188
+ return {
189
+ "skill_dir": str(BUILDER_SKILL_DIR),
190
+ "scripts_dir": str(BUILDER_SCRIPTS_DIR),
191
+ "files": {
192
+ name: file_fingerprint(path)
193
+ for name, path in BUILDER_HELPER_FILES.items()
194
+ },
195
+ }
196
+
197
+
198
+ def parse_frontmatter(path: Path) -> dict[str, str]:
199
+ if not path.exists():
200
+ return {}
201
+ text = path.read_text(encoding="utf-8")
202
+ if not text.startswith("---\n"):
203
+ return {}
204
+ end = text.find("\n---", 4)
205
+ if end == -1:
206
+ return {}
207
+ fields: dict[str, str] = {}
208
+ for line in text[4:end].splitlines():
209
+ if ":" not in line:
210
+ continue
211
+ key, value = line.split(":", 1)
212
+ key = key.strip()
213
+ value = value.strip().strip('"').strip("'")
214
+ if key:
215
+ fields[key] = value
216
+ return fields
217
+
218
+
219
+ def resolve_memory(repo: Path, memory_arg: str | None) -> Path:
220
+ return Path(memory_arg).expanduser().resolve() if memory_arg else repo / ".repo_memory"
221
+
222
+
223
+ def body_after_frontmatter(text: str) -> str:
224
+ if not text.startswith("---\n"):
225
+ return text
226
+ end = text.find("\n---", 4)
227
+ if end == -1:
228
+ return text
229
+ return text[end + 4 :].lstrip("\n")
230
+
231
+
232
+ def commit_baseline(memory: Path, repo: Path | None = None, head_sha: str = "") -> str:
233
+ profile_head = parse_frontmatter(memory / "PROFILE.md").get("local_head", "")
234
+ if profile_head:
235
+ return profile_head
236
+ text_path = memory / "resources" / "commits.md"
237
+ if text_path.exists():
238
+ text = body_after_frontmatter(text_path.read_text(encoding="utf-8"))
239
+ shas = re.findall(r"(?im)^-\s*SHA:\s*`?([0-9a-f]{7,40})`?", text)
240
+ if repo is not None and head_sha and shas:
241
+ nearest = nearest_ancestor(repo, shas, head_sha)
242
+ if nearest:
243
+ return nearest
244
+ if shas:
245
+ return shas[0]
246
+ return ""
247
+
248
+
249
+ def is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
250
+ if not ancestor:
251
+ return False
252
+ result = run(["git", "-C", str(repo), "merge-base", "--is-ancestor", ancestor, descendant], repo)
253
+ return result.returncode == 0
254
+
255
+
256
+ def nearest_ancestor(repo: Path, candidates: list[str], descendant: str) -> str:
257
+ unique_candidates = list(dict.fromkeys(candidate for candidate in candidates if candidate))
258
+ if not unique_candidates:
259
+ return ""
260
+ result = run(["git", "-C", str(repo), "rev-list", descendant], repo)
261
+ if result.returncode != 0:
262
+ return ""
263
+ for sha in [line.strip() for line in result.stdout.splitlines() if line.strip()]:
264
+ for candidate in unique_candidates:
265
+ if sha.startswith(candidate):
266
+ return candidate
267
+ return ""
268
+
269
+
270
+ def commit_delta(repo: Path, baseline_sha: str, head_sha: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
271
+ if not baseline_sha:
272
+ return [], {
273
+ "status": "skipped",
274
+ "reason": "missing_baseline_commit",
275
+ "message": "No stored local commit baseline was found in .repo_memory.",
276
+ }
277
+ if baseline_sha == head_sha:
278
+ return [], {"status": "current", "reason": ""}
279
+ if not is_ancestor(repo, baseline_sha, head_sha):
280
+ return [], {
281
+ "status": "skipped",
282
+ "reason": "baseline_not_ancestor_of_head",
283
+ "message": "Stored repo-memory commit baseline is not an ancestor of the current HEAD; history was probably rebased or force-pushed.",
284
+ }
285
+ shas = git(repo, ["rev-list", "--reverse", f"{baseline_sha}..{head_sha}"], "git could not list new commits")
286
+ out: list[dict[str, Any]] = []
287
+ for sha in [line.strip() for line in shas.splitlines() if line.strip()]:
288
+ fields = git(
289
+ repo,
290
+ ["show", "-s", "--date=iso-strict", "--format=%H%x1f%h%x1f%an%x1f%ad%x1f%s", sha],
291
+ f"git could not read commit {sha}",
292
+ ).split("\x1f")
293
+ full_sha, short_sha, author, authored_at, title = (fields + [""] * 5)[:5]
294
+ files = git(repo, ["show", "--format=", "--name-only", "--no-renames", sha], f"git could not list files for {sha}")
295
+ out.append({
296
+ "sha": full_sha,
297
+ "short_sha": short_sha,
298
+ "title": title,
299
+ "author": author,
300
+ "authored_at": authored_at,
301
+ "files": [line for line in files.splitlines() if line],
302
+ })
303
+ return out, {"status": "ok", "reason": "", "count": len(out)}
304
+
305
+
306
+ def markdown_sections(path: Path) -> list[tuple[str, str]]:
307
+ if not path.exists():
308
+ return []
309
+ text = body_after_frontmatter(path.read_text(encoding="utf-8"))
310
+ matches = list(re.finditer(r"(?m)^##\s+(.+?)\s*$", text))
311
+ sections: list[tuple[str, str]] = []
312
+ for index, match in enumerate(matches):
313
+ start = match.end()
314
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
315
+ sections.append((match.group(1).strip(), text[start:end]))
316
+ return sections
317
+
318
+
319
+ def markdown_field(body: str, label: str) -> str:
320
+ pattern = rf"(?im)^-\s*{re.escape(label)}:\s*(.+?)\s*$"
321
+ match = re.search(pattern, body)
322
+ if not match:
323
+ return ""
324
+ value = match.group(1).strip()
325
+ return value.strip().strip("`").strip()
326
+
327
+
328
+ def first_code_value(text: str) -> str:
329
+ match = re.search(r"`([^`]+)`", text)
330
+ return match.group(1).strip() if match else text.strip()
331
+
332
+
333
+ def section_number(title: str) -> int | None:
334
+ match = re.search(r"[#!#]\s*(\d+)", title)
335
+ return int(match.group(1)) if match else None
336
+
337
+
338
+ def section_title(title: str) -> str:
339
+ return title.split(":", 1)[1].strip() if ":" in title else title.strip()
340
+
341
+
342
+ def branch_parts(value: str) -> tuple[str, str]:
343
+ if "<-" not in value:
344
+ return "", ""
345
+ base, head = value.split("<-", 1)
346
+ return base.strip(), head.strip()
347
+
348
+
349
+ def resource_items(memory: Path, resource_name: str) -> list[dict[str, Any]]:
350
+ path = memory / "resources" / resource_name
351
+ kind = "issue" if resource_name == "issues.md" else "pr"
352
+ out: list[dict[str, Any]] = []
353
+ for title, body in markdown_sections(path):
354
+ number = section_number(title)
355
+ if number is None:
356
+ continue
357
+ item: dict[str, Any] = {
358
+ "number": number,
359
+ "title": section_title(title),
360
+ "source": "resource",
361
+ }
362
+ state = first_code_value(markdown_field(body, "State"))
363
+ if state:
364
+ item["state"] = state
365
+ url = markdown_field(body, "URL")
366
+ if url:
367
+ item["url"] = url
368
+ if kind == "pr":
369
+ base_ref, head_ref = branch_parts(first_code_value(markdown_field(body, "Branch")))
370
+ if base_ref:
371
+ item["base_ref"] = base_ref
372
+ if head_ref:
373
+ item["head_ref"] = head_ref
374
+ out.append(item)
375
+ return out
376
+
377
+
378
+ def first_int(values: Any) -> int | None:
379
+ if isinstance(values, int):
380
+ return values
381
+ if isinstance(values, list):
382
+ for value in values:
383
+ if isinstance(value, int):
384
+ return value
385
+ return None
386
+
387
+
388
+ def facet_number(facet: dict[str, Any], source_type: str) -> int | None:
389
+ values = facet.get("issues") if source_type == "issue" else facet.get("prs")
390
+ number = first_int(values)
391
+ if number is not None:
392
+ return number
393
+ match = re.search(r"\.(\d+)$", str(facet.get("facetId", "")))
394
+ return int(match.group(1)) if match else None
395
+
396
+
397
+ AUTHORING_FACET_KEYS = [
398
+ "summary",
399
+ "labels",
400
+ "commits",
401
+ "commit_headlines",
402
+ "review_decision",
403
+ "review_states",
404
+ "files",
405
+ "symbols",
406
+ "evidence",
407
+ "changed_files",
408
+ "additions",
409
+ "deletions",
410
+ ]
411
+
412
+
413
+ def keep_value(value: Any) -> bool:
414
+ return value not in (None, "", [], {})
415
+
416
+
417
+ def facet_items(facets: Any, source_type: str) -> list[dict[str, Any]]:
418
+ if not isinstance(facets, list):
419
+ return []
420
+ out: list[dict[str, Any]] = []
421
+ for facet in facets:
422
+ if not isinstance(facet, dict) or facet.get("sourceType") != source_type:
423
+ continue
424
+ number = facet_number(facet, source_type)
425
+ if number is None:
426
+ continue
427
+ item: dict[str, Any] = {
428
+ "number": number,
429
+ "title": str(facet.get("title") or ""),
430
+ "state": str(facet.get("state") or ""),
431
+ "updated_at": str(facet.get("updatedAt") or facet.get("updated_at") or ""),
432
+ "url": str(facet.get("url") or ""),
433
+ "source": "provider_facet",
434
+ }
435
+ facet_id = str(facet.get("facetId") or "")
436
+ if facet_id:
437
+ item["raw_lookup"] = f"facetId={facet_id}"
438
+ for key in AUTHORING_FACET_KEYS:
439
+ if key in facet:
440
+ item[key] = facet[key]
441
+ if source_type == "pr":
442
+ item["base_ref"] = str(facet.get("base_ref") or "")
443
+ item["head_ref"] = str(facet.get("head_ref") or "")
444
+ item["merged_at"] = str(facet.get("mergedAt") or facet.get("merged_at") or "")
445
+ item["closed_at"] = str(facet.get("closedAt") or facet.get("closed_at") or "")
446
+ linked_issues = facet.get("issues")
447
+ if keep_value(linked_issues):
448
+ item["linked_issues"] = linked_issues
449
+ out.append({key: value for key, value in item.items() if keep_value(value)})
450
+ return out
451
+
452
+
453
+ def raw_provider_items(memory: Path, source_type: str) -> list[dict[str, Any]]:
454
+ out: list[dict[str, Any]] = []
455
+ for raw_name in ["github-facets.json", "gitlab-facets.json"]:
456
+ raw_path = memory / "raw" / raw_name
457
+ if raw_path.exists():
458
+ out.extend(facet_items(load_json(raw_path, []), source_type))
459
+ return out
460
+
461
+
462
+ def baseline_items(memory: Path, resource_name: str, source_type: str) -> list[dict[str, Any]]:
463
+ resource = resource_items(memory, resource_name)
464
+ raw_by_no = by_number(raw_provider_items(memory, source_type))
465
+ out: list[dict[str, Any]] = []
466
+ seen: set[int] = set()
467
+ for item in resource:
468
+ number = item_number(item)
469
+ if number is None:
470
+ continue
471
+ merged = dict(item)
472
+ merged.update(raw_by_no.get(number, {}))
473
+ if number in raw_by_no:
474
+ merged["source"] = "resource_with_raw_provider_facet"
475
+ out.append(merged)
476
+ seen.add(number)
477
+ for number, item in sorted(raw_by_no.items()):
478
+ if number not in seen:
479
+ out.append(item)
480
+ return out
481
+
482
+
483
+ def item_number(item: dict[str, Any]) -> int | None:
484
+ number = item.get("number")
485
+ return number if isinstance(number, int) else None
486
+
487
+
488
+ def by_number(items: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
489
+ out: dict[int, dict[str, Any]] = {}
490
+ for item in items:
491
+ number = item_number(item)
492
+ if number is not None:
493
+ out[number] = item
494
+ return out
495
+
496
+
497
+ def comparable_item(item: dict[str, Any]) -> str:
498
+ ignored_keys = {"raw_lookup", "source", "updated_at", "merged_at", "closed_at"}
499
+ normalized = {key: value for key, value in item.items() if key not in ignored_keys}
500
+ return json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
501
+
502
+
503
+ def changed_numbers(existing: dict[int, dict[str, Any]], current: dict[int, dict[str, Any]]) -> list[int]:
504
+ changed: list[int] = []
505
+ for number, item in current.items():
506
+ old = existing.get(number)
507
+ if old is None:
508
+ continue
509
+ for key in ["updated_at", "state", "title", "head_ref", "base_ref", "merged_at", "closed_at"]:
510
+ if old.get(key) not in (None, "") and old.get(key) != item.get(key):
511
+ changed.append(number)
512
+ break
513
+ else:
514
+ common_keys = {
515
+ key
516
+ for key, value in old.items()
517
+ if key not in {"number", "raw_lookup", "source"} and value not in (None, "")
518
+ }
519
+ old_common = {key: old.get(key) for key in common_keys}
520
+ current_common = {key: item.get(key) for key in common_keys}
521
+ if old_common != current_common and comparable_item(old) != comparable_item(item):
522
+ changed.append(number)
523
+ return sorted(changed)
524
+
525
+
526
+ def remote_url_from_line(line: str) -> str:
527
+ parts = line.split()
528
+ return parts[1] if len(parts) >= 2 else ""
529
+
530
+
531
+ def remote_name_from_line(line: str) -> str:
532
+ parts = line.split()
533
+ return parts[0] if parts else ""
534
+
535
+
536
+ def normalize_remote_path(path: str) -> str:
537
+ repo_path = path.strip().lstrip("/").rstrip("/")
538
+ return repo_path[:-4] if repo_path.endswith(".git") else repo_path
539
+
540
+
541
+ def provider_from_host(host: str) -> str:
542
+ lower = host.lower()
543
+ if lower == "github.com" or "github" in lower:
544
+ return "github"
545
+ if lower == "gitlab.com" or "gitlab" in lower:
546
+ return "gitlab"
547
+ return ""
548
+
549
+
550
+ def parse_remote_url(url: str) -> dict[str, str]:
551
+ if not url:
552
+ return {"provider": "", "repo": "", "host": "", "url": ""}
553
+ if "://" not in url:
554
+ match = re.match(r"^(?:[^@/\s]+@)?(?P<host>[^:/\s]+):(?P<path>.+)$", url)
555
+ if match:
556
+ host = match.group("host")
557
+ return {
558
+ "provider": provider_from_host(host),
559
+ "repo": normalize_remote_path(match.group("path")),
560
+ "host": host,
561
+ "url": url,
562
+ }
563
+ parsed = urlparse(url)
564
+ host = parsed.hostname or ""
565
+ return {
566
+ "provider": provider_from_host(host),
567
+ "repo": normalize_remote_path(parsed.path),
568
+ "host": host,
569
+ "url": url,
570
+ }
571
+
572
+
573
+ def parse_code_host_repo(remotes: str) -> dict[str, str]:
574
+ candidates: list[dict[str, str]] = []
575
+ for line in remotes.splitlines():
576
+ if "(push)" in line:
577
+ continue
578
+ parsed = parse_remote_url(remote_url_from_line(line))
579
+ if parsed["provider"] and parsed["repo"]:
580
+ parsed["remote_name"] = remote_name_from_line(line)
581
+ candidates.append(parsed)
582
+ for preferred in PREFERRED_REMOTE_NAMES:
583
+ for candidate in candidates:
584
+ if candidate.get("remote_name") == preferred:
585
+ candidate["selection_reason"] = f"preferred_{preferred}_fetch_remote"
586
+ return candidate
587
+ if candidates:
588
+ candidates[0]["selection_reason"] = "first_supported_fetch_remote"
589
+ return candidates[0]
590
+ return {"provider": "", "repo": "", "host": "", "url": "", "remote_name": "", "selection_reason": "none"}
591
+
592
+
593
+ def provider_host(provider: str) -> str:
594
+ if provider == "github":
595
+ return "github.com"
596
+ if provider == "gitlab":
597
+ return "gitlab.com"
598
+ return ""
599
+
600
+
601
+ def provider_from_profile(memory: Path) -> dict[str, str]:
602
+ profile = parse_frontmatter(memory / "PROFILE.md")
603
+ provider = profile.get("code_host_provider", "")
604
+ repo = profile.get("repo_full_name", "")
605
+ if provider in {"github", "gitlab"} and repo and "/" in repo:
606
+ parsed_profile_url = parse_remote_url(str(profile.get("repo_url", "")))
607
+ host = parsed_profile_url["host"] if parsed_profile_url["provider"] == provider else provider_host(provider)
608
+ return {
609
+ "provider": provider,
610
+ "repo": repo,
611
+ "host": host,
612
+ "url": profile.get("repo_url", ""),
613
+ "remote_name": "",
614
+ "selection_reason": "profile_frontmatter",
615
+ }
616
+ return {}
617
+
618
+
619
+ def auth_args(command: str, host: str) -> list[str]:
620
+ args = ["auth", "status"]
621
+ if host and command in {"gh", "glab"}:
622
+ args.extend(["--hostname", host])
623
+ return args
624
+
625
+
626
+ def login_hint(command: str, host: str) -> str:
627
+ default_host = "github.com" if command == "gh" else "gitlab.com" if command == "glab" else ""
628
+ if host and host != default_host:
629
+ return f"{command} auth login --hostname {host}"
630
+ return f"{command} auth login"
631
+
632
+
633
+ def cli_evidence_state(command: str, host: str) -> tuple[str, bool]:
634
+ if shutil.which(command) is None:
635
+ return "cli_missing", False
636
+ try:
637
+ result = subprocess.run([command, *auth_args(command, host)], text=True, capture_output=True, timeout=8)
638
+ except subprocess.TimeoutExpired:
639
+ return "auth_check_timeout", True
640
+ return ("ready", True) if result.returncode == 0 else ("auth_required", True)
641
+
642
+
643
+ def provider_report(repo: Path, memory: Path, *, inspect_cli: bool = True) -> dict[str, Any]:
644
+ remote = provider_from_profile(memory) or parse_code_host_repo(git(repo, ["remote", "-v"], "git could not list remotes"))
645
+ provider = remote["provider"]
646
+ cli = "gh" if provider == "github" else "glab" if provider == "gitlab" else ""
647
+ evidence_state = "unavailable" if inspect_cli else "skipped_by_policy"
648
+ cli_available = False
649
+ if cli and inspect_cli:
650
+ evidence_state, cli_available = cli_evidence_state(cli, remote["host"])
651
+ return {
652
+ "name": provider,
653
+ "repo": remote["repo"],
654
+ "host": remote["host"],
655
+ "remote_url": remote["url"],
656
+ "remote_name": remote.get("remote_name", ""),
657
+ "selection_reason": remote.get("selection_reason", ""),
658
+ "evidence_state": evidence_state,
659
+ "cli": cli,
660
+ "cli_available": cli_available,
661
+ "login_hint": login_hint(cli, remote["host"]) if cli else "",
662
+ }
663
+
664
+
665
+ def provider_script(provider: str) -> str:
666
+ if provider == "github":
667
+ return "github_resource_facets.py"
668
+ if provider == "gitlab":
669
+ return "gitlab_resource_facets.py"
670
+ return ""
671
+
672
+
673
+ def fetch_provider_items(repo: Path, provider: dict[str, Any], snapshot_ref: str, pr_limit: int, issue_limit: int, summary_chars: int) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
674
+ provider_name = str(provider.get("name") or "")
675
+ provider_repo = str(provider.get("repo") or "")
676
+ script_name = provider_script(provider_name)
677
+ if not script_name or not provider_repo or provider.get("evidence_state") != "ready":
678
+ return {
679
+ "attempted": False,
680
+ "reason": f"provider_evidence_state={provider.get('evidence_state', '') or 'unavailable'}",
681
+ }, [], []
682
+
683
+ script_path = BUILDER_SCRIPTS_DIR / script_name
684
+ if not script_path.exists():
685
+ return {"attempted": False, "reason": "memorax-code repo-build helpers are missing"}, [], []
686
+
687
+ with tempfile.TemporaryDirectory(prefix="memorax-code-repo-update.") as tmp:
688
+ tmp_root = Path(tmp)
689
+ raw = tmp_root / f"{provider_name}-facets.json"
690
+ facets = run(
691
+ [
692
+ sys.executable,
693
+ str(script_path),
694
+ "--repo",
695
+ provider_repo,
696
+ "--repo-path",
697
+ str(repo),
698
+ "--snapshot-ref",
699
+ snapshot_ref,
700
+ "--include",
701
+ "prs,issues",
702
+ "--pr-limit",
703
+ str(pr_limit),
704
+ "--issue-limit",
705
+ str(issue_limit),
706
+ "--state",
707
+ "all",
708
+ "--summary-chars",
709
+ str(summary_chars),
710
+ "--out",
711
+ str(raw),
712
+ *(["--hostname", str(provider.get("host"))] if provider.get("host") else []),
713
+ ],
714
+ repo,
715
+ )
716
+ if facets.returncode != 0:
717
+ return {
718
+ "attempted": True,
719
+ "ok": False,
720
+ "exit_code": facets.returncode,
721
+ "stderr": facets.stderr.strip(),
722
+ }, [], []
723
+ raw_facets = load_json(raw, [])
724
+ prs = facet_items(raw_facets, "pr")
725
+ issues = facet_items(raw_facets, "issue")
726
+ return {
727
+ "attempted": True,
728
+ "ok": True,
729
+ "provider": provider_name,
730
+ "repo": provider_repo,
731
+ "pr_count": len(prs) if isinstance(prs, list) else 0,
732
+ "issue_count": len(issues) if isinstance(issues, list) else 0,
733
+ }, prs, issues
734
+
735
+
736
+ def delta_summary(existing: list[dict[str, Any]], current: list[dict[str, Any]]) -> dict[str, Any]:
737
+ existing_by_no = by_number(existing)
738
+ current_by_no = by_number(current)
739
+ added = sorted(number for number in current_by_no if number not in existing_by_no)
740
+ updated = changed_numbers(existing_by_no, current_by_no)
741
+ baseline_only = sorted(number for number in existing_by_no if number not in current_by_no)
742
+ return {
743
+ "baseline_numbers": sorted(existing_by_no),
744
+ "current_numbers": sorted(current_by_no),
745
+ "added_numbers": added,
746
+ "updated_numbers": updated,
747
+ "upsert_numbers": sorted([*added, *updated]),
748
+ "baseline_only_numbers": baseline_only,
749
+ "delete_numbers": [],
750
+ }
751
+
752
+
753
+ def actions_for(deltas: dict[str, Any], provider_fetch: dict[str, Any]) -> list[str]:
754
+ actions: list[str] = []
755
+ local_commit_status = deltas.get("local_commit_status", {})
756
+ if local_commit_status.get("reason") == "missing_baseline_commit":
757
+ actions.append("Local commit delta was skipped because no stored commit baseline was found. Use $memorax-code repo-build for a full rebuild before doing incremental commit updates.")
758
+ if local_commit_status.get("reason") == "baseline_not_ancestor_of_head":
759
+ actions.append("Local commit delta was skipped because the stored baseline is not an ancestor of HEAD. Use $memorax-code repo-build for a full rebuild, or explicitly choose a new baseline before doing incremental commit updates.")
760
+ if deltas["local_commits"]:
761
+ actions.append("Update .repo_memory/resources/commits.md with sections for the new local commits; update PROFILE.md local_head and commit snapshot notes only where needed.")
762
+ if deltas["pull_requests"]["added_numbers"] or deltas["pull_requests"]["updated_numbers"]:
763
+ actions.append("Upsert .repo_memory/resources/prs.md sections by PR/MR number for only the added or changed numbers. Preserve baseline-only and unaffected PR/MR sections; do not delete missing numbers by default.")
764
+ if deltas["issues"]["added_numbers"] or deltas["issues"]["updated_numbers"]:
765
+ actions.append("Upsert .repo_memory/resources/issues.md sections by issue number for only the added or changed numbers. Preserve baseline-only and unaffected issue sections; do not delete missing numbers by default.")
766
+ if provider_fetch.get("attempted") is True and provider_fetch.get("ok") is False:
767
+ actions.append("Provider delta fetch failed; keeping existing PR/issue resources unchanged. Fix provider access and rerun $memorax-code repo-update, or use repo-build for a full provider refresh if needed.")
768
+ if provider_fetch.get("attempted") is False:
769
+ actions.append("Provider delta fetch was skipped; keep PR/issue resources unchanged unless the user asks to login/install provider CLI or force provider refresh.")
770
+ if not actions:
771
+ actions.append("No repo-memory content update is needed.")
772
+ return actions
773
+
774
+
775
+ def notices_for(deltas: dict[str, Any], provider_fetch: dict[str, Any]) -> list[dict[str, Any]]:
776
+ notices: list[dict[str, Any]] = []
777
+ local_commit_status = deltas.get("local_commit_status", {})
778
+ if local_commit_status.get("reason") == "missing_baseline_commit":
779
+ notices.append({
780
+ "level": "warning",
781
+ "title": "Repo Memory Commit Baseline Missing",
782
+ "message": "No stored local commit baseline was found in .repo_memory, so incremental commit detection was skipped.",
783
+ "next_steps": [
784
+ "Run $memorax-code repo-build for a full rebuild before incremental commit updates.",
785
+ ],
786
+ "render_as": "assistant_message",
787
+ })
788
+ if local_commit_status.get("reason") == "baseline_not_ancestor_of_head":
789
+ notices.append({
790
+ "level": "warning",
791
+ "title": "Repo Memory Baseline Rewritten",
792
+ "message": "Stored repo-memory commit baseline is not an ancestor of the current HEAD. History was probably rebased or force-pushed, so incremental commit detection was skipped.",
793
+ "next_steps": [
794
+ "Run $memorax-code repo-build for a full rebuild, or explicitly choose a new baseline before incremental commit updates.",
795
+ ],
796
+ "render_as": "assistant_message",
797
+ })
798
+ if provider_fetch.get("attempted") is True and provider_fetch.get("ok") is False:
799
+ notices.append({
800
+ "level": "warning",
801
+ "title": "Provider Delta Fetch Failed",
802
+ "message": "Provider delta fetch failed; keeping existing PR/issue resources unchanged. Fix provider access before rerunning provider deltas.",
803
+ "next_steps": [
804
+ "Fix GitHub/GitLab CLI access and rerun $memorax-code repo-update.",
805
+ "Use $memorax-code repo-build for a full provider refresh if a broader rebuild is needed.",
806
+ ],
807
+ "render_as": "assistant_message",
808
+ })
809
+ return notices
810
+
811
+
812
+ def parse_args(argv: list[str]) -> argparse.Namespace:
813
+ parser = argparse.ArgumentParser(description="Detect incremental updates for an existing .repo_memory bundle.")
814
+ parser.add_argument("--repo-path", default=".", help="Local git repository path")
815
+ parser.add_argument("--memory-path", help="Existing .repo_memory path; defaults to <repo>/.repo_memory")
816
+ parser.add_argument("--snapshot-ref", default="HEAD", help="Local git ref used for current detection")
817
+ parser.add_argument("--history-mode", choices=sorted(HISTORY_MODES), default=None, help="History evidence policy for this update")
818
+ parser.add_argument("--provider-mode", choices=["auto", "off"], default="auto", help="Whether to fetch latest provider PR/issue facets when provider evidence is ready")
819
+ parser.add_argument("--pr-limit", type=int, default=None, help="Maximum latest PR/MR candidates to compare")
820
+ parser.add_argument("--issue-limit", type=int, default=None, help="Maximum latest issue candidates to compare")
821
+ parser.add_argument("--summary-chars", type=int, default=None, help="Maximum raw provider summary characters when provider facets are fetched")
822
+ parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
823
+ args = parser.parse_args(argv)
824
+ args.repo_path = Path(args.repo_path).expanduser().resolve()
825
+ return apply_effective_settings(args, parser)
826
+
827
+
828
+ def main(argv: list[str]) -> int:
829
+ args = parse_args(argv)
830
+ repo = args.repo_path
831
+ memory = resolve_memory(repo, args.memory_path)
832
+ if not memory.exists() or not (memory / "PROFILE.md").exists():
833
+ report = {
834
+ "ok": False,
835
+ "repo_path": str(repo),
836
+ "memory_path": str(memory),
837
+ "builder_helpers": builder_helper_report(),
838
+ "error": "Existing .repo_memory/PROFILE.md is required; run $memorax-code repo-build before repo-update.",
839
+ }
840
+ print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
841
+ return 1
842
+
843
+ head = git(repo, ["rev-parse", "--verify", f"{args.snapshot_ref}^{{commit}}"], f"git could not resolve {args.snapshot_ref!r}")
844
+ baseline_sha = commit_baseline(memory, repo, head)
845
+ if args.history_collect["commits"]:
846
+ local_commits, local_commit_status = commit_delta(repo, baseline_sha, head)
847
+ else:
848
+ local_commits, local_commit_status = [], {
849
+ "status": "skipped",
850
+ "reason": "history_disabled_by_policy",
851
+ "message": "Local commit delta detection is disabled by repoHistory.mode.",
852
+ }
853
+
854
+ existing_prs = baseline_items(memory, "prs.md", "pr")
855
+ existing_issues = baseline_items(memory, "issues.md", "issue")
856
+ provider = provider_report(repo, memory, inspect_cli=args.history_collect["provider"])
857
+ if args.history_collect["provider"]:
858
+ provider_fetch, current_prs, current_issues = fetch_provider_items(
859
+ repo,
860
+ provider,
861
+ args.snapshot_ref,
862
+ args.pr_limit,
863
+ args.issue_limit,
864
+ args.summary_chars,
865
+ )
866
+ else:
867
+ reason = (
868
+ "provider-mode=off"
869
+ if args.provider_mode == "off"
870
+ else "history_disabled_by_policy"
871
+ if args.history_mode == "none"
872
+ else "history_provider_disabled_by_policy"
873
+ )
874
+ provider_fetch, current_prs, current_issues = {"attempted": False, "reason": reason}, [], []
875
+ fetched_prs = current_prs if provider_fetch.get("ok") is True else []
876
+ fetched_issues = current_issues if provider_fetch.get("ok") is True else []
877
+ if not current_prs and provider_fetch.get("ok") is not True:
878
+ current_prs = existing_prs
879
+ if not current_issues and provider_fetch.get("ok") is not True:
880
+ current_issues = existing_issues
881
+
882
+ deltas = {
883
+ "local_commits": local_commits,
884
+ "local_commit_status": local_commit_status,
885
+ "pull_requests": delta_summary(existing_prs, current_prs),
886
+ "issues": delta_summary(existing_issues, current_issues),
887
+ }
888
+ if local_commit_status.get("status") == "skipped" and local_commit_status.get("reason"):
889
+ deltas["commit_delta_skipped"] = local_commit_status["reason"]
890
+ report = {
891
+ "ok": True,
892
+ "repo_path": str(repo),
893
+ "memory_path": str(memory),
894
+ "effective_settings": args.effective_settings,
895
+ "builder_helpers": builder_helper_report(),
896
+ "baseline": {
897
+ "local_commit_sha": baseline_sha,
898
+ "pull_request_numbers": deltas["pull_requests"]["baseline_numbers"],
899
+ "issue_numbers": deltas["issues"]["baseline_numbers"],
900
+ },
901
+ "current": {
902
+ "local_head": head,
903
+ "provider": provider,
904
+ "provider_fetch": provider_fetch,
905
+ "provider_items": {
906
+ "pull_requests": fetched_prs,
907
+ "issues": fetched_issues,
908
+ },
909
+ },
910
+ "deltas": deltas,
911
+ "notices": notices_for(deltas, provider_fetch),
912
+ "actions": actions_for(deltas, provider_fetch),
913
+ }
914
+ print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
915
+ return 0
916
+
917
+
918
+ if __name__ == "__main__":
919
+ raise SystemExit(main(sys.argv[1:]))