@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.
- package/README.md +10 -3
- 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/build-pages-site.sh +268 -0
- 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/skills/j-uncharted/scripts/elicitation-state.sh +15 -1
- package/skills/jenga/SKILL.md +39 -7
- package/skills/jenga/playbooks/brainstorm-to-mirror.json +22 -0
- package/skills/jenga/playbooks/schema.json +42 -0
- package/skills/jenga/scripts/detect-nl-intent.sh +179 -0
- package/skills/jenga/scripts/load-nl-catalog.js +206 -0
- package/skills/jenga/scripts/load-nl-catalog.sh +65 -0
- package/skills/jenga/scripts/load-playbooks.sh +194 -0
- package/skills/jenga/scripts/match-playbook.sh +262 -0
- package/skills/jenga/scripts/render-playbook-confirmation.sh +363 -0
- package/skills/jenga/scripts/run-playbook-step.sh +273 -0
- package/templates/copilot-instructions.md.tpl +32 -0
package/scripts/postinstall.js
CHANGED
|
@@ -24,7 +24,86 @@
|
|
|
24
24
|
*
|
|
25
25
|
* The actual filesystem mirror is delegated to `lib/mirror.js`, which is the
|
|
26
26
|
* single source of truth shared with the in-repo `/self-sync` skill. Consumer
|
|
27
|
-
* install always uses `reconcileDeletes: false` (additive-only)
|
|
27
|
+
* install always uses `reconcileDeletes: false` (additive-only) — that flag diffs
|
|
28
|
+
* the destination's current contents against the source tree with no notion of who
|
|
29
|
+
* wrote a given file, so enabling it here would delete a consumer's own custom
|
|
30
|
+
* skills. It is deliberately left off; see the manifest mechanism below.
|
|
31
|
+
*
|
|
32
|
+
* Upgrade cleanup — manifest-based delete reconciliation (E26_S08_T01)
|
|
33
|
+
* ────────────────────────────────────────────────────────────────────
|
|
34
|
+
* Additive-only mirroring means a release that renames, removes, or excludes a
|
|
35
|
+
* skill leaves the previously installed copies in the consumer's mirror roots
|
|
36
|
+
* forever, so old and new forms both keep loading. To clean those up *without*
|
|
37
|
+
* risking consumer-authored files, each run records what it wrote and the next run
|
|
38
|
+
* removes only what it itself wrote last time and did not write again.
|
|
39
|
+
*
|
|
40
|
+
* Location : one manifest per destination root, inside that root —
|
|
41
|
+
* <consumer>/.agents/.jenga-postinstall-manifest.json
|
|
42
|
+
* <consumer>/.claude/.jenga-postinstall-manifest.json
|
|
43
|
+
* Keeping it inside the root it describes means it travels with that
|
|
44
|
+
* mirror; moving or renaming the consumer project cannot desync it.
|
|
45
|
+
*
|
|
46
|
+
* Format : {
|
|
47
|
+
* "manifest_version": 1,
|
|
48
|
+
* "package": "@jenga-ai/agent",
|
|
49
|
+
* "package_version": "3.0.1",
|
|
50
|
+
* "generated_at": "<ISO 8601>",
|
|
51
|
+
* "dest_root": ".agents",
|
|
52
|
+
* "paths": ["agents/developer.md", "skills/do/SKILL.md"]
|
|
53
|
+
* }
|
|
54
|
+
* `paths` are relative to the destination root, POSIX-separated,
|
|
55
|
+
* deduped and sorted, and record REGULAR FILES ONLY — never
|
|
56
|
+
* directories. Directory removal is derived by pruning parents that
|
|
57
|
+
* become empty, which is precisely why a directory still holding a
|
|
58
|
+
* consumer file is never removed.
|
|
59
|
+
*
|
|
60
|
+
* Rule : a path is deleted only if a manifest previously written by THIS
|
|
61
|
+
* package's postinstall lists it AND the current run did not write it.
|
|
62
|
+
* If no prior manifest exists, see the legacy-path seeding note below —
|
|
63
|
+
* "we don't know what we wrote before" is never treated as "delete
|
|
64
|
+
* everything we didn't just write". Every parse/IO failure likewise
|
|
65
|
+
* degrades to additive-only.
|
|
66
|
+
*
|
|
67
|
+
* Skipped : if any `copySet` entry is missing from the package (a packaging
|
|
68
|
+
* regression), BOTH the delete pass and the manifest write are skipped
|
|
69
|
+
* for that run — otherwise the run would under-report what it wrote and
|
|
70
|
+
* read an entire mirrored subtree as stale. The previous manifest is
|
|
71
|
+
* left in place because it still describes what is on disk.
|
|
72
|
+
*
|
|
73
|
+
* Legacy-path seeding — first-manifest orphan cleanup (E26_S08_T03)
|
|
74
|
+
* ────────────────────────────────────────────────────────────────────
|
|
75
|
+
* T01's rule above is purely forward-looking: a consumer already installed BEFORE any
|
|
76
|
+
* manifest existed takes the `no-prior-manifest` branch on their first run of a fixed
|
|
77
|
+
* version, and the manifest that run writes records only what that one run mirrored —
|
|
78
|
+
* pre-existing orphans (e.g. a retired `j:`-form skill, an excluded `j-<name>` twin)
|
|
79
|
+
* were never in it, so they could never become deletion candidates on any FUTURE
|
|
80
|
+
* upgrade either. Confirmed empirically against a throwaway fixture, not merely
|
|
81
|
+
* reasoned about: such a file survived two upgrades, the second with an active delete
|
|
82
|
+
* pass, appearing 0 times in either manifest.
|
|
83
|
+
*
|
|
84
|
+
* On the `no-prior-manifest` branch, this run now additionally seeds a SYNTHETIC prior
|
|
85
|
+
* path list from `lib/legacy-shipped-paths.json` — a static, package-shipped list of
|
|
86
|
+
* paths known to have shipped in some real prior published version (see
|
|
87
|
+
* `scripts/generate-legacy-shipped-paths.js`) — intersected with what is ACTUALLY a
|
|
88
|
+
* regular file on disk in that mirror root right now
|
|
89
|
+
* (`seedFromLegacyPaths` in `lib/postinstall-manifest.js`). If anything seeds, it is
|
|
90
|
+
* reconciled against THIS run's copy set immediately (adopt-then-reconcile), so the
|
|
91
|
+
* cleanup lands on the very upgrade that introduces this feature rather than one cycle
|
|
92
|
+
* later. Provenance stays the entire compensating control: a path absent from every
|
|
93
|
+
* published version's file list is never in `lib/legacy-shipped-paths.json`, so it can
|
|
94
|
+
* never be seeded, regardless of where it sits in the mirror root — a consumer's own
|
|
95
|
+
* hand-authored file was never in any published version and stays untouchable exactly
|
|
96
|
+
* as before. A genuine first-ever install has nothing on disk to intersect with, so the
|
|
97
|
+
* seed set is naturally empty and this falls straight through to the additive-only
|
|
98
|
+
* behaviour — no separate "is this a first install" branch is needed for that to hold.
|
|
99
|
+
* The seeded reconciliation reuses the EXACT SAME boundary/type-checked delete engine
|
|
100
|
+
* as the manifest-backed path (`reconcileWithPriorPaths` shares its core with
|
|
101
|
+
* `reconcileFromManifest`), so invariants 3, 4, and 5 apply unchanged over seeded
|
|
102
|
+
* candidates too.
|
|
103
|
+
*
|
|
104
|
+
* Implementation lives in `lib/postinstall-manifest.js` (full rationale + safety
|
|
105
|
+
* invariants documented there) and `scripts/generate-legacy-shipped-paths.js` (legacy
|
|
106
|
+
* path list generation); consumer-facing docs in `docs/distribution.md`.
|
|
28
107
|
*/
|
|
29
108
|
|
|
30
109
|
import fs from 'node:fs';
|
|
@@ -32,7 +111,16 @@ import path from 'node:path';
|
|
|
32
111
|
import { fileURLToPath } from 'node:url';
|
|
33
112
|
|
|
34
113
|
import { mirror } from '../lib/mirror.js';
|
|
114
|
+
import {
|
|
115
|
+
toRelativePaths,
|
|
116
|
+
reconcileFromManifest,
|
|
117
|
+
reconcileWithPriorPaths,
|
|
118
|
+
seedFromLegacyPaths,
|
|
119
|
+
writeManifest,
|
|
120
|
+
} from '../lib/postinstall-manifest.js';
|
|
121
|
+
import { readLegacyShippedPaths } from './generate-legacy-shipped-paths.js';
|
|
35
122
|
import { generateCopilotInstructions } from '../lib/generate-copilot-instructions.js';
|
|
123
|
+
import { generateCopilotHooks } from '../lib/generate-copilot-hooks.js';
|
|
36
124
|
import { generateSkillAllowList } from '../lib/generate-skill-allow-list.js';
|
|
37
125
|
|
|
38
126
|
// ESM equivalent of __dirname
|
|
@@ -73,10 +161,12 @@ function main() {
|
|
|
73
161
|
|
|
74
162
|
// Read this package's version
|
|
75
163
|
let packageVersion = '0.0.0';
|
|
164
|
+
let packageName = '';
|
|
76
165
|
try {
|
|
77
166
|
const pkgPath = path.join(packageRoot, 'package.json');
|
|
78
167
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
79
168
|
packageVersion = pkg.version || '0.0.0';
|
|
169
|
+
packageName = pkg.name || '';
|
|
80
170
|
} catch (_) {
|
|
81
171
|
// non-fatal — proceed with default
|
|
82
172
|
}
|
|
@@ -120,14 +210,36 @@ function main() {
|
|
|
120
210
|
// Warn about any copySet entries that are missing from the package — the
|
|
121
211
|
// helper silently skips missing sources, so we surface it here for parity
|
|
122
212
|
// with the previous UX.
|
|
213
|
+
const missingEntries = [];
|
|
123
214
|
for (const entry of copySet) {
|
|
124
215
|
if (!fs.existsSync(path.join(packageRoot, entry))) {
|
|
125
216
|
console.log(` ⚠ ${entry}/ not found in package — skipped`);
|
|
217
|
+
missingEntries.push(entry);
|
|
126
218
|
}
|
|
127
219
|
}
|
|
128
220
|
|
|
221
|
+
// A copySet entry missing from the package is a PACKAGING regression, not a signal
|
|
222
|
+
// that the consumer should lose those files. mirror() silently skips a missing
|
|
223
|
+
// source, so `currentPaths` would under-report everything under that entry and the
|
|
224
|
+
// delete pass would read the whole mirrored subtree as stale and wipe it — turning
|
|
225
|
+
// a bad publish into mass deletion on every consumer that installs it.
|
|
226
|
+
//
|
|
227
|
+
// So when anything is missing we skip BOTH the delete pass and the manifest write.
|
|
228
|
+
// Leaving the previous manifest untouched is deliberate: it still accurately
|
|
229
|
+
// describes what is on disk (those files are still there, just not refreshed), so a
|
|
230
|
+
// later healthy release reconciles correctly against it. Writing an under-reporting
|
|
231
|
+
// manifest here would merely defer the same mass delete to the next run.
|
|
232
|
+
const reconcileSafe = missingEntries.length === 0;
|
|
233
|
+
if (!reconcileSafe) {
|
|
234
|
+
console.log(
|
|
235
|
+
` ⚠ Upgrade cleanup skipped — ${missingEntries.join(', ')} missing from the package. ` +
|
|
236
|
+
'Existing mirrored files are left untouched.'
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
129
240
|
let totalCopied = 0;
|
|
130
241
|
let totalSkipped = 0;
|
|
242
|
+
let totalDeleted = 0;
|
|
131
243
|
|
|
132
244
|
for (const targetRoot of targetRoots) {
|
|
133
245
|
const destRoot = path.join(consumerRoot, targetRoot);
|
|
@@ -151,6 +263,82 @@ function main() {
|
|
|
151
263
|
console.log(` ✓ ${targetRoot}/${entry}/ — ${wrote} file(s) copied`);
|
|
152
264
|
}
|
|
153
265
|
|
|
266
|
+
// ── Manifest-based delete reconciliation (E26_S08_T01) ───────────────────
|
|
267
|
+
// The copy set for manifest purposes is added + overwritten + SKIPPED. Including
|
|
268
|
+
// `skipped` is essential, not incidental: a file that was byte-identical and
|
|
269
|
+
// therefore skipped by mirror() is still a package-owned path, and leaving it out
|
|
270
|
+
// would make the very next run classify it as stale and delete it.
|
|
271
|
+
const currentPaths = toRelativePaths(destRoot, [
|
|
272
|
+
...result.added,
|
|
273
|
+
...result.overwritten,
|
|
274
|
+
...result.skipped,
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
// Deletes only ever touch paths a manifest we ourselves wrote lists and this run
|
|
278
|
+
// did not rewrite. No prior manifest => see the E26_S08_T03 seeding branch below.
|
|
279
|
+
try {
|
|
280
|
+
let recon = reconcileSafe
|
|
281
|
+
? reconcileFromManifest({ destRoot, currentPaths })
|
|
282
|
+
: { deleted: [], prunedDirs: [], refused: [], reason: 'skipped-incomplete-package' };
|
|
283
|
+
|
|
284
|
+
// ── Legacy-path seeding (E26_S08_T03) ───────────────────────────────────
|
|
285
|
+
// reconcileFromManifest returned 'no-prior-manifest' — this is either a genuine
|
|
286
|
+
// first-ever install, OR a consumer already installed before this manifest
|
|
287
|
+
// mechanism (E26_S08_T01) existed. The two are indistinguishable from a manifest
|
|
288
|
+
// alone, which is exactly the gap T03 closes: seed a SYNTHETIC prior-path list from
|
|
289
|
+
// this package's known-shipped legacy paths, intersected with what is ACTUALLY a
|
|
290
|
+
// regular file on disk right now (seedFromLegacyPaths never seeds a path that isn't
|
|
291
|
+
// really there), then reconcile against that seed in THIS SAME run
|
|
292
|
+
// (adopt-then-reconcile) rather than waiting one more upgrade cycle. A genuine
|
|
293
|
+
// first-ever install has nothing on disk to intersect with, so the seed set is
|
|
294
|
+
// naturally empty and this falls straight through to the additive-only branch below
|
|
295
|
+
// — no separate first-install check is needed for that to hold.
|
|
296
|
+
if (reconcileSafe && recon.reason === 'no-prior-manifest') {
|
|
297
|
+
const legacyPathsFile = path.join(packageRoot, 'lib', 'legacy-shipped-paths.json');
|
|
298
|
+
const legacyPaths = readLegacyShippedPaths(legacyPathsFile);
|
|
299
|
+
const seeded = seedFromLegacyPaths({ destRoot, legacyPaths });
|
|
300
|
+
if (seeded.length > 0) {
|
|
301
|
+
const seedRecon = reconcileWithPriorPaths({ destRoot, priorPaths: seeded, currentPaths });
|
|
302
|
+
// Only surface this as a distinct outcome if the seed actually produced
|
|
303
|
+
// something to report. A seeded path that is STILL part of this run's own
|
|
304
|
+
// currentPaths (e.g. a legacy path this release still ships) is neither
|
|
305
|
+
// stale nor refused — nothing happened, so the plain "no previous install
|
|
306
|
+
// manifest" additive-only message stays accurate and must not be hidden by
|
|
307
|
+
// an outcome-less 'seeded-reconciled' relabel.
|
|
308
|
+
if (seedRecon.deleted.length > 0 || seedRecon.prunedDirs.length > 0 || seedRecon.refused.length > 0) {
|
|
309
|
+
recon = { ...seedRecon, reason: 'seeded-reconciled' };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (recon.reason === 'skipped-incomplete-package') {
|
|
315
|
+
// already reported once above, per-run rather than per-root
|
|
316
|
+
} else if (recon.reason === 'no-prior-manifest') {
|
|
317
|
+
console.log(` ℹ ${targetRoot}/ — no previous install manifest; additive copy only (no deletes)`);
|
|
318
|
+
} else if (recon.deleted.length > 0 || recon.prunedDirs.length > 0) {
|
|
319
|
+
const suffix = recon.reason === 'seeded-reconciled' ? ' (seeded from known-shipped legacy paths)' : '';
|
|
320
|
+
console.log(
|
|
321
|
+
` ✓ ${targetRoot}/ — ${recon.deleted.length} stale file(s) removed${suffix}` +
|
|
322
|
+
(recon.prunedDirs.length ? `, ${recon.prunedDirs.length} empty dir(s) pruned` : '')
|
|
323
|
+
);
|
|
324
|
+
totalDeleted += recon.deleted.length;
|
|
325
|
+
}
|
|
326
|
+
for (const r of recon.refused) {
|
|
327
|
+
console.log(` ⚠ ${targetRoot}/${r.path} — left in place (${r.reason})`);
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
// Cleanup is best-effort: a reconciliation failure must never fail the install.
|
|
331
|
+
console.log(` ⚠ ${targetRoot}/ — upgrade cleanup skipped (${e.message})`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
if (reconcileSafe) {
|
|
336
|
+
writeManifest({ destRoot, currentPaths, packageName, packageVersion });
|
|
337
|
+
}
|
|
338
|
+
} catch (e) {
|
|
339
|
+
console.log(` ⚠ ${targetRoot}/ — could not write install manifest (${e.message})`);
|
|
340
|
+
}
|
|
341
|
+
|
|
154
342
|
totalCopied += result.added.length + result.overwritten.length;
|
|
155
343
|
totalSkipped += result.skipped.length;
|
|
156
344
|
}
|
|
@@ -199,11 +387,26 @@ function main() {
|
|
|
199
387
|
console.log(` ⚠ Could not bootstrap .github/copilot-instructions.md — ${e.message}`);
|
|
200
388
|
}
|
|
201
389
|
|
|
390
|
+
// Bootstrap .github/hooks/jenga.json unconditionally, same pattern as the
|
|
391
|
+
// copilot-instructions.md bootstrap immediately above (E16_S03_T04). Wires Copilot CLI's
|
|
392
|
+
// native sessionEnd/userPromptSubmitted hooks to this package's hooks/copilot_session_end.sh
|
|
393
|
+
// and hooks/prompt_router.sh, using absolute node_modules/@jenga-ai/agent paths (packageRoot
|
|
394
|
+
// !== consumerRoot here, so lib/generate-copilot-hooks.js bakes in the stable installed-package
|
|
395
|
+
// path rather than a runtime git-root lookup — see that file's own header comment for why).
|
|
396
|
+
// Entirely Jenga-owned output (unlike copilot-instructions.md), so this is a plain
|
|
397
|
+
// idempotent overwrite — no marker-merge needed.
|
|
398
|
+
try {
|
|
399
|
+
const result = generateCopilotHooks(consumerRoot, packageRoot);
|
|
400
|
+
console.log(` ✓ .github/hooks/jenga.json bootstrapped (${result.path})`);
|
|
401
|
+
} catch (e) {
|
|
402
|
+
console.log(` ⚠ Could not bootstrap .github/hooks/jenga.json — ${e.message}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
202
405
|
// Write .jenga-version to record the installed version at consumer root
|
|
203
406
|
fs.writeFileSync(versionFile, packageVersion + '\n', 'utf8');
|
|
204
407
|
|
|
205
408
|
console.log('\n──────────────────────────────────────────────────────');
|
|
206
|
-
console.log(` Summary: ${totalCopied} file(s) copied, ${totalSkipped} skipped`);
|
|
409
|
+
console.log(` Summary: ${totalCopied} file(s) copied, ${totalSkipped} skipped, ${totalDeleted} stale file(s) removed`);
|
|
207
410
|
console.log(` .jenga-version written: ${packageVersion}`);
|
|
208
411
|
console.log('──────────────────────────────────────────────────────\n');
|
|
209
412
|
}
|
|
@@ -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 ]
|