diff --git a/CHANGELOG.md b/CHANGELOG.md index 0516087..7ccc13a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ releases begin. - `skills/stack-upgrade/SKILL.md` — `/stack-upgrade` command (gstack-style). - `UPDATING.md` — the non-destructive update model ("reference, don't copy") + the managed-block convention for the rare inlined case. +- `scripts/sync_inlined_method.py` — regenerates a `STACK:BEGIN/END` managed block + from `AGENTS.md` (with `--check` for CI). For agents without an `@file` import + (e.g. MiniMax/Mavis): the method is inlined and re-synced, never hand-forked. - `tools/ai_docs/source_config.py` — replaces `source_exts.py`; now also exports `EXCLUDE_DIRS`, the unified directory exclusion set shared by all tools. - `tools/ai_docs/module_discovery.py` — shared `find_module()` function, eliminating diff --git a/UPDATING.md b/UPDATING.md index 5fe2661..659af36 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -69,9 +69,20 @@ For those, wrap the stack-managed region in markers and edit only outside them: - ... ``` -An updater replaces only the bytes between `STACK:BEGIN`/`STACK:END`; everything -outside survives. This is the fallback for the rare inlined case — **prefer the -`@AGENTS.md` reference**, which needs no markers and no merge at all. +Regenerate the block from the canonical source with: + +```bash +python3 scripts/sync_inlined_method.py # refresh the block +python3 scripts/sync_inlined_method.py --check # CI/pre-commit: fail if stale +``` + +It replaces only the bytes between `STACK:BEGIN`/`STACK:END` (backing up the +target first); everything outside survives. This is the path for tools without +an import directive — e.g. **MiniMax/Mavis**, whose `agent.md` has no `@file` +include, so the method is inlined in a managed block and re-synced on update. + +**Prefer the `@AGENTS.md` reference** wherever the tool supports it (Claude Code, +Cursor, Codex) — it needs no markers and no sync at all. ## For maintainers: cutting a release diff --git a/scripts/sync_inlined_method.py b/scripts/sync_inlined_method.py new file mode 100644 index 0000000..00a6c23 --- /dev/null +++ b/scripts/sync_inlined_method.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""sync_inlined_method.py — Refresh a STACK-managed block from AGENTS.md. + +Some agents (e.g. MiniMax/Mavis) have no `@file` import — their config must +physically contain the method. To keep that copy from diverging, wrap the +region in markers and regenerate it from the canonical AGENTS.md: + + + ... (replaced wholesale on every sync) ... + + +Only the bytes between the markers are replaced; everything outside survives, so +the target's personal/tool-specific sections are never touched. This is the +non-destructive update path for inlined content (see UPDATING.md). + +Usage: + python3 scripts/sync_inlined_method.py [--source AGENTS.md] [--check] + + --check : exit 1 if the block is stale (CI guard / pre-commit), write nothing. + +If the target has no markers yet, the script prints where to add them and exits +non-zero — it never guesses an insertion point in a file it doesn't own. +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +BEGIN_RE = re.compile(r"^\s*$", re.MULTILINE) +END_RE = re.compile(r"^\s*$", re.MULTILINE) + +STACK_ROOT = Path(__file__).resolve().parent.parent + + +def _stack_version(source: Path) -> str: + m = re.search(r"stack-version:\s*([0-9][0-9A-Za-z.\-]*)", source.read_text(encoding="utf-8")) + if m: + return m.group(1) + vf = STACK_ROOT / "VERSION" + return vf.read_text(encoding="utf-8").strip() if vf.exists() else "0.0.0" + + +def _build_block(source: Path) -> str: + version = _stack_version(source) + body = source.read_text(encoding="utf-8").rstrip("\n") + begin = (f"") + end = "" + return f"{begin}\n\n{body}\n\n{end}" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("target", help="File containing the STACK-managed block") + ap.add_argument("--source", default=str(STACK_ROOT / "AGENTS.md"), + help="Canonical method file (default: AGENTS.md)") + ap.add_argument("--check", action="store_true", + help="Exit 1 if the block is stale; write nothing") + args = ap.parse_args() + + target = Path(args.target) + source = Path(args.source) + if not target.exists(): + print(f"ERROR: target not found: {target}") + return 2 + if not source.exists(): + print(f"ERROR: source not found: {source}") + return 2 + + content = target.read_text(encoding="utf-8") + bm, em = BEGIN_RE.search(content), END_RE.search(content) + if not bm or not em or bm.start() >= em.start(): + print("ERROR: no STACK:BEGIN/STACK:END block found in the target.") + print("Add these two markers around the region you want managed, then re-run:") + print(" ") + print(" ") + return 3 + + new_block = _build_block(source) + new_content = content[:bm.start()] + new_block + content[em.end():] + + if new_content == content: + print(f"OK: {target.name} already in sync.") + return 0 + + if args.check: + print(f"STALE: {target.name} differs from {source.name} — run without --check to sync.") + return 1 + + backup = target.with_suffix(target.suffix + ".bak") + backup.write_text(content, encoding="utf-8") + target.write_text(new_content, encoding="utf-8") + print(f"SYNCED: {target.name} updated from {source.name} (backup: {backup.name}).") + return 0 + + +if __name__ == "__main__": + sys.exit(main())