bigpowers 2.78.1 → 2.79.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.pi/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.78.1",
3
+ "version": "2.79.1",
4
4
  "description": "80 skills — 73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "keywords": [
6
6
  "pi-package"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [2.79.1](https://github.com/danielvm-git/bigpowers/compare/v2.79.0...v2.79.1) (2026-07-23)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **e53:** close migration-readiness gaps — code-injection, expiry semantics, fail-open gate ([4042c62](https://github.com/danielvm-git/bigpowers/commit/4042c62f5ccac9790596f81aac5aaebd1235c763))
7
+
8
+ # [2.79.0](https://github.com/danielvm-git/bigpowers/compare/v2.78.1...v2.79.0) (2026-07-23)
9
+
10
+
11
+ ### Features
12
+
13
+ * **scripts:** implement tombstone-skill.sh and validate-tombstones.sh ([c81a451](https://github.com/danielvm-git/bigpowers/commit/c81a451598d8338188fb9e18b4486e20e6c177f8))
14
+
1
15
  ## [2.78.1](https://github.com/danielvm-git/bigpowers/compare/v2.78.0...v2.78.1) (2026-07-23)
2
16
 
3
17
 
package/CONVENTIONS.md CHANGED
@@ -353,6 +353,39 @@ name must return < 5 results across the repo.
353
353
 
354
354
  Any new exception requires an entry in this table before the skill is published.
355
355
 
356
+ ## Tombstone Aliases (e53s02)
357
+
358
+ Every skill rename or merge ships a tombstone alias for one release, so consumers
359
+ referencing the old name don't break immediately.
360
+
361
+ **Mechanism:**
362
+
363
+ 1. Run `bash scripts/tombstone-skill.sh <old-name> <new-name-or-merge-target>` when
364
+ renaming or merging a skill. It replaces `skills/<old-name>/SKILL.md` with a stub
365
+ pointing at the new name/location, preserves the old skill's `# story: eNNsNN` tags
366
+ for traceability, and registers the mapping in `specs/tombstones.yaml` (old name,
367
+ new name/target, creation timestamp, and the `created_at_version` at creation time).
368
+ 2. Run `bash scripts/validate-tombstones.sh` to confirm every registered tombstone's
369
+ stub still resolves, and to flag any tombstone whose `created_at_version` is in a
370
+ different minor/major release train (major.minor) than the current version — that
371
+ stub has served its one-release transition window and should be removed. Patch-only
372
+ bumps within the same minor version don't count as a new release: this repo ships
373
+ via semantic-release on every merge to main, so comparing full semver strings would
374
+ expire a tombstone within minutes of creation instead of giving consumers a real
375
+ migration window.
376
+ 3. **Required alongside the first real use of `tombstone-skill.sh`:** update
377
+ `docs/references/model-profiles.md`'s skill-count annotations. A tombstone stub is
378
+ still a `skills/*/SKILL.md` file — it increments the live skill count the same as
379
+ any other skill, and the count doc must reflect that.
380
+
381
+ **Rejected names:** both the old and new name must be a bare kebab-case skill name
382
+ (`^[a-z][a-z0-9-]*$`) — `tombstone-skill.sh` refuses anything else before touching the
383
+ filesystem (path segments, absolute paths, etc.).
384
+
385
+ This story (e53s02) builds the mechanism only; it creates no real tombstone stub
386
+ itself, so the live skill count doesn't move yet. The first real use is expected in
387
+ e56 (skill reclassification/merges).
388
+
356
389
  ## File-Size Exceptions
357
390
 
358
391
  The 300-line file cap exists to ensure files fit in a single agent context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.78.1",
3
+ "version": "2.79.1",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -33,12 +33,25 @@ if [[ -f specs/traceability-matrix.json ]]; then
33
33
  # not-yet-built siblings (which have no code tags by definition until their
34
34
  # own turn) and not unrelated epics (which may be plan-worked-to-build-ready
35
35
  # but not yet kicked off).
36
- ACTIVE_STORY="$(grep '^active_story:' specs/state.yaml 2>/dev/null | awk '{print $2}')"
37
- if [[ -n "$ACTIVE_STORY" ]]; then
38
- UNDONE="$(jq --arg s "$ACTIVE_STORY" \
39
- '[.stories[]? | select(.id == $s and .status != "done" and ((.links // []) | map(select(.method == "explicit_tag")) | length) == 0)] | length' \
36
+ # `|| true` guards the whole pipe under `set -o pipefail`: a state.yaml
37
+ # with no active_story line makes grep exit 1, which would otherwise kill
38
+ # this script outright (no WARNING, no summary line) instead of falling
39
+ # through to the empty-ACTIVE_STORY branch below.
40
+ ACTIVE_STORY="$(grep '^active_story:' specs/state.yaml 2>/dev/null | awk '{print $2}' || true)"
41
+ if [[ -z "$ACTIVE_STORY" ]]; then
42
+ classify WARNING "specs/state.yaml has no active_story set — zero-tag check skipped, not a pass"
43
+ else
44
+ STORY_EXISTS="$(jq --arg s "$ACTIVE_STORY" \
45
+ '[.stories[]? | select(.id == $s)] | length' \
40
46
  specs/traceability-matrix.json 2>/dev/null || echo 0)"
41
- (( UNDONE > 0 )) && classify BLOCKER "active story $ACTIVE_STORY has zero explicit story-tag links"
47
+ if [[ "$STORY_EXISTS" -eq 0 ]]; then
48
+ classify WARNING "active_story $ACTIVE_STORY not found in specs/traceability-matrix.json — zero-tag check skipped, not a pass"
49
+ else
50
+ UNDONE="$(jq --arg s "$ACTIVE_STORY" \
51
+ '[.stories[]? | select(.id == $s and .status != "done" and ((.links // []) | map(select(.method == "explicit_tag")) | length) == 0)] | length' \
52
+ specs/traceability-matrix.json 2>/dev/null || echo 0)"
53
+ (( UNDONE > 0 )) && classify BLOCKER "active story $ACTIVE_STORY has zero explicit story-tag links"
54
+ fi
42
55
  fi
43
56
  COVERAGE="$(jq -r '.summary.coverage_percent // .coverage_percent // empty' specs/traceability-matrix.json 2>/dev/null)"
44
57
  if [[ -n "$COVERAGE" && "$COVERAGE" != "null" ]]; then
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s01
3
+ # test-completeness-critic-scope.sh — regression test for
4
+ # BUG-2026-07-23-completeness-critic-code-tags-schema-drift: the zero-tag
5
+ # check must scope to the active story only (not siblings/other epics), and
6
+ # must WARN — not silently no-op — when active_story is unset or unresolved.
7
+ #
8
+ # Usage: bash scripts/test-completeness-critic-scope.sh
9
+
10
+ set -euo pipefail
11
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
12
+ cd "$REPO_ROOT"
13
+
14
+ RED='\033[0;31m'
15
+ GREEN='\033[0;32m'
16
+ NC='\033[0m'
17
+
18
+ FAILURES=0
19
+ tt_pass() { echo -e "${GREEN}PASS${NC} $1"; }
20
+ tt_fail() { echo -e "${RED}FAIL${NC} $1"; FAILURES=$((FAILURES + 1)); }
21
+
22
+ STATE_FILE="specs/state.yaml"
23
+ MATRIX_FILE="specs/traceability-matrix.json"
24
+ STATE_BACKUP=$(mktemp)
25
+ MATRIX_BACKUP=$(mktemp)
26
+ cp "$STATE_FILE" "$STATE_BACKUP"
27
+ cp "$MATRIX_FILE" "$MATRIX_BACKUP"
28
+
29
+ cleanup() {
30
+ cp "$STATE_BACKUP" "$STATE_FILE"
31
+ cp "$MATRIX_BACKUP" "$MATRIX_FILE"
32
+ rm -f "$STATE_BACKUP" "$MATRIX_BACKUP"
33
+ }
34
+ trap cleanup EXIT
35
+
36
+ run_critic() {
37
+ bash scripts/lib/completeness-critic.sh 2>&1 || true
38
+ }
39
+
40
+ # Fixture matrix: one untagged sibling story (e00s02) that must NOT block,
41
+ # and the active story (e00s01) which is under test.
42
+ write_matrix() {
43
+ local active_links="$1"
44
+ cat > "$MATRIX_FILE" <<FIXTUREEOF
45
+ {
46
+ "stories": [
47
+ {"id": "e00s01", "status": "in_progress", "links": ${active_links}},
48
+ {"id": "e00s02", "status": "todo", "links": []}
49
+ ]
50
+ }
51
+ FIXTUREEOF
52
+ }
53
+
54
+ # --- Scenario: active_story unset → WARNING, not silent pass ---
55
+ sed -i.bak '/^active_story:/d' "$STATE_FILE" && rm -f "${STATE_FILE}.bak"
56
+ write_matrix '[]'
57
+ output=$(run_critic)
58
+ if echo "$output" | grep -q "\[WARNING\].*no active_story set"; then
59
+ tt_pass "unset active_story produces an explicit WARNING"
60
+ else
61
+ tt_fail "unset active_story did not produce the expected WARNING"
62
+ fi
63
+
64
+ # --- Scenario: active_story set but absent from the matrix → WARNING ---
65
+ printf 'active_story: e99s99\n' >> "$STATE_FILE"
66
+ output=$(run_critic)
67
+ if echo "$output" | grep -q "\[WARNING\].*e99s99 not found"; then
68
+ tt_pass "unresolved active_story produces an explicit WARNING"
69
+ else
70
+ tt_fail "unresolved active_story did not produce the expected WARNING"
71
+ fi
72
+ sed -i.bak '/^active_story:/d' "$STATE_FILE" && rm -f "${STATE_FILE}.bak"
73
+
74
+ # --- Scenario: active story has zero explicit_tag links → BLOCKER, scoped to it ---
75
+ printf 'active_story: e00s01\n' >> "$STATE_FILE"
76
+ write_matrix '[]'
77
+ output=$(run_critic)
78
+ if echo "$output" | grep -q "\[BLOCKER\] active story e00s01 has zero explicit story-tag links"; then
79
+ tt_pass "active story with zero tags is correctly flagged as BLOCKER"
80
+ else
81
+ tt_fail "active story with zero tags was not flagged as BLOCKER"
82
+ fi
83
+
84
+ # --- Scenario: active story IS tagged → no BLOCKER, even though sibling e00s02 is untagged ---
85
+ write_matrix '[{"method": "explicit_tag", "file": "scripts/fixture.sh", "line": 1, "confidence": 1.0}]'
86
+ output=$(run_critic)
87
+ if echo "$output" | grep -q "\[BLOCKER\] active story e00s01"; then
88
+ tt_fail "tagged active story incorrectly flagged as BLOCKER"
89
+ else
90
+ tt_pass "tagged active story is not flagged, and untagged sibling e00s02 does not leak into the check"
91
+ fi
92
+
93
+ echo "---"
94
+ if [[ "$FAILURES" -eq 0 ]]; then
95
+ echo -e "${GREEN}test-completeness-critic-scope: ALL PASS${NC}"
96
+ else
97
+ echo -e "${RED}test-completeness-critic-scope: $FAILURES failure(s)${NC}"
98
+ fi
99
+ exit "$FAILURES"
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s01
3
+ # test-generate-epics-wiki-zero-stories.sh — regression test for
4
+ # BUG-2026-07-23-epics-wiki-empty-references: generate-epics-wiki.sh must
5
+ # skip epics with zero sliced stories rather than emit an invalid OKF bundle
6
+ # with an empty references[].
7
+ #
8
+ # Usage: bash scripts/test-generate-epics-wiki-zero-stories.sh
9
+
10
+ set -euo pipefail
11
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/skill-common.sh"
12
+ resolve_repo_root
13
+ cd "$REPO_ROOT"
14
+
15
+ RED='\033[0;31m'
16
+ GREEN='\033[0;32m'
17
+ NC='\033[0m'
18
+
19
+ FAILURES=0
20
+ tt_pass() { echo -e "${GREEN}PASS${NC} $1"; }
21
+ tt_fail() { echo -e "${RED}FAIL${NC} $1"; FAILURES=$((FAILURES + 1)); }
22
+
23
+ FIXTURE_ID="e00"
24
+ FIXTURE_DIR="specs/epics/e00-zero-story-fixture"
25
+ FIXTURE_BUNDLE="specs/epics-wiki/${FIXTURE_ID}.okf.md"
26
+
27
+ cleanup() {
28
+ rm -rf "$FIXTURE_DIR"
29
+ rm -f "$FIXTURE_BUNDLE"
30
+ }
31
+ trap cleanup EXIT
32
+
33
+ mkdir -p "$FIXTURE_DIR"
34
+ cat > "${FIXTURE_DIR}/epic.yaml" <<'FIXTUREEOF'
35
+ id: e00
36
+ title: "Zero-story fixture epic"
37
+ wsjf: 0
38
+ bcps: 0
39
+ capsule_dir: epics/e00-zero-story-fixture
40
+ mode: capsule
41
+ status: todo
42
+ depends_on: []
43
+ stories: []
44
+ FIXTUREEOF
45
+
46
+ # A scoped-but-unsliced epic has a `stories:` key with nothing under it —
47
+ # grep -c '^\s*- id:' correctly returns 0 for this shape.
48
+ [[ -f "$FIXTURE_BUNDLE" ]] && rm -f "$FIXTURE_BUNDLE"
49
+
50
+ bash scripts/generate-epics-wiki.sh > /dev/null
51
+
52
+ if [[ ! -f "$FIXTURE_BUNDLE" ]]; then
53
+ tt_pass "generate-epics-wiki.sh skips a zero-story epic (no bundle emitted)"
54
+ else
55
+ tt_fail "generate-epics-wiki.sh emitted a bundle for a zero-story epic: $FIXTURE_BUNDLE"
56
+ fi
57
+
58
+ echo "---"
59
+ if [[ "$FAILURES" -eq 0 ]]; then
60
+ echo -e "${GREEN}test-generate-epics-wiki-zero-stories: ALL PASS${NC}"
61
+ else
62
+ echo -e "${RED}test-generate-epics-wiki-zero-stories: $FAILURES failure(s)${NC}"
63
+ fi
64
+ exit "$FAILURES"
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s01
3
+ # test-golden-g11-worktree-venv.sh — regression test for
4
+ # BUG-2026-07-23-golden-g11-worktree-venv: golden-g11-gitignore-venv.sh must
5
+ # PASS in a fresh worktree where .venv does not exist yet on disk (before
6
+ # setup-environment has run), not just when a real .venv is already present.
7
+ #
8
+ # Usage: bash scripts/test-golden-g11-worktree-venv.sh
9
+
10
+ set -euo pipefail
11
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
12
+ cd "$REPO_ROOT"
13
+
14
+ RED='\033[0;31m'
15
+ GREEN='\033[0;32m'
16
+ NC='\033[0m'
17
+
18
+ FAILURES=0
19
+ tt_pass() { echo -e "${GREEN}PASS${NC} $1"; }
20
+ tt_fail() { echo -e "${RED}FAIL${NC} $1"; FAILURES=$((FAILURES + 1)); }
21
+ strip_ansi() { sed -E 's/\x1b\[[0-9;]*m//g'; }
22
+
23
+ VENV_MOVED=0
24
+ if [[ -e .venv ]]; then
25
+ mv .venv .venv.test-golden-g11-backup
26
+ VENV_MOVED=1
27
+ fi
28
+
29
+ cleanup() {
30
+ rm -rf .venv
31
+ if [[ "$VENV_MOVED" -eq 1 ]]; then
32
+ mv .venv.test-golden-g11-backup .venv
33
+ fi
34
+ }
35
+ trap cleanup EXIT
36
+
37
+ # --- Scenario: .venv absent (fresh worktree) → gate still PASSes ---
38
+ if [[ -e .venv ]]; then
39
+ tt_fail "test setup failed to remove .venv before running the gate"
40
+ else
41
+ output=$(bash scripts/golden-g11-gitignore-venv.sh 2>&1 | strip_ansi) && exit_code=0 || exit_code=$?
42
+ if [[ "$exit_code" -eq 0 ]] && echo "$output" | grep -q "PASS git check-ignore .venv"; then
43
+ tt_pass "gate PASSes when .venv does not exist on disk"
44
+ else
45
+ tt_fail "gate did not PASS with .venv absent (exit=$exit_code)"
46
+ fi
47
+
48
+ # The gate must clean up the .venv it created for the check — a fresh
49
+ # worktree shouldn't be left with a stray directory after a gate run.
50
+ if [[ ! -e .venv ]]; then
51
+ tt_pass "gate removes the .venv it created for the check"
52
+ else
53
+ tt_fail "gate left a stray .venv behind after the check"
54
+ fi
55
+ fi
56
+
57
+ echo "---"
58
+ if [[ "$FAILURES" -eq 0 ]]; then
59
+ echo -e "${GREEN}test-golden-g11-worktree-venv: ALL PASS${NC}"
60
+ else
61
+ echo -e "${RED}test-golden-g11-worktree-venv: $FAILURES failure(s)${NC}"
62
+ fi
63
+ exit "$FAILURES"
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s02
3
+ # test-tombstone-mechanism.sh — exercises tombstone-skill.sh and
4
+ # validate-tombstones.sh end-to-end against throwaway fixtures.
5
+ #
6
+ # Usage: bash scripts/test-tombstone-mechanism.sh
7
+
8
+ set -euo pipefail
9
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/skill-common.sh"
10
+ resolve_repo_root
11
+ cd "$REPO_ROOT"
12
+
13
+ RED='\033[0;31m'
14
+ GREEN='\033[0;32m'
15
+ NC='\033[0m'
16
+
17
+ FAILURES=0
18
+ tt_pass() { echo -e "${GREEN}PASS${NC} $1"; }
19
+ tt_fail() { echo -e "${RED}FAIL${NC} $1"; FAILURES=$((FAILURES + 1)); }
20
+ strip_ansi() { sed -E 's/\x1b\[[0-9;]*m//g'; }
21
+
22
+ FIXTURE_OLD="test-tombstone-fixture"
23
+ FIXTURE_NEW="test-tombstone-target"
24
+ FIXTURE_DIR="skills/${FIXTURE_OLD}"
25
+ TOMBSTONES_FILE="specs/tombstones.yaml"
26
+ TOMBSTONES_BACKUP=""
27
+
28
+ cleanup() {
29
+ rm -rf "$FIXTURE_DIR" "skills/${FIXTURE_NEW}"
30
+ if [[ -n "$TOMBSTONES_BACKUP" ]]; then
31
+ mv "$TOMBSTONES_BACKUP" "$TOMBSTONES_FILE"
32
+ else
33
+ rm -f "$TOMBSTONES_FILE"
34
+ fi
35
+ }
36
+ trap cleanup EXIT
37
+
38
+ if [[ -f "$TOMBSTONES_FILE" ]]; then
39
+ TOMBSTONES_BACKUP=$(mktemp)
40
+ cp "$TOMBSTONES_FILE" "$TOMBSTONES_BACKUP"
41
+ rm -f "$TOMBSTONES_FILE"
42
+ fi
43
+
44
+ # --- Scenario: zero tombstones registered (6a) ---
45
+ if bash scripts/validate-tombstones.sh 2>&1 | grep -qi "no tombstones"; then
46
+ tt_pass "validate-tombstones.sh reports clean when zero tombstones registered"
47
+ else
48
+ tt_fail "validate-tombstones.sh did not report 'no tombstones' with an empty ledger"
49
+ fi
50
+
51
+ # --- Scenario: tombstone-skill.sh generates a stub ---
52
+ mkdir -p "$FIXTURE_DIR"
53
+ cat > "${FIXTURE_DIR}/SKILL.md" <<'FIXTUREEOF'
54
+ # story: e00s00
55
+ ---
56
+ name: test-tombstone-fixture
57
+ description: throwaway fixture for test-tombstone-mechanism.sh
58
+ ---
59
+ Fixture body.
60
+ FIXTUREEOF
61
+
62
+ bash scripts/tombstone-skill.sh "$FIXTURE_OLD" "$FIXTURE_NEW"
63
+
64
+ if grep -q "TOMBSTONE" "${FIXTURE_DIR}/SKILL.md"; then
65
+ tt_pass "tombstone-skill.sh replaced SKILL.md with a stub"
66
+ else
67
+ tt_fail "SKILL.md was not replaced with a tombstone stub"
68
+ fi
69
+
70
+ if grep -q "story: e00s00" "${FIXTURE_DIR}/SKILL.md"; then
71
+ tt_pass "tombstone-skill.sh preserved the old story tag"
72
+ else
73
+ tt_fail "story tag was not preserved in the stub"
74
+ fi
75
+
76
+ if [[ -f "$TOMBSTONES_FILE" ]] && grep -q "$FIXTURE_OLD" "$TOMBSTONES_FILE"; then
77
+ tt_pass "tombstone-skill.sh registered the mapping in ${TOMBSTONES_FILE}"
78
+ else
79
+ tt_fail "mapping not registered in ${TOMBSTONES_FILE}"
80
+ fi
81
+
82
+ # --- Scenario: fresh tombstone resolves cleanly ---
83
+ if bash scripts/validate-tombstones.sh 2>&1 | strip_ansi | grep -q "OK: ${FIXTURE_OLD}"; then
84
+ tt_pass "validate-tombstones.sh confirms the fresh stub resolves"
85
+ else
86
+ tt_fail "validate-tombstones.sh did not confirm the fresh stub"
87
+ fi
88
+
89
+ # --- Scenario: expired tombstone flagged (6b) ---
90
+ python3 -c "
91
+ import yaml
92
+ d = yaml.safe_load(open('$TOMBSTONES_FILE')) or {'tombstones': []}
93
+ for t in d['tombstones']:
94
+ if t['old_name'] == '$FIXTURE_OLD':
95
+ t['created_at_version'] = '0.0.0'
96
+ yaml.dump(d, open('$TOMBSTONES_FILE', 'w'), default_flow_style=False)
97
+ "
98
+
99
+ if bash scripts/validate-tombstones.sh 2>&1 | strip_ansi | grep -qi "EXPIRED: ${FIXTURE_OLD}"; then
100
+ tt_pass "validate-tombstones.sh flags a tombstone whose expiry window has passed"
101
+ else
102
+ tt_fail "expired tombstone was not flagged"
103
+ fi
104
+
105
+ echo "---"
106
+ if [[ "$FAILURES" -eq 0 ]]; then
107
+ echo -e "${GREEN}test-tombstone-mechanism: ALL PASS${NC}"
108
+ else
109
+ echo -e "${RED}test-tombstone-mechanism: $FAILURES failure(s)${NC}"
110
+ fi
111
+ exit "$FAILURES"
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s02
3
+ # tombstone-skill.sh — replace a renamed/merged skill's SKILL.md with a stub,
4
+ # preserving its story tags and registering the mapping in specs/tombstones.yaml
5
+ # for a one-release expiry window.
6
+ #
7
+ # Usage: bash scripts/tombstone-skill.sh <old-name> <new-name-or-merge-target>
8
+
9
+ set -euo pipefail
10
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/skill-common.sh"
11
+ resolve_repo_root
12
+ cd "$REPO_ROOT"
13
+
14
+ usage() {
15
+ cat <<'EOF'
16
+ Usage: tombstone-skill.sh <old-name> <new-name-or-merge-target>
17
+
18
+ Replaces skills/<old-name>/SKILL.md with a stub pointing at <new-name>,
19
+ preserves the old name's story tags, and registers the mapping in
20
+ specs/tombstones.yaml with a one-release expiry window.
21
+ EOF
22
+ }
23
+
24
+ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
25
+ usage
26
+ exit 0
27
+ fi
28
+
29
+ OLD_NAME="${1:-}"
30
+ NEW_NAME="${2:-}"
31
+ NAME_PATTERN='^[a-z][a-z0-9-]*$'
32
+
33
+ if [[ -z "$OLD_NAME" || -z "$NEW_NAME" ]]; then
34
+ usage >&2
35
+ exit 1
36
+ fi
37
+
38
+ # Threat-model mitigation (specs/security/epics/e53/THREAT_MODEL.md): reject
39
+ # anything that isn't a bare kebab-case skill name before any path use.
40
+ for name in "$OLD_NAME" "$NEW_NAME"; do
41
+ if [[ ! "$name" =~ $NAME_PATTERN ]]; then
42
+ echo "ERROR: '$name' is not a valid skill name (must match $NAME_PATTERN)" >&2
43
+ exit 1
44
+ fi
45
+ done
46
+
47
+ OLD_SKILL_MD="skills/${OLD_NAME}/SKILL.md"
48
+ if [[ ! -f "$OLD_SKILL_MD" ]]; then
49
+ echo "ERROR: $OLD_SKILL_MD does not exist" >&2
50
+ exit 1
51
+ fi
52
+
53
+ STORY_TAGS=$(grep -E '^#\s*story:' "$OLD_SKILL_MD" || true)
54
+ CURRENT_VERSION=$(jq -r '.version' package.json 2>/dev/null)
55
+ CREATED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
56
+
57
+ cat > "$OLD_SKILL_MD" <<STUBEOF
58
+ ---
59
+ name: ${OLD_NAME}
60
+ description: "TOMBSTONE — renamed/merged to ${NEW_NAME}. This stub resolves for one release then is removed."
61
+ ---
62
+
63
+ # ${OLD_NAME} (tombstoned)
64
+
65
+ This skill has been renamed or merged into \`${NEW_NAME}\`. Use \`${NEW_NAME}\` instead.
66
+
67
+ This stub exists for one release as a transition aid, then is removed once
68
+ \`scripts/validate-tombstones.sh\` flags it as expired.
69
+
70
+ ${STORY_TAGS}
71
+ STUBEOF
72
+
73
+ TOMBSTONES_FILE="specs/tombstones.yaml"
74
+ [[ -f "$TOMBSTONES_FILE" ]] || echo "tombstones: []" > "$TOMBSTONES_FILE"
75
+
76
+ # Values flow through the environment, never interpolated into the python
77
+ # source string — string-interpolating shell variables into `python3 -c`
78
+ # turns any unvalidated field (e.g. CURRENT_VERSION, sourced from
79
+ # package.json) into a code-injection vector.
80
+ TOMBSTONE_FILE="$TOMBSTONES_FILE" \
81
+ TOMBSTONE_OLD_NAME="$OLD_NAME" \
82
+ TOMBSTONE_NEW_NAME="$NEW_NAME" \
83
+ TOMBSTONE_CREATED_AT="$CREATED_AT" \
84
+ TOMBSTONE_VERSION="$CURRENT_VERSION" \
85
+ python3 -c "
86
+ import os
87
+ import yaml
88
+ path = os.environ['TOMBSTONE_FILE']
89
+ d = yaml.safe_load(open(path)) or {'tombstones': []}
90
+ d.setdefault('tombstones', []).append({
91
+ 'old_name': os.environ['TOMBSTONE_OLD_NAME'],
92
+ 'new_name_or_merge_target': os.environ['TOMBSTONE_NEW_NAME'],
93
+ 'created_at': os.environ['TOMBSTONE_CREATED_AT'],
94
+ 'created_at_version': os.environ['TOMBSTONE_VERSION'],
95
+ })
96
+ yaml.dump(d, open(path, 'w'), default_flow_style=False)
97
+ "
98
+
99
+ echo "Tombstoned: ${OLD_NAME} -> ${NEW_NAME}"
100
+ echo "Registered in ${TOMBSTONES_FILE}"
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # story: e53s02
3
+ # validate-tombstones.sh — confirm each registered tombstone's stub still
4
+ # resolves, and flag any whose one-release expiry window has passed.
5
+ #
6
+ # Usage: bash scripts/validate-tombstones.sh
7
+
8
+ set -euo pipefail
9
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/skill-common.sh"
10
+ resolve_repo_root
11
+ cd "$REPO_ROOT"
12
+
13
+ RED='\033[0;31m'
14
+ GREEN='\033[0;32m'
15
+ YELLOW='\033[1;33m'
16
+ NC='\033[0m'
17
+
18
+ TOMBSTONES_FILE="specs/tombstones.yaml"
19
+ NAME_PATTERN='^[a-z][a-z0-9-]*$'
20
+ FAIL=0
21
+
22
+ if [[ ! -f "$TOMBSTONES_FILE" ]]; then
23
+ echo -e "${GREEN}OK${NC}: no tombstones registered"
24
+ exit 0
25
+ fi
26
+
27
+ CURRENT_VERSION=$(jq -r '.version' package.json 2>/dev/null)
28
+
29
+ # A "release" for tombstone-expiry purposes is a minor/major version bump.
30
+ # This repo ships via semantic-release on every merge to main (multiple
31
+ # patch releases per day) — comparing full semver strings would expire
32
+ # every tombstone within minutes instead of giving consumers the one-release
33
+ # migration window CONVENTIONS.md promises.
34
+ release_train() { echo "$1" | cut -d. -f1-2; }
35
+
36
+ ROWS=$(python3 -c "
37
+ import yaml
38
+ d = yaml.safe_load(open('$TOMBSTONES_FILE')) or {'tombstones': []}
39
+ for t in d.get('tombstones', []) or []:
40
+ print(f\"{t['old_name']}\t{t['new_name_or_merge_target']}\t{t.get('created_at_version', '')}\")
41
+ ")
42
+
43
+ if [[ -z "$ROWS" ]]; then
44
+ echo -e "${GREEN}OK${NC}: no tombstones registered"
45
+ exit 0
46
+ fi
47
+
48
+ while IFS=$'\t' read -r old_name new_name created_version; do
49
+ [[ -z "$old_name" ]] && continue
50
+
51
+ # Threat-model mitigation (specs/security/epics/e53/THREAT_MODEL.md): apply
52
+ # the same kebab-case validation tombstone-skill.sh applies before writing
53
+ # entries, since these names come back out of specs/tombstones.yaml here
54
+ # and are used to construct a filesystem path.
55
+ if [[ ! "$old_name" =~ $NAME_PATTERN || ! "$new_name" =~ $NAME_PATTERN ]]; then
56
+ echo -e "${RED}FAIL${NC}: '${old_name}' -> '${new_name}' — not a valid skill name (must match ${NAME_PATTERN})"
57
+ FAIL=1
58
+ continue
59
+ fi
60
+
61
+ stub="skills/${old_name}/SKILL.md"
62
+
63
+ if [[ ! -f "$stub" ]]; then
64
+ echo -e "${RED}FAIL${NC}: ${old_name} -> ${new_name} — stub missing at ${stub}"
65
+ FAIL=1
66
+ continue
67
+ fi
68
+ if ! grep -q "TOMBSTONE" "$stub"; then
69
+ echo -e "${RED}FAIL${NC}: ${old_name} — stub exists but is not a tombstone"
70
+ FAIL=1
71
+ continue
72
+ fi
73
+
74
+ if [[ -n "$created_version" && "$(release_train "$created_version")" != "$(release_train "$CURRENT_VERSION")" ]]; then
75
+ echo -e "${YELLOW}EXPIRED${NC}: ${old_name} -> ${new_name} — created at ${created_version}, now ${CURRENT_VERSION}; remove the stub"
76
+ continue
77
+ fi
78
+
79
+ echo -e "${GREEN}OK${NC}: ${old_name} -> ${new_name} resolves"
80
+ done <<< "$ROWS"
81
+
82
+ exit "$FAIL"