@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.
@@ -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 ]