@nurix/etna 0.20.26 → 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.
Files changed (56) hide show
  1. package/README.md +15 -14
  2. package/bin/etna.js +61 -19
  3. package/data/harness-index.json +1 -1
  4. package/data/harnesses/code/codex/agents/bookkeeper.toml +1 -1
  5. package/data/harnesses/code/harness.json +6 -2
  6. package/data/harnesses/code/hooks/awareness.mjs +50 -2
  7. package/data/harnesses/code/hooks/hooks.json +1 -1
  8. package/data/harnesses/code/hooks/lib/backlog-detector.mjs +82 -0
  9. package/data/harnesses/code/hooks/lib/records-door.mjs +54 -0
  10. package/data/harnesses/code/hooks/mount.mjs +470 -0
  11. package/data/harnesses/code/skills/documentation/SKILL.md +6 -6
  12. package/data/harnesses/code/skills/documentation/references/examples.md +29 -25
  13. package/data/harnesses/code/skills/documentation/references/style-guide.md +211 -335
  14. package/data/harnesses/code/skills/documentation/references/untemplated.md +2 -1
  15. package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +33 -17
  16. package/data/harnesses/code/skills/documentation/templates/doctrine.md +15 -16
  17. package/data/harnesses/code/skills/documentation/templates/feature.md +53 -17
  18. package/data/harnesses/code/skills/documentation/templates/handover.md +1 -1
  19. package/data/harnesses/code/skills/documentation/templates/package.md +38 -15
  20. package/data/harnesses/code/skills/documentation/templates/root-readme.md +16 -16
  21. package/data/harnesses/code/skills/documentation/templates/setup.md +1 -1
  22. package/data/harnesses/code/skills/documentation/templates/system.md +37 -15
  23. package/data/harnesses/delivery/harness.json +6 -2
  24. package/data/harnesses/delivery/skills/documentation/SKILL.md +6 -6
  25. package/data/harnesses/delivery/skills/documentation/references/examples.md +29 -25
  26. package/data/harnesses/delivery/skills/documentation/references/style-guide.md +211 -335
  27. package/data/harnesses/delivery/skills/documentation/references/untemplated.md +2 -1
  28. package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +33 -17
  29. package/data/harnesses/delivery/skills/documentation/templates/doctrine.md +15 -16
  30. package/data/harnesses/delivery/skills/documentation/templates/feature.md +53 -17
  31. package/data/harnesses/delivery/skills/documentation/templates/handover.md +1 -1
  32. package/data/harnesses/delivery/skills/documentation/templates/package.md +38 -15
  33. package/data/harnesses/delivery/skills/documentation/templates/root-readme.md +16 -16
  34. package/data/harnesses/delivery/skills/documentation/templates/setup.md +1 -1
  35. package/data/harnesses/delivery/skills/documentation/templates/system.md +37 -15
  36. package/data/harnesses/discover/harness.json +4 -0
  37. package/data/harnesses/discussion/harness.json +4 -0
  38. package/data/harnesses/etna/harness.json +6 -2
  39. package/data/harnesses/etna/skills/documentation/SKILL.md +6 -6
  40. package/data/harnesses/etna/skills/documentation/references/examples.md +29 -25
  41. package/data/harnesses/etna/skills/documentation/references/style-guide.md +211 -335
  42. package/data/harnesses/etna/skills/documentation/references/untemplated.md +2 -1
  43. package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +33 -17
  44. package/data/harnesses/etna/skills/documentation/templates/doctrine.md +15 -16
  45. package/data/harnesses/etna/skills/documentation/templates/feature.md +53 -17
  46. package/data/harnesses/etna/skills/documentation/templates/handover.md +1 -1
  47. package/data/harnesses/etna/skills/documentation/templates/package.md +38 -15
  48. package/data/harnesses/etna/skills/documentation/templates/root-readme.md +16 -16
  49. package/data/harnesses/etna/skills/documentation/templates/setup.md +1 -1
  50. package/data/harnesses/etna/skills/documentation/templates/system.md +37 -15
  51. package/data/harnesses/hr/harness.json +4 -0
  52. package/data/harnesses/kb-agi/harness.json +4 -0
  53. package/lib/install/materialize.mjs +51 -23
  54. package/lib/install/mount.mjs +448 -0
  55. package/lib/install/plan.mjs +24 -29
  56. package/package.json +6 -3
@@ -10,5 +10,9 @@
10
10
  "seeds": [],
11
11
  "tools": [],
12
12
  "docsRoot": "docs",
13
+ "mount": {
14
+ "origin": "https://cdn.nustack.io",
15
+ "track": "latest"
16
+ },
13
17
  "digest": "b9b62b069087"
14
18
  }
@@ -21,7 +21,9 @@ import {
21
21
  rmSync,
22
22
  } from 'node:fs';
23
23
  import { join, dirname } from 'node:path';
24
+ import { spawnSync } from 'node:child_process';
24
25
  import { planHarnessFiles, PRUNABLE_SUBDIRS, CODEX_REBUILT_DIRS } from './plan.mjs';
26
+ import { writeIgnoreBlock, recordMount, ignoreBlockLines } from './mount.mjs';
25
27
  import { projectSkills } from '../codex/skills.mjs';
26
28
 
27
29
  // Hook scripts shipped by earlier harness versions (the legacy memory-ledger tier) and since retired.
@@ -121,25 +123,23 @@ function retireOldHooks(targetDir, bundledHarnessDir) {
121
123
  return removed;
122
124
  }
123
125
 
124
- // Artifacts a prior install leaves behind, in the order they are cheapest to trust. `.claude/` is
125
- // deliberately NOT the only one: a repo that gitignores it never commits `harness.json`, so a fresh
126
- // CI checkout of an already-harnessed repo carries no marker there and would read as first contact
127
- // on EVERY roll — which seeds pristine scaffolds over the project's real docs, backs the originals
128
- // up as .bkp, and does it again next time, since the gitignore is what stops the marker persisting.
129
- // The Codex artifacts and AGENTS.md are written by the same install and are conventionally tracked,
130
- // so any one of them present is proof this repo has been harnessed before.
131
- const PRIOR_INSTALL_MARKERS = [
132
- join('.claude', 'harness.json'),
133
- join('.codex', 'hooks.json'),
134
- join('.agents', 'skills'),
135
- 'AGENTS.md',
136
- ];
137
-
138
126
  // First contact means "this repo has never been harnessed", which is what licenses the installer to
139
- // move a colliding file aside as .bkp and write the pristine seed over it. Getting it wrong in the
140
- // permissive direction is destructive, so any evidence of a prior install settles it.
127
+ // move a colliding file aside as .bkp and write the pristine seed over it. `.claude/harness.json` is
128
+ // always committed (the ignore block negates it), so its absence alone decides.
141
129
  function isFirstContact(targetDir) {
142
- return !PRIOR_INSTALL_MARKERS.some((rel) => existsSync(join(targetDir, rel)));
130
+ return !existsSync(join(targetDir, '.claude', 'harness.json'));
131
+ }
132
+
133
+ // The ignore-block names a repository still tracks — what adopting the mount has to untrack. A
134
+ // read-only `git ls-files`; the installer never runs a git command that changes anything.
135
+ export function trackedMountedPaths(targetDir, mountedPaths) {
136
+ const listed = spawnSync('git', ['ls-files', '-z'], { cwd: targetDir, encoding: 'utf8' });
137
+ if (listed.status !== 0) return [];
138
+ const tracked = (listed.stdout || '').split('\0').filter(Boolean);
139
+ return ignoreBlockLines(mountedPaths)
140
+ .filter((line) => line.startsWith('.') && line !== '.claude/state/')
141
+ .filter((name) => tracked.some((file) => (name.endsWith('/') ? file.startsWith(name) : file === name)))
142
+ .map((name) => name.replace(/\/$/, ''));
143
143
  }
144
144
 
145
145
  // Read the `owned` manifest the PREVIOUS install recorded in the project's harness.json.
@@ -230,12 +230,14 @@ function hookScript(command) {
230
230
  // "hooks", preserving every existing key. Idempotent: for each event, an incoming entry
231
231
  // merges into the matcher-group of the same matcher, skipping any hook whose command
232
232
  // already exists there; a hook already registered for the same script under an older command
233
- // shape is replaced in place, never doubled; a new matcher creates a new group. Returns the
234
- // count of hooks added or replaced.
233
+ // shape is replaced in place, never doubled, and so is a harness script the incoming group no
234
+ // longer registers; a new matcher creates a new group. Returns the count of hooks changed.
235
235
  function mergeHooks(targetDir, bundledHooksJson) {
236
236
  if (!existsSync(bundledHooksJson)) return 0;
237
237
  const bundled = readJson(bundledHooksJson);
238
238
  const incoming = bundled.hooks || {};
239
+ const hooksDir = dirname(bundledHooksJson);
240
+ const shipped = new Set(readdirSync(hooksDir).filter((name) => /\.m?js$/.test(name)));
239
241
  const settingsPath = join(targetDir, '.claude', 'settings.json');
240
242
  const settings = readJson(settingsPath);
241
243
  settings.hooks = settings.hooks || {};
@@ -260,14 +262,23 @@ function mergeHooks(targetDir, bundledHooksJson) {
260
262
  continue;
261
263
  }
262
264
  match.hooks = match.hooks || [];
265
+ const registered = new Set((group.hooks || []).map((h) => hookScript(h.command)).filter(Boolean));
266
+ const isRetiredHere = (h) => {
267
+ const script = hookScript(h.command);
268
+ return !!script && shipped.has(script) && !registered.has(script);
269
+ };
263
270
  for (const hook of group.hooks || []) {
264
271
  if (groupHasCommand(match, hook.command)) continue;
265
272
  const script = hookScript(hook.command);
266
- const staleIndex = script ? match.hooks.findIndex((h) => hookScript(h.command) === script) : -1;
273
+ let staleIndex = script ? match.hooks.findIndex((h) => hookScript(h.command) === script) : -1;
274
+ if (staleIndex === -1) staleIndex = match.hooks.findIndex(isRetiredHere);
267
275
  if (staleIndex === -1) match.hooks.push(hook);
268
276
  else match.hooks[staleIndex] = hook;
269
277
  added += 1;
270
278
  }
279
+ const before = match.hooks.length;
280
+ match.hooks = match.hooks.filter((h) => !isRetiredHere(h));
281
+ added += before - match.hooks.length;
271
282
  }
272
283
  }
273
284
  if (added > 0) writeJson(settingsPath, settings);
@@ -290,8 +301,8 @@ function mergeHooks(targetDir, bundledHooksJson) {
290
301
  * Returns the accounting the caller renders: `installed`, `notices`, `warnings`, plus `owned` (the
291
302
  * manifest written into `.claude/harness.json`) and `meta` (the bundle marker).
292
303
  */
293
- export function materializeHarness({ bundledHarnessDir, targetDir }) {
294
- const { files, owned, meta, capabilityNotes } = planHarnessFiles(bundledHarnessDir, targetDir);
304
+ export function materializeHarness({ bundledHarnessDir, targetDir, mount }) {
305
+ const { files, owned, meta, capabilityNotes } = planHarnessFiles(bundledHarnessDir, targetDir, { mount });
295
306
 
296
307
  const installed = [];
297
308
  const notices = [...(capabilityNotes || [])];
@@ -403,6 +414,23 @@ export function materializeHarness({ bundledHarnessDir, targetDir }) {
403
414
  const added = mergeHooks(targetDir, join(bundledHarnessDir, 'hooks', 'hooks.json'));
404
415
  if (added > 0) installed.push(`${added} hook(s) → .claude/settings.json`);
405
416
 
417
+ // The mount's bookkeeping, the same the session-start hook does: the managed ignore block names
418
+ // what is mounted, and the mount record lets that hook see this version is already on disk.
419
+ // Named from the ungated plan — the same set the version manifest lists — so the block the
420
+ // installer writes and the block a session's mount writes are byte-identical.
421
+ const mountedPaths = planHarnessFiles(bundledHarnessDir)
422
+ .files.filter((file) => file.policy === 'mount')
423
+ .map((file) => file.path);
424
+ if (writeIgnoreBlock(targetDir, mountedPaths)) installed.push('mount block → .gitignore');
425
+ if (meta.version) recordMount(targetDir, { version: meta.version, digest: meta.digest || null, owned });
426
+ const adoption = trackedMountedPaths(targetDir, mountedPaths);
427
+ if (adoption.length) {
428
+ notices.push(
429
+ 'This repository still tracks mounted harness paths. To adopt the mount, untrack them ' +
430
+ '(the files stay on disk) and commit:\n' +
431
+ ` git rm -r --cached -- ${adoption.join(' ')}`,
432
+ );
433
+ }
406
434
 
407
- return { installed, notices, warnings, owned, meta };
435
+ return { installed, notices, warnings, owned, meta, mountedPaths, adoption };
408
436
  }
@@ -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
+ };