@jenga-ai/agent 3.0.0 → 3.1.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.
@@ -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 ]
@@ -151,7 +151,21 @@ if [ -z "$REPO_ROOT" ]; then
151
151
  exit 2
152
152
  fi
153
153
 
154
- WITH_LOCK="$REPO_ROOT/scripts/with-lock.sh"
154
+ # ─── Resolve with-lock.sh's package root ──────────────────────────────────
155
+ # postinstall.js mirrors only skills/ and agents/ into a consumer's .claude/
156
+ # and .agents/ — scripts/ (which owns with-lock.sh) is never copied there, so
157
+ # this script — itself shipped under skills/j-uncharted/scripts/ and mirrored
158
+ # alongside it — cannot assume "$REPO_ROOT/scripts/with-lock.sh" exists.
159
+ # Mirrors skills/init/scripts/init.sh's PKG_ROOT fallback: prefer a monorepo
160
+ # checkout's sibling scripts/ dir, else fall back to the installed npm
161
+ # package under node_modules/@jenga-ai/agent.
162
+ if [ -f "$SCRIPT_DIR/../../../scripts/with-lock.sh" ]; then
163
+ WITH_LOCK="$SCRIPT_DIR/../../../scripts/with-lock.sh"
164
+ elif [ -f "$REPO_ROOT/node_modules/@jenga-ai/agent/scripts/with-lock.sh" ]; then
165
+ WITH_LOCK="$REPO_ROOT/node_modules/@jenga-ai/agent/scripts/with-lock.sh"
166
+ else
167
+ WITH_LOCK="$REPO_ROOT/scripts/with-lock.sh"
168
+ fi
155
169
  STATE_DIR="$REPO_ROOT/project/queue/elicitation-state"
156
170
  DEFAULT_CAP=5
157
171
 
@@ -132,13 +132,15 @@ If all applicable rules pass (or the task is a legacy task), proceed to the next
132
132
 
133
133
  ### Phase 0.75 — Entry Mode Resolution
134
134
 
135
- This phase determines **how `/jenga` was invoked** and, for two of the three entry modes, produces a **scoped set** — a confirmed list of board IDs (epics/stories/tasks) that Phases 1-4 must restrict themselves to. All board scanning, ID parsing, cascade expansion, and rendering used by this phase already live in `skills/jenga/scripts/` per this repo's "Scripts Over Inline Logic" principle — this phase never re-implements any of that logic inline. The executing agent's job here is limited to: invoking the right script with the right arguments, relaying its STDOUT verbatim to the user when the contract calls for that, capturing the `STATE_FILE:` line from STDERR for the next turn, and forwarding the user's raw reply back into the next invocation unmodified.
135
+ This phase determines **how `/jenga` was invoked** and, for two of the four entry modes, produces a **scoped set** — a confirmed list of board IDs (epics/stories/tasks) that Phases 1-4 must restrict themselves to. All board scanning, ID parsing, cascade expansion, and rendering used by this phase already live in `skills/jenga/scripts/` per this repo's "Scripts Over Inline Logic" principle — this phase never re-implements any of that logic inline. The executing agent's job here is limited to: invoking the right script with the right arguments, relaying its STDOUT verbatim to the user when the contract calls for that, capturing the `STATE_FILE:` line from STDERR for the next turn, and forwarding the user's raw reply back into the next invocation unmodified.
136
136
 
137
137
  **Determine the invocation form** from the raw argument (if any) passed to `/jenga`:
138
138
 
139
139
  - No argument at all → **bare branch**.
140
140
  - The argument is the literal string `*` → **wildcard branch**.
141
- - Any other non-empty argument → **scoped branch** (treat the whole argument as the comma-separated raw ID list).
141
+ - Any other non-empty argument → invoke `skills/jenga/scripts/detect-nl-intent.sh "<raw argument>"` (E53_S01_T01) and branch on its `classification` field:
142
+ - `all_resolved` or `mixed` → **scoped branch** (below) — this is the same branch as before; only its internal mechanics changed (see below).
143
+ - `nl_intent` → **natural-language branch** (below) — new for E53_S01, no new sigil or entry point, purely a new outcome of this same argument-shape detection.
142
144
 
143
145
  #### Wildcard branch (`/jenga *`)
144
146
 
@@ -154,10 +156,35 @@ Skip both the picker and the confirmation step entirely. There is no scoped set
154
156
 
155
157
  #### Scoped branch (`/jenga <ids>`)
156
158
 
157
- 1. Invoke `skills/jenga/scripts/resolve-id.sh "<raw argument>"` directly — the picker is skipped entirely in this branch.
158
- 2. Parse the JSON array response, one object per comma-delimited input segment.
159
- - If **every** segment has `status: "resolved"`, collect their `resolved_id` values into a comma-separated list and continue to the shared confirmation step below.
160
- - If **any** segment has `status: "rejected"`, halt this phase (do not proceed to confirmation or Phase 1) and report each rejected segment's `input` and `reason` to the user verbatim, per `resolve-id.sh`'s own contract — a partial or ambiguous ID is never guessed. The user must re-invoke `/jenga <ids>` with corrected input.
159
+ This branch is entered when `detect-nl-intent.sh` (invoked above) classifies the argument as `all_resolved` or `mixed` — the picker is skipped entirely in this branch. `detect-nl-intent.sh` has already invoked `resolve-id.sh` internally and reduced its per-segment output to one of these two shapes; `skills/jenga/SKILL.md` never parses `resolve-id.sh`'s raw array itself (see `detect-nl-intent.sh`'s own header comment for the full classification contract, E53_S01_T01).
160
+
161
+ 1. On `all_resolved`, take the `resolved_ids` (or `resolved_ids_csv`) field directly from `detect-nl-intent.sh`'s output and continue to the shared confirmation step below.
162
+ 2. On `mixed`, halt this phase (do not proceed to confirmation or Phase 1) and report each entry in `detect-nl-intent.sh`'s `rejected` array — its `input` and `reason` — to the user verbatim; a partial or ambiguous ID is never guessed. The user must re-invoke `/jenga <ids>` with corrected input.
163
+
164
+ #### Natural-language branch (`/jenga <free-form text>`)
165
+
166
+ This branch is entered when `detect-nl-intent.sh` classifies the argument as `nl_intent` — every comma-delimited segment failed the ID grammar, so the raw argument is treated as natural-language intent rather than a malformed ID list. This is purely a new *outcome* of the same argument-shape detection above — no new sigil, trigger prefix, or separate entry point is introduced.
167
+
168
+ 1. **Load the catalog** — invoke `skills/jenga/scripts/load-nl-catalog.sh` with no arguments (E53_S01_T02). Its stdout is the full skill catalog (`name`/`description`/`keywords`/`examples`/`prefered_agent` per skill), sourced exclusively from `lib/generate-skill-allow-list.js`'s generated inventory — see the script's own header for the full contract. Never re-derive this catalog by re-scanning `skills/` inline.
169
+ 2. **Match** — run `skills/route/SKILL.md`'s **Step 2 — Match the Prompt to a Skill** (the three-pass keyword → example-similarity → description match, including its tie-break and no-match handling) against this catalog, treating `detect-nl-intent.sh`'s `raw_argument` field as the prompt. Reuse that section's matching logic by reference — do not re-author its prose here.
170
+ 3. **Confident single match** — report the routing decision using `skills/route/SKILL.md`'s **Step 7 — Report Routing Decision** format (substitute `/jenga` for `/route` as the invoking command named in the report), then invoke the matched skill exactly as `skills/route/SKILL.md`'s **Step 6 — Invoke the Matched Skill** already does: load `agents/<prefered_agent>.md` when the matched skill specifies `metadata.prefered_agent`, otherwise execute the skill instructions directly. The matched skill's own execution takes over from here — do not continue into this `/jenga` invocation's Phase 1.
171
+ 4. **No match, or an ambiguous multi-way tie (single-skill match)** — before surfacing `/route`'s generic disambiguation options, attempt a **playbook fallback** (E53_S02): invoke `skills/jenga/scripts/match-playbook.sh "<raw_argument>"`. This step only ever runs when step 3 above did NOT already commit to a confident single-skill match — a confident single-skill match always wins outright and this playbook fallback is never even invoked in that case. Branch on `match-playbook.sh`'s `classification` field:
172
+ - `playbook_match` → continue to **step 5 (Playbook proposal and execution)** below.
173
+ - `ambiguous` or `no_match` → continue to **step 6 (Fall through to `/route`'s disambiguation)** below — the exact behavior this branch already had before E53_S02, unchanged.
174
+ 5. **Playbook proposal and execution** — entered only on a `playbook_match` result from step 4. A proposed playbook is an ordered chain of skills (e.g. the canonical `brainstorm -> j.todo -> j.do -> j.dev-done -> j.mirror-public` chain defined in `skills/jenga/playbooks/brainstorm-to-mirror.json`) that must be confirmed, editable, and confirmable per `CLAUDE.md`'s Interaction Pattern before any step executes — the same confirm-before-execute posture `/jenga` already applies to the bare/scoped branches via `render-confirmation.sh`.
175
+ a. **Render and confirm the chain** — invoke `skills/jenga/scripts/render-playbook-confirmation.sh "<playbook_id>" "<name>" "<comma-separated steps>"` (start mode, using `match-playbook.sh`'s `playbook_id`/`name`/`steps` fields verbatim). Relay STDOUT (the numbered chain + instructions) to the user verbatim. Capture the `STATE_FILE:` path from STDERR.
176
+ b. Wait for the user's chat reply, then invoke `skills/jenga/scripts/render-playbook-confirmation.sh <state_file> "<raw_reply>"` (continue mode).
177
+ - **Toggle or error turn** (plain text on STDOUT, state file retained) — relay verbatim and return to step 5b for another reply. This loops exactly as the existing bare/scoped confirmation flow's own toggle/error turns already do.
178
+ - **Cancellation** — relay the cancellation acknowledgement and halt the entire `/jenga` invocation immediately, with no step executed — identical posture to the existing picker/confirmation cancellation edge cases already documented for the bare/scoped branches (see "## Edge Cases" below).
179
+ - **Confirmed** (JSON object on STDOUT, state file removed) — take the confirmed `steps` array (checked-only, in original playbook order) and continue to step 5c.
180
+ c. **Initialize the sequential runner** — invoke `skills/jenga/scripts/run-playbook-step.sh init "<playbook_id>" "<name>" "<comma-separated confirmed steps>"`. Its `step_ready` result names the first step to invoke.
181
+ d. **Execute steps in a loop** — for the step named by the runner's most recent `step_ready` result:
182
+ i. Invoke that step exactly as `skills/route/SKILL.md`'s **Step 6 — Invoke the Matched Skill** already does for a single matched skill: load `agents/<prefered_agent>.md` when that step's own `SKILL.md` specifies `metadata.prefered_agent`, otherwise execute its instructions directly.
183
+ ii. After that step's execution concludes, call `skills/jenga/scripts/run-playbook-step.sh advance <state_file> passed` (the step completed successfully) or `... advance <state_file> failed "<short failure note>"` (the step failed).
184
+ iii. On a `step_ready` result, repeat step 5d for the newly-named step.
185
+ iv. On a `complete` result, report the full list of completed steps to the user and stop — the playbook run is finished; do not continue into this `/jenga` invocation's Phase 1.
186
+ v. On a `halted` result, **immediately stop executing any further steps** — no silent skip-ahead. Report `failed_step`, `failed_note`, `completed` (steps that already finished), and `never_run` (steps that never got a chance to run) to the user verbatim from the halt report. Do not continue into this `/jenga` invocation's Phase 1.
187
+ 6. **Fall through to `/route`'s disambiguation** — entered when step 4 found no playbook match (`ambiguous` or `no_match`). Surface the same disambiguation options `skills/route/SKILL.md`'s **Step 2** already defines for these cases (browse `/help`, create a new skill via `/btw`, or proceed with the raw prompt) by reference to that section — do not re-copy its prose. Halt this `/jenga` invocation once the user picks an option; none of Phase 0.75's remaining steps or Phases 1-4 run for this branch.
161
188
 
162
189
  #### Shared confirmation step (bare and scoped branches only)
163
190
 
@@ -255,6 +282,11 @@ When no eligible candidates remain in Phase 4, exit and output:
255
282
  - **Bundle `/do` call failure** — treated as a skip for the entire bundle; mark all bundled tasks' status back to `Pending` and continue Phase 4 with remaining non-bundled candidates.
256
283
  - **Picker cancelled (bare branch)** — the entire `/jenga` run halts immediately after relaying the cancellation acknowledgement; no phase past 0.75 runs, and nothing on the board is modified.
257
284
  - **Confirmation cancelled (bare or scoped branch)** — same as picker cancellation: the entire `/jenga` run halts immediately; no scoped set is produced and no later phase runs.
258
- - **`resolve-id.sh` rejects one or more segments (scoped branch)** — the whole invocation halts at Phase 0.75 with the rejected segments' reasons reported verbatim; no partial scope is assembled from the segments that did resolve, and no fallback guess is made for the rejected ones. The user must re-invoke `/jenga <ids>` with corrected input.
285
+ - **`detect-nl-intent.sh` classifies the argument as `mixed` (scoped branch)** — the whole invocation halts at Phase 0.75 with each rejected segment's `input`/`reason` reported verbatim, per `detect-nl-intent.sh`'s own classification contract (E53_S01_T01); no partial scope is assembled from the segments that did resolve, and no fallback guess is made for the rejected ones. The user must re-invoke `/jenga <ids>` with corrected input.
286
+ - **`detect-nl-intent.sh` classifies the argument as `nl_intent`, no confident single-skill match, and `match-playbook.sh` (E53_S02) also finds no playbook match** — the natural-language branch's step 4 attempts the playbook fallback first (see the Natural-language branch's step 4/6), and only THEN surfaces `skills/route/SKILL.md`'s Step 2 no-match disambiguation options (browse `/help`, create a new skill via `/btw`, proceed with the raw prompt) instead of guessing; no phase past 0.75 runs until the user picks one.
287
+ - **`detect-nl-intent.sh` classifies the argument as `nl_intent`, no confident single-skill match, and `match-playbook.sh` returns an ambiguous multi-way tie between playbooks** — treated the same as the no-playbook-match case above: falls through to `skills/route/SKILL.md`'s Step 2 tie-break prompt (top candidates + a "neither, describe what you need" option) instead of guessing; no phase past 0.75 runs until the user picks one. (`match-playbook.sh`'s own `ambiguous` result — a tie between playbooks — is intentionally not given its own separate disambiguation UI; it is treated identically to `no_match` and routed to the same `/route` Step 2 fallback prose, which already has its own tie-break handling.)
288
+ - **`match-playbook.sh` returns `playbook_match` and the user confirms the full chain, and every step succeeds** — the Natural-language branch's step 5d reports the full `completed` steps list to the user and stops; `/jenga`'s own Phase 1 never runs for this invocation (execution was already fully handled by the playbook's own steps, e.g. `j.do`/`j.dev-done`).
289
+ - **`match-playbook.sh` returns `playbook_match` but the user cancels at the chain confirmation step (step 5b)** — identical posture to the existing picker/confirmation cancellation cases above: the entire `/jenga` run halts immediately after relaying the cancellation acknowledgement, with NO step of the chain executed; nothing on the board is modified by this invocation.
290
+ - **`match-playbook.sh` returns `playbook_match`, the user confirms, and a step mid-chain fails** — the Natural-language branch's step 5d(v) halts immediately on `run-playbook-step.sh`'s `halted` result: no step after the failed one runs (no silent skip-ahead), and the user is shown exactly which steps already completed, which step failed (with its note), and which steps never ran.
259
291
  - **`/jenga *` (wildcard branch)** — never produces a scoped set; Phases 1-4 run fully unrestricted over the entire board, identical to `/jenga`'s behavior before Phase 0.75 existed.
260
292
  - **Stale out-of-scope story queued in `todo.md` from an earlier run (scoped run only)** — Phase 3.5's scoped-set guard skips it entirely (not considered for bundling), so it cannot be dispatched via a bundle `/do <E##_S##>` call that would otherwise bypass Phase 4's own scoped-set exclusion; it remains untouched in `todo.md` until a future run's scope includes it.
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "brainstorm-to-mirror",
3
+ "name": "Idea to Public Release",
4
+ "description": "Takes a rough idea all the way from planning through implementation, committing, and a public mirror release -- the canonical end-to-end Jenga workflow chain.",
5
+ "keywords": [
6
+ "idea to release",
7
+ "plan and ship",
8
+ "idea to done",
9
+ "full workflow",
10
+ "end to end",
11
+ "plan build ship",
12
+ "idea to production"
13
+ ],
14
+ "examples": [
15
+ "I have an idea, help me plan it, build it, and ship it",
16
+ "take this feature from idea to committed and published",
17
+ "let's go from a rough idea all the way to a public release",
18
+ "plan this out, implement it, commit it, and push it to the public mirror",
19
+ "walk this through the whole pipeline from brainstorm to release"
20
+ ],
21
+ "steps": ["brainstorm", "todo", "do", "dev-done", "mirror-public"]
22
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://jenga.local/schemas/jenga-playbook.schema.json",
4
+ "title": "Jenga Multi-Skill Playbook",
5
+ "description": "Schema for a single multi-skill playbook definition consumed by skills/jenga/scripts/load-playbooks.sh (E53_S02_T01). A playbook is a dedicated, versionable data file describing an ORDERED chain of skills that /jenga's natural-language branch may propose (as an editable, confirmable numbered list -- see skills/jenga/scripts/render-playbook-confirmation.sh, E53_S02_T03) when free-text intent spans more than one skill and does not cleanly resolve to a single one via skills/route/SKILL.md's Step 2 matching. This file itself (schema.json) is never treated as a playbook -- load-playbooks.sh explicitly excludes it by filename when scanning skills/jenga/playbooks/*.json.",
6
+ "type": "object",
7
+ "required": ["id", "name", "description", "keywords", "examples", "steps"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "id": {
11
+ "type": "string",
12
+ "description": "Stable, unique, kebab-case identifier for this playbook (e.g. \"brainstorm-to-mirror\"). Must equal the filename's basename without the .json extension -- load-playbooks.sh validates this so a playbook's id can never silently drift from its file location.",
13
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
14
+ },
15
+ "name": {
16
+ "type": "string",
17
+ "description": "Short human-readable display name shown to the user in the confirmation prompt and in routing/report output, e.g. \"Idea to Public Release\"."
18
+ },
19
+ "description": {
20
+ "type": "string",
21
+ "description": "One-sentence explanation of what this playbook accomplishes end-to-end, used as the lowest-priority match signal (description match, same as skills/route/SKILL.md's Step 2 Pass 3) when keywords/examples don't produce a confident match."
22
+ },
23
+ "keywords": {
24
+ "type": "array",
25
+ "description": "Short phrases (1-3 words) for verbatim, case-insensitive keyword matching against the raw natural-language prompt -- the highest-priority match signal (Pass 1), mirroring skills/route/SKILL.md's Step 2 Pass 1 semantics exactly, but scoped to this playbook's catalog rather than the single-skill catalog.",
26
+ "items": { "type": "string" },
27
+ "minItems": 1
28
+ },
29
+ "examples": {
30
+ "type": "array",
31
+ "description": "Natural-language example prompts a user might type that should resolve to this playbook. Used for the semantic similarity match (Pass 2), mirroring skills/route/SKILL.md's Step 2 Pass 2 semantics. At least one example must plausibly span the full breadth of this playbook's steps (not just its first step) so it is distinguishable from a plain single-skill match.",
32
+ "items": { "type": "string" },
33
+ "minItems": 1
34
+ },
35
+ "steps": {
36
+ "type": "array",
37
+ "description": "Ordered list of bare skill names (the directory name under skills/<name>/SKILL.md, e.g. \"brainstorm\", not \"j.brainstorm\" or \"/brainstorm\") that make up this playbook's chain, in the exact execution order. Each entry MUST resolve to an existing skills/<name>/SKILL.md at load time -- load-playbooks.sh skips (with a stderr warning) any playbook referencing a nonexistent skill rather than silently including a broken chain in the catalog.",
38
+ "items": { "type": "string" },
39
+ "minItems": 2
40
+ }
41
+ }
42
+ }