create-egregore 0.19.33 → 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.
Files changed (171) hide show
  1. package/assets/egregore-launcher.js +118 -13
  2. package/bin/cli.js +19 -3
  3. package/lib/connect-runtime.js +4 -2
  4. package/lib/local.js +15 -1
  5. package/lib/prime-runtime.js +208 -0
  6. package/lib/setup.js +45 -3
  7. package/package.json +4 -3
  8. package/runtime/codex/.codex/skills/ingest-notion/SKILL.md +29 -0
  9. package/runtime/codex/.codex/skills/notion-connect/SKILL.md +29 -0
  10. package/runtime/codex/.codex/skills/teams-connect/SKILL.md +29 -0
  11. package/runtime/codex/.codex/spec-manifest.json +3 -3
  12. package/runtime/codex/AGENTS.md +11 -4
  13. package/runtime/codex/bin/activity-data.sh +4 -8
  14. package/runtime/codex/bin/attendant.sh +13 -0
  15. package/runtime/codex/bin/autosave.sh +27 -1
  16. package/runtime/codex/bin/codex-render-spec.mjs +6 -0
  17. package/runtime/codex/bin/codex-session-start.sh +3 -1
  18. package/runtime/codex/bin/graph-batch.sh +25 -6
  19. package/runtime/codex/bin/graph-op.sh +5 -4
  20. package/runtime/codex/bin/graph.sh +31 -5
  21. package/runtime/codex/bin/ingest.py +81 -5
  22. package/runtime/codex/bin/lib/context.sh +26 -1
  23. package/runtime/codex/bin/lib/greeting.sh +4 -1
  24. package/runtime/codex/bin/lib/worktree-links.sh +25 -0
  25. package/runtime/codex/manifest.json +7 -1
  26. package/runtime/pi/.codex/skills/ingest-notion/SKILL.md +29 -0
  27. package/runtime/pi/.codex/skills/notion-connect/SKILL.md +29 -0
  28. package/runtime/pi/.codex/skills/teams-connect/SKILL.md +29 -0
  29. package/runtime/pi/.codex/spec-manifest.json +3 -3
  30. package/runtime/pi/.pi/APPEND_SYSTEM.md +3 -2
  31. package/runtime/pi/.pi/extensions/egregore.ts +17 -6
  32. package/runtime/pi/.pi/spec-manifest.json +2 -2
  33. package/runtime/pi/AGENTS.md +11 -4
  34. package/runtime/pi/bin/activity-data.sh +4 -8
  35. package/runtime/pi/bin/attendant.sh +13 -0
  36. package/runtime/pi/bin/autosave.sh +27 -1
  37. package/runtime/pi/bin/codex-render-spec.mjs +6 -0
  38. package/runtime/pi/bin/codex-session-start.sh +3 -1
  39. package/runtime/pi/bin/graph-batch.sh +25 -6
  40. package/runtime/pi/bin/graph-op.sh +5 -4
  41. package/runtime/pi/bin/graph.sh +31 -5
  42. package/runtime/pi/bin/ingest.py +81 -5
  43. package/runtime/pi/bin/lib/context.sh +26 -1
  44. package/runtime/pi/bin/lib/greeting.sh +4 -1
  45. package/runtime/pi/bin/lib/worktree-links.sh +25 -0
  46. package/runtime/pi/bin/pi-branch-policy.mjs +138 -1
  47. package/runtime/pi/bin/pi-product-workflows.mjs +35 -7
  48. package/runtime/pi/manifest.json +7 -1
  49. package/runtime/prime/.claude/context/notification-consent.md +66 -0
  50. package/runtime/prime/.codex/config.toml +17 -0
  51. package/runtime/prime/.codex/hooks/branch-guard.js +461 -0
  52. package/runtime/prime/.codex/hooks/search-hint.js +67 -0
  53. package/runtime/prime/.codex/hooks.json +30 -0
  54. package/runtime/prime/.codex/skills/activity/SKILL.md +82 -0
  55. package/runtime/prime/.codex/skills/announce/SKILL.md +67 -0
  56. package/runtime/prime/.codex/skills/archive/SKILL.md +45 -0
  57. package/runtime/prime/.codex/skills/branch/SKILL.md +29 -0
  58. package/runtime/prime/.codex/skills/checkup/SKILL.md +45 -0
  59. package/runtime/prime/.codex/skills/commit/SKILL.md +29 -0
  60. package/runtime/prime/.codex/skills/contribute/SKILL.md +29 -0
  61. package/runtime/prime/.codex/skills/create-skill/SKILL.md +29 -0
  62. package/runtime/prime/.codex/skills/dark-mode/SKILL.md +29 -0
  63. package/runtime/prime/.codex/skills/dashboard/SKILL.md +59 -0
  64. package/runtime/prime/.codex/skills/deep-reflect/SKILL.md +109 -0
  65. package/runtime/prime/.codex/skills/delete-user/SKILL.md +29 -0
  66. package/runtime/prime/.codex/skills/emissary/SKILL.md +45 -0
  67. package/runtime/prime/.codex/skills/handoff/SKILL.md +166 -0
  68. package/runtime/prime/.codex/skills/harvest/QUESTION_PALETTE.md +76 -0
  69. package/runtime/prime/.codex/skills/harvest/SKILL.md +131 -0
  70. package/runtime/prime/.codex/skills/ingest/SKILL.md +29 -0
  71. package/runtime/prime/.codex/skills/ingest-notion/SKILL.md +29 -0
  72. package/runtime/prime/.codex/skills/ingest-user-interview/SKILL.md +45 -0
  73. package/runtime/prime/.codex/skills/invite/SKILL.md +44 -0
  74. package/runtime/prime/.codex/skills/issue/SKILL.md +45 -0
  75. package/runtime/prime/.codex/skills/me/SKILL.md +29 -0
  76. package/runtime/prime/.codex/skills/meeting/SKILL.md +45 -0
  77. package/runtime/prime/.codex/skills/note/SKILL.md +29 -0
  78. package/runtime/prime/.codex/skills/notion-connect/SKILL.md +29 -0
  79. package/runtime/prime/.codex/skills/onboarding/SKILL.md +45 -0
  80. package/runtime/prime/.codex/skills/pr/SKILL.md +29 -0
  81. package/runtime/prime/.codex/skills/pull/SKILL.md +29 -0
  82. package/runtime/prime/.codex/skills/push/SKILL.md +29 -0
  83. package/runtime/prime/.codex/skills/quest/SKILL.md +80 -0
  84. package/runtime/prime/.codex/skills/reflect/SKILL.md +45 -0
  85. package/runtime/prime/.codex/skills/review-pr/SKILL.md +45 -0
  86. package/runtime/prime/.codex/skills/save/SKILL.md +125 -0
  87. package/runtime/prime/.codex/skills/slack-connect/SKILL.md +29 -0
  88. package/runtime/prime/.codex/skills/summon/SKILL.md +45 -0
  89. package/runtime/prime/.codex/skills/sync-repos/SKILL.md +29 -0
  90. package/runtime/prime/.codex/skills/teams-connect/SKILL.md +29 -0
  91. package/runtime/prime/.codex/skills/telegram-connect/SKILL.md +29 -0
  92. package/runtime/prime/.codex/skills/telemetry/SKILL.md +29 -0
  93. package/runtime/prime/.codex/skills/test/SKILL.md +45 -0
  94. package/runtime/prime/.codex/skills/the-spiral/SKILL.md +84 -0
  95. package/runtime/prime/.codex/skills/tui-design/SKILL.md +45 -0
  96. package/runtime/prime/.codex/skills/tutorial/SKILL.md +45 -0
  97. package/runtime/prime/.codex/skills/update/SKILL.md +29 -0
  98. package/runtime/prime/.codex/skills/view/SKILL.md +156 -0
  99. package/runtime/prime/.codex/skills/view/agents/openai.yaml +4 -0
  100. package/runtime/prime/.codex/skills/view/assets/compose-scaffold.html +221 -0
  101. package/runtime/prime/.codex/skills/visual-explain/SKILL.md +45 -0
  102. package/runtime/prime/.codex/skills/wrap/SKILL.md +58 -0
  103. package/runtime/prime/.codex/spec-manifest.json +132 -0
  104. package/runtime/prime/.prime/agent/APPEND_SYSTEM.md +393 -0
  105. package/runtime/prime/.prime/agent/extensions/egregore.ts +460 -0
  106. package/runtime/prime/.prime/agent/settings.json +4 -0
  107. package/runtime/prime/.prime/agent/skills/egregore/SKILL.md +34 -0
  108. package/runtime/prime/.prime/agent/skills/egregore/pyproject.toml +13 -0
  109. package/runtime/prime/.prime/agent/skills/egregore/src/egregore/__init__.py +156 -0
  110. package/runtime/prime/.prime/agent/spec-manifest.json +12 -0
  111. package/runtime/prime/AGENTS.md +489 -0
  112. package/runtime/prime/bin/activity-data.sh +238 -0
  113. package/runtime/prime/bin/agent.sh +775 -0
  114. package/runtime/prime/bin/attendant.sh +707 -0
  115. package/runtime/prime/bin/autosave.sh +112 -0
  116. package/runtime/prime/bin/capture-reconcile.sh +69 -0
  117. package/runtime/prime/bin/capture-run.sh +341 -0
  118. package/runtime/prime/bin/codex-install-skills.sh +78 -0
  119. package/runtime/prime/bin/codex-render-spec.mjs +440 -0
  120. package/runtime/prime/bin/codex-session-start.sh +265 -0
  121. package/runtime/prime/bin/codex-skill-render.mjs +347 -0
  122. package/runtime/prime/bin/codex-sync-skills.sh +232 -0
  123. package/runtime/prime/bin/connect-refresh.sh +33 -0
  124. package/runtime/prime/bin/dashboard-data.sh +178 -0
  125. package/runtime/prime/bin/graph-batch.sh +79 -0
  126. package/runtime/prime/bin/graph-op.sh +1102 -0
  127. package/runtime/prime/bin/graph-wal.sh +229 -0
  128. package/runtime/prime/bin/graph.sh +223 -0
  129. package/runtime/prime/bin/handoff-pr-backfill.sh +86 -0
  130. package/runtime/prime/bin/handoff-run.sh +671 -0
  131. package/runtime/prime/bin/handoff-save-egregore.sh +212 -0
  132. package/runtime/prime/bin/index-handoff.sh +322 -0
  133. package/runtime/prime/bin/ingest-graph.sh +80 -0
  134. package/runtime/prime/bin/ingest-surface.html +495 -0
  135. package/runtime/prime/bin/ingest.py +756 -0
  136. package/runtime/prime/bin/ingest.sh +279 -0
  137. package/runtime/prime/bin/ingest_graph.py +856 -0
  138. package/runtime/prime/bin/ingest_surface.py +544 -0
  139. package/runtime/prime/bin/lib/artifact-id.sh +46 -0
  140. package/runtime/prime/bin/lib/config.sh +116 -0
  141. package/runtime/prime/bin/lib/context.sh +621 -0
  142. package/runtime/prime/bin/lib/dashboard-artifact.sh +242 -0
  143. package/runtime/prime/bin/lib/ensure-develop.sh +183 -0
  144. package/runtime/prime/bin/lib/git-sync.sh +406 -0
  145. package/runtime/prime/bin/lib/greeting.sh +519 -0
  146. package/runtime/prime/bin/lib/hash.sh +13 -0
  147. package/runtime/prime/bin/lib/identity.sh +213 -0
  148. package/runtime/prime/bin/lib/metrics.sh +374 -0
  149. package/runtime/prime/bin/lib/noncode.sh +31 -0
  150. package/runtime/prime/bin/lib/permissions.sh +106 -0
  151. package/runtime/prime/bin/lib/time.sh +56 -0
  152. package/runtime/prime/bin/lib/worktree-links.sh +79 -0
  153. package/runtime/prime/bin/notify.sh +636 -0
  154. package/runtime/prime/bin/person.py +630 -0
  155. package/runtime/prime/bin/person.sh +448 -0
  156. package/runtime/prime/bin/pi-branch-policy.mjs +312 -0
  157. package/runtime/prime/bin/pi-product-workflows.mjs +394 -0
  158. package/runtime/prime/bin/prime-render-spec.mjs +123 -0
  159. package/runtime/prime/bin/prime-session-start.sh +24 -0
  160. package/runtime/prime/bin/publish-artifact.sh +271 -0
  161. package/runtime/prime/bin/publish-references.sh +144 -0
  162. package/runtime/prime/bin/repo-state.sh +96 -0
  163. package/runtime/prime/bin/search.sh +240 -0
  164. package/runtime/prime/bin/session-autosave.sh +209 -0
  165. package/runtime/prime/bin/session-log.sh +199 -0
  166. package/runtime/prime/bin/settings.sh +304 -0
  167. package/runtime/prime/bin/telemetry.sh +391 -0
  168. package/runtime/prime/bin/worktree-create.sh +128 -0
  169. package/runtime/prime/bin/worktree-remove.sh +50 -0
  170. package/runtime/prime/bin/worktree.sh +198 -0
  171. 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())