Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bd50ee2
feat(booping): scaffold-seeded plan creation — unified-diff scaffold …
A Aug 9, 2026
683a050
develop: 202608091310_scaffold-seeded-plan-creation → in-progress
A Aug 9, 2026
8b200d1
feat(booping): scaffold-seeded plan creation — frontmatter-update pri…
A Aug 9, 2026
c84f64e
develop: 202608091310_scaffold-seeded-plan-creation → in-progress
A Aug 9, 2026
aca681f
feat(groom): scaffold-seeded plan creation — seed the plan directory …
A Aug 9, 2026
579639e
develop: 202608091310_scaffold-seeded-plan-creation → in-progress
A Aug 9, 2026
b949ce5
refactor(groom): scaffold-seeded plan creation — retire the duplicate…
A Aug 9, 2026
8efa59d
develop: 202608091310_scaffold-seeded-plan-creation → in-progress
A Aug 9, 2026
81a5f1c
docs(booping): scaffold-seeded plan creation — three scaffold trees a…
A Aug 9, 2026
131ef17
develop: 202608091310_scaffold-seeded-plan-creation → in-progress
A Aug 9, 2026
55ca5e2
docs(booping): scaffold-seeded plan creation — changelog entry and th…
A Aug 9, 2026
19d5902
develop: 202608091310_scaffold-seeded-plan-creation → done
A Aug 9, 2026
5ad3934
chore(booping): bump plugin version 1.0.0 → 1.0.1
A Aug 9, 2026
9b09a76
fix(booping): harden the plan scaffold tree against an omitted --set
A Aug 10, 2026
1aff179
test(booping): parametrize the frontmatter-update and scaffold receip…
A Aug 10, 2026
8bed56c
docs(booping): code-review run for scaffold-seeded plan creation
A Aug 10, 2026
0116676
fix(code-review): close-code-review mishandled an inline code_reviews…
A Aug 10, 2026
40049f5
docs(booping): fold the lazy-import lesson into a code-style lesson
A Aug 10, 2026
deb506a
Merge pull request #22 from A/feat/scaffold-seeded-plans
A Aug 10, 2026
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "booping",
"version": "1.0.0",
"version": "1.0.1",
"description": "Per-project grooming, implementation, retrospective, and lessons workflow with mandatory sub-agent delegation",
"author": {
"name": "Anton"
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

Notable user-visible changes, newest first, in the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format; versions follow [semantic versioning](https://semver.org/).

## Unreleased

### Changed

- `booping scaffold` and `booping frontmatter-update` now answer with a unified diff of every file they changed, and say nothing about a file whose content did not move — a rendered prompt can act on the receipt instead of reading the file back.
- `booping scaffold` decides per file rather than per destination: an existing target is skipped and named, `--force` overwrites the files the tree names, and a destination that already holds part of the tree no longer aborts the run.
- `booping frontmatter-update` writes a scalar with its YAML type, so `sp=23` lands as an integer; a string whose plain form would reload as something else keeps its quotes.
- Groom seeds the plan directory from the new `core.groom_playbook.scaffold` tree, so a plan carries a real `created` and the repo's `commit` from the moment it exists, and the frontmatter shape lives in the config alone.

## v1.0.0 — 2026-08-08

Everything booping does became a playbook. `/playbook` is now the only skill the plugin ships, procedures carry their own run state and resume across sessions, and retro and code review moved onto tracks of their own.
Expand Down
57 changes: 55 additions & 2 deletions booping-python/src/booping/commands/frontmatter_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@
from pathlib import Path
from typing import Any

import yaml
from jinja2 import Environment, TemplateError
from ruamel.yaml import YAML as RuamelYAML
from ruamel.yaml.error import YAMLError as RuamelYAMLError
from ruamel.yaml.scalarstring import SingleQuotedScalarString

from booping import logger
from booping.context import Context
from booping.context._yaml import update_frontmatter
from booping.macros import MacroError, make_macro
from booping.utils import diff_report

_NULL_FORMS = {"null", "Null", "NULL", "~"}


def add_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: # type: ignore[type-arg]
Expand Down Expand Up @@ -80,6 +87,41 @@ def interpolate(
sys.exit(2)


def coerce_scalar(value: str) -> object:
"""Return *value* as the Python type its plain YAML form loads as.

An integer, float, boolean or null form becomes that type, so the emitter
writes it unquoted. Everything else stays a string; a string whose plain
form would reload as another type under the YAML 1.1 resolver the readers
use is marked single-quoted, so it reloads as the string it was written as.
"""
if not value.strip():
return value

try:
loaded: object = RuamelYAML(typ="rt").load(value) # type: ignore[reportUnknownMemberType]
except RuamelYAMLError:
loaded = value

if loaded is None:
# An anchor or an empty tag also loads as None; only the null spellings
# are a real null.
return None if value.strip() in _NULL_FORMS else _quote_if_ambiguous(value)
if isinstance(loaded, bool | int | float):
return loaded
return _quote_if_ambiguous(value)


def _quote_if_ambiguous(value: str) -> str:
try:
reloaded: object = yaml.safe_load(value)
except yaml.YAMLError:
return value
if isinstance(reloaded, str) and reloaded == value:
return value
return SingleQuotedScalarString(value)


def parse_pairs(pairs: list[str]) -> dict[str, str]:
updates: dict[str, str] = {}
for pair in pairs:
Expand Down Expand Up @@ -117,13 +159,20 @@ def _run(args: argparse.Namespace) -> None:
vault_dir = project.directory if project is not None else None

resolved: dict[str, object] = {}
# The summary echoes the interpolated text, not the coerced value, so the
# stderr line reads the same as before the typing rule.
summary_values: dict[str, str] = {}
for key, value in updates.items():
resolved[key] = interpolate(value, repo_dir, ctx.config, vault_dir)
rendered = interpolate(value, repo_dir, ctx.config, vault_dir)
summary_values[key] = rendered
resolved[key] = coerce_scalar(rendered)

resolved_appends: dict[str, object] = {}
for key, value in appends.items():
resolved_appends[key] = interpolate(value, repo_dir, ctx.config, vault_dir)

previous = plan_path.read_text()

try:
update_frontmatter(plan_path, resolved, removals=removals, appends=resolved_appends)
except ValueError as exc:
Expand All @@ -133,6 +182,10 @@ def _run(args: argparse.Namespace) -> None:
print(f"error: {exc}", file=sys.stderr)
sys.exit(2)

report = diff_report(plan_path, previous, plan_path.read_text())
if report:
print(report)

vault = project.directory if project is not None else None
changed = [f"-{k}" for k in removals] + list(resolved.keys()) + list(resolved_appends.keys())
logger.log(
Expand All @@ -141,7 +194,7 @@ def _run(args: argparse.Namespace) -> None:

parts = (
[f"-{k}" for k in removals]
+ [f"{k}={v}" for k, v in resolved.items()]
+ [f"{k}={v}" for k, v in summary_values.items()]
+ [f"{k}+={v}" for k, v in resolved_appends.items()]
)
print(f"updated {plan_path}: {', '.join(parts)}", file=sys.stderr)
34 changes: 21 additions & 13 deletions booping-python/src/booping/commands/scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from booping.context.scaffold import DirNode, FileNode, ScaffoldError, load
from booping.macros import parse_stub_overrides
from booping.rendering import build_source_env
from booping.utils import deep_merge, parse_set_overrides
from booping.utils import deep_merge, diff_report, parse_set_overrides


def add_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: # type: ignore[type-arg]
Expand All @@ -38,7 +38,7 @@ def add_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser])
"--force",
action="store_true",
help=(
"Write into a non-empty destination, overwriting the files the tree names;"
"Overwrite the files the tree names when they already exist;"
" never deletes a directory"
),
)
Expand Down Expand Up @@ -104,7 +104,7 @@ def walk(node: DirNode, base: Path) -> None:
return writes


def _apply(writes: list[_Write]) -> tuple[int, int, int]:
def _apply(writes: list[_Write], force: bool) -> tuple[int, int, int]:
created_dirs = created_files = overwritten = 0
for write in writes:
existed = write.path.exists()
Expand All @@ -113,14 +113,24 @@ def _apply(writes: list[_Write]) -> tuple[int, int, int]:
if not existed:
print(f"created dir {write.path}")
created_dirs += 1
continue

if existed and not force:
print(f"skipped existing file {write.path}")
continue

previous = write.path.read_text(encoding="utf-8") if existed else None
if previous == write.content:
continue

write.path.write_text(write.content, encoding="utf-8")
diff = diff_report(write.path, previous, write.content)
if diff:
print(diff)
if existed:
overwritten += 1
else:
write.path.write_text(write.content, encoding="utf-8")
if existed:
print(f"overwrote file {write.path}")
overwritten += 1
else:
print(f"created file {write.path}")
created_files += 1
created_files += 1
return created_dirs, created_files, overwritten


Expand Down Expand Up @@ -148,8 +158,6 @@ def _run(args: argparse.Namespace) -> None:

if dest.exists() and not dest.is_dir():
_fail(f"destination {dest} exists and is not a directory")
if dest.is_dir() and any(dest.iterdir()) and not args.force:
_fail(f"destination {dest} is not empty — pass --force to write into it")

config = deep_merge(ctx.config, stub_overrides) if stub_overrides else ctx.config
env = build_source_env(context=ctx, config=config)
Expand All @@ -162,7 +170,7 @@ def _run(args: argparse.Namespace) -> None:
_fail(str(exc))

try:
created_dirs, created_files, overwritten = _apply(writes)
created_dirs, created_files, overwritten = _apply(writes, args.force)
except OSError as exc:
_fail(f"failed to write scaffold into {dest}: {exc}", code=2)

Expand Down
21 changes: 21 additions & 0 deletions booping-python/src/booping/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
import difflib
from collections.abc import Sequence
from pathlib import Path
from typing import Any, cast


def diff_report(path: Path, previous: str | None, current: str) -> str:
"""Unified diff of *previous* against *current* for *path*, empty when identical.

``previous`` is ``None`` when the file did not exist, which renders the
from-file as ``/dev/null``.
"""
if previous == current:
return ""
before = previous or ""
lines = difflib.unified_diff(
before.splitlines(),
current.splitlines(),
fromfile="/dev/null" if previous is None else str(path),
tofile=str(path),
lineterm="",
)
return "\n".join(lines)


class PathError(Exception):
"""Raised when a dotted path does not resolve in a mapping.

Expand Down
Loading
Loading