create-egregore 0.19.32 → 0.20.0
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.
- package/assets/egregore-launcher.js +118 -13
- package/bin/cli.js +48 -3
- package/lib/connect-runtime.js +4 -2
- package/lib/local.js +15 -1
- package/lib/prime-runtime.js +208 -0
- package/lib/setup.js +45 -3
- package/package.json +5 -4
- package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
- package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
- package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
- package/runtime/codex/.codex/spec-manifest.json +3 -3
- package/runtime/codex/AGENTS.md +11 -4
- package/runtime/codex/bin/activity-data.sh +4 -8
- package/runtime/codex/bin/attendant.sh +13 -0
- package/runtime/codex/bin/autosave.sh +27 -1
- package/runtime/codex/bin/codex-render-spec.mjs +6 -0
- package/runtime/codex/bin/codex-session-start.sh +8 -1
- package/runtime/codex/bin/connect-refresh.sh +33 -0
- package/runtime/codex/bin/graph-batch.sh +25 -6
- package/runtime/codex/bin/graph-op.sh +5 -4
- package/runtime/codex/bin/graph.sh +31 -5
- package/runtime/codex/bin/ingest.py +81 -5
- package/runtime/codex/bin/lib/context.sh +26 -1
- package/runtime/codex/bin/lib/greeting.sh +4 -1
- package/runtime/codex/bin/lib/worktree-links.sh +25 -0
- package/runtime/codex/manifest.json +8 -1
- package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
- package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
- package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
- package/runtime/pi/.codex/spec-manifest.json +3 -3
- package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
- package/runtime/pi/.pi/extensions/egregore.ts +17 -6
- package/runtime/pi/.pi/spec-manifest.json +2 -2
- package/runtime/pi/AGENTS.md +11 -4
- package/runtime/pi/bin/activity-data.sh +4 -8
- package/runtime/pi/bin/attendant.sh +13 -0
- package/runtime/pi/bin/autosave.sh +27 -1
- package/runtime/pi/bin/codex-render-spec.mjs +6 -0
- package/runtime/pi/bin/codex-session-start.sh +8 -1
- package/runtime/pi/bin/connect-refresh.sh +33 -0
- package/runtime/pi/bin/graph-batch.sh +25 -6
- package/runtime/pi/bin/graph-op.sh +5 -4
- package/runtime/pi/bin/graph.sh +31 -5
- package/runtime/pi/bin/ingest.py +81 -5
- package/runtime/pi/bin/lib/context.sh +26 -1
- package/runtime/pi/bin/lib/greeting.sh +4 -1
- package/runtime/pi/bin/lib/worktree-links.sh +25 -0
- package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
- package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
- package/runtime/pi/manifest.json +8 -1
- package/runtime/prime/.claude/context/notification-consent.md +66 -0
- package/runtime/prime/.codex/config.toml +17 -0
- package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
- package/runtime/prime/.codex/hooks/search-hint.js +67 -0
- package/runtime/prime/.codex/hooks.json +30 -0
- package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
- package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
- package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
- package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
- package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
- package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
- package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
- package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
- package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
- package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
- package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
- package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
- package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
- package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
- package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
- package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
- package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
- package/runtime/prime/.codex/spec-manifest.json +132 -0
- package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
- package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
- package/runtime/prime/.prime/agent/settings.json +4 -0
- package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
- package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
- package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
- package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
- package/runtime/prime/AGENTS.md +489 -0
- package/runtime/prime/bin/activity-data.sh +238 -0
- package/runtime/prime/bin/agent.sh +775 -0
- package/runtime/prime/bin/attendant.sh +707 -0
- package/runtime/prime/bin/autosave.sh +112 -0
- package/runtime/prime/bin/capture-reconcile.sh +69 -0
- package/runtime/prime/bin/capture-run.sh +341 -0
- package/runtime/prime/bin/codex-install-skills.sh +78 -0
- package/runtime/prime/bin/codex-render-spec.mjs +440 -0
- package/runtime/prime/bin/codex-session-start.sh +265 -0
- package/runtime/prime/bin/codex-skill-render.mjs +347 -0
- package/runtime/prime/bin/codex-sync-skills.sh +232 -0
- package/runtime/prime/bin/connect-refresh.sh +33 -0
- package/runtime/prime/bin/dashboard-data.sh +178 -0
- package/runtime/prime/bin/graph-batch.sh +79 -0
- package/runtime/prime/bin/graph-op.sh +1102 -0
- package/runtime/prime/bin/graph-wal.sh +229 -0
- package/runtime/prime/bin/graph.sh +223 -0
- package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
- package/runtime/prime/bin/handoff-run.sh +671 -0
- package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
- package/runtime/prime/bin/index-handoff.sh +322 -0
- package/runtime/prime/bin/ingest-graph.sh +80 -0
- package/runtime/prime/bin/ingest-surface.html +495 -0
- package/runtime/prime/bin/ingest.py +756 -0
- package/runtime/prime/bin/ingest.sh +279 -0
- package/runtime/prime/bin/ingest_graph.py +856 -0
- package/runtime/prime/bin/ingest_surface.py +544 -0
- package/runtime/prime/bin/lib/artifact-id.sh +46 -0
- package/runtime/prime/bin/lib/config.sh +116 -0
- package/runtime/prime/bin/lib/context.sh +621 -0
- package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
- package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
- package/runtime/prime/bin/lib/git-sync.sh +406 -0
- package/runtime/prime/bin/lib/greeting.sh +519 -0
- package/runtime/prime/bin/lib/hash.sh +13 -0
- package/runtime/prime/bin/lib/identity.sh +213 -0
- package/runtime/prime/bin/lib/metrics.sh +374 -0
- package/runtime/prime/bin/lib/noncode.sh +31 -0
- package/runtime/prime/bin/lib/permissions.sh +106 -0
- package/runtime/prime/bin/lib/time.sh +56 -0
- package/runtime/prime/bin/lib/worktree-links.sh +79 -0
- package/runtime/prime/bin/notify.sh +636 -0
- package/runtime/prime/bin/person.py +630 -0
- package/runtime/prime/bin/person.sh +448 -0
- package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
- package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
- package/runtime/prime/bin/prime-render-spec.mjs +123 -0
- package/runtime/prime/bin/prime-session-start.sh +24 -0
- package/runtime/prime/bin/publish-artifact.sh +271 -0
- package/runtime/prime/bin/publish-references.sh +144 -0
- package/runtime/prime/bin/repo-state.sh +96 -0
- package/runtime/prime/bin/search.sh +240 -0
- package/runtime/prime/bin/session-autosave.sh +209 -0
- package/runtime/prime/bin/session-log.sh +199 -0
- package/runtime/prime/bin/settings.sh +304 -0
- package/runtime/prime/bin/telemetry.sh +391 -0
- package/runtime/prime/bin/worktree-create.sh +128 -0
- package/runtime/prime/bin/worktree-remove.sh +50 -0
- package/runtime/prime/bin/worktree.sh +198 -0
- package/runtime/prime/manifest.json +178 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reconcile one Egregore member identity into state and memory/people."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ROOT = Path(
|
|
17
|
+
os.environ.get("EGREGORE_PERSON_ROOT")
|
|
18
|
+
or Path(__file__).resolve().parent.parent
|
|
19
|
+
).resolve()
|
|
20
|
+
STATE_PATH = ROOT / ".egregore-state.json"
|
|
21
|
+
PEOPLE_DIR = ROOT / "memory" / "people"
|
|
22
|
+
FIELD_ORDER = (
|
|
23
|
+
"Person-ID",
|
|
24
|
+
"GitHub",
|
|
25
|
+
"GitHub-ID",
|
|
26
|
+
"GitHub-Aliases",
|
|
27
|
+
"Email",
|
|
28
|
+
"Emails",
|
|
29
|
+
"Previous-Names",
|
|
30
|
+
"Role",
|
|
31
|
+
"Joined",
|
|
32
|
+
"Onboarded",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clean_strings(values: list[Any], *, lower: bool = False) -> list[str]:
|
|
37
|
+
result: list[str] = []
|
|
38
|
+
seen: set[str] = set()
|
|
39
|
+
for value in values:
|
|
40
|
+
if not isinstance(value, str) or not value.strip():
|
|
41
|
+
continue
|
|
42
|
+
item = value.strip().lower() if lower else value.strip()
|
|
43
|
+
key = item.lower()
|
|
44
|
+
if key not in seen:
|
|
45
|
+
seen.add(key)
|
|
46
|
+
result.append(item)
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def comma_values(value: Any) -> list[str]:
|
|
51
|
+
if isinstance(value, list):
|
|
52
|
+
return clean_strings(value)
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
return []
|
|
55
|
+
return clean_strings([item for item in value.split(",")])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_profile(path: Path) -> dict[str, Any]:
|
|
59
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
60
|
+
yaml_fields: dict[str, str] = {}
|
|
61
|
+
if text.startswith("---\n"):
|
|
62
|
+
parts = text.split("\n---\n", 1)
|
|
63
|
+
if len(parts) == 2:
|
|
64
|
+
for line in parts[0].splitlines()[1:]:
|
|
65
|
+
if ":" in line:
|
|
66
|
+
key, value = line.split(":", 1)
|
|
67
|
+
yaml_fields[key.strip().lower()] = value.strip().strip("\"'")
|
|
68
|
+
text = parts[1]
|
|
69
|
+
lines = text.splitlines()
|
|
70
|
+
title = yaml_fields.get("display_name") or yaml_fields.get("name") or ""
|
|
71
|
+
fields: dict[str, str] = {}
|
|
72
|
+
yaml_map = {
|
|
73
|
+
"person_id": "Person-ID",
|
|
74
|
+
"github": "GitHub",
|
|
75
|
+
"github_id": "GitHub-ID",
|
|
76
|
+
"email": "Email",
|
|
77
|
+
"role": "Role",
|
|
78
|
+
"joined": "Joined",
|
|
79
|
+
"onboarded": "Onboarded",
|
|
80
|
+
}
|
|
81
|
+
for source, target in yaml_map.items():
|
|
82
|
+
if yaml_fields.get(source):
|
|
83
|
+
fields[target] = yaml_fields[source]
|
|
84
|
+
body: list[str] = []
|
|
85
|
+
for line in lines:
|
|
86
|
+
if not title and line.startswith("# "):
|
|
87
|
+
title = line[2:].strip()
|
|
88
|
+
continue
|
|
89
|
+
matched = False
|
|
90
|
+
for field in FIELD_ORDER:
|
|
91
|
+
if line.lower().startswith(field.lower() + ":"):
|
|
92
|
+
fields[field] = line.split(":", 1)[1].strip()
|
|
93
|
+
matched = True
|
|
94
|
+
break
|
|
95
|
+
if not matched:
|
|
96
|
+
body.append(line)
|
|
97
|
+
while body and not body[0].strip():
|
|
98
|
+
body.pop(0)
|
|
99
|
+
return {"title": title, "fields": fields, "body": "\n".join(body).strip()}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def display_slug(value: str) -> str:
|
|
103
|
+
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", value.lower())).strip("-")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def atomic_text(path: Path, text: str) -> None:
|
|
107
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
fd, temp_name = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
|
|
109
|
+
try:
|
|
110
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
111
|
+
handle.write(text)
|
|
112
|
+
os.replace(temp_name, path)
|
|
113
|
+
finally:
|
|
114
|
+
if os.path.exists(temp_name):
|
|
115
|
+
os.unlink(temp_name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def atomic_json(path: Path, value: dict[str, Any]) -> None:
|
|
119
|
+
atomic_text(path, json.dumps(value, indent=2, ensure_ascii=False) + "\n")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def alias_candidate(
|
|
123
|
+
path: Path,
|
|
124
|
+
profile: dict[str, Any],
|
|
125
|
+
*,
|
|
126
|
+
canonical: Path,
|
|
127
|
+
person_id: str,
|
|
128
|
+
github: str,
|
|
129
|
+
github_aliases: list[str],
|
|
130
|
+
preferred_slug: str,
|
|
131
|
+
) -> bool:
|
|
132
|
+
if path == canonical or path.name == "index.md":
|
|
133
|
+
return False
|
|
134
|
+
fields = profile["fields"]
|
|
135
|
+
profile_pid = fields.get("Person-ID", "")
|
|
136
|
+
profile_github = fields.get("GitHub", "").lower()
|
|
137
|
+
stem = path.stem.lower()
|
|
138
|
+
if profile_pid and profile_pid == person_id:
|
|
139
|
+
return True
|
|
140
|
+
if profile_github and profile_github == github.lower():
|
|
141
|
+
return True
|
|
142
|
+
if stem in {item.lower() for item in github_aliases}:
|
|
143
|
+
return True
|
|
144
|
+
# Back-compat duplicate created before identity metadata existed. Restrict
|
|
145
|
+
# this heuristic to tiny witness files so a real teammate profile is never
|
|
146
|
+
# absorbed merely because two people prefer the same name.
|
|
147
|
+
if (
|
|
148
|
+
stem == preferred_slug
|
|
149
|
+
and not profile_github
|
|
150
|
+
and len(path.read_text(encoding="utf-8").splitlines()) <= 12
|
|
151
|
+
):
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def choose_source_profile(
|
|
157
|
+
*,
|
|
158
|
+
github: str,
|
|
159
|
+
display_name: str,
|
|
160
|
+
github_name: str,
|
|
161
|
+
) -> tuple[Path | None, list[str]]:
|
|
162
|
+
"""Choose one legacy profile conservatively.
|
|
163
|
+
|
|
164
|
+
Exact GitHub-addressed profiles win. Preferred/display-name matching is
|
|
165
|
+
accepted only when it identifies one file, so two people called "Kaan"
|
|
166
|
+
can never be merged by a bulk run.
|
|
167
|
+
"""
|
|
168
|
+
profiles = [
|
|
169
|
+
path for path in sorted(PEOPLE_DIR.glob("*.md"))
|
|
170
|
+
if path.name != "index.md"
|
|
171
|
+
]
|
|
172
|
+
canonical = PEOPLE_DIR / f"{github}.md"
|
|
173
|
+
if canonical.exists():
|
|
174
|
+
return canonical, []
|
|
175
|
+
|
|
176
|
+
github_matches = [
|
|
177
|
+
path for path in profiles
|
|
178
|
+
if parse_profile(path)["fields"].get("GitHub", "").lower() == github.lower()
|
|
179
|
+
]
|
|
180
|
+
if len(github_matches) == 1:
|
|
181
|
+
return github_matches[0], []
|
|
182
|
+
|
|
183
|
+
preferred_slug = display_slug(display_name)
|
|
184
|
+
slug_matches = [
|
|
185
|
+
path for path in profiles
|
|
186
|
+
if preferred_slug and path.stem.lower() == preferred_slug
|
|
187
|
+
]
|
|
188
|
+
if len(slug_matches) == 1:
|
|
189
|
+
return slug_matches[0], []
|
|
190
|
+
|
|
191
|
+
names = {
|
|
192
|
+
value.strip().lower()
|
|
193
|
+
for value in (display_name, github_name)
|
|
194
|
+
if value and value.strip()
|
|
195
|
+
}
|
|
196
|
+
title_matches = [
|
|
197
|
+
path for path in profiles
|
|
198
|
+
if parse_profile(path)["title"].strip().lower() in names
|
|
199
|
+
]
|
|
200
|
+
if len(title_matches) == 1:
|
|
201
|
+
return title_matches[0], []
|
|
202
|
+
|
|
203
|
+
ambiguous = sorted({
|
|
204
|
+
path.name
|
|
205
|
+
for matches in (github_matches, slug_matches, title_matches)
|
|
206
|
+
if len(matches) > 1
|
|
207
|
+
for path in matches
|
|
208
|
+
})
|
|
209
|
+
return None, ambiguous
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def render_identity_profile(
|
|
213
|
+
*,
|
|
214
|
+
canonical: Path,
|
|
215
|
+
source: Path | None,
|
|
216
|
+
person_id: str,
|
|
217
|
+
github: str,
|
|
218
|
+
github_id: int | None,
|
|
219
|
+
github_name: str,
|
|
220
|
+
display_name: str,
|
|
221
|
+
emails: list[str],
|
|
222
|
+
github_aliases: list[str],
|
|
223
|
+
previous_names: list[str],
|
|
224
|
+
role: str,
|
|
225
|
+
joined: str,
|
|
226
|
+
onboarded: str,
|
|
227
|
+
dry_run: bool,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
"""Render one canonical profile and safe alias witnesses."""
|
|
230
|
+
canonical_existed = canonical.exists()
|
|
231
|
+
current = parse_profile(canonical)
|
|
232
|
+
source_profile = (
|
|
233
|
+
parse_profile(source)
|
|
234
|
+
if source is not None and source != canonical
|
|
235
|
+
else {"title": "", "fields": {}, "body": ""}
|
|
236
|
+
)
|
|
237
|
+
source_fields = source_profile["fields"]
|
|
238
|
+
current_fields = current["fields"]
|
|
239
|
+
|
|
240
|
+
emails = clean_strings(
|
|
241
|
+
emails
|
|
242
|
+
+ comma_values(current_fields.get("Emails"))
|
|
243
|
+
+ [current_fields.get("Email")]
|
|
244
|
+
+ comma_values(source_fields.get("Emails"))
|
|
245
|
+
+ [source_fields.get("Email")],
|
|
246
|
+
lower=True,
|
|
247
|
+
)
|
|
248
|
+
github_aliases = clean_strings(
|
|
249
|
+
github_aliases
|
|
250
|
+
+ comma_values(current_fields.get("GitHub-Aliases"))
|
|
251
|
+
+ comma_values(source_fields.get("GitHub-Aliases")),
|
|
252
|
+
lower=True,
|
|
253
|
+
)
|
|
254
|
+
previous_names = clean_strings(
|
|
255
|
+
previous_names
|
|
256
|
+
+ comma_values(current_fields.get("Previous-Names"))
|
|
257
|
+
+ comma_values(source_fields.get("Previous-Names"))
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
alias_paths: list[Path] = []
|
|
261
|
+
merged_bodies: list[str] = []
|
|
262
|
+
preferred_slug = display_slug(display_name)
|
|
263
|
+
for path in sorted(PEOPLE_DIR.glob("*.md")):
|
|
264
|
+
profile = parse_profile(path)
|
|
265
|
+
explicitly_selected = source is not None and path == source and path != canonical
|
|
266
|
+
if not explicitly_selected and not alias_candidate(
|
|
267
|
+
path,
|
|
268
|
+
profile,
|
|
269
|
+
canonical=canonical,
|
|
270
|
+
person_id=person_id,
|
|
271
|
+
github=github,
|
|
272
|
+
github_aliases=github_aliases,
|
|
273
|
+
preferred_slug=preferred_slug,
|
|
274
|
+
):
|
|
275
|
+
continue
|
|
276
|
+
alias_paths.append(path)
|
|
277
|
+
if profile["body"] and "This identity is represented by" not in profile["body"]:
|
|
278
|
+
merged_bodies.append(profile["body"])
|
|
279
|
+
alias_github = profile["fields"].get("GitHub", "")
|
|
280
|
+
if alias_github and alias_github.lower() != github.lower():
|
|
281
|
+
github_aliases = clean_strings(github_aliases + [alias_github], lower=True)
|
|
282
|
+
if (
|
|
283
|
+
profile["title"]
|
|
284
|
+
and profile["title"].lower() != display_name.lower()
|
|
285
|
+
):
|
|
286
|
+
previous_names = clean_strings(previous_names + [profile["title"]])
|
|
287
|
+
|
|
288
|
+
for title in (current["title"], source_profile["title"]):
|
|
289
|
+
if title and title.lower() != display_name.lower():
|
|
290
|
+
previous_names = clean_strings(previous_names + [title])
|
|
291
|
+
|
|
292
|
+
metadata = {
|
|
293
|
+
"Person-ID": person_id,
|
|
294
|
+
"GitHub": github,
|
|
295
|
+
"GitHub-ID": str(github_id or ""),
|
|
296
|
+
"GitHub-Aliases": ", ".join(github_aliases),
|
|
297
|
+
"Email": emails[0] if emails else "",
|
|
298
|
+
"Emails": ", ".join(emails),
|
|
299
|
+
"Previous-Names": ", ".join(previous_names),
|
|
300
|
+
"Role": (
|
|
301
|
+
current_fields.get("Role")
|
|
302
|
+
or source_fields.get("Role")
|
|
303
|
+
or role
|
|
304
|
+
or "Member"
|
|
305
|
+
),
|
|
306
|
+
"Joined": (
|
|
307
|
+
current_fields.get("Joined")
|
|
308
|
+
or source_fields.get("Joined")
|
|
309
|
+
or joined
|
|
310
|
+
or datetime.now(timezone.utc).date().isoformat()
|
|
311
|
+
),
|
|
312
|
+
"Onboarded": (
|
|
313
|
+
current_fields.get("Onboarded")
|
|
314
|
+
or source_fields.get("Onboarded")
|
|
315
|
+
or onboarded
|
|
316
|
+
),
|
|
317
|
+
}
|
|
318
|
+
bodies = clean_strings([
|
|
319
|
+
current["body"],
|
|
320
|
+
source_profile["body"],
|
|
321
|
+
*merged_bodies,
|
|
322
|
+
])
|
|
323
|
+
rendered = [f"# {display_name}", ""]
|
|
324
|
+
rendered.extend(
|
|
325
|
+
f"{field}: {metadata[field]}"
|
|
326
|
+
for field in FIELD_ORDER
|
|
327
|
+
if metadata[field] or field != "Onboarded"
|
|
328
|
+
)
|
|
329
|
+
if bodies:
|
|
330
|
+
rendered.extend(["", "\n\n".join(bodies)])
|
|
331
|
+
|
|
332
|
+
canonical_text = "\n".join(rendered).rstrip() + "\n"
|
|
333
|
+
alias_texts: dict[Path, str] = {}
|
|
334
|
+
for path in alias_paths:
|
|
335
|
+
profile = parse_profile(path)
|
|
336
|
+
alias_texts[path] = "\n".join(
|
|
337
|
+
[
|
|
338
|
+
f"# {profile['title'] or display_name}",
|
|
339
|
+
"",
|
|
340
|
+
f"Person-ID: {person_id}",
|
|
341
|
+
f"Alias-Of: {canonical.name}",
|
|
342
|
+
f"GitHub: {github}",
|
|
343
|
+
"",
|
|
344
|
+
f"This identity is represented by [{canonical.name}](./{canonical.name}).",
|
|
345
|
+
"",
|
|
346
|
+
]
|
|
347
|
+
)
|
|
348
|
+
canonical_changed = (
|
|
349
|
+
not canonical_existed
|
|
350
|
+
or canonical.read_text(encoding="utf-8") != canonical_text
|
|
351
|
+
)
|
|
352
|
+
aliases_changed = any(
|
|
353
|
+
not path.exists() or path.read_text(encoding="utf-8") != text
|
|
354
|
+
for path, text in alias_texts.items()
|
|
355
|
+
)
|
|
356
|
+
profile_action = (
|
|
357
|
+
"create"
|
|
358
|
+
if not canonical_existed
|
|
359
|
+
else "update"
|
|
360
|
+
if canonical_changed or aliases_changed
|
|
361
|
+
else "unchanged"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if not dry_run:
|
|
365
|
+
canonical.parent.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
if canonical_changed:
|
|
367
|
+
atomic_text(canonical, canonical_text)
|
|
368
|
+
for path, alias_text in alias_texts.items():
|
|
369
|
+
if not path.exists() or path.read_text(encoding="utf-8") != alias_text:
|
|
370
|
+
atomic_text(path, alias_text)
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"schema": "egregore-person-identity/v1",
|
|
374
|
+
"person_id": person_id,
|
|
375
|
+
"github_username": github,
|
|
376
|
+
"github_id": github_id,
|
|
377
|
+
"github_name": github_name or display_name,
|
|
378
|
+
"display_name": display_name,
|
|
379
|
+
"email": emails[0] if emails else None,
|
|
380
|
+
"emails": emails,
|
|
381
|
+
"github_aliases": github_aliases,
|
|
382
|
+
"previous_names": previous_names,
|
|
383
|
+
"profile_path": f"people/{canonical.name}",
|
|
384
|
+
"source_profile": source.name if source else None,
|
|
385
|
+
"profile_action": profile_action,
|
|
386
|
+
"aliases_reconciled": [path.name for path in alias_paths],
|
|
387
|
+
"dry_run": dry_run,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def reconcile_profile(args: argparse.Namespace) -> dict[str, Any]:
|
|
392
|
+
"""Reconcile an arbitrary org member without touching the current user state."""
|
|
393
|
+
github = (args.github or "").strip()
|
|
394
|
+
if not github or not re.fullmatch(r"[A-Za-z0-9-]{1,39}", github):
|
|
395
|
+
raise ValueError("a valid GitHub login is required")
|
|
396
|
+
github_id = int(args.github_id) if args.github_id not in (None, "") else None
|
|
397
|
+
if github_id is None:
|
|
398
|
+
# Self-heal: adopt the numeric id the canonical profile already
|
|
399
|
+
# carries so a caller without --github-id cannot degrade a
|
|
400
|
+
# github:<id> profile back to github-login form.
|
|
401
|
+
profile_github_id = str(
|
|
402
|
+
parse_profile(PEOPLE_DIR / f"{github}.md")["fields"].get("GitHub-ID") or ""
|
|
403
|
+
).strip()
|
|
404
|
+
if profile_github_id.isdigit():
|
|
405
|
+
github_id = int(profile_github_id)
|
|
406
|
+
person_id = (
|
|
407
|
+
f"github:{github_id}"
|
|
408
|
+
if github_id is not None
|
|
409
|
+
else f"github-login:{github.lower()}"
|
|
410
|
+
)
|
|
411
|
+
display_name = (
|
|
412
|
+
args.display_name or args.github_name or github
|
|
413
|
+
).strip()
|
|
414
|
+
source, ambiguous = choose_source_profile(
|
|
415
|
+
github=github,
|
|
416
|
+
display_name=display_name,
|
|
417
|
+
github_name=args.github_name or "",
|
|
418
|
+
)
|
|
419
|
+
if args.source_profile:
|
|
420
|
+
requested = PEOPLE_DIR / Path(args.source_profile).name
|
|
421
|
+
if not requested.exists():
|
|
422
|
+
raise ValueError(f"profile does not exist: {requested.name}")
|
|
423
|
+
source = requested
|
|
424
|
+
ambiguous = []
|
|
425
|
+
|
|
426
|
+
aliases = clean_strings(comma_values(args.github_aliases), lower=True)
|
|
427
|
+
prior_github = (args.previous_github or "").strip()
|
|
428
|
+
if prior_github and prior_github.lower() != github.lower():
|
|
429
|
+
aliases = clean_strings(aliases + [prior_github], lower=True)
|
|
430
|
+
result = render_identity_profile(
|
|
431
|
+
canonical=PEOPLE_DIR / f"{github}.md",
|
|
432
|
+
source=source,
|
|
433
|
+
person_id=person_id,
|
|
434
|
+
github=github,
|
|
435
|
+
github_id=github_id,
|
|
436
|
+
github_name=(args.github_name or "").strip(),
|
|
437
|
+
display_name=display_name,
|
|
438
|
+
emails=clean_strings(comma_values(args.emails) + [args.email], lower=True),
|
|
439
|
+
github_aliases=aliases,
|
|
440
|
+
previous_names=[],
|
|
441
|
+
role=(args.role or "").strip(),
|
|
442
|
+
joined=(args.joined or "").strip(),
|
|
443
|
+
onboarded="",
|
|
444
|
+
dry_run=args.dry_run,
|
|
445
|
+
)
|
|
446
|
+
result["ambiguous_profiles"] = ambiguous
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def reconcile(args: argparse.Namespace) -> dict[str, Any]:
|
|
451
|
+
state = json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
|
452
|
+
previous_github = str(state.get("github_username") or "").strip()
|
|
453
|
+
github = (args.github or state.get("github_username") or "").strip()
|
|
454
|
+
if not github:
|
|
455
|
+
raise ValueError("github_username is required")
|
|
456
|
+
github_id = args.github_id or state.get("github_id")
|
|
457
|
+
github_id = int(github_id) if github_id not in (None, "") else None
|
|
458
|
+
if github_id is None:
|
|
459
|
+
# Self-heal pre-migration state: a state file written before the
|
|
460
|
+
# numeric-id migration carries github_id null and a github-login
|
|
461
|
+
# person_id, which degraded github:<id> profiles back to login
|
|
462
|
+
# form on every boot. Adopt the numeric id from the canonical
|
|
463
|
+
# profile; state.update() below persists it durably.
|
|
464
|
+
profile_github_id = str(
|
|
465
|
+
parse_profile(PEOPLE_DIR / f"{github}.md")["fields"].get("GitHub-ID") or ""
|
|
466
|
+
).strip()
|
|
467
|
+
if profile_github_id.isdigit():
|
|
468
|
+
github_id = int(profile_github_id)
|
|
469
|
+
person_id = (
|
|
470
|
+
f"github:{github_id}" if github_id is not None
|
|
471
|
+
else str(state.get("person_id") or f"github-login:{github.lower()}")
|
|
472
|
+
)
|
|
473
|
+
display_name = (
|
|
474
|
+
args.display_name
|
|
475
|
+
or state.get("display_name")
|
|
476
|
+
or state.get("github_name")
|
|
477
|
+
or github
|
|
478
|
+
).strip()
|
|
479
|
+
full_name = str(state.get("github_name") or display_name).strip()
|
|
480
|
+
emails = clean_strings(
|
|
481
|
+
[args.email, state.get("email"), *comma_values(state.get("emails"))],
|
|
482
|
+
lower=True,
|
|
483
|
+
)
|
|
484
|
+
github_aliases = clean_strings(
|
|
485
|
+
comma_values(state.get("github_aliases")), lower=True
|
|
486
|
+
)
|
|
487
|
+
if previous_github and previous_github.lower() != github.lower():
|
|
488
|
+
github_aliases = clean_strings(
|
|
489
|
+
github_aliases + [previous_github], lower=True
|
|
490
|
+
)
|
|
491
|
+
previous_names = clean_strings(comma_values(state.get("previous_names")))
|
|
492
|
+
canonical = PEOPLE_DIR / f"{github}.md"
|
|
493
|
+
current = parse_profile(canonical)
|
|
494
|
+
fields = current["fields"]
|
|
495
|
+
emails = clean_strings(
|
|
496
|
+
emails + comma_values(fields.get("Emails")) + [fields.get("Email")],
|
|
497
|
+
lower=True,
|
|
498
|
+
)
|
|
499
|
+
github_aliases = clean_strings(
|
|
500
|
+
github_aliases + comma_values(fields.get("GitHub-Aliases")),
|
|
501
|
+
lower=True,
|
|
502
|
+
)
|
|
503
|
+
previous_names = clean_strings(
|
|
504
|
+
previous_names + comma_values(fields.get("Previous-Names"))
|
|
505
|
+
)
|
|
506
|
+
if current["title"] and current["title"].lower() != display_name.lower():
|
|
507
|
+
previous_names = clean_strings(previous_names + [current["title"]])
|
|
508
|
+
|
|
509
|
+
aliases_reconciled: list[str] = []
|
|
510
|
+
merged_bodies: list[str] = []
|
|
511
|
+
PEOPLE_DIR.mkdir(parents=True, exist_ok=True)
|
|
512
|
+
for path in sorted(PEOPLE_DIR.glob("*.md")):
|
|
513
|
+
profile = parse_profile(path)
|
|
514
|
+
if not alias_candidate(
|
|
515
|
+
path,
|
|
516
|
+
profile,
|
|
517
|
+
canonical=canonical,
|
|
518
|
+
person_id=person_id,
|
|
519
|
+
github=github,
|
|
520
|
+
github_aliases=github_aliases,
|
|
521
|
+
preferred_slug=display_slug(display_name),
|
|
522
|
+
):
|
|
523
|
+
continue
|
|
524
|
+
aliases_reconciled.append(path.name)
|
|
525
|
+
if profile["body"] and "This identity is represented by" not in profile["body"]:
|
|
526
|
+
merged_bodies.append(profile["body"])
|
|
527
|
+
alias_github = profile["fields"].get("GitHub", "") or path.stem
|
|
528
|
+
if alias_github.lower() != github.lower():
|
|
529
|
+
github_aliases = clean_strings(github_aliases + [alias_github], lower=True)
|
|
530
|
+
if profile["title"] and profile["title"].lower() != display_name.lower():
|
|
531
|
+
previous_names = clean_strings(previous_names + [profile["title"]])
|
|
532
|
+
alias_text = "\n".join(
|
|
533
|
+
[
|
|
534
|
+
f"# {profile['title'] or display_name}",
|
|
535
|
+
"",
|
|
536
|
+
f"Person-ID: {person_id}",
|
|
537
|
+
f"Alias-Of: {canonical.name}",
|
|
538
|
+
f"GitHub: {github}",
|
|
539
|
+
"",
|
|
540
|
+
f"This identity is represented by [{canonical.name}](./{canonical.name}).",
|
|
541
|
+
"",
|
|
542
|
+
]
|
|
543
|
+
)
|
|
544
|
+
atomic_text(path, alias_text)
|
|
545
|
+
|
|
546
|
+
role = state.get("member_role") or fields.get("Role") or "Member"
|
|
547
|
+
now = datetime.now(timezone.utc)
|
|
548
|
+
joined = fields.get("Joined") or now.date().isoformat()
|
|
549
|
+
onboarded = fields.get("Onboarded") or (now.isoformat() if args.onboarded else "")
|
|
550
|
+
metadata = {
|
|
551
|
+
"Person-ID": person_id,
|
|
552
|
+
"GitHub": github,
|
|
553
|
+
"GitHub-ID": str(github_id or ""),
|
|
554
|
+
"GitHub-Aliases": ", ".join(github_aliases),
|
|
555
|
+
"Email": emails[0] if emails else "",
|
|
556
|
+
"Emails": ", ".join(emails),
|
|
557
|
+
"Previous-Names": ", ".join(previous_names),
|
|
558
|
+
"Role": str(role),
|
|
559
|
+
"Joined": joined,
|
|
560
|
+
"Onboarded": onboarded,
|
|
561
|
+
}
|
|
562
|
+
rendered = [f"# {display_name}", ""]
|
|
563
|
+
rendered.extend(
|
|
564
|
+
f"{field}: {metadata[field]}"
|
|
565
|
+
for field in FIELD_ORDER
|
|
566
|
+
if metadata[field] or field not in {"Onboarded"}
|
|
567
|
+
)
|
|
568
|
+
bodies = clean_strings([current["body"], *merged_bodies])
|
|
569
|
+
if bodies:
|
|
570
|
+
rendered.extend(["", "\n\n".join(bodies)])
|
|
571
|
+
atomic_text(canonical, "\n".join(rendered).rstrip() + "\n")
|
|
572
|
+
|
|
573
|
+
state.update(
|
|
574
|
+
{
|
|
575
|
+
"person_id": person_id,
|
|
576
|
+
"github_username": github,
|
|
577
|
+
"github_id": github_id,
|
|
578
|
+
"display_name": display_name,
|
|
579
|
+
"email": emails[0] if emails else None,
|
|
580
|
+
"emails": emails,
|
|
581
|
+
"github_aliases": github_aliases,
|
|
582
|
+
"previous_names": previous_names,
|
|
583
|
+
"identity_version": 1,
|
|
584
|
+
}
|
|
585
|
+
)
|
|
586
|
+
atomic_json(STATE_PATH, state)
|
|
587
|
+
return {
|
|
588
|
+
"schema": "egregore-person-identity/v1",
|
|
589
|
+
"person_id": person_id,
|
|
590
|
+
"github_username": github,
|
|
591
|
+
"github_id": github_id,
|
|
592
|
+
"github_name": full_name,
|
|
593
|
+
"display_name": display_name,
|
|
594
|
+
"email": emails[0] if emails else None,
|
|
595
|
+
"emails": emails,
|
|
596
|
+
"github_aliases": github_aliases,
|
|
597
|
+
"previous_names": previous_names,
|
|
598
|
+
"profile_path": f"people/{canonical.name}",
|
|
599
|
+
"aliases_reconciled": aliases_reconciled,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def main() -> int:
|
|
604
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
605
|
+
parser.add_argument("command", choices=("sync-local", "sync-profile"))
|
|
606
|
+
parser.add_argument("--github")
|
|
607
|
+
parser.add_argument("--github-id", type=int)
|
|
608
|
+
parser.add_argument("--github-name")
|
|
609
|
+
parser.add_argument("--github-aliases")
|
|
610
|
+
parser.add_argument("--previous-github")
|
|
611
|
+
parser.add_argument("--display-name")
|
|
612
|
+
parser.add_argument("--email")
|
|
613
|
+
parser.add_argument("--emails")
|
|
614
|
+
parser.add_argument("--role")
|
|
615
|
+
parser.add_argument("--joined")
|
|
616
|
+
parser.add_argument("--source-profile")
|
|
617
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
618
|
+
parser.add_argument("--onboarded", action="store_true")
|
|
619
|
+
args = parser.parse_args()
|
|
620
|
+
try:
|
|
621
|
+
result = reconcile_profile(args) if args.command == "sync-profile" else reconcile(args)
|
|
622
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
623
|
+
return 0
|
|
624
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
625
|
+
print(json.dumps({"status": "error", "error": str(exc)}))
|
|
626
|
+
return 2
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
if __name__ == "__main__":
|
|
630
|
+
raise SystemExit(main())
|