@nurix/etna 0.19.64 → 0.19.66

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 (63) hide show
  1. package/bin/etna.js +155 -557
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/apollo/CLAUDE.md +1 -1
  4. package/data/harnesses/apollo/agents/maintenance.md +1 -0
  5. package/data/harnesses/apollo/harness.json +2 -2
  6. package/data/harnesses/apollo/hooks/approved-packages.md +2 -0
  7. package/data/harnesses/apollo/rules/changelog.md +2 -0
  8. package/data/harnesses/apollo/skills/documentation/SKILL.md +1 -0
  9. package/data/harnesses/apollo/skills/documentation/references/frontmatter.md +1 -1
  10. package/data/harnesses/apollo/skills/documentation/references/style-guide.md +34 -1
  11. package/data/harnesses/apollo/skills/documentation/scripts/doc-lint.mjs +1 -1
  12. package/data/harnesses/apollo/skills/documentation/templates/Readme.md +1 -0
  13. package/data/harnesses/apollo/skills/documentation/templates/behavioral-checklist.md +2 -0
  14. package/data/harnesses/apollo/skills/documentation/templates/changelog-entry.md +2 -2
  15. package/data/harnesses/apollo/skills/documentation/templates/feature.md +1 -0
  16. package/data/harnesses/apollo/skills/documentation/templates/implementation-map.md +42 -0
  17. package/data/harnesses/apollo/skills/testing/SKILL.md +5 -5
  18. package/data/harnesses/code/CLAUDE.md +1 -1
  19. package/data/harnesses/code/agents/maintenance.md +1 -0
  20. package/data/harnesses/code/harness.json +2 -2
  21. package/data/harnesses/code/hooks/approved-packages.md +2 -0
  22. package/data/harnesses/code/hooks/check-completion-integrity.mjs +8 -0
  23. package/data/harnesses/code/hooks/compliance.mjs +138 -27
  24. package/data/harnesses/code/hooks/lib/ledger.mjs +31 -0
  25. package/data/harnesses/code/hooks/lib/session-log.mjs +75 -0
  26. package/data/harnesses/code/rules/changelog.md +2 -0
  27. package/data/harnesses/code/skills/documentation/SKILL.md +1 -0
  28. package/data/harnesses/code/skills/documentation/references/frontmatter.md +1 -1
  29. package/data/harnesses/code/skills/documentation/references/style-guide.md +34 -1
  30. package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +1 -1
  31. package/data/harnesses/code/skills/documentation/templates/Readme.md +1 -0
  32. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +2 -0
  33. package/data/harnesses/code/skills/documentation/templates/changelog-entry.md +2 -2
  34. package/data/harnesses/code/skills/documentation/templates/feature.md +1 -0
  35. package/data/harnesses/code/skills/documentation/templates/implementation-map.md +42 -0
  36. package/data/harnesses/code/skills/testing/SKILL.md +5 -5
  37. package/data/harnesses/delivery/agents/maintenance.md +1 -0
  38. package/data/harnesses/delivery/harness.json +2 -2
  39. package/data/harnesses/delivery/rules/changelog.md +2 -0
  40. package/data/harnesses/delivery/skills/documentation/SKILL.md +1 -0
  41. package/data/harnesses/delivery/skills/documentation/references/frontmatter.md +1 -1
  42. package/data/harnesses/delivery/skills/documentation/references/style-guide.md +34 -1
  43. package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +1 -1
  44. package/data/harnesses/delivery/skills/documentation/templates/Readme.md +1 -0
  45. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +2 -0
  46. package/data/harnesses/delivery/skills/documentation/templates/changelog-entry.md +2 -2
  47. package/data/harnesses/delivery/skills/documentation/templates/feature.md +1 -0
  48. package/data/harnesses/delivery/skills/documentation/templates/implementation-map.md +42 -0
  49. package/data/harnesses/etna/agents/maintenance.md +1 -0
  50. package/data/harnesses/etna/harness.json +2 -2
  51. package/data/harnesses/etna/rules/changelog.md +2 -0
  52. package/data/harnesses/etna/skills/documentation/SKILL.md +1 -0
  53. package/data/harnesses/etna/skills/documentation/references/frontmatter.md +1 -1
  54. package/data/harnesses/etna/skills/documentation/references/style-guide.md +34 -1
  55. package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +1 -1
  56. package/data/harnesses/etna/skills/documentation/templates/Readme.md +1 -0
  57. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +2 -0
  58. package/data/harnesses/etna/skills/documentation/templates/changelog-entry.md +2 -2
  59. package/data/harnesses/etna/skills/documentation/templates/feature.md +1 -0
  60. package/data/harnesses/etna/skills/documentation/templates/implementation-map.md +42 -0
  61. package/lib/install/materialize.mjs +402 -0
  62. package/lib/install/plan.mjs +271 -0
  63. package/package.json +2 -2
@@ -0,0 +1,402 @@
1
+ // packages/cli/lib/install/materialize.mjs
2
+ // Turn one bundled harness into an installed project tree. THE SINGLE AUTHORITY on where a
3
+ // harness's files land — a bundle says `skills/design/SKILL.md`, a project says
4
+ // `.claude/skills/design/SKILL.md`, and this module is the only place that mapping exists.
5
+ //
6
+ // TWO SHIPPERS, ONE MAPPING. `bin/etna.js` calls this against the user's cwd; the Apollo push
7
+ // (`.github/scripts/push-harnesses.mjs`) calls it against a throwaway directory and ships the
8
+ // result, so an App's export from Apollo extracts as the same tree `npx @nurix/etna` writes.
9
+ // They diverged once — the push sent the BUNDLE layout, so a downloaded project had its
10
+ // instruction tree at the archive root, no AGENTS.md, and no wired hooks, while the manifest
11
+ // claimed otherwise. One function is what stops that recurring.
12
+ //
13
+ // ZERO DEPENDENCIES, BY NECESSITY. The push runs in CI without an `npm install` (that workflow
14
+ // reaches `npm publish` → `prepack` and nothing else), so this module and everything it imports
15
+ // may use only what Node ships. That rules out importing `bin/etna.js`, which pulls in
16
+ // @clack/prompts for its UI — hence the extraction rather than a re-export.
17
+ //
18
+ // PURE FILE OPS, NO UI. Every human-facing string is RETURNED (installed/notices/warnings) and
19
+ // printed by the caller, so the push can discard what the CLI renders.
20
+
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ writeFileSync,
26
+ renameSync,
27
+ cpSync,
28
+ readdirSync,
29
+ lstatSync,
30
+ rmSync,
31
+ } from 'node:fs';
32
+ import { join, dirname } from 'node:path';
33
+ import { planHarnessFiles, PRUNABLE_SUBDIRS, CODEX_REBUILT_DIRS } from './plan.mjs';
34
+
35
+ // Hook scripts shipped by earlier harness versions (the legacy memory-ledger tier) and since retired.
36
+ // mergeHooks is additive, so an upgrade must explicitly remove both the stale settings.json
37
+ // entries pointing at these and the leftover script files they reference.
38
+ const RETIRED_HOOKS = [
39
+ 'session-stop.js',
40
+ 'post-tool-failure.js',
41
+ 'post-commit.js',
42
+ 'generate-ownership-index.mjs', // folded into awareness.mjs — unretired, the index would inject twice
43
+ ];
44
+
45
+ export function isSymlink(path) {
46
+ try {
47
+ return lstatSync(path).isSymbolicLink();
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ // Move <path> to <path>.bak.<timestamp> if it exists. Returns the backup path, or null.
54
+ function backupIfExists(path) {
55
+ if (!existsSync(path) && !isSymlink(path)) return null;
56
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
57
+ const backup = `${path}.bak.${ts}`;
58
+ renameSync(path, backup);
59
+ return backup;
60
+ }
61
+
62
+ // Copy a directory to <path>.bak.<timestamp>, leaving the original in place. Returns the
63
+ // backup path, or null. Used for .claude/, which is refreshed in place (not overwritten
64
+ // wholesale), so the live tree must survive for the in-place update — snapshot, don't move.
65
+ function snapshotDirIfExists(path) {
66
+ if (!existsSync(path)) return null;
67
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
68
+ const backup = `${path}.bak.${ts}`;
69
+ cpSync(path, backup, { recursive: true });
70
+ return backup;
71
+ }
72
+
73
+ // Throws rather than exiting: the CLI turns this into its own `die`, and the push into a
74
+ // non-zero exit naming the harness.
75
+ export function readJson(path) {
76
+ if (!existsSync(path)) return {};
77
+ try {
78
+ return JSON.parse(readFileSync(path, 'utf8'));
79
+ } catch (e) {
80
+ throw new Error(`failed to parse ${path}: ${e.message}`);
81
+ }
82
+ }
83
+
84
+ // 2-space indent matches Claude Code's own writes to settings.json.
85
+ function writeJson(path, value) {
86
+ mkdirSync(dirname(path), { recursive: true });
87
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
88
+ }
89
+
90
+ // Clean up any leftover marketplace/enabledPlugins entries from a previous 0.1.x
91
+ // install. After 0.2.0 we don't use the marketplace flow at all — leaving stale
92
+ // entries causes Claude Code to also resolve the plugin from the global cache,
93
+ // double-loading the same skills under a different namespace.
94
+ function migrateFromPluginInstall(targetDir, pluginName) {
95
+ const removed = [];
96
+ const projectSettingsPath = join(targetDir, '.claude', 'settings.json');
97
+ if (existsSync(projectSettingsPath)) {
98
+ const s = readJson(projectSettingsPath);
99
+ if (s.enabledPlugins) {
100
+ const keys = Object.keys(s.enabledPlugins).filter((k) => k.startsWith(`${pluginName}@`));
101
+ for (const k of keys) {
102
+ delete s.enabledPlugins[k];
103
+ removed.push(`enabledPlugins["${k}"] in ${projectSettingsPath}`);
104
+ }
105
+ if (Object.keys(s.enabledPlugins).length === 0) delete s.enabledPlugins;
106
+ writeJson(projectSettingsPath, s);
107
+ }
108
+ }
109
+ return removed;
110
+ }
111
+
112
+ // Remove settings.json hook entries and .claude/hooks/ files for retired hook scripts.
113
+ // Bundle-aware: a name on the retired list is skipped while THIS bundle still ships it —
114
+ // several harnesses share the list, and they retire the same script at different versions.
115
+ function retireOldHooks(targetDir, bundledHarnessDir) {
116
+ const removed = [];
117
+ const bundledHooksDir = join(bundledHarnessDir, 'hooks');
118
+ const stillShipped = new Set(
119
+ existsSync(bundledHooksDir)
120
+ ? readdirSync(bundledHooksDir, { withFileTypes: true })
121
+ .filter((e) => e.isFile())
122
+ .map((e) => e.name)
123
+ : [],
124
+ );
125
+ const retired = RETIRED_HOOKS.filter((name) => !stillShipped.has(name));
126
+ const settingsPath = join(targetDir, '.claude', 'settings.json');
127
+ if (existsSync(settingsPath)) {
128
+ const settings = readJson(settingsPath);
129
+ const isRetired = (h) => retired.some((name) => (h.command || '').includes(`/hooks/${name}`));
130
+ let pruned = 0;
131
+ for (const [event, groups] of Object.entries(settings.hooks || {})) {
132
+ for (const group of groups) {
133
+ const before = (group.hooks || []).length;
134
+ group.hooks = (group.hooks || []).filter((h) => !isRetired(h));
135
+ pruned += before - group.hooks.length;
136
+ }
137
+ settings.hooks[event] = groups.filter((g) => (g.hooks || []).length > 0);
138
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
139
+ }
140
+ if (pruned > 0) {
141
+ writeJson(settingsPath, settings);
142
+ removed.push(`${pruned} retired hook entr${pruned === 1 ? 'y' : 'ies'} in settings.json`);
143
+ }
144
+ }
145
+ for (const name of retired) {
146
+ const file = join(targetDir, '.claude', 'hooks', name);
147
+ if (existsSync(file)) {
148
+ rmSync(file, { force: true });
149
+ removed.push(`stale hook script ${name}`);
150
+ }
151
+ }
152
+ return removed;
153
+ }
154
+
155
+ // Read the `owned` manifest the PREVIOUS install recorded in the project's harness.json.
156
+ // Absent (first install, or pre-pruning version) ⇒ {} ⇒ nothing is pruned this round.
157
+ function readInstalledOwned(targetDir) {
158
+ const path = join(targetDir, '.claude', 'harness.json');
159
+ if (!existsSync(path)) return {};
160
+ const owned = readJson(path).owned;
161
+ return owned && typeof owned === 'object' ? owned : {};
162
+ }
163
+
164
+ // Remove vendored components a prior harness version owned but this one no longer ships
165
+ // (e.g. a retired agent like harness-manager). Only names present in the PRIOR `owned`
166
+ // manifest are eligible, so a project's own components — never in that manifest — survive.
167
+ function pruneOrphanedComponents(targetDir, priorOwned, newOwned) {
168
+ const removed = [];
169
+ for (const sub of PRUNABLE_SUBDIRS) {
170
+ const had = priorOwned[sub] || [];
171
+ const has = new Set(newOwned[sub] || []);
172
+ for (const name of had) {
173
+ if (has.has(name)) continue;
174
+ const target = join(targetDir, '.claude', sub, name);
175
+ if (existsSync(target)) {
176
+ rmSync(target, { recursive: true, force: true });
177
+ removed.push(`orphaned ${sub.replace(/s$/, '')} ${name}`);
178
+ }
179
+ }
180
+ }
181
+ return removed;
182
+ }
183
+
184
+ // Every file inside dir, as paths relative to dir.
185
+ export function listFilesRecursive(dir, prefix = '') {
186
+ const out = [];
187
+ if (!existsSync(dir)) return out;
188
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
189
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
190
+ if (entry.isDirectory()) out.push(...listFilesRecursive(join(dir, entry.name), rel));
191
+ else out.push(rel);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ // Remove directories left empty by a prune, depth-first.
197
+ function removeEmptyDirs(dir) {
198
+ if (!existsSync(dir)) return;
199
+ for (const entry of readdirSync(dir, { withFileTypes: true }))
200
+ if (entry.isDirectory()) removeEmptyDirs(join(dir, entry.name));
201
+ if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
202
+ }
203
+
204
+ // Remove files inside an installed component that this bundle no longer ships. copyHarnessSubdir
205
+ // merges, so a component that SURVIVES an upgrade while shedding a file keeps the stale copy
206
+ // forever — that is how a retired reference file goes on being read as live doctrine. Scoped to
207
+ // components the bundle still ships (a project's own components are never in that set, so they
208
+ // are never touched), and needs no prior manifest, so one refresh clears orphans left by any
209
+ // earlier version. Whole components that disappear are pruneOrphanedComponents' job.
210
+ function pruneStaleComponentFiles(bundledSubdir, projectSubdir) {
211
+ const removed = [];
212
+ if (!existsSync(bundledSubdir)) return removed;
213
+ for (const entry of readdirSync(bundledSubdir, { withFileTypes: true })) {
214
+ if (!entry.isDirectory()) continue;
215
+ const shipped = new Set(listFilesRecursive(join(bundledSubdir, entry.name)));
216
+ const installedDir = join(projectSubdir, entry.name);
217
+ for (const rel of listFilesRecursive(installedDir)) {
218
+ if (shipped.has(rel)) continue;
219
+ rmSync(join(installedDir, rel), { force: true });
220
+ removed.push(`${entry.name}/${rel}`);
221
+ }
222
+ removeEmptyDirs(installedDir);
223
+ }
224
+ return removed;
225
+ }
226
+
227
+ // True if an event's matcher-group already carries a hook with this command string.
228
+ function groupHasCommand(group, command) {
229
+ return (group.hooks || []).some((h) => h.command === command);
230
+ }
231
+
232
+ // Deep-merge the bundle's hooks.json object into <project>/.claude/settings.json under
233
+ // "hooks", preserving every existing key. Idempotent: for each event, an incoming entry
234
+ // merges into the matcher-group of the same matcher, skipping any hook whose command
235
+ // already exists there; a new matcher creates a new group. Returns count of hooks added.
236
+ function mergeHooks(targetDir, bundledHooksJson) {
237
+ if (!existsSync(bundledHooksJson)) return 0;
238
+ const bundled = readJson(bundledHooksJson);
239
+ const incoming = bundled.hooks || {};
240
+ const settingsPath = join(targetDir, '.claude', 'settings.json');
241
+ const settings = readJson(settingsPath);
242
+ settings.hooks = settings.hooks || {};
243
+ let added = 0;
244
+ // Harness-shipped permission defaults (e.g. Edit on docs/** flows without prompts —
245
+ // the deny hooks still hard-block frozen/protected paths underneath). Additive, idempotent.
246
+ for (const rule of bundled.permissions?.allow || []) {
247
+ settings.permissions = settings.permissions || {};
248
+ const allowList = (settings.permissions.allow = settings.permissions.allow || []);
249
+ if (!allowList.includes(rule)) {
250
+ allowList.push(rule);
251
+ added += 1;
252
+ }
253
+ }
254
+ for (const [event, groups] of Object.entries(incoming)) {
255
+ const existingGroups = (settings.hooks[event] = settings.hooks[event] || []);
256
+ for (const group of groups) {
257
+ const match = existingGroups.find((g) => g.matcher === group.matcher);
258
+ if (!match) {
259
+ existingGroups.push(group);
260
+ added += (group.hooks || []).length;
261
+ continue;
262
+ }
263
+ match.hooks = match.hooks || [];
264
+ for (const hook of group.hooks || []) {
265
+ if (groupHasCommand(match, hook.command)) continue;
266
+ match.hooks.push(hook);
267
+ added += 1;
268
+ }
269
+ }
270
+ }
271
+ if (added > 0) writeJson(settingsPath, settings);
272
+ return added;
273
+ }
274
+
275
+ /**
276
+ * Write one compiled harness into `targetDir` as an installed project tree.
277
+ *
278
+ * THE PLAN DECIDES WHAT AND WHERE; THIS DECIDES HOW IT MEETS AN EXISTING PROJECT. Placement lives
279
+ * in `plan.mjs` and is shared with the Apollo push, so the two shippers cannot disagree about a
280
+ * path. Everything here is the part a payload has no use for: backing up what git cannot recover,
281
+ * rebuilding the Codex mirrors wholesale, pruning components a prior version owned, retiring hook
282
+ * entries pointing at scripts this bundle no longer carries, and folding the harness's hooks into
283
+ * a settings.json that already belongs to somebody.
284
+ *
285
+ * `pluginName` is used only for the legacy marketplace cleanup. `projectHasGit` decides the backup
286
+ * policy for the harness-owned artifacts a refresh overwrites — git is the safety net when it is
287
+ * there, a `.bak.<timestamp>` copy when it is not.
288
+ *
289
+ * Returns the accounting the caller renders: `installed`, `notices`, `warnings`, plus `owned` (the
290
+ * manifest written into `.claude/harness.json`) and `meta` (the bundle marker).
291
+ */
292
+ export function materializeHarness({ bundledHarnessDir, targetDir, pluginName, projectHasGit }) {
293
+ const { files, owned, meta } = planHarnessFiles(bundledHarnessDir);
294
+
295
+ const installed = [];
296
+ const notices = [];
297
+ const warnings = [];
298
+
299
+ // Read BEFORE anything is written: this is what a prior install recorded as harness-owned, and
300
+ // the marker that holds it is one of the files about to be overwritten.
301
+ const priorOwned = readInstalledOwned(targetDir);
302
+
303
+ // The safety net, for the two artifacts a refresh overwrites outright. A git repo recovers them
304
+ // from history, so we only say so; without git there is no net, so we take a copy and warn. The
305
+ // .claude/ tree is SNAPSHOTTED rather than moved — it is refreshed in place, so the live tree has
306
+ // to survive for the writes below.
307
+ for (const [rel, kind] of [
308
+ ['CLAUDE.md', 'file'],
309
+ ['AGENTS.md', 'file'],
310
+ ]) {
311
+ const dest = join(targetDir, rel);
312
+ if (!existsSync(dest) && !isSymlink(dest)) continue;
313
+ if (projectHasGit) {
314
+ notices.push(
315
+ `Overwrote existing ${rel} (tracked by git — \`git checkout ${rel}\` to restore).`,
316
+ );
317
+ } else {
318
+ warnings.push(
319
+ `No .git here, so no version-control safety net — backed up existing ${rel} → ${backupIfExists(dest)}`,
320
+ );
321
+ }
322
+ void kind;
323
+ }
324
+ for (const rel of ['.claude', ...CODEX_REBUILT_DIRS]) {
325
+ const dest = join(targetDir, rel);
326
+ if (!existsSync(dest)) continue;
327
+ if (projectHasGit) {
328
+ notices.push(
329
+ `Refreshed existing ${rel}/ in place (tracked by git — \`git checkout ${rel}\` to restore).`,
330
+ );
331
+ } else {
332
+ const backup = snapshotDirIfExists(dest);
333
+ if (backup) {
334
+ warnings.push(
335
+ `No .git here, so no version-control safety net — snapshotted existing ${rel}/ → ${backup}`,
336
+ );
337
+ }
338
+ }
339
+ }
340
+
341
+ // The Codex mirrors are rebuilt wholesale, not merged: they are byte projections of the Claude
342
+ // tree, so a file left behind from a prior version is a skill Codex still sees after the harness
343
+ // dropped it. Removed before the writes, never after.
344
+ for (const rel of CODEX_REBUILT_DIRS)
345
+ rmSync(join(targetDir, rel), { recursive: true, force: true });
346
+
347
+ let written = 0;
348
+ let seeded = 0;
349
+ for (const file of files) {
350
+ const dest = join(targetDir, file.path);
351
+ // A seed is one-time scaffolding: once the path exists it is the project's living copy, and a
352
+ // refresh must not touch it. This is also the rule that lets an Apollo export carry an App's
353
+ // own `docs/history/backlog.md` instead of the harness's.
354
+ if (file.policy === 'seed') {
355
+ if (existsSync(dest)) continue;
356
+ mkdirSync(dirname(dest), { recursive: true });
357
+ writeFileSync(dest, file.content);
358
+ seeded += 1;
359
+ continue;
360
+ }
361
+ // settings.json belongs to the project, not the harness — it carries the user's own hooks and
362
+ // permissions. The plan's copy is what a FRESH install would hold; against an existing file the
363
+ // authority is mergeHooks below, which is additive and idempotent.
364
+ if (file.policy === 'merge') continue;
365
+ mkdirSync(dirname(dest), { recursive: true });
366
+ writeFileSync(dest, file.content);
367
+ written += 1;
368
+ }
369
+ installed.push(`${written} harness file(s) → ./ and .claude/`);
370
+ if (seeded > 0) installed.push(`${seeded} seed file(s) → ./ (only where absent)`);
371
+
372
+ // Prune components a prior harness version owned but this bundle no longer ships (e.g. a retired
373
+ // agent). Only prior-owned names are eligible, so the project's own files survive.
374
+ for (const r of pruneOrphanedComponents(targetDir, priorOwned, owned))
375
+ notices.push(`removed ${r}`);
376
+
377
+ // Then prune stale files INSIDE components the bundle still ships — the case the name-level diff
378
+ // above cannot see, since the component itself is still present. Without it, a component that
379
+ // survives an upgrade while shedding a file keeps the stale copy for ever, and a retired
380
+ // reference file goes on being read as live doctrine.
381
+ for (const sub of PRUNABLE_SUBDIRS) {
382
+ for (const stale of pruneStaleComponentFiles(
383
+ join(bundledHarnessDir, sub),
384
+ join(targetDir, '.claude', sub),
385
+ )) {
386
+ notices.push(`removed stale ${sub.replace(/s$/, '')} file ${stale}`);
387
+ }
388
+ }
389
+
390
+ // Retire hooks earlier harness versions shipped, before merging, so the additive merge never
391
+ // resurrects an entry pointing at a script this bundle no longer carries.
392
+ for (const r of retireOldHooks(targetDir, bundledHarnessDir)) notices.push(`removed ${r}`);
393
+
394
+ const added = mergeHooks(targetDir, join(bundledHarnessDir, 'hooks', 'hooks.json'));
395
+ if (added > 0) installed.push(`${added} hook(s) → .claude/settings.json`);
396
+
397
+ for (const entry of migrateFromPluginInstall(targetDir, pluginName)) {
398
+ notices.push(`removed stale ${entry}`);
399
+ }
400
+
401
+ return { installed, notices, warnings, owned, meta };
402
+ }
@@ -0,0 +1,271 @@
1
+ // packages/cli/lib/install/plan.mjs
2
+ // THE HARNESS COMPILER: one bundled harness definition in, the exact file list a fresh install
3
+ // produces out. Pure — it reads the bundle and returns data, and writes nothing.
4
+ //
5
+ // This is the single authority on WHERE a harness's files land and WHAT is in them. Both shippers
6
+ // consume it and neither re-derives it:
7
+ //
8
+ // push-harnesses.mjs → publishes the array to Apollo verbatim
9
+ // materializeHarness → writes the array to a project, applying each entry's policy
10
+ //
11
+ // They diverged once, and that is the whole reason this file exists. The Apollo push used to walk
12
+ // the BUNDLE directory and send those paths, so a project downloaded from Apollo Studio extracted
13
+ // with its instruction tree at the archive root where no runtime looks for it, no AGENTS.md, and no
14
+ // hooks wired into settings.json — while the export manifest claimed the folder was ready to open.
15
+ // Nothing failed: every file was present, only its location was wrong.
16
+ //
17
+ // PURE, AND THAT IS THE POINT. Deriving the payload by installing into a temp directory and reading
18
+ // it back works, but it is disk I/O to compute a mapping — and it hides the mapping inside a
19
+ // sequence of file operations where no test can see it. As data, the mapping is a value you can
20
+ // assert on.
21
+ //
22
+ // EVERY ENTRY CARRIES A POLICY, because "what a fresh install produces" and "what a refresh writes
23
+ // over an existing project" differ for exactly two kinds of file. A payload flattens all three (an
24
+ // empty target takes every write); an install honours them.
25
+ //
26
+ // own — harness-owned. Overwritten on every refresh.
27
+ // seed — one-time scaffolding. Written only where the path is ABSENT; once present it is the
28
+ // project's living copy, never refreshed and never pruned.
29
+ // merge — folded into whatever the project already has, key by key. Only `.claude/settings.json`,
30
+ // which carries the user's own hooks and permissions beside the harness's.
31
+
32
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
33
+ import { join, relative, sep } from 'node:path';
34
+ import { buildCodexAdapter } from '../codex/agents-md.mjs';
35
+ import { toCodexCommand } from '../codex/hooks.mjs';
36
+
37
+ // Subfolders bundled inside each harness payload that map 1:1 onto <project>/.claude/<name>/.
38
+ // Order matches Claude Code's own folder layout. hooks/ carries the hook scripts + lib/ (and
39
+ // hooks.json, shipped harmlessly — the settings merge reads it from the bundle, not this copy).
40
+ export const PLUGIN_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
41
+
42
+ // Component subdirs whose membership is tracked so a removed-from-the-harness component can be
43
+ // pruned on refresh. Hooks are excluded — retireOldHooks owns them, because it must also prune the
44
+ // matching settings.json entry, which a name-only diff cannot reach.
45
+ export const PRUNABLE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts'];
46
+
47
+ // Codex targets rebuilt wholesale on every install rather than merged, so a refresh must delete
48
+ // them first. Recorded in harness.json's `owned.codex` so a later version can prune one it drops.
49
+ export const CODEX_REBUILT_DIRS = ['.agents/skills', '.codex/agents'];
50
+
51
+ // Every file under dir, as posix paths relative to it. Posix regardless of host, because these
52
+ // become ZIP entry names on Apollo's side and a backslash is a legal filename character on the
53
+ // systems that extract them.
54
+ function filesUnder(dir, prefix = '') {
55
+ const out = [];
56
+ if (!existsSync(dir)) return out;
57
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
58
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
59
+ if (entry.isDirectory()) out.push(...filesUnder(join(dir, entry.name), rel));
60
+ else if (entry.isFile()) out.push(rel);
61
+ // A dirent that is neither is almost certainly a symlink. Refusing beats skipping: Apollo
62
+ // stores content, so there is no link to follow on the other side, and a silently short
63
+ // payload is the exact failure mode this module exists to prevent.
64
+ else
65
+ throw new Error(
66
+ `${join(dir, entry.name)} is neither a file nor a directory — a harness may not ship one`,
67
+ );
68
+ }
69
+ return out;
70
+ }
71
+
72
+ // One bundle path → one or more entries. A `files:`/`seeds:` entry may legally name a directory
73
+ // (the installer copies it with `recursive: true`), so a single declared path can expand.
74
+ function entriesFor(bundleDir, rel, policy) {
75
+ const source = join(bundleDir, rel);
76
+ if (!existsSync(source)) return [];
77
+ if (statSync(source).isDirectory()) {
78
+ return filesUnder(source).map((child) => ({
79
+ path: `${rel}/${child}`,
80
+ content: readFileSync(join(source, child), 'utf8'),
81
+ policy,
82
+ }));
83
+ }
84
+ return [{ path: rel.split(sep).join('/'), content: readFileSync(source, 'utf8'), policy }];
85
+ }
86
+
87
+ // 2-space indent + trailing newline matches Claude Code's own writes to settings.json, and the
88
+ // installed bytes must equal the payload's bytes or the two shippers disagree on content.
89
+ function asJson(value) {
90
+ return JSON.stringify(value, null, 2) + '\n';
91
+ }
92
+
93
+ // Sorted base names of *.<ext> files directly under dir (e.g. rules/*.md → rule names).
94
+ function listByExt(dir, ext) {
95
+ if (!existsSync(dir)) return [];
96
+ return readdirSync(dir)
97
+ .filter((f) => f.endsWith(ext))
98
+ .map((f) => f.slice(0, -ext.length))
99
+ .sort();
100
+ }
101
+
102
+ // The manifest of what THIS harness owns, per prunable subdir — raw top-level entry names, which
103
+ // is what the next refresh diffs against to find components the harness has since dropped.
104
+ function bundledOwned(bundleDir) {
105
+ const owned = {};
106
+ for (const sub of PRUNABLE_SUBDIRS) {
107
+ const dir = join(bundleDir, sub);
108
+ owned[sub] = existsSync(dir) ? readdirSync(dir).sort() : [];
109
+ }
110
+ return owned;
111
+ }
112
+
113
+ // `.claude/settings.json` as a FRESH install writes it: the bundle's hooks.json folded into an
114
+ // empty settings object. `hooks` is created before the permissions loop, so it comes first in key
115
+ // order — and key order is part of the bytes, so it is part of the contract.
116
+ function freshSettings(bundleDir) {
117
+ const hooksJson = join(bundleDir, 'hooks', 'hooks.json');
118
+ if (!existsSync(hooksJson)) return null;
119
+ const bundled = JSON.parse(readFileSync(hooksJson, 'utf8'));
120
+ const settings = { hooks: {} };
121
+ for (const rule of bundled.permissions?.allow || []) {
122
+ settings.permissions = settings.permissions || {};
123
+ settings.permissions.allow = settings.permissions.allow || [];
124
+ if (!settings.permissions.allow.includes(rule)) settings.permissions.allow.push(rule);
125
+ }
126
+ for (const [event, groups] of Object.entries(bundled.hooks || {})) {
127
+ settings.hooks[event] = groups;
128
+ }
129
+ if (!Object.keys(settings.hooks).length && !settings.permissions) return null;
130
+ return asJson(settings);
131
+ }
132
+
133
+ // `.codex/hooks.json` — the Claude hooks.json filtered to type:"command", the only kind Codex
134
+ // executes, with each command prefixed so `$CLAUDE_PROJECT_DIR` resolves under a runtime that does
135
+ // not export it. The translation itself is `toCodexCommand`, imported rather than restated.
136
+ function codexHooks(bundleDir) {
137
+ const hooksJson = join(bundleDir, 'hooks', 'hooks.json');
138
+ if (!existsSync(hooksJson)) return null;
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(readFileSync(hooksJson, 'utf8'));
142
+ } catch {
143
+ return null;
144
+ }
145
+ const out = {};
146
+ for (const [event, groups] of Object.entries(parsed.hooks || {})) {
147
+ const kept = [];
148
+ for (const group of groups) {
149
+ const hooks = (group.hooks || [])
150
+ .filter((h) => h.type === 'command')
151
+ .map((h) => ({ ...h, command: toCodexCommand(h.command) }));
152
+ if (hooks.length) kept.push({ ...group, hooks });
153
+ }
154
+ if (kept.length) out[event] = kept;
155
+ }
156
+ return Object.keys(out).length ? asJson({ hooks: out }) : null;
157
+ }
158
+
159
+ /**
160
+ * Compile one bundled harness into the file list a fresh install produces.
161
+ *
162
+ * Returns `{ files, owned, meta }` — `files` sorted by path, each with a policy; `owned` the
163
+ * manifest that goes into `.claude/harness.json`; `meta` the parsed bundle marker.
164
+ *
165
+ * Throws when the bundle has no CLAUDE.md: that is the one file every harness must carry, and a
166
+ * harness without a policy is not a harness.
167
+ */
168
+ export function planHarnessFiles(bundleDir) {
169
+ const claudeMdPath = join(bundleDir, 'CLAUDE.md');
170
+ if (!existsSync(claudeMdPath)) {
171
+ throw new Error(`Bundled CLAUDE.md missing at ${claudeMdPath}. Rebuild package data.`);
172
+ }
173
+
174
+ const files = [];
175
+ const claudeMd = readFileSync(claudeMdPath, 'utf8');
176
+ files.push({ path: 'CLAUDE.md', content: claudeMd, policy: 'own' });
177
+
178
+ // The component subtrees. This is the mapping the whole module exists for: a bundle says
179
+ // `skills/design/SKILL.md`, a project says `.claude/skills/design/SKILL.md`.
180
+ for (const sub of PLUGIN_SUBDIRS) {
181
+ for (const rel of filesUnder(join(bundleDir, sub))) {
182
+ files.push({
183
+ path: `.claude/${sub}/${rel}`,
184
+ content: readFileSync(join(bundleDir, sub, rel), 'utf8'),
185
+ policy: 'own',
186
+ });
187
+ }
188
+ }
189
+
190
+ // Dual-runtime: the Codex artifacts the CLI derives at install time. A ZIP extract runs no
191
+ // installer, so if these are not in the payload a downloaded project has no Codex support at
192
+ // all and no way to generate it.
193
+ const owned = bundledOwned(bundleDir);
194
+ const codexOwned = [];
195
+
196
+ const adapter = buildCodexAdapter(bundleDir);
197
+ const separator = claudeMd.endsWith('\n') ? '\n' : '\n\n';
198
+ files.push({
199
+ path: 'AGENTS.md',
200
+ content: adapter ? claudeMd + separator + adapter : claudeMd,
201
+ policy: 'own',
202
+ });
203
+ codexOwned.push('AGENTS.md');
204
+
205
+ // `.agents/skills/` is Codex's official scan path and a byte mirror of `.claude/skills/`. Both
206
+ // runtimes require exactly name+description in SKILL.md frontmatter, so the mirrored files are
207
+ // valid for Codex unchanged.
208
+ const skillFiles = filesUnder(join(bundleDir, 'skills'));
209
+ if (skillFiles.length) {
210
+ for (const rel of skillFiles) {
211
+ files.push({
212
+ path: `.agents/skills/${rel}`,
213
+ content: readFileSync(join(bundleDir, 'skills', rel), 'utf8'),
214
+ policy: 'own',
215
+ });
216
+ }
217
+ codexOwned.push('.agents/skills');
218
+ }
219
+
220
+ const tomls = listByExt(join(bundleDir, 'codex', 'agents'), '.toml');
221
+ if (tomls.length) {
222
+ for (const name of tomls) {
223
+ files.push({
224
+ path: `.codex/agents/${name}.toml`,
225
+ content: readFileSync(join(bundleDir, 'codex', 'agents', `${name}.toml`), 'utf8'),
226
+ policy: 'own',
227
+ });
228
+ }
229
+ codexOwned.push('.codex/agents');
230
+ }
231
+
232
+ const hooks = codexHooks(bundleDir);
233
+ if (hooks) {
234
+ files.push({ path: '.codex/hooks.json', content: hooks, policy: 'own' });
235
+ codexOwned.push('.codex/hooks.json');
236
+ }
237
+
238
+ owned.codex = codexOwned;
239
+
240
+ // The marker, at the path the CLI writes and the maintenance agent resolves the installed
241
+ // version from — never at the bundle root, where nothing reads it.
242
+ const markerPath = join(bundleDir, 'harness.json');
243
+ const meta = existsSync(markerPath) ? JSON.parse(readFileSync(markerPath, 'utf8')) : {};
244
+ if (existsSync(markerPath)) {
245
+ files.push({
246
+ path: '.claude/harness.json',
247
+ content: asJson({ ...meta, owned }),
248
+ policy: 'own',
249
+ });
250
+ }
251
+
252
+ // Project-root config the harness owns outright (.prettierrc.json, .vscode/settings.json, …),
253
+ // then the one-time scaffolding it seeds. Same landing, opposite ownership.
254
+ for (const rel of meta.files || []) files.push(...entriesFor(bundleDir, rel, 'own'));
255
+ for (const rel of meta.seeds || []) files.push(...entriesFor(bundleDir, rel, 'seed'));
256
+
257
+ const settings = freshSettings(bundleDir);
258
+ if (settings) files.push({ path: '.claude/settings.json', content: settings, policy: 'merge' });
259
+
260
+ // Sorted so the plan is byte-stable across machines — readdir order is not guaranteed, and an
261
+ // unstable order would make two identical builds look different to anything comparing them.
262
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
263
+
264
+ const seen = new Set();
265
+ for (const file of files) {
266
+ if (seen.has(file.path)) throw new Error(`the harness plans ${file.path} twice`);
267
+ seen.add(file.path);
268
+ }
269
+
270
+ return { files, owned, meta };
271
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.19.64",
3
+ "version": "0.19.66",
4
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).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,5 +42,5 @@
42
42
  "agent",
43
43
  "plugin"
44
44
  ],
45
- "etnaFingerprint": "6b7cd94d8ed9"
45
+ "etnaFingerprint": "774efa3ae2da"
46
46
  }