@jenga-ai/agent 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/bin/jenga.js +10 -0
- package/hooks/copilot_session_end.sh +7 -3
- package/hooks/prompt_router_helper.js +17 -5
- package/lib/commands/doctor.js +351 -0
- package/lib/commands/init.js +16 -0
- package/lib/generate-copilot-hooks.js +116 -0
- package/lib/legacy-shipped-paths.json +336 -0
- package/lib/postinstall-manifest.js +469 -0
- package/lib/skill-allow-list.json +1 -1
- package/package.json +2 -1
- package/scripts/generate-legacy-shipped-paths.js +248 -0
- package/scripts/postinstall.js +205 -2
- package/scripts/verify-legacy-seed-reconcile.sh +254 -0
- package/scripts/verify-postinstall-reconcile.sh +392 -0
- package/templates/copilot-instructions.md.tpl +32 -0
|
@@ -0,0 +1,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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jenga-ai/agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
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 --",
|