saga-cli 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.
saga/model.py ADDED
@@ -0,0 +1,253 @@
1
+ """Data model for PR sagas — the chapter-based review of one change set.
2
+
3
+ A saga partitions a change set's whole diff into ordered *chapters* that
4
+ tell a coherent story. This module is the pure, side-effect-light core:
5
+
6
+ * ``parse_hunks`` splits a unified diff into individually addressable ``Hunk``s
7
+ (stable ``h0, h1, …`` ids in diff order), and ``reconstruct_diff`` rebuilds a
8
+ valid unified-diff string for any subset of them — so the browser can render a
9
+ single chapter through diff2html with the *same* file/line anchors the full
10
+ diff uses.
11
+ * ``Chapter``/``Saga`` are the persisted shape (``to_dict``/``from_dict``).
12
+ * ``validate_coverage`` enforces the hard invariant: every hunk belongs to at
13
+ least one chapter. It raises ``SagaError`` loudly on any gap.
14
+
15
+ Stdlib only — this is the standalone core and has no external dependencies.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+
23
+ CONFIDENCE_LEVELS = ("high", "medium", "low")
24
+
25
+
26
+ class SagaError(Exception):
27
+ """Raised when a saga is malformed or fails the coverage invariant.
28
+
29
+ Carries a human-readable message surfaced to the reviewer as a non-blocking
30
+ notice before falling back to the standard diff.
31
+ """
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Hunk parsing / diff reconstruction
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _DIFF_GIT_RE = re.compile(r"^diff --git ", re.MULTILINE)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Hunk:
43
+ """One ``@@`` hunk of a unified diff, addressable by a stable id.
44
+
45
+ ``preamble`` is the file-level header (``diff --git`` … through the ``+++``
46
+ line) shared by every hunk of that file; ``body`` is the ``@@`` line plus its
47
+ context/change lines. Reconstructing a file emits the preamble once followed
48
+ by each of its hunks' bodies — a valid unified diff diff2html can render.
49
+ """
50
+
51
+ id: str
52
+ file_path: str
53
+ preamble: str
54
+ body: str
55
+
56
+
57
+ def _file_path_from_preamble(preamble: str) -> str:
58
+ """The new-side path diff2html shows in its file header (``+++ b/<path>``).
59
+
60
+ Falls back to the ``diff --git b/<path>`` side for pure renames/deletes with
61
+ no ``+++`` line.
62
+ """
63
+ for line in preamble.splitlines():
64
+ if line.startswith("+++ "):
65
+ target = line[4:].strip()
66
+ if target and target != "/dev/null":
67
+ return target[2:] if target.startswith(("a/", "b/")) else target
68
+ m = re.search(r"^diff --git a/.* b/(.*)$", preamble, re.MULTILINE)
69
+ return m.group(1).strip() if m else ""
70
+
71
+
72
+ def parse_hunks(diff_text: str) -> list[Hunk]:
73
+ """Split a unified diff into ``Hunk``s with stable ``h0, h1, …`` ids.
74
+
75
+ Files with no ``@@`` hunk (pure renames, mode-only changes) contribute no
76
+ hunks — there is nothing line-addressable to review.
77
+ """
78
+ hunks: list[Hunk] = []
79
+ starts = [m.start() for m in _DIFF_GIT_RE.finditer(diff_text)]
80
+ for i, start in enumerate(starts):
81
+ end = starts[i + 1] if i + 1 < len(starts) else len(diff_text)
82
+ block = diff_text[start:end]
83
+ at_idx = block.find("\n@@")
84
+ if at_idx == -1:
85
+ continue # no hunks in this file block
86
+ preamble = block[: at_idx + 1] # keep the trailing newline
87
+ rest = block[at_idx + 1 :]
88
+ file_path = _file_path_from_preamble(preamble)
89
+ # Each hunk runs from an "@@" line to the next "@@" (or end of block).
90
+ for m in re.finditer(r"(?m)^@@ ", rest):
91
+ h_start = m.start()
92
+ nxt = rest.find("\n@@ ", h_start + 1)
93
+ h_end = nxt + 1 if nxt != -1 else len(rest)
94
+ hunks.append(
95
+ Hunk(
96
+ id=f"h{len(hunks)}",
97
+ file_path=file_path,
98
+ preamble=preamble,
99
+ body=rest[h_start:h_end],
100
+ )
101
+ )
102
+ return hunks
103
+
104
+
105
+ def reconstruct_diff(hunks: list[Hunk]) -> str:
106
+ """Rebuild a valid unified diff from an ordered subset of hunks.
107
+
108
+ Hunks are grouped by file (first-seen order preserved); each file's preamble
109
+ is emitted once, then its selected hunk bodies in the given order. The result
110
+ renders through diff2html exactly like the full view.
111
+ """
112
+ out: list[str] = []
113
+ seen_preamble: set[str] = set()
114
+ for hunk in hunks:
115
+ if hunk.preamble not in seen_preamble:
116
+ out.append(hunk.preamble)
117
+ seen_preamble.add(hunk.preamble)
118
+ out.append(hunk.body)
119
+ return "".join(out)
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Chapter / Saga model
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ @dataclass
128
+ class Chapter:
129
+ """One ordered chapter of a saga.
130
+
131
+ ``hunks`` lists the hunk ids this chapter covers. ``deviation`` is set (to the
132
+ agent's explanation) only when the implementation diverged from the stated
133
+ intent; ``qa`` carries an optional ``{"status": …, "note": str}`` receipt.
134
+ Both are ``None`` when no intent context was supplied.
135
+ """
136
+
137
+ id: str
138
+ title: str
139
+ summary: str
140
+ narration: str
141
+ hunks: list[str] = field(default_factory=list)
142
+ plan_step: str | None = None
143
+ confidence: str = "medium"
144
+ deviation: str | None = None
145
+ qa: dict | None = None
146
+
147
+ @classmethod
148
+ def from_dict(cls, d: dict) -> Chapter:
149
+ confidence = d.get("confidence", "medium")
150
+ if confidence not in CONFIDENCE_LEVELS:
151
+ confidence = "medium"
152
+ return cls(
153
+ id=d["id"],
154
+ title=d.get("title", ""),
155
+ summary=d.get("summary", ""),
156
+ narration=d.get("narration", ""),
157
+ hunks=list(d.get("hunks", [])),
158
+ plan_step=d.get("plan_step"),
159
+ confidence=confidence,
160
+ deviation=d.get("deviation") or None,
161
+ qa=d.get("qa"),
162
+ )
163
+
164
+ def to_dict(self) -> dict:
165
+ return {
166
+ "id": self.id,
167
+ "title": self.title,
168
+ "summary": self.summary,
169
+ "narration": self.narration,
170
+ "hunks": self.hunks,
171
+ "plan_step": self.plan_step,
172
+ "confidence": self.confidence,
173
+ "deviation": self.deviation,
174
+ "qa": self.qa,
175
+ }
176
+
177
+
178
+ @dataclass
179
+ class Saga:
180
+ """A change set's saga: ordered chapters + diff context."""
181
+
182
+ branch: str
183
+ base: str
184
+ commit_sha: str
185
+ generated_at: str = ""
186
+ chapters: list[Chapter] = field(default_factory=list)
187
+
188
+ @classmethod
189
+ def from_dict(cls, d: dict) -> Saga:
190
+ return cls(
191
+ branch=d.get("branch", ""),
192
+ base=d.get("base", ""),
193
+ commit_sha=d.get("commit_sha", ""),
194
+ generated_at=d.get("generated_at", ""),
195
+ chapters=[Chapter.from_dict(c) for c in d.get("chapters", [])],
196
+ )
197
+
198
+ def to_dict(self) -> dict:
199
+ return {
200
+ "branch": self.branch,
201
+ "base": self.base,
202
+ "commit_sha": self.commit_sha,
203
+ "generated_at": self.generated_at,
204
+ "chapters": [c.to_dict() for c in self.chapters],
205
+ }
206
+
207
+ def verdict(self, *, qa_state: str) -> dict:
208
+ """The top-line summary rendered above the table of contents.
209
+
210
+ Computed here (not trusted from the LLM) so the counts always match the
211
+ chapters actually present.
212
+ """
213
+ return {
214
+ "chapters": len(self.chapters),
215
+ "deviations": sum(1 for c in self.chapters if c.deviation),
216
+ "low_confidence": sum(1 for c in self.chapters if c.confidence == "low"),
217
+ "qa": qa_state,
218
+ }
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Validation
223
+ # ---------------------------------------------------------------------------
224
+
225
+
226
+ def validate_coverage(chapters: list[Chapter], hunks: list[Hunk]) -> None:
227
+ """Enforce the hard invariant: every hunk belongs to at least one chapter.
228
+
229
+ Raises ``SagaError`` if any chapter references an unknown hunk id or
230
+ if any hunk is left unassigned. Chapters overlapping (a hunk in two chapters)
231
+ is allowed — coverage, not partition, is the contract.
232
+ """
233
+ if not chapters:
234
+ raise SagaError("Saga has no chapters.")
235
+
236
+ all_ids = {h.id for h in hunks}
237
+ referenced: set[str] = set()
238
+ for ch in chapters:
239
+ for hid in ch.hunks:
240
+ referenced.add(hid)
241
+
242
+ unknown = referenced - all_ids
243
+ if unknown:
244
+ raise SagaError(
245
+ f"Chapters reference unknown hunks: {', '.join(sorted(unknown))}."
246
+ )
247
+
248
+ unassigned = all_ids - referenced
249
+ if unassigned:
250
+ raise SagaError(
251
+ f"{len(unassigned)} hunk(s) not assigned to any chapter: "
252
+ f"{', '.join(sorted(unassigned))}."
253
+ )
saga/prompts/saga.md ADDED
@@ -0,0 +1,50 @@
1
+ You are giving a reviewer a guided tour of a code change — a chapter-by-chapter walkthrough that makes a large diff easy to review without losing focus.
2
+
3
+ You are handed:
4
+
5
+ 1. **The full diff**, split into labeled hunks. Every hunk has a stable id like `h0`, `h1`, … shown as `### HUNK h3 — path/to/file (new lines 40-55)`.
6
+ 2. **The commit messages** on this change, for context on intent and sequencing.
7
+ 3. **Optional intent** — a plan, spec, or description of what the change set out to do. This may be absent; if so, infer intent from the diff and commits.
8
+
9
+ Your job: partition the diff into an **ordered list of chapters** that tell one coherent story, and explain each in your own voice.
10
+
11
+ ## Rules
12
+
13
+ - **Cover every hunk.** Every hunk id must appear in at least one chapter. This is mandatory — the walkthrough is rejected if any hunk is left out. A hunk may appear in more than one chapter only if it genuinely belongs to both.
14
+ - **Order matters.** Sequence chapters so a reviewer who reads top-to-bottom builds understanding naturally — usually setup/foundation first, then the core change, then wiring and tests.
15
+ - **Group by idea, not by file.** A chapter can interleave hunks from several files when they serve one idea.
16
+ - **Zoom out when it helps.** If a chapter is about how the change fits the wider codebase, say so in the narration.
17
+ - **Zoom in on the hard parts.** For complex or non-obvious changes, explain why it was needed and why it was done this way rather than an alternative.
18
+
19
+ ## What each chapter needs
20
+
21
+ - `title` — a short, concrete title.
22
+ - `summary` — one line for the table of contents (≤ 12 words).
23
+ - `narration` — **2–4 short sentences, plain language.** The intent, the key decision, and the rationale. The reviewer does not want a wall of text — be brief and concrete. Markdown is allowed but keep it light.
24
+ - `hunks` — the list of hunk ids in this chapter, in reading order.
25
+ - `plan_step` — when an intent document was supplied, the step/section this chapter maps to (e.g. `"PR 1: …"`); otherwise `null`.
26
+ - `confidence` — `"high"`, `"medium"`, or `"low"`. Use `"low"` honestly for anything that deserves close review.
27
+ - `deviation` — `null` unless an intent was supplied _and_ the implementation diverged from it; then a short sentence explaining how and why. Be honest — deviations are highlighted for the reviewer.
28
+ - `qa` — `null`, or `{"status": "green", "note": "…"}` when tests cover this chapter's changes, or `{"status": "none", "note": "…"}` to flag a chapter with no test coverage worth noting.
29
+
30
+ ## Output
31
+
32
+ Return **only** a single JSON object, no prose before or after, no code fences:
33
+
34
+ ```
35
+ {
36
+ "chapters": [
37
+ {
38
+ "id": "ch1",
39
+ "title": "…",
40
+ "summary": "…",
41
+ "narration": "…",
42
+ "hunks": ["h0", "h1"],
43
+ "plan_step": null,
44
+ "confidence": "high",
45
+ "deviation": null,
46
+ "qa": {"status": "green", "note": "Covered by existing tests."}
47
+ }
48
+ ]
49
+ }
50
+ ```
saga/render.py ADDED
@@ -0,0 +1,144 @@
1
+ """Render a ``Saga`` into one self-contained static HTML file.
2
+
3
+ Everything is inlined — the design tokens, diff2html's CSS/JS (which bundles
4
+ highlight.js), marked, our saga JS, and the saga data itself — so
5
+ the output is a single file that opens offline and can be emailed, committed, or
6
+ dropped on any static host. The vendored CDN bundles are fetched once and cached
7
+ under ``.assets-cache/`` next to the package.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from urllib.request import urlopen
16
+
17
+ from .diff import compute_diff
18
+ from .model import Saga, parse_hunks, reconstruct_diff
19
+
20
+ _ASSETS = Path(__file__).resolve().parent / "assets"
21
+
22
+
23
+ def _cache_dir() -> Path:
24
+ """User-level cache for the vendored CDN bundles.
25
+
26
+ Uses ``$XDG_CACHE_HOME`` when set, else ``~/.cache`` — never the install dir,
27
+ which may be read-only under site-packages.
28
+ """
29
+ base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
30
+ return Path(base) / "saga"
31
+
32
+
33
+ _CACHE = _cache_dir()
34
+
35
+ _CDN = {
36
+ "diff2html.min.css": "https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css",
37
+ "diff2html-ui.min.js": "https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js",
38
+ "marked.min.js": "https://cdn.jsdelivr.net/npm/marked/marked.min.js",
39
+ }
40
+
41
+
42
+ def _vendored(name: str) -> str:
43
+ """Return a cached CDN bundle, downloading it once on first use."""
44
+ cached = _CACHE / name
45
+ if not cached.exists():
46
+ _CACHE.mkdir(parents=True, exist_ok=True)
47
+ cached.write_bytes(urlopen(_CDN[name]).read()) # noqa: S310
48
+ return cached.read_text()
49
+
50
+
51
+ def _asset(name: str) -> str:
52
+ return (_ASSETS / name).read_text()
53
+
54
+
55
+ def build_payload(repo_root: Path, saga: Saga, *, qa_state: str = "n/a") -> dict:
56
+ """Attach each chapter's reconstructed diff to the saga for the client.
57
+
58
+ The hunk map is recomputed from the live diff of the saga's own
59
+ base...head, so every stored hunk id resolves to its current diff text.
60
+ """
61
+ diff = compute_diff(repo_root, saga.base, saga.branch)
62
+ hmap = {h.id: h for h in parse_hunks(diff.diff_text)}
63
+ chapters = []
64
+ for ch in saga.chapters:
65
+ d = ch.to_dict()
66
+ d["diff"] = reconstruct_diff([hmap[h] for h in ch.hunks if h in hmap])
67
+ chapters.append(d)
68
+ return {
69
+ "branch": saga.branch,
70
+ "base": saga.base,
71
+ "verdict": saga.verdict(qa_state=qa_state),
72
+ "chapters": chapters,
73
+ }
74
+
75
+
76
+ def _json_for_script(payload: dict) -> str:
77
+ """JSON-encode *payload* safe to inline in a ``<script>`` tag.
78
+
79
+ Escaping ``<`` to ``\\u003c`` means diff content containing ``</script>`` or
80
+ ``<!--`` cannot break out of the tag.
81
+ """
82
+ return json.dumps(payload, ensure_ascii=False).replace("<", "\\u003c")
83
+
84
+
85
+ def render(repo_root: Path, saga: Saga, *, qa_state: str = "n/a") -> str:
86
+ """Build the complete self-contained HTML document for *saga*."""
87
+ payload = build_payload(repo_root, saga, qa_state=qa_state)
88
+ title = f"Saga · {saga.branch}"
89
+ styles = "\n".join(
90
+ [
91
+ _asset("tokens.css"),
92
+ _vendored("diff2html.min.css"),
93
+ _asset("base.css"),
94
+ _asset("saga.css"),
95
+ ]
96
+ )
97
+ scripts = "\n".join(
98
+ [
99
+ _vendored("diff2html-ui.min.js"),
100
+ _vendored("marked.min.js"),
101
+ f"window.__sagaData = {_json_for_script(payload)};",
102
+ _asset("saga.js"),
103
+ ]
104
+ )
105
+ return f"""<!DOCTYPE html>
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="utf-8">
109
+ <meta name="viewport" content="width=device-width, initial-scale=1">
110
+ <title>{title}</title>
111
+ <script>
112
+ // Apply the saved theme before first paint so there is no flash.
113
+ try {{
114
+ var t = localStorage.getItem('saga-theme');
115
+ if (t === 'light' || t === 'dark') {{
116
+ document.documentElement.setAttribute('data-theme', t);
117
+ }}
118
+ }} catch (e) {{}}
119
+ </script>
120
+ <style>
121
+ {styles}
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <div class="saga-rail" id="saga-rail"></div>
126
+ <div class="header">
127
+ <button id="saga-theme" class="saga-theme-toggle" type="button"
128
+ aria-label="Toggle light or dark theme" title="Toggle theme"></button>
129
+ <nav class="saga-crumbs">
130
+ <span class="saga-crumb-current">Saga</span>
131
+ <span class="saga-crumb-sep">&rsaquo;</span>
132
+ <span class="mono">{saga.base}...{saga.branch}</span>
133
+ </nav>
134
+ <h1>Saga</h1>
135
+ <div id="saga-verdict" class="saga-verdict"></div>
136
+ </div>
137
+ <div id="saga-notice"></div>
138
+ <div id="saga-toc" class="saga-toc"></div>
139
+ <div id="saga-reader" class="saga-reader" hidden></div>
140
+ <script>
141
+ {scripts}
142
+ </script>
143
+ </body>
144
+ </html>"""
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: saga-cli
3
+ Version: 0.1.0
4
+ Summary: Generate a self-contained static HTML saga of a git diff — a chapter-by-chapter guided tour of a change.
5
+ Project-URL: Homepage, https://github.com/JakeBeresford/saga
6
+ Project-URL: Repository, https://github.com/JakeBeresford/saga
7
+ Project-URL: Issues, https://github.com/JakeBeresford/saga/issues
8
+ Author: Jake Beresford
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: code-review,diff,documentation,git,html,llm
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Documentation
20
+ Classifier: Topic :: Software Development :: Version Control :: Git
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: anthropic
23
+ Requires-Dist: instructor
24
+ Requires-Dist: openai
25
+ Requires-Dist: typer>=0.26.8
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Saga
29
+
30
+ Generate a **chapter-by-chapter guided tour** of a code change as a single,
31
+ self-contained static HTML page, a _saga_ of your diff. It partitions a diff into
32
+ ordered chapters that tell one coherent story, each with a plain-language narration
33
+ and just the hunks that belong to it. Large PRs become easy to review without
34
+ losing the thread.
35
+
36
+ The output is one HTML file with everything inlined (diff2html, syntax highlighting,
37
+ the data). Open it offline, email it, commit it, or drop it on any static host.
38
+
39
+ [**See an example saga**](https://jakeberesford.github.io/saga/example.html).
40
+
41
+ ## Requirements
42
+
43
+ - Python 3.11+
44
+ - `git`
45
+ - An API key for your chosen provider, in the standard environment variable:
46
+ `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `OPENROUTER_API_KEY`
47
+
48
+ Generation is one structured LLM call made through [`instructor`](https://python.useinstructor.com).
49
+
50
+ ## Install
51
+
52
+ Install once and the `saga` command is available from any repo:
53
+
54
+ ```sh
55
+ uv tool install saga-cli # recommended
56
+ # or
57
+ pipx install saga-cli
58
+ # or, into the current environment
59
+ pip install saga-cli
60
+ ```
61
+
62
+ Installing from a local checkout instead? Point the installer at this directory,
63
+ e.g. `uv tool install /path/to/saga`. To upgrade: `uv tool install --force saga-cli`.
64
+
65
+ ## Usage
66
+
67
+ From inside the repo you want to review:
68
+
69
+ ```sh
70
+ saga --base main --head my-feature -o saga.html --open
71
+ ```
72
+
73
+ Run it from anywhere with `--repo`:
74
+
75
+ ```sh
76
+ saga --repo ~/src/some-project --base main --head my-feature -o out.html
77
+ ```
78
+
79
+ | Flag | Default | Meaning |
80
+ | ---------------------- | --------------------------- | -------------------------------------------------------------------------------------------------- |
81
+ | `--base` | `main` | Base ref to diff against |
82
+ | `--head` | current branch | Head ref to walk through |
83
+ | `--intent PATH` | — | Optional plan/spec describing the change's intent, for plan-aware narration and deviation flagging |
84
+ | `--model` | `anthropic/claude-opus-4-8` | `provider/model` string (see [Providers](#providers)); also `$SAGA_MODEL` |
85
+ | `-o, --output` | `saga.html` | Output file |
86
+ | `--repo` | cwd | A path inside the target git repo |
87
+ | `--open` / `--no-open` | on | Open the result in a browser (on by default; `--no-open` to disable) |
88
+
89
+ ## Providers
90
+
91
+ The model is a single `provider/model` string, dispatched through `instructor`.
92
+ Choose it with `--model` or the `SAGA_MODEL` environment variable, and set
93
+ the matching API key:
94
+
95
+ | Provider | `--model` example | API key env var |
96
+ | ---------- | ---------------------------------------- | -------------------- |
97
+ | Anthropic | `anthropic/claude-opus-4-8` | `ANTHROPIC_API_KEY` |
98
+ | OpenAI | `openai/gpt-4o` | `OPENAI_API_KEY` |
99
+ | OpenRouter | `openrouter/anthropic/claude-3.5-sonnet` | `OPENROUTER_API_KEY` |
100
+
101
+ ```sh
102
+ export SAGA_MODEL=openai/gpt-4o
103
+ export OPENAI_API_KEY=sk-…
104
+ saga --base main --head my-feature -o saga.html
105
+ ```
106
+
107
+ ## As a Claude Code skill
108
+
109
+ The `skills/` directory contains two Claude Code skills. To install both:
110
+
111
+ ```sh
112
+ cp -R "$(pwd)/skills" ~/.claude/skills/saga
113
+ ```
114
+
115
+ - **`saga`** — say **"/saga"** (or "give me a walkthrough of this branch") to generate a
116
+ saga. It resolves the base/head refs and runs the tool for you.
117
+ - **`saga-comments`** — say **"/saga-comments"** (or "address the saga comments") to read an
118
+ exported `saga.comments.json` and act on the reviewer's feedback in code.
119
+
120
+ ## Reviewing: comments
121
+
122
+ The saga page is also a lightweight review surface. Open `saga.html` and leave three
123
+ kinds of comments — **inline** (click a line's number in any chapter's diff), **per-file**
124
+ (the "💬 File comment" control in each file header), and one **overall** review comment
125
+ (the box at the top of the Chapters list). Comments are drafted in your browser's
126
+ `localStorage`, so they survive a reload.
127
+
128
+ When you're done, click **Export comments** to download a `saga.comments.json` sidecar next
129
+ to the HTML. (Export is disabled in the [hosted example](https://jakeberesford.github.io/saga/example.html)
130
+ so it never writes a file — every other part of the review UX works.) Two commands consume it:
131
+
132
+ ```sh
133
+ # Post everything as a single PENDING review on the PR (you submit it on GitHub).
134
+ saga comments push --comments saga.comments.json
135
+
136
+ # Emit the comments as JSON on stdout — for a coding agent to act on.
137
+ saga comments read --comments saga.comments.json
138
+ ```
139
+
140
+ `push` uses the `gh` CLI: it finds the open PR for the sidecar's branch and creates one
141
+ pending review (inline → line comments, per-file → a note anchored to the file's first
142
+ changed line, overall → the review body). Nothing is submitted until you review and submit
143
+ it on GitHub. Requires the [`gh`](https://cli.github.com) CLI, authenticated.
144
+
145
+ ## How it works
146
+
147
+ 1. `diff.py` computes `git diff base...head` (no checkout) and the commit list.
148
+ 2. `model.py` splits the diff into stable-id hunks (`h0, h1, …`).
149
+ 3. `generate.py` sends the labeled diff + commits (+ optional intent) to the chosen
150
+ model via `instructor`, which returns chapters as schema-validated JSON. Coverage
151
+ is **re-validated in code** — every hunk must belong to a chapter or generation fails.
152
+ 4. `render.py` reconstructs each chapter's diff and inlines everything into one
153
+ self-contained HTML file.
154
+
155
+ ## Not included (yet)
156
+
157
+ - Support for Local LLMs via ollama / LM Studio
158
+ - A GitHub Action to auto-generate saga on PRs.
@@ -0,0 +1,18 @@
1
+ saga/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ saga/__main__.py,sha256=Qd-f8z2Q2vpiEP2x6PBFsJrpACWDVxFKQk820MhFmHo,59
3
+ saga/cli.py,sha256=ICexToy2Tl-Ja4-Jc9mTQz-R5YwLP1PUyDmbyF2Xpnc,3328
4
+ saga/comments.py,sha256=FrsUpyYTB8lkO0RiXq-0_TPdb1NI_FpJeylG3jqkrk0,8924
5
+ saga/diff.py,sha256=FG0fo_394cQerfEgah45qj1Xvbd83ekfMVDRAS-0cEc,2508
6
+ saga/generate.py,sha256=KvNRbCoIfp8WbLmFlAWNbGZ5T9worFSFmgRv_rwXxQU,5714
7
+ saga/model.py,sha256=tyF-dwIWsPiTVwXbEMggHdrpDX9ViiPqgcPi2_q8sek,8784
8
+ saga/render.py,sha256=NLG-wVp2VmgHnK5KHgSfss4XWupdrwckdwrAZx_pluA,4588
9
+ saga/assets/base.css,sha256=Yv8XCzevdQwRKpivTXcB8SxD6WKP2Iyhd9SqgHyICUE,2453
10
+ saga/assets/saga.css,sha256=XWSsrhjVejY0yXKF2qBDNGbhiKEhD1iXnTn4Klnzfno,9482
11
+ saga/assets/saga.js,sha256=4CLnx7D6Dyqe4penPch5ccdIul4xf5W5bM6Bz7yXSGU,17658
12
+ saga/assets/tokens.css,sha256=H34kFxqonmbAnd4ibyUQj2ECfd1Xdu6JbTugwDej8Zw,5978
13
+ saga/prompts/saga.md,sha256=tzFW4DI0kAaESWgrTvXqyg7DbRTBZq7Ljb0dqsjN7eg,2982
14
+ saga_cli-0.1.0.dist-info/METADATA,sha256=eVOOlEh18K4uo08tufmwvbFeTunQ1i4wbC7EJB89-LI,7211
15
+ saga_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
16
+ saga_cli-0.1.0.dist-info/entry_points.txt,sha256=THvRLIVVZE2hhRHNRbrv7pdZ64afQfO_jYhR7bkkavY,38
17
+ saga_cli-0.1.0.dist-info/licenses/LICENSE,sha256=pARUeoUO0rxQdvv3Xz-VYrogjxss-G7JU3epAQn27AQ,1071
18
+ saga_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ saga = saga.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jake Beresford
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.