diff --git a/.gitignore b/.gitignore index 576becf6..e81d1312 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ .letta/worktrees/ .Codex/ +# Machine-local multi-agent coordination (not product source) +.agents/claims/ +.agents/runs/ + # Local editor/vault metadata .obsidian/ .DS_Store diff --git a/AGENT.md b/AGENT.md index 9201eff0..4452380b 100644 --- a/AGENT.md +++ b/AGENT.md @@ -56,12 +56,20 @@ no unique law and does not satisfy this rule. - **Always use a worktree for file changes.** Never edit the shared primary checkout. A read-only design conversation creates no worktree. +- Prefer `tools/worktree-new.sh --class + --key ...` (seeds cargo target and takes a local claim). Finish with + `tools/worktree-done.sh ` (releases claim, prunes `target/`, removes + worktree). +- **Claim before heavy work.** Exclusive keys must not overlap an active + claim on this machine (`tools/claim.sh list`). See + [wiki/process/agent-scale.md](wiki/process/agent-scale.md). - A shell working directory does not guarantee that every patch tool resolves relative paths there. Verify the patch base before the first edit, use an explicit worktree path when needed, and keep the shared checkout clean. - After entering a Rust-impacting worktree, run - `tools/seed-cargo-target.sh` before long Cargo commands. Never share one - external `CARGO_TARGET_DIR` across worktrees. + `tools/seed-cargo-target.sh` before long Cargo commands (or use + `worktree-new.sh`). Never share one external `CARGO_TARGET_DIR` across + worktrees. - Commit coherent completed work without waiting for routine permission. - Land completed work, then remove its worktree and task branch. No stale worktrees. @@ -73,14 +81,15 @@ no unique law and does not satisfy this rule. - Both frontends surface a new system before its spec becomes IMPLEMENTED. - Verification is proportional to impact (see [wiki/process/workflows.md](wiki/process/workflows.md)): - - `./tools/check.sh` auto-classifies the task delta (`docs` / `lib` / - `frontend` / `full`). Prefer that, or an explicit `--docs`/`--lib`/ - `--frontend` mid-loop; one auto or `--full` gate before land. - - At most one Rust gate runs on the machine at a time (shared lock). Do - not start a second sim/frontend-heavy work order that will contend. + - Mid-loop: narrowest `./tools/check.sh --docs|--lib|--frontend` (or auto). + - Land: one `./tools/check.sh --land` (or auto/`--full`) under the rust lock. + - At most one Rust gate runs on the machine at a time (shared lock). Claims + block overlapping exclusive keys even when gates would not. - Docs/spec/process/log/reference-art work stays on the docs path. - Site and shell changes run their targeted build, syntax, or smoke checks. - Rust-impacting landings still need a relevant observed run. +- Long dispatches: `tools/heartbeat.sh start|phase|end` so progress is + visible under `.agents/runs/` (gitignored). - The local hook and Tangled pipeline reject a `src/` change without a changed `Type: law` or `Type: spec` page under `wiki/`. - Update knowledge made stale, add a dated session log, and add a DEVLOG entry. diff --git a/prompts/README.md b/prompts/README.md index 25bd0361..53638c8b 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -21,32 +21,34 @@ These rules bind every prompted agent, and each prompt file assumes them: `Type: law`, owning `Type: spec`, dependencies, and nearby `Type: knowledge` pages it orders. Do not skip this on later runs — the corpus moves daily. -2. **Worktree, always.** First action: create/enter a git worktree - (`git worktree add .claude/worktrees/ -b worktree-`). - Never edit the primary checkout; files changing under you there are - another session's live work. +2. **Worktree + claim, always.** First action: create a claimed worktree + (`tools/worktree-new.sh --class + --key ...`) or equivalent worktree + `tools/claim.sh claim`. + Never edit the primary checkout. If claim fails on exclusive keys, + stop — another agent owns that surface (`tools/claim.sh list`). 3. **The one rule.** Functional changes ship with their owning binding wiki amendment in the same commit; every behavior-changing commit carries a `Defense:` paragraph naming the clause that justifies it. 4. **Gate before landing.** Mid-loop use the narrowest `./tools/check.sh` mode that can catch your edit (`--docs` / - `--lib` / `--frontend`, or auto). Before land, one green auto or - `--full` gate. At most one Rust gate runs on the machine at a time - (shared lock) — do not start a second heavy work order that will - contend. Then land by rebasing onto `origin/main` and pushing to - `main` directly (the PR flow is removed — do not create PRs). Remove - your worktree and branch. -5. **Ledgers merge by union.** Conflicts in `wiki/log/DEVLOG.md`, + `--lib` / `--frontend`, or auto). Before land, one green + `./tools/check.sh --land` (or auto/`--full`). At most one Rust gate + runs on the machine at a time (shared lock). Then land by rebasing + onto `origin/main` and pushing to `main` directly (no PRs). Finish + with `tools/worktree-done.sh ` (releases claim, prunes target). +5. **Heartbeat on long runs.** `tools/heartbeat.sh start ` then + `phase` / `end` so empty stdout is not the only progress signal. +6. **Ledgers merge by union.** Conflicts in `wiki/log/DEVLOG.md`, dated `wiki/log/decisions/*.md` volumes, or `wiki/process/specs.md` tables are resolved by keeping BOTH sides' entries — never take your side wholesale. -6. **Close the loop.** `wiki/log/DEVLOG.md` ledger entry (and a +7. **Close the loop.** `wiki/log/DEVLOG.md` ledger entry (and a `wiki/log/` file for a meaningful session); update any `Type: knowledge` wiki page your change made stale. -7. **House style:** no emoji, no AI attribution in commits, stage files +8. **House style:** no emoji, no AI attribution in commits, stage files explicitly (never `git add -A`), ASCII only in anything that could reach a game string. -8. **One finding per run.** Ticks are bounded: find one thing, act on it +9. **One finding per run.** Ticks are bounded: find one thing, act on it fully, land it. Ten shallow findings are worth less than one closed loop. If you cannot finish, file what you found (Tangled issue or a devlog note) so nothing is lost. Tangled issues obey the body diff --git a/prompts/implement-gap.md b/prompts/implement-gap.md index 72b30ea0..ccdac208 100644 --- a/prompts/implement-gap.md +++ b/prompts/implement-gap.md @@ -12,8 +12,9 @@ Read, in order: `AGENT.md`, the corpus map and relevant law/spec slice, `wiki/process/specs.md` (the status table), and `wiki/process/ROADMAP.md` (the dispatch board — it may already name the highest-value gap and its worktree name). Obey the shared contract in `prompts/README.md`: -worktree first, one rule, check.sh gate, direct merge to main, union -merges on ledgers. +worktree+claim first (`tools/worktree-new.sh` or claim.sh), one rule, +check.sh gate (`--land` before merge), heartbeat on long runs, direct +merge to main, union merges on ledgers. ## Procedure @@ -48,10 +49,10 @@ merges on ledgers. `prompts/ask-the-human.md` instead. 6. **Land it:** mid-loop run the narrowest `./tools/check.sh` tier (`--lib` / `--frontend` / `--docs` or auto); before land one green - auto/`--full` gate. Rebase onto `origin/main`, push to `main`, - `wiki/log/DEVLOG.md` ledger entry, remove the worktree. Mark the - ROADMAP item's result if you closed one. Do not run a second - Rust-heavy work order while another holds the gate lock. + `./tools/check.sh --land`. Rebase onto `origin/main`, push to `main`, + `wiki/log/DEVLOG.md` ledger entry, `tools/worktree-done.sh `. + Mark the ROADMAP item's result if you closed one. Do not claim + exclusive keys another agent already holds. ## Definition of done diff --git a/tools/check.sh b/tools/check.sh index 89c79aea..bcba9eb4 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -8,9 +8,11 @@ # ./tools/check.sh --lib # fmt, lib tests, terminal clippy, cheap bevy check # ./tools/check.sh --frontend # fmt, bevy bin tests, bevy clippy # ./tools/check.sh --full # everything (also MISALIGNED_FORCE_RUST_GATE=1) +# ./tools/check.sh --land # land policy: auto-classify (full when unclear) # # Concurrent agents: at most one Rust gate runs at a time on this machine -# (flock on /tmp/misaligned-rust-gate.lock). Docs gates never take the lock. +# (mkdir lock). Docs gates never take the lock. Semantic exclusivity is +# tools/claim.sh (agent-scale slice A), not this lock. # # CI and an unclassifiable clean primary checkout stay conservative (full). # Exit non-zero on any failure. @@ -24,13 +26,13 @@ step() { printf '\n=== %s ===\n' "$1"; } mode=auto for arg in "$@"; do case "$arg" in - --docs|--lib|--frontend|--full) mode=${arg#--} ;; + --docs|--lib|--frontend|--full|--land) mode=${arg#--} ;; -h|--help) - sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) - echo "unknown argument: $arg (try --docs|--lib|--frontend|--full)" + echo "unknown argument: $arg (try --docs|--lib|--frontend|--full|--land)" exit 2 ;; esac @@ -40,6 +42,12 @@ if [ "${MISALIGNED_FORCE_RUST_GATE:-0}" = "1" ]; then mode=full fi +# --land is the named land-phase gate: same as auto (full when unclassifiable). +if [ "$mode" = "land" ]; then + mode=auto + printf 'check phase: land (auto-classify)\n' +fi + changed_files() { # Classify both uncommitted work and commits on a linked task worktree. # CI and the primary checkout normally have a .git directory (not the @@ -137,7 +145,9 @@ guard_cargo_target_dir() { # ── Always-on cheap steps ──────────────────────────────────────────────── step "script syntax" -for script in tools/check.sh tools/corpus_gate.sh tools/wiki_gate.sh tools/seed-cargo-target.sh; do +for script in tools/check.sh tools/corpus_gate.sh tools/wiki_gate.sh tools/seed-cargo-target.sh \ + tools/claim.sh tools/worktree-new.sh tools/worktree-done.sh tools/heartbeat.sh; do + [ -f "$script" ] || continue bash -n "$script" || { echo "FAIL: shell syntax: $script"; fail=1; } done diff --git a/tools/claim.sh b/tools/claim.sh new file mode 100755 index 00000000..aea8ea9d --- /dev/null +++ b/tools/claim.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +# Local work-order claims — semantic exclusivity for concurrent agents. +# Binding behavior: wiki/process/agent-scale.md slice A. +# +# Claims live under .agents/claims/ (gitignored, machine-local). Any agent +# on this machine discovers active work via `tools/claim.sh list`. +# +# Usage: +# tools/claim.sh claim --class \ +# [--key path]... [--force] +# tools/claim.sh status [id] +# tools/claim.sh list +# tools/claim.sh set-status +# tools/claim.sh release [--status done|abandoned] +# tools/claim.sh check --key path [--key path]... +# +# Exit codes: 0 ok, 1 conflict/error, 2 bad usage. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +cd "$root" + +CLAIMS_DIR=${MISALIGNED_CLAIMS_DIR:-"$root/.agents/claims"} +ACTIVE_STATUSES="claimed checking landing" + +usage() { + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +ensure_dir() { + mkdir -p "$CLAIMS_DIR" +} + +claim_path() { + local id="$1" + # Sanitize id to a single path segment. + case "$id" in + ""|*/*|*\\*|*..*) + echo "FAIL: claim id must be a single path segment (got '$id')" >&2 + exit 2 + ;; + esac + printf '%s/%s.claim\n' "$CLAIMS_DIR" "$id" +} + +is_active() { + case "$1" in + claimed|checking|landing) return 0 ;; + *) return 1 ;; + esac +} + +# Read claim file into globals: c_id c_class c_status c_pid c_started c_host c_keys (array) +read_claim() { + local path="$1" line + c_id=""; c_class=""; c_status=""; c_pid=""; c_started=""; c_host="" + c_keys=() + [ -f "$path" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + id=*) c_id=${line#id=} ;; + class=*) c_class=${line#class=} ;; + status=*) c_status=${line#status=} ;; + pid=*) c_pid=${line#pid=} ;; + started=*) c_started=${line#started=} ;; + host=*) c_host=${line#host=} ;; + key=*) c_keys+=("${line#key=}") ;; + esac + done < "$path" + return 0 +} + +write_claim() { + local path="$1" key + { + printf 'id=%s\n' "$c_id" + printf 'class=%s\n' "$c_class" + printf 'status=%s\n' "$c_status" + printf 'pid=%s\n' "$c_pid" + printf 'started=%s\n' "$c_started" + printf 'host=%s\n' "$c_host" + for key in "${c_keys[@]+"${c_keys[@]}"}"; do + printf 'key=%s\n' "$key" + done + } > "$path" +} + +keys_intersect() { + # Args: proposed keys..., then --, then held keys... + local -a proposed=() held=() + local mode=proposed k p h + for k in "$@"; do + if [ "$k" = "--" ]; then + mode=held + continue + fi + if [ "$mode" = proposed ]; then + proposed+=("$k") + else + held+=("$k") + fi + done + # Empty proposed keys never conflict (docs-wide parallel ok). + [ "${#proposed[@]}" -eq 0 ] && return 1 + for p in "${proposed[@]}"; do + for h in "${held[@]+"${held[@]}"}"; do + [ "$p" = "$h" ] && return 0 + done + done + return 1 +} + +pid_alive() { + local pid="$1" + [ -n "$pid" ] && [ "$pid" -eq "$pid" ] 2>/dev/null && kill -0 "$pid" 2>/dev/null +} + +# If an active claim's holder pid is dead, mark abandoned and remove. +reap_stale() { + local path id + ensure_dir + for path in "$CLAIMS_DIR"/*.claim; do + [ -f "$path" ] || continue + read_claim "$path" || continue + is_active "$c_status" || continue + if ! pid_alive "$c_pid"; then + echo "claim: reaping stale claim '$c_id' (dead pid $c_pid, was $c_status)" >&2 + rm -f "$path" + fi + done +} + +cmd_list() { + ensure_dir + reap_stale + local path any=0 + for path in "$CLAIMS_DIR"/*.claim; do + [ -f "$path" ] || continue + read_claim "$path" || continue + any=1 + printf '%s class=%s status=%s pid=%s keys=%s\n' \ + "$c_id" "$c_class" "$c_status" "$c_pid" \ + "$(IFS=,; echo "${c_keys[*]-}")" + done + if [ "$any" -eq 0 ]; then + echo "(no active claims in $CLAIMS_DIR)" + fi +} + +cmd_status() { + local id="${1:-}" + ensure_dir + if [ -z "$id" ]; then + cmd_list + return + fi + local path + path=$(claim_path "$id") + if ! read_claim "$path"; then + echo "FAIL: no claim '$id' ($path)" >&2 + exit 1 + fi + printf 'id=%s\nclass=%s\nstatus=%s\npid=%s\nstarted=%s\nhost=%s\n' \ + "$c_id" "$c_class" "$c_status" "$c_pid" "$c_started" "$c_host" + local k + for k in "${c_keys[@]+"${c_keys[@]}"}"; do + printf 'key=%s\n' "$k" + done +} + +find_conflict() { + # Sets conflict_id conflict_keys on match. Args: proposed keys... + local -a proposed=("$@") + local path + conflict_id="" + conflict_keys="" + for path in "$CLAIMS_DIR"/*.claim; do + [ -f "$path" ] || continue + read_claim "$path" || continue + is_active "$c_status" || continue + if keys_intersect "${proposed[@]+"${proposed[@]}"}" -- "${c_keys[@]+"${c_keys[@]}"}"; then + conflict_id=$c_id + conflict_keys=$(IFS=,; echo "${c_keys[*]-}") + return 0 + fi + done + return 1 +} + +cmd_check() { + ensure_dir + reap_stale + local -a keys=() + while [ $# -gt 0 ]; do + case "$1" in + --key) shift; [ $# -gt 0 ] || usage; keys+=("$1"); shift ;; + *) usage ;; + esac + done + if find_conflict "${keys[@]+"${keys[@]}"}"; then + echo "FAIL: keys conflict with active claim '$conflict_id' (keys: $conflict_keys)" >&2 + exit 1 + fi + echo "check: ok (no active claim holds those keys)" +} + +cmd_claim() { + ensure_dir + reap_stale + local id="" class="" force=0 + local -a keys=() + id="${1:-}"; shift || true + [ -n "$id" ] || usage + while [ $# -gt 0 ]; do + case "$1" in + --class) shift; class="${1:-}"; shift || true ;; + --key) shift; keys+=("${1:-}"); shift || true ;; + --force) force=1; shift ;; + *) usage ;; + esac + done + case "$class" in + docs|frontend|sim|save|process) ;; + *) + echo "FAIL: --class must be docs|frontend|sim|save|process" >&2 + exit 2 + ;; + esac + + local path + path=$(claim_path "$id") + + holder=${MISALIGNED_CLAIM_PID:-$PPID} + if [ -f "$path" ]; then + read_claim "$path" + if is_active "$c_status"; then + if [ "$force" -eq 1 ]; then + echo "claim: --force replacing existing claim '$id' (was $c_status pid $c_pid)" + elif pid_alive "$c_pid" && [ "$c_pid" = "$holder" ]; then + : # same agent re-claim updates keys/status + elif pid_alive "$c_pid"; then + echo "FAIL: claim '$id' already active (status=$c_status pid=$c_pid keys=$(IFS=,; echo "${c_keys[*]-}"))" >&2 + exit 1 + else + echo "claim: replacing claim '$id' from dead pid $c_pid" + fi + fi + fi + + if [ "$force" -eq 0 ] && find_conflict "${keys[@]+"${keys[@]}"}"; then + # Allow if the only conflict is ourselves (re-claim same id). + if [ "$conflict_id" != "$id" ]; then + echo "FAIL: exclusive key conflict with claim '$conflict_id'" >&2 + echo " their keys: $conflict_keys" >&2 + echo " your keys: $(IFS=,; echo "${keys[*]-}")" >&2 + echo " list: tools/claim.sh list" >&2 + exit 1 + fi + fi + + c_id=$id + c_class=$class + c_status=claimed + # Holder is the calling agent/shell (PPID), not this short-lived script. + # Override with MISALIGNED_CLAIM_PID when a dispatcher wraps the tool. + c_pid=$holder + c_started=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + c_host=$(hostname 2>/dev/null || echo unknown) + c_keys=("${keys[@]+"${keys[@]}"}") + write_claim "$path" + echo "claim: acquired '$id' class=$class status=claimed pid=$c_pid keys=$(IFS=,; echo "${c_keys[*]-}")" + echo " file $path" +} + +cmd_set_status() { + local id="${1:-}" status="${2:-}" + [ -n "$id" ] && [ -n "$status" ] || usage + case "$status" in + claimed|checking|landing|done|abandoned) ;; + *) + echo "FAIL: status must be claimed|checking|landing|done|abandoned" >&2 + exit 2 + ;; + esac + ensure_dir + local path + path=$(claim_path "$id") + if ! read_claim "$path"; then + echo "FAIL: no claim '$id'" >&2 + exit 1 + fi + if [ "$status" = "done" ] || [ "$status" = "abandoned" ]; then + rm -f "$path" + echo "claim: '$id' -> $status (cleared)" + return 0 + fi + c_status=$status + # Keep original holder pid; only refresh if explicitly set. + if [ -n "${MISALIGNED_CLAIM_PID:-}" ]; then + c_pid=$MISALIGNED_CLAIM_PID + fi + write_claim "$path" + echo "claim: '$id' -> $status" +} + +cmd_release() { + local id="${1:-}" status=done + shift || true + [ -n "$id" ] || usage + while [ $# -gt 0 ]; do + case "$1" in + --status) shift; status="${1:-done}"; shift || true ;; + *) usage ;; + esac + done + cmd_set_status "$id" "$status" +} + +main() { + local cmd="${1:-}" + shift || true + case "$cmd" in + claim) cmd_claim "$@" ;; + list) cmd_list "$@" ;; + status) cmd_status "$@" ;; + set-status) cmd_set_status "$@" ;; + release) cmd_release "$@" ;; + check) cmd_check "$@" ;; + -h|--help|help|"") usage ;; + *) + echo "unknown command: $cmd" >&2 + usage + ;; + esac +} + +main "$@" diff --git a/tools/heartbeat.sh b/tools/heartbeat.sh new file mode 100755 index 00000000..db35935a --- /dev/null +++ b/tools/heartbeat.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Append a run heartbeat for long agent dispatches. +# Binding: wiki/process/agent-scale.md slice G. +# +# Usage: +# tools/heartbeat.sh start [--worktree path] [--phase text] +# tools/heartbeat.sh phase +# tools/heartbeat.sh end [--status ok|fail] +# +# Writes machine-local (gitignored) files under .agents/runs//. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +RUNS_DIR=${MISALIGNED_RUNS_DIR:-"$root/.agents/runs"} + +usage() { + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +run_dir() { + local id="$1" + case "$id" in + ""|*/*|*..*) echo "FAIL: bad run id" >&2; exit 2 ;; + esac + printf '%s/%s\n' "$RUNS_DIR" "$id" +} + +now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } + +cmd_start() { + local id="${1:-}"; shift || true + [ -n "$id" ] || usage + local worktree="" phase="start" + while [ $# -gt 0 ]; do + case "$1" in + --worktree) shift; worktree="${1:-}"; shift || true ;; + --phase) shift; phase="${1:-start}"; shift || true ;; + *) usage ;; + esac + done + local dir + dir=$(run_dir "$id") + mkdir -p "$dir" + cat > "$dir/status.json" <> "$dir/events.ndjson" + echo "heartbeat: started $id -> $dir" +} + +cmd_phase() { + local id="${1:-}" phase="${2:-}" + [ -n "$id" ] && [ -n "$phase" ] || usage + local dir + dir=$(run_dir "$id") + mkdir -p "$dir" + local started + if [ -f "$dir/status.json" ]; then + started=$(grep -o '"started_at":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"started_at":"//;s/"$//') + fi + started=${started:-$(now)} + cat > "$dir/status.json" <> "$dir/events.ndjson" + echo "heartbeat: $id -> $phase" +} + +cmd_end() { + local id="${1:-}"; shift || true + [ -n "$id" ] || usage + local status=ok + while [ $# -gt 0 ]; do + case "$1" in + --status) shift; status="${1:-ok}"; shift || true ;; + *) usage ;; + esac + done + local dir + dir=$(run_dir "$id") + mkdir -p "$dir" + local started phase + started=$(now); phase=end + if [ -f "$dir/status.json" ]; then + started=$(grep -o '"started_at":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"started_at":"//;s/"$//' || echo "$started") + phase=$(grep -o '"phase":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"phase":"//;s/"$//' || echo end) + fi + cat > "$dir/status.json" <> "$dir/events.ndjson" + echo "heartbeat: ended $id ($status)" +} + +cmd="${1:-}"; shift || true +case "$cmd" in + start) cmd_start "$@" ;; + phase) cmd_phase "$@" ;; + end) cmd_end "$@" ;; + *) usage ;; +esac diff --git a/tools/worktree-done.sh b/tools/worktree-done.sh new file mode 100755 index 00000000..5006c066 --- /dev/null +++ b/tools/worktree-done.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Finish a task worktree: release claim, remove worktree + branch, prune target. +# Binding: wiki/process/agent-scale.md slices A + D. +# +# Usage: +# tools/worktree-done.sh [--status done|abandoned] [--keep-target] +# +# Run from the primary checkout (or any worktree of the same repo). Does not +# delete uncommitted work without asking — requires a clean task worktree +# unless --force. +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +# Resolve primary checkout (shared .git). +primary=$root +if [ -f "$root/.git" ]; then + common=$(git -C "$root" rev-parse --path-format=absolute --git-common-dir) + case "$common" in + */.git) primary=${common%/.git} ;; + esac +fi +cd "$primary" + +task="" +status=done +keep_target=0 +force=0 + +while [ $# -gt 0 ]; do + case "$1" in + --status) shift; status="${1:-done}"; shift || true ;; + --keep-target) keep_target=1; shift ;; + --force) force=1; shift ;; + -h|--help) + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -*) + echo "unknown flag: $1" >&2 + exit 2 + ;; + *) + task=$1 + shift + ;; + esac +done + +[ -n "$task" ] || { echo "usage: tools/worktree-done.sh " >&2; exit 2; } + +wt="$primary/.claude/worktrees/$task" +branch="worktree-$task" + +if [ -d "$wt" ]; then + if [ "$force" -eq 0 ]; then + dirty=$(git -C "$wt" status --porcelain 2>/dev/null || true) + if [ -n "$dirty" ]; then + echo "FAIL: worktree has uncommitted changes. Commit/land first, or --force." >&2 + echo "$dirty" | head -20 + exit 1 + fi + fi + if [ "$keep_target" -eq 0 ] && [ -d "$wt/target" ]; then + echo "worktree-done: removing $wt/target" + rm -rf "$wt/target" + fi + echo "worktree-done: removing worktree $wt" + git worktree remove --force "$wt" 2>/dev/null || git worktree remove "$wt" +fi + +if git show-ref --verify --quiet "refs/heads/$branch"; then + # Only delete if merged or force. + if git merge-base --is-ancestor "$branch" origin/main 2>/dev/null || \ + git merge-base --is-ancestor "$branch" main 2>/dev/null || \ + [ "$force" -eq 1 ]; then + git branch -D "$branch" 2>/dev/null || git branch -d "$branch" || true + echo "worktree-done: deleted branch $branch" + else + echo "worktree-done: branch $branch not merged; leave it (use --force to delete)" + fi +fi + +if [ -x "$primary/tools/claim.sh" ] || [ -f "$primary/tools/claim.sh" ]; then + bash "$primary/tools/claim.sh" release "$task" --status "$status" 2>/dev/null || \ + echo "worktree-done: no claim named '$task' (ok)" +fi + +echo "worktree-done: finished $task ($status)" diff --git a/tools/worktree-new.sh b/tools/worktree-new.sh new file mode 100755 index 00000000..bd4c6c90 --- /dev/null +++ b/tools/worktree-new.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Create a seeded task worktree and optionally take a local claim. +# Binding: wiki/process/agent-scale.md slices A + D. +# +# Usage: +# tools/worktree-new.sh [--class ] \ +# [--key path]... [--no-seed] [--no-claim] +# +# Creates: +# .claude/worktrees/ on branch worktree- from origin/main +# Runs tools/seed-cargo-target.sh unless --no-seed. +# Claims with the given class/keys unless --no-claim (default class=process +# with no keys if class omitted — docs-parallel; prefer an explicit --class). +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +cd "$root" + +usage() { + sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +task="" +class="" +seed=1 +do_claim=1 +keys=() + +while [ $# -gt 0 ]; do + case "$1" in + --class) shift; class="${1:-}"; shift || true ;; + --key) shift; keys+=("${1:-}"); shift || true ;; + --no-seed) seed=0; shift ;; + --no-claim) do_claim=0; shift ;; + -h|--help) usage ;; + -*) + echo "unknown flag: $1" >&2 + usage + ;; + *) + if [ -z "$task" ]; then + task=$1 + shift + else + usage + fi + ;; + esac +done + +[ -n "$task" ] || usage +case "$task" in + */*|*" "*|worktree-*) + echo "FAIL: task name should be a short slug (e.g. dark-frame), not '$task'" >&2 + exit 2 + ;; +esac + +git fetch origin 2>/dev/null || true +if ! git rev-parse --verify origin/main >/dev/null 2>&1; then + echo "FAIL: origin/main not available; fetch first" >&2 + exit 1 +fi + +wt="$root/.claude/worktrees/$task" +branch="worktree-$task" + +if [ -e "$wt" ]; then + echo "FAIL: worktree path already exists: $wt" >&2 + exit 1 +fi +if git show-ref --verify --quiet "refs/heads/$branch"; then + echo "FAIL: branch $branch already exists" >&2 + exit 1 +fi + +echo "worktree-new: creating $wt on $branch from origin/main" +git worktree add -b "$branch" "$wt" origin/main + +if [ "$seed" -eq 1 ]; then + echo "worktree-new: seeding cargo target" + (cd "$wt" && bash tools/seed-cargo-target.sh) || \ + echo "worktree-new: seed skipped or failed (ok if no primary target cache)" +fi + +if [ "$do_claim" -eq 1 ]; then + if [ -z "$class" ]; then + class=process + fi + claim_args=(claim "$task" --class "$class") + for k in "${keys[@]+"${keys[@]}"}"; do + claim_args+=(--key "$k") + done + bash "$root/tools/claim.sh" "${claim_args[@]}" +fi + +cat </.agents/claims`, gitignored). | +| `MISALIGNED_CLAIM_PID` | `tools/claim.sh` | pid | Holder pid written into a claim (default: parent of the claim tool). | +| `MISALIGNED_RUNS_DIR` | `tools/heartbeat.sh` | path | Override run heartbeat dir (default `/.agents/runs`, gitignored). | | `MISALIGNED_SEED_TARGET_FROM` | `tools/seed-cargo-target.sh` | path | Source target directory to seed a worktree's private `target/` from (default: the primary checkout's `target/`). | Externally-defined variables the tooling respects: `CARGO_TARGET_DIR` diff --git a/wiki/log/2026-07-09-agent-claims.md b/wiki/log/2026-07-09-agent-claims.md new file mode 100644 index 00000000..f67ebcf0 --- /dev/null +++ b/wiki/log/2026-07-09-agent-claims.md @@ -0,0 +1,28 @@ +# 2026-07-09 — Agent-scale claims, worktrees, land, heartbeats + +``` +Type: log +``` + +## Intent + +Implement agent-scale slices A (claims), B land alias, D (worktree +bootstrap/prune), and G (heartbeats) so multi-agent work has a semantic +mutex, a named land gate, and visible progress. + +## Tools + +| Tool | Role | +|---|---| +| `tools/claim.sh` | claim / list / status / set-status / release / check | +| `tools/worktree-new.sh` | create branch+worktree, seed target, claim | +| `tools/worktree-done.sh` | release claim, prune target/, remove worktree | +| `tools/heartbeat.sh` | start / phase / end under `.agents/runs/` | +| `./tools/check.sh --land` | land-phase auto gate | + +Claims and runs are gitignored (machine-local). + +## Defense + +Implements wiki/process/agent-scale.md acceptance criteria for slices A, +B.1–B.2, D, and G. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 9300ad9d..7e78b3ee 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -73,6 +73,16 @@ Reverse chronological implementation notes. Keep this factual: what changed, why - Log: wiki/log/2026-07-09-dark-frame.md (opening, worklight pair, and zoom-bound screenshots; [TUNE] values recorded). +## 2026-07-09 - Agent-scale slices A/B/D/G: claims, land, worktrees, heartbeats + +- Intent: implement coordination tooling so concurrent agents stop + colliding on exclusive paths and empty logs. +- Changed: tools/claim.sh, worktree-new.sh, worktree-done.sh, + heartbeat.sh; check.sh --land; gitignore claims/runs; AGENT/prompts/ + workflows/env; agent-scale IN PROGRESS with A/B/D/G HELD. +- Checks: claim/heartbeat smokes; ./tools/check.sh --docs. +- Log: wiki/log/2026-07-09-agent-claims.md. + ## 2026-07-09 - Agent-scale architecture + deferred crate workspace (capture) - Intent: Cameron adopted the multi-agent architecture package; crate diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 5a0d0c0f..beef016b 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -660,16 +660,10 @@ Standing work orders for multi-agent throughput. Prefer **one slice per agent**. Do not start the crate workspace until compile cost or package edges clearly block parallel work — coordination slices land first. -### P1. Dispatch claims (semantic exclusivity) 🟩 process -- **Spec:** [agent-scale.md](agent-scale.md) slice A (READY) -- **Why:** Concurrent agents took overlapping worktrees on the same - files (e.g. dark-frame vs close-camera). A claim registry is the - semantic mutex; check.sh's rust lock is only the CPU mutex. -- **Size:** S. **Dispatch:** "Work in a worktree named `agent-claims`. - Implement wiki/process/agent-scale.md slice A (dispatch claims): - claim procedure, exclusive_keys intersection fail-fast, clear on - done/abandon, AGENT/prompts pointer. Run ./tools/check.sh --docs, - land on main." +### P1. Dispatch claims (semantic exclusivity) 🟩 process — DONE 2026-07-09 +- **Spec:** [agent-scale.md](agent-scale.md) slice A (HELD) +- **Landed:** `tools/claim.sh` (claim/list/status/release/check), + gitignored `.agents/claims/`, AGENT + prompts contract. ### P2. Append-only ledgers / generated indexes 🟩 process - **Spec:** [agent-scale.md](agent-scale.md) slice C (READY) @@ -679,13 +673,10 @@ edges clearly block parallel work — coordination slices land first. specs board from uniquely named sources; agents stop hand-editing indexes. Run ./tools/check.sh --docs, land on main." -### P3. Worktree bootstrap + prune (+ sccache docs) 🟩 process -- **Spec:** [agent-scale.md](agent-scale.md) slice D (READY) -- **Why:** N private 8GB targets without seed/prune thrash disk. -- **Size:** S. **Dispatch:** "Work in a worktree named `worktree-boot`. - Implement wiki/process/agent-scale.md slice D: worktree-new helper, - seed, prune on remove, document dependency cache. Run - ./tools/check.sh --docs, land on main." +### P3. Worktree bootstrap + prune (+ sccache docs) 🟩 process — DONE 2026-07-09 +- **Spec:** [agent-scale.md](agent-scale.md) slice D (HELD) +- **Landed:** `tools/worktree-new.sh`, `tools/worktree-done.sh` (seed, + claim, prune target/, remove worktree), sccache note in workflows. ### P4. Corpus/wiki engine 🟩 process - **Spec:** [agent-scale.md](agent-scale.md) slice E (READY) @@ -703,13 +694,9 @@ edges clearly block parallel work — coordination slices land first. shot/fog-audit path for land evidence. Run ./tools/check.sh --frontend or auto, land on main." -### P6. Agent heartbeats + land-phase polish 🟩 process -- **Spec:** [agent-scale.md](agent-scale.md) slices B leftovers + G -- **Why:** Empty dispatch logs made progress unanswerable; land policy - should be one named command. -- **Size:** S. **Dispatch:** "Work in a worktree named `agent-heartbeat`. - Implement wiki/process/agent-scale.md slices B (land alias) and G - (run heartbeats). Run ./tools/check.sh --docs, land on main." +### P6. Agent heartbeats + land-phase polish 🟩 process — DONE 2026-07-09 +- **Spec:** [agent-scale.md](agent-scale.md) slices B + G (HELD) +- **Landed:** `./tools/check.sh --land`, `tools/heartbeat.sh`, prompts. ### P7. Crate workspace (core / terminal / Bevy / assets) 🟧 deferred package split - **Spec:** [crate-workspace.md](../engineering/crate-workspace.md) diff --git a/wiki/process/agent-scale.md b/wiki/process/agent-scale.md index cd2ce41f..fbf742c2 100644 --- a/wiki/process/agent-scale.md +++ b/wiki/process/agent-scale.md @@ -2,13 +2,12 @@ ``` Type: spec -Status: READY -Status note: captured 2026-07-09 from Cameron's adoption of the full - package (dispatch exclusivity, append-only ledgers, verification phases, - worktree cache, corpus engine, headless Bevy evidence, heartbeats, and - the deferred crate workspace). Several criteria are already partially - held by tools/check.sh tiers + rust lock (2026-07-09); the rest are - implementable slices. Prefer one slice per agent landing. +Status: IN PROGRESS +Status note: captured 2026-07-09. Landed 2026-07-09: slice A (tools/claim.sh), + slice B land alias (`./tools/check.sh --land`), slice D (worktree-new / + worktree-done + target prune), slice G (tools/heartbeat.sh). Still open: + slice C (ledger indexes), E (corpus engine), F (headless Bevy). Crate + workspace remains its own READY deferred work order. Stage: Process Design: - wiki/vision/simulation-laws.md#justification-and-legibility @@ -65,16 +64,27 @@ A second claim whose exclusive_keys intersect an active claim must not proceed (fail fast with a readable message). Docs-only claims with empty or log-only keys may run widely in parallel. -Storage: start simple and repo-local (e.g. `.agents/claims/.json` or a -single lock file under `.agents/` that is **not** merged as product source — -gitignored or regenerated). If gitignored, document how an agent discovers -active claims on the machine. Do not require Tangled auth for the local -mutex. +Storage (landed): machine-local gitignored files +`.agents/claims/.claim` (override with `MISALIGNED_CLAIMS_DIR`). +Discover with `tools/claim.sh list`. Holder pid is the calling agent/shell +(`PPID`, or `MISALIGNED_CLAIM_PID`). Dead holders are reaped on the next +claim/list. No Tangled auth required. + +```bash +tools/claim.sh claim dark-frame --class frontend --key src/bin/bevy.rs \ + --key wiki/interface/material-dark-frame.md +tools/claim.sh list +tools/claim.sh set-status dark-frame checking # or landing +tools/claim.sh release dark-frame # done (or --status abandoned) +# bundled with worktree create/remove: +tools/worktree-new.sh dark-frame --class frontend --key src/bin/bevy.rs +tools/worktree-done.sh dark-frame +``` ROADMAP conflict flags (🟥 sim+save / 🟧 sim / 🟩 isolated) remain the human index; claims enforce them mechanically for hot paths. -### Acceptance criteria (slice A) +### Acceptance criteria (slice A) — HELD 2026-07-09 1. A documented claim procedure exists (tool or script + AGENT.md / prompts pointer) that records class + exclusive_keys + status. @@ -93,16 +103,19 @@ index; claims enforce them mechanically for hot paths. | Land | Once before merge to main | One green auto or `--full` under the rust lock when Rust is involved | | CI | After push | Conservative full gate | -Already held (2026-07-09): path auto-classification, `--docs|--lib|--frontend|--full`, rust gate lock, parallel docs gates, collapsed agent smoke. Remaining work is **contract completeness** (prompts/AGENT always teach phases; optional `check.sh --land` alias) and package-aware classification after the crate split. +Already held (2026-07-09): path auto-classification, +`--docs|--lib|--frontend|--full|--land`, rust gate lock, parallel docs gates, +collapsed agent smoke. Package-aware classification waits on +[crate-workspace.md](../engineering/crate-workspace.md). ### Acceptance criteria (slice B) 1. AGENT.md and prompts/README state mid-loop narrow vs one land gate - (partially done — keep true as files churn). -2. Optional: `./tools/check.sh --land` is an alias for the land policy - (auto or full) documented in workflows.md. + — HELD. +2. `./tools/check.sh --land` is an alias for the land policy (auto) + documented in workflows.md — HELD. 3. After [crate-workspace.md](../engineering/crate-workspace.md) lands, - classification keys off packages, not only path prefixes. + classification keys off packages, not only path prefixes — OPEN. ## 3. Append-only ledgers and generated indexes @@ -140,23 +153,26 @@ after generation, the generator is the source of truth for the index file. ## 4. Worktree bootstrap, dependency cache, prune -### Behavior +### Behavior (landed helpers) + +```bash +tools/worktree-new.sh --class frontend --key src/bin/bevy.rs +# -> .claude/worktrees/ on worktree-, seed-cargo-target, claim +tools/worktree-done.sh # release claim, rm target/, remove worktree +``` -- `tools/worktree-new.sh ` (name flexible): create - `.claude/worktrees/` on `worktree-`, run - `seed-cargo-target.sh`, print next steps (claim, check tier). -- Shared **dependency** compilation cache (sccache or cargo cache) is - recommended and documented; **local package** artifacts stay per-worktree. -- Worktree remove prunes or schedules prune of that worktree's `target/`. -- Never share one `CARGO_TARGET_DIR` across worktrees for local packages. +Shared **dependency** compilation cache (sccache or cargo cache) remains +recommended; **local package** artifacts stay per-worktree. Never share one +`CARGO_TARGET_DIR` across worktrees for local packages. -### Acceptance criteria (slice D) +### Acceptance criteria (slice D) — HELD 2026-07-09 (helpers; sccache optional) -1. A single documented command creates a seeded worktree. -2. workflows.md documents sccache (or chosen cache) setup and the private- - target rule. -3. Removing a worktree does not leave an 8GB `target/` orphan by default - (scripted cleanup or documented `rm -rf` in the remove path). +1. A single documented command creates a seeded worktree — HELD + (`tools/worktree-new.sh`). +2. workflows.md documents private-target rule; sccache is optional install + guidance — HELD (private target); sccache install remains operator choice. +3. Removing a worktree prunes `target/` by default via + `tools/worktree-done.sh` — HELD. ## 5. Corpus / wiki engine @@ -205,26 +221,25 @@ on Bevy-impacting landings. ## 7. Agent heartbeats -### Behavior - -Long-running agent dispatches write: +### Behavior (landed) -``` -.agents/runs// - status.json # phase, pid, worktree, started_at, last_step - events.ndjson # append-only steps +```bash +tools/heartbeat.sh start --worktree path --phase boot +tools/heartbeat.sh phase 'check-land' +tools/heartbeat.sh end --status ok|fail ``` -`check.sh` may append phase lines when invoked under a run id. Humans and -other agents inspect status files instead of empty stdout pipes. +Writes gitignored `.agents/runs//status.json` + `events.ndjson` +(override `MISALIGNED_RUNS_DIR`). Humans and other agents inspect status +files instead of empty stdout pipes. Dispatch prompts should call start/ +phase/end around long runs (implement-gap contract). -### Acceptance criteria (slice G) +### Acceptance criteria (slice G) — HELD 2026-07-09 (tool + docs) -1. Documented convention + helper to start/update a run record. -2. At least the implement-gap / droid-style dispatch path records phases - through land or failure. -3. Paths are gitignored if they are machine-local; the convention is still - written in knowledge/process docs. +1. Documented convention + helper to start/update a run record — HELD. +2. implement-gap / prompts path documents heartbeat use — HELD (prompts + README + implement-gap pointer; agents still must call the tool). +3. Paths are gitignored; convention is written in process docs — HELD. ## Relationship to the crate workspace diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 378ab9b0..1460528e 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -94,5 +94,5 @@ acceptance criteria are stage-scoped; do not start B2/B3 work as B1. | [interface/agent-play.md](../interface/agent-play.md) | Agent mode: command-clocked line-protocol drive of the terminal frontend | IMPLEMENTED | | [engineering/env.md](../engineering/env.md) | Environment variable registry: every switch documented, gate-enforced | IMPLEMENTED | | [engineering/crate-workspace.md](../engineering/crate-workspace.md) | Cargo workspace: core / terminal / Bevy / assets (deferred implement) | READY | -| [process/agent-scale.md](agent-scale.md) | Multi-agent scale: claims, ledgers, gates, cache, corpus engine, heartbeats | READY | +| [process/agent-scale.md](agent-scale.md) | Multi-agent scale: claims, ledgers, gates, cache, corpus engine, heartbeats | IN PROGRESS (A/B/D/G landed; C/E/F open) | | [interface/site.md](../interface/site.md) | The public site: one visual law from splash to spec page (clinical gore on the web) | IMPLEMENTED | diff --git a/wiki/process/workflows.md b/wiki/process/workflows.md index 2afa0b00..7d680b43 100644 --- a/wiki/process/workflows.md +++ b/wiki/process/workflows.md @@ -39,8 +39,24 @@ heartbeats) lives in [agent-scale.md](agent-scale.md) and ./tools/check.sh --lib # fmt, lib tests, terminal clippy, cheap bevy check, agent smoke ./tools/check.sh --frontend # fmt, bevy bin tests, bevy clippy ./tools/check.sh --full # everything (also MISALIGNED_FORCE_RUST_GATE=1) +./tools/check.sh --land # land phase: same as auto (full when unclassifiable) ``` +**Claims and worktrees** (semantic exclusivity — see +[agent-scale.md](agent-scale.md)): + +```bash +tools/worktree-new.sh dark-frame --class frontend --key src/bin/bevy.rs +tools/claim.sh list +tools/heartbeat.sh start dark-frame --phase implement +./tools/check.sh --land +tools/worktree-done.sh dark-frame # release claim, prune target/, remove worktree +``` + +Optional: install [sccache](https://github.com/mozilla/sccache) and set +`RUSTC_WRAPPER=sccache` so dependency objects reuse across worktrees while +each worktree keeps a private `target/` for local packages. + Auto-classification (linked task worktrees only; primary/CI stay full when the delta is empty or unclassifiable):