Files
malkuthro-ComfyUI-Koolook/scripts/sync_to_dev.py
T
ba37ec3d41 feat: LTX Director 2.0.2 fork, keyframe grid-snap, and audio→video bind schedule (#259)
* feat(forks): LTX Director v2_0_2 fork + keyframe latent-grid snap (#258)

Bump the WhatDreamsCost Koolook fork to upstream 2.0.2 (pinned fe09f73) in a
new versions/v2_0_2 namespace, and add `snap_keyframes_to_grid` (default on)
to fix hard-pinned image keyframes "jumping" at latent-bucket edges.

Why: a Director image keyframe is injected as a single latent frame at the
segment's left edge, quantized to the model temporal stride s
(L(f)=0 if f==0 else 1+(f-1)//s). Pins near a bucket boundary round
ambiguously and two pins in one bucket collide. Snapping each pin to its
bucket center removes the ambiguity without changing which latent frame it
lands on; colliding pins are bumped to the next free bucket with a warning.

- forks/whatdreamscost_koolook/versions/v2_0_2/: vendored 2.0.2 sources with
  the v1.3.9 Koolook deltas carried forward (relay_overrides,
  audio_transcript_json, per-segment sigma — build_segments is byte-identical
  1.3.9->2.0.2 so the sigma patch drops in verbatim), plus new pure
  keyframe_grid.py snapper wired into the insert_frames build.
- Canonical LTXDirector__koolook rolls to 2.0.2 (entrypoint loads v2_0_2
  last); LTXDirector__koolook_v1_3_2 alias preserved; v1.3.9 kept on disk.
- LICENSE re-verified GPL-3.0 (2026-06-22). Manifest + THIRD_PARTY + CHANGELOG
  + audio-lipsync findings/CHEATSHEET updated.
- 37 new unit tests for the snapper; 107 fork tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(forks): append Koolook LTX Director widgets last for clean load

Move the v2_0_2 Koolook widgets (relay_overrides, audio_transcript_json,
snap_keyframes_to_grid) to the end of the schema, after override_audio. They
were inserted after img_compression, which shifted the last upstream widget
when an upstream-2.0.x-saved workflow is loaded onto LTXDirector__koolook.
With them last, an upstream graph's positional widgets_values map onto the
upstream widgets untouched and only the Koolook widgets append with defaults
(snap_keyframes_to_grid=on). Execution is unaffected (kwargs by name).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(forks): make v2_0_2 a clean upstream-2.0.2 replica + grid-snap only

Per maintainer direction: the v2_0_2 Koolook LTX Director should faithfully
replicate upstream 2.0.2 (so all latest features work) rather than carry the
older customizations, which are unused.

- Drop relay_overrides, audio_transcript_json, and the per-segment sigma patch.
  prompt_relay.py and patches.py are now vendored VERBATIM from upstream 2.0.2;
  _relay_overrides.py removed.
- ltx_director.py is upstream 2.0.2 verbatim except two additive changes:
  the namespaced node ID LTXDirector__koolook, and the snap_keyframes_to_grid
  latent-bucket keyframe snap (issue #258), appended last in the schema.
- v2_0_2 registers only LTXDirector__koolook; the legacy
  LTXDirector__koolook_v1_3_2 ID stays backed by the v1_3_9 package. v1.3.9
  (with the older customizations) remains on disk for rollback.
- Manifest / THIRD_PARTY / UPSTREAM_PIN / CHANGELOG updated to the clean scope.
  grid-snap unit tests unchanged (107 fork tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(forks): vendor upstream 2.0.2 timeline editor JS for LTXDirector__koolook

The v2_0_2 Python was upstream-exact but the node still rendered with the old
1.3.9-era timeline editor (fewer toolbar actions, no IC-LoRA track, leaked
*_second widgets, frame_rate NaN). Replace web/whatdreamscost_koolook/
ltx_director.js with upstream 2.0.2's js/ltx_director.js vendored verbatim,
retargeted with only two edits: unique extension name 'LTXDirector_Koolook'
and node guard nodeData.name === 'LTXDirector__koolook'. UX now matches
upstream 2.0.2 exactly; only the Python differs (grid-snap).

The old koolook base64-scrub deltas are dropped as obsolete — upstream 2.0.2
persists timeline media via imageFile/audioFile + /api/view URLs (saved
timelines are already lean), and the localStorage quota guard stays in the
global web/koolook_draft_guard.js (upstream does not override localStorage).

Rewrote tests/sidebar/test_ltx_director_draft_guard.py to the new contract
(no embedded guard, retargeted to the koolook node, pinned-upstream
provenance). 110 fork+contract tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scripts): resolve .env from main repo when dev-sync runs in a worktree

Unrelated to the LTX work: plain `dev-sync` failed with "KOLOOK_COMFYUI_DEV_PATH
not set" from any worktree because scripts/sync_to_dev.py only loaded
`.env` from the worktree root, where the committed `.env` does not exist
(it lives in the main checkout). sync_to_dev_audio.py already had the
worktree->main fallback, which is why dev-sync-audio worked.

Move that fallback into a shared sync_to_dev.find_dotenv() and use it in both
scripts so they can't drift. Adds regression tests for the worktree fallback,
the worktree-own-.env preference, and the not-a-worktree case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: gitignore root-level generated workflow JSONs

Node-swapped/converted workflow copies (e.g. *.koolook.json) are dropped at
the repo root during dev iteration; they're scratch deliverables, not package
content. Ignore root-level *.json only (leading `/`), with a `!config.json`
exception, so the 30+ tracked JSON in subdirs (test fixtures, web/, .claude/,
docs/runs/*/workflow.json) stay tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTX A/V Bind Schedule node — gate audio→video binding by sigma

New Koolook node LTXAVBindSchedule decouples big motion from lip-sync in LTX
2.3. The AV transformer (comfy/ldm/lightricks/av_model.py, BasicAVTransformerBlock)
adds audio into video via a gated `audio_to_video_attn` per block, run only when
transformer_options["a2v_cross_attn"] is set. This node patches each block's
audio_to_video_attn.forward to scale its output by a per-step gain, and a
unet-function-wrapper computes that gain from the current sigma: early/high-sigma
steps run near `early_gain` (coarse motion settles audio-blind), ramping
(smoothstep) to full at low sigma (lips bind onto settled motion). One pass, no
re-noise — the fix for keyframe transitions jumping on audio peaks.

early_gain=1.0 reproduces stock behavior; a graph without the node is unchanged.
Registered as a root node + added to RUNTIME_PATHS so dev-sync ships it. Pure
ramp (a2v_gain) unit-tested (9 cases); model wiring validated by live render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTX A/V Bind Schedule — add transition-window mode (lips synced + smooth)

The global sigma ramp decoupled motion from lips but desynced the mouth (lip
timing is coarse motion too, so holding audio out globally moved the mouth
audio-blind). Add a WINDOWED mode: audio binds at full strength everywhere
(lips sync across the whole clip) EXCEPT a +/-transition_window band around each
keyframe transition, where the sigma ramp applies — so only those moments are
motion-protected early and rejoined late.

Implemented as a per-latent-frame gain on a2v_out: read grid_sizes from
transformer_options to map frame-major video tokens -> latent frames, build a
gain vector (1.0 outside bands, smoothstep-tapered to sigma_gain at each
transition center), expand per-frame -> per-token, scale. New optional inputs:
transition_frames, total_frames, transition_window. Empty transition_frames =
prior global-ramp behavior. windowed_frame_gains unit-tested (7 cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: LTXAVBindSchedule — one-shot diagnostic log (shapes/grid/mode)

Logs out.shape, grid_sizes, transition_fracs, and whether the per-frame mask
fired vs fell back to scalar — so a single render reveals whether the a2v patch
hooks the model and whether the windowed token->frame mapping aligns, instead
of guessing blind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(audio-lipsync): record validated a2v bind-schedule knee (0.35/0.25/0.55)

Promote the finding that LTX 2.3's audio->video coupling must be sigma-ramped
(not full-on) to keep motion smooth AND lips synced, with the validated knee
early_gain=0.35 / bind_start=0.25 / bind_end=0.55 on the 8-step base pass, plus
the trade-axis table and the per-frame-mode caveat (grid_sizes None + appended
guide frames -> scalar fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(forks): keyframe ease — strength-ramped neighbor pins to de-robotic transitions

Adds `keyframe_ease` / `ease_falloff` to the Koolook LTX Director (default off).
Each hard image pin keeps its exact center pose, but emits same-pose neighbor
pins one latent bucket apart at decreasing strength (center*falloff**k), so the
model glides into and out of the locked pose instead of snapping to a dead stop
— softening the "stop/dissolve/stop" robotic feel without softening the pose.

Implemented as a pure list expansion (`expand_keyframe_ease` in keyframe_grid.py,
6 unit tests) applied to guide_data after grid-snap, so the upstream guide node
is untouched (no fork). Center stays exact; neighbors that fall off-clip or onto
another center are skipped. Costs extra guide frames (more compute), hence opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(audio-lipsync): downgrade a2v knee to 'promising' — note lows-placement confound

The 0.35/0.25/0.55 win was measured on a clip whose transitions sit on audio
lows, so 'no jumps' may be the placement, not the node. Mark the controlled
on-peak test (early_gain 1.0 vs 0.35 on the same on-peak layout) as still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: mark vendored WhatDreamsCost fork files linguist-vendored

The koolook Director ships upstream 2.0.2 code (the 8.5k-line timeline-editor JS
+ verbatim prompt_relay/patches) so the pack runs and distributes, but it's
upstream, not authored here. Mark those linguist-vendored (drop from language
stats) and the JS -diff (collapse the large blob in PR diffs). Our edited/new
files stay attributed to us.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ltx-av): drop unused `import math` (ruff F401)

a2v_gain uses an explicit smoothstep polynomial, not math.*, so the
import was dead. Unblocks the Lint & Smoke CI gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(codeql): advanced setup that excludes vendored upstream code

CodeQL default setup cannot read a config file, so it scanned the
verbatim-vendored WhatDreamsCost editor (web/whatdreamscost_koolook/
ltx_director.js) and raised 4 high-sev DOM-XSS alerts in upstream code we
do not own. Switch to advanced setup with a committed config whose
paths-ignore mirrors the .gitattributes linguist-vendored block, so the
scanner treats vendored upstream as out-of-scope while still fully
scanning our own nodes and sidebar JS.

Languages match the prior default-setup matrix (actions,
javascript-typescript, python).

NOTE: requires disabling "default setup" once in repo Settings before
this workflow can run (GitHub rejects advanced runs while default setup
is enabled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): scope vendored fork exclusions to v2_0_2

* fix(forks): preserve final LTX keyframe snap bucket

---------

Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:15:41 +02:00

405 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Copy runtime-relevant Koolook files into a live ComfyUI custom_nodes
folder so a fix can be tested without a tag-and-publish round-trip.
USER-INITIATED ONLY. This script overwrites a live ComfyUI install.
Agents must NEVER run it automatically - not after a commit, not after
a PR merge or ship-pr, not at session end, not from any "task complete"
cleanup. The maintainer typically has multiple parallel sessions across
worktrees, and an unsolicited sync from one silently destroys what
another is reviewing. See project CLAUDE.md `dev-sync` section for the
full policy. Run only on the explicit user trigger phrase.
The target path is read from the KOLOOK_COMFYUI_DEV_PATH environment
variable (loaded from `.env` at the repo root if present). The variable
is intentionally kept out of the committed tree - see `.env.example`.
`KOLOOK_COMFYUI_DEV_PATH` should point at the eventual Koolook
subdirectory inside `custom_nodes/`, NOT at the `custom_nodes/` parent.
Target ``custom_nodes/koolook/`` — that's where ComfyUI-Manager and the
Comfy Registry install (derived from ``[project].name`` in
``pyproject.toml``), so dev-sync overwrites the Manager install in place.
Targeting ``custom_nodes/ComfyUI-Koolook/`` instead spawns a parallel
install; ``__init__.py``'s duplicate-install guard logs a critical
message and disables the non-winning copy (issue #162).
Example layouts:
macOS: /Volumes/Data/ComfyUI/custom_nodes/koolook
Windows: C:/ComfyUI_portable/ComfyUI/custom_nodes/koolook
Usage:
python scripts/sync_to_dev.py # copy files
python scripts/sync_to_dev.py --dry-run # show what would copy
python scripts/sync_to_dev.py --init # first-run: create the
# target folder if missing
# (parent custom_nodes/ must
# already exist), then sync
After copying Python files, restart ComfyUI manually so custom-node modules
are re-imported. This script only copies files.
Exit codes:
0 success
2 KOLOOK_COMFYUI_DEV_PATH unset, parent missing, or target missing
(without --init)
3 --init refused: parent is not an existing directory or doesn't
resemble a ComfyUI custom_nodes/ folder
This script never reaches outside the repo, never deletes anything in
the source, and only touches paths under the configured target. It does
overwrite files in the target - that's the point.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _get_short_sha() -> str | None:
"""Best-effort short commit SHA of the source tree being synced.
Returns ``None`` if git isn't reachable or the call fails - the
summary line just omits the SHA in that case.
"""
try:
r = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=2,
)
if r.returncode == 0:
sha = r.stdout.strip()
if sha:
return sha
except (OSError, subprocess.TimeoutExpired):
pass
return None
def _get_worktree_name() -> str:
"""Returns the basename of the source tree being synced - useful when
the maintainer is running multiple parallel ComfyUI installs and
needs to know which checkout fed the most recent sync.
For a worktree at ``.../ComfyUI-Koolook/.claude/worktrees/foo`` this
returns ``foo``; for the main repo at ``.../ComfyUI-Koolook`` it
returns ``ComfyUI-Koolook``. Either is informative enough to
disambiguate."""
return REPO_ROOT.name
def build_line() -> str:
"""Composes the two-piece header line consumed by the chat-report
convention defined in project CLAUDE.md:
<short-sha> - <worktree-name>
SHA falls back to ``unknown`` if git is unreachable (we always need
SOMETHING in slot 1 - the line shape is part of the convention).
Worktree name comes from ``REPO_ROOT.name`` and is always present.
Public - consumed by scoped per-module wrappers like
``sync_to_dev_audio.py`` so every dev-sync variant emits the same
chat-report header.
"""
sha = _get_short_sha() or "unknown"
return f"{sha} - {_get_worktree_name()}"
# Backwards-compatible alias (the function was private until the audio
# wrapper landed). Drop after the next release cycle once we're sure no
# downstream caller imports the underscore name.
_build_line = build_line
def write_build_info(target: Path, scope: str | None) -> None:
"""Drop a tiny JSON next to the sidebar JS so the in-browser footer
can render `dev <sha> * <time>` (and an italic <scope> on a second
line) - same identifier the chat report quotes, but visible in the
running ComfyUI itself. Absent on registry installs (the file is
only written by this dev script), so the footer stays empty there.
Best-effort: missing git -> omit `commit` field; no `--scope` -> omit
`scope`; the timestamp alone is still useful for the maintainer to
eyeball "did my last sync land in this browser tab?\""""
info: dict[str, str] = {
"synced_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
"worktree": _get_worktree_name(),
}
sha = _get_short_sha()
if sha:
info["commit"] = sha
if scope:
info["scope"] = scope
out = target / "web" / "_dev_build.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(info, indent=2) + "\n", encoding="utf-8")
# Files / dirs ComfyUI loads at runtime, plus the package metadata ComfyUI
# Manager reads when showing the installed custom-node version. Anything
# outside this list (CI, docs, .claude/, .github/, .cursor/, CHANGELOG, LICENSE,
# README, fork manifest YAML, etc.) does not affect what ComfyUI executes or
# how the dev install is identified, and is intentionally skipped.
RUNTIME_PATHS: tuple[str, ...] = (
"pyproject.toml",
"__init__.py",
"config.json",
"k_ai_pipeline.py",
"k_easy_image_batch.py",
"k_easy_pattern.py",
"k_easy_resize.py",
"k_easy_track.py",
"k_easy_utility.py",
"k_easy_wan22_prompt.py",
"k_loop_status.py",
"k_ltx_av_bind_schedule.py",
"k_publish_contract.py",
"k_video_combine.py",
"k_video_load.py",
"koolook_install_guard.py",
"koolook_routes.py",
"koolook_setup_runner.py",
"koolook_setups.py",
"koolook_versioning.py",
"forks",
"video_formats",
"web",
)
# When copying directories, exclude these subpaths - they are dev-only
# metadata that ComfyUI doesn't need and which can churn unnecessarily.
DIR_EXCLUDES: tuple[str, ...] = (
"__pycache__",
"UPSTREAM_PIN.yaml",
"THIRD_PARTY.md",
"forks_manifest.yaml",
"README.md",
)
def load_dotenv(env_path: Path) -> None:
"""Minimal `.env` loader. No dependency on python-dotenv."""
if not env_path.exists():
return
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
# Don't overwrite anything already in the environment.
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def find_dotenv() -> Path | None:
"""Locate the `.env` to load.
Worktree root first, then the **main repo root** when running from a git
worktree (where `.git` is a file pointing at
`<main>/.git/worktrees/<name>`). The committed `.env` lives only in the
main checkout, so without this fallback `dev-sync` is unusable from a
worktree. Mirrors `scripts/make_card.py` and the documented behavior in
the dev-sync docs.
"""
direct = REPO_ROOT / ".env"
if direct.exists():
return direct
git_marker = REPO_ROOT / ".git"
if not git_marker.is_file():
return None
try:
content = git_marker.read_text(encoding="utf-8").strip()
except OSError:
return None
if not content.startswith("gitdir:"):
return None
gitdir = Path(content.split(":", 1)[1].strip())
if "worktrees" not in gitdir.parts:
return None
idx = gitdir.parts.index("worktrees")
main_repo_root = Path(*gitdir.parts[:idx]).parent
candidate = main_repo_root / ".env"
return candidate if candidate.exists() else None
def _ignore(_dir: str, names: list[str]) -> list[str]:
return [n for n in names if n in DIR_EXCLUDES]
def sync(
target: Path,
dry_run: bool,
verbose: bool,
paths: tuple[str, ...] = RUNTIME_PATHS,
) -> int:
"""Copy each entry in ``paths`` (relative to the repo root) to the
matching subpath under ``target``. Directories are recursively
copied with ``DIR_EXCLUDES`` filtered out and the previous dest
subtree removed first; files are overwritten in place.
The optional ``paths`` argument lets scoped wrappers (e.g.
``sync_to_dev_audio.py``) reuse this function with a smaller set -
just the subtree their automation module touches. The default is
the full ``RUNTIME_PATHS`` (every file ComfyUI loads at runtime).
"""
copied = 0
for rel in paths:
src = REPO_ROOT / rel
if not src.exists():
continue
dst = target / rel
if dry_run:
if verbose:
print(f"would copy: {rel}")
copied += 1
continue
if src.is_dir():
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(src, dst, ignore=_ignore)
else:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
if verbose:
print(f"copied: {rel}")
copied += 1
return copied
def _looks_like_custom_nodes(parent: Path) -> bool:
"""Heuristic: parent is named `custom_nodes` OR sits inside a
directory named `ComfyUI`. A bit conservative - saves the user from
a `KOLOOK_COMFYUI_DEV_PATH` typo that would otherwise create a fresh
`ComfyUI-Koolook/` somewhere unexpected on disk."""
if parent.name == "custom_nodes":
return True
grandparent = parent.parent
return grandparent.name.lower() == "comfyui" and parent.is_dir()
def ensure_target(target: Path, init: bool) -> int | None:
"""Validate the target. Returns an exit code (2 or 3) on error,
or None on success (with target now guaranteed to exist as a dir)."""
if target.exists():
if not target.is_dir():
print(f"target is not a directory: {target}", file=sys.stderr)
return 2
return None
# Target is missing.
if not init:
print(
f"target does not exist: {target}\n"
f"first time on this machine? re-run with --init to create it.",
file=sys.stderr,
)
return 2
parent = target.parent
if not parent.exists() or not parent.is_dir():
print(
f"--init refused: parent does not exist: {parent}\n"
f"check KOLOOK_COMFYUI_DEV_PATH - the *parent* (typically a "
f"ComfyUI custom_nodes/ folder) must already be in place.",
file=sys.stderr,
)
return 3
if not _looks_like_custom_nodes(parent):
print(
f"--init refused: parent doesn't look like a ComfyUI "
f"custom_nodes/ folder: {parent}\n"
f"expected the parent to be named 'custom_nodes' or to sit "
f"inside a 'ComfyUI' directory. If this really is your "
f"ComfyUI install, create the target manually with "
f"`mkdir -p \"{target}\"` and re-run without --init.",
file=sys.stderr,
)
return 3
target.mkdir(parents=False, exist_ok=False)
print(f"created target: {target}")
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be copied without touching the target.",
)
parser.add_argument(
"--init",
action="store_true",
help=(
"Create the target directory if it doesn't exist. The parent "
"(typically a ComfyUI custom_nodes/ folder) must already be "
"in place. Use on first-run only."
),
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help=(
"Print each file/dir as it's copied. Default output is just "
"the one-line build summary."
),
)
parser.add_argument(
"--scope",
type=str,
default=None,
help=(
"Short (<=10 word) description of what this build is about - "
"the same scope summary that goes in the chat report's second "
"line. Persisted in `web/_dev_build.json` and rendered in the "
"Kforge Labs sidebar footer (italic, second line, below the "
"`dev <sha> * <time>` identifier) so the maintainer can "
"correlate live ComfyUI state with chat history when juggling "
"multiple parallel worktree sessions. Optional - when absent, "
"the footer renders just identifier + timestamp."
),
)
args = parser.parse_args()
env_path = find_dotenv()
if env_path is not None:
load_dotenv(env_path)
target_str = os.environ.get("KOLOOK_COMFYUI_DEV_PATH")
if not target_str:
print(
"KOLOOK_COMFYUI_DEV_PATH not set. Add it to .env "
"(see .env.example).",
file=sys.stderr,
)
return 2
target = Path(target_str).expanduser()
err = ensure_target(target, init=args.init)
if err is not None:
return err
n = sync(target, dry_run=args.dry_run, verbose=args.verbose)
verb = "would sync" if args.dry_run else "synced"
# Two-line summary - see project CLAUDE.md `dev-sync` section for the
# chat-report convention that consumes this output. Header first so
# the maintainer's eye lands on the build identifier before the
# mechanical sync details.
print(build_line())
print(f"{verb} {n} entries -> {target}")
if not args.dry_run:
write_build_info(target, args.scope)
return 0
if __name__ == "__main__":
sys.exit(main())