diff --git a/CHANGELOG.md b/CHANGELOG.md index d54294d..343e337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 0.21.7 — 2026-05-26 + +### `bstack skills graduate` — crystallized Tier-2 migration (Phase 6b) + +New subcommand `bstack skills graduate ` that automates the Tier-2 skill-graduation pattern which ran **8 times manually** on 2026-05-25/26 (Phases 2–4f: strategy bundle, content ×2, research+finance, specialty, neuroscience, orcahand dedup). This is the P16 crystallization of that pattern — rule-of-three exceeded ~8× over. + +What it automates: +1. Clone source repo + monorepo into temp worktrees +2. Copy canonical content into `skills//` — excluding `.git`, dot-prefixed IDE-mirror dirs, `LICENSE`, `skills-lock.json` (the exact exclusion set learned across the 8 manual runs) +3. Append a row to the monorepo README Tier-2 table +4. Commit + push + open PR on the monorepo +5. `--stub` (default ON): add a redirect-stub README to the source repo + open PR +6. `--merge` (default OFF): merge the opened PRs +7. Cleanup temp clones (trap on EXIT) + +Supports `--target` (rename, e.g. drop `-skill` suffix), `--source-repo`, `--monorepo`, `--category`, `--description`, `--exclude` (repeatable), `--dry-run`. + +The registry update (companion-skills.yaml + skills-roster.md + VERSION + CHANGELOG) is intentionally NOT automated — it needs a coordinated bstack version bump a human/agent reviews. The script PRINTS the exact registry entry to add (copy-paste ready). + +### Files + +- **NEW** `scripts/skill-graduate.sh` — the graduation engine (~230 lines). Env-overridable `BSTACK_GRADUATE_GH` / `BSTACK_GRADUATE_GIT` / `BSTACK_GRADUATE_TMPDIR` / `BSTACK_GRADUATE_DRY_RUN` for hermetic testing. +- **NEW** `tests/skill-graduate.test.sh` — 9-test offline smoke (arg-parse, dry-run, rename detection, stub-gh/git execution path with copy + exclude verification, no-SKILL.md error). All 9 pass. +- **CHANGED** `bin/bstack-skills` — adds `graduate)` dispatch + usage entry. +- **CHANGED** `SKILL.md` + `bin/bstack` — advertise the subcommand. +- **VERSION** `0.21.6` → `0.21.7`. + +### Provenance + +The pattern's 8 manual instances are the audit trail (broomva/skills PRs #2–#9 + broomva/bstack PRs #53–#59). Crystallization closes the loop: the next graduation is `bstack skills graduate ` instead of a full manual clone→copy→README→PR→stub→registry cycle. + +--- + + ## 0.21.6 — 2026-05-26 ### Phase 4f orcahand migration + dedup (final Tier-2 migration) diff --git a/VERSION b/VERSION index 78cfa5e..aa53fc8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.6 +0.21.7 diff --git a/bin/bstack b/bin/bstack index bc2f4d2..99944d0 100755 --- a/bin/bstack +++ b/bin/bstack @@ -43,6 +43,8 @@ Observability: status [--json|--setpoint X] Substrate health summary (≥ 0.5.0). status --aggregate Federation rollup across registered workspaces (≥ 0.18.0). skills install|status|list Companion skill roster manager (≥ 0.7.0). + skills graduate Migrate a standalone skill repo into the + broomva/skills Tier-2 monorepo (≥ 0.21.7). crystallize candidates|promote P16 rule-of-three candidate detector (≥ 0.9.5). bench run|compare|tasks|status Skill-evolution benchmark harness (≥ 0.10.0). diff --git a/bin/bstack-skills b/bin/bstack-skills index 764609c..999bcbe 100755 --- a/bin/bstack-skills +++ b/bin/bstack-skills @@ -46,6 +46,9 @@ Subcommands: Install missing (or all) skills via npx status [--json] Show installed/missing roster summary list [--json] [--required-only] List declared roster + graduate [options] Migrate a standalone broomva/ skill repo + into the broomva/skills Tier-2 monorepo + (≥ 0.21.7). Run `bstack skills graduate --help`. help | --help This message Examples: @@ -53,6 +56,7 @@ Examples: bstack skills install --required-only bstack skills install --dry-run bstack skills status --json + bstack skills graduate handoff --category lifecycle --dry-run EOF } @@ -266,6 +270,7 @@ case "${1:-}" in install) shift; cmd_install "$@" ;; status) shift; cmd_status "$@" ;; list) shift; cmd_list "$@" ;; + graduate) shift; exec "$BSTACK_DIR/scripts/skill-graduate.sh" "$@" ;; -h|--help|help|"") usage ;; *) echo "bstack-skills: unknown subcommand '$1'" >&2 diff --git a/scripts/skill-graduate.sh b/scripts/skill-graduate.sh new file mode 100755 index 0000000..1392431 --- /dev/null +++ b/scripts/skill-graduate.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# skill-graduate.sh — crystallized Tier-2 skill-graduation pattern (v0.21.7). +# +# Migrates a standalone `broomva/` skill repo into the `broomva/skills` +# Tier-2 monorepo, following the Phase 2-4f migration pattern that ran 8 times +# manually on 2026-05-25/26 (strategy bundle + content + research + finance + +# specialty + neuroscience + orcahand dedup). This script is the crystallization +# of that repeated pattern (P16 — rule-of-three exceeded ~8x over). +# +# Invoked as: `bstack skills graduate [options]` +# +# The pattern, automated: +# 1. Clone source repo + monorepo into temp worktrees +# 2. Copy canonical content into monorepo skills// — EXCLUDING +# .git, dot-prefixed IDE-mirror dirs, LICENSE, skills-lock.json +# 3. Commit + push branch + open PR on the monorepo +# 4. (--stub) add redirect-stub README to the source repo + open PR +# 5. (--merge) merge the opened PR(s) [default: leave open for review] +# 6. Cleanup temp clones (always, via trap) +# +# NOT automated (printed for manual follow-up, since both need human judgment): +# - The monorepo README Tier-2 table row — category placement varies; the +# script prints a ready-to-paste row instead of guessing the section. +# - The bstack registry (companion-skills.yaml + skills-roster.md + VERSION +# + CHANGELOG) — needs a coordinated version bump a human/agent reviews. +# Both are printed copy-paste-ready in the closing "NEXT" block. +# +# Env overrides (test fixtures use these to avoid network): +# BSTACK_GRADUATE_GH gh command (default: gh) +# BSTACK_GRADUATE_GIT git command (default: git) +# BSTACK_GRADUATE_TMPDIR temp root (default: mktemp -d) +# BSTACK_GRADUATE_DRY_RUN force dry-run (default: 0) +set -euo pipefail + +GH="${BSTACK_GRADUATE_GH:-gh}" +GIT="${BSTACK_GRADUATE_GIT:-git}" + +usage() { + cat <<'EOF' +bstack skills graduate — migrate a standalone skill repo into broomva/skills monorepo + +Usage: + bstack skills graduate [options] + +Arguments: + Source skill name (the broomva/ repo) + +Options: + --target Rename during migration (e.g. drop -skill suffix). + Default: same as . + --source-repo Source GitHub repo. Default: broomva/. + --monorepo Destination monorepo. Default: broomva/skills. + --category Registry category (for the printed registry entry). + One of: lifecycle knowledge orchestration safety meta + design platform strategy content observability. + --description One-line description (README cell + registry entry). + --stub Add redirect-stub README to the source repo (default ON). + --no-stub Skip the source redirect-stub. + --merge Merge opened PR(s) after opening (default: leave open). + --exclude Extra exclude pattern (repeatable). Defaults always + exclude: .git, .* (dot dirs), LICENSE, skills-lock.json. + --dry-run Print the plan; make no clones, commits, or PRs. + -h | --help This message. + +Examples: + bstack skills graduate handoff --category lifecycle \ + --description "Fresh-session handoff doc drafting" + + bstack skills graduate omnivoice-skill --target omnivoice --category content \ + --description "OmniVoice Studio — TTS, voice cloning, dubbing in 646 languages" + + bstack skills graduate pre-mortem --category strategy --no-stub --dry-run +EOF +} + +# ---- defaults ---- +NAME="" +TARGET="" +SOURCE_REPO="" +MONOREPO="broomva/skills" +CATEGORY="" +DESCRIPTION="" +DO_STUB=1 +DO_MERGE=0 +DRY_RUN="${BSTACK_GRADUATE_DRY_RUN:-0}" +EXTRA_EXCLUDES=() + +# ---- arg parsing ---- +if [ $# -eq 0 ]; then usage >&2; exit 2; fi +while [ $# -gt 0 ]; do + case "$1" in + --target) TARGET="${2:?--target needs a value}"; shift 2 ;; + --source-repo) SOURCE_REPO="${2:?--source-repo needs a value}"; shift 2 ;; + --monorepo) MONOREPO="${2:?--monorepo needs a value}"; shift 2 ;; + --category) CATEGORY="${2:?--category needs a value}"; shift 2 ;; + --description) DESCRIPTION="${2:?--description needs a value}"; shift 2 ;; + --stub) DO_STUB=1; shift ;; + --no-stub) DO_STUB=0; shift ;; + --merge) DO_MERGE=1; shift ;; + --exclude) EXTRA_EXCLUDES+=("${2:?--exclude needs a value}"); shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help|help) usage; exit 0 ;; + -*) echo "skill-graduate: unknown option: $1" >&2; usage >&2; exit 2 ;; + *) + if [ -z "$NAME" ]; then NAME="$1"; shift + else echo "skill-graduate: unexpected argument: $1" >&2; exit 2; fi ;; + esac +done + +[ -n "$NAME" ] || { echo "skill-graduate: is required" >&2; usage >&2; exit 2; } +TARGET="${TARGET:-$NAME}" +SOURCE_REPO="${SOURCE_REPO:-broomva/$NAME}" + +# Validate target name against agentskills.io spec (lowercase, hyphens, <=64). +if ! printf '%s' "$TARGET" | grep -qE '^[a-z][a-z0-9-]{0,63}$'; then + echo "skill-graduate: invalid target name '$TARGET' (must match ^[a-z][a-z0-9-]{0,63}\$)" >&2 + exit 2 +fi + +# Determine if this is a rename. +RENAME_NOTE="" +[ "$NAME" != "$TARGET" ] && RENAME_NOTE=" (renamed: $NAME -> $TARGET)" + +echo "skill-graduate plan:" +echo " source repo : $SOURCE_REPO" +echo " monorepo : $MONOREPO" +echo " skill path : skills/$TARGET/$RENAME_NOTE" +echo " category : ${CATEGORY:-}" +echo " redirect-stub : $([ "$DO_STUB" = 1 ] && echo yes || echo no)" +echo " auto-merge : $([ "$DO_MERGE" = 1 ] && echo yes || echo no)" +echo " excludes : .git .* LICENSE skills-lock.json ${EXTRA_EXCLUDES[*]:-}" +echo "" + +if [ "$DRY_RUN" = 1 ]; then + echo "[dry-run] No clones, commits, or PRs will be made." + echo "[dry-run] Registry entry to add to broomva/bstack references/companion-skills.yaml:" + cat </dev/null | grep -q '[0-9]'; then + echo "skill-graduate: an open PR for branch '$BRANCH' already exists on $MONOREPO." >&2 + echo " Close/merge it first, or pass --target to use a different skill name." >&2 + exit 1 +fi + +echo "==> cloning $MONOREPO + $SOURCE_REPO" +$GH repo clone "$MONOREPO" "$MONO_DIR" -- --depth=10 >/dev/null 2>&1 +$GH repo clone "$SOURCE_REPO" "$SRC_DIR" -- --depth=10 >/dev/null 2>&1 + +echo "==> copying canonical content into skills/$TARGET/" +mkdir -p "$MONO_DIR/skills/$TARGET" +# Build exclude test. Always exclude dot-entries, LICENSE, skills-lock.json. +should_exclude() { + local item="$1" + case "$item" in + .*|LICENSE|skills-lock.json) return 0 ;; + esac + local ex + for ex in "${EXTRA_EXCLUDES[@]:-}"; do + [ -n "$ex" ] && [[ "$item" == $ex ]] && return 0 + done + return 1 +} +copied=0 +# Null-delimited iteration: robust against filenames with spaces, globs, +# or newlines. `ls`-based word-splitting (the obvious naive loop) breaks on +# spaced filenames under `set -e` mid-copy — regression-tested in +# tests/skill-graduate.test.sh T10. +while IFS= read -r -d '' path; do + item="$(basename "$path")" + if should_exclude "$item"; then continue; fi + cp -R "$path" "$MONO_DIR/skills/$TARGET/" + copied=$((copied + 1)) +done < <(find "$SRC_DIR" -mindepth 1 -maxdepth 1 -print0) +echo " copied $copied top-level items ($(find "$MONO_DIR/skills/$TARGET" -type f | wc -l | tr -d ' ') files total)" + +# Sanity: a SKILL.md must exist after copy. +if [ ! -f "$MONO_DIR/skills/$TARGET/SKILL.md" ]; then + echo "skill-graduate: ERROR — no SKILL.md found in skills/$TARGET/ after copy." >&2 + echo " Source $SOURCE_REPO may keep its SKILL.md under a subdir; migrate manually." >&2 + exit 1 +fi + +echo "==> committing + opening PR on $MONOREPO" +( + cd "$MONO_DIR" + $GIT checkout -b "$BRANCH" >/dev/null 2>&1 + $GIT add "skills/$TARGET" >/dev/null 2>&1 + $GIT commit -q -m "feat(monorepo): graduate $TARGET to Tier-2$RENAME_NOTE + +Migrated from $SOURCE_REPO via \`bstack skills graduate\`. +Install: npx skills add $MONOREPO --skill $TARGET" + $GIT push -u origin "$BRANCH" >/dev/null 2>&1 + $GH pr create --base main --head "$BRANCH" \ + --title "feat(monorepo): graduate $TARGET to Tier-2$RENAME_NOTE" \ + --body "Graduated from \`$SOURCE_REPO\` via \`bstack skills graduate\`. Install: \`npx skills add $MONOREPO --skill $TARGET\`.${DESCRIPTION:+ + +$DESCRIPTION}" >/dev/null 2>&1 + if [ "$DO_MERGE" = 1 ]; then + # Try delete-branch first; fall back to plain squash if branch + # deletion is blocked (protected). A genuine merge failure (red CI, + # conflicts) is surfaced, not swallowed. + if ! $GH pr merge "$BRANCH" --squash --delete-branch >/dev/null 2>&1 \ + && ! $GH pr merge "$BRANCH" --squash >/dev/null 2>&1; then + echo " WARNING: auto-merge of monorepo PR failed (CI red, conflicts, or gate) — leaving PR open for manual review." >&2 + fi + fi +) +MONO_PR=$($GH pr list --repo "$MONOREPO" --head "$BRANCH" --state all --json url --jq '.[0].url' 2>/dev/null || echo "(see $MONOREPO PRs)") +echo " monorepo PR: $MONO_PR" + +if [ "$DO_STUB" = 1 ]; then + echo "==> adding redirect-stub on $SOURCE_REPO" + ( + cd "$SRC_DIR" + cat > README.md < **Status:** migrated to the [$MONOREPO](https://github.com/$MONOREPO) monorepo as a Tier-2 vendored skill$RENAME_NOTE. 6-month deprecation window before archival. + +## New install command + +\`\`\`bash +npx skills add $MONOREPO --skill $TARGET +\`\`\` + +## Skill home + +[$MONOREPO/skills/$TARGET](https://github.com/$MONOREPO/tree/main/skills/$TARGET) + +## License + +[MIT](LICENSE) — unchanged. +STUBEOF + $GIT checkout -b chore/deprecate-redirect >/dev/null 2>&1 + $GIT add README.md >/dev/null 2>&1 + $GIT commit -q -m "chore(deprecate): redirect to $MONOREPO monorepo + +Migrated to $MONOREPO/skills/$TARGET via \`bstack skills graduate\`." + $GIT push -u origin chore/deprecate-redirect >/dev/null 2>&1 + $GH pr create --base main --head chore/deprecate-redirect \ + --title "chore(deprecate): redirect to $MONOREPO monorepo" \ + --body "Migrated to \`$MONOREPO/skills/$TARGET\` via \`bstack skills graduate\`. Install: \`npx skills add $MONOREPO --skill $TARGET\`." >/dev/null 2>&1 + if [ "$DO_MERGE" = 1 ]; then + if ! $GH pr merge chore/deprecate-redirect --squash --delete-branch >/dev/null 2>&1 \ + && ! $GH pr merge chore/deprecate-redirect --squash >/dev/null 2>&1; then + echo " WARNING: auto-merge of source redirect-stub PR failed — leaving it open." >&2 + fi + fi + ) + STUB_PR=$($GH pr list --repo "$SOURCE_REPO" --head chore/deprecate-redirect --state all --json url --jq '.[0].url' 2>/dev/null || echo "(see $SOURCE_REPO PRs)") + echo " stub PR: $STUB_PR" +fi + +echo "" +echo "==> NEXT: add this entry to broomva/bstack references/companion-skills.yaml + bump VERSION + CHANGELOG:" +cat < + min_bstack_version: 0.21.0 + description: "${DESCRIPTION:-TODO}" +EOF +echo "" +echo "skill-graduate: done. $([ "$DO_MERGE" = 1 ] && echo 'PRs merged.' || echo 'PRs opened (review + merge when ready).')" diff --git a/tests/skill-graduate.test.sh b/tests/skill-graduate.test.sh new file mode 100755 index 0000000..48cff90 --- /dev/null +++ b/tests/skill-graduate.test.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# tests/skill-graduate.test.sh — Smoke tests for `bstack skills graduate`. +# +# Tests run offline. Arg-parse + dry-run paths make no network calls. +# The execution path is exercised with stub `gh`/`git` (via +# BSTACK_GRADUATE_GH / BSTACK_GRADUATE_GIT) against a fake source tree to +# verify the copy + exclude logic without touching GitHub. +# +# Run from the bstack repo root: +# bash tests/skill-graduate.test.sh + +set -uo pipefail + +BSTACK_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SKILLS_BIN="$BSTACK_REPO/bin/bstack-skills" +GRADUATE_SH="$BSTACK_REPO/scripts/skill-graduate.sh" + +PASS=0 +FAIL=0 +FAILED_TESTS=() + +assert_pass() { PASS=$((PASS + 1)); echo " [pass] $1"; } +assert_fail() { FAIL=$((FAIL + 1)); FAILED_TESTS+=("$1"); echo " [FAIL] $1"; [ -n "${2:-}" ] && echo " $2"; } + +echo "── skill-graduate CLI smoke tests ─────────────────────────────────" + +# T1: bstack-skills usage advertises graduate +t="bstack-skills --help advertises graduate" +if "$SKILLS_BIN" --help 2>&1 | grep -q 'graduate '; then assert_pass "$t"; else assert_fail "$t"; fi + +# T2: graduate --help routes to the script's usage +t="graduate --help routes to script" +if "$SKILLS_BIN" graduate --help 2>&1 | grep -q 'migrate a standalone skill repo'; then assert_pass "$t"; else assert_fail "$t"; fi + +# T3: missing exits non-zero +t="missing exits 2" +"$GRADUATE_SH" --category content >/dev/null 2>&1; rc=$? +if [ "$rc" -eq 2 ]; then assert_pass "$t"; else assert_fail "$t" "expected exit 2, got $rc"; fi + +# T4: invalid target name exits non-zero +t="invalid target name exits 2" +"$GRADUATE_SH" foo --target "Bad_Name" --dry-run >/dev/null 2>&1; rc=$? +if [ "$rc" -eq 2 ]; then assert_pass "$t"; else assert_fail "$t" "expected exit 2, got $rc"; fi + +# T5: unknown option exits non-zero +t="unknown option exits 2" +"$GRADUATE_SH" foo --bogus >/dev/null 2>&1; rc=$? +if [ "$rc" -eq 2 ]; then assert_pass "$t"; else assert_fail "$t" "expected exit 2, got $rc"; fi + +# T6: dry-run prints plan + registry entry, exits 0, makes no network calls +t="dry-run prints plan + registry entry" +out=$("$GRADUATE_SH" myskill --category content --description "Does a thing" --dry-run 2>&1); rc=$? +if [ "$rc" -eq 0 ] && echo "$out" | grep -q 'skillPath: skills/myskill/SKILL.md' && echo "$out" | grep -q 'category: content'; then + assert_pass "$t" +else + assert_fail "$t" "rc=$rc" +fi + +# T7: rename detection in dry-run output +t="rename detection (foo-skill -> foo)" +out=$("$GRADUATE_SH" foo-skill --target foo --dry-run 2>&1) +if echo "$out" | grep -q 'renamed: foo-skill -> foo' && echo "$out" | grep -q 'skillPath: skills/foo/SKILL.md'; then + assert_pass "$t" +else + assert_fail "$t" +fi + +# Shared stub-builder: writes a gh stub (clone copies a fixture; pr/* are no-ops) +# and a no-op git stub into $1/bin. $2 = source fixture dir. +build_stubs() { + local bindir="$1" src="$2" + mkdir -p "$bindir" + cat > "$bindir/gh" < "$bindir/git" <<'STUBGIT' +#!/usr/bin/env bash +exit 0 +STUBGIT + chmod +x "$bindir/gh" "$bindir/git" +} + +# T8: execution path — inspect the COPIED TREE (not just exit code) +t="execution copies canonical content + excludes dot-dirs/LICENSE/lock" +WORK="$(mktemp -d)" +SRC_FIXTURE="$WORK/src" +mkdir -p "$SRC_FIXTURE/references" "$SRC_FIXTURE/.claude" "$SRC_FIXTURE/scripts" +printf -- '---\nname: testskill\n---\nbody\n' > "$SRC_FIXTURE/SKILL.md" +echo "ref" > "$SRC_FIXTURE/references/r.md" +echo "scr" > "$SRC_FIXTURE/scripts/s.sh" +echo "MIT" > "$SRC_FIXTURE/LICENSE" +echo "{}" > "$SRC_FIXTURE/skills-lock.json" +echo "mirror" > "$SRC_FIXTURE/.claude/m.md" +build_stubs "$WORK/bin" "$SRC_FIXTURE" +RUN_TMP="$WORK/run"; mkdir -p "$RUN_TMP" +BSTACK_GRADUATE_GH="$WORK/bin/gh" BSTACK_GRADUATE_GIT="$WORK/bin/git" \ + BSTACK_GRADUATE_TMPDIR="$RUN_TMP" BSTACK_GRADUATE_NO_CLEANUP=1 \ + "$GRADUATE_SH" testskill --category knowledge --no-stub >/dev/null 2>&1; rc=$? +DST="$RUN_TMP/monorepo/skills/testskill" +if [ "$rc" -eq 0 ] \ + && [ -f "$DST/SKILL.md" ] \ + && [ -f "$DST/references/r.md" ] \ + && [ -f "$DST/scripts/s.sh" ] \ + && [ ! -e "$DST/.claude" ] \ + && [ ! -e "$DST/LICENSE" ] \ + && [ ! -e "$DST/skills-lock.json" ]; then + assert_pass "$t" +else + assert_fail "$t" "rc=$rc; tree: $(ls -A "$DST" 2>/dev/null | tr '\n' ' ')" +fi +rm -rf "$WORK" + +# T10: regression — source filename WITH A SPACE must not break the copy loop +t="regression: spaced filename in source copies cleanly (no word-split break)" +WORK="$(mktemp -d)" +SRC_FIXTURE="$WORK/src"; mkdir -p "$SRC_FIXTURE/references" +printf -- '---\nname: spacetest\n---\n' > "$SRC_FIXTURE/SKILL.md" +echo "spaced" > "$SRC_FIXTURE/references/a file with spaces.md" +echo "glob" > "$SRC_FIXTURE/references/star[1].md" +build_stubs "$WORK/bin" "$SRC_FIXTURE" +RUN_TMP="$WORK/run"; mkdir -p "$RUN_TMP" +BSTACK_GRADUATE_GH="$WORK/bin/gh" BSTACK_GRADUATE_GIT="$WORK/bin/git" \ + BSTACK_GRADUATE_TMPDIR="$RUN_TMP" BSTACK_GRADUATE_NO_CLEANUP=1 \ + "$GRADUATE_SH" spacetest --category knowledge --no-stub >/dev/null 2>&1; rc=$? +DST="$RUN_TMP/monorepo/skills/spacetest" +if [ "$rc" -eq 0 ] && [ -f "$DST/references/a file with spaces.md" ] && [ -f "$DST/references/star[1].md" ]; then + assert_pass "$t" +else + assert_fail "$t" "rc=$rc — spaced/glob filename broke the copy loop" +fi +rm -rf "$WORK" + +# T9: execution fails cleanly when source has no SKILL.md +t="execution errors when no SKILL.md in source" +WORK="$(mktemp -d)"; SRC_FIXTURE="$WORK/src"; mkdir -p "$SRC_FIXTURE" +echo "no skill here" > "$SRC_FIXTURE/README.md" +STUB_BIN="$WORK/bin"; mkdir -p "$STUB_BIN" +cat > "$STUB_BIN/gh" < "$STUB_BIN/git" <<'STUBGIT' +#!/usr/bin/env bash +exit 0 +STUBGIT +chmod +x "$STUB_BIN/gh" "$STUB_BIN/git" +BSTACK_GRADUATE_GH="$STUB_BIN/gh" BSTACK_GRADUATE_GIT="$STUB_BIN/git" BSTACK_GRADUATE_TMPDIR="$WORK/run" \ + "$GRADUATE_SH" emptyskill --no-stub >/dev/null 2>&1; rc=$? +if [ "$rc" -eq 1 ]; then assert_pass "$t"; else assert_fail "$t" "expected exit 1 (no SKILL.md), got $rc"; fi +rm -rf "$WORK" + +echo "" +echo "── results: $PASS passed, $FAIL failed ────────────────────────────" +if [ "$FAIL" -gt 0 ]; then + printf ' failed: %s\n' "${FAILED_TESTS[@]}" + exit 1 +fi +exit 0