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