@nurix/etna 0.19.64 → 0.19.65

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "fingerprint": "6b7cd94d8ed9",
2
+ "fingerprint": "b0d947332bf0",
3
3
  "harnesses": {
4
4
  "apollo": {
5
5
  "plugin": "apollo",
@@ -15,5 +15,5 @@
15
15
  "docs/memory/main.md",
16
16
  ".mcp.json"
17
17
  ],
18
- "digest": "d01be54a3e94"
18
+ "digest": "673c1a2ae5b8"
19
19
  }
@@ -39,4 +39,6 @@ Read by the PreToolUse install gate (`compliance.mjs`) — an off-table install
39
39
  | `fast-deep-equal` | ^3.1.3 | Deep equality checks | `lodash.isEqual` |
40
40
  | `@ngneat/falso` | latest | Fake data generation | `@faker-js/faker`, `chance` |
41
41
  | `pino` | ^10.1.0 | Logging | `winston` |
42
+ | `wrangler` | ^4.118.0 | Cloudflare Workers CLI — dev server, deploy | `@cloudflare/wrangler` (deprecated), `miniflare` standalone |
43
+ | `mermaid` | ^11.16.0 | Diagram rendering in the browser | `mermaid` via CDN, `viz.js`, `flowchart.js` |
42
44
 
@@ -154,6 +154,39 @@ For screen layouts (not data flows), use these conventions:
154
154
 
155
155
  ---
156
156
 
157
+ ## Mermaid Diagrams
158
+
159
+ **Structure at rest is ASCII; behaviour over time is mermaid.** A file tree, an on-disk layout, a load-class split, a wireframe — draw with box characters, because the diagram *is* the thing's shape. A sequence of turns, a state machine, a branching flow — write mermaid, because a renderer tracks the arrows better than a reader tracks them across box art, and the source stays diffable when a step is inserted.
160
+
161
+ **Docs only**, on the same reasoning as ASCII: a diagram never enters an instruction file.
162
+
163
+ ### When to Use
164
+
165
+ | Diagram | Use for |
166
+ | --- | --- |
167
+ | `sequenceDiagram` | Who calls whom in what order — a session turn, a hand-off between components |
168
+ | `stateDiagram-v2` | Modes with transitions — a workflow, a record's lifecycle |
169
+ | `flowchart` | A process with branches, gates, or fan-out |
170
+ | `timeline` | A dated lineage, in records only — never in a truth document |
171
+
172
+ ### Rules
173
+
174
+ - **At most 7 participants and 15 interactions** per diagram. Past that, split it — an unreadable diagram is worse than the prose it replaced.
175
+ - **Labels under 8 words**, no trailing punctuation.
176
+ - **Declare a direction** on every flowchart (`flowchart TD` / `LR`) so the render is stable.
177
+ - **The diagram never carries a fact the prose omits.** It restates structure for a reader who scans; a reader who greps must still find everything in the text.
178
+ - **No styling directives** — no `style`, no `classDef`, no theme overrides. Rendering is the reader's; the source stays portable across GitHub, Obsidian, and a plain editor.
179
+
180
+ ```
181
+ flowchart LR
182
+ A[Plan approved] --> B{boundary}
183
+ B -->|BLOCK| C[Re-plan]
184
+ B -->|PASS| D[testing plan-gate]
185
+ D --> E[Implement]
186
+ ```
187
+
188
+ ---
189
+
157
190
  ## Code Examples
158
191
 
159
192
  ### Rules
@@ -344,7 +377,7 @@ Always end with:
344
377
  | Parameters | Type + purpose |
345
378
  | Returns | Type + shape |
346
379
  | State | Persistence points only |
347
- | Diagrams | ASCII for all flows — docs only, never instruction files |
380
+ | Diagrams | ASCII for structure at rest, mermaid for behaviour over time — docs only, never instruction files |
348
381
  | Code | Under 15 lines, contracts only |
349
382
  | Voice | Imperative/active |
350
383
  | Mechanics | Inline-code identifiers; bold-assertion bullets |
@@ -10,5 +10,5 @@
10
10
  "seeds": [
11
11
  ".mcp.json"
12
12
  ],
13
- "digest": "b7f0d55f9ba4"
13
+ "digest": "105461e25748"
14
14
  }
@@ -39,4 +39,6 @@ Read by the PreToolUse install gate (`compliance.mjs`) — an off-table install
39
39
  | `fast-deep-equal` | ^3.1.3 | Deep equality checks | `lodash.isEqual` |
40
40
  | `@ngneat/falso` | latest | Fake data generation | `@faker-js/faker`, `chance` |
41
41
  | `pino` | ^10.1.0 | Logging | `winston` |
42
+ | `wrangler` | ^4.118.0 | Cloudflare Workers CLI — dev server, deploy | `@cloudflare/wrangler` (deprecated), `miniflare` standalone |
43
+ | `mermaid` | ^11.16.0 | Diagram rendering in the browser | `mermaid` via CDN, `viz.js`, `flowchart.js` |
42
44
 
@@ -154,6 +154,39 @@ For screen layouts (not data flows), use these conventions:
154
154
 
155
155
  ---
156
156
 
157
+ ## Mermaid Diagrams
158
+
159
+ **Structure at rest is ASCII; behaviour over time is mermaid.** A file tree, an on-disk layout, a load-class split, a wireframe — draw with box characters, because the diagram *is* the thing's shape. A sequence of turns, a state machine, a branching flow — write mermaid, because a renderer tracks the arrows better than a reader tracks them across box art, and the source stays diffable when a step is inserted.
160
+
161
+ **Docs only**, on the same reasoning as ASCII: a diagram never enters an instruction file.
162
+
163
+ ### When to Use
164
+
165
+ | Diagram | Use for |
166
+ | --- | --- |
167
+ | `sequenceDiagram` | Who calls whom in what order — a session turn, a hand-off between components |
168
+ | `stateDiagram-v2` | Modes with transitions — a workflow, a record's lifecycle |
169
+ | `flowchart` | A process with branches, gates, or fan-out |
170
+ | `timeline` | A dated lineage, in records only — never in a truth document |
171
+
172
+ ### Rules
173
+
174
+ - **At most 7 participants and 15 interactions** per diagram. Past that, split it — an unreadable diagram is worse than the prose it replaced.
175
+ - **Labels under 8 words**, no trailing punctuation.
176
+ - **Declare a direction** on every flowchart (`flowchart TD` / `LR`) so the render is stable.
177
+ - **The diagram never carries a fact the prose omits.** It restates structure for a reader who scans; a reader who greps must still find everything in the text.
178
+ - **No styling directives** — no `style`, no `classDef`, no theme overrides. Rendering is the reader's; the source stays portable across GitHub, Obsidian, and a plain editor.
179
+
180
+ ```
181
+ flowchart LR
182
+ A[Plan approved] --> B{boundary}
183
+ B -->|BLOCK| C[Re-plan]
184
+ B -->|PASS| D[testing plan-gate]
185
+ D --> E[Implement]
186
+ ```
187
+
188
+ ---
189
+
157
190
  ## Code Examples
158
191
 
159
192
  ### Rules
@@ -344,7 +377,7 @@ Always end with:
344
377
  | Parameters | Type + purpose |
345
378
  | Returns | Type + shape |
346
379
  | State | Persistence points only |
347
- | Diagrams | ASCII for all flows — docs only, never instruction files |
380
+ | Diagrams | ASCII for structure at rest, mermaid for behaviour over time — docs only, never instruction files |
348
381
  | Code | Under 15 lines, contracts only |
349
382
  | Voice | Imperative/active |
350
383
  | Mechanics | Inline-code identifiers; bold-assertion bullets |
@@ -8,5 +8,5 @@
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "3d15299f4b1f"
11
+ "digest": "83870917a79b"
12
12
  }
@@ -154,6 +154,39 @@ For screen layouts (not data flows), use these conventions:
154
154
 
155
155
  ---
156
156
 
157
+ ## Mermaid Diagrams
158
+
159
+ **Structure at rest is ASCII; behaviour over time is mermaid.** A file tree, an on-disk layout, a load-class split, a wireframe — draw with box characters, because the diagram *is* the thing's shape. A sequence of turns, a state machine, a branching flow — write mermaid, because a renderer tracks the arrows better than a reader tracks them across box art, and the source stays diffable when a step is inserted.
160
+
161
+ **Docs only**, on the same reasoning as ASCII: a diagram never enters an instruction file.
162
+
163
+ ### When to Use
164
+
165
+ | Diagram | Use for |
166
+ | --- | --- |
167
+ | `sequenceDiagram` | Who calls whom in what order — a session turn, a hand-off between components |
168
+ | `stateDiagram-v2` | Modes with transitions — a workflow, a record's lifecycle |
169
+ | `flowchart` | A process with branches, gates, or fan-out |
170
+ | `timeline` | A dated lineage, in records only — never in a truth document |
171
+
172
+ ### Rules
173
+
174
+ - **At most 7 participants and 15 interactions** per diagram. Past that, split it — an unreadable diagram is worse than the prose it replaced.
175
+ - **Labels under 8 words**, no trailing punctuation.
176
+ - **Declare a direction** on every flowchart (`flowchart TD` / `LR`) so the render is stable.
177
+ - **The diagram never carries a fact the prose omits.** It restates structure for a reader who scans; a reader who greps must still find everything in the text.
178
+ - **No styling directives** — no `style`, no `classDef`, no theme overrides. Rendering is the reader's; the source stays portable across GitHub, Obsidian, and a plain editor.
179
+
180
+ ```
181
+ flowchart LR
182
+ A[Plan approved] --> B{boundary}
183
+ B -->|BLOCK| C[Re-plan]
184
+ B -->|PASS| D[testing plan-gate]
185
+ D --> E[Implement]
186
+ ```
187
+
188
+ ---
189
+
157
190
  ## Code Examples
158
191
 
159
192
  ### Rules
@@ -344,7 +377,7 @@ Always end with:
344
377
  | Parameters | Type + purpose |
345
378
  | Returns | Type + shape |
346
379
  | State | Persistence points only |
347
- | Diagrams | ASCII for all flows — docs only, never instruction files |
380
+ | Diagrams | ASCII for structure at rest, mermaid for behaviour over time — docs only, never instruction files |
348
381
  | Code | Under 15 lines, contracts only |
349
382
  | Voice | Imperative/active |
350
383
  | Mechanics | Inline-code identifiers; bold-assertion bullets |
@@ -8,5 +8,5 @@
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "5ddc026fa3b1"
11
+ "digest": "4d6b33a51517"
12
12
  }
@@ -154,6 +154,39 @@ For screen layouts (not data flows), use these conventions:
154
154
 
155
155
  ---
156
156
 
157
+ ## Mermaid Diagrams
158
+
159
+ **Structure at rest is ASCII; behaviour over time is mermaid.** A file tree, an on-disk layout, a load-class split, a wireframe — draw with box characters, because the diagram *is* the thing's shape. A sequence of turns, a state machine, a branching flow — write mermaid, because a renderer tracks the arrows better than a reader tracks them across box art, and the source stays diffable when a step is inserted.
160
+
161
+ **Docs only**, on the same reasoning as ASCII: a diagram never enters an instruction file.
162
+
163
+ ### When to Use
164
+
165
+ | Diagram | Use for |
166
+ | --- | --- |
167
+ | `sequenceDiagram` | Who calls whom in what order — a session turn, a hand-off between components |
168
+ | `stateDiagram-v2` | Modes with transitions — a workflow, a record's lifecycle |
169
+ | `flowchart` | A process with branches, gates, or fan-out |
170
+ | `timeline` | A dated lineage, in records only — never in a truth document |
171
+
172
+ ### Rules
173
+
174
+ - **At most 7 participants and 15 interactions** per diagram. Past that, split it — an unreadable diagram is worse than the prose it replaced.
175
+ - **Labels under 8 words**, no trailing punctuation.
176
+ - **Declare a direction** on every flowchart (`flowchart TD` / `LR`) so the render is stable.
177
+ - **The diagram never carries a fact the prose omits.** It restates structure for a reader who scans; a reader who greps must still find everything in the text.
178
+ - **No styling directives** — no `style`, no `classDef`, no theme overrides. Rendering is the reader's; the source stays portable across GitHub, Obsidian, and a plain editor.
179
+
180
+ ```
181
+ flowchart LR
182
+ A[Plan approved] --> B{boundary}
183
+ B -->|BLOCK| C[Re-plan]
184
+ B -->|PASS| D[testing plan-gate]
185
+ D --> E[Implement]
186
+ ```
187
+
188
+ ---
189
+
157
190
  ## Code Examples
158
191
 
159
192
  ### Rules
@@ -344,7 +377,7 @@ Always end with:
344
377
  | Parameters | Type + purpose |
345
378
  | Returns | Type + shape |
346
379
  | State | Persistence points only |
347
- | Diagrams | ASCII for all flows — docs only, never instruction files |
380
+ | Diagrams | ASCII for structure at rest, mermaid for behaviour over time — docs only, never instruction files |
348
381
  | Code | Under 15 lines, contracts only |
349
382
  | Voice | Imperative/active |
350
383
  | Mechanics | Inline-code identifiers; bold-assertion bullets |
@@ -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
+ }