Skip to content
Open
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
48 changes: 48 additions & 0 deletions docs/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -1671,6 +1671,54 @@ tests/smoke never touch the real launchd domain or delete the predecessor file.

---

## `internal_dev`

For internal development of the rig-ecosystem tools themselves (rig, tg-cli, review-cli, …): when a
repo is developed IN PLACE — the checked-out files ARE the running binary (a live symlink) or a
long-running daemon reads them — a code change only takes effect after the daemon is restarted. This
block wires that restart to the commit: an opt-in `post-commit` git hook that, when a commit touches
the configured daemon-source paths, runs a graceful reload command (`tg-ctl restart` by default).
This is a per-REPO, **committed** concern (the enablement + source paths travel with the repo's
`rig.yaml`, exactly like `agent_hooks.worktree_only`) — **not** the global config. **Default OFF**
(an absent or empty `internal_dev:` block installs nothing).

```yaml
internal_dev:
auto_reload_on_commit: true # opt-in; default false
daemon_source_paths: ["src/daemon/*", "bin/tg-ctl"] # POSIX `case` globs, repo-relative
reload_command: "tg-ctl restart" # default shown; any shell command
```

| Key | Type | Default | Meaning |
|-----|------|---------|---------|
| `auto_reload_on_commit` | bool | `false` | install a `post-commit` hook that reloads the daemon when a commit touches a daemon-source path |
| `daemon_source_paths` | array of str | `[]` | repo-relative shell-glob patterns (POSIX `case`, a `*` spans `/`) whose change in a commit triggers the reload |
| `reload_command` | str | `tg-ctl restart` | the graceful reload command the hook runs on a match |

**What rig writes.** The repo-local `<git-dir>/hooks/post-commit` (worktree-correct — resolved via
`git rev-parse --git-common-dir`, not assumed to be `<repo>/.git`; a linked worktree's hooks live in
the repo's COMMON dir, not its private per-worktree admin dir). When a global `core.hooksPath`
composer shadows repo-local hooks (the rig global-hook dispatcher sets one machine-wide), rig also
writes a generic `post-commit` COMPOSER trampoline into that composer dir — the agent-tools
dispatcher ships no `post-commit` of its own, so without the trampoline a composer-shadowed
repo-local hook would never fire.

**The reload never blocks a commit.** `post-commit` runs after the commit is recorded; a failed
reload is reported and swallowed — a broken daemon must never wedge a developer's commit.

**Idempotent, marker-keyed, backup-on-conflict** — like every other rig-managed artifact
(`on_conflict` applies to both the hook and the composer).

**Drift.** `rig status` flags a missing or modified `post-commit` hook under the `internal_dev`
category (REPO layer).

**Dry-run seam.** `RIG_DEV_RELOAD_DRY_RUN=1` (mirrors `RIG_TG_CTL_DRY_RUN` / `RIG_TMUX_DRY_RUN`):
the hook reads it at FIRE time and skips the real reload; the runner reads it at APPLY time and
skips writing the machine-global composer (the one live/global mutation) — so tests/CI never fire a
real reload nor touch the real global hooks dir.

---

## Editing this config — `rig setup` (wizard) and `rig config get|set`

`rig setup` is the **interactive configuration wizard**. In a terminal it (1) SHOWS what is
Expand Down
44 changes: 44 additions & 0 deletions riglib/actions/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5696,6 +5696,49 @@ def _do_provision_spotlight(action: Action, on_conflict: str) -> ActionResult:
return ActionResult(action, "created", "; ".join(notes), out.backup)


def _do_install_dev_reload_hook(action: Action, on_conflict: str) -> ActionResult:
"""Install the per-repo post-commit daemon auto-reload hook (+ global composer trampoline).

Writes the repo-local ``<git-dir>/hooks/post-commit`` at the resolved (worktree-correct) path
unconditionally — it is the managed, reviewable artifact. Honors ``dev_reload.DRY_RUN_ENV``:
when set, the machine-global composer trampoline (the one live/global mutation, needed only
when a ``core.hooksPath`` composer shadows the repo-local hook) is skipped, mirroring
``RIG_TG_CTL_DRY_RUN`` / ``RIG_TMUX_DRY_RUN``.
"""
from .. import dev_reload

repo_root = action.source
dplan = dev_reload.build_dev_reload(
repo_root=repo_root,
daemon_source_paths=action.options.get("daemon_source_paths", []),
reload_command=action.options.get("reload_command", dev_reload.DEFAULT_RELOAD_COMMAND),
)
hook_target = dev_reload.post_commit_hook_path(repo_root)
out = fsutil.write_file(hook_target, dplan.render_hook(), on_conflict)
_chmod_x_if_changed(hook_target, out)
notes = [f"post-commit hook {out.detail}"]
status, backup = out.status, out.backup

if os.environ.get(dev_reload.DRY_RUN_ENV):
return ActionResult(action, status, "; ".join(notes), backup)

composer_target = dev_reload.composer_post_commit_path(repo_root)
if composer_target is not None:
c_out = fsutil.write_file(composer_target, dev_reload.render_post_commit_composer(), on_conflict)
_chmod_x_if_changed(composer_target, c_out)
notes.append(f"composer {c_out.detail}")
# Surface the composer's own outcome — it is the one machine-global mutation this
# action makes. A backup there must not be silently dropped (per the backup-on-conflict
# rule), and a composer-only change (hook unchanged, composer newly written/backed up)
# must not report "skipped" and hide that a real live/global write happened.
if c_out.backup is not None:
backup = c_out.backup
if c_out.status in _CHANGED_STATUSES and status == "skipped":
status = c_out.status

return ActionResult(action, status, "; ".join(notes), backup)


_HANDLERS: dict[str, Callable[[Action, str], ActionResult]] = {
"record_mode": _do_record_mode,
"copy_skill": _do_copy_skill,
Expand All @@ -5722,4 +5765,5 @@ def _do_provision_spotlight(action: Action, on_conflict: str) -> ActionResult:
"provision_spotlight": _do_provision_spotlight,
"provision_tools": _do_provision_tools,
"provision_tg_ctl": _do_provision_tg_ctl,
"install_dev_reload_hook": _do_install_dev_reload_hook,
}
1 change: 1 addition & 0 deletions riglib/areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class Area:
Area("ci", "CI gates", REPO, ("ci",), ship_slot=False),
Area("ship", "ship / `gh ship` merge gate", REPO, ("ci",), ship_slot=True),
Area("ship_delegator", "`gh ship` delegator (.claude/scripts/pr-ship.sh)", REPO, ("ship_delegator",)),
Area("internal_dev", "internal-dev daemon auto-reload hook", REPO, ("internal_dev",)),
Area("linters", "linter / formatter config files", REPO, ("linters",)),
Area("project_tools", "project tools (Haft / Serena / Sverklo)", REPO, ("project_tools",)),
Area("agents_md", "AGENTS.md / CLAUDE.md symlinks", REPO, ("agents_md",)),
Expand Down
47 changes: 47 additions & 0 deletions riglib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"spotlight",
"tools",
"tg_ctl",
"internal_dev",
"ship_delegator",
"linters",
"project_tools",
Expand Down Expand Up @@ -467,6 +468,16 @@ def _merge_layer(path: Path, label: str) -> None:
fix=f"move mode to {global_config_path()} or run `rig config set mode.name ... --global`",
schema_path="mode",
)
if label == "global" and data.get("internal_dev"):
raise ConfigError(
"internal_dev is a repo-only config block",
why=(
f"{path} is the GLOBAL layer, but internal_dev.daemon_source_paths is "
"repo-specific — a global block would silently cascade into every repo apply"
),
fix="move internal_dev into this repo's committed ./rig.yaml",
schema_path="internal_dev",
)
merged = _deep_merge(merged, data)
for k in data:
key_sources[k] = path
Expand Down Expand Up @@ -572,6 +583,7 @@ def validate(data: dict[str, Any]) -> None:
_validate_spotlight(data.get("spotlight", {}))
_validate_tools(data.get("tools", {}))
_validate_tg_ctl(data.get("tg_ctl", {}))
_validate_internal_dev(data.get("internal_dev", {}))
_validate_ship_delegator(data.get("ship_delegator", {}))
_validate_linters(data.get("linters", {}))
_validate_project_tools(data.get("project_tools", {}))
Expand Down Expand Up @@ -2022,3 +2034,38 @@ def _validate_tg_ctl(t: dict[str, Any]) -> None:
f"tg_ctl.{strkey} must be a string, got {value!r}",
schema_path=f"tg_ctl.{strkey}",
)


def _validate_internal_dev(d: dict[str, Any]) -> None:
"""Validate the ``internal_dev`` block — the per-repo daemon auto-reload post-commit hook.

A per-repo COMMITTED concern (the enablement + source paths travel with the repo's rig.yaml,
like ``agent_hooks.worktree_only``), so it belongs in the REPO layer — NOT the global config.
Default **OFF** (opt-in): an EMPTY/absent block is a no-op. Fail-closed, consistent with every
other block, on: a non-mapping block, an unknown key (typo guard), a non-bool
``auto_reload_on_commit``, a non-string-list ``daemon_source_paths``, a non-string
``reload_command``, and (a review-caught gap) enabling with no paths at all — that would
install a hook that can never match anything, silently.
"""
if not isinstance(d, dict):
raise ConfigError("internal_dev must be a mapping", schema_path="internal_dev")
if not d:
return
_reject_unknown_keys(d, "internal_dev")
_check_bool(d, "auto_reload_on_commit", "internal_dev.auto_reload_on_commit")
paths = d.get("daemon_source_paths")
if paths is not None and (not isinstance(paths, list) or not all(isinstance(e, str) for e in paths)):
raise ConfigError(
f"internal_dev.daemon_source_paths must be a list of strings, got {paths!r}",
schema_path="internal_dev.daemon_source_paths",
)
_check_str(d, "reload_command", "internal_dev.reload_command")
# Whitespace-only entries strip to nothing in dev_reload.build_dev_reload, so a list of
# blank strings passes the type check above yet still installs the same dead hook the
# empty-list check exists to prevent — require at least one NON-blank pattern.
if d.get("auto_reload_on_commit") and not any(str(p).strip() for p in (paths or [])):
raise ConfigError(
"internal_dev.auto_reload_on_commit is true but daemon_source_paths is empty — "
"the hook would never match anything",
schema_path="internal_dev.daemon_source_paths",
)
28 changes: 28 additions & 0 deletions riglib/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,33 @@ def to_node(self) -> dict[str, Any]:
},
)

_INTERNAL_DEV_BLOCK = Block(
doc=(
"internal development of the rig-ecosystem tools themselves (rig, tg-cli, review-cli, …): "
"a per-repo COMMITTED opt-in that installs a post-commit git hook so a commit touching the "
"daemon's source gracefully reloads the running daemon (tg-ctl restart) — zero manual restart."
),
leaves={
"auto_reload_on_commit": Leaf(
"boolean",
"install a post-commit hook that reloads the daemon when a commit touches a "
"daemon-source path (opt-in)",
default=False,
),
"daemon_source_paths": Leaf(
"array",
"repo-relative shell-glob patterns (POSIX `case`, `*` spans `/`) whose change in a "
"commit triggers the reload",
items_type="string",
),
"reload_command": Leaf(
"string",
"the graceful reload command the hook runs on a match",
default="tg-ctl restart",
),
},
)


# The top-level shape: the scalar top keys + every block, in the order config.py validates them.
_TOP_LEAVES: dict[str, Leaf] = {
Expand Down Expand Up @@ -828,6 +855,7 @@ def to_node(self) -> dict[str, Any]:
"spotlight": _SPOTLIGHT_BLOCK,
"tools": _TOOLS_BLOCK,
"tg_ctl": _TG_CTL_BLOCK,
"internal_dev": _INTERNAL_DEV_BLOCK,
"ship_delegator": _SHIP_DELEGATOR_BLOCK,
"linters": _LINTERS_BLOCK,
"project_tools": _PROJECT_TOOLS_BLOCK,
Expand Down
Loading
Loading