#!/usr/bin/env bash
# Scaffold an outer-loop state spine (loop.config.yaml + STATE.md + run-log.md).
#
# Usage: loop-scaffold.sh --name NAME [OPTIONS]
# Input: argv flags only (no stdin).
# Output: stdout = the created loop.config.yaml path (data). Under --dry-run, the
# path then the rendered config. Data only.
# Stderr: the creation panel, reminders, warnings, errors.
# Exit: 0 created (or dry-run rendered), 2 usage, 3 template/dir not found,
# 5 precondition (target dir already populated, no --force)
#
# Creates
// from the bundled templates, substituting name/pattern/tier/
# cadence/permission_mode. Never clobbers a populated loop dir. Atomic writes.
# Next step: fill the config, then `loop-check.sh //loop.config.yaml`.
#
# Examples:
# loop-scaffold.sh --name pr-watch --pattern pr-watch --tier L1
# loop-scaffold.sh --name dep-bump --pattern dep-bump --tier L2 --cadence 1d
# loop-scaffold.sh --name nightly --cadence "0 3 * * *" --dry-run
set -uo pipefail
readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3 EX_PRECOND=5
# Terminal design system (skills/_lib/term.sh). stdout = the created path (data);
# the creation panel frames on stderr, so detect color on fd 2. Degrade to plain
# stderr lines if the shared lib is unreachable.
__lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
else
term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; }
term_status_row() { shift; printf ' - %s %s\n' "$1" "${2:-}"; }
term_alert() { shift; printf ' ! %s\n' "$*"; }
term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|"
fi
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ASSETS="$HERE/../assets"
CFG_TPL="$ASSETS/loop.config.template.yaml"
STATE_TPL="$ASSETS/STATE.template.md"
RUN_TPL="$ASSETS/run.template.md"
RUN_SH_TPL="$ASSETS/run.sh.template"
# ── defaults ────────────────────────────────────────────────────────────────
NAME=""
PATTERN="custom"
TIER="L1"
CADENCE="1h"
DIR=".loops"
DRY_RUN=0
FORCE=0
usage() {
cat <<'EOF'
loop-scaffold.sh — scaffold an outer-loop state spine.
Usage:
loop-scaffold.sh --name NAME [OPTIONS]
Options:
--name NAME loop identifier, kebab-case (required). Names the directory.
--pattern KEY catalog key (pr-watch, ci-watch, dep-bump,
changelog-gen, merge-hygiene, issue-sort,
daily-scan) or "custom" (default: custom).
--tier L1|L2|L3 starting autonomy tier (default: L1).
--cadence STR 10m | 1h | 6h | 1d, or a cron string (default: 1h).
--dir DIR parent directory for the loop (default: .loops).
--dry-run print the target path + rendered config; write nothing.
--force overwrite an already-populated // directory.
-h, --help show this help and exit 0.
Exit codes:
0 created (or dry-run) 2 usage 3 template/dir not found 5 dir populated
Examples:
loop-scaffold.sh --name pr-watch --pattern pr-watch --tier L1
loop-scaffold.sh --name dep-bump --pattern dep-bump --tier L2 --cadence 1d
loop-scaffold.sh --name nightly --cadence "0 3 * * *" --dry-run
EOF
}
die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; }
# ── parse args ──────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--name) [[ $# -ge 2 ]] || die_usage "--name needs a value"; NAME="$2"; shift 2 ;;
--pattern) [[ $# -ge 2 ]] || die_usage "--pattern needs a value"; PATTERN="$2"; shift 2 ;;
--tier) [[ $# -ge 2 ]] || die_usage "--tier needs a value"; TIER="$2"; shift 2 ;;
--cadence) [[ $# -ge 2 ]] || die_usage "--cadence needs a value"; CADENCE="$2"; shift 2 ;;
--dir) [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--force) FORCE=1; shift ;;
-h|--help) usage; exit "$EX_OK" ;;
-*) die_usage "unknown flag: $1" ;;
*) die_usage "unexpected positional argument: $1" ;;
esac
done
# ── validate ────────────────────────────────────────────────────────────────
[[ -n "$NAME" ]] || die_usage "--name is required"
[[ "$NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || die_usage "--name must be kebab-case (got '$NAME')"
[[ "$PATTERN" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || die_usage "--pattern must be kebab-case (got '$PATTERN')"
case "$TIER" in L1|L2|L3) ;; *) die_usage "--tier must be L1|L2|L3 (got '$TIER')" ;; esac
# cadence: Nm/Nh/Nd OR a cron-ish string (digits, spaces, * / , -)
[[ "$CADENCE" =~ ^[0-9]+[mhd]$ || "$CADENCE" =~ ^[-0-9*/,\ ]+$ ]] \
|| die_usage "--cadence must be like 10m/1h/1d or a cron string (got '$CADENCE')"
[[ -f "$CFG_TPL" ]] || { printf 'error: config template not found at %s\n' "$CFG_TPL" >&2; exit "$EX_NOTFOUND"; }
[[ -f "$STATE_TPL" ]] || { printf 'error: STATE template not found at %s\n' "$STATE_TPL" >&2; exit "$EX_NOTFOUND"; }
[[ -f "$RUN_TPL" ]] || { printf 'error: run template not found at %s\n' "$RUN_TPL" >&2; exit "$EX_NOTFOUND"; }
[[ -f "$RUN_SH_TPL" ]] || { printf 'error: run.sh template not found at %s\n' "$RUN_SH_TPL" >&2; exit "$EX_NOTFOUND"; }
# Default permission_mode from tier (the workhorse mapping; see references/risk-tiers.md).
case "$TIER" in
L1|L2) PMODE="dontAsk" ;;
L3) PMODE="bypassPermissions" ;;
esac
# ── pattern presets ─────────────────────────────────────────────────────────
# Seed a near-ready config for a known --pattern (the user reviews, doesn't start
# from blank placeholders). Doctrine: always scaffold at the chosen tier; report/
# propose/draft patterns carry no gate (VERIFY_SEED empty), code-changing ones do.
SEEDED=0; SCOPE_SEED=""; GOAL_SEED=""; ESCAL_SEED=""; VERIFY_SEED=""; GUARD_SEED=""; BUDGET_SEED=""
case "$PATTERN" in
daily-scan) SEEDED=1
SCOPE_SEED="src/**"
GOAL_SEED="Sweep the backlog/issues/alerts and write the day's STATE.md priority list; report only."
ESCAL_SEED="everything - a human decides what to action; this loop never changes code" ;;
pr-watch) SEEDED=1
SCOPE_SEED="src/**"
GOAL_SEED="Watch open PRs; flag stuck/failing/conflicted; post a summary comment at most; never merge."
ESCAL_SEED="a human reviews and merges; never merge to main" ;;
ci-watch) SEEDED=1
SCOPE_SEED="src/**"
GOAL_SEED="Detect red CI; classify the failure; at L2 propose a fix in a worktree; never auto-merge to main."
ESCAL_SEED="flaky/infra failures, anything touching deploy/secrets, ambiguous root cause"
VERIFY_SEED="npm test"; GUARD_SEED="npm run typecheck" ;;
dep-bump) SEEDED=1
SCOPE_SEED="package.json"
GOAL_SEED="Patch-only dependency bumps behind the release cooldown + guard; open a PR; never minor/major."
ESCAL_SEED="minor/major bumps, guard failures, any flagged advisory"
VERIFY_SEED="npm test"; GUARD_SEED="npm run build && npm test" ;;
changelog-gen) SEEDED=1
SCOPE_SEED="CHANGELOG.md"
GOAL_SEED="Summarize merged PRs since the last tag into RELEASE_NOTES_DRAFT.md; never publish a release."
ESCAL_SEED="the human edits and publishes; never run gh release create" ;;
merge-hygiene) SEEDED=1
SCOPE_SEED="src/**"
GOAL_SEED="Find merged-deletable branches / stale flags / orphaned artifacts; report; never delete unmerged work."
ESCAL_SEED="anything ambiguous; never delete a branch with unmerged commits" ;;
issue-sort) SEEDED=1
SCOPE_SEED="src/**"
GOAL_SEED="Classify new issues and suggest labels + priority; propose only; never close or set priority unattended."
ESCAL_SEED="priority calls, dupe-closing, anything needing product judgment" ;;
metric-chase) SEEDED=1
SCOPE_SEED="src/**"
GOAL_SEED="Drive a measurable target (coverage/latency/bundle/eval score) to goal via iterate; keep gains, discard regressions."
ESCAL_SEED="target unreachable after the budget, guard failures, any change to the gate/test itself"
VERIFY_SEED="npm test -- --coverage"; GUARD_SEED="npm run typecheck"
BUDGET_SEED=400000 ;; # iterate fan-out is the most expensive tick — fit it
regression-watch) SEEDED=1
SCOPE_SEED="bench/**"
GOAL_SEED="Run the benchmark/eval suite, diff against the recorded baseline; report a regression; never edit the suite."
ESCAL_SEED="a confirmed regression (a human triages); a single flaky run is advisory, not a page" ;;
digest) SEEDED=1
SCOPE_SEED="reports/**"
GOAL_SEED="Summarize email/Asana/calendar/news via connectors into a morning report; read-only, never act."
ESCAL_SEED="anything requiring a reply or an action; this loop only summarizes" ;;
backfill) SEEDED=1
SCOPE_SEED="src/**"
GOAL_SEED="Drain a migration/queue to completion via /goal; one item per step, verify each; stop when empty or after the bound."
ESCAL_SEED="any item needing a judgment call; never exceed the stop-after-N / token bound"
VERIFY_SEED="npm test"; GUARD_SEED="npm run typecheck" ;;
monitor) SEEDED=1
SCOPE_SEED="src/**"
GOAL_SEED="React to an error/log/deploy event (via a Channel); triage it and page a human on a real anomaly; never auto-remediate prod."
ESCAL_SEED="any anomaly worth a human; production remediation; anything destructive" ;;
freshness) SEEDED=1
SCOPE_SEED="docs/**"
GOAL_SEED="Re-check docs/data/deps/links against reality on a cadence; report confirmed drift; never auto-edit on a transient failure."
ESCAL_SEED="confirmed drift a human should fix; a transient/network failure is advisory only" ;;
esac
TARGET_DIR="$DIR/$NAME"
CFG_OUT="$TARGET_DIR/loop.config.yaml"
STATE_OUT="$TARGET_DIR/STATE.md"
LOG_OUT="$TARGET_DIR/run-log.md"
RUN_OUT="$TARGET_DIR/run.md"
RUN_SH_OUT="$TARGET_DIR/loop-run.sh"
# Refuse a populated target unless --force.
if [[ -d "$TARGET_DIR" ]] && [[ -n "$(ls -A "$TARGET_DIR" 2>/dev/null)" ]] && [[ "$FORCE" -ne 1 ]]; then
printf 'error: loop directory already populated: %s (use --force to overwrite)\n' "$TARGET_DIR" >&2
exit "$EX_PRECOND"
fi
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# ── render config from template ─────────────────────────────────────────────
# Line-anchored sed substitutions: identity placeholders globally, the three
# tunable scalar lines by their default value. Kill-switch path carries .
render_config() {
sed -E \
-e "s||$NAME|g" \
-e "s||$PATTERN|" \
-e "s|^tier: L1|tier: $TIER|" \
-e "s|^cadence: 1h|cadence: $CADENCE|" \
-e "s|^permission_mode: dontAsk|permission_mode: $PMODE|" \
"$CFG_TPL"
}
render_state() {
sed -E \
-e "s||$NAME|g" \
-e "s||$NOW|" \
"$STATE_TPL"
}
render_log() {
cat < run#N action= outcome=<…> tokens=
EOF
}
render_run() {
sed -E \
-e "s||$NAME|g" \
-e "s|tier |tier $TIER|g" \
"$RUN_TPL"
}
# The runner-agnostic tick wrapper any scheduler invokes (cron / Task Scheduler /
# systemd / process-compose / by hand) — no GitHub Actions required.
render_run_sh() {
sed -E \
-e "s||$NAME|g" \
-e "s||$PMODE|g" \
"$RUN_SH_TPL"
}
# Seeded config for a known pattern. L1 stays report-only (gate fields are a
# commented graduation block); L2/L3 emit verify/guard/worktree/land_via — using
# the pattern's gate if it has one, else a placeholder the audit will flag.
render_seeded_config() {
cat <}"
# guard: "${GUARD_SEED:-}"
# worktree: true
# land_via: fleet-ops
EOF
else
cat <}"
guard: "${GUARD_SEED:-}"
worktree: true
land_via: fleet-ops
EOF
fi
}
# Pick the seeded renderer for a known pattern, else the generic template.
emit_config() { if [[ "$SEEDED" -eq 1 ]]; then render_seeded_config; else render_config; fi; }
# ── dry-run: print and stop ─────────────────────────────────────────────────
if [[ "$DRY_RUN" -eq 1 ]]; then
printf '%s\n' "$CFG_OUT"
{
term_panel_open loop "loop ${TERM_DOT} init (dry-run)" "$NAME"
term_panel_vert
term_status_row skip "would create $TARGET_DIR/" "tier $TIER ${TERM_DOT} $PATTERN ${TERM_DOT} $CADENCE"
term_status_row skip " loop.config.yaml" "permission_mode: $PMODE"
term_status_row skip " STATE.md / run-log.md / run.md / loop-run.sh" ""
term_panel_vert
term_panel_close "nothing written" ""
} >&2
emit_config
exit "$EX_OK"
fi
# ── atomic writes ───────────────────────────────────────────────────────────
mkdir -p "$TARGET_DIR" || { printf 'error: could not create %s\n' "$TARGET_DIR" >&2; exit 1; }
write_atomic() { # write_atomic
local dest="$1" content="$2" tmp
tmp="$dest.tmp.$$"
printf '%s\n' "$content" > "$tmp" || { printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; }
mv -f "$tmp" "$dest" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$dest" >&2; exit 1; }
}
write_atomic "$CFG_OUT" "$(emit_config)"
write_atomic "$STATE_OUT" "$(render_state)"
write_atomic "$LOG_OUT" "$(render_log)"
write_atomic "$RUN_OUT" "$(render_run)"
write_atomic "$RUN_SH_OUT" "$(render_run_sh)"
chmod +x "$RUN_SH_OUT" 2>/dev/null || true
printf '%s\n' "$CFG_OUT"
{
term_panel_open loop "loop ${TERM_DOT} init" "$NAME"
term_panel_vert
term_status_row ok "created $TARGET_DIR/" "tier $TIER ${TERM_DOT} $PATTERN ${TERM_DOT} $CADENCE"
term_status_row ok " loop.config.yaml" "permission_mode: $PMODE"
term_status_row ok " STATE.md / run-log.md / run.md / loop-run.sh" ""
if [[ "$TIER" != "L1" ]]; then
term_alert warning "tier $TIER needs a verify gate, guard, worktree, escalation + land_via — fill them before auditing"
fi
term_panel_vert
term_panel_close "then: fill the config ${TERM_DOT} loop-check.sh $CFG_OUT" ""
} >&2
exit "$EX_OK"