Merge remote-tracking branch 'origin/main'

# Conflicts:
#	AGENTS.md
#	agent/config.yml
#	omp-docs/log.md
This commit is contained in:
Frank
2026-10-18 16:47:37 -07:00
9 changed files with 637 additions and 0 deletions
+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.**
- **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.
- **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
markers every other machine pulls to. Hotfixes are ordinary branches off the
tag you are fixing forward from.
@@ -62,6 +67,25 @@ commit"). Applied here:
gate is the point of the hook).
- 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)
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
## 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
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.
* [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.
* ](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.
+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 ]
}