Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

## 0.23.0 — 2026-05-29

### fix+feat: gitignore-aware, public-repo-aware, non-destructive bootstrap (issue #67)

Found dogfooding v0.22.0 on existing real repos (stimulus, broomva.tech): bootstrap is built for *fresh* workspaces and did two unsafe things + lacked one needed capability on repos with their own hooks/CI/gitignore.

### Fixed

- **`install-l3-stability.sh` no longer clobbers a TRACKED `.githooks/pre-commit`.** On a repo with a committed pre-commit (e.g. broomva.tech's multimedia-asset validator), bootstrap overwrote it with the bstack L3 rate-gate hook — moving the original to `.pre-commit.local` — **even when `core.hooksPath` ≠ `.githooks`**, so the bstack hook never even fired. Now: if the hook is git-tracked, **skip + warn** (the committed hook is authoritative; `--force` still allows the move-aside). An *untracked* hook is still preserved as `.pre-commit.local` as before.

### Added

- **`bootstrap.sh` Phase 2.6 — gitignore reconciliation + public-repo advisory.** Reconciles `.gitignore` against the committable-vs-machine-local manifest:
- machine-local telemetry (`.control/audit/*.jsonl`) → auto-added to `.gitignore` if missing.
- committable substrate (`.control/arcs.yaml`, `rcs-parameters.toml`, `policy.yaml`) → **warns** if gitignored (a coverage gap — the loop won't survive a fresh clone), never auto-un-ignores (publishing a maybe-private file is the human's call).
- **public-repo advisory** (via `gh repo view --json visibility`, graceful if absent): on a PUBLIC remote, surfaces that scaffolded governance is committable-and-public so the operator reviews for secrets before pushing.
- `tests/gitignore-aware-bootstrap.test.sh` — 5 assertions covering both fixes (tracked-hook preserved, untracked-hook sidecar still works, audit-glob added, committable-ignored warning). 5/5.

### Notes

- Additive + non-blocking: Phase 2.6 skips gracefully on a non-git workspace; canary 14/14 unaffected.
- The manual handling on stimulus (#1811) + broomva.tech (broomva.tech#211) is the spec this automates.
- `VERSION` 0.22.0 → 0.23.0.

## 0.22.0 — 2026-05-28

### feat: wire the RCS control loop on `bstack bootstrap` (close the split-brain)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.22.0
0.23.0
56 changes: 56 additions & 0 deletions scripts/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,62 @@ scaffold_governance_file ".control/arcs.yaml" "arcs.yaml.template"

echo " scaffolded: $scaffolded | preserved: $preserved"

# ─── Phase 2.6: gitignore reconciliation + public-repo advisory ────────────
# The control loop splits into two file classes (the committable-vs-machine-local
# manifest). This phase reconciles .gitignore against it:
# - machine-local (paths/telemetry) → MUST be ignored; add if missing.
# - committable (team-wide, no secrets) → must NOT be ignored; WARN (don't
# auto-un-ignore — un-ignoring a deliberately-private file is the human's call).
# Plus a public-repo advisory: on a public remote, committable governance becomes
# public — surface that, don't decide it silently. Never blocks.
echo ""
echo "=== bstack gitignore reconciliation ==="
GITIGNORE="$WORKSPACE_DIR/.gitignore"
# Machine-local globs that should always be ignored (absolute paths / runtime telemetry).
LOCAL_GLOBS=(".control/audit/*.jsonl")
# Committable substrate the loop needs — warn if a repo ignores these.
COMMITTABLE_FILES=(".control/arcs.yaml" ".control/rcs-parameters.toml" ".control/policy.yaml")
Comment on lines +168 to +171
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Also reconcile .claude/settings.json as machine-local.

Phase 2.6 only adds .control/audit/*.jsonl, but the machine-local set for this flow also includes .claude/settings.json. Without adding it here, bootstrap still leaves a user-specific settings file committable in a fresh repo.

Suggested fix
-LOCAL_GLOBS=(".control/audit/*.jsonl")
+LOCAL_GLOBS=(".claude/settings.json" ".control/audit/*.jsonl")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Machine-local globs that should always be ignored (absolute paths / runtime telemetry).
LOCAL_GLOBS=(".control/audit/*.jsonl")
# Committable substrate the loop needs — warn if a repo ignores these.
COMMITTABLE_FILES=(".control/arcs.yaml" ".control/rcs-parameters.toml" ".control/policy.yaml")
# Machine-local globs that should always be ignored (absolute paths / runtime telemetry).
LOCAL_GLOBS=(".claude/settings.json" ".control/audit/*.jsonl")
# Committable substrate the loop needs — warn if a repo ignores these.
COMMITTABLE_FILES=(".control/arcs.yaml" ".control/rcs-parameters.toml" ".control/policy.yaml")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bootstrap.sh` around lines 168 - 171, The LOCAL_GLOBS array currently
contains only ".control/audit/*.jsonl" and should also include
".claude/settings.json" so user-specific Claude settings are treated as
machine-local; update the LOCAL_GLOBS definition (the LOCAL_GLOBS variable) to
add the ".claude/settings.json" entry alongside ".control/audit/*.jsonl" so the
bootstrap flow ignores that file as intended.


if command -v git >/dev/null 2>&1 && git -C "$WORKSPACE_DIR" rev-parse --git-dir >/dev/null 2>&1; then
# Ensure machine-local globs are ignored (append the missing ones).
_added_ignores=()
for glob in "${LOCAL_GLOBS[@]}"; do
if ! git -C "$WORKSPACE_DIR" check-ignore -q "${glob/\*/x}" 2>/dev/null; then
_added_ignores+=("$glob")
fi
done
if [ ${#_added_ignores[@]} -gt 0 ]; then
{
echo ""
echo "# bstack RCS control-loop machine-local telemetry (not committed)"
for g in "${_added_ignores[@]}"; do echo "$g"; done
} >> "$GITIGNORE"
echo " [ignore] added ${#_added_ignores[@]} machine-local glob(s) to .gitignore: ${_added_ignores[*]}"
else
echo " [ok] machine-local telemetry already ignored"
fi

# Warn if committable substrate is ignored (coverage gap — the loop needs these committed).
for f in "${COMMITTABLE_FILES[@]}"; do
if git -C "$WORKSPACE_DIR" check-ignore -q "$f" 2>/dev/null; then
echo " [warn] $f is gitignored but the loop needs it committed."
echo " → un-ignore it (after confirming it carries no secrets) so the control loop survives a fresh clone."
fi
done

# Public-repo advisory (graceful if gh missing or not a GitHub remote).
if command -v gh >/dev/null 2>&1; then
_vis=$(gh repo view --json visibility --jq .visibility 2>/dev/null || true)
if [ "$_vis" = "PUBLIC" ]; then
Comment on lines +201 to +203
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate and print the relevant section of scripts/bootstrap.sh (around lines 180-230)
if [ ! -f scripts/bootstrap.sh ]; then
  echo "Missing scripts/bootstrap.sh"
  exit 1
fi

echo "== scripts/bootstrap.sh (lines 160-240) =="
nl -ba scripts/bootstrap.sh | sed -n '160,240p'

# 2) Print where WORKSPACE_DIR is defined and how it's used (if present)
echo
echo "== WORKSPACE_DIR occurrences =="
rg -n "WORKSPACE_DIR" scripts/bootstrap.sh || true

# 3) Detect any prior cd into workspace (before the gh call)
echo
echo "== cd commands before the gh call section =="
# Print lines up to ~230 and look for cd
nl -ba scripts/bootstrap.sh | sed -n '1,240p' | rg -n "^[[:space:]]*[0-9]+[[:space:]]+cd[[:space:]]" || true

Repository: broomva/bstack

Length of output: 163


🌐 Web query:

gh repo view no repository argument targets current directory behavior

💡 Result:

When you run the command gh repo view without any arguments, the GitHub CLI automatically detects and displays the repository associated with your current working directory [1]. The command uses your local Git configuration to identify the remote repository corresponding to your current directory and retrieves its information—such as the owner, repository name, description, and README—from GitHub [1]. If the current directory is not part of a Git repository, the command will be unable to identify the target and will return an error [2]. Key details regarding this behavior: - Scope: It defaults to the repository linked to the current directory [1]. - Default Branch: By default, gh repo view displays information from the repository's default branch [3]. - No Arguments: Providing no arguments is the standard way to view the repository associated with your current project context, avoiding the need to manually type the owner/repo path [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Show scripts/bootstrap.sh around lines 190-215 (avoid nl) =="
if [ ! -f scripts/bootstrap.sh ]; then
  echo "Missing scripts/bootstrap.sh"
  exit 1
fi

# Print with line numbers via awk
awk 'NR>=190 && NR<=215 { printf "%5d  %s\n", NR, $0 }' scripts/bootstrap.sh

echo
echo "== Find gh repo view usages =="
rg -n "gh repo view" scripts/bootstrap.sh || true

echo
echo "== Find WORKSPACE_DIR definition/usage =="
rg -n "WORKSPACE_DIR" scripts/bootstrap.sh || true

echo
echo "== List cd commands before/near the gh repo view call =="
# Print first 250 lines with grep for "cd " lines
awk 'NR<=250 {print NR ":" $0}' scripts/bootstrap.sh | rg -n "^[0-9]+:.*\bcd\b" || true

Repository: broomva/bstack

Length of output: 2878


Resolve visibility from $WORKSPACE_DIR, not the caller’s cwd.

gh repo view with no repository argument uses the current working directory; since this script’s WORKSPACE_DIR can be overridden via BROOMVA_WORKSPACE but the advisory doesn’t cd into it (unlike other git -C "$WORKSPACE_DIR" calls), the PUBLIC/private advisory can come from the wrong repo. (cli.github.com)

Suggested fix
-        _vis=$(gh repo view --json visibility --jq .visibility 2>/dev/null || true)
+        _vis=$(cd "$WORKSPACE_DIR" && gh repo view --json visibility --jq .visibility 2>/dev/null || true)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if command -v gh >/dev/null 2>&1; then
_vis=$(gh repo view --json visibility --jq .visibility 2>/dev/null || true)
if [ "$_vis" = "PUBLIC" ]; then
if command -v gh >/dev/null 2>&1; then
_vis=$(cd "$WORKSPACE_DIR" && gh repo view --json visibility --jq .visibility 2>/dev/null || true)
if [ "$_vis" = "PUBLIC" ]; then
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bootstrap.sh` around lines 201 - 203, The gh check is running in the
caller's cwd instead of the intended workspace, so change the gh invocation to
run from the workspace directory (use the existing
WORKSPACE_DIR/BROOMVA_WORKSPACE variable) — e.g. run gh repo view inside a
subshell that cds into "$WORKSPACE_DIR" (or use an equivalent cd && gh pattern)
so the gh repo view --json visibility --jq .visibility call resolves the correct
repository.

echo " [advisory] PUBLIC repo — scaffolded governance (CLAUDE.md/AGENTS.md/METALAYER.md/.control/*)"
echo " is committable and will be PUBLIC. Review for secrets before pushing; keep"
echo " machine-local files (.claude/settings.json, .control/audit/*.jsonl) ignored."
fi
fi
else
echo " [skip] not a git repo — gitignore reconciliation skipped"
fi

# ─── Phase 3: wire missing hooks into .claude/settings.json ────────────────
# Idempotent: never overwrites existing hook entries. Only adds missing ones.
echo ""
Expand Down
43 changes: 29 additions & 14 deletions scripts/install-l3-stability.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,22 +83,37 @@ do_install "$BSTACK_REPO/assets/templates/rcs-parameters.toml.template" \

# ── 2. Deploy git pre-commit hook ───────────────────────────────────────────
echo "2. Git pre-commit hook (.githooks/pre-commit)"
# If a pre-commit hook exists and is NOT ours, preserve it as .local sidecar
PRE_COMMIT="$WORKSPACE/.githooks/pre-commit"
if [ -f "$PRE_COMMIT" ] && [ "$FORCE" = "0" ]; then
if grep -q "L3 rate gate" "$PRE_COMMIT" 2>/dev/null; then
echo " [skip] .githooks/pre-commit (already bstack L3 hook)"
SKIPPED=$((SKIPPED + 1))
else
# Preserve existing as .local
echo " [info] existing .githooks/pre-commit found — preserving as .pre-commit.local"
if [ "$DRY_RUN" = "0" ]; then
mv "$PRE_COMMIT" "$WORKSPACE/.githooks/pre-commit.local"
chmod +x "$WORKSPACE/.githooks/pre-commit.local"
fi
do_install "$BSTACK_REPO/assets/templates/githook-pre-commit-l3-rate.sh.template" \
"$PRE_COMMIT" "755"
if [ -f "$PRE_COMMIT" ] && grep -q "L3 rate gate" "$PRE_COMMIT" 2>/dev/null; then
# Already our hook — idempotent no-op (even under --force; reinstalling
# identical content is pointless).
echo " [skip] .githooks/pre-commit (already bstack L3 hook)"
SKIPPED=$((SKIPPED + 1))
Comment on lines +87 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Honor --force when the existing file is already the L3 hook.

This branch skips solely on the marker string, so a workspace that already has the L3 hook can never receive template updates via --force. That breaks the script’s overwrite contract and leaves existing installs stuck on older hook content.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-l3-stability.sh` around lines 87 - 91, The current skip
branch checks only for the "L3 rate gate" marker and prevents overwriting even
when the script was invoked with --force; update the conditional that guards the
skip so it respects the force flag (e.g., a parsed variable like FORCE or
FORCE_FLAG). Concretely, modify the if in the pre-commit check (the block
referencing PRE_COMMIT, "L3 rate gate", SKIPPED) to also require that the force
flag is not set (for example: && [ "$FORCE" != "1" ] or similar) before treating
the hook as idempotent; this preserves the skip behavior normally but allows
--force to overwrite existing L3 hook content.

elif [ -f "$PRE_COMMIT" ] \
&& (cd "$WORKSPACE" && git ls-files --error-unmatch .githooks/pre-commit >/dev/null 2>&1) \
&& [ "$FORCE" = "0" ]; then
# TRACKED hook + no --force → NEVER clobber. The committed pre-commit is
# authoritative; overwriting it destroys a tracked file. Skip + warn.
# (Bug found dogfooding on a repo with a tracked .githooks/pre-commit: the
# repo's own hook got replaced even though core.hooksPath ≠ .githooks, so
# the L3 hook never fired.) --force routes to the preserve-then-install
# branch below, which DOES create the .pre-commit.local sidecar.
echo " [skip] .githooks/pre-commit is git-tracked — preserving the repo's committed hook"
echo " → to add the L3 rate gate: chain it into the existing hook by hand,"
echo " or re-run with --force (which preserves the current hook as .pre-commit.local)."
SKIPPED=$((SKIPPED + 1))
elif [ -f "$PRE_COMMIT" ]; then
# An existing hook we are about to replace: either an UNTRACKED local hook,
# or a tracked hook under --force. Preserve it as .pre-commit.local first,
# then install ours — so the sidecar recovery path the warning promises
# actually exists in both cases.
echo " [info] existing .githooks/pre-commit found — preserving as .pre-commit.local"
if [ "$DRY_RUN" = "0" ]; then
mv "$PRE_COMMIT" "$WORKSPACE/.githooks/pre-commit.local"
chmod +x "$WORKSPACE/.githooks/pre-commit.local"
Comment on lines +110 to +113
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't overwrite an existing .pre-commit.local sidecar.

mv will clobber any existing .githooks/pre-commit.local, so a later forced install can destroy the previously preserved hook. Please guard this case or write to a unique backup name instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-l3-stability.sh` around lines 110 - 113, The current move of
"$PRE_COMMIT" to "$WORKSPACE/.githooks/pre-commit.local" (inside the DRY_RUN
check) will clobber any existing .pre-commit.local; update the block that runs
when DRY_RUN=0 to detect an existing target and avoid overwrite — e.g., if
"$WORKSPACE/.githooks/pre-commit.local" exists, create a unique backup name
(append a timestamp or incrementing suffix) or fail with a clear message, then
mv "$PRE_COMMIT" to that unique path and chmod +x the new file; reference the
variables/commands PRE_COMMIT, WORKSPACE, .githooks/pre-commit.local, DRY_RUN,
mv and chmod when making the change.

fi
do_install "$BSTACK_REPO/assets/templates/githook-pre-commit-l3-rate.sh.template" \
"$PRE_COMMIT" "755"
else
do_install "$BSTACK_REPO/assets/templates/githook-pre-commit-l3-rate.sh.template" \
"$PRE_COMMIT" "755"
Expand Down
99 changes: 99 additions & 0 deletions tests/gitignore-aware-bootstrap.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# gitignore-aware-bootstrap — covers the v0.23.0 safety fixes (issue #67):
# 1. install-l3-stability.sh never clobbers a TRACKED .githooks/pre-commit
# 2. install-l3-stability.sh still preserves an UNTRACKED pre-commit as .local
# 3. bootstrap.sh adds machine-local audit-log glob to .gitignore (real git repo)
# 4. bootstrap.sh warns when committable substrate is gitignored (coverage gap)

set -uo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0; FAIL=0; FAILED=()
pass() { echo " [ok] $1"; PASS=$((PASS + 1)); }
fail() { echo " [FAIL] $1"; FAIL=$((FAIL + 1)); FAILED+=("$1"); }

echo "── gitignore-aware-bootstrap ──────────────────────────"

# ── 1. TRACKED pre-commit is preserved (not clobbered) ──────────────────────
TW=$(mktemp -d)
(
cd "$TW" && git init -q
mkdir -p .githooks
printf '#!/bin/bash\n# repo multimedia validator\n' > .githooks/pre-commit
chmod +x .githooks/pre-commit
git add .githooks/pre-commit && git -c user.email=t@t -c user.name=t commit -q -m "hook"
)
BROOMVA_WORKSPACE="$TW" bash "$REPO/scripts/install-l3-stability.sh" >/dev/null 2>&1
if grep -q "multimedia validator" "$TW/.githooks/pre-commit"; then
pass "tracked .githooks/pre-commit preserved (not clobbered)"
else
fail "tracked .githooks/pre-commit was clobbered"
fi
if [ ! -f "$TW/.githooks/pre-commit.local" ]; then
pass "no .pre-commit.local created for tracked hook (default)"
else
fail ".pre-commit.local was created for a tracked hook (default)"
fi

# ── 1b. --force on a TRACKED hook DOES preserve it as .pre-commit.local ──────
BROOMVA_WORKSPACE="$TW" bash "$REPO/scripts/install-l3-stability.sh" --force >/dev/null 2>&1
if grep -q "L3 rate gate" "$TW/.githooks/pre-commit" 2>/dev/null \
&& grep -q "multimedia validator" "$TW/.githooks/pre-commit.local" 2>/dev/null; then
pass "--force preserves tracked hook as .pre-commit.local, then installs L3 hook"
else
fail "--force did NOT create the promised .pre-commit.local sidecar"
fi
rm -rf "$TW"

# ── 2. UNTRACKED pre-commit IS preserved as sidecar, ours installed ─────────
TW=$(mktemp -d)
(
cd "$TW" && git init -q
mkdir -p .githooks
printf '#!/bin/bash\n# untracked local hook\n' > .githooks/pre-commit
chmod +x .githooks/pre-commit
)
BROOMVA_WORKSPACE="$TW" bash "$REPO/scripts/install-l3-stability.sh" >/dev/null 2>&1
if grep -q "L3 rate gate" "$TW/.githooks/pre-commit" 2>/dev/null \
&& grep -q "untracked local hook" "$TW/.githooks/pre-commit.local" 2>/dev/null; then
pass "untracked hook moved to .pre-commit.local, L3 hook installed"
else
fail "untracked-hook preservation regressed"
fi
rm -rf "$TW"

# ── 3 + 4. bootstrap gitignore reconciliation on a real git repo ────────────
TW=$(mktemp -d); TH=$(mktemp -d)
( cd "$TW" && git init -q )
HOME="$TH" BROOMVA_WORKSPACE="$TW" BSTACK_SKIP_SKILLS=1 BSTACK_SKIP_RCS=1 \
BROOMVA_STATE_DIR="$TH/.cfg" bash "$REPO/scripts/bootstrap.sh" >/dev/null 2>&1
if grep -q "control/audit/\*.jsonl" "$TW/.gitignore" 2>/dev/null; then
pass "bootstrap added machine-local audit-log glob to .gitignore"
else
fail "bootstrap did not add audit-log glob to .gitignore"
fi
# committable-ignored warning
echo ".control/arcs.yaml" >> "$TW/.gitignore"
out=$(HOME="$TH" BROOMVA_WORKSPACE="$TW" BSTACK_SKIP_SKILLS=1 BSTACK_SKIP_RCS=1 \
BROOMVA_STATE_DIR="$TH/.cfg" bash "$REPO/scripts/bootstrap.sh" 2>&1)
if echo "$out" | grep -q "arcs.yaml is gitignored but the loop needs it"; then
pass "bootstrap warns when committable substrate is gitignored"
else
fail "no warning for gitignored committable substrate"
fi
# idempotency: the audit-log glob appears exactly once after repeated runs
n=$(grep -c "^\.control/audit/\*\.jsonl$" "$TW/.gitignore" 2>/dev/null || echo 0)
if [ "$n" -eq 1 ]; then
pass "audit-log glob is idempotent (single .gitignore entry after 2 runs)"
else
fail "audit-log glob duplicated in .gitignore ($n entries)"
fi
rm -rf "$TW" "$TH"

echo "─────────────────────────────────────"
echo " Passed: $PASS"
echo " Failed: $FAIL"
if [ "$FAIL" -gt 0 ]; then
for t in "${FAILED[@]}"; do echo " - $t"; done
exit 1
fi
echo " gitignore-aware-bootstrap passed."
Loading