loop-scaffold.sh 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. #!/usr/bin/env bash
  2. # Scaffold an outer-loop state spine (loop.config.yaml + STATE.md + run-log.md).
  3. #
  4. # Usage: loop-scaffold.sh --name NAME [OPTIONS]
  5. # Input: argv flags only (no stdin).
  6. # Output: stdout = the created loop.config.yaml path (data). Under --dry-run, the
  7. # path then the rendered config. Data only.
  8. # Stderr: the creation panel, reminders, warnings, errors.
  9. # Exit: 0 created (or dry-run rendered), 2 usage, 3 template/dir not found,
  10. # 5 precondition (target dir already populated, no --force)
  11. #
  12. # Creates <dir>/<name>/ from the bundled templates, substituting name/pattern/tier/
  13. # cadence/permission_mode. Never clobbers a populated loop dir. Atomic writes.
  14. # Next step: fill the config, then `loop-check.sh <dir>/<name>/loop.config.yaml`.
  15. #
  16. # Examples:
  17. # loop-scaffold.sh --name pr-watch --pattern pr-watch --tier L1
  18. # loop-scaffold.sh --name dep-bump --pattern dep-bump --tier L2 --cadence 1d
  19. # loop-scaffold.sh --name nightly --cadence "0 3 * * *" --dry-run
  20. set -uo pipefail
  21. readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3 EX_PRECOND=5
  22. # Terminal design system (skills/_lib/term.sh). stdout = the created path (data);
  23. # the creation panel frames on stderr, so detect color on fd 2. Degrade to plain
  24. # stderr lines if the shared lib is unreachable.
  25. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
  26. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
  27. else
  28. term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; }
  29. term_status_row() { shift; printf ' - %s %s\n' "$1" "${2:-}"; }
  30. term_alert() { shift; printf ' ! %s\n' "$*"; }
  31. term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|"
  32. fi
  33. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  34. ASSETS="$HERE/../assets"
  35. CFG_TPL="$ASSETS/loop.config.template.yaml"
  36. STATE_TPL="$ASSETS/STATE.template.md"
  37. RUN_TPL="$ASSETS/run.template.md"
  38. RUN_SH_TPL="$ASSETS/run.sh.template"
  39. # ── defaults ────────────────────────────────────────────────────────────────
  40. NAME=""
  41. PATTERN="custom"
  42. TIER="L1"
  43. CADENCE="1h"
  44. DIR=".loops"
  45. DRY_RUN=0
  46. FORCE=0
  47. usage() {
  48. cat <<'EOF'
  49. loop-scaffold.sh — scaffold an outer-loop state spine.
  50. Usage:
  51. loop-scaffold.sh --name NAME [OPTIONS]
  52. Options:
  53. --name NAME loop identifier, kebab-case (required). Names the directory.
  54. --pattern KEY catalog key (pr-watch, ci-watch, dep-bump,
  55. changelog-gen, merge-hygiene, issue-sort,
  56. daily-scan) or "custom" (default: custom).
  57. --tier L1|L2|L3 starting autonomy tier (default: L1).
  58. --cadence STR 10m | 1h | 6h | 1d, or a cron string (default: 1h).
  59. --dir DIR parent directory for the loop (default: .loops).
  60. --dry-run print the target path + rendered config; write nothing.
  61. --force overwrite an already-populated <dir>/<name>/ directory.
  62. -h, --help show this help and exit 0.
  63. Exit codes:
  64. 0 created (or dry-run) 2 usage 3 template/dir not found 5 dir populated
  65. Examples:
  66. loop-scaffold.sh --name pr-watch --pattern pr-watch --tier L1
  67. loop-scaffold.sh --name dep-bump --pattern dep-bump --tier L2 --cadence 1d
  68. loop-scaffold.sh --name nightly --cadence "0 3 * * *" --dry-run
  69. EOF
  70. }
  71. die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; }
  72. # ── parse args ──────────────────────────────────────────────────────────────
  73. while [[ $# -gt 0 ]]; do
  74. case "$1" in
  75. --name) [[ $# -ge 2 ]] || die_usage "--name needs a value"; NAME="$2"; shift 2 ;;
  76. --pattern) [[ $# -ge 2 ]] || die_usage "--pattern needs a value"; PATTERN="$2"; shift 2 ;;
  77. --tier) [[ $# -ge 2 ]] || die_usage "--tier needs a value"; TIER="$2"; shift 2 ;;
  78. --cadence) [[ $# -ge 2 ]] || die_usage "--cadence needs a value"; CADENCE="$2"; shift 2 ;;
  79. --dir) [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;;
  80. --dry-run) DRY_RUN=1; shift ;;
  81. --force) FORCE=1; shift ;;
  82. -h|--help) usage; exit "$EX_OK" ;;
  83. -*) die_usage "unknown flag: $1" ;;
  84. *) die_usage "unexpected positional argument: $1" ;;
  85. esac
  86. done
  87. # ── validate ────────────────────────────────────────────────────────────────
  88. [[ -n "$NAME" ]] || die_usage "--name is required"
  89. [[ "$NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || die_usage "--name must be kebab-case (got '$NAME')"
  90. [[ "$PATTERN" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || die_usage "--pattern must be kebab-case (got '$PATTERN')"
  91. case "$TIER" in L1|L2|L3) ;; *) die_usage "--tier must be L1|L2|L3 (got '$TIER')" ;; esac
  92. # cadence: Nm/Nh/Nd OR a cron-ish string (digits, spaces, * / , -)
  93. [[ "$CADENCE" =~ ^[0-9]+[mhd]$ || "$CADENCE" =~ ^[-0-9*/,\ ]+$ ]] \
  94. || die_usage "--cadence must be like 10m/1h/1d or a cron string (got '$CADENCE')"
  95. [[ -f "$CFG_TPL" ]] || { printf 'error: config template not found at %s\n' "$CFG_TPL" >&2; exit "$EX_NOTFOUND"; }
  96. [[ -f "$STATE_TPL" ]] || { printf 'error: STATE template not found at %s\n' "$STATE_TPL" >&2; exit "$EX_NOTFOUND"; }
  97. [[ -f "$RUN_TPL" ]] || { printf 'error: run template not found at %s\n' "$RUN_TPL" >&2; exit "$EX_NOTFOUND"; }
  98. [[ -f "$RUN_SH_TPL" ]] || { printf 'error: run.sh template not found at %s\n' "$RUN_SH_TPL" >&2; exit "$EX_NOTFOUND"; }
  99. # Default permission_mode from tier (the workhorse mapping; see references/risk-tiers.md).
  100. case "$TIER" in
  101. L1|L2) PMODE="dontAsk" ;;
  102. L3) PMODE="bypassPermissions" ;;
  103. esac
  104. # ── pattern presets ─────────────────────────────────────────────────────────
  105. # Seed a near-ready config for a known --pattern (the user reviews, doesn't start
  106. # from blank placeholders). Doctrine: always scaffold at the chosen tier; report/
  107. # propose/draft patterns carry no gate (VERIFY_SEED empty), code-changing ones do.
  108. SEEDED=0; SCOPE_SEED=""; GOAL_SEED=""; ESCAL_SEED=""; VERIFY_SEED=""; GUARD_SEED=""; BUDGET_SEED=""
  109. case "$PATTERN" in
  110. daily-scan) SEEDED=1
  111. SCOPE_SEED="src/**"
  112. GOAL_SEED="Sweep the backlog/issues/alerts and write the day's STATE.md priority list; report only."
  113. ESCAL_SEED="everything - a human decides what to action; this loop never changes code" ;;
  114. pr-watch) SEEDED=1
  115. SCOPE_SEED="src/**"
  116. GOAL_SEED="Watch open PRs; flag stuck/failing/conflicted; post a summary comment at most; never merge."
  117. ESCAL_SEED="a human reviews and merges; never merge to main" ;;
  118. ci-watch) SEEDED=1
  119. SCOPE_SEED="src/**"
  120. GOAL_SEED="Detect red CI; classify the failure; at L2 propose a fix in a worktree; never auto-merge to main."
  121. ESCAL_SEED="flaky/infra failures, anything touching deploy/secrets, ambiguous root cause"
  122. VERIFY_SEED="npm test"; GUARD_SEED="npm run typecheck" ;;
  123. dep-bump) SEEDED=1
  124. SCOPE_SEED="package.json"
  125. GOAL_SEED="Patch-only dependency bumps behind the release cooldown + guard; open a PR; never minor/major."
  126. ESCAL_SEED="minor/major bumps, guard failures, any flagged advisory"
  127. VERIFY_SEED="npm test"; GUARD_SEED="npm run build && npm test" ;;
  128. changelog-gen) SEEDED=1
  129. SCOPE_SEED="CHANGELOG.md"
  130. GOAL_SEED="Summarize merged PRs since the last tag into RELEASE_NOTES_DRAFT.md; never publish a release."
  131. ESCAL_SEED="the human edits and publishes; never run gh release create" ;;
  132. merge-hygiene) SEEDED=1
  133. SCOPE_SEED="src/**"
  134. GOAL_SEED="Find merged-deletable branches / stale flags / orphaned artifacts; report; never delete unmerged work."
  135. ESCAL_SEED="anything ambiguous; never delete a branch with unmerged commits" ;;
  136. issue-sort) SEEDED=1
  137. SCOPE_SEED="src/**"
  138. GOAL_SEED="Classify new issues and suggest labels + priority; propose only; never close or set priority unattended."
  139. ESCAL_SEED="priority calls, dupe-closing, anything needing product judgment" ;;
  140. metric-chase) SEEDED=1
  141. SCOPE_SEED="src/**"
  142. GOAL_SEED="Drive a measurable target (coverage/latency/bundle/eval score) to goal via iterate; keep gains, discard regressions."
  143. ESCAL_SEED="target unreachable after the budget, guard failures, any change to the gate/test itself"
  144. VERIFY_SEED="npm test -- --coverage"; GUARD_SEED="npm run typecheck"
  145. BUDGET_SEED=400000 ;; # iterate fan-out is the most expensive tick — fit it
  146. regression-watch) SEEDED=1
  147. SCOPE_SEED="bench/**"
  148. GOAL_SEED="Run the benchmark/eval suite, diff against the recorded baseline; report a regression; never edit the suite."
  149. ESCAL_SEED="a confirmed regression (a human triages); a single flaky run is advisory, not a page" ;;
  150. digest) SEEDED=1
  151. SCOPE_SEED="reports/**"
  152. GOAL_SEED="Summarize email/Asana/calendar/news via connectors into a morning report; read-only, never act."
  153. ESCAL_SEED="anything requiring a reply or an action; this loop only summarizes" ;;
  154. backfill) SEEDED=1
  155. SCOPE_SEED="src/**"
  156. GOAL_SEED="Drain a migration/queue to completion via /goal; one item per step, verify each; stop when empty or after the bound."
  157. ESCAL_SEED="any item needing a judgment call; never exceed the stop-after-N / token bound"
  158. VERIFY_SEED="npm test"; GUARD_SEED="npm run typecheck" ;;
  159. monitor) SEEDED=1
  160. SCOPE_SEED="src/**"
  161. 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."
  162. ESCAL_SEED="any anomaly worth a human; production remediation; anything destructive" ;;
  163. freshness) SEEDED=1
  164. SCOPE_SEED="docs/**"
  165. GOAL_SEED="Re-check docs/data/deps/links against reality on a cadence; report confirmed drift; never auto-edit on a transient failure."
  166. ESCAL_SEED="confirmed drift a human should fix; a transient/network failure is advisory only" ;;
  167. esac
  168. TARGET_DIR="$DIR/$NAME"
  169. CFG_OUT="$TARGET_DIR/loop.config.yaml"
  170. STATE_OUT="$TARGET_DIR/STATE.md"
  171. LOG_OUT="$TARGET_DIR/run-log.md"
  172. RUN_OUT="$TARGET_DIR/run.md"
  173. RUN_SH_OUT="$TARGET_DIR/loop-run.sh"
  174. # Refuse a populated target unless --force.
  175. if [[ -d "$TARGET_DIR" ]] && [[ -n "$(ls -A "$TARGET_DIR" 2>/dev/null)" ]] && [[ "$FORCE" -ne 1 ]]; then
  176. printf 'error: loop directory already populated: %s (use --force to overwrite)\n' "$TARGET_DIR" >&2
  177. exit "$EX_PRECOND"
  178. fi
  179. NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
  180. # ── render config from template ─────────────────────────────────────────────
  181. # Line-anchored sed substitutions: identity placeholders globally, the three
  182. # tunable scalar lines by their default value. Kill-switch path carries <loop-name>.
  183. render_config() {
  184. sed -E \
  185. -e "s|<loop-name>|$NAME|g" \
  186. -e "s|<pattern-key>|$PATTERN|" \
  187. -e "s|^tier: L1|tier: $TIER|" \
  188. -e "s|^cadence: 1h|cadence: $CADENCE|" \
  189. -e "s|^permission_mode: dontAsk|permission_mode: $PMODE|" \
  190. "$CFG_TPL"
  191. }
  192. render_state() {
  193. sed -E \
  194. -e "s|<loop-name>|$NAME|g" \
  195. -e "s|<ISO-8601 Z>|$NOW|" \
  196. "$STATE_TPL"
  197. }
  198. render_log() {
  199. cat <<EOF
  200. # $NAME — run log (append-only; one line per run)
  201. # format: <ISO-Z> run#N action=<reported|proposed|none> <key=val…> outcome=<…> tokens=<N>
  202. EOF
  203. }
  204. render_run() {
  205. sed -E \
  206. -e "s|<loop-name>|$NAME|g" \
  207. -e "s|tier <L1\\|L2\\|L3>|tier $TIER|g" \
  208. "$RUN_TPL"
  209. }
  210. # The runner-agnostic tick wrapper any scheduler invokes (cron / Task Scheduler /
  211. # systemd / process-compose / by hand) — no GitHub Actions required.
  212. render_run_sh() {
  213. sed -E \
  214. -e "s|<loop-name>|$NAME|g" \
  215. -e "s|<permission-mode>|$PMODE|g" \
  216. "$RUN_SH_TPL"
  217. }
  218. # Seeded config for a known pattern. L1 stays report-only (gate fields are a
  219. # commented graduation block); L2/L3 emit verify/guard/worktree/land_via — using
  220. # the pattern's gate if it has one, else a <fill:…> placeholder the audit will flag.
  221. render_seeded_config() {
  222. cat <<EOF
  223. # loop.config.yaml - $PATTERN (seeded by loop-scaffold at $TIER; REVIEW before scheduling)
  224. # Full field semantics: skills/loop-ops/references/state-spine.md
  225. name: $NAME
  226. pattern: $PATTERN
  227. tier: $TIER
  228. permission_mode: $PMODE
  229. cadence: $CADENCE
  230. goal: "$GOAL_SEED"
  231. scope:
  232. - "$SCOPE_SEED"
  233. escalation: "$ESCAL_SEED"
  234. budget_tokens: ${BUDGET_SEED:-200000}
  235. kill_switch: ".loops/$NAME/PAUSED exists, OR the loop-pause label is set"
  236. EOF
  237. if [[ "$TIER" == "L1" ]]; then
  238. cat <<EOF
  239. # ── graduate to L2 (assisted): set tier: L2, uncomment + fill, re-run loop-check + loop-doctor --live ──
  240. # verify: "${VERIFY_SEED:-<fill: the gate command, e.g. npm test>}"
  241. # guard: "${GUARD_SEED:-<fill: a must-always-pass command>}"
  242. # worktree: true
  243. # land_via: fleet-ops
  244. EOF
  245. else
  246. cat <<EOF
  247. verify: "${VERIFY_SEED:-<fill: the gate command for this loop>}"
  248. guard: "${GUARD_SEED:-<fill: a must-always-pass command>}"
  249. worktree: true
  250. land_via: fleet-ops
  251. EOF
  252. fi
  253. }
  254. # Pick the seeded renderer for a known pattern, else the generic template.
  255. emit_config() { if [[ "$SEEDED" -eq 1 ]]; then render_seeded_config; else render_config; fi; }
  256. # ── dry-run: print and stop ─────────────────────────────────────────────────
  257. if [[ "$DRY_RUN" -eq 1 ]]; then
  258. printf '%s\n' "$CFG_OUT"
  259. {
  260. term_panel_open loop "loop ${TERM_DOT} init (dry-run)" "$NAME"
  261. term_panel_vert
  262. term_status_row skip "would create $TARGET_DIR/" "tier $TIER ${TERM_DOT} $PATTERN ${TERM_DOT} $CADENCE"
  263. term_status_row skip " loop.config.yaml" "permission_mode: $PMODE"
  264. term_status_row skip " STATE.md / run-log.md / run.md / loop-run.sh" ""
  265. term_panel_vert
  266. term_panel_close "nothing written" ""
  267. } >&2
  268. emit_config
  269. exit "$EX_OK"
  270. fi
  271. # ── atomic writes ───────────────────────────────────────────────────────────
  272. mkdir -p "$TARGET_DIR" || { printf 'error: could not create %s\n' "$TARGET_DIR" >&2; exit 1; }
  273. write_atomic() { # write_atomic <dest> <content>
  274. local dest="$1" content="$2" tmp
  275. tmp="$dest.tmp.$$"
  276. printf '%s\n' "$content" > "$tmp" || { printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; }
  277. mv -f "$tmp" "$dest" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$dest" >&2; exit 1; }
  278. }
  279. write_atomic "$CFG_OUT" "$(emit_config)"
  280. write_atomic "$STATE_OUT" "$(render_state)"
  281. write_atomic "$LOG_OUT" "$(render_log)"
  282. write_atomic "$RUN_OUT" "$(render_run)"
  283. write_atomic "$RUN_SH_OUT" "$(render_run_sh)"
  284. chmod +x "$RUN_SH_OUT" 2>/dev/null || true
  285. printf '%s\n' "$CFG_OUT"
  286. {
  287. term_panel_open loop "loop ${TERM_DOT} init" "$NAME"
  288. term_panel_vert
  289. term_status_row ok "created $TARGET_DIR/" "tier $TIER ${TERM_DOT} $PATTERN ${TERM_DOT} $CADENCE"
  290. term_status_row ok " loop.config.yaml" "permission_mode: $PMODE"
  291. term_status_row ok " STATE.md / run-log.md / run.md / loop-run.sh" ""
  292. if [[ "$TIER" != "L1" ]]; then
  293. term_alert warning "tier $TIER needs a verify gate, guard, worktree, escalation + land_via — fill them before auditing"
  294. fi
  295. term_panel_vert
  296. term_panel_close "then: fill the config ${TERM_DOT} loop-check.sh $CFG_OUT" ""
  297. } >&2
  298. exit "$EX_OK"