@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,469 @@
1
+ /**
2
+ * lib/postinstall-manifest.js — Provenance manifest for consumer postinstall mirrors
3
+ *
4
+ * Why this exists (E26_S08_T01)
5
+ * ─────────────────────────────
6
+ * `scripts/postinstall.js` mirrors `skills/` and `agents/` into a consumer's
7
+ * `.claude/` and `.agents/` discovery roots additively — it never deletes
8
+ * (`reconcileDeletes: false`, an intentional E27_S01_T01 decision). That means a
9
+ * release which renames, removes, or excludes a skill leaves the previously
10
+ * installed copies in the consumer's mirror roots forever, and both the old and
11
+ * new forms keep loading.
12
+ *
13
+ * The blanket fix — flipping `lib/mirror.js`'s `reconcileDeletes` to `true` — is
14
+ * unsafe here: that flag's delete logic diffs the destination's *current directory
15
+ * contents* against the source tree, with no notion of who wrote a given file. On a
16
+ * consumer's machine it would happily delete their own hand-authored custom skills
17
+ * sitting alongside package-owned ones.
18
+ *
19
+ * This module is the narrower alternative. It records, per destination root, the
20
+ * exact set of relative paths this package's own postinstall wrote on its last run.
21
+ * A path is eligible for deletion *only* if a manifest previously written by this
22
+ * package lists it and the current run did not write it. Anything a consumer
23
+ * authored themselves was never in a manifest, so it can never be deleted —
24
+ * regardless of where it sits.
25
+ *
26
+ * Manifest location
27
+ * ─────────────────
28
+ * One manifest per destination root, stored inside that root:
29
+ *
30
+ * <consumer>/.agents/.jenga-postinstall-manifest.json
31
+ * <consumer>/.claude/.jenga-postinstall-manifest.json
32
+ *
33
+ * Keeping it inside the root it describes means it travels with that mirror: moving
34
+ * or renaming the consumer project cannot desynchronise it, and deleting one mirror
35
+ * root without the other cannot leave a stale record of the deleted one behind.
36
+ *
37
+ * Manifest format
38
+ * ───────────────
39
+ * {
40
+ * "manifest_version": 1,
41
+ * "package": "@jenga-ai/agent",
42
+ * "package_version": "3.0.1",
43
+ * "generated_at": "2026-09-07T00:00:00.000Z",
44
+ * "dest_root": ".agents",
45
+ * "paths": ["agents/developer.md", "skills/do/SKILL.md"]
46
+ * }
47
+ *
48
+ * - `paths` are relative to the destination root, POSIX-separated, deduped and
49
+ * sorted — portable across platforms and independent of the consumer's absolute
50
+ * project path.
51
+ * - `paths` records **regular files only**, never directories. Directory removal is
52
+ * derived by pruning parent directories that become empty after the file deletes.
53
+ * This is the core safety property: a directory that still holds any consumer file
54
+ * is not empty, so it is never pruned.
55
+ * - `manifest_version` lets a future format change be detected; an unrecognised
56
+ * version is treated as "no manifest", which disables the delete pass.
57
+ *
58
+ * Safety invariants (all four hold independently)
59
+ * ───────────────────────────────────────────────
60
+ * 1. No prior manifest ⇒ no delete pass at all. First installs, and upgrades from
61
+ * any version predating this feature, are additive-only. "We don't know what we
62
+ * wrote before" is never treated as "delete everything we didn't just write".
63
+ * 2. A path is deletable only if it appears in a prior manifest written by this
64
+ * package's postinstall AND is absent from the current run's copy set.
65
+ * 3. Every candidate is boundary-checked to resolve strictly inside its destination
66
+ * root, and must be a regular file (`lstat`, so symlinks are refused, not
67
+ * followed). Directories are never deleted directly, only pruned when empty.
68
+ * 4. Every read/parse/IO failure degrades toward doing nothing: a corrupt or
69
+ * unreadable manifest disables the delete pass rather than guessing.
70
+ * 5. A candidate whose inode identity matches a file THIS run wrote is never
71
+ * deleted, even if its recorded path string differs. Path-string comparison
72
+ * alone is not sufficient on a case-insensitive filesystem (macOS APFS,
73
+ * Windows NTFS) or under Unicode normalisation differences: a case-only
74
+ * rename makes the old and new manifest strings differ while both resolve to
75
+ * the SAME file on disk, so a string-only diff would delete the very file the
76
+ * current run just wrote. Comparing dev+ino closes that whole collision
77
+ * class rather than special-casing letter case.
78
+ *
79
+ * ESM, Node built-ins only — matching `lib/mirror.js` and `scripts/postinstall.js`.
80
+ */
81
+
82
+ import fs from 'node:fs';
83
+ import path from 'node:path';
84
+
85
+ /** Filename written into each destination root. */
86
+ export const MANIFEST_FILENAME = '.jenga-postinstall-manifest.json';
87
+
88
+ /** Format version of the manifest this module writes and accepts. */
89
+ export const MANIFEST_VERSION = 1;
90
+
91
+ // ── path helpers ─────────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Boundary predicate: does `child` resolve to a path strictly inside `parent`?
95
+ *
96
+ * Deliberately a *predicate* rather than lib/mirror.js's throwing `assertInside`:
97
+ * a single suspicious manifest entry must be skipped, not allowed to abort an
98
+ * unattended `npm install`.
99
+ *
100
+ * @returns {boolean} true iff child is inside parent (parent itself → false).
101
+ */
102
+ export function isInside(parent, child) {
103
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
104
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
105
+ }
106
+
107
+ /**
108
+ * Convert absolute destination paths into manifest-shaped relative paths:
109
+ * POSIX-separated, deduped, sorted. Entries outside `destRoot` are dropped.
110
+ *
111
+ * @param {string} destRoot Absolute destination root.
112
+ * @param {string[]} absPaths Absolute paths beneath it.
113
+ * @returns {string[]}
114
+ */
115
+ export function toRelativePaths(destRoot, absPaths = []) {
116
+ const root = path.resolve(destRoot);
117
+ const out = new Set();
118
+ for (const abs of absPaths) {
119
+ if (typeof abs !== 'string' || abs.length === 0) continue;
120
+ if (!isInside(root, abs)) continue;
121
+ out.add(path.relative(root, path.resolve(abs)).split(path.sep).join('/'));
122
+ }
123
+ return [...out].sort();
124
+ }
125
+
126
+ /** Absolute path of the manifest for a given destination root. */
127
+ export function manifestPath(destRoot) {
128
+ return path.join(path.resolve(destRoot), MANIFEST_FILENAME);
129
+ }
130
+
131
+ // ── read / write ─────────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Read the manifest previously written into `destRoot`.
135
+ *
136
+ * Returns `null` for every failure mode — absent, unreadable, unparseable, wrong
137
+ * shape, or an unrecognised `manifest_version`. Callers treat `null` as "no prior
138
+ * manifest", which suppresses the delete pass entirely (invariant 1).
139
+ *
140
+ * @returns {{manifest_version: number, paths: string[]}|null}
141
+ */
142
+ export function readManifest(destRoot) {
143
+ let raw;
144
+ try {
145
+ raw = fs.readFileSync(manifestPath(destRoot), 'utf8');
146
+ } catch (_) {
147
+ return null; // absent or unreadable — first install, or pre-feature version
148
+ }
149
+
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(raw);
153
+ } catch (_) {
154
+ return null; // corrupt — refuse to infer anything from it
155
+ }
156
+
157
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
158
+ if (parsed.manifest_version !== MANIFEST_VERSION) return null;
159
+ if (!Array.isArray(parsed.paths)) return null;
160
+ if (!parsed.paths.every((p) => typeof p === 'string' && p.length > 0)) return null;
161
+
162
+ return parsed;
163
+ }
164
+
165
+ /**
166
+ * Write the manifest for `destRoot`, recording `currentPaths` as the set this run
167
+ * mirrored. Atomic: written to a temp file and renamed, so an interrupted run leaves
168
+ * either the old manifest or the new one — never a truncated one.
169
+ *
170
+ * @param {object} opts
171
+ * @param {string} opts.destRoot Absolute destination root.
172
+ * @param {string[]} opts.currentPaths Relative POSIX paths written this run.
173
+ * @param {string} [opts.packageName]
174
+ * @param {string} [opts.packageVersion]
175
+ * @param {boolean} [opts.dryRun=false]
176
+ * @returns {{path: string, count: number, written: boolean}}
177
+ */
178
+ export function writeManifest({
179
+ destRoot,
180
+ currentPaths = [],
181
+ packageName = '',
182
+ packageVersion = '',
183
+ dryRun = false,
184
+ } = {}) {
185
+ const target = manifestPath(destRoot);
186
+ const body = {
187
+ manifest_version: MANIFEST_VERSION,
188
+ package: packageName,
189
+ package_version: packageVersion,
190
+ generated_at: new Date().toISOString(),
191
+ dest_root: path.basename(path.resolve(destRoot)),
192
+ paths: [...new Set(currentPaths)].sort(),
193
+ };
194
+
195
+ if (dryRun) return { path: target, count: body.paths.length, written: false };
196
+
197
+ const tmp = `${target}.tmp-${process.pid}`;
198
+ fs.mkdirSync(path.dirname(target), { recursive: true });
199
+ fs.writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8');
200
+ fs.renameSync(tmp, target);
201
+
202
+ return { path: target, count: body.paths.length, written: true };
203
+ }
204
+
205
+ // ── delete reconciliation ────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Filesystem identity of a path: `dev:ino`. Returns null if it cannot be stat'd.
209
+ * Two paths with the same identity are the same file, regardless of how their path
210
+ * strings compare — which is exactly the case-insensitive / normalisation situation
211
+ * a string diff gets wrong.
212
+ */
213
+ function identityOf(abs) {
214
+ try {
215
+ const st = fs.lstatSync(abs);
216
+ return `${st.dev}:${st.ino}`;
217
+ } catch (_) {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Core diff/delete engine shared by `reconcileFromManifest` (E26_S08_T01) and the
224
+ * legacy-path seeding branch (E26_S08_T03): given a "prior" path list — whether read
225
+ * from a real manifest or seeded from `seedFromLegacyPaths` — delete whatever is in
226
+ * `priorPaths` but absent from `currentPaths`, applying every safety invariant, then
227
+ * prune directories left empty.
228
+ *
229
+ * Extracted as its own function (E26_S08_T03) so seeding can share the EXACT same
230
+ * validated diff/delete logic rather than re-implementing the invariant checks a
231
+ * second time — seeding only ever changes *which paths are eligible for
232
+ * consideration* (via `seedFromLegacyPaths`), never *how a candidate is checked*
233
+ * once it is under consideration. `reconcileFromManifest`'s own behavior and return
234
+ * shape are unchanged by this extraction.
235
+ *
236
+ * @param {object} opts
237
+ * @param {string} opts.root Absolute, already-resolved destination root.
238
+ * @param {string[]} opts.priorPaths Relative POSIX paths considered previously written.
239
+ * @param {string[]} opts.currentPaths Relative POSIX paths mirrored by THIS run. Must
240
+ * include paths that were byte-identical and
241
+ * therefore skipped by `mirror()` — a skipped file
242
+ * is still package-owned, and omitting it would make
243
+ * the next run consider it stale.
244
+ * @param {boolean} [opts.dryRun=false] Compute the plan without touching the filesystem.
245
+ * @returns {{deleted: string[], prunedDirs: string[], refused: Array<{path: string, reason: string}>}}
246
+ * Entries in `refused` carry why they were spared ('outside-dest-root',
247
+ * 'not-a-regular-file', 'written-this-run', ...). Paths in all three arrays are
248
+ * relative + POSIX.
249
+ */
250
+ function reconcilePriorPaths({ root, priorPaths = [], currentPaths = [], dryRun = false }) {
251
+ const result = { deleted: [], prunedDirs: [], refused: [] };
252
+
253
+ const current = new Set(currentPaths);
254
+ const stale = priorPaths.filter((p) => !current.has(p));
255
+ const dirsToConsider = new Set();
256
+
257
+ // INVARIANT 5 — identity backstop for the path-string diff above.
258
+ // `current` is a case-SENSITIVE string set, but it is used to decide unlinks on a
259
+ // filesystem that may be case-INSENSITIVE. After a case-only rename
260
+ // (skills/x/skill.md -> skills/x/SKILL.md) the two strings differ, so the old name
261
+ // looks stale — yet on macOS/Windows it resolves to the file this run just wrote,
262
+ // and deleting it removes the new file. Built lazily: costs nothing on the
263
+ // overwhelmingly common run where nothing is stale.
264
+ let currentIdentities = null;
265
+ const identitiesWrittenThisRun = () => {
266
+ if (currentIdentities === null) {
267
+ currentIdentities = new Set();
268
+ for (const rel of current) {
269
+ const id = identityOf(path.resolve(root, rel));
270
+ if (id !== null) currentIdentities.add(id);
271
+ }
272
+ }
273
+ return currentIdentities;
274
+ };
275
+
276
+ for (const rel of stale) {
277
+ const abs = path.resolve(root, rel);
278
+
279
+ // INVARIANT 3a — must resolve strictly inside the destination root.
280
+ if (!isInside(root, abs)) {
281
+ result.refused.push({ path: rel, reason: 'outside-dest-root' });
282
+ continue;
283
+ }
284
+ // Never delete our own bookkeeping file.
285
+ if (path.basename(abs) === MANIFEST_FILENAME) {
286
+ result.refused.push({ path: rel, reason: 'manifest-file' });
287
+ continue;
288
+ }
289
+
290
+ let stat;
291
+ try {
292
+ // lstat, not stat: a symlink must be refused, not followed.
293
+ stat = fs.lstatSync(abs);
294
+ } catch (_) {
295
+ continue; // already gone — nothing to do, and not an error
296
+ }
297
+
298
+ // INVARIANT 3b — manifests only ever record regular files. Anything else at this
299
+ // path is not what we wrote, so it is not ours to remove.
300
+ if (!stat.isFile()) {
301
+ result.refused.push({ path: rel, reason: 'not-a-regular-file' });
302
+ continue;
303
+ }
304
+
305
+ // INVARIANT 5 — refuse anything that IS a file this run wrote, under any name.
306
+ if (identitiesWrittenThisRun().has(`${stat.dev}:${stat.ino}`)) {
307
+ result.refused.push({ path: rel, reason: 'written-this-run' });
308
+ continue;
309
+ }
310
+
311
+ try {
312
+ if (!dryRun) fs.rmSync(abs);
313
+ result.deleted.push(rel);
314
+ dirsToConsider.add(path.dirname(abs));
315
+ } catch (e) {
316
+ // INVARIANT 4 — a single EPERM must not abort an unattended npm install.
317
+ result.refused.push({ path: rel, reason: `unlink-failed: ${e.code || e.message}` });
318
+ }
319
+ }
320
+
321
+ if (!dryRun) {
322
+ for (const dir of dirsToConsider) {
323
+ pruneEmptyDirs(root, dir, result.prunedDirs);
324
+ }
325
+ result.prunedDirs.sort();
326
+ }
327
+
328
+ return result;
329
+ }
330
+
331
+ /**
332
+ * Delete files this package's postinstall wrote on a previous run but did NOT write
333
+ * on this one, then prune any directories left empty by those deletions.
334
+ *
335
+ * @param {object} opts
336
+ * @param {string} opts.destRoot Absolute destination root (e.g. <consumer>/.agents).
337
+ * @param {string[]} opts.currentPaths Relative POSIX paths mirrored by THIS run. Must
338
+ * include paths that were byte-identical and
339
+ * therefore skipped by `mirror()` — a skipped file
340
+ * is still package-owned, and omitting it would make
341
+ * the next run consider it stale.
342
+ * @param {boolean} [opts.dryRun=false] Compute the plan without touching the filesystem.
343
+ * @returns {{deleted: string[], prunedDirs: string[], refused: Array<{path: string, reason: string}>, reason: string}}
344
+ * `reason` is 'no-prior-manifest' when the delete pass was skipped outright,
345
+ * otherwise 'reconciled'. Entries in `refused` carry why they were spared
346
+ * ('outside-dest-root', 'not-a-regular-file', 'written-this-run', ...). Paths in all three arrays are relative + POSIX.
347
+ */
348
+ export function reconcileFromManifest({ destRoot, currentPaths = [], dryRun = false } = {}) {
349
+ const root = path.resolve(destRoot);
350
+
351
+ const prior = readManifest(root);
352
+ if (prior === null) {
353
+ // INVARIANT 1 — never infer deletions without a manifest we ourselves wrote.
354
+ return { deleted: [], prunedDirs: [], refused: [], reason: 'no-prior-manifest' };
355
+ }
356
+
357
+ const core = reconcilePriorPaths({ root, priorPaths: prior.paths, currentPaths, dryRun });
358
+ return { ...core, reason: 'reconciled' };
359
+ }
360
+
361
+ /**
362
+ * Seed a synthetic "prior manifest" path list (E26_S08_T03) from a static list of paths
363
+ * known to have shipped in some prior published version, intersected with what is
364
+ * ACTUALLY a regular file on disk in `destRoot` right now. Never seeds a path that is
365
+ * not really there, and applies the identical boundary (`isInside`) and type (`lstat`
366
+ * regular-file, symlinks refused) checks the delete pass itself uses — seeding only
367
+ * narrows *which* paths are eligible for consideration, it does not relax how a
368
+ * candidate is validated once under consideration.
369
+ *
370
+ * The returned list is meant to be passed as `priorPaths` to `reconcileWithPriorPaths`
371
+ * so the very same run that first sees `legacyPaths` can adopt-then-reconcile in one
372
+ * pass, rather than requiring the consumer to upgrade twice.
373
+ *
374
+ * @param {object} opts
375
+ * @param {string} opts.destRoot Absolute destination root.
376
+ * @param {string[]} opts.legacyPaths Relative POSIX paths known to have shipped in some
377
+ * prior published version (see
378
+ * lib/legacy-shipped-paths.json /
379
+ * scripts/generate-legacy-shipped-paths.js).
380
+ * @returns {string[]} Relative POSIX paths — the subset of `legacyPaths` that both
381
+ * resolve inside `destRoot` and are a real regular file there now.
382
+ */
383
+ export function seedFromLegacyPaths({ destRoot, legacyPaths = [] } = {}) {
384
+ const root = path.resolve(destRoot);
385
+ const seeded = [];
386
+
387
+ for (const rel of legacyPaths) {
388
+ if (typeof rel !== 'string' || rel.length === 0) continue;
389
+ const abs = path.resolve(root, rel);
390
+
391
+ // Same boundary check the delete pass itself applies to every candidate.
392
+ if (!isInside(root, abs)) continue;
393
+
394
+ let stat;
395
+ try {
396
+ // lstat, not stat: a symlink must never be seeded, let alone followed.
397
+ stat = fs.lstatSync(abs);
398
+ } catch (_) {
399
+ continue; // not on disk — never seed a path that isn't really there
400
+ }
401
+ if (!stat.isFile()) continue; // regular files only, exactly like manifest `paths`
402
+
403
+ seeded.push(rel);
404
+ }
405
+
406
+ return [...new Set(seeded)].sort();
407
+ }
408
+
409
+ /**
410
+ * Public entry point (E26_S08_T03) for reconciling an explicit prior path list — used by
411
+ * `scripts/postinstall.js`'s `no-prior-manifest` branch to adopt-then-reconcile a seeded
412
+ * list (`seedFromLegacyPaths`) within the same run, rather than waiting for a manifest to
413
+ * exist first. `reconcileFromManifest` (T01) remains the entry point for the normal,
414
+ * manifest-backed case and is unchanged by this addition.
415
+ *
416
+ * @param {object} opts
417
+ * @param {string} opts.destRoot Absolute destination root.
418
+ * @param {string[]} opts.priorPaths Relative POSIX paths to treat as previously written
419
+ * (typically the output of `seedFromLegacyPaths`).
420
+ * @param {string[]} opts.currentPaths Relative POSIX paths mirrored by THIS run.
421
+ * @param {boolean} [opts.dryRun=false]
422
+ * @returns {{deleted: string[], prunedDirs: string[], refused: Array<{path: string, reason: string}>}}
423
+ */
424
+ export function reconcileWithPriorPaths({ destRoot, priorPaths = [], currentPaths = [], dryRun = false } = {}) {
425
+ const root = path.resolve(destRoot);
426
+ return reconcilePriorPaths({ root, priorPaths, currentPaths, dryRun });
427
+ }
428
+
429
+ /**
430
+ * Walk upward from `startDir`, removing directories that are empty, stopping before
431
+ * `root` (which is never removed). Only ever removes directories `readdirSync`
432
+ * reports as empty — so a directory still holding a consumer-authored file survives,
433
+ * which is how a consumer's custom skill folder inside an otherwise-removed package
434
+ * directory is preserved.
435
+ *
436
+ * Exported (E26_S08_T02) so `lib/commands/doctor.js`'s heuristic-based cleanup can prune
437
+ * directories the exact same way this module's own manifest-based delete pass does, rather
438
+ * than re-implementing the same walk-upward-while-empty logic a second time.
439
+ */
440
+ export function pruneEmptyDirs(root, startDir, pruned) {
441
+ let dir = path.resolve(startDir);
442
+ while (isInside(root, dir)) {
443
+ let entries;
444
+ try {
445
+ entries = fs.readdirSync(dir);
446
+ } catch (_) {
447
+ return; // already gone or unreadable
448
+ }
449
+ if (entries.length > 0) return; // not empty — stop, and never recurse further up
450
+ try {
451
+ fs.rmdirSync(dir);
452
+ } catch (_) {
453
+ return;
454
+ }
455
+ pruned.push(path.relative(root, dir).split(path.sep).join('/'));
456
+ dir = path.dirname(dir);
457
+ }
458
+ }
459
+
460
+ export default {
461
+ readManifest,
462
+ writeManifest,
463
+ reconcileFromManifest,
464
+ toRelativePaths,
465
+ isInside,
466
+ pruneEmptyDirs,
467
+ seedFromLegacyPaths,
468
+ reconcileWithPriorPaths,
469
+ };
@@ -1,5 +1,5 @@
1
1
  {
2
- "generated_at": "2026-09-06T15:53:37.575Z",
2
+ "generated_at": "2026-09-08T21:53:54.204Z",
3
3
  "skill_count": 36,
4
4
  "skills": [
5
5
  "brainstorm",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jenga-ai/agent",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "An agentic development workflow for Claude Code, Copilot, and Codex — with a persistent Epic/Story/Task board, an isolated git worktree per task, and a separate tester agent that runs your test suite before anything is marked done.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "scripts": {
16
16
  "postinstall": "node scripts/postinstall.js",
17
+ "generate:legacy-paths": "node scripts/generate-legacy-shipped-paths.js",
17
18
  "test": "bats tests/*.bats",
18
19
  "validate:npm-metadata": "bash scripts/validate_npm_metadata.sh",
19
20
  "ui:dev": "npm run ui:dev --prefix project/app --",