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
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ghost_reader.io import load_yaml_file, write_yaml
8
+ from ghost_reader.paths import artifact_paths, session_dir
9
+ from ghost_reader.project import load_manifest, read_persona
10
+ from ghost_reader.reviews import feedback_summary, find_review_item, load_reviews
11
+ from ghost_reader.telemetry import append_event
12
+ from ghost_reader.time import now_iso
13
+
14
+ _TURN_HEADER = re.compile(r"^## Turn (\d+) — (.+)$")
15
+
16
+ _INDEX_SCHEMA_VERSION = 1
17
+
18
+
19
+ def _dialogue_dir(project_root: Path, session_id: str) -> Path:
20
+ return artifact_paths(project_root, session_id)["dialogue"]
21
+
22
+
23
+ def _index_path(project_root: Path, session_id: str) -> Path:
24
+ return _dialogue_dir(project_root, session_id) / "index.yaml"
25
+
26
+
27
+ def _load_index(project_root: Path, session_id: str) -> dict[str, Any]:
28
+ path = _index_path(project_root, session_id)
29
+ if not path.exists():
30
+ return {"schema_version": _INDEX_SCHEMA_VERSION, "threads": {}}
31
+ return load_yaml_file(path)
32
+
33
+
34
+ def _save_index(project_root: Path, session_id: str, index: dict[str, Any]) -> None:
35
+ write_yaml(_index_path(project_root, session_id), index)
36
+
37
+
38
+ def load_dialogue(
39
+ project_root: Path, session_id: str, persona_id: str
40
+ ) -> list[dict[str, Any]]:
41
+ path = _dialogue_dir(project_root, session_id) / f"{persona_id}.md"
42
+ if not path.exists():
43
+ return []
44
+ turns: list[dict[str, Any]] = []
45
+ current_turn: dict[str, Any] | None = None
46
+ current_role: str | None = None
47
+ current_lines: list[str] = []
48
+ for line in path.read_text(encoding="utf-8").splitlines():
49
+ header_match = _TURN_HEADER.match(line)
50
+ if header_match:
51
+ if current_turn is not None:
52
+ _flush_turn_block(current_turn, current_role, current_lines)
53
+ turns.append(current_turn)
54
+ current_turn = {
55
+ "turn_index": int(header_match.group(1)),
56
+ "timestamp": header_match.group(2),
57
+ }
58
+ current_role = None
59
+ current_lines = []
60
+ continue
61
+ if line.startswith("**User:**") or line.startswith("**Persona:**"):
62
+ if current_role is not None and current_lines:
63
+ _flush_turn_block(current_turn, current_role, current_lines)
64
+ current_role = "user" if line.startswith("**User:**") else "persona"
65
+ current_lines = [line.split(":**", 1)[1].strip() if ":**" in line else ""]
66
+ continue
67
+ if current_turn is not None and current_role is not None:
68
+ current_lines.append(line)
69
+ if current_turn is not None:
70
+ _flush_turn_block(current_turn, current_role, current_lines)
71
+ turns.append(current_turn)
72
+ return turns
73
+
74
+
75
+ def _flush_turn_block(
76
+ turn: dict[str, Any], role: str | None, lines: list[str]
77
+ ) -> None:
78
+ if role and lines:
79
+ turn[role] = "\n".join(lines).strip()
80
+
81
+
82
+ def dialogue_thread_context(
83
+ project_root: Path,
84
+ session_id: str,
85
+ thread_id: str,
86
+ ) -> dict[str, Any]:
87
+ index = _load_index(project_root, session_id)
88
+ threads = index.get("threads", {})
89
+ if thread_id not in threads:
90
+ raise FileNotFoundError(
91
+ f"Thread `{thread_id}` not found in dialogue index for session `{session_id}`."
92
+ )
93
+ return threads[thread_id]
94
+
95
+
96
+ def dialogue_context(
97
+ project_root: Path,
98
+ home: Path,
99
+ session_id: str,
100
+ persona_id: str,
101
+ item_id: str,
102
+ ) -> dict[str, Any]:
103
+ load_manifest(project_root, session_id)
104
+ persona = read_persona(home, persona_id)
105
+ reviews = {
106
+ review["persona_id"]: review
107
+ for review in load_reviews(project_root, home, session_id)
108
+ }
109
+ review = reviews.get(persona_id)
110
+ if not review:
111
+ raise FileNotFoundError(
112
+ f"No review artifact found for persona `{persona_id}` in session `{session_id}`."
113
+ )
114
+ found = find_review_item(review, item_id)
115
+ if not found:
116
+ raise FileNotFoundError(
117
+ f"Review item `{item_id}` not found in {persona_id}'s review."
118
+ )
119
+ kind, item = found
120
+ feedback = feedback_summary(project_root, session_id)
121
+ target_id = f"{persona_id}:{item_id}"
122
+ user_note = feedback["user_notes"].get(target_id, "")
123
+ prior = load_dialogue(project_root, session_id, persona_id)
124
+ locators = [
125
+ evidence.get("locator")
126
+ for evidence in item.get("evidence", [])
127
+ if evidence.get("locator")
128
+ ]
129
+ context_dir = session_dir(project_root, session_id) / "context"
130
+ source_map_path = context_dir / "source-map.yaml"
131
+ relevant_context: list[dict[str, str]] = []
132
+ if source_map_path.exists():
133
+ source_map = load_yaml_file(source_map_path)
134
+ for entry in source_map.get("source_ranges", []):
135
+ if entry.get("locator") in locators:
136
+ relevant_context.append(
137
+ {
138
+ "source_id": entry.get("source_id", ""),
139
+ "locator": entry.get("locator", ""),
140
+ }
141
+ )
142
+ return {
143
+ "session_id": session_id,
144
+ "persona": {
145
+ "id": persona["id"],
146
+ "reader_type": persona["reader_type"],
147
+ "review_focus": persona["review_focus"],
148
+ },
149
+ "review_item": {
150
+ "id": item_id,
151
+ "kind": "strength" if kind == "strengths" else "concern",
152
+ "reason": item.get("reason"),
153
+ "locator": locators[0] if locators else None,
154
+ "revision_hint": item.get("revision_hint"),
155
+ "reader_effect": item.get("reader_effect"),
156
+ },
157
+ "user_note": user_note,
158
+ "prior_dialogue_summary": (
159
+ f"{len(prior)} previous turn(s)."
160
+ if prior
161
+ else "No previous dialogue."
162
+ ),
163
+ "relevant_context": relevant_context,
164
+ }
165
+
166
+
167
+ def dialogue_append(
168
+ project_root: Path,
169
+ home: Path,
170
+ session_id: str,
171
+ persona_id: str,
172
+ item_id: str,
173
+ turn_text: str,
174
+ thread_id: str | None = None,
175
+ round_id: int | None = None,
176
+ item_uid: str | None = None,
177
+ ) -> dict[str, Any]:
178
+ load_manifest(project_root, session_id)
179
+ dialogue_dir = _dialogue_dir(project_root, session_id)
180
+ dialogue_dir.mkdir(parents=True, exist_ok=True)
181
+ existing = load_dialogue(project_root, session_id, persona_id)
182
+ turn_index = len(existing) + 1
183
+ timestamp = now_iso()
184
+ path = dialogue_dir / f"{persona_id}.md"
185
+ block = f"## Turn {turn_index} — {timestamp}\n\n{turn_text.strip()}\n"
186
+ with path.open("a", encoding="utf-8") as handle:
187
+ handle.write("\n" + block if existing else block)
188
+ append_event(
189
+ home,
190
+ project_root,
191
+ "dialogue_turn_created",
192
+ session_id,
193
+ meta={
194
+ "persona_id": persona_id,
195
+ "review_item_id": item_id,
196
+ "turn_index": turn_index,
197
+ },
198
+ )
199
+ index = _load_index(project_root, session_id)
200
+ tid = thread_id or f"th-{persona_id}-{item_id}"
201
+ if tid not in index.setdefault("threads", {}):
202
+ index["threads"][tid] = {
203
+ "persona_id": persona_id,
204
+ "round_id": round_id or 1,
205
+ "item_uid": item_uid or "",
206
+ "created_at": timestamp,
207
+ "turns": [],
208
+ }
209
+ user_text, persona_text = _split_turn_text(turn_text)
210
+ index["threads"][tid]["turns"].append(
211
+ {
212
+ "turn_index": turn_index,
213
+ "timestamp": timestamp,
214
+ "user": user_text,
215
+ "persona": persona_text,
216
+ }
217
+ )
218
+ _save_index(project_root, session_id, index)
219
+ return {
220
+ "written": True,
221
+ "path": str(path),
222
+ "turn_index": turn_index,
223
+ "persona_id": persona_id,
224
+ "review_item_id": item_id,
225
+ "thread_id": tid,
226
+ }
227
+
228
+
229
+ def _split_turn_text(turn_text: str) -> tuple[str, str]:
230
+ user_text = ""
231
+ persona_text = ""
232
+ lines = turn_text.strip().splitlines()
233
+ current_role: str | None = None
234
+ current_lines: list[str] = []
235
+ for line in lines:
236
+ if line.startswith("**User:**"):
237
+ if current_role == "persona" and current_lines:
238
+ persona_text = "\n".join(current_lines).strip()
239
+ current_role = "user"
240
+ current_lines = [line.split(":**", 1)[1].strip() if ":**" in line else ""]
241
+ elif line.startswith("**Persona:**"):
242
+ if current_role == "user" and current_lines:
243
+ user_text = "\n".join(current_lines).strip()
244
+ current_role = "persona"
245
+ current_lines = [line.split(":**", 1)[1].strip() if ":**" in line else ""]
246
+ elif current_role is not None:
247
+ current_lines.append(line)
248
+ if current_role == "user" and current_lines:
249
+ user_text = "\n".join(current_lines).strip()
250
+ elif current_role == "persona" and current_lines:
251
+ persona_text = "\n".join(current_lines).strip()
252
+ return user_text, persona_text
253
+
254
+
255
+ def load_all_dialogue(
256
+ project_root: Path, session_id: str, personas: list[str]
257
+ ) -> dict[str, list[dict[str, Any]]]:
258
+ index = _load_index(project_root, session_id)
259
+ if index.get("threads"):
260
+ result: dict[str, list[dict[str, Any]]] = {}
261
+ for thread_id, thread in index["threads"].items():
262
+ persona_id = thread.get("persona_id", "")
263
+ if persona_id not in personas:
264
+ continue
265
+ if persona_id not in result:
266
+ result[persona_id] = []
267
+ for turn in thread.get("turns", []):
268
+ result[persona_id].append(
269
+ {
270
+ "thread_id": thread_id,
271
+ "turn_index": turn.get("turn_index"),
272
+ "timestamp": turn.get("timestamp"),
273
+ "user": turn.get("user", ""),
274
+ "persona": turn.get("persona", ""),
275
+ }
276
+ )
277
+ return result
278
+ result: dict[str, list[dict[str, Any]]] = {}
279
+ for persona_id in personas:
280
+ turns = load_dialogue(project_root, session_id, persona_id)
281
+ if turns:
282
+ result[persona_id] = turns
283
+ return result
ghost_reader/errors.py ADDED
@@ -0,0 +1,2 @@
1
+ class GhostReaderError(Exception):
2
+ """Expected CLI error shown without a traceback."""
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from ghost_reader.constants import SCHEMA_VERSIONS
7
+ from ghost_reader.io import write_yaml
8
+ from ghost_reader.paths import artifact_paths
9
+ from ghost_reader.project import load_manifest
10
+ from ghost_reader.telemetry import append_event
11
+ from ghost_reader.validators import validate_feedback
12
+
13
+
14
+ def normalize_feedback(
15
+ feedback: dict[str, Any],
16
+ session_id: str,
17
+ project_root: Path,
18
+ ) -> dict[str, Any]:
19
+ manifest = load_manifest(project_root, session_id)
20
+ normalized = dict(feedback)
21
+ normalized["schema_version"] = SCHEMA_VERSIONS["feedback"]
22
+ normalized["session_id"] = session_id
23
+ normalized.setdefault("round_id", manifest.get("current_round", 1))
24
+ if "mode" not in feedback:
25
+ normalized["mode"] = "revise"
26
+ normalized.setdefault("refinement_pass", 0)
27
+ return normalized
28
+
29
+
30
+ def record_feedback(
31
+ project_root: Path,
32
+ home: Path,
33
+ session_id: str,
34
+ feedback: dict[str, Any],
35
+ *,
36
+ surface: str = "cli",
37
+ ) -> Path:
38
+ """Validate, persist feedback.yaml, and append content-safe telemetry."""
39
+ load_manifest(project_root, session_id)
40
+ feedback = normalize_feedback(feedback, session_id, project_root)
41
+ validate_feedback(feedback, session_id)
42
+ target = artifact_paths(project_root, session_id)["feedback"] / "feedback.yaml"
43
+ write_yaml(target, feedback)
44
+ append_event(
45
+ home,
46
+ project_root,
47
+ "feedback_recorded",
48
+ session_id,
49
+ surface=surface,
50
+ meta={
51
+ "selected_review_item_ids": [
52
+ item["target_id"] for item in feedback.get("selected_items", [])
53
+ ]
54
+ },
55
+ )
56
+ return target
ghost_reader/io.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from ghost_reader.errors import GhostReaderError
11
+
12
+
13
+ def load_yaml_file(path: Path) -> Any:
14
+ try:
15
+ with path.open("r", encoding="utf-8") as handle:
16
+ return yaml.safe_load(handle) or {}
17
+ except yaml.YAMLError as exc:
18
+ raise GhostReaderError(_yaml_error_message(str(path), exc)) from exc
19
+
20
+
21
+ def load_yaml_text(text: str, source: str = "provided YAML text") -> Any:
22
+ try:
23
+ return yaml.safe_load(text) or {}
24
+ except yaml.YAMLError as exc:
25
+ raise GhostReaderError(_yaml_error_message(source, exc)) from exc
26
+
27
+
28
+ def _yaml_error_message(source: str, exc: yaml.YAMLError) -> str:
29
+ return (
30
+ f"Invalid YAML in {source}. Common causes are unquoted special characters "
31
+ f"(:, #, {{, }}, [, ]) or inconsistent indentation. Original parser message: {exc}"
32
+ )
33
+
34
+
35
+ def dump_yaml(data: Any) -> str:
36
+ return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
37
+
38
+
39
+ def write_yaml(path: Path, data: Any) -> None:
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ path.write_text(dump_yaml(data), encoding="utf-8")
42
+
43
+
44
+ def emit_yaml(data: Any) -> None:
45
+ sys.stdout.write(dump_yaml(data))
46
+
47
+
48
+ def read_input(args: argparse.Namespace) -> str:
49
+ if getattr(args, "from_file", None):
50
+ if args.from_file == "-":
51
+ return sys.stdin.read()
52
+ return Path(args.from_file).read_text(encoding="utf-8")
53
+ if getattr(args, "content", None) is not None:
54
+ return args.content
55
+ if sys.stdin.isatty():
56
+ raise GhostReaderError(
57
+ "No input provided. Use --from, --content, or pipe content on stdin."
58
+ )
59
+ return sys.stdin.read()
ghost_reader/models.py ADDED
@@ -0,0 +1,227 @@
1
+ """Pydantic models for ghost-reader data schemas.
2
+
3
+ Provides parse-once validation for persona, review, and feedback data.
4
+ All models preserve schema_version for backward compatibility with on-disk files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pydantic import BaseModel, Field, model_validator
10
+ from pydantic.config import ConfigDict
11
+
12
+
13
+ class GhostReaderBase(BaseModel):
14
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
15
+
16
+
17
+ class PersonaProfile(GhostReaderBase):
18
+ summary: str | None = None
19
+ favorite_genres: list[str] = Field(default_factory=list)
20
+
21
+
22
+ class PersonaPreferences(GhostReaderBase):
23
+ seeks: list[str] = Field(default_factory=list)
24
+ dislikes: list[str] = Field(default_factory=list)
25
+
26
+
27
+ class ReviewFocus(GhostReaderBase):
28
+ primary: list[str] = Field(default_factory=list)
29
+ secondary: list[str] = Field(default_factory=list)
30
+
31
+
32
+ class FeedbackVoice(GhostReaderBase):
33
+ voice: str | None = None
34
+ default_question: str | None = None
35
+
36
+
37
+ class Persona(GhostReaderBase):
38
+ """Persona schema v1 — loaded from YAML files in defaults/personas/."""
39
+
40
+ schema_version: int = 1
41
+ id: str
42
+ name: str
43
+ reader_type: str
44
+ profile: PersonaProfile = PersonaProfile()
45
+ preferences: PersonaPreferences = PersonaPreferences()
46
+ review_focus: ReviewFocus = ReviewFocus()
47
+ feedback_style: FeedbackVoice = FeedbackVoice()
48
+
49
+ @model_validator(mode="after")
50
+ def check_schema_version(self) -> Persona:
51
+ from ghost_reader.constants import SCHEMA_VERSIONS
52
+
53
+ expected = SCHEMA_VERSIONS["persona"]
54
+ if self.schema_version != expected:
55
+ raise ValueError(f"Persona schema_version must be {expected}.")
56
+ return self
57
+
58
+ @model_validator(mode="after")
59
+ def check_forbidden_fields(self) -> Persona:
60
+ forbidden = {
61
+ "posture",
62
+ "tolerance",
63
+ "telemetry",
64
+ "matching",
65
+ "analytics",
66
+ "weights",
67
+ }
68
+ # Check both defined fields and extra fields (extra="allow" stores unknown keys)
69
+ defined = set(type(self).model_fields.keys())
70
+ extra = getattr(self, "model_extra", {}) or {}
71
+ present = sorted(forbidden.intersection(defined | set(extra.keys())))
72
+ if present:
73
+ raise ValueError(
74
+ f"Persona includes forbidden Phase 1 fields: {', '.join(present)}"
75
+ )
76
+ return self
77
+
78
+
79
+ class Evidence(GhostReaderBase):
80
+ source_id: str
81
+ locator: str
82
+
83
+
84
+ class ReviewItem(GhostReaderBase):
85
+ id: str = Field(..., pattern=r"^[sc][1-9][0-9]*$")
86
+ reason: str
87
+ item_uid: str = Field(
88
+ ...,
89
+ pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
90
+ )
91
+ refinement_pass: bool
92
+ evidence: list[Evidence] = Field(..., min_length=1)
93
+
94
+ # concern-specific (optional for strengths)
95
+ reader_effect: str | None = None
96
+ revision_hint: str | None = None
97
+
98
+
99
+ class OverallScore(GhostReaderBase):
100
+ summary: str
101
+ would_continue: bool
102
+
103
+
104
+ class ReviewScores(GhostReaderBase):
105
+ engagement: int = Field(..., ge=1, le=10)
106
+ character: int = Field(..., ge=1, le=10)
107
+ pacing: int = Field(..., ge=1, le=10)
108
+ genre_fit: int = Field(..., ge=1, le=10)
109
+
110
+
111
+ class Review(GhostReaderBase):
112
+ """Review schema v2 — loaded from .review.yaml artifact files."""
113
+
114
+ schema_version: int = 2
115
+ session_id: str
116
+ persona_id: str
117
+ round_id: int | str
118
+ overall: OverallScore
119
+ scores: ReviewScores
120
+ strengths: list[ReviewItem]
121
+ concerns: list[ReviewItem]
122
+
123
+ @model_validator(mode="after")
124
+ def check_schema_version(self) -> Review:
125
+ from ghost_reader.constants import SCHEMA_VERSIONS
126
+
127
+ expected = SCHEMA_VERSIONS["review"]
128
+ if self.schema_version != expected:
129
+ raise ValueError(f"Review schema_version must be {expected}.")
130
+ return self
131
+
132
+ @model_validator(mode="after")
133
+ def check_strength_ids(self) -> Review:
134
+ for item in self.strengths:
135
+ if not item.id.startswith("s"):
136
+ raise ValueError(f"Strength id `{item.id}` must start with s.")
137
+ return self
138
+
139
+ @model_validator(mode="after")
140
+ def check_concern_ids(self) -> Review:
141
+ for item in self.concerns:
142
+ if not item.id.startswith("c"):
143
+ raise ValueError(f"Concern id `{item.id}` must start with c.")
144
+ if not item.reader_effect:
145
+ raise ValueError(f"Concern {item.id} must include reader_effect.")
146
+ if not item.revision_hint:
147
+ raise ValueError(f"Concern {item.id} must include revision_hint.")
148
+ return self
149
+
150
+
151
+ class SelectedItem(GhostReaderBase):
152
+ target_id: str
153
+ item_uid: str
154
+ thread_id: str
155
+ note: str | None = None
156
+
157
+ @model_validator(mode="after")
158
+ def check_forbidden_fields(self) -> SelectedItem:
159
+ forbidden = {
160
+ "supportive_items",
161
+ "rejective_items",
162
+ "accepted_items",
163
+ "rejected_items",
164
+ "stance",
165
+ "classification",
166
+ }
167
+ defined = set(type(self).model_fields.keys())
168
+ extra = getattr(self, "model_extra", {}) or {}
169
+ present = sorted(forbidden.intersection(defined | set(extra.keys())))
170
+ if present:
171
+ raise ValueError(
172
+ f"SelectedItem includes forbidden fields: {', '.join(present)}"
173
+ )
174
+ return self
175
+
176
+
177
+ class Feedback(GhostReaderBase):
178
+ """Feedback schema v2 — loaded from feedback.yaml artifact files."""
179
+
180
+ schema_version: int = 2
181
+ session_id: str
182
+ round_id: int | str | None = None
183
+ mode: str
184
+ selected_items: list[SelectedItem] = Field(..., min_length=1)
185
+
186
+ @model_validator(mode="after")
187
+ def check_schema_version(self) -> Feedback:
188
+ from ghost_reader.constants import SCHEMA_VERSIONS
189
+
190
+ expected = SCHEMA_VERSIONS["feedback"]
191
+ if self.schema_version != expected:
192
+ raise ValueError(f"Feedback schema_version must be {expected}.")
193
+ return self
194
+
195
+ @model_validator(mode="after")
196
+ def check_mode(self) -> Feedback:
197
+ if self.mode not in ("refine", "revise"):
198
+ raise ValueError("Feedback mode must be 'refine' or 'revise'.")
199
+ return self
200
+
201
+ @model_validator(mode="after")
202
+ def check_target_id_format(self) -> Feedback:
203
+ for item in self.selected_items:
204
+ if ":" not in item.target_id:
205
+ raise ValueError(
206
+ f"Feedback target_id `{item.target_id}` must use persona:item format."
207
+ )
208
+ return self
209
+
210
+ @model_validator(mode="after")
211
+ def check_forbidden_fields(self) -> Feedback:
212
+ forbidden = {
213
+ "supportive_items",
214
+ "rejective_items",
215
+ "accepted_items",
216
+ "rejected_items",
217
+ "stance",
218
+ "classification",
219
+ }
220
+ defined = set(type(self).model_fields.keys())
221
+ extra = getattr(self, "model_extra", {}) or {}
222
+ present = sorted(forbidden.intersection(defined | set(extra.keys())))
223
+ if present:
224
+ raise ValueError(
225
+ f"Feedback includes forbidden stance-classification fields: {', '.join(present)}"
226
+ )
227
+ return self
ghost_reader/paths.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from ghost_reader.errors import GhostReaderError
7
+
8
+
9
+ def ghost_reader_home() -> Path:
10
+ return Path(
11
+ os.environ.get("GHOST_READER_HOME", Path.home() / ".ghostreader")
12
+ ).expanduser()
13
+
14
+
15
+ def project_dir(project_root: Path) -> Path:
16
+ return project_root / ".ghostreader"
17
+
18
+
19
+ def session_dir(project_root: Path, session_id: str) -> Path:
20
+ return project_dir(project_root) / "sessions" / session_id
21
+
22
+
23
+ def round_dir(project_root: Path, session_id: str, round_id: int) -> Path:
24
+ return session_dir(project_root, session_id) / f"round-{round_id}"
25
+
26
+
27
+ def active_round_dir(project_root: Path, session_id: str) -> Path:
28
+ from ghost_reader.project import load_manifest
29
+
30
+ manifest = load_manifest(project_root, session_id)
31
+ current = manifest.get("current_round", 1)
32
+ return round_dir(project_root, session_id, current)
33
+
34
+
35
+ def artifact_paths(project_root: Path, session_id: str) -> dict[str, Path]:
36
+ active = active_round_dir(project_root, session_id)
37
+ session = session_dir(project_root, session_id)
38
+ return {
39
+ "context": active / "context",
40
+ "reviews": active / "reviews",
41
+ "feedback": active / "feedback",
42
+ "prompts": active / "prompts",
43
+ "report": active / "report",
44
+ "refinements": active / "refinements",
45
+ "dialogue": session / "dialogue",
46
+ "manifest": session / "manifest.yaml",
47
+ "telemetry": session / "telemetry",
48
+ "session": session,
49
+ "active_round": active,
50
+ }
51
+
52
+
53
+ def find_project_root(start: Path | None = None) -> Path:
54
+ current = (start or Path.cwd()).resolve()
55
+ for candidate in [current, *current.parents]:
56
+ if (candidate / ".ghostreader" / "project.yaml").exists():
57
+ return candidate
58
+ raise GhostReaderError(
59
+ "Ghost Reader project not initialized. Run `ghost-reader init` first."
60
+ )
61
+
62
+
63
+ def outbox_path(home: Path) -> Path:
64
+ return home / "telemetry" / "outbox" / "events.ndjson"
65
+
66
+
67
+ def sync_state_path(home: Path) -> Path:
68
+ return home / "telemetry" / "sync-state.yaml"