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/cli.py ADDED
@@ -0,0 +1,1124 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import signal
7
+ import sys
8
+ if sys.platform != "win32":
9
+ import fcntl
10
+ import time
11
+ import traceback
12
+ import uuid
13
+ from pathlib import Path
14
+ from pprint import pformat
15
+
16
+ from ghost_reader import __version__
17
+ from ghost_reader.constants import (
18
+ AVAILABLE_PERSONAS,
19
+ DEFAULT_PERSONAS,
20
+ SCHEMA_VERSIONS,
21
+ TEMPLATE_VERSION,
22
+ )
23
+ from ghost_reader.dialogue import (
24
+ dialogue_append,
25
+ dialogue_context,
26
+ )
27
+ from ghost_reader.errors import GhostReaderError
28
+ from ghost_reader.io import (
29
+ emit_yaml,
30
+ load_yaml_file,
31
+ load_yaml_text,
32
+ read_input,
33
+ write_yaml,
34
+ )
35
+ from ghost_reader.paths import (
36
+ artifact_paths,
37
+ find_project_root,
38
+ ghost_reader_home,
39
+ project_dir,
40
+ session_dir,
41
+ )
42
+ from ghost_reader.project import (
43
+ ensure_home,
44
+ ensure_project,
45
+ ensure_session_structure,
46
+ load_manifest,
47
+ parse_personas,
48
+ persona_brief,
49
+ read_persona,
50
+ save_manifest,
51
+ )
52
+ from ghost_reader.report import render_report
53
+ from ghost_reader.reviews import compact_review_item, feedback_summary, load_reviews
54
+ from ghost_reader.revision import generate_revision_prompt
55
+ from ghost_reader.round import refine_snapshot, round_init, round_status
56
+ from ghost_reader.server import serve_report
57
+ from ghost_reader.sync import create_sync_bundle
58
+ from ghost_reader.telemetry import append_event, telemetry_status
59
+ from ghost_reader.time import new_session_id, now_iso
60
+ from ghost_reader.feedback_store import record_feedback
61
+ from ghost_reader.release import (
62
+ GITHUB_INSTALL_GIT,
63
+ GITHUB_REPO_URL,
64
+ UNAVAILABLE_STATUS,
65
+ embedded_release_version,
66
+ fetch_latest_release,
67
+ )
68
+ from ghost_reader.validators import validate_review
69
+ from ghost_reader.verify import verify_session
70
+
71
+ OUTPUT_FORMATS = ("yaml", "json", "pretty")
72
+
73
+
74
+ def add_output_format_option(
75
+ parser: argparse.ArgumentParser,
76
+ *,
77
+ default: str | object = argparse.SUPPRESS,
78
+ ) -> None:
79
+ parser.add_argument(
80
+ "--format",
81
+ "-f",
82
+ dest="output_format",
83
+ choices=OUTPUT_FORMATS,
84
+ default=default,
85
+ help="Output format for summary/status commands (default: yaml).",
86
+ )
87
+
88
+
89
+ def emit_output(args: argparse.Namespace, data: object) -> None:
90
+ output_format = getattr(args, "output_format", "yaml")
91
+ if output_format == "json":
92
+ sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
93
+ elif output_format == "pretty":
94
+ sys.stdout.write(pformat(data, sort_dicts=False) + "\n")
95
+ else:
96
+ emit_yaml(data)
97
+
98
+
99
+ def _help_personas(home: Path) -> list[dict[str, str]]:
100
+ entries: list[dict[str, str]] = []
101
+ for persona_id in AVAILABLE_PERSONAS:
102
+ persona = read_persona(home, persona_id)
103
+ entries.append(
104
+ {
105
+ "id": persona_id,
106
+ "name": persona["name"],
107
+ "focus": persona_brief(persona),
108
+ }
109
+ )
110
+ return entries
111
+
112
+
113
+ def command_help(args: argparse.Namespace) -> int:
114
+ home = ghost_reader_home()
115
+ ensure_home(home)
116
+ emit_output(
117
+ args,
118
+ {
119
+ "name": "ghost-reader",
120
+ "version": __version__,
121
+ "description": "Local-first persona-driven fiction review simulator",
122
+ "repository": GITHUB_REPO_URL,
123
+ "install": f"uv tool install '{GITHUB_INSTALL_GIT}'",
124
+ "personas": _help_personas(home),
125
+ "commands": {
126
+ "init": "Initialize story project and persona home",
127
+ "help": "Show this manifest",
128
+ "update check": "Compare CLI version with latest GitHub release",
129
+ "session create": "Create a review session with selected personas",
130
+ "session summary": "List review items and artifact paths",
131
+ "artifact write": "Write context, source map, source text, or review YAML",
132
+ "feedback add": "Record user feedback on review items",
133
+ "feedback summary": "Summarize selected feedback items",
134
+ "prompt revision": "Generate unified revision prompt from feedback",
135
+ "dialogue append": "Append a turn to a persona dialogue thread",
136
+ "dialogue context": "Show dialogue context for a review item",
137
+ "history summary": "Show recent session usage",
138
+ "round init": "Start a new review round",
139
+ "round status": "Show current round state",
140
+ "refine snapshot": "Archive reviews and feedback before refinement",
141
+ "render": "Build report HTML from payload (use --format list for templates)",
142
+ "verify": "Validate session artifacts and return workflow status",
143
+ "telemetry append": "Append a content-safe telemetry event",
144
+ "telemetry status": "Show telemetry outbox and last sync time",
145
+ "sync": "Export a local telemetry bundle",
146
+ "serve": "Serve report and accept feedback via HTTP",
147
+ },
148
+ "phase": 2,
149
+ "planned_commands": ["recommend"],
150
+ }
151
+ )
152
+ return 0
153
+
154
+
155
+ def command_init(args: argparse.Namespace) -> int:
156
+ home = ghost_reader_home()
157
+ project_root = Path.cwd()
158
+ project = ensure_project(project_root, home)
159
+ status = telemetry_status(home)
160
+ emit_output(
161
+ args,
162
+ {
163
+ "project_initialized": True,
164
+ "project_root": str(project_root),
165
+ "config_found": (home / "config.yaml").exists(),
166
+ "available_personas": project.get("available_personas", AVAILABLE_PERSONAS),
167
+ "default_personas": project.get("default_personas", DEFAULT_PERSONAS),
168
+ "schema_versions": dict(SCHEMA_VERSIONS),
169
+ "template_version": TEMPLATE_VERSION,
170
+ "updates": {
171
+ "cli": "current",
172
+ "personas": "current",
173
+ "templates": "current",
174
+ },
175
+ "pending_telemetry_events": status["pending_events"],
176
+ "last_sync_at": status["last_sync_at"],
177
+ "warnings": [],
178
+ }
179
+ )
180
+ return 0
181
+
182
+
183
+ def command_update_check(args: argparse.Namespace) -> int:
184
+ embedded_version = embedded_release_version()
185
+ payload: dict[str, object] = {
186
+ "cli_version": __version__,
187
+ "repository": GITHUB_REPO_URL,
188
+ "repository_visibility": "private_or_public",
189
+ "personas": "current",
190
+ "persona_schema_version": SCHEMA_VERSIONS["persona"],
191
+ "templates": "current",
192
+ "template_version": TEMPLATE_VERSION,
193
+ }
194
+ if embedded_version:
195
+ payload["embedded_release_version"] = embedded_version
196
+ if embedded_version != __version__:
197
+ payload["update_status"] = "upgrade_available"
198
+ payload["install_hint"] = (
199
+ f"uv tool install '{GITHUB_INSTALL_GIT}' "
200
+ f"# latest packaged release record is {embedded_version}"
201
+ )
202
+ # Fall through to GitHub API for full release metadata
203
+ # (the embedded record is a floor, not a ceiling)
204
+
205
+ latest = fetch_latest_release()
206
+ if latest and latest.get("version"):
207
+ latest_version = str(latest["version"])
208
+ payload["latest_release"] = latest
209
+ if latest_version == __version__:
210
+ payload["update_status"] = "current"
211
+ else:
212
+ payload["update_status"] = "upgrade_available"
213
+ tag = latest.get("tag")
214
+ ref = tag if tag else "main"
215
+ install_ref = GITHUB_INSTALL_GIT if ref == "main" else f"{GITHUB_INSTALL_GIT}@{ref}"
216
+ payload["install_hint"] = (
217
+ f"uv tool install '{install_ref}' "
218
+ f"# private repo: requires GitHub SSH access, GH_TOKEN, or authenticated git HTTPS"
219
+ )
220
+ else:
221
+ if embedded_version:
222
+ if embedded_version == __version__:
223
+ payload["update_status"] = "current"
224
+ # else: update_status already set to "upgrade_available" above
225
+ payload["latest_release"] = {
226
+ "version": embedded_version,
227
+ "source": "embedded_release_record",
228
+ }
229
+ payload["warning"] = "GitHub release API unavailable; using local release record."
230
+ else:
231
+ payload["update_status"] = UNAVAILABLE_STATUS
232
+ payload["latest_release"] = None
233
+ emit_output(args, payload)
234
+ return 0
235
+
236
+
237
+ def command_persona_list(args: argparse.Namespace) -> int:
238
+ home = ghost_reader_home()
239
+ ensure_home(home)
240
+ output: list[dict[str, object]] = []
241
+ for persona_id in AVAILABLE_PERSONAS:
242
+ try:
243
+ persona = read_persona(home, persona_id)
244
+ except GhostReaderError:
245
+ continue
246
+ brief = persona_brief(persona)
247
+ output.append({
248
+ "id": persona_id,
249
+ "name": persona.get("name", persona_id.title()),
250
+ "reader_type": persona.get("reader_type", ""),
251
+ "summary": persona.get("profile", {}).get("summary", ""),
252
+ "review_focus": brief,
253
+ })
254
+ emit_output(args, output)
255
+ return 0
256
+
257
+
258
+ def command_persona_show(args: argparse.Namespace) -> int:
259
+ home = ghost_reader_home()
260
+ ensure_home(home)
261
+ persona_id = args.persona_id
262
+ try:
263
+ persona = read_persona(home, persona_id)
264
+ except GhostReaderError as exc:
265
+ sys.stderr.write(f"ghost-reader: error: {exc}\n")
266
+ return 2
267
+ emit_output(args, persona)
268
+ return 0
269
+
270
+
271
+ def command_session_create(args: argparse.Namespace) -> int:
272
+ home = ghost_reader_home()
273
+ project_root = find_project_root()
274
+ ensure_project(project_root, home)
275
+ personas = parse_personas(args.personas)
276
+ session_id = args.id or new_session_id(args.story_unit)
277
+ path = session_dir(project_root, session_id)
278
+ existed = (path / "manifest.yaml").exists()
279
+ if existed and not args.resume:
280
+ raise GhostReaderError(
281
+ f"Session `{session_id}` already exists. Use --resume to reuse it."
282
+ )
283
+
284
+ ensure_session_structure(path)
285
+ if existed:
286
+ manifest = load_yaml_file(path / "manifest.yaml")
287
+ else:
288
+ manifest = {
289
+ "schema_version": SCHEMA_VERSIONS["session_manifest"],
290
+ "session_id": session_id,
291
+ "current_round": 1,
292
+ "story_unit": args.story_unit,
293
+ "personas": personas,
294
+ "story_audience_contract": load_yaml_file(Path(args.contract))
295
+ if args.contract
296
+ else {},
297
+ "rounds": [
298
+ {
299
+ "id": 1,
300
+ "status": "reviews_pending",
301
+ "created_at": now_iso(),
302
+ "refinement_count": 0,
303
+ }
304
+ ],
305
+ "created_at": now_iso(),
306
+ "updated_at": now_iso(),
307
+ }
308
+ write_yaml(path / "manifest.yaml", manifest)
309
+ append_event(
310
+ home,
311
+ project_root,
312
+ "session_created",
313
+ session_id,
314
+ meta={"selected_personas": personas},
315
+ correlation_id=getattr(args, '_correlation_id', None),
316
+ )
317
+
318
+ emit_output(
319
+ args,
320
+ {
321
+ "session_id": session_id,
322
+ "session_path": str(path),
323
+ "personas": manifest.get("personas", personas),
324
+ "created": not existed,
325
+ }
326
+ )
327
+ return 0
328
+
329
+
330
+ def command_session_summary(args: argparse.Namespace) -> int:
331
+ home = ghost_reader_home()
332
+ project_root = find_project_root()
333
+ manifest = load_manifest(project_root, args.session)
334
+ review_items = {}
335
+ for review in load_reviews(project_root, home, args.session):
336
+ items = {}
337
+ for item in review.get("strengths", []):
338
+ items[item["id"]] = compact_review_item(item)
339
+ for item in review.get("concerns", []):
340
+ items[item["id"]] = compact_review_item(item)
341
+ review_items[review["persona_id"]] = items
342
+
343
+ base = session_dir(project_root, args.session)
344
+ available_artifacts = [
345
+ str(path.relative_to(base))
346
+ for path in sorted(base.rglob("*"))
347
+ if path.is_file() and path.name != "events.ndjson"
348
+ ]
349
+ emit_output(
350
+ args,
351
+ {
352
+ "session_id": args.session,
353
+ "story_unit": manifest.get("story_unit"),
354
+ "personas": manifest.get("personas", []),
355
+ "review_items": review_items,
356
+ "user_notes": feedback_summary(project_root, args.session)["user_notes"],
357
+ "available_artifacts": available_artifacts,
358
+ },
359
+ )
360
+ return 0
361
+
362
+
363
+ def command_artifact_write(args: argparse.Namespace) -> int:
364
+ home = ghost_reader_home()
365
+ project_root = find_project_root()
366
+ ensure_project(project_root, home)
367
+ manifest = load_manifest(project_root, args.session)
368
+ path = session_dir(project_root, args.session)
369
+ ensure_session_structure(path)
370
+ content = read_input(args)
371
+ validated_yaml = None
372
+ if args.validate:
373
+ if args.type not in {"review", "source-map"}:
374
+ raise GhostReaderError("--validate is only available for YAML artifacts.")
375
+ validated_yaml = load_yaml_text(content, source=f"{args.type} input")
376
+
377
+ if args.type == "review":
378
+ target = write_review_artifact(
379
+ home,
380
+ project_root,
381
+ args.session,
382
+ args.persona,
383
+ content,
384
+ manifest,
385
+ parsed_review=validated_yaml,
386
+ correlation_id=getattr(args, '_correlation_id', None),
387
+ )
388
+ elif args.type == "context-note":
389
+ target = artifact_paths(project_root, args.session)["context"] / "context-notes.md"
390
+ if args.append and target.exists():
391
+ target.write_text(
392
+ f"{target.read_text(encoding='utf-8').rstrip()}\n\n{content.strip()}\n",
393
+ encoding="utf-8",
394
+ )
395
+ else:
396
+ target.write_text(content, encoding="utf-8")
397
+ append_event(home, project_root, "context_note_written", args.session, correlation_id=getattr(args, '_correlation_id', None))
398
+ elif args.type == "source-map":
399
+ target = artifact_paths(project_root, args.session)["context"] / "source-map.yaml"
400
+ write_yaml(
401
+ target,
402
+ validated_yaml
403
+ if validated_yaml is not None
404
+ else load_yaml_text(content, source="source-map input"),
405
+ )
406
+ append_event(
407
+ home,
408
+ project_root,
409
+ "context_note_written",
410
+ args.session,
411
+ meta={"artifact": "source-map"},
412
+ correlation_id=getattr(args, '_correlation_id', None),
413
+ )
414
+ elif args.type == "source-text":
415
+ target = artifact_paths(project_root, args.session)["context"] / "source-text.md"
416
+ target.write_text(content, encoding="utf-8")
417
+ append_event(
418
+ home,
419
+ project_root,
420
+ "context_note_written",
421
+ args.session,
422
+ meta={"artifact": "source-text"},
423
+ correlation_id=getattr(args, '_correlation_id', None),
424
+ )
425
+ else:
426
+ raise GhostReaderError(f"Unsupported artifact type `{args.type}`.")
427
+
428
+ emit_output(args, {"written": True, "path": str(target)})
429
+ return 0
430
+
431
+
432
+ def write_review_artifact(
433
+ home: Path,
434
+ project_root: Path,
435
+ session_id: str,
436
+ persona_id: str | None,
437
+ content: str,
438
+ manifest: dict,
439
+ parsed_review: dict | None = None,
440
+ correlation_id: str | None = None,
441
+ ) -> Path:
442
+ if not persona_id:
443
+ raise GhostReaderError("--persona is required when --type review.")
444
+ review = parsed_review if parsed_review is not None else load_yaml_text(
445
+ content, source="review input"
446
+ )
447
+ validate_review(review, session_id, persona_id)
448
+ read_persona(home, persona_id)
449
+ target = (
450
+ artifact_paths(project_root, session_id)["reviews"] / f"{persona_id}.review.yaml"
451
+ )
452
+ write_yaml(target, review)
453
+ if persona_id not in manifest.get("personas", []):
454
+ manifest.setdefault("personas", []).append(persona_id)
455
+ save_manifest(project_root, session_id, manifest)
456
+ append_event(
457
+ home,
458
+ project_root,
459
+ "review_completed",
460
+ session_id,
461
+ meta={"persona_id": persona_id},
462
+ correlation_id=correlation_id,
463
+ )
464
+ return target
465
+
466
+
467
+ def command_feedback_add(args: argparse.Namespace) -> int:
468
+ home = ghost_reader_home()
469
+ project_root = find_project_root()
470
+ feedback = load_yaml_text(read_input(args))
471
+ target = record_feedback(
472
+ project_root, home, args.session, feedback, surface="cli"
473
+ )
474
+ emit_output(args, {"feedback_recorded": True, "path": str(target)})
475
+ return 0
476
+
477
+
478
+ def command_feedback_summary(args: argparse.Namespace) -> int:
479
+ emit_output(args, feedback_summary(find_project_root(), args.session))
480
+ return 0
481
+
482
+
483
+ def command_prompt_revision(args: argparse.Namespace) -> int:
484
+ paths = generate_revision_prompt(
485
+ find_project_root(),
486
+ ghost_reader_home(),
487
+ args.session,
488
+ args.goal,
489
+ args.preserve,
490
+ )
491
+ emit_output(args, {"revision_prompt_generated": True, **paths})
492
+ return 0
493
+
494
+
495
+ def command_render(args: argparse.Namespace) -> int:
496
+ home = ghost_reader_home()
497
+ if args.format == "list":
498
+ templates = sorted(
499
+ p.name for p in (home / "templates").glob("*.html")
500
+ )
501
+ emit_output(args, {"templates": templates, "default": "report"})
502
+ return 0
503
+ paths = render_report(
504
+ find_project_root(),
505
+ home,
506
+ args.session,
507
+ args.command_prefix,
508
+ args.format,
509
+ args.export_config,
510
+ )
511
+ emit_output(
512
+ args,
513
+ {
514
+ "rendered": True,
515
+ **paths,
516
+ "next_step": f"serve --session {args.session}",
517
+ },
518
+ )
519
+ return 0
520
+
521
+
522
+ def command_verify(args: argparse.Namespace) -> int:
523
+ result = verify_session(find_project_root(), ghost_reader_home(), args.session)
524
+ if result.get("status") == "review_ready_feedback_pending":
525
+ result["next_step"] = f"serve --session {args.session}"
526
+ emit_output(args, result)
527
+ return 0
528
+
529
+
530
+ def command_telemetry_append(args: argparse.Namespace) -> int:
531
+ home = ghost_reader_home()
532
+ project_root = None
533
+ try:
534
+ project_root = find_project_root()
535
+ if args.session:
536
+ load_manifest(project_root, args.session)
537
+ except GhostReaderError:
538
+ if args.session:
539
+ raise
540
+ event = append_event(
541
+ home,
542
+ project_root,
543
+ args.event,
544
+ args.session,
545
+ args.surface,
546
+ parse_meta(args.meta),
547
+ )
548
+ emit_output(args, {"telemetry_appended": True, "event_id": event["event_id"]})
549
+ return 0
550
+
551
+
552
+ def parse_meta(values: list[str] | None) -> dict[str, str]:
553
+ meta = {}
554
+ for value in values or []:
555
+ if "=" not in value:
556
+ raise GhostReaderError(
557
+ f"Telemetry meta `{value}` must use key=value format."
558
+ )
559
+ key, item = value.split("=", 1)
560
+ meta[key] = item
561
+ return meta
562
+
563
+
564
+ def command_telemetry_status(args: argparse.Namespace) -> int:
565
+ home = ghost_reader_home()
566
+ ensure_home(home)
567
+ emit_output(args, telemetry_status(home))
568
+ return 0
569
+
570
+
571
+ def command_dialogue_append(args: argparse.Namespace) -> int:
572
+ home = ghost_reader_home()
573
+ project_root = find_project_root()
574
+ has_question_answer = args.question is not None or args.answer is not None
575
+ has_blob_input = args.content is not None or args.from_file is not None
576
+ if has_question_answer and has_blob_input:
577
+ raise GhostReaderError(
578
+ "--question/--answer cannot be combined with --content or --from."
579
+ )
580
+ if (args.question is None) != (args.answer is None):
581
+ raise GhostReaderError("--question and --answer must be used together.")
582
+ turn_text = (
583
+ f"**User:** {args.question}\n**Persona:** {args.answer}"
584
+ if has_question_answer
585
+ else read_input(args)
586
+ )
587
+ result = dialogue_append(
588
+ project_root,
589
+ home,
590
+ args.session,
591
+ args.persona,
592
+ args.item,
593
+ turn_text,
594
+ )
595
+ emit_output(args, result)
596
+ return 0
597
+
598
+
599
+ def command_dialogue_context(args: argparse.Namespace) -> int:
600
+ home = ghost_reader_home()
601
+ project_root = find_project_root()
602
+ emit_output(
603
+ args,
604
+ dialogue_context(project_root, home, args.session, args.persona, args.item),
605
+ )
606
+ return 0
607
+
608
+
609
+ def command_history_summary(args: argparse.Namespace) -> int:
610
+ home = ghost_reader_home()
611
+ project_root = None
612
+ try:
613
+ project_root = find_project_root()
614
+ except GhostReaderError:
615
+ pass
616
+ emit_output(args, history_summary(home, project_root))
617
+ return 0
618
+
619
+
620
+ def history_summary(home: Path, project_root: Path | None) -> dict:
621
+ all_manifests: list[dict] = []
622
+
623
+ if project_root:
624
+ sessions_dir = project_root / ".ghostreader" / "sessions"
625
+ if sessions_dir.exists():
626
+ for manifest_path in sorted(sessions_dir.glob("*/manifest.yaml")):
627
+ try:
628
+ manifest = load_yaml_file(manifest_path)
629
+ if "session_id" in manifest:
630
+ all_manifests.append(manifest)
631
+ except Exception:
632
+ pass
633
+
634
+ session_count = len(all_manifests)
635
+ persona_counts: dict[str, int] = {}
636
+ for manifest in all_manifests:
637
+ for persona_id in manifest.get("personas", []):
638
+ persona_counts[persona_id] = persona_counts.get(persona_id, 0) + 1
639
+ most_selected = sorted(
640
+ persona_counts.items(), key=lambda item: item[1], reverse=True
641
+ )[:5]
642
+
643
+ return {
644
+ "recent_usage": {
645
+ "sessions": session_count,
646
+ "most_selected_personas": [
647
+ {"persona_id": pid, "session_count": count}
648
+ for pid, count in most_selected
649
+ ],
650
+ "frequently_included_concern_types": [],
651
+ "common_story_contracts": [],
652
+ }
653
+ }
654
+
655
+
656
+ def command_round_init(args: argparse.Namespace) -> int:
657
+ home = ghost_reader_home()
658
+ project_root = find_project_root()
659
+ emit_output(
660
+ args,
661
+ round_init(
662
+ project_root,
663
+ home,
664
+ args.session,
665
+ story_unit=getattr(args, "story_unit", None),
666
+ source_file=getattr(args, "source", None),
667
+ )
668
+ )
669
+ return 0
670
+
671
+
672
+ def command_round_status(args: argparse.Namespace) -> int:
673
+ project_root = find_project_root()
674
+ emit_output(args, round_status(project_root, args.session))
675
+ return 0
676
+
677
+
678
+ def command_refine_snapshot(args: argparse.Namespace) -> int:
679
+ home = ghost_reader_home()
680
+ project_root = find_project_root()
681
+ emit_output(args, refine_snapshot(project_root, home, args.session))
682
+ return 0
683
+
684
+
685
+ def command_serve(args: argparse.Namespace) -> int:
686
+ home = ghost_reader_home()
687
+ project_root = find_project_root()
688
+ if not getattr(args, "session", None):
689
+ raise GhostReaderError("--session is required.")
690
+ load_manifest(project_root, args.session)
691
+ render_flag = None if getattr(args, "render", None) is None else args.render
692
+ if args.detach:
693
+ return _serve_detached(args, project_root, home, render_flag)
694
+ serve_report(
695
+ project_root,
696
+ home,
697
+ args.session,
698
+ port=args.port,
699
+ timeout=args.timeout,
700
+ render=render_flag,
701
+ command_prefix=getattr(args, "command_prefix", "ghost-reader"),
702
+ template_name=getattr(args, "format", "report"),
703
+ )
704
+ return 0
705
+
706
+
707
+ def _serve_detached(
708
+ args: argparse.Namespace,
709
+ project_root: Path,
710
+ home: Path,
711
+ render_flag: bool | None,
712
+ ) -> int:
713
+ if sys.platform == "win32":
714
+ raise GhostReaderError(
715
+ "ghost-reader serve --detach is not supported on Windows. "
716
+ "Use `ghost-reader serve --session <id>` and run it in a separate terminal."
717
+ )
718
+
719
+ state_dir = project_dir(project_root)
720
+ state_dir.mkdir(parents=True, exist_ok=True)
721
+ pid_path = state_dir / "server.pid"
722
+ log_path = state_dir / "server.log"
723
+
724
+ pidfile_fd = os.open(str(pid_path), os.O_CREAT | os.O_RDWR, 0o644)
725
+ try:
726
+ fcntl.flock(pidfile_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
727
+ except BlockingIOError:
728
+ os.close(pidfile_fd)
729
+ raise GhostReaderError(
730
+ "Detached server already running. "
731
+ "Run `ghost-reader serve stop` first."
732
+ )
733
+
734
+ r_fd, w_fd = os.pipe()
735
+ try:
736
+ pid = os.fork()
737
+ except OSError:
738
+ os.close(r_fd)
739
+ os.close(w_fd)
740
+ os.close(pidfile_fd)
741
+ raise
742
+ if pid == 0:
743
+ os.close(r_fd)
744
+ try:
745
+ os.setsid()
746
+ _redirect_daemon_io(log_path)
747
+ serve_report(
748
+ project_root,
749
+ home,
750
+ args.session,
751
+ port=args.port,
752
+ timeout=args.timeout,
753
+ render=render_flag,
754
+ command_prefix=getattr(args, "command_prefix", "ghost-reader"),
755
+ template_name=getattr(args, "format", "report"),
756
+ pipe_wfd=w_fd,
757
+ )
758
+ except BaseException:
759
+ traceback.print_exc()
760
+ os.write(w_fd, b"error\n")
761
+ os.close(w_fd)
762
+ os._exit(1)
763
+ os.close(w_fd)
764
+ os._exit(0)
765
+ else:
766
+ try:
767
+ os.close(w_fd)
768
+ data = os.read(r_fd, 1024).decode().strip()
769
+ os.close(r_fd)
770
+ if not data.startswith("ok:"):
771
+ raise GhostReaderError(
772
+ "Detached server failed to start. Check the server log for details."
773
+ )
774
+ actual_port = data[3:]
775
+ pid_path.write_text(f"{pid}\n", encoding="utf-8")
776
+ emit_output(
777
+ args,
778
+ {
779
+ "detached": True,
780
+ "pid": pid,
781
+ "pid_file": str(pid_path),
782
+ "log_file": str(log_path),
783
+ "url": f"http://localhost:{actual_port}",
784
+ }
785
+ )
786
+ sys.stdout.flush()
787
+ return 0
788
+ finally:
789
+ os.close(pidfile_fd)
790
+
791
+
792
+ def command_serve_stop(args: argparse.Namespace) -> int:
793
+ if getattr(args, "session", None):
794
+ raise GhostReaderError(
795
+ "`serve stop` is project-wide, not session-scoped. "
796
+ "Use `ghost-reader serve stop` without --session."
797
+ )
798
+ project_root = find_project_root()
799
+ pid_path = project_dir(project_root) / "server.pid"
800
+ pid = _read_pid(pid_path)
801
+ if not pid:
802
+ raise GhostReaderError("No detached Ghost Reader server PID file found.")
803
+ if not _process_running(pid):
804
+ pid_path.unlink(missing_ok=True)
805
+ emit_output(
806
+ args,
807
+ {"stopped": False, "pid": pid, "stale_pid_file_removed": True},
808
+ )
809
+ return 0
810
+
811
+ os.kill(pid, signal.SIGTERM)
812
+ stopped = False
813
+ for _ in range(30):
814
+ if not _process_running(pid):
815
+ stopped = True
816
+ break
817
+ time.sleep(0.1)
818
+ if not stopped:
819
+ raise GhostReaderError(f"Server process {pid} did not stop after SIGTERM.")
820
+
821
+ pid_path.unlink(missing_ok=True)
822
+ emit_output(args, {"stopped": True, "pid": pid})
823
+ return 0
824
+
825
+
826
+ def _read_pid(path: Path) -> int | None:
827
+ try:
828
+ return int(path.read_text(encoding="utf-8").strip())
829
+ except (FileNotFoundError, ValueError):
830
+ return None
831
+
832
+
833
+ def _process_running(pid: int) -> bool:
834
+ try:
835
+ os.kill(pid, 0)
836
+ except ProcessLookupError:
837
+ return False
838
+ except PermissionError:
839
+ return True
840
+ return True
841
+
842
+
843
+ def _redirect_daemon_io(log_path: Path) -> None:
844
+ log_path.parent.mkdir(parents=True, exist_ok=True)
845
+ log = log_path.open("w", encoding="utf-8")
846
+ devnull = open(os.devnull, "r", encoding="utf-8")
847
+ os.dup2(devnull.fileno(), sys.stdin.fileno())
848
+ os.dup2(log.fileno(), sys.stdout.fileno())
849
+ os.dup2(log.fileno(), sys.stderr.fileno())
850
+ devnull.close()
851
+ log.close()
852
+
853
+
854
+ def command_sync(args: argparse.Namespace) -> int:
855
+ home = ghost_reader_home()
856
+ ensure_home(home)
857
+ project_root = None
858
+ try:
859
+ project_root = find_project_root()
860
+ except GhostReaderError:
861
+ pass
862
+ append_event(
863
+ home,
864
+ project_root,
865
+ "sync_attempted",
866
+ args.session,
867
+ meta={"mode": "local_bundle"},
868
+ correlation_id=getattr(args, '_correlation_id', None),
869
+ )
870
+ emit_output(args, create_sync_bundle(home))
871
+ return 0
872
+
873
+
874
+ def build_parser() -> argparse.ArgumentParser:
875
+ parser = argparse.ArgumentParser(prog="ghost-reader")
876
+ add_output_format_option(parser, default="yaml")
877
+ subparsers = parser.add_subparsers(dest="command", required=True)
878
+
879
+ subparsers.add_parser("init").set_defaults(func=command_init)
880
+
881
+ help_parser = subparsers.add_parser("help")
882
+ help_parser.set_defaults(func=command_help)
883
+
884
+ update_parser = subparsers.add_parser("update")
885
+ update_subparsers = update_parser.add_subparsers(
886
+ dest="update_command", required=True
887
+ )
888
+ update_check = update_subparsers.add_parser("check")
889
+ add_output_format_option(update_check)
890
+ update_check.set_defaults(func=command_update_check)
891
+
892
+ session_parser = subparsers.add_parser("session")
893
+ session_subparsers = session_parser.add_subparsers(
894
+ dest="session_command", required=True
895
+ )
896
+ session_create = session_subparsers.add_parser("create")
897
+ session_create.add_argument("--id")
898
+ session_create.add_argument("--story-unit")
899
+ session_create.add_argument(
900
+ "--personas", help="Comma or space separated persona IDs."
901
+ )
902
+ session_create.add_argument(
903
+ "--contract", help="Optional story/audience contract YAML."
904
+ )
905
+ session_create.add_argument("--resume", action="store_true")
906
+ session_create.set_defaults(func=command_session_create)
907
+ session_summary = session_subparsers.add_parser("summary")
908
+ session_summary.add_argument("--session", required=True)
909
+ add_output_format_option(session_summary)
910
+ session_summary.set_defaults(func=command_session_summary)
911
+
912
+ artifact_parser = subparsers.add_parser("artifact")
913
+ artifact_subparsers = artifact_parser.add_subparsers(
914
+ dest="artifact_command", required=True
915
+ )
916
+ artifact_write = artifact_subparsers.add_parser("write")
917
+ artifact_write.add_argument("--session", required=True)
918
+ artifact_write.add_argument(
919
+ "--type",
920
+ required=True,
921
+ choices=["review", "context-note", "source-map", "source-text"],
922
+ )
923
+ artifact_write.add_argument("--persona")
924
+ artifact_write.add_argument("--from", dest="from_file")
925
+ artifact_write.add_argument("--content")
926
+ artifact_write.add_argument("--append", action="store_true")
927
+ artifact_write.add_argument(
928
+ "--validate",
929
+ action="store_true",
930
+ help="Validate YAML syntax before writing review or source-map artifacts.",
931
+ )
932
+ artifact_write.set_defaults(func=command_artifact_write)
933
+
934
+ feedback_parser = subparsers.add_parser("feedback")
935
+ feedback_subparsers = feedback_parser.add_subparsers(
936
+ dest="feedback_command", required=True
937
+ )
938
+ feedback_add = feedback_subparsers.add_parser("add")
939
+ feedback_add.add_argument("--session", required=True)
940
+ feedback_add.add_argument("--from", dest="from_file", required=True)
941
+ feedback_add.set_defaults(func=command_feedback_add)
942
+ feedback_summary_parser = feedback_subparsers.add_parser("summary")
943
+ feedback_summary_parser.add_argument("--session", required=True)
944
+ add_output_format_option(feedback_summary_parser)
945
+ feedback_summary_parser.set_defaults(func=command_feedback_summary)
946
+
947
+ prompt_parser = subparsers.add_parser("prompt")
948
+ prompt_subparsers = prompt_parser.add_subparsers(
949
+ dest="prompt_command", required=True
950
+ )
951
+ prompt_revision = prompt_subparsers.add_parser("revision")
952
+ prompt_revision.add_argument("--session", required=True)
953
+ prompt_revision.add_argument("--goal")
954
+ prompt_revision.add_argument("--preserve", action="append")
955
+ prompt_revision.set_defaults(func=command_prompt_revision)
956
+
957
+ dialogue_parser = subparsers.add_parser("dialogue")
958
+ dialogue_subparsers = dialogue_parser.add_subparsers(
959
+ dest="dialogue_command", required=True
960
+ )
961
+ dialogue_append_parser = dialogue_subparsers.add_parser("append")
962
+ dialogue_append_parser.add_argument("--session", required=True)
963
+ dialogue_append_parser.add_argument("--persona", required=True)
964
+ dialogue_append_parser.add_argument(
965
+ "--item",
966
+ required=True,
967
+ help="Review concern ID (e.g. c1, c3) or strength ID (e.g. s1, s2) from the persona review.",
968
+ )
969
+ dialogue_append_parser.add_argument("--from", dest="from_file")
970
+ dialogue_append_parser.add_argument("--content")
971
+ dialogue_append_parser.add_argument("--question")
972
+ dialogue_append_parser.add_argument("--answer")
973
+ dialogue_append_parser.set_defaults(func=command_dialogue_append)
974
+ dialogue_context_parser = dialogue_subparsers.add_parser("context")
975
+ dialogue_context_parser.add_argument("--session", required=True)
976
+ dialogue_context_parser.add_argument("--persona", required=True)
977
+ dialogue_context_parser.add_argument(
978
+ "--item",
979
+ required=True,
980
+ help="Review concern ID (e.g. c1, c3) or strength ID (e.g. s1, s2) from the persona review.",
981
+ )
982
+ dialogue_context_parser.set_defaults(func=command_dialogue_context)
983
+
984
+ history_parser = subparsers.add_parser("history")
985
+ history_subparsers = history_parser.add_subparsers(
986
+ dest="history_command", required=True
987
+ )
988
+ history_summary_parser = history_subparsers.add_parser("summary")
989
+ add_output_format_option(history_summary_parser)
990
+ history_summary_parser.set_defaults(func=command_history_summary)
991
+
992
+ round_parser = subparsers.add_parser("round")
993
+ round_subparsers = round_parser.add_subparsers(
994
+ dest="round_command", required=True
995
+ )
996
+ round_init_parser = round_subparsers.add_parser("init")
997
+ round_init_parser.add_argument("--session", required=True)
998
+ round_init_parser.add_argument("--story-unit")
999
+ round_init_parser.add_argument("--source")
1000
+ round_init_parser.set_defaults(func=command_round_init)
1001
+ round_status_parser = round_subparsers.add_parser("status")
1002
+ round_status_parser.add_argument("--session", required=True)
1003
+ round_status_parser.set_defaults(func=command_round_status)
1004
+
1005
+ refine_parser = subparsers.add_parser("refine")
1006
+ refine_subparsers = refine_parser.add_subparsers(
1007
+ dest="refine_command", required=True
1008
+ )
1009
+ refine_snapshot_parser = refine_subparsers.add_parser("snapshot")
1010
+ refine_snapshot_parser.add_argument("--session", required=True)
1011
+ refine_snapshot_parser.set_defaults(func=command_refine_snapshot)
1012
+
1013
+ render_parser = subparsers.add_parser("render")
1014
+ render_parser.add_argument("--session", required=True)
1015
+ render_parser.add_argument(
1016
+ "--command-prefix",
1017
+ default="ghost-reader",
1018
+ help="Command prefix embedded in the local report's continuation block.",
1019
+ )
1020
+ render_parser.add_argument(
1021
+ "--format",
1022
+ default="report",
1023
+ help="Template name to render (default: report). Use 'list' to see available templates.",
1024
+ )
1025
+ render_parser.add_argument(
1026
+ "--export-config",
1027
+ action="store_true",
1028
+ default=False,
1029
+ help="Also write report/config.yaml alongside payload.json.",
1030
+ )
1031
+ render_parser.set_defaults(func=command_render)
1032
+
1033
+ verify_parser = subparsers.add_parser("verify")
1034
+ verify_parser.add_argument("--session", required=True)
1035
+ verify_parser.set_defaults(func=command_verify)
1036
+
1037
+ telemetry_parser = subparsers.add_parser("telemetry")
1038
+ telemetry_subparsers = telemetry_parser.add_subparsers(
1039
+ dest="telemetry_command", required=True
1040
+ )
1041
+ telemetry_append = telemetry_subparsers.add_parser("append")
1042
+ telemetry_append.add_argument("--event", required=True)
1043
+ telemetry_append.add_argument("--session")
1044
+ telemetry_append.add_argument("--surface", default="cli")
1045
+ telemetry_append.add_argument("--meta", action="append")
1046
+ telemetry_append.set_defaults(func=command_telemetry_append)
1047
+ telemetry_status_parser = telemetry_subparsers.add_parser("status")
1048
+ add_output_format_option(telemetry_status_parser)
1049
+ telemetry_status_parser.set_defaults(func=command_telemetry_status)
1050
+
1051
+ sync_parser = subparsers.add_parser("sync")
1052
+ sync_parser.add_argument("--session")
1053
+ sync_parser.set_defaults(func=command_sync)
1054
+
1055
+ persona_parser = subparsers.add_parser("persona")
1056
+ persona_subparsers = persona_parser.add_subparsers(dest="persona_command", required=True)
1057
+ persona_list = persona_subparsers.add_parser("list")
1058
+ persona_list.set_defaults(func=command_persona_list)
1059
+ persona_show = persona_subparsers.add_parser("show")
1060
+ persona_show.add_argument("persona_id", help="Persona ID (e.g., mara, dex, pip)")
1061
+ persona_show.set_defaults(func=command_persona_show)
1062
+
1063
+ serve_parser = subparsers.add_parser("serve")
1064
+ serve_subparsers = serve_parser.add_subparsers(dest="serve_command")
1065
+ serve_stop = serve_subparsers.add_parser("stop")
1066
+ serve_stop.set_defaults(func=command_serve_stop)
1067
+ serve_parser.add_argument("--session")
1068
+ serve_parser.add_argument("--port", type=int, default=8765)
1069
+ serve_parser.add_argument("--timeout", type=int, default=0)
1070
+ serve_parser.add_argument("--detach", action="store_true")
1071
+ serve_parser.add_argument("--render", dest="render", action="store_true", default=None)
1072
+ serve_parser.add_argument("--no-render", dest="render", action="store_false", default=None)
1073
+ serve_parser.add_argument(
1074
+ "--command-prefix",
1075
+ default="ghost-reader",
1076
+ help="Command prefix for the rendered report's continuation block.",
1077
+ )
1078
+ serve_parser.add_argument(
1079
+ "--format",
1080
+ default="report",
1081
+ help="Template name to render (default: report).",
1082
+ )
1083
+ serve_parser.set_defaults(func=command_serve)
1084
+
1085
+ return parser
1086
+
1087
+
1088
+ def main(argv: list[str] | None = None) -> int:
1089
+ parser = build_parser()
1090
+ args = parser.parse_args(argv)
1091
+ correlation_id = str(uuid.uuid4())
1092
+ args._correlation_id = correlation_id
1093
+ try:
1094
+ return args.func(args)
1095
+ except GhostReaderError as exc:
1096
+ try:
1097
+ home = ghost_reader_home()
1098
+ except Exception:
1099
+ home = Path.home() / ".ghostreader"
1100
+ try:
1101
+ project_root = find_project_root()
1102
+ ensure_home(home)
1103
+ append_event(
1104
+ home,
1105
+ project_root,
1106
+ "error_occurred",
1107
+ meta={
1108
+ "error_type": type(exc).__name__,
1109
+ "error_message": str(exc),
1110
+ },
1111
+ correlation_id=correlation_id,
1112
+ )
1113
+ except Exception:
1114
+ pass
1115
+ sys.stderr.write(f"ghost-reader: error: {exc}\n")
1116
+ return 2
1117
+
1118
+
1119
+ def entrypoint() -> None:
1120
+ raise SystemExit(main())
1121
+
1122
+
1123
+ if __name__ == "__main__":
1124
+ entrypoint()