ghost-reader 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. ghost_reader/.release-version +1 -0
  2. ghost_reader/__init__.py +3 -0
  3. ghost_reader/agent_loader.py +64 -0
  4. ghost_reader/cli.py +1124 -0
  5. ghost_reader/constants.py +75 -0
  6. ghost_reader/defaults/__init__.py +1 -0
  7. ghost_reader/defaults/personas/__init__.py +1 -0
  8. ghost_reader/defaults/personas/dex.yaml +30 -0
  9. ghost_reader/defaults/personas/elena.yaml +30 -0
  10. ghost_reader/defaults/personas/mara.yaml +30 -0
  11. ghost_reader/defaults/personas/pip.yaml +30 -0
  12. ghost_reader/defaults/personas/rook.yaml +30 -0
  13. ghost_reader/defaults/templates/__init__.py +1 -0
  14. ghost_reader/defaults/templates/blog-review.html +384 -0
  15. ghost_reader/defaults/templates/report.html +1293 -0
  16. ghost_reader/dialogue.py +283 -0
  17. ghost_reader/errors.py +2 -0
  18. ghost_reader/feedback_store.py +56 -0
  19. ghost_reader/io.py +59 -0
  20. ghost_reader/models.py +227 -0
  21. ghost_reader/paths.py +68 -0
  22. ghost_reader/project.py +277 -0
  23. ghost_reader/release.py +56 -0
  24. ghost_reader/report.py +264 -0
  25. ghost_reader/reviews.py +89 -0
  26. ghost_reader/revision.py +165 -0
  27. ghost_reader/round.py +155 -0
  28. ghost_reader/server.py +281 -0
  29. ghost_reader/sync.py +112 -0
  30. ghost_reader/telemetry.py +111 -0
  31. ghost_reader/time.py +20 -0
  32. ghost_reader/validators.py +66 -0
  33. ghost_reader/verify.py +255 -0
  34. ghost_reader-0.0.1.dist-info/METADATA +221 -0
  35. ghost_reader-0.0.1.dist-info/RECORD +39 -0
  36. ghost_reader-0.0.1.dist-info/WHEEL +5 -0
  37. ghost_reader-0.0.1.dist-info/entry_points.txt +2 -0
  38. ghost_reader-0.0.1.dist-info/licenses/LICENSE +21 -0
  39. ghost_reader-0.0.1.dist-info/top_level.txt +1 -0
ghost_reader/sync.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import urllib.error
6
+ import urllib.request
7
+ from pathlib import Path
8
+
9
+ from ghost_reader.errors import GhostReaderError
10
+ from ghost_reader.io import write_yaml
11
+ from ghost_reader.paths import sync_state_path
12
+ from ghost_reader.project import read_config
13
+ from ghost_reader.telemetry import pending_events, read_sync_state
14
+ from ghost_reader.time import now_iso, timestamp
15
+
16
+ _MAX_RETRIES = 3
17
+ _RETRY_BASE_DELAY = 2.0 # seconds, doubles each retry
18
+
19
+
20
+ def _post_bundle(endpoint: str, bundle_path: Path) -> bool:
21
+ """POST the NDJSON bundle to the sync endpoint with retry + backoff."""
22
+ payload = bundle_path.read_bytes()
23
+ last_error: str | None = None
24
+ for attempt in range(_MAX_RETRIES):
25
+ try:
26
+ req = urllib.request.Request(
27
+ endpoint,
28
+ data=payload,
29
+ headers={"Content-Type": "application/x-ndjson"},
30
+ method="POST",
31
+ )
32
+ resp = urllib.request.urlopen(req, timeout=30)
33
+ if 200 <= resp.status < 300:
34
+ return True
35
+ last_error = f"HTTP {resp.status}"
36
+ except urllib.error.HTTPError as exc:
37
+ last_error = f"HTTP {exc.code}"
38
+ except Exception as exc:
39
+ last_error = str(exc)
40
+ if attempt < _MAX_RETRIES - 1:
41
+ time.sleep(_RETRY_BASE_DELAY * (2 ** attempt))
42
+ raise GhostReaderError(f"Sync failed after {_MAX_RETRIES} attempts: {last_error}")
43
+
44
+
45
+ def create_sync_bundle(home: Path) -> dict[str, object]:
46
+ events = pending_events(home)
47
+ if not events:
48
+ return {
49
+ "sync_status": "no_pending_events",
50
+ "event_count": 0,
51
+ "retained_local_telemetry": True,
52
+ }
53
+
54
+ created_at = now_iso()
55
+ bundle_id = timestamp()
56
+ bundle = home / "telemetry" / "synced" / f"{bundle_id}-bundle.ndjson"
57
+ bundle.write_text(
58
+ "\n".join(
59
+ json.dumps(event, ensure_ascii=False, sort_keys=True) for event in events
60
+ )
61
+ + "\n",
62
+ encoding="utf-8",
63
+ )
64
+
65
+ # Attempt remote sync if configured
66
+ sync_status = "local_bundle_created"
67
+ last_error: str | None = None
68
+ try:
69
+ config = read_config(home)
70
+ endpoint = (config.get("telemetry") or {}).get("sync_endpoint")
71
+ if endpoint and isinstance(endpoint, str) and endpoint.strip():
72
+ _post_bundle(endpoint.strip(), bundle)
73
+ sync_status = "remote_sync_succeeded"
74
+ except Exception as exc:
75
+ last_error = str(exc)
76
+ sync_status = "remote_sync_failed"
77
+
78
+ receipt = {
79
+ "schema_version": 1,
80
+ "created_at": created_at,
81
+ "status": sync_status,
82
+ "bundle": str(bundle),
83
+ "event_count": len(events),
84
+ "event_ids": [event["event_id"] for event in events],
85
+ }
86
+ if last_error:
87
+ receipt["last_error"] = last_error
88
+ write_yaml(home / "telemetry" / "receipts" / f"{bundle_id}.yaml", receipt)
89
+ state = read_sync_state(home)
90
+ state.update(
91
+ {
92
+ "schema_version": 1,
93
+ "synced_event_ids": list(
94
+ dict.fromkeys(
95
+ [*state.get("synced_event_ids", []), *receipt["event_ids"]]
96
+ )
97
+ ),
98
+ "last_sync_at": receipt["created_at"],
99
+ "last_sync_status": receipt["status"],
100
+ "last_error": last_error,
101
+ }
102
+ )
103
+ write_yaml(sync_state_path(home), state)
104
+ result: dict[str, object] = {
105
+ "sync_status": sync_status,
106
+ "bundle": str(bundle),
107
+ "event_count": len(events),
108
+ "retained_local_telemetry": True,
109
+ }
110
+ if last_error:
111
+ result["last_error"] = last_error
112
+ return result
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ghost_reader import __version__
9
+ from ghost_reader.constants import CONTENT_SENSITIVE_TELEMETRY_KEYS, TELEMETRY_EVENTS
10
+ from ghost_reader.errors import GhostReaderError
11
+ from ghost_reader.io import load_yaml_file
12
+ from ghost_reader.paths import outbox_path, session_dir, sync_state_path
13
+ from ghost_reader.project import read_config
14
+ from ghost_reader.time import now_iso
15
+
16
+
17
+ def append_ndjson(path: Path, payload: dict[str, Any]) -> None:
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ with path.open("a", encoding="utf-8") as handle:
20
+ handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
21
+
22
+
23
+ def read_events(path: Path) -> list[dict[str, Any]]:
24
+ if not path.exists():
25
+ return []
26
+ return [
27
+ json.loads(line)
28
+ for line in path.read_text(encoding="utf-8").splitlines()
29
+ if line.strip()
30
+ ]
31
+
32
+
33
+ def sanitize_meta(meta: dict[str, Any]) -> dict[str, Any]:
34
+ forbidden = sorted(CONTENT_SENSITIVE_TELEMETRY_KEYS.intersection(meta.keys()))
35
+ if forbidden:
36
+ raise GhostReaderError(
37
+ f"Telemetry metadata includes forbidden content-sensitive keys: {', '.join(forbidden)}"
38
+ )
39
+ return meta
40
+
41
+
42
+ def append_event(
43
+ home: Path,
44
+ project_root: Path | None,
45
+ event_name: str,
46
+ session_id: str | None = None,
47
+ surface: str = "cli",
48
+ meta: dict[str, Any] | None = None,
49
+ correlation_id: str | None = None,
50
+ ) -> dict[str, Any]:
51
+ if event_name not in TELEMETRY_EVENTS:
52
+ raise GhostReaderError(f"Unsupported telemetry event `{event_name}`.")
53
+ config = read_config(home)
54
+ payload = {
55
+ "schema_version": 1,
56
+ "event_id": str(uuid.uuid4()),
57
+ "occurred_at": now_iso(),
58
+ "event": event_name,
59
+ "tool_version": __version__,
60
+ "surface": surface,
61
+ "install_id": config.get("install_id"),
62
+ "session_id": session_id,
63
+ "meta": sanitize_meta(meta or {}),
64
+ }
65
+ if correlation_id:
66
+ payload["correlation_id"] = correlation_id
67
+ append_ndjson(outbox_path(home), payload)
68
+ if project_root and session_id:
69
+ append_ndjson(
70
+ session_dir(project_root, session_id) / "telemetry" / "events.ndjson",
71
+ payload,
72
+ )
73
+ return payload
74
+
75
+
76
+ def read_sync_state(home: Path) -> dict[str, Any]:
77
+ path = sync_state_path(home)
78
+ if not path.exists():
79
+ return {
80
+ "schema_version": 1,
81
+ "synced_event_ids": [],
82
+ "last_sync_at": None,
83
+ "last_sync_status": None,
84
+ "last_error": None,
85
+ }
86
+ state = load_yaml_file(path)
87
+ state.setdefault("synced_event_ids", [])
88
+ return state
89
+
90
+
91
+ def pending_events(home: Path) -> list[dict[str, Any]]:
92
+ synced = set(read_sync_state(home).get("synced_event_ids", []))
93
+ return [
94
+ event
95
+ for event in read_events(outbox_path(home))
96
+ if event.get("event_id") not in synced
97
+ ]
98
+
99
+
100
+ def telemetry_status(home: Path) -> dict[str, Any]:
101
+ state = read_sync_state(home)
102
+ events = pending_events(home)
103
+ return {
104
+ "pending_events": len(events),
105
+ "pending_sessions": len(
106
+ {event.get("session_id") for event in events if event.get("session_id")}
107
+ ),
108
+ "last_sync_at": state.get("last_sync_at"),
109
+ "last_sync_status": state.get("last_sync_status"),
110
+ "last_error": state.get("last_error"),
111
+ }
ghost_reader/time.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ def now_iso() -> str:
7
+ return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
8
+
9
+
10
+ def timestamp() -> str:
11
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
12
+
13
+
14
+ def new_session_id(story_unit: str | None = None) -> str:
15
+ stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
16
+ if not story_unit:
17
+ return stamp
18
+ slug = "".join(ch.lower() if ch.isalnum() else "-" for ch in story_unit).strip("-")
19
+ slug = "-".join(part for part in slug.split("-") if part)
20
+ return f"{stamp}-{slug[:48]}" if slug else stamp
@@ -0,0 +1,66 @@
1
+ """Validator functions — thin wrappers around Pydantic models.
2
+
3
+ Preserved for backward compatibility. All functions delegate to Pydantic
4
+ model_validate() and wrap ValidationError as GhostReaderError.
5
+
6
+ New code should use ghost_reader.models.{Persona,Review,Feedback} directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from ghost_reader.errors import GhostReaderError
14
+ from ghost_reader.models import Feedback, Persona, Review
15
+
16
+
17
+ def _wrap(cls: type, data: dict[str, Any]) -> Any:
18
+ """Validate dict against Pydantic model, wrapping as GhostReaderError."""
19
+ from pydantic import ValidationError
20
+
21
+ try:
22
+ return cls.model_validate(data)
23
+ except ValidationError as e:
24
+ msgs = [f"{err['loc']}: {err['msg']}" for err in e.errors()]
25
+ raise GhostReaderError(f"Validation failed: {'; '.join(msgs)}") from e
26
+
27
+
28
+ def validate_persona(persona: dict[str, Any]) -> None:
29
+ """Validate a persona dict against the Persona schema.
30
+
31
+ Raises GhostReaderError on validation failure (backward compatible).
32
+ """
33
+ _wrap(Persona, persona)
34
+
35
+
36
+ def validate_review(
37
+ review: dict[str, Any],
38
+ session_id: str | None = None,
39
+ persona_id: str | None = None,
40
+ ) -> None:
41
+ """Validate a review dict against the Review schema.
42
+
43
+ Raises GhostReaderError on validation failure (backward compatible).
44
+ Optionally validates session_id and persona_id match if provided.
45
+ """
46
+ model = _wrap(Review, review)
47
+ if session_id and model.session_id != session_id:
48
+ raise GhostReaderError(
49
+ f"Review session_id `{model.session_id}` does not match `{session_id}`."
50
+ )
51
+ if persona_id and model.persona_id != persona_id:
52
+ raise GhostReaderError(
53
+ f"Review persona_id `{model.persona_id}` does not match `{persona_id}`."
54
+ )
55
+
56
+
57
+ def validate_feedback(feedback: dict[str, Any], session_id: str) -> None:
58
+ """Validate a feedback dict against the Feedback schema.
59
+
60
+ Raises GhostReaderError on validation failure (backward compatible).
61
+ """
62
+ model = _wrap(Feedback, feedback)
63
+ if model.session_id != session_id:
64
+ raise GhostReaderError(
65
+ f"Feedback session_id `{model.session_id}` does not match `{session_id}`."
66
+ )
ghost_reader/verify.py ADDED
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ghost_reader.constants import SCHEMA_VERSIONS
8
+ from ghost_reader.errors import GhostReaderError
9
+ from ghost_reader.io import load_yaml_file
10
+ from ghost_reader.paths import artifact_paths
11
+ from ghost_reader.project import load_manifest, save_manifest, save_round_manifest
12
+ from ghost_reader.reviews import feedback_summary, load_reviews
13
+ from ghost_reader.telemetry import read_events
14
+
15
+
16
+ def verify_session(project_root: Path, home: Path, session_id: str) -> dict[str, Any]:
17
+ paths = artifact_paths(project_root, session_id)
18
+ session_path = paths["session"]
19
+ manifest = load_manifest(project_root, session_id)
20
+ personas = manifest.get("personas", [])
21
+ current_round = manifest.get("current_round", 1)
22
+ issues: list[str] = []
23
+ warnings: list[str] = []
24
+
25
+ if not personas:
26
+ issues.append("manifest has no selected personas")
27
+
28
+ reviews = load_reviews(project_root, home, session_id)
29
+ review_personas = {review["persona_id"] for review in reviews}
30
+ missing_reviews = [
31
+ persona_id for persona_id in personas if persona_id not in review_personas
32
+ ]
33
+ if missing_reviews:
34
+ issues.append(f"missing review artifacts for: {', '.join(missing_reviews)}")
35
+
36
+ round_manifest_path = paths["active_round"] / "manifest.yaml"
37
+ refinement_count = 0
38
+ round_manifest = None
39
+ if round_manifest_path.exists():
40
+ round_manifest = load_yaml_file(round_manifest_path)
41
+ refinement_count = round_manifest.get("refinement_count", 0)
42
+
43
+ report_summary = verify_report(paths, session_id, personas, issues)
44
+ telemetry_summary = verify_telemetry(session_path, session_id, personas, issues)
45
+ feedback = feedback_summary(project_root, session_id)
46
+ prompt_summary = verify_prompt(
47
+ paths, session_id, feedback["feedback_found"], issues
48
+ )
49
+ sync_summary = verify_sync_scaffold(home, issues)
50
+ verify_no_mixed_artifacts(session_path, issues)
51
+
52
+ dialogue_dir = paths["dialogue"]
53
+ dialogue_files = (
54
+ sorted(dialogue_dir.glob("*.md"))
55
+ if dialogue_dir.exists()
56
+ else []
57
+ )
58
+ dialogue_index_found = (dialogue_dir / "index.yaml").exists() if dialogue_dir.exists() else False
59
+
60
+ if issues:
61
+ raise GhostReaderError("Verification failed: " + "; ".join(issues))
62
+
63
+ revision_prompt_found = prompt_summary.get("revision_prompt_found", False)
64
+ if feedback["feedback_found"] and revision_prompt_found:
65
+ status = "phase1_complete"
66
+ elif refinement_count > 0 and not revision_prompt_found:
67
+ status = "refinement_complete"
68
+ else:
69
+ status = "review_ready_feedback_pending"
70
+
71
+ if round_manifest_path.exists() and round_manifest.get("status") != status:
72
+ round_manifest["status"] = status
73
+ save_round_manifest(project_root, session_id, current_round, round_manifest)
74
+ for entry in manifest.get("rounds", []):
75
+ if entry.get("id") == current_round:
76
+ entry["status"] = status
77
+ break
78
+ save_manifest(project_root, session_id, manifest)
79
+
80
+ return {
81
+ "ok": True,
82
+ "status": status,
83
+ "session_id": session_id,
84
+ "current_round": current_round,
85
+ "story_unit": manifest.get("story_unit"),
86
+ "personas": personas,
87
+ "reviews": {
88
+ "expected": personas,
89
+ "found": [review["persona_id"] for review in reviews],
90
+ "paths": {
91
+ review["persona_id"]: str(
92
+ paths["reviews"] / f"{review['persona_id']}.review.yaml"
93
+ )
94
+ for review in reviews
95
+ },
96
+ },
97
+ "report": report_summary,
98
+ "feedback": {
99
+ "feedback_found": feedback["feedback_found"],
100
+ "selected_item_count": len(feedback["selected_items"]),
101
+ },
102
+ "revision_prompt": prompt_summary,
103
+ "telemetry": telemetry_summary,
104
+ "dialogue": {
105
+ "personas_with_dialogue": [
106
+ path.stem for path in dialogue_files
107
+ ],
108
+ "file_count": len(dialogue_files),
109
+ "index_found": dialogue_index_found,
110
+ },
111
+ "round": {
112
+ "current": current_round,
113
+ "refinement_count": refinement_count,
114
+ },
115
+ "sync_scaffold": sync_summary,
116
+ "warnings": warnings,
117
+ }
118
+
119
+
120
+ def verify_report(
121
+ paths: dict[str, Path], session_id: str, personas: list[str], issues: list[str]
122
+ ) -> dict[str, Any]:
123
+ payload_path = paths["report"] / "payload.json"
124
+ html_path = paths["report"] / "index.html"
125
+ if not payload_path.exists():
126
+ issues.append("missing report/payload.json")
127
+ return {"payload_found": False, "html_found": html_path.exists()}
128
+
129
+ payload = json.loads(payload_path.read_text(encoding="utf-8"))
130
+ template_name = payload.get("template_name") or "report"
131
+ output_name = "index.html" if template_name == "report" else f"{template_name}.html"
132
+ html_path = paths["report"] / output_name
133
+ if not html_path.exists():
134
+ issues.append(f"missing report/{output_name}")
135
+
136
+ if payload.get("session_id") != session_id:
137
+ issues.append("report payload session_id does not match session")
138
+ payload_personas = [
139
+ review.get("persona_id") for review in payload.get("reviews", [])
140
+ ]
141
+ missing_payload_reviews = [
142
+ persona_id for persona_id in personas if persona_id not in payload_personas
143
+ ]
144
+ if missing_payload_reviews:
145
+ issues.append(
146
+ f"report payload missing reviews for: {', '.join(missing_payload_reviews)}"
147
+ )
148
+ if html_path.exists() and "__GHOST_READER_PAYLOAD__" in html_path.read_text(
149
+ encoding="utf-8"
150
+ ):
151
+ issues.append("report HTML still contains the payload placeholder")
152
+
153
+ return {
154
+ "payload_found": True,
155
+ "html_found": html_path.exists(),
156
+ "template_name": template_name,
157
+ "payload_reviews": payload_personas,
158
+ "html": str(html_path),
159
+ "payload": str(payload_path),
160
+ }
161
+
162
+
163
+ def verify_telemetry(
164
+ session_path: Path, session_id: str, personas: list[str], issues: list[str]
165
+ ) -> dict[str, Any]:
166
+ events = read_events(session_path / "telemetry" / "events.ndjson")
167
+ event_names = [event.get("event") for event in events]
168
+ if "session_created" not in event_names:
169
+ issues.append("telemetry missing session_created")
170
+ if "html_rendered" not in event_names:
171
+ issues.append("telemetry missing html_rendered")
172
+ completed_personas = [
173
+ event.get("meta", {}).get("persona_id")
174
+ for event in events
175
+ if event.get("event") == "review_completed"
176
+ ]
177
+ missing_review_events = [
178
+ persona_id for persona_id in personas if persona_id not in completed_personas
179
+ ]
180
+ if missing_review_events:
181
+ issues.append(
182
+ f"telemetry missing review_completed for: {', '.join(missing_review_events)}"
183
+ )
184
+ if any(event.get("session_id") != session_id for event in events):
185
+ issues.append("telemetry contains event with mismatched session_id")
186
+
187
+ return {
188
+ "event_count": len(events),
189
+ "events": event_names,
190
+ "review_completed_personas": completed_personas,
191
+ }
192
+
193
+
194
+ def verify_prompt(
195
+ paths: dict[str, Path], session_id: str, feedback_found: bool, issues: list[str]
196
+ ) -> dict[str, Any]:
197
+ yaml_path = paths["prompts"] / "revision-prompt.yaml"
198
+ markdown_path = paths["prompts"] / "revision-prompt.md"
199
+ if not feedback_found:
200
+ return {
201
+ "revision_prompt_found": False,
202
+ "required_now": False,
203
+ }
204
+
205
+ if not yaml_path.exists():
206
+ issues.append("feedback exists but prompts/revision-prompt.yaml is missing")
207
+ return {
208
+ "revision_prompt_found": False,
209
+ "required_now": True,
210
+ }
211
+ if not markdown_path.exists():
212
+ issues.append("feedback exists but prompts/revision-prompt.md is missing")
213
+
214
+ prompt = load_yaml_file(yaml_path)
215
+ if prompt.get("schema_version") != SCHEMA_VERSIONS["revision_prompt"]:
216
+ issues.append(
217
+ f"revision prompt schema_version must be {SCHEMA_VERSIONS['revision_prompt']}"
218
+ )
219
+ if prompt.get("session_id") != session_id:
220
+ issues.append("revision prompt session_id does not match session")
221
+
222
+ return {
223
+ "revision_prompt_found": True,
224
+ "required_now": True,
225
+ "yaml": str(yaml_path),
226
+ "markdown": str(markdown_path),
227
+ }
228
+
229
+
230
+ def verify_no_mixed_artifacts(session_path: Path, issues: list[str]) -> None:
231
+ flat_dirs = ["reviews", "feedback", "prompts", "report", "context"]
232
+ has_round = (session_path / "round-1").exists()
233
+ has_flat = any((session_path / d).exists() for d in flat_dirs)
234
+ if has_round and has_flat:
235
+ issues.append(
236
+ "session mixes round-1/ with legacy flat artifact directories; "
237
+ "remove flat directories or start a new session"
238
+ )
239
+
240
+
241
+ def verify_sync_scaffold(home: Path, issues: list[str]) -> dict[str, Any]:
242
+ telemetry_root = home / "telemetry"
243
+ required_dirs = ["outbox", "synced", "receipts"]
244
+ missing = [
245
+ str(telemetry_root / directory)
246
+ for directory in required_dirs
247
+ if not (telemetry_root / directory).is_dir()
248
+ ]
249
+ if missing:
250
+ issues.append("missing telemetry sync scaffold: " + ", ".join(missing))
251
+ return {
252
+ "outbox_found": (telemetry_root / "outbox").is_dir(),
253
+ "synced_found": (telemetry_root / "synced").is_dir(),
254
+ "receipts_found": (telemetry_root / "receipts").is_dir(),
255
+ }