Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ docs/private/
/output.pdf
/output.png

# Live-preview mirror — a stable copy of the most recent render, regenerated on
# every render so a watching viewer (SumatraPDF) reloads in place. Derived and
# per-machine; never committed. Location is overridable via GRAPHCOMPOSE_LIVE_DIR.
/live/

# Shell interpolation accidents — files named after unexpanded variables or
# stray shell punctuation. They come from PowerShell heredocs / pipelines that
# write to ${command}, ${file}, etc. when the variable was empty. Never
Expand Down
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,13 @@ scripts/
render.mjs project-agnostic render (examples/<project-id>)
render-cv-reference.mjs clean + debug render for the cv example
render-invoice-reference.mjs same for invoice
preview-live.mjs open live/current.pdf in SumatraPDF (auto-reload)
run-pipeline.mjs print / run the agent chain for one revision
publish-template.mjs copy approved revision into templates/

live/ gitignored mirror of the most recent render
current.pdf / current-debug.pdf stable path to watch in SumatraPDF
current.png / current.txt page-1 raster + project/revision stamp
```

## Quick recipes
Expand Down Expand Up @@ -274,6 +279,17 @@ scripts/
revisions; quote the metric.
- GraphCompose API is the source of truth; never invent.

## Working on the flow itself (branch first)

Template/document work (new revisions under `examples/<project>/`) is the
product output and flows normally. But changes to the *flow itself* — `scripts/`,
the `tools/` modules, `prompts/`, `skills/`, the docs — must not land directly
on `main`: it is the clean state the user renders from. Cut a topic branch
(`feat/…`, `fix/…`, `docs/…`, `chore/…`), do the work and render there, and
merge to `main` only when it is finished. Releases are tagged from a
known-good `main`. Full rules: [`CONTRIBUTING.md`](CONTRIBUTING.md) →
"Branching and release workflow".

## Editor / agent rule-packs

If your tool supports project rules, it may have already loaded a thin pointer to
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ The project follows [Semantic Versioning](https://semver.org/) and stays in
`0.x` while the workflow stabilizes — skills are still `needs-validation`, and
the full visual-baseline pass is the gate to `1.0.0`.

## Unreleased

### Live preview
- **`live/` mirror.** Every render now also writes a single stable copy of the
latest output to `live/current.pdf` (plus `current-debug.pdf`, `current.png`,
`current.txt`) at the repo root, regardless of which project/revision produced
it. Open `live/current.pdf` once in SumatraPDF (auto-reloads on change, no
file lock) and watch every render refresh in place — no digging for the latest
revision folder. Override the location with `GRAPHCOMPOSE_LIVE_DIR`; disable
with `RENDER_NO_LIVE=1`. The folder is gitignored.
- **`scripts/preview-live.mjs`** (`npm run preview` / `npm run preview:debug`)
opens the live file in SumatraPDF with `-reuse-instance`, resolving it via
`SUMATRAPDF_PATH`, `PATH`, or the standard install locations, and falling back
to the OS default viewer.

### Developer workflow
- `CONTRIBUTING.md` documents the branch-per-change + release-from-`main`
workflow that keeps `main` always renderable; `AGENTS.md` carries the
agent-facing summary ("Working on the flow itself").

## v0.1.0 — 2026-06-03

First tagged release. The kit already turned visual references into
Expand Down
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,32 @@ a buggy skill instead of fixing the skill will be rejected.
- Reference the related issue when one exists.
- One logical change per commit.

## Branching and release workflow

`main` is always the clean, usable, releasable state of the kit. Never develop
the flow itself directly on `main` — a half-finished tooling change must not sit
on the branch a user renders from.

- **Branch per change.** Cut a topic branch off `main` for every flow update:
`feat/<slug>`, `fix/<slug>`, `docs/<slug>`, or `chore/<slug>`. Do the work,
render, and review there; `main` stays usable the whole time.
- **Document work vs flow work.** Day-to-day template work (new revisions under
`examples/<project>/revisions/`) is the product output and lands through the
normal revision flow. Changes to the *tooling* — `scripts/`, the `tools/`
modules, `prompts/`, `skills/`, the docs — are "flow updates" and belong on a
topic branch.
- **Merge when it is done.** When the change is finished and reviewed, merge the
branch into `main` (fast-forward or PR) so `main` only ever moves forward in
releasable steps.
- **Release from a known-good `main`:**
1. Move the `## Unreleased` notes in `CHANGELOG.md` under a new
`## vX.Y.Z — <date>` heading (SemVer; the kit stays in `0.x`).
2. Tag it: `git tag vX.Y.Z && git push origin vX.Y.Z`.
3. The tag is the citable version in the compatibility matrix.

The commit rules above still apply on branches: explicit staging, one logical
change per commit, imperative subjects.

## Pull request checklist

Before requesting review:
Expand Down
33 changes: 33 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,39 @@ examples/cv-reference/revisions/revision-002/output.png
examples/cv-reference/revisions/revision-002/output-page-2.png
```

## Live preview (watch renders update)

Every render also refreshes a single stable copy of the latest output under
`live/` at the repository root:

```text
live/current.pdf clean render (open this)
live/current-debug.pdf debug render with guide lines
live/current.png page-1 raster
live/current.txt which project / revision / time it reflects
```

`live/current.pdf` always points at the most recent render, whatever project
or revision produced it — so you do not have to dig into
`examples/<project>/revisions/<latest>/` to find the newest output.

Open it once in [SumatraPDF](https://www.sumatrapdfreader.org/) — it reloads a
PDF automatically when the file changes and does not lock it — and leave it
open while you iterate; each render refreshes the view in place:

```powershell
node scripts\preview-live.mjs # opens live\current.pdf
node scripts\preview-live.mjs --debug # opens live\current-debug.pdf
```

`npm run preview` is the same thing. The helper finds SumatraPDF on `PATH`, at
`%LOCALAPPDATA%\SumatraPDF`, or via `SUMATRAPDF_PATH`; with none found it falls
back to the OS default PDF viewer (which may not live-reload).

The `live/` folder is gitignored. To keep it off OneDrive (avoiding sync churn)
point `GRAPHCOMPOSE_LIVE_DIR` at another path; disable the mirror entirely with
`RENDER_NO_LIVE=1`.

## Inspect the Revision History

```powershell
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"type": "module",
"scripts": {
"setup": "node scripts/setup.mjs",
"setup:check": "node scripts/setup.mjs --check"
"setup:check": "node scripts/setup.mjs --check",
"preview": "node scripts/preview-live.mjs",
"preview:debug": "node scripts/preview-live.mjs --debug"
},
"engines": {
"node": ">=20"
Expand Down
144 changes: 144 additions & 0 deletions scripts/lib/render-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,137 @@ import path from "node:path";

import { ensureSkillValidationVerdict } from "./skill-validation-gate.mjs";

// --- live preview mirror -----------------------------------------------------
// A single, stable set of files that always reflects the MOST RECENT render,
// regardless of which project/revision produced it. Open live/current.pdf once
// in a viewer that auto-reloads on change and does not lock the file (e.g.
// SumatraPDF) and watch every render update live — no hunting for the latest
// revision folder.
//
// live/current.pdf clean render (the one to open)
// live/current-debug.pdf debug render with guide lines
// live/current.png page-1 raster of the clean render
// live/current-debug.png page-1 raster of the debug render
// live/current.txt which project / revision / time this reflects
//
// Location: <repoRoot>/live by default; override with GRAPHCOMPOSE_LIVE_DIR
// (e.g. a path outside OneDrive to avoid sync churn). Disable with
// RENDER_NO_LIVE=1. Mirroring is best-effort: a failure here only warns, it
// never fails the render.

const LIVE_README = `# Live preview

This folder always reflects the MOST RECENT render, regardless of which
project or revision produced it. It is regenerated on every render and is
gitignored — do not edit by hand.

Files:
current.pdf clean render (open this one)
current-debug.pdf debug render with guide lines
current.png page-1 raster of the clean render
current-debug.png page-1 raster of the debug render
current.txt which project / revision / time this reflects

## Watch renders update live (SumatraPDF)

SumatraPDF reloads a PDF automatically when the file changes on disk and does
not lock it. Open current.pdf once and leave it open; every render refreshes
the view in place.

node scripts/preview-live.mjs # opens live/current.pdf
node scripts/preview-live.mjs --debug # opens live/current-debug.pdf

Or open current.pdf in this folder manually in SumatraPDF.

## Options

GRAPHCOMPOSE_LIVE_DIR move this folder elsewhere (e.g. off OneDrive):
$env:GRAPHCOMPOSE_LIVE_DIR = "C:\\Temp\\gc-live"
RENDER_NO_LIVE=1 disable this live mirror entirely
`;

function resolveLiveDir(repoRoot) {
const override = process.env.GRAPHCOMPOSE_LIVE_DIR;
if (override && override.trim()) return path.resolve(override.trim());
return path.join(repoRoot, "live");
}

function mirrorFileToLive(liveDir, srcPath, destName) {
if (!srcPath || !fs.existsSync(srcPath)) return false;
const dest = path.join(liveDir, destName);
const tmp = path.join(liveDir, `.${destName}.tmp`);
// Copy to a temp file, then rename over the target. rename is atomic on the
// same volume, so a watching viewer never sees a half-written PDF (the same
// trick the LaTeX + SumatraPDF live-preview workflow relies on). Fall back to
// a direct copy if the rename is refused (e.g. a cross-volume live dir).
try {
fs.copyFileSync(srcPath, tmp);
fs.renameSync(tmp, dest);
return true;
} catch {
try {
fs.rmSync(tmp, { force: true });
} catch {
/* ignore */
}
try {
fs.copyFileSync(srcPath, dest);
return true;
} catch (err) {
console.warn(`> live mirror: could not update ${destName} (${err.message})`);
return false;
}
}
}

function writeLiveManifest(liveDir, info) {
const lines = [
`project: ${info.projectId}`,
`revision: ${info.revisionId}`,
`rendered: ${new Date().toISOString()}`,
`source: ${info.revisionDir}`,
``,
`current.pdf <- output.pdf`,
];
if (info.hasDebug) lines.push(`current-debug.pdf <- output-debug.pdf`);
lines.push("");
try {
fs.writeFileSync(path.join(liveDir, "current.txt"), lines.join("\n"), "utf8");
} catch (err) {
console.warn(`> live mirror: could not write current.txt (${err.message})`);
}
}

const NOOP_LIVE_MIRROR = { update() {}, manifest() {}, announce() {} };

function createLiveMirror(repoRoot) {
if (process.env.RENDER_NO_LIVE === "1") return NOOP_LIVE_MIRROR;
let dir;
try {
dir = resolveLiveDir(repoRoot);
fs.mkdirSync(dir, { recursive: true });
const readme = path.join(dir, "README.md");
if (!fs.existsSync(readme)) fs.writeFileSync(readme, LIVE_README, "utf8");
} catch (err) {
console.warn(`> live mirror disabled (${err.message})`);
return NOOP_LIVE_MIRROR;
}
let updated = 0;
return {
update(srcPath, destName) {
if (mirrorFileToLive(dir, srcPath, destName)) updated += 1;
},
manifest(info) {
writeLiveManifest(dir, info);
},
announce() {
if (updated > 0) {
console.log(`> live preview updated -> ${path.join(dir, "current.pdf")}`);
}
},
};
}

export function runRender({ repoRoot, projectId, revisionId }) {
const projectDir = path.join(repoRoot, "examples", projectId);
const templateProjectPath = path.join(projectDir, "template-project.json");
Expand Down Expand Up @@ -118,6 +249,7 @@ export function runRender({ repoRoot, projectId, revisionId }) {
const outputPdf = path.join(revisionDir, "output.pdf");
const debugPdf = path.join(revisionDir, "output-debug.pdf");
const dataFile = dataDriven ? path.join(revisionDir, dataFileName) : null;
const live = createLiveMirror(repoRoot);

// 1. Skill validation gate
ensureSkillValidationVerdict({
Expand Down Expand Up @@ -291,8 +423,14 @@ export function runRender({ repoRoot, projectId, revisionId }) {
);
}

// Mirror the clean render into the live-preview folder (live/current.*).
live.update(outputPdf, "current.pdf");
live.update(path.join(revisionDir, "output.png"), "current.png");
live.manifest({ projectId, revisionId, revisionDir, hasDebug: false });

if (!debugPass) {
console.log(`> debug pass skipped by project config`);
live.announce();
return;
}

Expand Down Expand Up @@ -347,6 +485,12 @@ export function runRender({ repoRoot, projectId, revisionId }) {
repoRoot,
);
}

// Mirror the debug render too, then point the user at the live file.
live.update(debugPdf, "current-debug.pdf");
live.update(path.join(revisionDir, "output-debug.png"), "current-debug.png");
live.manifest({ projectId, revisionId, revisionDir, hasDebug: true });
live.announce();
}

function runMaven(args, cwd) {
Expand Down
Loading
Loading