create-egregore 0.19.8 → 0.19.11

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 (91) hide show
  1. package/lib/local.js +11 -1
  2. package/lib/setup.js +33 -0
  3. package/package.json +1 -1
  4. package/runtime/codex/.codex/hooks/branch-guard.js +242 -34
  5. package/runtime/codex/.codex/skills/activity/SKILL.md +18 -6
  6. package/runtime/codex/.codex/skills/deep-reflect/SKILL.md +90 -55
  7. package/runtime/codex/.codex/skills/deploy-bridge/SKILL.md +25 -0
  8. package/runtime/codex/.codex/skills/handoff/SKILL.md +6 -2
  9. package/runtime/codex/.codex/skills/save/SKILL.md +21 -3
  10. package/runtime/codex/.codex/skills/scroll/SKILL.md +54 -3
  11. package/runtime/codex/.codex/skills/task/SKILL.md +25 -0
  12. package/runtime/codex/.codex/skills/tui-design/SKILL.md +1 -1
  13. package/runtime/codex/.codex/skills/wrap/SKILL.md +4 -1
  14. package/runtime/codex/.codex/spec-manifest.json +15 -3
  15. package/runtime/codex/AGENTS.md +45 -9
  16. package/runtime/codex/bin/activity-data.sh +1 -1
  17. package/runtime/codex/bin/agent.sh +66 -23
  18. package/runtime/codex/bin/attendant.sh +525 -0
  19. package/runtime/codex/bin/capture-reconcile.sh +67 -0
  20. package/runtime/codex/bin/capture-run.sh +341 -0
  21. package/runtime/codex/bin/codex-render-spec.mjs +15 -4
  22. package/runtime/codex/bin/codex-session-start.sh +45 -3
  23. package/runtime/codex/bin/codex-skill-render.mjs +23 -4
  24. package/runtime/codex/bin/graph-op.sh +719 -49
  25. package/runtime/codex/bin/graph-wal.sh +3 -2
  26. package/runtime/codex/bin/handoff-run.sh +72 -0
  27. package/runtime/codex/bin/handoff-save-egregore.sh +51 -11
  28. package/runtime/codex/bin/index-handoff.sh +26 -17
  29. package/runtime/codex/bin/ingest-graph.sh +80 -0
  30. package/runtime/codex/bin/ingest-surface.html +429 -0
  31. package/runtime/codex/bin/ingest.py +614 -0
  32. package/runtime/codex/bin/ingest.sh +231 -0
  33. package/runtime/codex/bin/ingest_graph.py +776 -0
  34. package/runtime/codex/bin/ingest_surface.py +464 -0
  35. package/runtime/codex/bin/lib/git-sync.sh +2 -6
  36. package/runtime/codex/bin/lib/greeting.sh +157 -22
  37. package/runtime/codex/bin/lib/identity.sh +50 -29
  38. package/runtime/codex/bin/lib/noncode.sh +31 -0
  39. package/runtime/codex/bin/person.py +610 -0
  40. package/runtime/codex/bin/person.sh +448 -0
  41. package/runtime/codex/bin/session-autosave.sh +184 -0
  42. package/runtime/codex/bin/session-log.sh +199 -0
  43. package/runtime/codex/bin/settings.sh +1 -1
  44. package/runtime/codex/manifest.json +19 -1
  45. package/runtime/pi/.codex/hooks/branch-guard.js +242 -34
  46. package/runtime/pi/.codex/skills/activity/SKILL.md +18 -6
  47. package/runtime/pi/.codex/skills/deep-reflect/SKILL.md +90 -55
  48. package/runtime/pi/.codex/skills/deploy-bridge/SKILL.md +25 -0
  49. package/runtime/pi/.codex/skills/handoff/SKILL.md +6 -2
  50. package/runtime/pi/.codex/skills/save/SKILL.md +21 -3
  51. package/runtime/pi/.codex/skills/scroll/SKILL.md +54 -3
  52. package/runtime/pi/.codex/skills/task/SKILL.md +25 -0
  53. package/runtime/pi/.codex/skills/tui-design/SKILL.md +1 -1
  54. package/runtime/pi/.codex/skills/wrap/SKILL.md +4 -1
  55. package/runtime/pi/.codex/spec-manifest.json +15 -3
  56. package/runtime/pi/.pi/APPEND_SYSTEM.md +41 -5
  57. package/runtime/pi/.pi/extensions/egregore.ts +98 -8
  58. package/runtime/pi/.pi/spec-manifest.json +2 -2
  59. package/runtime/pi/AGENTS.md +45 -9
  60. package/runtime/pi/bin/activity-data.sh +1 -1
  61. package/runtime/pi/bin/agent.sh +66 -23
  62. package/runtime/pi/bin/attendant.sh +525 -0
  63. package/runtime/pi/bin/capture-reconcile.sh +67 -0
  64. package/runtime/pi/bin/capture-run.sh +341 -0
  65. package/runtime/pi/bin/codex-render-spec.mjs +15 -4
  66. package/runtime/pi/bin/codex-session-start.sh +45 -3
  67. package/runtime/pi/bin/codex-skill-render.mjs +23 -4
  68. package/runtime/pi/bin/graph-op.sh +719 -49
  69. package/runtime/pi/bin/graph-wal.sh +3 -2
  70. package/runtime/pi/bin/handoff-run.sh +72 -0
  71. package/runtime/pi/bin/handoff-save-egregore.sh +51 -11
  72. package/runtime/pi/bin/index-handoff.sh +26 -17
  73. package/runtime/pi/bin/ingest-graph.sh +80 -0
  74. package/runtime/pi/bin/ingest-surface.html +429 -0
  75. package/runtime/pi/bin/ingest.py +614 -0
  76. package/runtime/pi/bin/ingest.sh +231 -0
  77. package/runtime/pi/bin/ingest_graph.py +776 -0
  78. package/runtime/pi/bin/ingest_surface.py +464 -0
  79. package/runtime/pi/bin/lib/git-sync.sh +2 -6
  80. package/runtime/pi/bin/lib/greeting.sh +157 -22
  81. package/runtime/pi/bin/lib/identity.sh +50 -29
  82. package/runtime/pi/bin/lib/noncode.sh +31 -0
  83. package/runtime/pi/bin/person.py +610 -0
  84. package/runtime/pi/bin/person.sh +448 -0
  85. package/runtime/pi/bin/pi-branch-policy.mjs +164 -4
  86. package/runtime/pi/bin/pi-product-workflows.mjs +31 -2
  87. package/runtime/pi/bin/publish-artifact.sh +14 -0
  88. package/runtime/pi/bin/session-autosave.sh +184 -0
  89. package/runtime/pi/bin/session-log.sh +199 -0
  90. package/runtime/pi/bin/settings.sh +274 -0
  91. package/runtime/pi/manifest.json +20 -1
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env python3
2
+ """Local, one-shot browser surface for selecting Egregore intake files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import mimetypes
10
+ import os
11
+ import re
12
+ import secrets
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import urllib.parse
18
+ import webbrowser
19
+ from datetime import datetime, timezone
20
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
21
+ from pathlib import Path, PurePosixPath
22
+ from threading import Event, Lock
23
+
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+ HTML_PATH = Path(__file__).with_name("ingest-surface.html")
27
+ DESIGN_CSS_PATH = ROOT / "packages" / "design-system" / "dist" / "egregore-design.css"
28
+ SELECTION_SCHEMA = "egregore-ingest-selection/v1"
29
+ STALE_AFTER_SECONDS = 24 * 60 * 60
30
+ MAX_JSON_BYTES = 64 * 1024
31
+ RECEIPT_GRACE_SECONDS = 20 * 60
32
+ VALID_THEME_PAIRS = {"meridian", "agronomic", "sovereign"}
33
+ VALID_THEME_MODES = {"light", "auto", "dark"}
34
+
35
+
36
+ def ingest_root() -> Path:
37
+ override = os.environ.get("EGREGORE_INGEST_ROOT")
38
+ return Path(override).resolve() if override else (ROOT / ".egregore" / "ingest").resolve()
39
+
40
+
41
+ def staging_root() -> Path:
42
+ override = os.environ.get("EGREGORE_INGEST_STAGING_ROOT")
43
+ return Path(override).resolve() if override else (ROOT / ".egregore" / "ingest-staging").resolve()
44
+
45
+
46
+ def preferences_path() -> Path:
47
+ override = os.environ.get("EGREGORE_INGEST_UI_PREFERENCES")
48
+ return Path(override).resolve() if override else (ROOT / ".egregore" / "ingest-ui-preferences.json").resolve()
49
+
50
+
51
+ def load_preferences(path: Path) -> dict[str, str]:
52
+ try:
53
+ value = json.loads(path.read_text(encoding="utf-8"))
54
+ except (OSError, json.JSONDecodeError):
55
+ value = {}
56
+ pair = value.get("pair") if value.get("pair") in VALID_THEME_PAIRS else "sovereign"
57
+ mode = value.get("mode") if value.get("mode") in VALID_THEME_MODES else "auto"
58
+ return {"pair": pair, "mode": mode}
59
+
60
+
61
+ def save_preferences(path: Path, value: dict) -> dict[str, str]:
62
+ pair = value.get("pair")
63
+ mode = value.get("mode")
64
+ if pair not in VALID_THEME_PAIRS or mode not in VALID_THEME_MODES:
65
+ raise ValueError("invalid theme preference")
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ temporary = path.with_suffix(path.suffix + ".tmp")
68
+ result = {"pair": pair, "mode": mode}
69
+ temporary.write_text(json.dumps(result, sort_keys=True) + "\n", encoding="utf-8")
70
+ temporary.replace(path)
71
+ return result
72
+
73
+
74
+ def slug(value: str) -> str:
75
+ result = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
76
+ if not result:
77
+ raise ValueError("source id must contain at least one letter or number")
78
+ return result
79
+
80
+
81
+ def safe_relative_path(raw: str) -> str:
82
+ value = urllib.parse.unquote(raw).strip()
83
+ if not value or "\x00" in value or "\\" in value:
84
+ raise ValueError("invalid relative path")
85
+ path = PurePosixPath(value)
86
+ if path.is_absolute() or any(part in {"", ".", ".."} or part.startswith(".") for part in path.parts):
87
+ raise ValueError("path must be a visible relative path without traversal")
88
+ if len(value) > 2048:
89
+ raise ValueError("relative path is too long")
90
+ return path.as_posix()
91
+
92
+
93
+ def clean_stale_sessions(base: Path, now: float | None = None) -> None:
94
+ if not base.exists():
95
+ return
96
+ cutoff = (now if now is not None else time.time()) - STALE_AFTER_SECONDS
97
+ for child in base.iterdir():
98
+ try:
99
+ if child.is_dir() and child.stat().st_mtime < cutoff:
100
+ shutil.rmtree(child)
101
+ except OSError:
102
+ continue
103
+
104
+
105
+ class SelectionState:
106
+ def __init__(self, staging_base: Path, session_id: str | None = None) -> None:
107
+ self.session_id = session_id or secrets.token_hex(12)
108
+ self.session_root = (staging_base / self.session_id).resolve()
109
+ self.files_root = self.session_root / "files"
110
+ self.files_root.mkdir(parents=True, exist_ok=False)
111
+ self.items: dict[str, dict] = {}
112
+ self.done = Event()
113
+ self.cancelled = False
114
+ self.result: dict | None = None
115
+ self.receipt: dict = {"status": "selecting"}
116
+ self.receipt_done = Event()
117
+ self.receipt_ack = Event()
118
+ self.receipt_url = ""
119
+ self.receipt_token = ""
120
+ self.lock = Lock()
121
+
122
+ def upload(self, relative_path: str, mime: str, stream, size: int) -> dict:
123
+ relative = safe_relative_path(relative_path)
124
+ if size < 0:
125
+ raise ValueError("invalid content length")
126
+ free = shutil.disk_usage(self.session_root).free
127
+ if size > max(0, free - 16 * 1024 * 1024):
128
+ raise ValueError("not enough free disk space for this file")
129
+
130
+ with self.lock:
131
+ if self.done.is_set():
132
+ raise ValueError("selection is already closed")
133
+ if relative in self.items:
134
+ existing = self.items[relative]
135
+ hasher = hashlib.sha256()
136
+ remaining = size
137
+ while remaining:
138
+ block = stream.read(min(1024 * 1024, remaining))
139
+ if not block:
140
+ raise ValueError("upload ended before content length")
141
+ hasher.update(block)
142
+ remaining -= len(block)
143
+ if size == existing["bytes"] and hasher.hexdigest() == existing["sha256"]:
144
+ return existing
145
+ raise FileExistsError(f"duplicate relative path has different bytes: {relative}")
146
+ destination = (self.files_root / relative).resolve()
147
+ if self.files_root not in destination.parents:
148
+ raise ValueError("staged path escaped the selection root")
149
+ destination.parent.mkdir(parents=True, exist_ok=True)
150
+ temporary = destination.with_name(destination.name + ".upload")
151
+ hasher = hashlib.sha256()
152
+ remaining = size
153
+ try:
154
+ with temporary.open("xb") as handle:
155
+ while remaining:
156
+ block = stream.read(min(1024 * 1024, remaining))
157
+ if not block:
158
+ raise ValueError("upload ended before content length")
159
+ handle.write(block)
160
+ hasher.update(block)
161
+ remaining -= len(block)
162
+ temporary.replace(destination)
163
+ except Exception:
164
+ temporary.unlink(missing_ok=True)
165
+ raise
166
+
167
+ item = {
168
+ "relative_path": relative,
169
+ "local_path": str(destination),
170
+ "mime": mime or mimetypes.guess_type(relative)[0] or "application/octet-stream",
171
+ "bytes": size,
172
+ "sha256": hasher.hexdigest(),
173
+ }
174
+ self.items[relative] = item
175
+ return item
176
+
177
+ def confirm(self, payload: dict) -> dict:
178
+ with self.lock:
179
+ if self.done.is_set():
180
+ raise ValueError("selection is already closed")
181
+ source = payload.get("source")
182
+ if not isinstance(source, dict):
183
+ raise ValueError("source contract is required")
184
+ if "org" in payload or "org" in source:
185
+ raise ValueError("org is derived from egregore.json and cannot be supplied")
186
+ source_id = slug(str(source.get("id", "")))
187
+ name = str(source.get("name", "")).strip() or source_id
188
+ if len(name) > 200:
189
+ raise ValueError("source name is too long")
190
+ boundaries_raw = source.get("boundaries", {})
191
+ if not isinstance(boundaries_raw, dict):
192
+ raise ValueError("boundaries must be an object")
193
+ boundaries: dict[str, str] = {}
194
+ for raw_key, raw_value in boundaries_raw.items():
195
+ key = slug(str(raw_key))
196
+ value = str(raw_value).strip()
197
+ if not value:
198
+ raise ValueError(f"boundary value is empty: {raw_key}")
199
+ boundaries[key] = value
200
+ if not self.items:
201
+ raise ValueError("select at least one file")
202
+
203
+ created_at = datetime.now(timezone.utc).isoformat()
204
+ manifest = {
205
+ "schema": SELECTION_SCHEMA,
206
+ "session_id": self.session_id,
207
+ "created_at": created_at,
208
+ "source": {
209
+ "id": source_id,
210
+ "name": name,
211
+ "kind": "files",
212
+ "boundaries": dict(sorted(boundaries.items())),
213
+ "authoritative_snapshot": bool(source.get("authoritative_snapshot", False)),
214
+ },
215
+ "staging_root": str(self.session_root),
216
+ "items": [self.items[key] for key in sorted(self.items)],
217
+ "receipt": {"url": self.receipt_url, "token": self.receipt_token},
218
+ }
219
+ manifest_path = self.session_root / "selection.json"
220
+ temporary = manifest_path.with_suffix(".json.tmp")
221
+ temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
222
+ temporary.replace(manifest_path)
223
+ self.result = {
224
+ "schema": SELECTION_SCHEMA,
225
+ "selection_path": str(manifest_path),
226
+ "source_id": source_id,
227
+ "files": len(self.items),
228
+ "bytes": sum(item["bytes"] for item in self.items.values()),
229
+ "receipt_url": self.receipt_url,
230
+ }
231
+ self.receipt = {**self.result, "status": "processing", "message": "The agent is validating and indexing this selection."}
232
+ self.done.set()
233
+ return self.result
234
+
235
+ def finish_receipt(self, payload: dict) -> dict:
236
+ with self.lock:
237
+ if not self.done.is_set() or self.cancelled:
238
+ raise ValueError("selection has not been confirmed")
239
+ status = payload.get("status")
240
+ if status not in {"complete", "attention", "failed"}:
241
+ raise ValueError("invalid receipt status")
242
+ self.receipt = payload
243
+ self.receipt_done.set()
244
+ return self.receipt
245
+
246
+ def cancel(self) -> None:
247
+ with self.lock:
248
+ self.cancelled = True
249
+ self.done.set()
250
+
251
+ def cleanup(self) -> None:
252
+ shutil.rmtree(self.session_root, ignore_errors=True)
253
+
254
+
255
+ def handler_class(state: SelectionState, token: str, expected_origin: str, preference_file: Path | None = None):
256
+ html_template = HTML_PATH.read_text(encoding="utf-8")
257
+ design_css = DESIGN_CSS_PATH.read_text(encoding="utf-8")
258
+ capabilities = {"pdftotext": bool(shutil.which("pdftotext"))}
259
+ preferences = load_preferences(preference_file or preferences_path())
260
+ page = (
261
+ html_template.replace("__EGREGORE_DESIGN_CSS__", design_css)
262
+ .replace("__EGREGORE_TOKEN_JSON__", json.dumps(token))
263
+ .replace("__EGREGORE_CAPABILITIES_JSON__", json.dumps(capabilities))
264
+ .replace("__EGREGORE_PREFERENCES_JSON__", json.dumps(preferences))
265
+ .encode("utf-8")
266
+ )
267
+ entry_path = f"/{token}/"
268
+
269
+ class Handler(BaseHTTPRequestHandler):
270
+ server_version = "EgregoreIngest/1"
271
+
272
+ def log_message(self, _format: str, *_args) -> None:
273
+ return
274
+
275
+ def send_headers(self, status: int, content_type: str, length: int) -> None:
276
+ self.send_response(status)
277
+ self.send_header("Content-Type", content_type)
278
+ self.send_header("Content-Length", str(length))
279
+ self.send_header("Cache-Control", "no-store")
280
+ self.send_header("X-Content-Type-Options", "nosniff")
281
+ self.send_header("X-Frame-Options", "DENY")
282
+ self.send_header("Referrer-Policy", "no-referrer")
283
+ self.send_header("Cross-Origin-Opener-Policy", "same-origin")
284
+ self.send_header(
285
+ "Content-Security-Policy",
286
+ "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
287
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
288
+ )
289
+ self.end_headers()
290
+
291
+ def send_json(self, status: int, value: object) -> None:
292
+ body = json.dumps(value, ensure_ascii=False).encode("utf-8")
293
+ self.send_headers(status, "application/json; charset=utf-8", len(body))
294
+ self.wfile.write(body)
295
+
296
+ def do_GET(self) -> None:
297
+ if urllib.parse.urlsplit(self.path).path != entry_path:
298
+ self.send_json(404, {"error": "not found"})
299
+ return
300
+ self.send_headers(200, "text/html; charset=utf-8", len(page))
301
+ self.wfile.write(page)
302
+
303
+ def authorized(self) -> bool:
304
+ return secrets.compare_digest(self.headers.get("X-Egregore-Token", ""), token) and self.headers.get("Origin") == expected_origin
305
+
306
+ def token_authorized(self) -> bool:
307
+ return secrets.compare_digest(self.headers.get("X-Egregore-Token", ""), token)
308
+
309
+ def read_json(self) -> dict:
310
+ try:
311
+ length = int(self.headers.get("Content-Length", "-1"))
312
+ except ValueError as exc:
313
+ raise ValueError("invalid content length") from exc
314
+ if length < 0 or length > MAX_JSON_BYTES:
315
+ raise ValueError("invalid JSON body size")
316
+ value = json.loads(self.rfile.read(length))
317
+ if not isinstance(value, dict):
318
+ raise ValueError("JSON body must be an object")
319
+ return value
320
+
321
+ def do_POST(self) -> None:
322
+ path = urllib.parse.urlsplit(self.path).path
323
+ if path == "/api/result":
324
+ if not self.token_authorized():
325
+ self.send_json(403, {"error": "invalid receipt token"})
326
+ return
327
+ try:
328
+ self.send_json(200, state.finish_receipt(self.read_json()))
329
+ except (ValueError, OSError, json.JSONDecodeError) as exc:
330
+ self.send_json(400, {"error": str(exc)})
331
+ return
332
+ if not self.authorized():
333
+ self.send_json(403, {"error": "invalid selection token or origin"})
334
+ return
335
+ try:
336
+ if path == "/api/upload":
337
+ raw_length = self.headers.get("Content-Length")
338
+ if raw_length is None:
339
+ raise ValueError("content length is required")
340
+ item = state.upload(
341
+ self.headers.get("X-Egregore-Path", ""),
342
+ self.headers.get("Content-Type", ""),
343
+ self.rfile,
344
+ int(raw_length),
345
+ )
346
+ self.send_json(201, item)
347
+ return
348
+ if path == "/api/confirm":
349
+ result = state.confirm(self.read_json())
350
+ self.send_json(200, result)
351
+ return
352
+ if path == "/api/cancel":
353
+ state.cancel()
354
+ self.send_json(200, {"cancelled": True})
355
+ return
356
+ if path == "/api/status":
357
+ self.send_json(200, state.receipt)
358
+ return
359
+ if path == "/api/ack":
360
+ state.receipt_ack.set()
361
+ self.send_json(200, {"acknowledged": True})
362
+ return
363
+ if path == "/api/preferences":
364
+ saved = save_preferences(preference_file or preferences_path(), self.read_json())
365
+ self.send_json(200, saved)
366
+ return
367
+ self.send_json(404, {"error": "not found"})
368
+ except FileExistsError as exc:
369
+ self.send_json(409, {"error": str(exc)})
370
+ except (ValueError, OSError, json.JSONDecodeError) as exc:
371
+ self.send_json(400, {"error": str(exc)})
372
+
373
+ return Handler
374
+
375
+
376
+ def build_server(state: SelectionState, token: str, host: str = "127.0.0.1", port: int = 0, preference_file: Path | None = None) -> ThreadingHTTPServer:
377
+ server = ThreadingHTTPServer((host, port), BaseHTTPRequestHandler)
378
+ actual_host, actual_port = server.server_address[:2]
379
+ origin = f"http://{actual_host}:{actual_port}"
380
+ state.receipt_url = origin
381
+ state.receipt_token = token
382
+ server.RequestHandlerClass = handler_class(state, token, origin, preference_file)
383
+ server.daemon_threads = True
384
+ return server
385
+
386
+
387
+ def serve_picker(args: argparse.Namespace) -> int:
388
+ staging_base = Path(args.staging_root).resolve() if args.staging_root else staging_root()
389
+ staging_base.mkdir(parents=True, exist_ok=True)
390
+ clean_stale_sessions(staging_base)
391
+ state = SelectionState(staging_base)
392
+ token = secrets.token_urlsafe(24)
393
+ server = build_server(state, token, port=args.port)
394
+ host, port = server.server_address[:2]
395
+ url = f"http://{host}:{port}/{token}/"
396
+ server.timeout = 0.25
397
+ print(json.dumps({"event": "ready", "url": url}), flush=True)
398
+ deadline = time.monotonic() + args.timeout
399
+ try:
400
+ while not state.done.is_set() and time.monotonic() < deadline:
401
+ server.handle_request()
402
+ except KeyboardInterrupt:
403
+ state.cancel()
404
+ if not state.done.is_set():
405
+ server.server_close()
406
+ state.cleanup()
407
+ print("ingest picker timed out", file=sys.stderr)
408
+ return 2
409
+ if state.cancelled:
410
+ server.server_close()
411
+ state.cleanup()
412
+ print(json.dumps({"schema": SELECTION_SCHEMA, "cancelled": True}))
413
+ return 0
414
+ print(json.dumps({"event": "selected", **state.result}, ensure_ascii=False), flush=True)
415
+ receipt_deadline = time.monotonic() + args.receipt_timeout
416
+ while not state.receipt_ack.is_set() and time.monotonic() < receipt_deadline:
417
+ server.handle_request()
418
+ if state.receipt_done.is_set() and time.monotonic() + 5 >= receipt_deadline:
419
+ break
420
+ server.server_close()
421
+ return 0
422
+
423
+
424
+ def run_picker(args: argparse.Namespace) -> int:
425
+ command = [sys.executable, str(Path(__file__).resolve()), "--serve", "--port", str(args.port), "--timeout", str(args.timeout), "--receipt-timeout", str(args.receipt_timeout)]
426
+ if args.staging_root:
427
+ command.extend(["--staging-root", args.staging_root])
428
+ child = subprocess.Popen(
429
+ command,
430
+ cwd=ROOT,
431
+ text=True,
432
+ stdout=subprocess.PIPE,
433
+ stderr=subprocess.DEVNULL,
434
+ start_new_session=True,
435
+ )
436
+ assert child.stdout
437
+ ready = json.loads(child.stdout.readline())
438
+ url = ready["url"]
439
+ if not args.no_open:
440
+ webbrowser.open(url, new=1)
441
+ print(f"ingest picker: {url}", file=sys.stderr, flush=True)
442
+ selected = json.loads(child.stdout.readline())
443
+ if selected.get("event") == "selected":
444
+ selected.pop("event", None)
445
+ print(json.dumps(selected, ensure_ascii=False), flush=True)
446
+ return 0
447
+ print(json.dumps({"schema": SELECTION_SCHEMA, "cancelled": True}))
448
+ return 0
449
+
450
+
451
+ def parser() -> argparse.ArgumentParser:
452
+ result = argparse.ArgumentParser(description=__doc__)
453
+ result.add_argument("--port", type=int, default=0)
454
+ result.add_argument("--timeout", type=int, default=30 * 60)
455
+ result.add_argument("--receipt-timeout", type=int, default=RECEIPT_GRACE_SECONDS)
456
+ result.add_argument("--staging-root")
457
+ result.add_argument("--no-open", action="store_true")
458
+ result.add_argument("--serve", action="store_true", help=argparse.SUPPRESS)
459
+ return result
460
+
461
+
462
+ if __name__ == "__main__":
463
+ options = parser().parse_args()
464
+ raise SystemExit(serve_picker(options) if options.serve else run_picker(options))
@@ -265,6 +265,8 @@ if [ -d "$SCRIPT_DIR/memory/.git" ]; then
265
265
  fi
266
266
 
267
267
  # --- Sync managed repos ---
268
+ # Managed repos sync silently — they are a CLI concern and never render in the
269
+ # greeting. REPOS_STATUS stays exported (empty) for consumers of this lib.
268
270
  REPOS_STATUS=""
269
271
  for REPO in $MANAGED_REPOS; do
270
272
  REPO_DIR="$SCRIPT_DIR/../$REPO"
@@ -278,11 +280,5 @@ for REPO in $MANAGED_REPOS; do
278
280
  else
279
281
  git -C "$REPO_DIR" merge --ff-only "origin/$REPO_BASE" --quiet 2>/dev/null || true
280
282
  fi
281
- # Collect status
282
- R_DIRTY=""
283
- if [ -n "$(git -C "$REPO_DIR" status --porcelain 2>/dev/null | head -1)" ]; then
284
- R_DIRTY=" *"
285
- fi
286
- REPOS_STATUS="${REPOS_STATUS} ◇ ${REPO}: ${R_BRANCH}${R_DIRTY}\n"
287
283
  fi
288
284
  done