@jenga-ai/agent 3.0.0 → 3.1.0
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/README.md +7 -0
- package/bin/jenga.js +10 -0
- package/hooks/copilot_session_end.sh +7 -3
- package/hooks/prompt_router_helper.js +17 -5
- package/lib/commands/doctor.js +351 -0
- package/lib/commands/init.js +16 -0
- package/lib/generate-copilot-hooks.js +116 -0
- package/lib/legacy-shipped-paths.json +336 -0
- package/lib/postinstall-manifest.js +469 -0
- package/lib/skill-allow-list.json +1 -1
- package/package.json +2 -1
- package/scripts/generate-legacy-shipped-paths.js +248 -0
- package/scripts/postinstall.js +205 -2
- package/scripts/verify-legacy-seed-reconcile.sh +254 -0
- package/scripts/verify-postinstall-reconcile.sh +392 -0
- package/templates/copilot-instructions.md.tpl +32 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# verify-legacy-seed-reconcile.sh — fixture rehearsal for E26_S08_T03
|
|
4
|
+
#
|
|
5
|
+
# Rehearses the legacy-path-seeding mechanism (lib/postinstall-manifest.js's
|
|
6
|
+
# seedFromLegacyPaths/reconcileWithPriorPaths, wired into scripts/postinstall.js's
|
|
7
|
+
# no-prior-manifest branch) against THROWAWAY fixture consumer projects, as required by
|
|
8
|
+
# this task's `crucial_level: gated` note ("must be rehearsed against a throwaway
|
|
9
|
+
# fixture consumer directory before being considered done, not just unit-reasoned
|
|
10
|
+
# about"). Kept as a SEPARATE script from scripts/verify-postinstall-reconcile.sh
|
|
11
|
+
# (E26_S08_T01's already-Passed rehearsal harness) so that harness is not touched by
|
|
12
|
+
# this task.
|
|
13
|
+
#
|
|
14
|
+
# SAFETY: this script creates its own fixture root via `mktemp -d` and writes and
|
|
15
|
+
# deletes ONLY inside it. It takes no path argument and will never operate on an
|
|
16
|
+
# existing directory, so it cannot touch this repo or a real consumer project.
|
|
17
|
+
# Set KEEP_FIXTURE=1 to leave the fixture on disk for inspection.
|
|
18
|
+
#
|
|
19
|
+
# Scenarios covered
|
|
20
|
+
# A. Pre-manifest orphan cleanup — THE fix this task delivers. A consumer already
|
|
21
|
+
# carrying an orphan predating any manifest (e.g. a retired j-<name> twin) gets it
|
|
22
|
+
# cleaned up on the VERY FIRST run of a fixed version (adopt-then-reconcile in one
|
|
23
|
+
# pass), while a consumer-authored sibling file in the same directory survives
|
|
24
|
+
# along with the directory itself.
|
|
25
|
+
# B. Genuine first-ever install (nothing on disk to intersect with) stays purely
|
|
26
|
+
# additive — no deletes, regardless of what the legacy path list contains.
|
|
27
|
+
# C. A legacy-listed path that is STILL currently shipped survives (it's part of this
|
|
28
|
+
# run's own copy set, so it is never "stale").
|
|
29
|
+
# D. A legacy-listed path that isn't actually present on disk is never seeded (no
|
|
30
|
+
# phantom delete attempt, no error).
|
|
31
|
+
# E. Boundary/type safety over SEEDED paths: a traversal entry and a symlink entry in
|
|
32
|
+
# the legacy list are refused exactly like a manifest-based candidate would be —
|
|
33
|
+
# seeding narrows which paths are eligible, it does not relax how a candidate is
|
|
34
|
+
# validated.
|
|
35
|
+
# F. Regression: a consumer with a VALID prior manifest reconciles exactly as T01
|
|
36
|
+
# already verifies — seeding must never fire once a real manifest exists.
|
|
37
|
+
#
|
|
38
|
+
# Usage: bash scripts/verify-legacy-seed-reconcile.sh
|
|
39
|
+
# Exit: 0 = all assertions passed, 1 = at least one failed.
|
|
40
|
+
|
|
41
|
+
set -uo pipefail
|
|
42
|
+
|
|
43
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
44
|
+
FIXTURE="$(mktemp -d "${TMPDIR:-/tmp}/jenga-legacy-seed-fixture.XXXXXX")"
|
|
45
|
+
|
|
46
|
+
PASS=0
|
|
47
|
+
FAIL=0
|
|
48
|
+
|
|
49
|
+
# When RESULTS_FILE is set, every verdict is also appended in a machine-readable
|
|
50
|
+
# `PASS<TAB><name>` / `FAIL<TAB><name>` form, so tests/postinstall-legacy-seed.bats can
|
|
51
|
+
# assert on individual named checks instead of on one opaque exit status. Mirrors the
|
|
52
|
+
# exact convention scripts/verify-postinstall-reconcile.sh already established.
|
|
53
|
+
record() { if [ -n "${RESULTS_FILE:-}" ]; then printf '%s\t%s\n' "$1" "$2" >> "$RESULTS_FILE"; fi; }
|
|
54
|
+
|
|
55
|
+
pass() { PASS=$((PASS + 1)); record PASS "$1"; printf ' \033[32mPASS\033[0m %s\n' "$1"; }
|
|
56
|
+
fail() { FAIL=$((FAIL + 1)); record FAIL "$1"; printf ' \033[31mFAIL\033[0m %s\n' "$1"; }
|
|
57
|
+
|
|
58
|
+
assert_file() { if [ -f "$1" ]; then pass "$2"; else fail "$2 (missing: $1)"; fi; }
|
|
59
|
+
assert_absent() { if [ ! -e "$1" ]; then pass "$2"; else fail "$2 (still present: $1)"; fi; }
|
|
60
|
+
assert_dir() { if [ -d "$1" ]; then pass "$2"; else fail "$2 (missing dir: $1)"; fi; }
|
|
61
|
+
assert_grep() { if grep -q "$1" "$2" 2>/dev/null; then pass "$3"; else fail "$3"; fi; }
|
|
62
|
+
assert_nogrep_re() { if grep -qE "$1" "$2" 2>/dev/null; then fail "$3"; else pass "$3"; fi; }
|
|
63
|
+
|
|
64
|
+
cleanup() {
|
|
65
|
+
if [ "${KEEP_FIXTURE:-0}" = "1" ]; then
|
|
66
|
+
printf '\n Fixture retained at: %s\n' "$FIXTURE"
|
|
67
|
+
else
|
|
68
|
+
rm -rf "$FIXTURE"
|
|
69
|
+
fi
|
|
70
|
+
}
|
|
71
|
+
trap cleanup EXIT
|
|
72
|
+
|
|
73
|
+
# ── build a synthetic "current package" ──────────────────────────────────────
|
|
74
|
+
# Real lib/ + scripts/ (the code under test), a small synthetic skills/+agents/ tree,
|
|
75
|
+
# and a hand-authored lib/legacy-shipped-paths.json standing in for the real generated
|
|
76
|
+
# artifact — this fixture's package version doesn't need to match any real published
|
|
77
|
+
# version; only the LOGIC under test needs to be real.
|
|
78
|
+
make_pkg() {
|
|
79
|
+
local dir="$1"
|
|
80
|
+
mkdir -p "$dir"
|
|
81
|
+
cp -R "$REPO_ROOT/lib" "$dir/lib"
|
|
82
|
+
cp -R "$REPO_ROOT/scripts" "$dir/scripts"
|
|
83
|
+
cat > "$dir/package.json" <<'JSON'
|
|
84
|
+
{ "name": "@jenga-ai/agent", "version": "3.0.0", "type": "module" }
|
|
85
|
+
JSON
|
|
86
|
+
mkdir -p "$dir/skills/do" "$dir/agents"
|
|
87
|
+
printf '# do skill (still shipped)\n' > "$dir/skills/do/SKILL.md"
|
|
88
|
+
printf '# developer agent (still shipped)\n' > "$dir/agents/developer.md"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
write_legacy_paths() {
|
|
92
|
+
local dir="$1"; shift
|
|
93
|
+
local paths_json
|
|
94
|
+
paths_json=$(printf '"%s",' "$@")
|
|
95
|
+
paths_json="[${paths_json%,}]"
|
|
96
|
+
cat > "$dir/lib/legacy-shipped-paths.json" <<JSON
|
|
97
|
+
{
|
|
98
|
+
"generated_at": "2026-01-01T00:00:00.000Z",
|
|
99
|
+
"package": "@jenga-ai/agent",
|
|
100
|
+
"source": "test-fixture",
|
|
101
|
+
"paths": $paths_json
|
|
102
|
+
}
|
|
103
|
+
JSON
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
run_install() {
|
|
107
|
+
local pkg="$1" consumer="$2" log="$3"
|
|
108
|
+
( cd "$pkg" && INIT_CWD="$consumer" node scripts/postinstall.js ) > "$log" 2>&1
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
echo
|
|
112
|
+
echo "══ Jenga legacy-path seeding rehearsal (E26_S08_T03) ══"
|
|
113
|
+
echo " fixture: $FIXTURE"
|
|
114
|
+
|
|
115
|
+
PKG="$FIXTURE/pkg"
|
|
116
|
+
make_pkg "$PKG"
|
|
117
|
+
write_legacy_paths "$PKG" \
|
|
118
|
+
"agents/developer.md" \
|
|
119
|
+
"skills/do/SKILL.md" \
|
|
120
|
+
"skills/j-legacy-twin/SKILL.md" \
|
|
121
|
+
"../OUTSIDE-THE-FIXTURE.txt" \
|
|
122
|
+
"skills/trap/LINK.md"
|
|
123
|
+
|
|
124
|
+
# ═══ Scenario A — pre-manifest orphan cleanup (THE fix) ══════════════════════
|
|
125
|
+
echo
|
|
126
|
+
echo "── Scenario A: pre-manifest orphan cleaned on the very first run ──"
|
|
127
|
+
|
|
128
|
+
CONSUMER_A="$FIXTURE/consumer-a"
|
|
129
|
+
mkdir -p "$CONSUMER_A/.agents/skills/j-legacy-twin" "$CONSUMER_A/.claude/skills/j-legacy-twin"
|
|
130
|
+
printf '# retired twin, predates any manifest\n' > "$CONSUMER_A/.agents/skills/j-legacy-twin/SKILL.md"
|
|
131
|
+
printf 'my own notes\n' > "$CONSUMER_A/.agents/skills/j-legacy-twin/my-notes.md"
|
|
132
|
+
printf '# retired twin, predates any manifest\n' > "$CONSUMER_A/.claude/skills/j-legacy-twin/SKILL.md"
|
|
133
|
+
printf 'my own notes\n' > "$CONSUMER_A/.claude/skills/j-legacy-twin/my-notes.md"
|
|
134
|
+
|
|
135
|
+
run_install "$PKG" "$CONSUMER_A" "$FIXTURE/a-install.log"
|
|
136
|
+
|
|
137
|
+
assert_absent "$CONSUMER_A/.agents/skills/j-legacy-twin/SKILL.md" \
|
|
138
|
+
"pre-manifest orphan removed on first run (.agents)"
|
|
139
|
+
assert_absent "$CONSUMER_A/.claude/skills/j-legacy-twin/SKILL.md" \
|
|
140
|
+
"pre-manifest orphan removed on first run (.claude)"
|
|
141
|
+
assert_file "$CONSUMER_A/.agents/skills/j-legacy-twin/my-notes.md" \
|
|
142
|
+
"consumer-authored sibling survives (.agents)"
|
|
143
|
+
assert_file "$CONSUMER_A/.claude/skills/j-legacy-twin/my-notes.md" \
|
|
144
|
+
"consumer-authored sibling survives (.claude)"
|
|
145
|
+
assert_dir "$CONSUMER_A/.agents/skills/j-legacy-twin" \
|
|
146
|
+
"directory survives because it still holds a consumer file (.agents)"
|
|
147
|
+
assert_grep "seeded from known-shipped legacy paths" "$FIXTURE/a-install.log" \
|
|
148
|
+
"install log attributes the cleanup to legacy-path seeding"
|
|
149
|
+
assert_file "$CONSUMER_A/.agents/skills/do/SKILL.md" \
|
|
150
|
+
"currently-shipped skill untouched"
|
|
151
|
+
assert_file "$CONSUMER_A/.agents/agents/developer.md" \
|
|
152
|
+
"currently-shipped agent file untouched"
|
|
153
|
+
|
|
154
|
+
# Manifest written this run must describe reality: current package paths only, never
|
|
155
|
+
# the just-deleted orphan and never the consumer's own file.
|
|
156
|
+
python3 - "$CONSUMER_A/.agents/.jenga-postinstall-manifest.json" <<'PYEOF'
|
|
157
|
+
import json, sys
|
|
158
|
+
paths = json.load(open(sys.argv[1]))["paths"]
|
|
159
|
+
assert "skills/j-legacy-twin/SKILL.md" not in paths, "deleted orphan must not be in the fresh manifest"
|
|
160
|
+
assert "skills/j-legacy-twin/my-notes.md" not in paths, "consumer file must never be in the manifest"
|
|
161
|
+
assert "skills/do/SKILL.md" in paths and "agents/developer.md" in paths, "current package paths must be present"
|
|
162
|
+
PYEOF
|
|
163
|
+
if [ $? -eq 0 ]; then pass "fresh manifest describes only what is actually on disk post-cleanup"; else fail "fresh manifest is inaccurate"; fi
|
|
164
|
+
|
|
165
|
+
# ═══ Scenario B — genuine first-ever install stays additive-only ═════════════
|
|
166
|
+
echo
|
|
167
|
+
echo "── Scenario B: genuine first-ever install (nothing to intersect with) ──"
|
|
168
|
+
|
|
169
|
+
CONSUMER_B="$FIXTURE/consumer-b"
|
|
170
|
+
mkdir -p "$CONSUMER_B"
|
|
171
|
+
run_install "$PKG" "$CONSUMER_B" "$FIXTURE/b-install.log"
|
|
172
|
+
|
|
173
|
+
assert_nogrep_re "[1-9][0-9]* stale file" "$FIXTURE/b-install.log" \
|
|
174
|
+
"first-ever install deletes nothing despite a non-empty legacy path list"
|
|
175
|
+
assert_grep "no previous install manifest" "$FIXTURE/b-install.log" \
|
|
176
|
+
"first-ever install reports plain additive-only, not a seeded outcome"
|
|
177
|
+
assert_file "$CONSUMER_B/.agents/skills/do/SKILL.md" \
|
|
178
|
+
"first-ever install still copies current package files"
|
|
179
|
+
|
|
180
|
+
# ═══ Scenario C — a legacy path still currently shipped is never touched ═════
|
|
181
|
+
# (Implicitly exercised by Scenario A's "do"/"developer.md" already surviving, but
|
|
182
|
+
# asserted here as its own named check for clarity.)
|
|
183
|
+
assert_file "$CONSUMER_A/.claude/skills/do/SKILL.md" \
|
|
184
|
+
"a legacy-listed path that is STILL shipped survives untouched (not stale)"
|
|
185
|
+
|
|
186
|
+
# ═══ Scenario D — a legacy path absent from disk is never phantom-seeded ═════
|
|
187
|
+
# The legacy list includes "skills/j-legacy-twin/SKILL.md" for consumer-b too, but
|
|
188
|
+
# consumer-b never had that file on disk at all. No error, no crash, nothing reported.
|
|
189
|
+
assert_absent "$CONSUMER_B/.agents/skills/j-legacy-twin" \
|
|
190
|
+
"a legacy path never present on disk produces no phantom entries"
|
|
191
|
+
|
|
192
|
+
# ═══ Scenario E — boundary/type safety over SEEDED paths ═════════════════════
|
|
193
|
+
echo
|
|
194
|
+
echo "── Scenario E: adversarial legacy-listed paths are refused, not exploited ──"
|
|
195
|
+
|
|
196
|
+
CONSUMER_E="$FIXTURE/consumer-e"
|
|
197
|
+
mkdir -p "$CONSUMER_E/.agents/skills/trap"
|
|
198
|
+
printf 'precious\n' > "$FIXTURE/OUTSIDE-THE-FIXTURE.txt"
|
|
199
|
+
ln -sf "$FIXTURE/OUTSIDE-THE-FIXTURE.txt" "$CONSUMER_E/.agents/skills/trap/LINK.md"
|
|
200
|
+
|
|
201
|
+
run_install "$PKG" "$CONSUMER_E" "$FIXTURE/e-install.log"
|
|
202
|
+
|
|
203
|
+
assert_file "$FIXTURE/OUTSIDE-THE-FIXTURE.txt" \
|
|
204
|
+
"traversal entry in the legacy list cannot delete outside the mirror root"
|
|
205
|
+
if [ -L "$CONSUMER_E/.agents/skills/trap/LINK.md" ]; then
|
|
206
|
+
pass "symlink entry in the legacy list is refused, not followed or unlinked"
|
|
207
|
+
else
|
|
208
|
+
fail "symlink entry in the legacy list was removed"
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
# ═══ Scenario F — regression: a VALID prior manifest reconciles normally, unseeded ═══
|
|
212
|
+
# Uses a genuine two-version upgrade (like T01's own harness) so a REAL manifest-backed
|
|
213
|
+
# stale entry exists to clean up — proving the seeding branch never fires once a real
|
|
214
|
+
# manifest is present, not merely that nothing happened to need it.
|
|
215
|
+
echo
|
|
216
|
+
echo "── Scenario F: a consumer with a real prior manifest reconciles normally, unseeded ──"
|
|
217
|
+
|
|
218
|
+
PKG_F1="$FIXTURE/pkg-f1"
|
|
219
|
+
make_pkg "$PKG_F1"
|
|
220
|
+
mkdir -p "$PKG_F1/skills/renamed-old"
|
|
221
|
+
printf '# renamed-old — removed in the next version\n' > "$PKG_F1/skills/renamed-old/SKILL.md"
|
|
222
|
+
write_legacy_paths "$PKG_F1" "agents/developer.md" "skills/do/SKILL.md"
|
|
223
|
+
|
|
224
|
+
PKG_F2="$FIXTURE/pkg-f2"
|
|
225
|
+
make_pkg "$PKG_F2"
|
|
226
|
+
python3 -c "
|
|
227
|
+
import json
|
|
228
|
+
p = '$PKG_F2/package.json'
|
|
229
|
+
d = json.load(open(p)); d['version'] = '3.0.1'; json.dump(d, open(p, 'w'))
|
|
230
|
+
"
|
|
231
|
+
write_legacy_paths "$PKG_F2" "agents/developer.md" "skills/do/SKILL.md" "skills/renamed-old/SKILL.md"
|
|
232
|
+
|
|
233
|
+
CONSUMER_F="$FIXTURE/consumer-f"
|
|
234
|
+
mkdir -p "$CONSUMER_F"
|
|
235
|
+
run_install "$PKG_F1" "$CONSUMER_F" "$FIXTURE/f-install-1.log"
|
|
236
|
+
assert_grep "no previous install manifest" "$FIXTURE/f-install-1.log" \
|
|
237
|
+
"first run for consumer-f is a normal, unseeded additive install"
|
|
238
|
+
assert_file "$CONSUMER_F/.agents/skills/renamed-old/SKILL.md" \
|
|
239
|
+
"v1 skill mirrored before the upgrade that removes it"
|
|
240
|
+
|
|
241
|
+
run_install "$PKG_F2" "$CONSUMER_F" "$FIXTURE/f-install-2.log"
|
|
242
|
+
assert_absent "$CONSUMER_F/.agents/skills/renamed-old/SKILL.md" \
|
|
243
|
+
"genuinely stale entry cleaned up via the REAL manifest-backed path"
|
|
244
|
+
assert_grep "stale file(s) removed" "$FIXTURE/f-install-2.log" \
|
|
245
|
+
"the real manifest-backed delete pass ran and reported a removal"
|
|
246
|
+
assert_nogrep_re "seeded from known-shipped legacy paths" "$FIXTURE/f-install-2.log" \
|
|
247
|
+
"seeding branch never fires when a real prior manifest is present"
|
|
248
|
+
|
|
249
|
+
echo
|
|
250
|
+
echo "──────────────────────────────────────────────────────"
|
|
251
|
+
printf ' %d passed, %d failed\n' "$PASS" "$FAIL"
|
|
252
|
+
echo "──────────────────────────────────────────────────────"
|
|
253
|
+
|
|
254
|
+
[ "$FAIL" -eq 0 ]
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# verify-postinstall-reconcile.sh — fixture rehearsal for E26_S08_T01
|
|
4
|
+
#
|
|
5
|
+
# Rehearses the manifest-based delete reconciliation in scripts/postinstall.js
|
|
6
|
+
# against a THROWAWAY fixture consumer project, as required by E26_S08's
|
|
7
|
+
# `crucial_level: gated` note ("must be rehearsed against a throwaway fixture
|
|
8
|
+
# consumer directory before being considered done, not just unit-reasoned about").
|
|
9
|
+
#
|
|
10
|
+
# SAFETY: this script creates its own fixture root via `mktemp -d` and writes and
|
|
11
|
+
# deletes ONLY inside it. It takes no path argument and will never operate on an
|
|
12
|
+
# existing directory, so it cannot touch this repo or a real consumer project.
|
|
13
|
+
# Set KEEP_FIXTURE=1 to leave the fixture on disk for inspection.
|
|
14
|
+
#
|
|
15
|
+
# Scenarios covered
|
|
16
|
+
# A. First install with no prior manifest -> additive only, pre-existing consumer
|
|
17
|
+
# files survive, NO delete pass runs.
|
|
18
|
+
# B. Upgrade across a release that renames / removes / excludes skills -> stale
|
|
19
|
+
# package files removed from both mirror roots, consumer-authored files survive
|
|
20
|
+
# (including one planted INSIDE a package directory that is otherwise emptied),
|
|
21
|
+
# and files common to both versions are left byte- and mtime-identical.
|
|
22
|
+
#
|
|
23
|
+
# Usage: bash scripts/verify-postinstall-reconcile.sh
|
|
24
|
+
# Exit: 0 = all assertions passed, 1 = at least one failed.
|
|
25
|
+
|
|
26
|
+
set -uo pipefail
|
|
27
|
+
|
|
28
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
29
|
+
FIXTURE="$(mktemp -d "${TMPDIR:-/tmp}/jenga-postinstall-fixture.XXXXXX")"
|
|
30
|
+
|
|
31
|
+
PASS=0
|
|
32
|
+
FAIL=0
|
|
33
|
+
|
|
34
|
+
# When RESULTS_FILE is set, every verdict is also appended in a machine-readable
|
|
35
|
+
# `PASS<TAB><name>` / `FAIL<TAB><name>` form, so tests/postinstall-delete-reconciliation.bats
|
|
36
|
+
# can assert on individual named checks instead of on one opaque exit status.
|
|
37
|
+
record() { if [ -n "${RESULTS_FILE:-}" ]; then printf '%s\t%s\n' "$1" "$2" >> "$RESULTS_FILE"; fi; }
|
|
38
|
+
|
|
39
|
+
pass() { PASS=$((PASS + 1)); record PASS "$1"; printf ' \033[32mPASS\033[0m %s\n' "$1"; }
|
|
40
|
+
fail() { FAIL=$((FAIL + 1)); record FAIL "$1"; printf ' \033[31mFAIL\033[0m %s\n' "$1"; }
|
|
41
|
+
|
|
42
|
+
exists() { [ -e "$1" ]; }
|
|
43
|
+
assert_file() { if [ -f "$1" ]; then pass "$2"; else fail "$2 (missing: $1)"; fi; }
|
|
44
|
+
assert_absent() { if [ ! -e "$1" ]; then pass "$2"; else fail "$2 (still present: $1)"; fi; }
|
|
45
|
+
assert_grep() { if grep -q "$1" "$2" 2>/dev/null; then pass "$3"; else fail "$3"; fi; }
|
|
46
|
+
assert_nogrep() { if grep -q "$1" "$2" 2>/dev/null; then fail "$3"; else pass "$3"; fi; }
|
|
47
|
+
# ERE variant — needed where the pattern is a real regex rather than a literal.
|
|
48
|
+
assert_nogrep_re() { if grep -qE "$1" "$2" 2>/dev/null; then fail "$3"; else pass "$3"; fi; }
|
|
49
|
+
|
|
50
|
+
cleanup() {
|
|
51
|
+
if [ "${KEEP_FIXTURE:-0}" = "1" ]; then
|
|
52
|
+
printf '\n Fixture retained at: %s\n' "$FIXTURE"
|
|
53
|
+
else
|
|
54
|
+
# Confined to the mktemp -d created by this script itself.
|
|
55
|
+
rm -rf "$FIXTURE"
|
|
56
|
+
fi
|
|
57
|
+
}
|
|
58
|
+
trap cleanup EXIT
|
|
59
|
+
|
|
60
|
+
# ── build a fake package version ─────────────────────────────────────────────
|
|
61
|
+
# Copies the real lib/, scripts/ and templates/ from this repo (so the code under
|
|
62
|
+
# test is the real code), then lays down a small synthetic skills/ + agents/ tree.
|
|
63
|
+
make_pkg() {
|
|
64
|
+
local dir="$1" version="$2"
|
|
65
|
+
mkdir -p "$dir"
|
|
66
|
+
cp -R "$REPO_ROOT/lib" "$dir/lib"
|
|
67
|
+
cp -R "$REPO_ROOT/scripts" "$dir/scripts"
|
|
68
|
+
cp -R "$REPO_ROOT/templates" "$dir/templates" 2>/dev/null || true
|
|
69
|
+
cat > "$dir/package.json" <<JSON
|
|
70
|
+
{ "name": "@jenga-ai/agent", "version": "$version", "type": "module" }
|
|
71
|
+
JSON
|
|
72
|
+
mkdir -p "$dir/skills" "$dir/agents"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
add_skill() {
|
|
76
|
+
local dir="$1" name="$2" body="$3"
|
|
77
|
+
mkdir -p "$dir/skills/$name"
|
|
78
|
+
printf '%s\n' "$body" > "$dir/skills/$name/SKILL.md"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
run_install() {
|
|
82
|
+
local pkg="$1" consumer="$2" log="$3"
|
|
83
|
+
( cd "$pkg" && INIT_CWD="$consumer" node scripts/postinstall.js ) > "$log" 2>&1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
echo
|
|
87
|
+
echo "══ Jenga postinstall delete-reconciliation rehearsal ══"
|
|
88
|
+
echo " fixture: $FIXTURE"
|
|
89
|
+
|
|
90
|
+
# ── package v1.0.0 ───────────────────────────────────────────────────────────
|
|
91
|
+
PKG1="$FIXTURE/pkg-v1"
|
|
92
|
+
make_pkg "$PKG1" "1.0.0"
|
|
93
|
+
add_skill "$PKG1" "do" "# do — stable across both versions"
|
|
94
|
+
add_skill "$PKG1" "renamed-old" "# renamed-old — becomes renamed-new in v2"
|
|
95
|
+
add_skill "$PKG1" "j-legacy-twin" "# j-legacy-twin — excluded in v2 (E50_S06 style)"
|
|
96
|
+
printf '# developer agent v1\n' > "$PKG1/agents/developer.md"
|
|
97
|
+
|
|
98
|
+
# ── package v2.0.0 ───────────────────────────────────────────────────────────
|
|
99
|
+
PKG2="$FIXTURE/pkg-v2"
|
|
100
|
+
make_pkg "$PKG2" "2.0.0"
|
|
101
|
+
add_skill "$PKG2" "do" "# do — stable across both versions" # byte-identical
|
|
102
|
+
add_skill "$PKG2" "renamed-new" "# renamed-new — replaces renamed-old"
|
|
103
|
+
# j-legacy-twin intentionally absent (excluded), renamed-old intentionally absent
|
|
104
|
+
printf '# developer agent v2 (changed)\n' > "$PKG2/agents/developer.md"
|
|
105
|
+
|
|
106
|
+
CONSUMER="$FIXTURE/consumer"
|
|
107
|
+
mkdir -p "$CONSUMER"
|
|
108
|
+
|
|
109
|
+
# ═══ Scenario A — first install, no prior manifest ═══════════════════════════
|
|
110
|
+
echo
|
|
111
|
+
echo "── Scenario A: first install (no prior manifest) ──"
|
|
112
|
+
|
|
113
|
+
# Plant a consumer file BEFORE the very first install. A first install must never
|
|
114
|
+
# delete it, because there is no manifest telling us what we wrote before.
|
|
115
|
+
mkdir -p "$CONSUMER/.agents/skills/preexisting" "$CONSUMER/.claude/skills/preexisting"
|
|
116
|
+
printf '# consumer file that predates any jenga install\n' > "$CONSUMER/.agents/skills/preexisting/JUNK.md"
|
|
117
|
+
printf '# consumer file that predates any jenga install\n' > "$CONSUMER/.claude/skills/preexisting/JUNK.md"
|
|
118
|
+
|
|
119
|
+
run_install "$PKG1" "$CONSUMER" "$FIXTURE/install-v1.log"
|
|
120
|
+
|
|
121
|
+
assert_grep "no previous install manifest" "$FIXTURE/install-v1.log" \
|
|
122
|
+
"first install reports no delete pass"
|
|
123
|
+
assert_nogrep_re "[1-9][0-9]* stale file" "$FIXTURE/install-v1.log" \
|
|
124
|
+
"first install deletes nothing (zero stale files reported)"
|
|
125
|
+
assert_file "$CONSUMER/.agents/skills/preexisting/JUNK.md" \
|
|
126
|
+
"pre-existing consumer file survives first install (.agents)"
|
|
127
|
+
assert_file "$CONSUMER/.claude/skills/preexisting/JUNK.md" \
|
|
128
|
+
"pre-existing consumer file survives first install (.claude)"
|
|
129
|
+
assert_file "$CONSUMER/.agents/skills/renamed-old/SKILL.md" \
|
|
130
|
+
"v1 skill mirrored to .agents"
|
|
131
|
+
assert_file "$CONSUMER/.claude/skills/renamed-old/SKILL.md" \
|
|
132
|
+
"v1 skill mirrored to .claude"
|
|
133
|
+
assert_file "$CONSUMER/.agents/.jenga-postinstall-manifest.json" \
|
|
134
|
+
"manifest written to .agents on first install"
|
|
135
|
+
assert_file "$CONSUMER/.claude/.jenga-postinstall-manifest.json" \
|
|
136
|
+
"manifest written to .claude on first install"
|
|
137
|
+
assert_nogrep "preexisting/JUNK.md" "$CONSUMER/.agents/.jenga-postinstall-manifest.json" \
|
|
138
|
+
"consumer file is NOT recorded in the manifest"
|
|
139
|
+
assert_grep "skills/do/SKILL.md" "$CONSUMER/.agents/.jenga-postinstall-manifest.json" \
|
|
140
|
+
"manifest records mirrored package paths"
|
|
141
|
+
|
|
142
|
+
# ═══ Scenario B — upgrade with renames / removals / exclusions ════════════════
|
|
143
|
+
echo
|
|
144
|
+
echo "── Scenario B: upgrade 1.0.0 -> 2.0.0 ──"
|
|
145
|
+
|
|
146
|
+
# Consumer authors their own custom skill alongside package-owned ones.
|
|
147
|
+
for root in .agents .claude; do
|
|
148
|
+
mkdir -p "$CONSUMER/$root/skills/my-custom-skill"
|
|
149
|
+
printf '# my hand-authored skill\n' > "$CONSUMER/$root/skills/my-custom-skill/SKILL.md"
|
|
150
|
+
# ...and drops a note INSIDE a package directory that v2 removes entirely.
|
|
151
|
+
printf '# my notes inside a package-owned dir\n' > "$CONSUMER/$root/skills/j-legacy-twin/MY-NOTES.md"
|
|
152
|
+
done
|
|
153
|
+
|
|
154
|
+
# Record identity of a file common to both versions, to prove it is not needlessly
|
|
155
|
+
# deleted and recopied (mirror() should classify it as `skipped`).
|
|
156
|
+
DO_BEFORE="$(ls -li "$CONSUMER/.agents/skills/do/SKILL.md" | awk '{print $1, $6, $7, $8}')"
|
|
157
|
+
|
|
158
|
+
run_install "$PKG2" "$CONSUMER" "$FIXTURE/install-v2.log"
|
|
159
|
+
|
|
160
|
+
echo
|
|
161
|
+
echo " [upgrade cleanup lines]"
|
|
162
|
+
grep -E "stale file|left in place|no previous" "$FIXTURE/install-v2.log" | sed 's/^/ /'
|
|
163
|
+
echo
|
|
164
|
+
|
|
165
|
+
# 1. Stale, fully-removed package skill is gone from BOTH roots, dir pruned.
|
|
166
|
+
assert_absent "$CONSUMER/.agents/skills/renamed-old" \
|
|
167
|
+
"renamed-away skill dir removed from .agents"
|
|
168
|
+
assert_absent "$CONSUMER/.claude/skills/renamed-old" \
|
|
169
|
+
"renamed-away skill dir removed from .claude"
|
|
170
|
+
|
|
171
|
+
# 2. Excluded twin's package file is gone, but the consumer's note in the same dir
|
|
172
|
+
# survives — so the directory itself must NOT be pruned.
|
|
173
|
+
assert_absent "$CONSUMER/.agents/skills/j-legacy-twin/SKILL.md" \
|
|
174
|
+
"excluded twin's package file removed (.agents)"
|
|
175
|
+
assert_absent "$CONSUMER/.claude/skills/j-legacy-twin/SKILL.md" \
|
|
176
|
+
"excluded twin's package file removed (.claude)"
|
|
177
|
+
assert_file "$CONSUMER/.agents/skills/j-legacy-twin/MY-NOTES.md" \
|
|
178
|
+
"consumer note INSIDE the emptied package dir survives (.agents)"
|
|
179
|
+
assert_file "$CONSUMER/.claude/skills/j-legacy-twin/MY-NOTES.md" \
|
|
180
|
+
"consumer note INSIDE the emptied package dir survives (.claude)"
|
|
181
|
+
|
|
182
|
+
# 3. Consumer's own custom skill is untouched.
|
|
183
|
+
assert_file "$CONSUMER/.agents/skills/my-custom-skill/SKILL.md" \
|
|
184
|
+
"consumer custom skill survives upgrade (.agents)"
|
|
185
|
+
assert_file "$CONSUMER/.claude/skills/my-custom-skill/SKILL.md" \
|
|
186
|
+
"consumer custom skill survives upgrade (.claude)"
|
|
187
|
+
assert_file "$CONSUMER/.agents/skills/preexisting/JUNK.md" \
|
|
188
|
+
"pre-manifest consumer file still survives upgrade (.agents)"
|
|
189
|
+
assert_file "$CONSUMER/.claude/skills/preexisting/JUNK.md" \
|
|
190
|
+
"pre-manifest consumer file still survives upgrade (.claude)"
|
|
191
|
+
|
|
192
|
+
# 4. New skill arrived.
|
|
193
|
+
assert_file "$CONSUMER/.agents/skills/renamed-new/SKILL.md" \
|
|
194
|
+
"renamed-to skill installed (.agents)"
|
|
195
|
+
assert_file "$CONSUMER/.claude/skills/renamed-new/SKILL.md" \
|
|
196
|
+
"renamed-to skill installed (.claude)"
|
|
197
|
+
|
|
198
|
+
# 5. Unchanged common file was neither deleted nor recopied.
|
|
199
|
+
DO_AFTER="$(ls -li "$CONSUMER/.agents/skills/do/SKILL.md" | awk '{print $1, $6, $7, $8}')"
|
|
200
|
+
if [ "$DO_BEFORE" = "$DO_AFTER" ]; then
|
|
201
|
+
pass "file common to both versions untouched (same inode+mtime)"
|
|
202
|
+
else
|
|
203
|
+
fail "file common to both versions was rewritten ($DO_BEFORE -> $DO_AFTER)"
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
# 6. Changed common file was overwritten.
|
|
207
|
+
assert_grep "v2 (changed)" "$CONSUMER/.agents/agents/developer.md" \
|
|
208
|
+
"changed package file overwritten with v2 content"
|
|
209
|
+
|
|
210
|
+
# 7. Manifest refreshed.
|
|
211
|
+
assert_nogrep "renamed-old" "$CONSUMER/.agents/.jenga-postinstall-manifest.json" \
|
|
212
|
+
"refreshed manifest no longer lists the removed skill"
|
|
213
|
+
assert_grep "renamed-new" "$CONSUMER/.agents/.jenga-postinstall-manifest.json" \
|
|
214
|
+
"refreshed manifest lists the new skill"
|
|
215
|
+
assert_nogrep "my-custom-skill" "$CONSUMER/.agents/.jenga-postinstall-manifest.json" \
|
|
216
|
+
"refreshed manifest never records consumer-authored files"
|
|
217
|
+
|
|
218
|
+
# ═══ Scenario C — idempotent re-run of the same version ══════════════════════
|
|
219
|
+
echo
|
|
220
|
+
echo "── Scenario C: re-run same version (version gate) ──"
|
|
221
|
+
run_install "$PKG2" "$CONSUMER" "$FIXTURE/install-v2-again.log"
|
|
222
|
+
assert_grep "Nothing to do" "$FIXTURE/install-v2-again.log" \
|
|
223
|
+
"same-version re-run short-circuits before any delete pass"
|
|
224
|
+
assert_file "$CONSUMER/.agents/skills/my-custom-skill/SKILL.md" \
|
|
225
|
+
"consumer custom skill still present after re-run"
|
|
226
|
+
assert_file "$CONSUMER/.agents/skills/renamed-new/SKILL.md" \
|
|
227
|
+
"package skill still present after re-run"
|
|
228
|
+
|
|
229
|
+
# ═══ Scenario D — adversarial / corrupt manifests ════════════════════════════
|
|
230
|
+
# Exercises the guards directly: a manifest is data on the consumer's disk, so a
|
|
231
|
+
# tampered or corrupted one must never turn into an out-of-bounds or wrong-type
|
|
232
|
+
# delete. Uses a fresh consumer so it cannot disturb the scenarios above.
|
|
233
|
+
echo
|
|
234
|
+
echo "── Scenario D: adversarial + corrupt manifests ──"
|
|
235
|
+
|
|
236
|
+
CONSUMER_D="$FIXTURE/consumer-d"
|
|
237
|
+
mkdir -p "$CONSUMER_D"
|
|
238
|
+
run_install "$PKG1" "$CONSUMER_D" "$FIXTURE/d-install-v1.log"
|
|
239
|
+
|
|
240
|
+
# A file OUTSIDE the mirror roots that a traversal entry would try to reach.
|
|
241
|
+
printf 'precious\n' > "$CONSUMER_D/OUTSIDE-THE-MIRROR.txt"
|
|
242
|
+
# A symlink planted at a path the manifest will claim as ours.
|
|
243
|
+
mkdir -p "$CONSUMER_D/.agents/skills/trap"
|
|
244
|
+
ln -sf "$CONSUMER_D/OUTSIDE-THE-MIRROR.txt" "$CONSUMER_D/.agents/skills/trap/LINK.md"
|
|
245
|
+
# A directory planted where the manifest claims a file.
|
|
246
|
+
mkdir -p "$CONSUMER_D/.agents/skills/trap/DIR.md"
|
|
247
|
+
|
|
248
|
+
# Hand-craft a hostile manifest: traversal escape, symlink, directory, plus a real
|
|
249
|
+
# stale file that SHOULD still be cleaned up (proves the guards are selective, not
|
|
250
|
+
# a blanket bail-out).
|
|
251
|
+
python3 - "$CONSUMER_D" <<'PYEOF'
|
|
252
|
+
import json, sys, os
|
|
253
|
+
root = sys.argv[1]
|
|
254
|
+
m = os.path.join(root, ".agents", ".jenga-postinstall-manifest.json")
|
|
255
|
+
d = json.load(open(m))
|
|
256
|
+
d["paths"] = [
|
|
257
|
+
"../OUTSIDE-THE-MIRROR.txt", # traversal escape -> must be refused
|
|
258
|
+
"skills/trap/LINK.md", # symlink -> must be refused
|
|
259
|
+
"skills/trap/DIR.md", # directory -> must be refused
|
|
260
|
+
"skills/renamed-old/SKILL.md", # genuinely stale -> must be deleted
|
|
261
|
+
]
|
|
262
|
+
json.dump(d, open(m, "w"), indent=2)
|
|
263
|
+
PYEOF
|
|
264
|
+
|
|
265
|
+
run_install "$PKG2" "$CONSUMER_D" "$FIXTURE/d-install-v2.log"
|
|
266
|
+
|
|
267
|
+
echo
|
|
268
|
+
echo " [guard lines]"
|
|
269
|
+
grep -E "left in place|stale file" "$FIXTURE/d-install-v2.log" | sed 's/^/ /'
|
|
270
|
+
echo
|
|
271
|
+
|
|
272
|
+
assert_file "$CONSUMER_D/OUTSIDE-THE-MIRROR.txt" \
|
|
273
|
+
"traversal entry cannot delete a file outside the mirror root"
|
|
274
|
+
assert_grep "outside-dest-root" "$FIXTURE/d-install-v2.log" \
|
|
275
|
+
"traversal entry explicitly refused"
|
|
276
|
+
if [ -L "$CONSUMER_D/.agents/skills/trap/LINK.md" ]; then
|
|
277
|
+
pass "symlink entry refused, not followed or unlinked"
|
|
278
|
+
else
|
|
279
|
+
fail "symlink entry was removed"
|
|
280
|
+
fi
|
|
281
|
+
assert_grep "not-a-regular-file" "$FIXTURE/d-install-v2.log" \
|
|
282
|
+
"non-regular-file entries explicitly refused"
|
|
283
|
+
if [ -d "$CONSUMER_D/.agents/skills/trap/DIR.md" ]; then
|
|
284
|
+
pass "directory entry refused (manifests record files only)"
|
|
285
|
+
else
|
|
286
|
+
fail "directory entry was removed"
|
|
287
|
+
fi
|
|
288
|
+
assert_absent "$CONSUMER_D/.agents/skills/renamed-old/SKILL.md" \
|
|
289
|
+
"genuinely stale entry still cleaned up alongside refusals"
|
|
290
|
+
|
|
291
|
+
# Corrupt manifest must disable the delete pass entirely, not guess.
|
|
292
|
+
CONSUMER_E="$FIXTURE/consumer-e"
|
|
293
|
+
mkdir -p "$CONSUMER_E"
|
|
294
|
+
run_install "$PKG1" "$CONSUMER_E" "$FIXTURE/e-install-v1.log"
|
|
295
|
+
printf '{ this is not valid json' > "$CONSUMER_E/.agents/.jenga-postinstall-manifest.json"
|
|
296
|
+
printf '{"manifest_version": 99, "paths": ["skills/renamed-old/SKILL.md"]}' \
|
|
297
|
+
> "$CONSUMER_E/.claude/.jenga-postinstall-manifest.json"
|
|
298
|
+
run_install "$PKG2" "$CONSUMER_E" "$FIXTURE/e-install-v2.log"
|
|
299
|
+
|
|
300
|
+
assert_file "$CONSUMER_E/.agents/skills/renamed-old/SKILL.md" \
|
|
301
|
+
"corrupt manifest disables the delete pass (.agents)"
|
|
302
|
+
assert_file "$CONSUMER_E/.claude/skills/renamed-old/SKILL.md" \
|
|
303
|
+
"unknown manifest_version disables the delete pass (.claude)"
|
|
304
|
+
assert_grep "no previous install manifest" "$FIXTURE/e-install-v2.log" \
|
|
305
|
+
"unreadable manifest degrades to additive-only"
|
|
306
|
+
assert_grep "manifest_version" "$CONSUMER_E/.agents/.jenga-postinstall-manifest.json" \
|
|
307
|
+
"a fresh valid manifest is rewritten over the corrupt one"
|
|
308
|
+
|
|
309
|
+
# ═══ Scenario E — case-only rename on a case-insensitive filesystem ══════════
|
|
310
|
+
# Regression cover for tester finding 1 (E26_S08_T01 rapport). Staleness is decided
|
|
311
|
+
# by a case-SENSITIVE string set, but unlinks happen on a filesystem that may be
|
|
312
|
+
# case-INSENSITIVE (macOS APFS, Windows NTFS). After skill.md -> SKILL.md the old
|
|
313
|
+
# manifest string looks stale, yet it resolves to the file this run just wrote --
|
|
314
|
+
# so a string-only diff deletes the new file and the skill vanishes entirely.
|
|
315
|
+
echo
|
|
316
|
+
echo "── Scenario E: case-only rename (finding 1) ──"
|
|
317
|
+
|
|
318
|
+
PKG1C="$FIXTURE/pkg-v1-case"
|
|
319
|
+
PKG2C="$FIXTURE/pkg-v2-case"
|
|
320
|
+
make_pkg "$PKG1C" "1.0.0"
|
|
321
|
+
make_pkg "$PKG2C" "2.0.0"
|
|
322
|
+
mkdir -p "$PKG1C/skills/alpha" "$PKG2C/skills/alpha"
|
|
323
|
+
printf '# alpha\n' > "$PKG1C/skills/alpha/skill.md" # lowercase in v1
|
|
324
|
+
printf '# alpha\n' > "$PKG2C/skills/alpha/SKILL.md" # UPPERCASE in v2
|
|
325
|
+
|
|
326
|
+
CONSUMER_C="$FIXTURE/consumer-case"
|
|
327
|
+
mkdir -p "$CONSUMER_C"
|
|
328
|
+
run_install "$PKG1C" "$CONSUMER_C" "$FIXTURE/case-v1.log"
|
|
329
|
+
run_install "$PKG2C" "$CONSUMER_C" "$FIXTURE/case-v2.log"
|
|
330
|
+
|
|
331
|
+
# Detect whether this filesystem is even case-insensitive; on a case-SENSITIVE fs
|
|
332
|
+
# both names legitimately coexist and the old one is genuinely stale.
|
|
333
|
+
if [ -f "$CONSUMER_C/.agents/skills/alpha/skill.md" ] && \
|
|
334
|
+
[ -f "$CONSUMER_C/.agents/skills/alpha/SKILL.md" ] && \
|
|
335
|
+
[ "$(cat "$CONSUMER_C/.agents/skills/alpha/skill.md" 2>/dev/null)" != "$(cat "$CONSUMER_C/.agents/skills/alpha/SKILL.md" 2>/dev/null)" ]; then
|
|
336
|
+
CASE_INSENSITIVE=0
|
|
337
|
+
else
|
|
338
|
+
CASE_INSENSITIVE=1
|
|
339
|
+
fi
|
|
340
|
+
|
|
341
|
+
if [ "$CASE_INSENSITIVE" -eq 1 ]; then
|
|
342
|
+
# The whole point: the skill must still exist under SOME name after the upgrade.
|
|
343
|
+
if [ -f "$CONSUMER_C/.agents/skills/alpha/SKILL.md" ] || [ -f "$CONSUMER_C/.agents/skills/alpha/skill.md" ]; then
|
|
344
|
+
pass "case-only rename does not delete the just-written file (.agents)"
|
|
345
|
+
else
|
|
346
|
+
fail "case-only rename deleted the skill entirely (.agents)"
|
|
347
|
+
fi
|
|
348
|
+
if [ -f "$CONSUMER_C/.claude/skills/alpha/SKILL.md" ] || [ -f "$CONSUMER_C/.claude/skills/alpha/skill.md" ]; then
|
|
349
|
+
pass "case-only rename does not delete the just-written file (.claude)"
|
|
350
|
+
else
|
|
351
|
+
fail "case-only rename deleted the skill entirely (.claude)"
|
|
352
|
+
fi
|
|
353
|
+
assert_grep "written-this-run" "$FIXTURE/case-v2.log" \
|
|
354
|
+
"identity guard reports the spared entry as written-this-run"
|
|
355
|
+
else
|
|
356
|
+
pass "case-only rename (skipped: filesystem is case-sensitive)"
|
|
357
|
+
pass "case-only rename (skipped: filesystem is case-sensitive) (.claude)"
|
|
358
|
+
pass "identity guard reports the spared entry as written-this-run (n/a on case-sensitive fs)"
|
|
359
|
+
fi
|
|
360
|
+
|
|
361
|
+
# ═══ Scenario F — copySet entry missing from the package ═════════════════════
|
|
362
|
+
# Regression cover for tester finding 2. mirror() silently skips a missing source,
|
|
363
|
+
# so currentPaths under-reports and the delete pass would read the whole mirrored
|
|
364
|
+
# subtree as stale -- converting a bad publish into mass deletion on every consumer.
|
|
365
|
+
echo
|
|
366
|
+
echo "── Scenario F: packaging regression (finding 2) ──"
|
|
367
|
+
|
|
368
|
+
PKG2M="$FIXTURE/pkg-v2-missing"
|
|
369
|
+
make_pkg "$PKG2M" "2.0.0"
|
|
370
|
+
printf '# developer agent v2\n' > "$PKG2M/agents/developer.md"
|
|
371
|
+
rm -rf "$PKG2M/skills" # simulate skills/ omitted from the published package
|
|
372
|
+
|
|
373
|
+
CONSUMER_M="$FIXTURE/consumer-missing"
|
|
374
|
+
mkdir -p "$CONSUMER_M"
|
|
375
|
+
run_install "$PKG1" "$CONSUMER_M" "$FIXTURE/missing-v1.log"
|
|
376
|
+
run_install "$PKG2M" "$CONSUMER_M" "$FIXTURE/missing-v2.log"
|
|
377
|
+
|
|
378
|
+
assert_file "$CONSUMER_M/.agents/skills/do/SKILL.md" \
|
|
379
|
+
"missing copySet entry does not wipe the mirrored subtree (.agents)"
|
|
380
|
+
assert_file "$CONSUMER_M/.claude/skills/do/SKILL.md" \
|
|
381
|
+
"missing copySet entry does not wipe the mirrored subtree (.claude)"
|
|
382
|
+
assert_grep "Upgrade cleanup skipped" "$FIXTURE/missing-v2.log" \
|
|
383
|
+
"packaging regression reported, cleanup skipped"
|
|
384
|
+
assert_grep "skills/renamed-old/SKILL.md" "$CONSUMER_M/.agents/.jenga-postinstall-manifest.json" \
|
|
385
|
+
"previous manifest left intact (still describes what is on disk)"
|
|
386
|
+
|
|
387
|
+
echo
|
|
388
|
+
echo "══════════════════════════════════════════════════════"
|
|
389
|
+
printf ' Result: %d passed, %d failed\n' "$PASS" "$FAIL"
|
|
390
|
+
echo "══════════════════════════════════════════════════════"
|
|
391
|
+
echo
|
|
392
|
+
[ "$FAIL" -eq 0 ]
|