ghost-reader 0.1.0__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.
- ghost_reader/.release-version +1 -0
- ghost_reader/__init__.py +3 -0
- ghost_reader/agent_loader.py +64 -0
- ghost_reader/cli.py +1124 -0
- ghost_reader/constants.py +75 -0
- ghost_reader/defaults/__init__.py +1 -0
- ghost_reader/defaults/personas/__init__.py +1 -0
- ghost_reader/defaults/personas/dex.yaml +30 -0
- ghost_reader/defaults/personas/elena.yaml +30 -0
- ghost_reader/defaults/personas/mara.yaml +30 -0
- ghost_reader/defaults/personas/pip.yaml +30 -0
- ghost_reader/defaults/personas/rook.yaml +30 -0
- ghost_reader/defaults/templates/__init__.py +1 -0
- ghost_reader/defaults/templates/blog-review.html +384 -0
- ghost_reader/defaults/templates/report.html +1293 -0
- ghost_reader/dialogue.py +283 -0
- ghost_reader/errors.py +2 -0
- ghost_reader/feedback_store.py +56 -0
- ghost_reader/io.py +59 -0
- ghost_reader/models.py +227 -0
- ghost_reader/paths.py +68 -0
- ghost_reader/project.py +277 -0
- ghost_reader/release.py +56 -0
- ghost_reader/report.py +264 -0
- ghost_reader/reviews.py +89 -0
- ghost_reader/revision.py +165 -0
- ghost_reader/round.py +155 -0
- ghost_reader/server.py +282 -0
- ghost_reader/sync.py +115 -0
- ghost_reader/telemetry.py +111 -0
- ghost_reader/time.py +20 -0
- ghost_reader/validators.py +66 -0
- ghost_reader/verify.py +255 -0
- ghost_reader-0.1.0.dist-info/METADATA +221 -0
- ghost_reader-0.1.0.dist-info/RECORD +39 -0
- ghost_reader-0.1.0.dist-info/WHEEL +5 -0
- ghost_reader-0.1.0.dist-info/entry_points.txt +2 -0
- ghost_reader-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghost_reader-0.1.0.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")
|
|
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()
|