Merge remote-tracking branch 'origin/main'

# Conflicts:
#	AGENTS.md
#	agent/config.yml
#	omp-docs/log.md
This commit is contained in:
Frank committed 2026-10-18 16:47:37 -07:00
commit a550db6027
9 files changed
+637

No files matched your search

+24
View File
@@ -36,6 +36,11 @@ with YAML frontmatter, spec at `omp-docs/references/okf-spec.md`).
stay visible in history. **Never merge a PR without an explicit OK from Frank.** stay visible in history. **Never merge a PR without an explicit OK from Frank.**
- **The moment a PR is open, open it in Frank's browser** — `xdg-open <PR-URL>` - **The moment a PR is open, open it in Frank's browser** — `xdg-open <PR-URL>`
lands a tab in his Firefox; never make him hunt for it in Gitea. lands a tab in his Firefox; never make him hunt for it in Gitea.
- **Attribution footer (issues and PRs)** — every issue or PR body you open ends
with a `---` line, then `cwd: <working directory> · session: <omp session id>`
(session id from `/status`; `cwd` is that session's own working directory —
not the build checkout, when they differ). Machine-made artifacts stay attributable to the
box and session that spawned them.
- Releases: tag `vX.Y.Z` straight on `main` after verifying; tags are the deploy - Releases: tag `vX.Y.Z` straight on `main` after verifying; tags are the deploy
markers every other machine pulls to. Hotfixes are ordinary branches off the markers every other machine pulls to. Hotfixes are ordinary branches off the
tag you are fixing forward from. tag you are fixing forward from.
@@ -62,6 +67,25 @@ commit"). Applied here:
gate is the point of the hook). gate is the point of the hook).
- Messages: imperative mood, what + why, `Refs #N` when a Gitea issue exists. - Messages: imperative mood, what + why, `Refs #N` when a Gitea issue exists.
### Live-sync tiers (the machine is a committer too — Refs #11)
The checkout is the deployment, so several tracked files are live state
written continuously through symlinks by the harness, auto-learn, and
desktop GUIs. "Dirty tree" is judged by the writer:
| Tier | Paths | Commit law |
|------|-------|------------|
| Human/agent prose | `AGENTS.md`, `omp-docs/`, `scripts/`, `plugins/`, `agent/skills/` | branch → PR → Frank OK (unchanged) |
| Machine live | `agent/memories/`, `agent/managed-skills/`, `agent/config.yml`, `agent/models.yml` | `scripts/live-sync.sh` heartbeat |
| Desktop live | `dotfiles/` | same heartbeat |
The heartbeat (systemd user timer, hourly) stages ONLY allowlisted tiers,
commits with hooks armed — the secret gate included — merges `origin/main`,
pushes, and reaps `wt/*` worktrees older than 7 days. Non-allowlist dirt is
never committed, only surfaced. Conflicts abort and alert (ntfy); it never
force-pushes, auto-resolves, or uses `--no-verify`. Details:
[omp-docs/tools/live-sync.md](omp-docs/tools/live-sync.md).
## Secret gate (HARD rule) ## Secret gate (HARD rule)
The pre-commit hook (`.githooks/pre-commit`, enabled via The pre-commit hook (`.githooks/pre-commit`, enabled via
@@ -0,0 +1,12 @@
[Unit]
Description=command-center live-sync heartbeat (commits machine-written paths)
Documentation=file://%h/.omp/omp-docs/tools/live-sync.md
[Service]
# One-shot heartbeat: commit allowlisted live paths, merge trunk, push, reap
# stale wt/* worktrees. Self-healing by design — a failed fetch/merge alerts
# via ntfy and simply retries on the next tick; nothing to restart.
Type=oneshot
Nice=19
IOSchedulingClass=idle
ExecStart=%h/.omp/scripts/live-sync.sh
@@ -0,0 +1,11 @@
[Unit]
Description=command-center live-sync heartbeat every hour
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
RandomizedDelaySec=3min
Persistent=true
[Install]
WantedBy=timers.target
+8
View File
@@ -11,6 +11,14 @@ timestamp: 2026-08-28
# Bundle Change Log # Bundle Change Log
## 2026-10-18 — Fleet law: issue/PR attribution footers
Every agent-opened issue and PR body now ends with a `cwd:` + `session:` footer (session id from `/status`), in AGENTS.md of both `dank/command-center` and `dank/serverAppConfigs` (mirrored PRs). Born from the live-sync review: once the machine starts filing and committing on its own, its artifacts must trace back to the box and session that produced them.
## 2026-10-18 — live-sync heartbeat for machine-written paths (Refs #11)
Root-caused the month of silent divergence in the live checkout (~61 auto-learned skills on one disk only, a stranded `unexpectedStopDetection` fix, merge-refusals from dirty trees): the checkout IS the deployment, symlinks make machine writes dirty tracked files by design, and no commit law covered that churn. Added `scripts/live-sync.sh` + systemd user timer — allowlist tiers (memories, managed-skills, live config, dotfiles) auto-commit with hooks armed, merge `origin/main`, push, and reap stale `wt/*` worktrees; everything outside the allowlist is never staged, conflicts abort-and-alert. Black-box suite `tests/live-sync.bats` (9 tests). Tier law in AGENTS.md § Live-sync tiers; reference page `tools/live-sync.md` registered in the tools index.
## 2026-10-18 — Fleet law: filed PRs open in Frank's browser immediately ## 2026-10-18 — Fleet law: filed PRs open in Frank's browser immediately
Both repos' git rules now require it: the moment a PR is opened, `xdg-open <PR-URL>` drops a Firefox tab on the workstation (AGENTS.md § Trunk-based SDLC here; § Git Workflow step 6 in `dank/serverAppConfigs`, riding their PR #6 branch). Both repos' git rules now require it: the moment a PR is opened, `xdg-open <PR-URL>` drops a Firefox tab on the workstation (AGENTS.md § Trunk-based SDLC here; § Git Workflow step 6 in `dank/serverAppConfigs`, riding their PR #6 branch).
+199
View File
@@ -0,0 +1,199 @@
---
type: reference
title: OMP Status Line Customization
description: How the omp status line actually works — presets vs custom arrays, composer-shape segment routing, the context meter (box/band only), the 27-segment catalog, and the fleet's tuned layout.
tags:
- omp
- tui
- statusline
- config
timestamp: 2026-10-18
---
# OMP Status Line Customization
Ground truth from the installed bundle (`@oh-my-pi/pi-coding-agent/dist/cli.js`),
verified live on blankpad 2026-10-18 and re-audited line-by-line against the
minified source the same day (see [Audit log](#audit-log)). Status knobs live
in `agent/config.yml` under `statusLine:`; the two composer knobs
(`composer.shape`, `composer.tokenRate`) under `composer:`. The file is
file-watched — edits repaint a running session without restart (verify
visually: niri screenshot, see [niri capture recipe](../system/index.md)).
`/settings` (Appearance tab) previews these live with sample data.
## Preset vs custom — the trap
`statusLine.preset` ships whole configurations. **`leftSegments` /
`rightSegments` are ignored unless `preset: custom`** (`s ? user ?? preset
: preset` in the resolver). **`segmentOptions` is NOT gated** — user options
merge over the preset's own options under *every* preset.
| preset | leftSegments | rightSegments | separator |
|---|---|---|---|
| `default` | pi vim model mode collab stream path git pr context_pct cost | session_name | powerline-thin |
| `minimal` | vim path git | session_name mode context_pct | slash |
| `compact` | vim model mode git pr | session_name cost context_pct | powerline-thin |
| `full` | pi vim hostname model mode path git pr subagents | session_name cache_hit token_in token_out token_rate cache_read cost context_pct time_spent time | powerline |
| `nerd` | pi vim hostname model mode path git pr session subagents | session_name token_in token_out cache_read cache_write token_rate cost context_pct context_total time_spent time | powerline |
| `ascii` | vim model mode path git pr | session_name token_total cost context_pct | ascii |
| `custom` | your `statusLine.leftSegments` | your `statusLine.rightSegments` | powerline-thin |
The built-in default for `statusLine.leftSegments`/`rightSegments` themselves
is the `ascii`-era pair (`vim model mode path git pr` / `session_name
token_total cost context_pct`) — a config carrying those arrays with a named
preset is running the preset, not the arrays. The fleet runs `preset: custom`.
## Composer shape routes the segments
`composer.shape` decides *where* the two arrays render. Eight shapes ship in
the bundle; the plugin API can register more (`zG()` = built-ins + a live
registration map). Internally each shape carries `statusAttachment`,
`bottomBar`, `bottomBarGap`:
| shape | attach | bottom bar | leftSegments | rightSegments |
|---|---|---|---|---|
| `band` (fleet, default) | top-band | none | merged single row above editor, left | same row, right (`session_name` skipped — title rides the working row) |
| `box` | top-border | none | box top border | box top border |
| `claude` | top-rule-chip | left | bottom strip, left | top rule chip, right-aligned |
| `rule` | top-rule-chip | left + gap | like claude, gap above strip | like claude |
| `pi` | none | full | single bottom bar | single bottom bar |
| `borderless` | none | full | single bottom bar | single bottom bar |
| `field` / `rail` | none | full + gap (filled surface) | single bottom bar | single bottom bar |
The chip/strip layouts paint their rules as **session-accent fillers, not
meters** — the context meter exists only in `box` and `band`
(see next section). The top-rule chip renders right segments only, the bottom
strip left segments only.
The working row is composer chrome, not segment-addressable: session title +
shimmer, plus the tok/s readout when `composer.tokenRate: true` (default
false). The readout is estimated from streamed deltas, corrected by billed
output counts; the bundle also mentions a native-composer-bar placement in
filled styles.
## The context meter
`statusLine.contextLine` (enum `off` / `percentage` / `annotated` /
`embedded`, default `embedded`) tunes the rule between the segment groups.
The knob description says "box composer only" — **that is wrong**: the meter
renders in `box` **and** `band`; in every other shape the rule is a constant
session-accent filler regardless of this setting (live-verified claude with
`off` vs `embedded`: identical, and not context-proportional).
In box/band:
- `off` → plain accent rule (single color, full width).
- `embedded` → the inline labeled meter: `60% ——— × 200K`. Requires
`contextWindow > 0`, a `context_pct`/`context_total` segment present in
either array, and at least one non-context segment. The context segment(s)
are **hoisted out of the arrays** — they never render as text in this mode.
- `percentage` / `annotated` → unlabeled proportional meter painted into the
rule (live-verified in band: colored fill ∝ usage at the rule's right end);
`context_pct` stays a normal text segment.
Fill length ∝ `min(100, contextPercent)`; >100% paints the raw percent and
shortens the meter. Meter/segment color thresholds scale with window size —
warning at ≥ min(50 %, 150K tokens), purple ≥ min(70 %, 270K), error ≥
min(90 %, 500K); below that the theme's `statusLineContext` color.
## Segment universe (27)
`pi`, `status`, `model`, `mode`, `path`, `git`, `pr`, `subagents`,
`token_in`, `token_out`, `token_total`, `token_rate`, `cost`, `context_pct`,
`context_total`, `time_spent`, `time`, `session`, `hostname`, `cache_read`,
`cache_write`, `cache_hit`, `session_name`, `usage`, `collab`, `stream`, `vim`.
Zero/inactive segments render nothing — dead weight costs no width.
| segment | renders | trigger / notes |
|---|---|---|
| `pi` | brand glyph + turn-elapsed | while streaming; focused-agent ghost id (warning color) when a subagent is focused |
| `status` | hook/extension statuses, dot-joined | extension pushes via `ctx.ui.setStatus()` (ordered by key); suppressed in footer when `statusLine.showHookStatus: false` |
| `model` | display `name` + thinking level | suffix hidden by `segmentOptions.model.showThinkingLevel: false`; `statusLine.compactThinkingLevel: true` (default) swaps in a compact glyph; fast-mode, advisor/tariff badges attach here |
| `mode` | `Plan` / `Prewalk` / `Goal` / `Vibe` / `Loop …` | only in those modes (paused variants add a pause glyph); there is **no yolo/auto-approval display** in this segment |
| `path` | cwd or `project/worktree` label | see [Path segment](#path-segment-and-stripworkprefix) |
| `git` | branch + dirty badges | `+N` staged, `*N` unstaged, `?N` untracked (`segmentOptions.git.showStaged/showUnstaged/showUntracked`, default true); `showBranch: false` hides branch text and keeps badges (git icon carries them); color flips on dirty |
| `pr` | `#<number>` hyperlink | only once the session has an associated PR |
| `subagents` | count | hidden at 0; when subagents are actively running the composer paints its own live badge and this segment self-suppresses |
| `token_in` / `token_out` | cumulative session input/output tokens | hidden at 0; `cache_read` / `cache_write` likewise cumulative |
| `token_total` | `input + output + cache_write + orchestration_in + orchestration_out` | hidden at 0 |
| `token_rate` | `N.N tok/s` | hidden when not streaming (the working-row readout is a separate, `composer.tokenRate`-gated thing) |
| `cost` | session cost | includes subagent-tree + advisor cost; `↑`/`↓` peak/off-peak arrow for time-priced providers |
| `context_pct` | `NN.N%/200K` (or window alone when percent is unknown) | hoisted into the meter in `embedded` box/band; auto-compact speculation icon rides along |
| `context_total` | absolute context tokens | also hoisted by the embedded meter |
| `time_spent` | active session time | hidden under 1 s; this is *active* time, not wall clock |
| `time` | wall clock | `segmentOptions.time.format: "12h"/"24h"`, `showSeconds` |
| `session` | first 8 chars of session id (`new` before resume exists) | resume-by-id handle |
| `session_name` | session title, accent | in `band` it renders on the working row instead |
| `hostname` | short hostname | accent-tinted with session color |
| `usage` | rate-limit windows | 5h / daily / 7d / monthly bars + tier badge, when the provider reports them |
| `collab` | `collab:N` / `collab guest:N` | only in collab sessions |
| `stream` | `● LIVE <viewers>` / `● REC` | only while streaming/recording |
| `vim` | mode glyph/word | only while vim keybindings are active; selected-line count appended (` 3L`); `display` icon vs text comes from the vim runtime, not config |
## Path segment and `stripWorkPrefix`
`segmentOptions.path.stripWorkPrefix` (default **true** — the code tests
`!== false`) gates two behaviors in the `path` renderer:
1. **Worktree sessions:** `true` never shows the disk path — it renders the
logical `projectName/worktreeName` label (bare `projectName` when the
current branch is named after the worktree) with the worktree glyph.
`false` opts out and shows the real cwd with a folder glyph, plus a
` ↳ relativeRepoRoot` suffix inside a repo. Base-directory-only is not
renderable in either mode.
2. **All sessions:** `true` uses the pretty display path (scratch sessions
get the scratch-folder icon); `false` uses the raw absolute cwd.
`abbreviate` (default **true**: home → `~`; `false` keeps the full path —
also affects tool labels/errors per upstream docs) and `maxLength` (clip,
default 40) apply afterwards in both modes. The fleet runs `true` — logical
label, worktree glyph (verified live 2026-10-18; an earlier trial of `false`
for the full cwd was rejected by eye).
## Status knobs (registry-verified)
`statusLine.preset` · `.leftSegments` · `.rightSegments` · `.segmentOptions` ·
`.separator` (`powerline` `powerline-thin` `slash` `pipe` `block` `none`
`ascii`; default `powerline-thin`) · `.contextLine` · `.sessionAccent`
(default true; hashes the session name into the editor border *and* the
accent-tinted segments/meter color) · `.transparent` (terminal background,
no status bg fill) · `.showHookStatus` · `.compactThinkingLevel` (default
true). Plus `symbolPreset` at top level (`unicode` `nerd` `ascii` — segment
glyphs follow it).
## Fleet configuration (2026-10-18)
- Shape `band`: one merged status row. Left group: `session_name`→working
row (skipped), `vim`, `mode`, `hostname`, `path`, `git` (no branch text —
worktree branches are autogenerated names), `pr`. Right group (array order
= paint order, so the last entry hugs the screen edge): `model`, `cost`,
`context_pct`, `time_spent` — `context_pct` hoists out of the run to paint
the labeled inline meter (`60% ——— × 200K`) between the groups under
`contextLine: embedded`.
- Deliberately cut: `token_rate` (working-row readout covers it), `time`
(panel clock), `cache_*` (noise), `context_total` (implied by the meter),
`subagents` (live badge supersedes it), `session`, `token_in`/`token_out`
(cumulative totals are in `/session` anyway).
- `models.yml`: frank-archon's display `name` slimmed to `"frank-archon"`
(endpoint suffix dropped); frank-dragoon/frank-zealot keep their endpoint
suffixes. `name:` is free-form display, `id:` is the wiring — renaming ids
is the cutover procedure, see [Pi Models](/pi/models.md).
## Audit log
2026-10-18 full-source re-audit corrected the first pass of this page:
- ~~claude shape's top rule is the context meter~~ — **false**. The
meter code path runs only for `box`/`band`; claude/rule/pi/borderless/
field/rail rules are constant session-accent fillers. (Earlier reading
mistook the accent rule for a meter.)
- ~~working-row composer-bar variant is "Tern-only"~~ — **false**; the
tok/s readout is the `composer.tokenRate` option (default false), title
placement is composer chrome.
- ~~`segmentOptions` gated to `preset: custom`~~ — **false**; options merge
under every preset, only the segment *arrays* are gated.
- ~~`mode` covers yolo~~ — **false**; states are Plan/Prewalk/Goal/Vibe/Loop.
- `contextLine` "(box composer only)" — the *bundle description* is wrong;
band honors it too (verified live: `off` plain rule, `percentage` rule
meter, `embedded` inline meter, screenshots + code).
+1
View File
@@ -8,6 +8,7 @@ Configuration guides and reference material for installed software. Each tool ha
* [[cachyos|cachyos]] — Arch-based rolling release with performance-optimized kernels, repositories, and gaming stack. Running on the workstation. * [[cachyos|cachyos]] — Arch-based rolling release with performance-optimized kernels, repositories, and gaming stack. Running on the workstation.
* [comfyui](comfyui/index.md) — Node-based AI image generation engine on dankclaw. FLUX.2 klein 4B, CPU backend, systemd managed. * [comfyui](comfyui/index.md) — Node-based AI image generation engine on dankclaw. FLUX.2 klein 4B, CPU backend, systemd managed.
* [live-sync](live-sync.md) — Heartbeat daemon committing machine-written paths (memories, managed-skills, live config, dotfiles) in the command-center checkout; systemd user timer, alerts via ntfy.
* [[applications|applications]] — Default userspace applications on CachyOS with Niri — browsers, editor, terminal, file manager, audio, screenshots, gaming. * [[applications|applications]] — Default userspace applications on CachyOS with Niri — browsers, editor, terminal, file manager, audio, screenshots, gaming.
* ](ghostty/index.md) — GPU-accelerated terminal emulator with extensive configuration, keybindings, and shaders. * ](ghostty/index.md) — GPU-accelerated terminal emulator with extensive configuration, keybindings, and shaders.
* ](gitea/index.md) — Private Git hosting on dankclaw. Quadlet-managed containers: Gitea, PostgreSQL, Caddy. * ](gitea/index.md) — Private Git hosting on dankclaw. Quadlet-managed containers: Gitea, PostgreSQL, Caddy.
+75
View File
@@ -0,0 +1,75 @@
---
type: reference
title: live-sync heartbeat
description: Auto-commit daemon for machine-written paths in the command-center checkout — the deployment is live, so symlinks make config churn dirty tracked files continuously.
tags:
- git
- automation
- systemd
- deployment
- fleet
verified: 2026-10-18
timestamp: 2026-10-18
---
# live-sync heartbeat
The command-center checkout IS the deployment, and several tracked files are
live state: the harness writes memories and managed-skills, the operator
edits `config.yml`/`models.yml` through the `cfg://` surface, and desktop
GUIs (Noctalia, niri) rewrite dotfiles through symlinks. Before Refs #11 this
machine churn just piled up unpushed — one disk holding the only copy of ~61
auto-learned skills, a settings fix stranded locally, and merge-refusals
because the dirty tree collided with incoming work.
`scripts/live-sync.sh` is the pressure valve: a heartbeat that commits the
machine's own writes on a schedule. Tier law (which paths it may touch) lives
in [AGENTS.md](../../AGENTS.md) § Live-sync tiers.
## Contract (black-box)
Run = one pass: flock → branch guard (`main` only) → stage allowlisted dirty
tiers → commit (hooks armed) → fetch + merge `origin/main` → push → reap
stale `wt/*` worktrees. Exit `0` = quiet heartbeat (synced/no-op/reaped);
exit `1` = an alert was raised. Pinned by `tests/live-sync.bats`.
Hard properties:
- **Allowlist only.** Task dirt (`plugins/`, `omp-docs/`, prose) is never
staged — merge conflicts with in-flight work become impossible by
construction.
- **Hooks stay armed.** A secret-gate refusal aborts the commit, leaves
changes staged for a human, and alerts. Never `--no-verify`, never force.
- **Never fights the remote.** Merge conflict → `merge --abort`, local
commit kept, alert. Retries are automatic next tick.
- **Branch guard.** On a non-`main` checkout it refuses and alerts — same
rule as the doctrine ("never end a session off main").
- **Polite reaper.** `wt/wt-*` worktrees untouched for 7+ days are removed
with their `wt/*` branch; dirty or process-occupied worktrees are skipped.
## Alerts
`notify()` writes stderr always; pushes to ntfy when the topic file
`~/.config/ntfy/live-sync` (mode 600, topic text only) exists, throttled to
one push per hour. Test seams: `LIVE_SYNC_REPO`, `LIVE_SYNC_NOTIFY`,
`LIVE_SYNC_NTFY_TOPIC_FILE`, `LIVE_SYNC_AGE_DAYS`.
## Install
Units ship in `dotfiles/systemd-user/.config/systemd/user/` and propagate
with the usual dotfiles chain:
```bash
systemctl --user enable --now live-sync.timer # after the unit is linked
systemctl --user start live-sync.service # one manual heartbeat
journalctl --user -u live-sync.service -n 20 # observe
```
## Failure modes
| Event | Result |
|-------|--------|
| Harness mid-write | commits a valid snapshot; remainder lands next heartbeat |
| Gitea down | commit safe locally; push retries next tick (≤1 h stale window) |
| Feature branch checked out in main checkout | refuses + alerts; protects the never-end-off-main rule |
| Secret shape in machine-written content | hook blocks, changes stay staged, alert |
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# live-sync.sh — heartbeat committer for machine-written paths in the command
# center checkout. Refs #11 (see omp-docs/tools/live-sync.md).
#
# The checkout IS the deployment; the harness, auto-learn, and desktop GUIs
# write tracked files continuously through symlinks. This script commits ONLY
# the allowlisted machine-live + desktop-live tiers (AGENTS.md § Live-sync
# tiers), merges the trunk, pushes, and reaps stale session worktrees. Task
# dirt outside the allowlist is NEVER committed — only surfaced.
#
# Black-box contract (tests/live-sync.bats pins it):
# input git state of $LIVE_SYNC_REPO
# output exit 0 = quiet heartbeat (synced, no-op, or reaped)
# exit 1 = alert raised (branch guard, hook refusal, merge conflict,
# fetch/push failure); alert goes to stderr + notify sink
# Env overrides (test seams):
# LIVE_SYNC_REPO target repo (default $HOME/.omp)
# LIVE_SYNC_NOTIFY alert command, message on stdin
# LIVE_SYNC_NTFY_TOPIC_FILE ntfy topic file, mode 600 (default
# ~/.config/ntfy/live-sync; absent/empty = no push)
# LIVE_SYNC_AGE_DAYS wt/wt-* reap threshold (default 7)
set -euo pipefail
REPO="${LIVE_SYNC_REPO:-$HOME/.omp}"
AGE_DAYS="${LIVE_SYNC_AGE_DAYS:-7}"
TOPIC_FILE="${LIVE_SYNC_NTFY_TOPIC_FILE:-$HOME/.config/ntfy/live-sync}"
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}"
# Allowlist tiers. Human prose (AGENTS.md, omp-docs, scripts, plugins,
# agent/skills) is deliberately absent: branch -> PR territory, never swept.
LIVE_PATHS=(
agent/memories
agent/managed-skills
agent/config.yml
agent/models.yml
dotfiles
)
notify() {
local msg="live-sync: $1" stamp now
stamp=$(cat "$STATE_DIR/live-sync.alert" 2>/dev/null || echo 0)
now=$(date +%s)
if (( now - stamp >= 3600 )); then
date +%s > "$STATE_DIR/live-sync.alert"
if [[ -n "${LIVE_SYNC_NOTIFY:-}" ]]; then
printf '%s' "$msg" | $LIVE_SYNC_NOTIFY || true
elif [[ -s "$TOPIC_FILE" ]]; then
curl -fsS -m 10 -d "$msg" "https://ntfy.sh/$(<"$TOPIC_FILE")" >/dev/null 2>&1 || true
fi
fi
printf '%s\n' "$msg" >&2
}
cd "$REPO" 2>/dev/null || exit 0
exec 9>"$STATE_DIR/live-sync.lock"
flock -n 9 || exit 0 # another heartbeat owns the repo
git rev-parse --verify -q HEAD >/dev/null || exit 0
branch=$(git symbolic-ref --quiet --short HEAD || true)
if [[ "$branch" != main ]]; then
notify "on branch '$branch', not main — refusing to sync, nothing changed"
exit 1
fi
# 1. stage only allowlisted paths that actually hold dirt (pathspecs that
# match nothing would make git add fatal, so check per-path)
for p in "${LIVE_PATHS[@]}"; do
if [[ -n $(git status --porcelain -- "$p") ]]; then
git add -A -- "$p"
fi
done
if ! git diff --cached --quiet; then
# 2. commit WITH hooks armed — the secret gate stays pointed at the diff;
# a refusal here is the system working, never bypassed
if ! git commit -q -m "live: heartbeat sync $(date -Is) (Refs #11)"; then
notify "commit rejected by hook/secret gate — live changes left STAGED for a human"
exit 1
fi
# 3. integrate trunk and publish; never fight the remote
if git remote get-url origin >/dev/null 2>&1; then
if ! git fetch -q origin; then
notify "fetch failed — commit is safe locally, will retry next heartbeat"
exit 1
fi
if git rev-parse --verify -q origin/main >/dev/null \
&& ! git merge -q --no-edit origin/main; then
git merge --abort || true
notify "merge conflict with origin/main — local commit kept; needs you at the keyboard"
exit 1
fi
if ! git push -q origin main; then
notify "push failed — commit is safe locally, will retry next heartbeat"
exit 1
fi
fi
fi
# 4. reap session worktrees older than AGE_DAYS. Dirty worktrees are left
# alone (git refuses), and so are any with a live process cwd inside.
in_use() {
local p
for p in /proc/[0-9]*/cwd; do
if [[ "$(readlink "$p" 2>/dev/null || true)" == "$1"* ]]; then
return 0
fi
done
return 1
}
git worktree prune
reap_path="" reap_ref=""
reap_flush() {
if [[ "$reap_path" == "$REPO/wt/wt-"* && -d "$reap_path" ]] \
&& [[ -z $(find "$reap_path" -mtime "-$AGE_DAYS" -print -quit) ]] \
&& ! in_use "$reap_path"; then
git worktree remove -- "$reap_path" 2>/dev/null && git worktree prune || true
if [[ -n "$reap_ref" ]]; then
git branch -q -d "${reap_ref#refs/heads/}" 2>/dev/null || true
fi
fi
reap_path="" reap_ref=""
}
while IFS= read -r line; do
case "$line" in
"worktree "*) reap_flush; reap_path="${line#worktree }" ;;
"branch "*) reap_ref="${line#branch }" ;;
esac
done < <(git worktree list --porcelain)
reap_flush
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bats
# tests/live-sync.bats — black-box tests for scripts/live-sync.sh (Refs #11)
#
# Contract (see script header): the heartbeat commits ONLY allowlisted live
# paths, merges origin/main, pushes, and reaps stale wt/* worktrees. Nothing
# is observed except exit status, stderr, the notify-sink log, and git state.
setup() {
TEST_TMP="$(mktemp -d)"
REPO="$TEST_TMP/repo"
export XDG_RUNTIME_DIR="$TEST_TMP/xdg"
mkdir -p "$XDG_RUNTIME_DIR"
export GIT_CONFIG_GLOBAL="$TEST_TMP/gitconfig" GIT_CONFIG_SYSTEM=/dev/null
export GIT_AUTHOR_DATE="2026-10-18T12:00:00+00:00" GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
export LIVE_SYNC_REPO="$REPO"
export LIVE_SYNC_NTFY_TOPIC_FILE="$TEST_TMP/no-topic-file"
export LIVE_SYNC_NOTIFY="$TEST_TMP/notify-stub"
export LIVE_SYNC_AGE_DAYS=7
# notify sink: appends each alert line to alerts.log
cat > "$LIVE_SYNC_NOTIFY" <<STUB
#!/usr/bin/env bash
cat >> "$TEST_TMP/alerts.log"
echo >> "$TEST_TMP/alerts.log"
STUB
chmod +x "$LIVE_SYNC_NOTIFY"
# bare "origin" + seeded clone with all tiers represented
git init -q --bare -b main "$TEST_TMP/origin.git"
git clone -q "$TEST_TMP/origin.git" "$REPO"
git -C "$REPO" config user.email frank@dank
git -C "$REPO" config user.name Frank
git -C "$REPO" checkout -q -b main
mkdir -p "$REPO/agent/memories" "$REPO/agent/managed-skills/s1" \
"$REPO/dotfiles/bash" "$REPO/plugins/bench" "$REPO/scripts"
echo base > "$REPO/agent/config.yml"
echo base > "$REPO/agent/models.yml"
echo base > "$REPO/agent/memories/learned.md"
echo base > "$REPO/agent/managed-skills/s1/SKILL.md"
echo base > "$REPO/dotfiles/bash/bashrc"
echo base > "$REPO/AGENTS.md"
echo base > "$REPO/scripts/deploy.sh"
echo base > "$REPO/plugins/bench/server.py"
git -C "$REPO" add -A
git -C "$REPO" commit -q -m seed
git -C "$REPO" push -q origin main
SEED="$(git -C "$REPO" rev-parse HEAD)"
}
teardown() { rm -rf "$TEST_TMP"; }
@test "heartbeat commits allowlisted live dirt only; task dirt stays dirty" {
echo new > "$REPO/agent/memories/learned.md"
mkdir "$REPO/agent/managed-skills/s2"
echo new > "$REPO/agent/managed-skills/s2/SKILL.md"
echo new > "$REPO/AGENTS.md"
echo new > "$REPO/plugins/bench/server.py"
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 0 ]
# exactly one new commit ahead of the seed
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 1 ]
# it touches ONLY live paths (untracked new dirs picked up too)
run git -C "$REPO" show --pretty=format: --name-only HEAD
printf '%s\n' "${lines[@]}" | grep -v '^$' | sort | diff -q - <(
printf '%s\n' agent/managed-skills/s2/SKILL.md agent/memories/learned.md)
# task dirt untouched, uncommitted
[ -n "$(git -C "$REPO" status --porcelain -- AGENTS.md plugins)" ]
# pushed: origin caught up
[ "$(git -C "$REPO" rev-parse origin/main)" = "$(git -C "$REPO" rev-parse main)" ]
}
@test "clean tree is a silent no-op" {
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 0 ]
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ]
[ ! -e "$TEST_TMP/alerts.log" ]
}
@test "non-allowlist dirt alone never commits" {
echo new > "$REPO/AGENTS.md"
echo new > "$REPO/scripts/deploy.sh"
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 0 ]
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ]
[ -n "$(git -C "$REPO" status --porcelain -- AGENTS.md scripts)" ]
}
@test "merge conflict aborts, keeps local commit, alerts, leaves remote alone" {
# a second clone advances origin/main on the same live file
git clone -q -b main "$TEST_TMP/origin.git" "$TEST_TMP/pocket"
git -C "$TEST_TMP/pocket" config user.email pocket@dank
git -C "$TEST_TMP/pocket" config user.name Pocket
echo remote-side > "$TEST_TMP/pocket/agent/models.yml"
git -C "$TEST_TMP/pocket" commit -qam "remote advance"
git -C "$TEST_TMP/pocket" push -q origin main
echo local-side > "$REPO/agent/models.yml"
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 1 ]
[[ "$output" == *"merge conflict"* ]]
[ -s "$TEST_TMP/alerts.log" ]
# HEAD is my local commit, merge left no residue, remote untouched
[[ "$(git -C "$REPO" log --format=%s -1)" == live:*"heartbeat sync"* ]]
[ ! -e "$REPO/.git/MERGE_HEAD" ]
[ "$(git -C "$REPO" rev-parse origin/main)" = "$(git -C "$TEST_TMP/pocket" rev-parse main)" ]
}
@test "branch guard refuses to sync on a feature branch" {
git -C "$REPO" checkout -q -b feature/whatever
echo new > "$REPO/agent/config.yml"
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 1 ]
[[ "$output" == *"not main"* ]]
[ "$(git -C "$REPO" rev-list --count "$SEED"..feature/whatever)" -eq 0 ]
[ -n "$(git -C "$REPO" status --porcelain -- agent/config.yml)" ]
}
@test "pre-commit hook refusal is surfaced, not bypassed; changes stay staged" {
mkdir "$TEST_TMP/hooks"
printf '#!/bin/sh\nexit 1\n' > "$TEST_TMP/hooks/pre-commit"
chmod +x "$TEST_TMP/hooks/pre-commit"
git -C "$REPO" config core.hooksPath "$TEST_TMP/hooks"
echo new > "$REPO/dotfiles/bash/bashrc"
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 1 ]
[[ "$output" == *"rejected"* ]]
[ -n "$(git -C "$REPO" diff --cached --name-only -- dotfiles)" ]
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ]
}
@test "repo without origin still commits locally and exits clean" {
git -C "$REPO" remote remove origin
echo new > "$REPO/dotfiles/bash/bashrc"
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 0 ]
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 1 ]
}
@test "stale wt/* worktree and its branch are reaped; fresh one survives" {
git -C "$REPO" worktree add -q wt/wt-old -b wt/old
git -C "$REPO" worktree add -q wt/wt-new -b wt/new
touch -d "10 days ago" "$REPO/wt/wt-old"
find "$REPO/wt/wt-old" -exec touch -d "10 days ago" {} +
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 0 ]
[ ! -d "$REPO/wt/wt-old" ]
[ -d "$REPO/wt/wt-new" ]
run git -C "$REPO" show-ref --verify refs/heads/wt/old
[ "$status" -ne 0 ]
git -C "$REPO" show-ref --verify --quiet refs/heads/wt/new
}
@test "second heartbeat inside the throttle window alerts once, never twice" {
# two conflicting heartbeats: alert sink must see exactly one line
git clone -q -b main "$TEST_TMP/origin.git" "$TEST_TMP/pocket"
git -C "$TEST_TMP/pocket" config user.email pocket@dank
git -C "$TEST_TMP/pocket" config user.name Pocket
echo remote-side > "$TEST_TMP/pocket/agent/config.yml"
git -C "$TEST_TMP/pocket" commit -qam "remote advance"
git -C "$TEST_TMP/pocket" push -q origin main
echo local-side > "$REPO/agent/config.yml"
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 1 ]
rm -f "$XDG_RUNTIME_DIR/live-sync.lock" # flock releases with the process; state stays
echo local-side2 > "$REPO/agent/config.yml"
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
[ "$status" -eq 1 ]
[ "$(wc -l < "$TEST_TMP/alerts.log")" -eq 1 ]
}