@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,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
|
+
}
|
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
|
}
|