@nurix/etna 0.20.27 → 0.20.28

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,448 @@
1
+ // packages/cli/lib/install/mount.mjs
2
+ // The mount: a harness version's mounted files materialised from a version-keyed manifest and
3
+ // the machine's content-addressed cache, plus the managed `.gitignore` block and the mount record.
4
+ // The region between the shared-core markers is copied byte-for-byte into
5
+ // harness/code/hooks/mount.mjs (a bare clone has no lib/ to import); a test pins the two equal.
6
+ // Fuller account: docs/packages/cli.md § The mount.
7
+
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ renameSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
18
+ import { basename, dirname, join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { createHash } from 'node:crypto';
21
+
22
+ // --- shared mount core: start ---
23
+ const DEFAULT_ORIGIN = 'https://cdn.nustack.io';
24
+ const MOUNT_BLOCK_START = '# etna:mount:start';
25
+ const MOUNT_BLOCK_END = '# etna:mount:end';
26
+ const MOUNTED_CLAUDE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
27
+ const CONTRACT_NEGATIONS = [
28
+ '!.claude/harness.json',
29
+ '!.claude/settings.json',
30
+ '!.claude/hooks/',
31
+ '!.claude/hooks/mount.mjs',
32
+ '!.codex/hooks.json',
33
+ ];
34
+ const FETCH_CONCURRENCY = 8;
35
+ const CAPABILITY_GATES = { '.claude/rules/cloudflare.md': 'worker' };
36
+
37
+ function sha256Of(bytes) {
38
+ return createHash('sha256').update(bytes).digest('hex');
39
+ }
40
+
41
+ // The machine cache every repository on the machine shares; ETNA_CACHE_DIR relocates it.
42
+ function cacheRoot(env = process.env) {
43
+ return env.ETNA_CACHE_DIR || join(homedir(), '.etna', 'cache');
44
+ }
45
+
46
+ // True for a path the mount owns; the contract layer (the committed half) answers false.
47
+ function isMountedPath(path) {
48
+ if (path === '.claude/hooks/mount.mjs' || path === '.codex/hooks.json') return false;
49
+ const parts = path.split('/');
50
+ if (parts[0] === '.claude') return parts.length > 2 && MOUNTED_CLAUDE_SUBDIRS.includes(parts[1]);
51
+ return (parts[0] === '.agents' || parts[0] === '.codex') && parts.length > 1;
52
+ }
53
+
54
+ // Numeric dotted-version order: negative, zero or positive, like a sort comparator.
55
+ function compareVersions(a, b) {
56
+ const left = String(a).split('.').map((part) => parseInt(part, 10) || 0);
57
+ const right = String(b).split('.').map((part) => parseInt(part, 10) || 0);
58
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
59
+ const diff = (left[i] || 0) - (right[i] || 0);
60
+ if (diff !== 0) return diff;
61
+ }
62
+ return 0;
63
+ }
64
+
65
+ function readJsonFile(file) {
66
+ try {
67
+ return JSON.parse(readFileSync(file, 'utf8'));
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // Temp file then rename, so a concurrent reader sees the old file or the new one, never a torn one.
74
+ function writeAtomic(file, bytes) {
75
+ mkdirSync(dirname(file), { recursive: true });
76
+ const temp = join(dirname(file), `.${basename(file)}.mount-${process.pid}`);
77
+ writeFileSync(temp, bytes);
78
+ renameSync(temp, file);
79
+ }
80
+
81
+ async function fetchBytes(url, fetchImpl, timeoutMs) {
82
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
83
+ if (!response.ok) throw new Error(`GET ${url} answered ${response.status}`);
84
+ return Buffer.from(await response.arrayBuffer());
85
+ }
86
+
87
+ // Runs task over items with a bounded number in flight.
88
+ async function eachLimited(items, task) {
89
+ let next = 0;
90
+ const worker = async () => {
91
+ while (next < items.length) {
92
+ const item = items[next];
93
+ next += 1;
94
+ await task(item);
95
+ }
96
+ };
97
+ await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, items.length) }, worker));
98
+ }
99
+
100
+ // The version manifest, from the machine cache when present, else fetched and cached.
101
+ async function resolveManifest({ origin, slug, version, cacheDir, fetchImpl, timeoutMs }) {
102
+ const cached = join(cacheDir, slug, version, 'manifest.json');
103
+ if (existsSync(cached)) return { manifest: JSON.parse(readFileSync(cached, 'utf8')), source: 'cache' };
104
+ const bytes = await fetchBytes(`${origin}/harness/${slug}/${version}/manifest.json`, fetchImpl, timeoutMs);
105
+ const manifest = JSON.parse(bytes.toString('utf8'));
106
+ writeAtomic(cached, bytes);
107
+ return { manifest, source: 'origin' };
108
+ }
109
+
110
+ // One file's bytes by sha256: the object cache first, else fetched, verified, and cached.
111
+ async function readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }) {
112
+ const file = join(cacheDir, 'objects', entry.sha256);
113
+ if (existsSync(file)) {
114
+ const bytes = readFileSync(file);
115
+ if (sha256Of(bytes) === entry.sha256) return bytes;
116
+ }
117
+ const bytes = await fetchBytes(`${origin}/${entry.key}`, fetchImpl, timeoutMs);
118
+ if (sha256Of(bytes) !== entry.sha256) throw new Error(`${entry.path}: fetched bytes do not match the manifest`);
119
+ writeAtomic(file, bytes);
120
+ return bytes;
121
+ }
122
+
123
+ function mountedEntries(manifest) {
124
+ return (manifest.files || []).filter(
125
+ (entry) => (entry.policy === undefined || entry.policy === 'mount') && isMountedPath(entry.path),
126
+ );
127
+ }
128
+
129
+ // A Worker's config at the root, or one or two directory levels down (node_modules and dot dirs skipped).
130
+ function hasWorkerConfig(projectDir) {
131
+ const names = ['wrangler.jsonc', 'wrangler.toml'];
132
+ const search = (dir, depth) => {
133
+ if (names.some((name) => existsSync(join(dir, name)))) return true;
134
+ if (depth === 0) return false;
135
+ let entries = [];
136
+ try {
137
+ entries = readdirSync(dir, { withFileTypes: true });
138
+ } catch {
139
+ return false;
140
+ }
141
+ return entries.some(
142
+ (entry) => entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.') && search(join(dir, entry.name), depth - 1),
143
+ );
144
+ };
145
+ return search(projectDir, 2);
146
+ }
147
+
148
+ // The mounted entries this project's capabilities admit; a gated rule mounts only where its capability is.
149
+ function admittedEntries(projectDir, entries) {
150
+ const capabilities = { worker: null };
151
+ return entries.filter((entry) => {
152
+ const gate = entry.requires || CAPABILITY_GATES[entry.path];
153
+ if (!gate) return true;
154
+ if (capabilities[gate] === null) capabilities[gate] = gate === 'worker' ? hasWorkerConfig(projectDir) : false;
155
+ return capabilities[gate] === true;
156
+ });
157
+ }
158
+
159
+ function filesBelow(root, rel) {
160
+ const full = join(root, rel);
161
+ let stat;
162
+ try {
163
+ stat = statSync(full);
164
+ } catch {
165
+ return [];
166
+ }
167
+ if (!stat.isDirectory()) return [rel];
168
+ return readdirSync(full).flatMap((name) => filesBelow(root, `${rel}/${name}`));
169
+ }
170
+
171
+ // On-disk files the mount may delete: inside owned component names, and the hooks tree bar mount.mjs.
172
+ function prunableFiles(projectDir, owned) {
173
+ const out = [];
174
+ for (const sub of ['skills', 'agents', 'rules', 'scripts']) {
175
+ for (const name of owned[sub] || []) out.push(...filesBelow(projectDir, `.claude/${sub}/${name}`));
176
+ }
177
+ for (const rel of filesBelow(projectDir, '.claude/hooks')) if (rel !== '.claude/hooks/mount.mjs') out.push(rel);
178
+ for (const name of owned.skills || []) out.push(...filesBelow(projectDir, `.agents/skills/${name}`));
179
+ for (const name of owned.agents || []) {
180
+ out.push(...filesBelow(projectDir, `.codex/agents/${name.replace(/\.md$/, '')}.toml`));
181
+ }
182
+ return out.filter((rel) => rel !== '.claude/hooks');
183
+ }
184
+
185
+ function unionOwned(...maps) {
186
+ const out = {};
187
+ for (const map of maps) {
188
+ for (const [sub, names] of Object.entries(map || {})) out[sub] = [...new Set([...(out[sub] || []), ...names])];
189
+ }
190
+ return out;
191
+ }
192
+
193
+ function removeEmptyParents(projectDir, rel) {
194
+ let dir = dirname(rel);
195
+ while (dir.split('/').length > 2) {
196
+ const full = join(projectDir, dir);
197
+ try {
198
+ if (readdirSync(full).length > 0) return;
199
+ rmSync(full, { recursive: true, force: true });
200
+ } catch {
201
+ return;
202
+ }
203
+ dir = dirname(dir);
204
+ }
205
+ }
206
+
207
+ // Writes the manifest's mounted files that differ on disk, then deletes owned names the manifest omits.
208
+ async function mountFromManifest({ projectDir, manifest, origin, cacheDir, fetchImpl, timeoutMs, priorOwned }) {
209
+ const all = mountedEntries(manifest);
210
+ const entries = admittedEntries(projectDir, all);
211
+ const listed = new Set(entries.map((entry) => entry.path));
212
+ const written = [];
213
+ const failed = [];
214
+ const removed = [];
215
+ const stale = entries.filter((entry) => {
216
+ const dest = join(projectDir, entry.path);
217
+ return !(existsSync(dest) && sha256Of(readFileSync(dest)) === entry.sha256);
218
+ });
219
+ await eachLimited(stale, async (entry) => {
220
+ try {
221
+ writeAtomic(join(projectDir, entry.path), await readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }));
222
+ written.push(entry.path);
223
+ } catch {
224
+ failed.push(entry.path);
225
+ }
226
+ });
227
+ for (const rel of prunableFiles(projectDir, unionOwned(manifest.owned, priorOwned))) {
228
+ if (listed.has(rel)) continue;
229
+ rmSync(join(projectDir, rel), { force: true });
230
+ removeEmptyParents(projectDir, rel);
231
+ removed.push(rel);
232
+ }
233
+ return { written: written.sort(), failed: failed.sort(), removed, mountedPaths: all.map((entry) => entry.path) };
234
+ }
235
+
236
+ // The block's lines: contract negations, then the mounted top-level names and the state folder.
237
+ function ignoreBlockLines(mountedPaths) {
238
+ const names = new Set(['.claude/state/']);
239
+ for (const path of mountedPaths) {
240
+ const parts = path.split('/');
241
+ if (parts[0] === '.claude') names.add(parts.length > 3 ? `${parts.slice(0, 3).join('/')}/` : path);
242
+ else names.add(parts.length > 2 ? `${parts[0]}/${parts[1]}/` : path);
243
+ }
244
+ return [
245
+ MOUNT_BLOCK_START,
246
+ '# Managed by etna: the mounted harness, rewritten on every mount. Edit outside this block.',
247
+ ...CONTRACT_NEGATIONS,
248
+ ...[...names].sort(),
249
+ MOUNT_BLOCK_END,
250
+ ];
251
+ }
252
+
253
+ // Rewrites the managed block at the end of .gitignore; a bare `.claude` line becomes `.claude/*`.
254
+ function writeIgnoreBlock(projectDir, mountedPaths) {
255
+ const file = join(projectDir, '.gitignore');
256
+ const before = existsSync(file) ? readFileSync(file, 'utf8') : '';
257
+ const kept = [];
258
+ let inBlock = false;
259
+ for (const line of before.split('\n')) {
260
+ if (line.trim() === MOUNT_BLOCK_START) inBlock = true;
261
+ else if (inBlock) inBlock = line.trim() !== MOUNT_BLOCK_END;
262
+ else {
263
+ const bare = /^(\/?)\.claude\/?\s*$/.exec(line);
264
+ kept.push(bare ? `${bare[1]}.claude/*` : line);
265
+ }
266
+ }
267
+ while (kept.length && kept[kept.length - 1].trim() === '') kept.pop();
268
+ const block = ignoreBlockLines(mountedPaths).join('\n');
269
+ const after = (kept.length ? `${kept.join('\n')}\n\n` : '') + `${block}\n`;
270
+ if (after === before) return false;
271
+ writeAtomic(file, after);
272
+ return true;
273
+ }
274
+
275
+ // Records what is mounted, beside the folder's self-ignoring `*` .gitignore.
276
+ function recordMount(projectDir, record) {
277
+ const dir = join(projectDir, '.claude', 'state');
278
+ mkdirSync(dir, { recursive: true });
279
+ if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), '*\n');
280
+ const body = { ...record, mountedAt: new Date().toISOString() };
281
+ writeAtomic(join(dir, 'mount.json'), `${JSON.stringify(body, null, 2)}\n`);
282
+ }
283
+
284
+ function mountSettings(marker) {
285
+ const mount = (marker && marker.mount) || {};
286
+ return {
287
+ origin: String(mount.origin || DEFAULT_ORIGIN).replace(/\/+$/, ''),
288
+ track: mount.track === 'pinned' ? 'pinned' : 'latest',
289
+ };
290
+ }
291
+
292
+ // Mounts the pinned version unless the mount record already names it.
293
+ async function mountHarness({ projectDir, cacheDir = cacheRoot(), fetchImpl = globalThis.fetch, timeoutMs = 5000, force = false }) {
294
+ const marker = readJsonFile(join(projectDir, '.claude', 'harness.json'));
295
+ if (!marker || !marker.harness || !marker.version) return { status: 'unmarked' };
296
+ const { origin } = mountSettings(marker);
297
+ const slug = marker.harness;
298
+ const version = marker.version;
299
+ const prior = readJsonFile(join(projectDir, '.claude', 'state', 'mount.json'));
300
+ if (!force && prior && prior.version === version) return { status: 'current', slug, version, digest: prior.digest };
301
+ let resolved;
302
+ try {
303
+ resolved = await resolveManifest({ origin, slug, version, cacheDir, fetchImpl, timeoutMs });
304
+ } catch (error) {
305
+ return { status: 'unavailable', slug, version, origin, reason: error.message };
306
+ }
307
+ const { manifest, source } = resolved;
308
+ const result = await mountFromManifest({
309
+ projectDir,
310
+ manifest,
311
+ origin,
312
+ cacheDir,
313
+ fetchImpl,
314
+ timeoutMs,
315
+ priorOwned: prior && prior.owned,
316
+ });
317
+ writeIgnoreBlock(projectDir, result.mountedPaths);
318
+ if (result.failed.length === 0) {
319
+ recordMount(projectDir, { version, digest: manifest.digest || null, owned: manifest.owned || null });
320
+ }
321
+ return { status: result.failed.length ? 'partial' : 'mounted', slug, version, source, ...result };
322
+ }
323
+
324
+ async function readLatest({ origin, slug, fetchImpl, timeoutMs }) {
325
+ return JSON.parse((await fetchBytes(`${origin}/harness/${slug}/latest.json`, fetchImpl, timeoutMs)).toString('utf8'));
326
+ }
327
+
328
+ // Stages a version into the cache, the manifest verified against the pointer and written last.
329
+ async function stageVersion({ origin, slug, latest, cacheDir, fetchImpl, timeoutMs }) {
330
+ const bytes = await fetchBytes(`${origin}/${latest.manifest}`, fetchImpl, timeoutMs);
331
+ if (sha256Of(bytes) !== latest.manifestSha256) {
332
+ throw new Error(`the ${slug} ${latest.version} manifest does not match latest.json's manifestSha256 — nothing staged`);
333
+ }
334
+ const manifest = JSON.parse(bytes.toString('utf8'));
335
+ await eachLimited(mountedEntries(manifest), (entry) => readObject({ origin, entry, cacheDir, fetchImpl, timeoutMs }));
336
+ writeAtomic(join(cacheDir, slug, latest.version, 'manifest.json'), bytes);
337
+ return manifest;
338
+ }
339
+
340
+ // Asks latest.json for a newer version; stages it, and on track latest moves the pin.
341
+ async function checkForUpdate({ projectDir, cacheDir = cacheRoot(), fetchImpl = globalThis.fetch, timeoutMs = 2000 }) {
342
+ const markerFile = join(projectDir, '.claude', 'harness.json');
343
+ const marker = readJsonFile(markerFile);
344
+ if (!marker || !marker.harness || !marker.version) return { status: 'unmarked' };
345
+ const { origin, track } = mountSettings(marker);
346
+ const slug = marker.harness;
347
+ let latest;
348
+ try {
349
+ latest = await readLatest({ origin, slug, fetchImpl, timeoutMs });
350
+ } catch (error) {
351
+ return { status: 'unreachable', reason: error.message };
352
+ }
353
+ if (!latest || !latest.version || compareVersions(latest.version, marker.version) <= 0) return { status: 'current' };
354
+ if (!existsSync(join(cacheDir, slug, latest.version, 'manifest.json'))) {
355
+ try {
356
+ await stageVersion({ origin, slug, latest, cacheDir, fetchImpl, timeoutMs: Math.max(timeoutMs, 5000) });
357
+ } catch (error) {
358
+ return { status: 'refused', reason: error.message, notice: `Harness update not staged: ${error.message}` };
359
+ }
360
+ }
361
+ const mounted = marker.version;
362
+ if (track === 'latest') writeAtomic(markerFile, `${JSON.stringify({ ...marker, version: latest.version }, null, 2)}\n`);
363
+ const tail = track === 'pinned' ? ' (track: pinned — bump `version` in .claude/harness.json to take it)' : '';
364
+ return {
365
+ status: 'staged',
366
+ from: mounted,
367
+ to: latest.version,
368
+ track,
369
+ notice: `Harness ${slug} ${latest.version} is ready (mounted ${mounted}); restart the session to run on it${tail}`,
370
+ };
371
+ }
372
+ // --- shared mount core: end ---
373
+
374
+ // The manifest describing one version prefix: every planned file keyed under
375
+ // `harness/<slug>/<version>/`, with its sha256, byte size, and whether the mount or the contract owns it.
376
+ export function buildManifest(files, { slug, version, digest, placementVersion, owned }) {
377
+ const prefix = `harness/${slug}/${version}/`;
378
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
379
+ const entries = sorted.map((file) => ({
380
+ path: file.path,
381
+ key: prefix + file.path,
382
+ sha256: sha256Of(file.content),
383
+ byteSize: Buffer.byteLength(file.content),
384
+ policy: isMountedPath(file.path) ? 'mount' : 'contract',
385
+ ...(CAPABILITY_GATES[file.path] ? { requires: CAPABILITY_GATES[file.path] } : {}),
386
+ }));
387
+ const fingerprint = createHash('sha256');
388
+ for (const file of sorted) fingerprint.update(`${file.path}:${sha256Of(file.content)}\n`);
389
+ return {
390
+ slug,
391
+ version,
392
+ harnessVersion: version,
393
+ placementVersion,
394
+ digest,
395
+ fingerprint: fingerprint.digest('hex').slice(0, 12),
396
+ prefix,
397
+ fileCount: entries.length,
398
+ byteSize: entries.reduce((total, entry) => total + entry.byteSize, 0),
399
+ ...(owned ? { owned } : {}),
400
+ files: entries,
401
+ };
402
+ }
403
+
404
+ // Seeds the machine cache from a planned file list, so a later session mounts this version offline.
405
+ export function warmCacheFromPlan({ files, manifest, cacheDir = cacheRoot() }) {
406
+ for (const file of files) {
407
+ if (!isMountedPath(file.path)) continue;
408
+ const object = join(cacheDir, 'objects', sha256Of(file.content));
409
+ if (!existsSync(object)) writeAtomic(object, file.content);
410
+ }
411
+ const manifestFile = join(cacheDir, manifest.slug, manifest.version, 'manifest.json');
412
+ if (!existsSync(manifestFile)) writeAtomic(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
413
+ }
414
+
415
+ // Whether the origin serves this version's manifest: 'published', 'unpublished', or 'unreachable'.
416
+ export async function probeOrigin({ origin, slug, version, fetchImpl = globalThis.fetch, timeoutMs = 3000 }) {
417
+ try {
418
+ const response = await fetchImpl(`${origin}/harness/${slug}/${version}/manifest.json`, {
419
+ method: 'HEAD',
420
+ signal: AbortSignal.timeout(timeoutMs),
421
+ });
422
+ return response.ok ? 'published' : 'unpublished';
423
+ } catch {
424
+ return 'unreachable';
425
+ }
426
+ }
427
+
428
+ export {
429
+ DEFAULT_ORIGIN,
430
+ MOUNT_BLOCK_START,
431
+ MOUNT_BLOCK_END,
432
+ sha256Of,
433
+ cacheRoot,
434
+ isMountedPath,
435
+ hasWorkerConfig,
436
+ compareVersions,
437
+ mountSettings,
438
+ resolveManifest,
439
+ readObject,
440
+ mountFromManifest,
441
+ ignoreBlockLines,
442
+ writeIgnoreBlock,
443
+ recordMount,
444
+ mountHarness,
445
+ readLatest,
446
+ stageVersion,
447
+ checkForUpdate,
448
+ };
@@ -21,11 +21,13 @@
21
21
  // project's living copy, never refreshed and never pruned.
22
22
  // merge — folded into whatever the project already has, key by key. Only `.claude/settings.json`,
23
23
  // which carries the user's own hooks and permissions beside the harness's.
24
+ // mount — the gitignored harness (`isMountedPath`), written here and at session start by `mount.mjs`.
24
25
 
25
26
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
26
27
  import { join, relative, sep } from 'node:path';
27
28
  import { buildCodexAdapter } from '../codex/agents-md.mjs';
28
29
  import { toCodexCommand } from '../codex/hooks.mjs';
30
+ import { isMountedPath, hasWorkerConfig, DEFAULT_ORIGIN } from './mount.mjs';
29
31
 
30
32
  // Subfolders bundled inside each harness payload that map 1:1 onto <project>/.claude/<name>/.
31
33
  // Order matches Claude Code's own folder layout. hooks/ carries the hook scripts + lib/ (and
@@ -166,30 +168,7 @@ function codexHooks(bundleDir) {
166
168
  const CAPABILITY_RULES = { 'cloudflare.md': targetHasWorkerConfig };
167
169
 
168
170
  function targetHasWorkerConfig(targetDir) {
169
- const names = ['wrangler.jsonc', 'wrangler.toml'];
170
- if (names.some((n) => existsSync(join(targetDir, n)))) return true;
171
- let entries = [];
172
- try {
173
- entries = readdirSync(targetDir, { withFileTypes: true });
174
- } catch {
175
- return false;
176
- }
177
- for (const e of entries) {
178
- if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue;
179
- if (names.some((n) => existsSync(join(targetDir, e.name, n)))) return true;
180
- // one more level: packages/*/wrangler.jsonc — the common monorepo shape
181
- let sub = [];
182
- try {
183
- sub = readdirSync(join(targetDir, e.name), { withFileTypes: true });
184
- } catch {
185
- continue;
186
- }
187
- for (const s of sub) {
188
- if (!s.isDirectory() || s.name === 'node_modules' || s.name.startsWith('.')) continue;
189
- if (names.some((n) => existsSync(join(targetDir, e.name, s.name, n)))) return true;
190
- }
191
- }
192
- return false;
171
+ return hasWorkerConfig(targetDir);
193
172
  }
194
173
 
195
174
  // A rule the target's capabilities exclude. No targetDir (payload verification, byte-identity
@@ -209,19 +188,32 @@ function ruleExcluded(sub, rel, targetDir) {
209
188
  //
210
189
  // Read from the target, never from the bundle. An absent or unreadable marker returns nothing and
211
190
  // the manifest default stands.
191
+ // `mount.origin` and `mount.track` are consumer-owned the same way.
212
192
  function consumerOwnedMarkerKeys(targetDir) {
213
193
  if (!targetDir) return {};
214
194
  try {
215
195
  const prior = JSON.parse(readFileSync(join(targetDir, '.claude', 'harness.json'), 'utf8'));
216
- return typeof prior.docsRoot === 'string' && prior.docsRoot.trim()
217
- ? { docsRoot: prior.docsRoot.trim() }
218
- : {};
196
+ const keys = {};
197
+ if (typeof prior.docsRoot === 'string' && prior.docsRoot.trim()) keys.docsRoot = prior.docsRoot.trim();
198
+ const mount = prior.mount && typeof prior.mount === 'object' ? prior.mount : {};
199
+ if (typeof mount.origin === 'string' && mount.origin.trim()) keys.origin = mount.origin.trim();
200
+ if (mount.track === 'latest' || mount.track === 'pinned') keys.track = mount.track;
201
+ return keys;
219
202
  } catch (e) {
220
203
  return {};
221
204
  }
222
205
  }
223
206
 
224
- export function planHarnessFiles(bundleDir, targetDir = null) {
207
+ // The marker's `mount` block: the CLI's flags, else the consumer's value, else the bundle's, else the defaults.
208
+ function markerMount(meta, prior, override = {}) {
209
+ const bundled = meta.mount && typeof meta.mount === 'object' ? meta.mount : {};
210
+ return {
211
+ origin: override.origin || prior.origin || bundled.origin || DEFAULT_ORIGIN,
212
+ track: override.track || prior.track || bundled.track || 'latest',
213
+ };
214
+ }
215
+
216
+ export function planHarnessFiles(bundleDir, targetDir = null, options = {}) {
225
217
  const claudeMdPath = join(bundleDir, 'CLAUDE.md');
226
218
  if (!existsSync(claudeMdPath)) {
227
219
  throw new Error(`Bundled CLAUDE.md missing at ${claudeMdPath}. Rebuild package data.`);
@@ -300,9 +292,11 @@ export function planHarnessFiles(bundleDir, targetDir = null) {
300
292
  const markerPath = join(bundleDir, 'harness.json');
301
293
  const meta = existsSync(markerPath) ? JSON.parse(readFileSync(markerPath, 'utf8')) : {};
302
294
  if (existsSync(markerPath)) {
295
+ const { origin, track, ...prior } = consumerOwnedMarkerKeys(targetDir);
296
+ const mount = markerMount(meta, { origin, track }, options.mount);
303
297
  files.push({
304
298
  path: '.claude/harness.json',
305
- content: asJson({ ...meta, ...consumerOwnedMarkerKeys(targetDir), owned }),
299
+ content: asJson({ ...meta, ...prior, mount, owned }),
306
300
  policy: 'own',
307
301
  });
308
302
  }
@@ -318,6 +312,7 @@ export function planHarnessFiles(bundleDir, targetDir = null) {
318
312
  // Sorted so the plan is byte-stable across machines — readdir order is not guaranteed, and an
319
313
  // unstable order would make two identical builds look different to anything comparing them.
320
314
  files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
315
+ for (const file of files) if (file.policy === 'own' && isMountedPath(file.path)) file.policy = 'mount';
321
316
 
322
317
  const seen = new Set();
323
318
  for (const file of files) {
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.20.27",
4
- "description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
3
+ "version": "0.20.28",
4
+ "description": "Bootstrap a project with a Nurix harness — writes the committed contract (CLAUDE.md, AGENTS.md, .claude/harness.json, settings.json, the mount hook) and mounts the harness itself, gitignored, from a bundled payload; sessions keep it current from the CDN.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "etna": "bin/etna.js"
8
8
  },
9
+ "exports": {
10
+ "./mount": "./lib/install/mount.mjs"
11
+ },
9
12
  "dependencies": {
10
13
  "@clack/prompts": "^0.11.0"
11
14
  },
@@ -43,5 +46,5 @@
43
46
  "agent",
44
47
  "plugin"
45
48
  ],
46
- "etnaFingerprint": "aa072fd57bad"
49
+ "etnaFingerprint": "47047f5258d8"
47
50
  }