@jenga-ai/agent 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/generate-legacy-shipped-paths.js — legacy shipped-path list generator (E26_S08_T03)
4
+ *
5
+ * Why this exists
6
+ * ────────────────
7
+ * `lib/postinstall-manifest.js`'s delete reconciliation (E26_S08_T01) is purely forward-looking:
8
+ * a consumer already installed before any manifest existed can never have their pre-existing
9
+ * orphans cleaned up, because the first manifest a fixed version ever writes for them records
10
+ * only what THAT run mirrored. This script produces the static list this package ships so the
11
+ * FIRST manifest a consumer ever gets can instead be *seeded* with paths known to have shipped in
12
+ * some real prior published version — see `seedFromLegacyPaths` in `lib/postinstall-manifest.js`
13
+ * and `scripts/postinstall.js`'s `no-prior-manifest` branch for how the seed is consumed.
14
+ *
15
+ * Why not git tags
16
+ * ─────────────────
17
+ * The task's design note allows deriving the list "from git tags" as an alternative to
18
+ * publish-time derivation. Checked and rejected for this repo specifically: this repo's local
19
+ * tags (`v0.0.1`, `v55.0.1`, `last-self-sync`) do not correspond to the real npm publish history
20
+ * at all — `npm view @jenga-ai/agent versions --json` shows the real, disconnected sequence
21
+ * (1.0.0 through 3.0.0 as of this writing). Reconstructing shipped paths from local git tags in
22
+ * this repo would silently produce a list bearing no relation to what was actually published.
23
+ *
24
+ * Two modes
25
+ * ─────────
26
+ * --bootstrap Fetches EVERY version `npm view <package> versions --json` currently lists from
27
+ * the real registry, `npm pack`s each one into a throwaway temp dir, and unions
28
+ * the `skills/`+`agents/` entries inside every tarball. Network-dependent. This is
29
+ * how the real historical shipped-path set is captured — including versions whose
30
+ * git history is not reliably reconstructable locally (verified true here). Meant
31
+ * to be run manually / rarely — a one-off backfill, or an occasional resync — NOT
32
+ * wired into the automatic per-publish flow (see incremental mode below for that).
33
+ *
34
+ * (default) Incremental, no network: reads whatever is already at the output path (if any)
35
+ * and unions it with the paths CURRENTLY on disk under this repo's own `skills/`
36
+ * and `agents/` directories — i.e. "what this release is about to ship" folds into
37
+ * the running cumulative record. This is the mode wired into the publish pipeline
38
+ * (`skills/publish/scripts/npm_pipeline.sh` / `npm_ci_pipeline.sh`, via the
39
+ * `generate:legacy-paths` npm script) so every future publish keeps the list
40
+ * current with zero network dependency and zero publish-time registry flakiness.
41
+ *
42
+ * IMPORTANT — this mode's first-ever run is NOT a substitute for --bootstrap: if
43
+ * the output file doesn't exist yet, incremental mode unions an EMPTY existing set
44
+ * with whatever's on disk in THIS repo's tree right now. That happens to currently
45
+ * equal the real historical union (this repo's working tree is a superset of every
46
+ * published version's file list, as of the 2026-09-07 verification below) — but
47
+ * that is a coincidence of this repo's current state, not a guarantee the mode
48
+ * itself provides. The very first generation of the shipped artifact MUST use
49
+ * --bootstrap so the baseline is verified against the real registry, not assumed.
50
+ *
51
+ * Output
52
+ * ──────
53
+ * `lib/legacy-shipped-paths.json` (ships automatically — `lib/` is already in package.json's
54
+ * `files` allow-list, no change needed there):
55
+ *
56
+ * {
57
+ * "generated_at": "<ISO 8601>",
58
+ * "package": "@jenga-ai/agent",
59
+ * "source": "bootstrap-from-registry+incremental" | "incremental",
60
+ * "paths": ["agents/developer.md", "skills/do/SKILL.md", ...]
61
+ * }
62
+ *
63
+ * `paths` are relative to a mirror root, POSIX-separated, deduped and sorted — matching the same
64
+ * shape convention `lib/postinstall-manifest.js`'s own manifest uses, for consistency.
65
+ *
66
+ * The generation step is regenerated automatically as part of the publish flow (incremental mode),
67
+ * per this task's AC — it is NOT hand-maintained, so it cannot silently go stale across releases.
68
+ *
69
+ * ESM, Node built-ins only — matches lib/postinstall-manifest.js and lib/mirror.js. `npm` itself is
70
+ * shelled out to (via `execFileSync`) only in `--bootstrap` mode.
71
+ */
72
+
73
+ import fs from 'node:fs';
74
+ import os from 'node:os';
75
+ import path from 'node:path';
76
+ import { execFileSync } from 'node:child_process';
77
+ import { fileURLToPath } from 'node:url';
78
+
79
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
80
+ const REPO_ROOT = path.join(__dirname, '..');
81
+
82
+ export const DEFAULT_OUTPUT_PATH = path.join(REPO_ROOT, 'lib', 'legacy-shipped-paths.json');
83
+ export const DEFAULT_PACKAGE_NAME = '@jenga-ai/agent';
84
+
85
+ /** Discovery-bound directories mirrored into a consumer's .agents/ and .claude/ (see docs/distribution.md §1). */
86
+ const COPY_SET = ['skills', 'agents'];
87
+
88
+ // ── walk a real directory tree ──────────────────────────────────────────────
89
+
90
+ function walkDir(base, dir, out) {
91
+ let entries;
92
+ try {
93
+ entries = fs.readdirSync(dir, { withFileTypes: true });
94
+ } catch (_) {
95
+ return;
96
+ }
97
+ for (const entry of entries) {
98
+ const abs = path.join(dir, entry.name);
99
+ if (entry.isDirectory()) {
100
+ walkDir(base, abs, out);
101
+ } else if (entry.isFile()) {
102
+ out.push(path.relative(base, abs).split(path.sep).join('/'));
103
+ }
104
+ // symlinks intentionally ignored — matches lib/mirror.js's own walk behavior.
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Relative POSIX paths this repo's CURRENT `skills/` + `agents/` trees would ship, i.e. exactly
110
+ * what a fresh install of the version about to be published would mirror.
111
+ *
112
+ * @param {string} repoRoot
113
+ * @returns {string[]} sorted, deduped
114
+ */
115
+ export function currentShippedPaths(repoRoot = REPO_ROOT) {
116
+ const out = [];
117
+ for (const entry of COPY_SET) {
118
+ const dir = path.join(repoRoot, entry);
119
+ if (fs.existsSync(dir)) walkDir(repoRoot, dir, out);
120
+ }
121
+ return [...new Set(out)].sort();
122
+ }
123
+
124
+ // ── read existing output (if any) ───────────────────────────────────────────
125
+
126
+ function readExistingPaths(outputPath) {
127
+ try {
128
+ const parsed = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
129
+ return Array.isArray(parsed.paths) ? parsed.paths.filter((p) => typeof p === 'string') : [];
130
+ } catch (_) {
131
+ return []; // absent, unreadable, or corrupt — start from an empty cumulative set
132
+ }
133
+ }
134
+
135
+ // ── bootstrap from the real npm registry ────────────────────────────────────
136
+
137
+ /**
138
+ * Fetch every currently-listed published version of `packageName`, `npm pack` each into a
139
+ * throwaway temp dir, and union the `skills/`+`agents/` entries found inside every tarball.
140
+ * Network-dependent — intended for manual/rare use (a one-off backfill or occasional resync),
141
+ * never called by the automatic per-publish (incremental) path.
142
+ *
143
+ * @param {string} packageName
144
+ * @returns {string[]} sorted, deduped relative POSIX paths
145
+ */
146
+ export function bootstrapFromRegistry(packageName = DEFAULT_PACKAGE_NAME) {
147
+ const versionsRaw = execFileSync('npm', ['view', packageName, 'versions', '--json'], {
148
+ encoding: 'utf8',
149
+ });
150
+ const versions = JSON.parse(versionsRaw);
151
+ const all = new Set();
152
+
153
+ for (const version of versions) {
154
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'jenga-legacy-bootstrap-'));
155
+ try {
156
+ execFileSync('npm', ['pack', `${packageName}@${version}`, '--silent'], { cwd: tmp, stdio: 'ignore' });
157
+ const tarball = fs.readdirSync(tmp).find((f) => f.endsWith('.tgz'));
158
+ if (!tarball) continue;
159
+ const listing = execFileSync('tar', ['-tzf', path.join(tmp, tarball)], { encoding: 'utf8' });
160
+ for (const line of listing.split('\n')) {
161
+ const m = line.match(/^package\/(skills|agents)\/(.+)$/);
162
+ if (m && !line.endsWith('/')) all.add(`${m[1]}/${m[2]}`);
163
+ }
164
+ } finally {
165
+ fs.rmSync(tmp, { recursive: true, force: true });
166
+ }
167
+ }
168
+
169
+ return [...all].sort();
170
+ }
171
+
172
+ // ── generate ─────────────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * @param {object} [opts]
176
+ * @param {string} [opts.outputPath] Default: lib/legacy-shipped-paths.json
177
+ * @param {string} [opts.packageName] Default: @jenga-ai/agent
178
+ * @param {boolean} [opts.bootstrap] Default: false (incremental, no network)
179
+ * @param {string} [opts.repoRoot] Default: this repo's own root
180
+ * @returns {{written: boolean, path: string, count: number, source: string}}
181
+ */
182
+ export function generate({
183
+ outputPath = DEFAULT_OUTPUT_PATH,
184
+ packageName = DEFAULT_PACKAGE_NAME,
185
+ bootstrap = false,
186
+ repoRoot = REPO_ROOT,
187
+ } = {}) {
188
+ const existing = readExistingPaths(outputPath);
189
+ let paths;
190
+ let source;
191
+
192
+ if (bootstrap) {
193
+ const registryPaths = bootstrapFromRegistry(packageName);
194
+ paths = [...new Set([...registryPaths, ...existing])].sort();
195
+ source = 'bootstrap-from-registry+incremental';
196
+ } else {
197
+ const current = currentShippedPaths(repoRoot);
198
+ paths = [...new Set([...existing, ...current])].sort();
199
+ source = 'incremental';
200
+ }
201
+
202
+ const artifact = {
203
+ generated_at: new Date().toISOString(),
204
+ package: packageName,
205
+ source,
206
+ paths,
207
+ };
208
+
209
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
210
+ fs.writeFileSync(outputPath, JSON.stringify(artifact, null, 2) + '\n', 'utf8');
211
+
212
+ return { written: true, path: outputPath, count: paths.length, source };
213
+ }
214
+
215
+ /**
216
+ * Read the shipped legacy-paths artifact's `paths` array. Fail-toward-doing-nothing: any read or
217
+ * parse failure returns `[]` rather than throwing — a missing/corrupt legacy-paths file must
218
+ * never abort or degrade an unattended `npm install`, mirroring `readManifest`'s own posture in
219
+ * `lib/postinstall-manifest.js`.
220
+ *
221
+ * @param {string} artifactPath
222
+ * @returns {string[]}
223
+ */
224
+ export function readLegacyShippedPaths(artifactPath = DEFAULT_OUTPUT_PATH) {
225
+ try {
226
+ const parsed = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
227
+ if (!Array.isArray(parsed.paths)) return [];
228
+ return parsed.paths.filter((p) => typeof p === 'string' && p.length > 0);
229
+ } catch (_) {
230
+ return [];
231
+ }
232
+ }
233
+
234
+ // ── CLI guard ────────────────────────────────────────────────────────────────
235
+ // node scripts/generate-legacy-shipped-paths.js [--bootstrap] [--package <name>] [outputPath]
236
+
237
+ const invokedPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : null;
238
+ if (invokedPath === fileURLToPath(import.meta.url)) {
239
+ const argv = process.argv.slice(2);
240
+ const bootstrap = argv.includes('--bootstrap');
241
+ const pkgFlagIndex = argv.indexOf('--package');
242
+ const packageName = pkgFlagIndex !== -1 ? argv[pkgFlagIndex + 1] : DEFAULT_PACKAGE_NAME;
243
+ const positional = argv.filter((a, i) => a !== '--bootstrap' && i !== pkgFlagIndex && i !== pkgFlagIndex + 1 && !a.startsWith('--'));
244
+ const outputPath = positional[0] || DEFAULT_OUTPUT_PATH;
245
+
246
+ const result = generate({ outputPath, packageName, bootstrap });
247
+ console.log(`✓ ${result.path} (${result.count} paths, ${result.source})`);
248
+ }
@@ -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
  }