diff --git a/LifeOS/Tools/OverlaySystem.ts b/LifeOS/Tools/OverlaySystem.ts
new file mode 100644
index 0000000000..f5a2e42d9b
--- /dev/null
+++ b/LifeOS/Tools/OverlaySystem.ts
@@ -0,0 +1,247 @@
+#!/usr/bin/env bun
+/**
+ * OverlaySystem.ts — the update-path counterpart to DeployCore: refresh SYSTEM-OWNED files
+ * that already exist, which copyMissing by contract cannot do.
+ *
+ * DeployCore is additive: `copyMissing` writes only when the destination is absent, so a
+ * populated install can receive NEW files but never an UPDATED one. That is the right
+ * contract for a fresh install and the wrong one for an update — `Workflows/Update.md`
+ * step 3 ("Re-overlay system") describes this tool, which did not previously exist.
+ *
+ * Scope is deliberately narrow. Only paths LifeOS owns are overwritten:
+ *
+ * hooks/ · skills/ · agents/ · LIFEOS/{TOOLS,DOCUMENTATION,ALGORITHM,RULES,PULSE}/
+ * plus CLAUDE.md and LIFEOS/LIFEOS_SYSTEM_PROMPT.md
+ *
+ * Never touched: USER/ (the principal's tree), LIFEOS/MEMORY/ (per-install state),
+ * settings.json (hook registrations — InstallHooks owns that merge), node_modules, .git.
+ *
+ * Nothing is ever DELETED. A file is written only where the payload ships one at the same
+ * relative path, so operator-authored hooks, skills and tools survive untouched.
+ *
+ * VERSION is written LAST and only on a fully successful --apply, so a partial update can
+ * never present as complete (the failure mode where the marker says the new version while
+ * the machinery is still the old one).
+ *
+ * Dry-run by default (`--apply` to mutate); REFUSES the author's live source tree
+ * (`--allow-dev` to override; exit 2), matching DeployCore's failure contract.
+ *
+ * Usage:
+ * bun OverlaySystem.ts [--config-root
] [--skill-root ] [--apply] [--allow-dev]
+ */
+
+import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync, cpSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, join } from "node:path";
+import { detectDevTree } from "./InstallEngine";
+
+/** Directories inside a system tree that are never overlaid, at any depth. */
+const SKIP_DIRS = new Set(["node_modules", ".git", "MEMORY", "USER", "out", ".next"]);
+
+/**
+ * System-owned trees: [payload-relative, configRoot-relative].
+ * Anything not listed here is left alone — absence from this list is the safety property.
+ */
+const SYSTEM_TREES: Array<[string, string]> = [
+ ["hooks", "hooks"],
+ ["skills", "skills"],
+ ["agents", "agents"],
+ ["LIFEOS/TOOLS", "LIFEOS/TOOLS"],
+ ["LIFEOS/DOCUMENTATION", "LIFEOS/DOCUMENTATION"],
+ ["LIFEOS/ALGORITHM", "LIFEOS/ALGORITHM"],
+ ["LIFEOS/RULES", "LIFEOS/RULES"],
+ ["LIFEOS/PULSE", "LIFEOS/PULSE"],
+];
+
+/** System-owned single files: [payload-relative, configRoot-relative]. */
+const SYSTEM_FILES: Array<[string, string]> = [
+ ["LIFEOS/LIFEOS_SYSTEM_PROMPT.md", "LIFEOS/LIFEOS_SYSTEM_PROMPT.md"],
+ ["skills/LifeOS/install/CLAUDE.template.md", "CLAUDE.md"],
+];
+
+function arg(a: string[], flag: string): string | undefined {
+ const i = a.indexOf(flag);
+ return i >= 0 && a[i + 1] && !a[i + 1].startsWith("--") ? a[i + 1] : undefined;
+}
+
+function sameBytes(a: string, b: string): boolean {
+ try {
+ const x = readFileSync(a);
+ const y = readFileSync(b);
+ return x.length === y.length && x.equals(y);
+ } catch {
+ return false;
+ }
+}
+
+interface OverlayResult {
+ what: string;
+ src: string;
+ dst: string;
+ present: boolean;
+ /** existed and differed → rewritten */
+ updated: number;
+ /** already byte-identical → untouched */
+ current: number;
+ /** absent → created */
+ created: number;
+ failures: string[];
+}
+
+/**
+ * Walk the payload tree and write each file to the matching relative path under dst.
+ * Updates and creations are both performed; deletions never are.
+ */
+function overlayTree(src: string, dst: string, what: string, apply: boolean): OverlayResult {
+ const r: OverlayResult = { what, src, dst, present: existsSync(src), updated: 0, current: 0, created: 0, failures: [] };
+ if (!r.present) return r;
+
+ const walk = (s: string, d: string): void => {
+ let entries: string[];
+ try {
+ entries = readdirSync(s);
+ } catch (err) {
+ r.failures.push(`readdir ${s}: ${String(err)}`);
+ return;
+ }
+ for (const name of entries) {
+ const sp = join(s, name);
+ const dp = join(d, name);
+ let st;
+ try {
+ st = lstatSync(sp);
+ } catch {
+ continue;
+ }
+ if (st.isSymbolicLink()) continue; // never follow or replace symlinks (USER is one)
+ if (st.isDirectory()) {
+ if (SKIP_DIRS.has(name)) continue;
+ walk(sp, dp);
+ continue;
+ }
+ if (!st.isFile()) continue;
+ if (existsSync(dp)) {
+ if (sameBytes(sp, dp)) {
+ r.current++;
+ continue;
+ }
+ if (apply) {
+ try {
+ cpSync(sp, dp);
+ } catch (err) {
+ r.failures.push(`copy ${sp}: ${String(err)}`);
+ continue;
+ }
+ }
+ r.updated++;
+ } else {
+ if (apply) {
+ try {
+ mkdirSync(dirname(dp), { recursive: true });
+ cpSync(sp, dp);
+ } catch (err) {
+ r.failures.push(`copy ${sp}: ${String(err)}`);
+ continue;
+ }
+ }
+ r.created++;
+ }
+ }
+ };
+
+ walk(src, dst);
+ return r;
+}
+
+function overlayFile(src: string, dst: string, what: string, apply: boolean): OverlayResult {
+ const r: OverlayResult = { what, src, dst, present: existsSync(src), updated: 0, current: 0, created: 0, failures: [] };
+ if (!r.present) return r;
+ const exists = existsSync(dst);
+ if (exists && sameBytes(src, dst)) {
+ r.current = 1;
+ return r;
+ }
+ if (apply) {
+ try {
+ mkdirSync(dirname(dst), { recursive: true });
+ cpSync(src, dst);
+ } catch (err) {
+ r.failures.push(`copy ${src}: ${String(err)}`);
+ return r;
+ }
+ }
+ if (exists) r.updated = 1;
+ else r.created = 1;
+ return r;
+}
+
+function main(): void {
+ const a = process.argv.slice(2);
+ const apply = a.includes("--apply");
+ const allowDev = a.includes("--allow-dev");
+ const configRoot = arg(a, "--config-root") || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
+ const skillRoot = arg(a, "--skill-root") || join(configRoot, "skills", "LifeOS");
+ const payloadInstall = join(skillRoot, "install");
+
+ if (!allowDev && detectDevTree(configRoot)) {
+ console.log(JSON.stringify({ ok: false, refused: "dev-tree", detail: `${configRoot} is a source tree — refusing to overlay.` }, null, 2));
+ process.exit(2);
+ }
+ if (!existsSync(payloadInstall)) {
+ console.log(JSON.stringify({ ok: false, blockers: [`payload absent: ${payloadInstall}`] }, null, 2));
+ process.exit(1);
+ }
+
+ const results: OverlayResult[] = [];
+ for (const [ps, ds] of SYSTEM_TREES) {
+ results.push(overlayTree(join(payloadInstall, ps), join(configRoot, ds), ps, apply));
+ }
+ for (const [ps, ds] of SYSTEM_FILES) {
+ results.push(overlayFile(join(payloadInstall, ps), join(configRoot, ds), ps, apply));
+ }
+
+ const failures = results.flatMap((r) => r.failures);
+ const ok = failures.length === 0;
+ const updated = results.reduce((n, r) => n + r.updated, 0);
+ const created = results.reduce((n, r) => n + r.created, 0);
+ const current = results.reduce((n, r) => n + r.current, 0);
+
+ // VERSION last, and only when everything else succeeded — a partial update must never
+ // leave a marker claiming the new version.
+ let version: { from: string | null; to: string | null; written: boolean } = { from: null, to: null, written: false };
+ const versionSrc = join(payloadInstall, "LIFEOS", "VERSION");
+ const versionDst = join(configRoot, "LIFEOS", "VERSION");
+ if (existsSync(versionSrc)) {
+ version.to = readFileSync(versionSrc, "utf8").trim();
+ version.from = existsSync(versionDst) ? readFileSync(versionDst, "utf8").trim() : null;
+ if (apply && ok) {
+ try {
+ writeFileSync(versionDst, version.to);
+ version.written = true;
+ } catch (err) {
+ failures.push(`write VERSION: ${String(err)}`);
+ }
+ }
+ }
+
+ const notes: string[] = [];
+ if (!apply) notes.push("dry-run — re-run with --apply to overlay (VERSION is written only on a successful apply)");
+ notes.push("USER/, LIFEOS/MEMORY/ and settings.json are never touched; nothing is ever deleted");
+
+ console.log(JSON.stringify({
+ ok: failures.length === 0,
+ dryRun: !apply,
+ configRoot,
+ payloadInstall,
+ updated,
+ created,
+ alreadyCurrent: current,
+ version,
+ failures,
+ results,
+ note: notes.join(" | "),
+ }, null, 2));
+ process.exit(failures.length === 0 ? 0 : 1);
+}
+
+main();
diff --git a/LifeOS/Workflows/Update.md b/LifeOS/Workflows/Update.md
index 084e6eed45..1259508843 100644
--- a/LifeOS/Workflows/Update.md
+++ b/LifeOS/Workflows/Update.md
@@ -25,10 +25,12 @@ curl -s -X POST http://localhost:31337/notify -H "Content-Type: application/json
- **Network unreachable** → say so and continue with the on-disk payload; re-applying the current version is safe, it just can't deliver anything newer.
Never diff the on-disk payload's version against the install marker — the payload is what wrote the marker, so that comparison always says "already current" and the update never fetches anything.
-3. **Re-overlay system** — re-copy the system templates (CLAUDE, system prompt, `settings.system.json` minus hooks), and overwrite `/LIFEOS/VERSION` with the payload's version — the copyMissing deploys never touch an existing marker, and a stale marker re-trips step 2 forever. These are system-owned and safe to overwrite.
+3. **Re-overlay system** — `bun Tools/OverlaySystem.ts --apply`. Refreshes system-owned files the copyMissing deploys cannot touch: `hooks/`, `skills/`, `agents/`, `LIFEOS/{TOOLS,DOCUMENTATION,ALGORITHM,RULES,PULSE}/`, plus `CLAUDE.md` and `LIFEOS_SYSTEM_PROMPT.md`. `USER/`, `LIFEOS/MEMORY/` and `settings.json` are never touched, and nothing is ever deleted — a file is written only where the payload ships one at the same relative path, so operator-authored hooks, skills and tools survive. `/LIFEOS/VERSION` is written LAST and only on a fully successful apply, so a partial update can never present as complete. Dry-run by default; `--apply` mutates.
+
+ > Without this step the update is silently partial: `copyMissing` (`InstallEngine.ts`) writes only when the destination is absent, so every file that CHANGED between versions keeps its old contents while `VERSION` claims the new one. Because the dispatchers (`StopGates`, `PreToolGuard`, `PostToolObserver`, `MemoryTurnStart`) are among those files, hooks composed inside them cannot fire even though the constitution mandating them installed fine.
4. **Re-merge hooks** — `bun Tools/InstallHooks.ts --apply` (idempotent): adds new hook entries, leaves existing ones, never duplicates (normalized-command dedup). Backs up `settings.json` first.
5. **Scaffold new USER templates only** — `bun Tools/ScaffoldUser.ts --apply` copyMissing: adds any NEW template files introduced by the version, never overwrites the user's existing files.
-6. **Re-activate imports** — `bun Tools/ActivateImports.ts --apply` for any newly-shipped identity import lines.
+6. **Re-activate imports** — `bun Tools/ActivateImports.ts --apply` for any newly-shipped identity import lines. **Required after step 3**, which restores `CLAUDE.md` from the template and therefore returns the identity imports to their commented state. Verify by reading `CLAUDE.md` back and counting active `@`-import lines, not by the tool's exit code — it reports `ok: true` even when it activates nothing.
7. **Verify** — two evidence classes (hooks fire + imports resolve), same as Setup step 9.
## Rule
diff --git a/LifeOS/install/skills/LifeOS/Tools/OverlaySystem.ts b/LifeOS/install/skills/LifeOS/Tools/OverlaySystem.ts
new file mode 100644
index 0000000000..f5a2e42d9b
--- /dev/null
+++ b/LifeOS/install/skills/LifeOS/Tools/OverlaySystem.ts
@@ -0,0 +1,247 @@
+#!/usr/bin/env bun
+/**
+ * OverlaySystem.ts — the update-path counterpart to DeployCore: refresh SYSTEM-OWNED files
+ * that already exist, which copyMissing by contract cannot do.
+ *
+ * DeployCore is additive: `copyMissing` writes only when the destination is absent, so a
+ * populated install can receive NEW files but never an UPDATED one. That is the right
+ * contract for a fresh install and the wrong one for an update — `Workflows/Update.md`
+ * step 3 ("Re-overlay system") describes this tool, which did not previously exist.
+ *
+ * Scope is deliberately narrow. Only paths LifeOS owns are overwritten:
+ *
+ * hooks/ · skills/ · agents/ · LIFEOS/{TOOLS,DOCUMENTATION,ALGORITHM,RULES,PULSE}/
+ * plus CLAUDE.md and LIFEOS/LIFEOS_SYSTEM_PROMPT.md
+ *
+ * Never touched: USER/ (the principal's tree), LIFEOS/MEMORY/ (per-install state),
+ * settings.json (hook registrations — InstallHooks owns that merge), node_modules, .git.
+ *
+ * Nothing is ever DELETED. A file is written only where the payload ships one at the same
+ * relative path, so operator-authored hooks, skills and tools survive untouched.
+ *
+ * VERSION is written LAST and only on a fully successful --apply, so a partial update can
+ * never present as complete (the failure mode where the marker says the new version while
+ * the machinery is still the old one).
+ *
+ * Dry-run by default (`--apply` to mutate); REFUSES the author's live source tree
+ * (`--allow-dev` to override; exit 2), matching DeployCore's failure contract.
+ *
+ * Usage:
+ * bun OverlaySystem.ts [--config-root ] [--skill-root ] [--apply] [--allow-dev]
+ */
+
+import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync, cpSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, join } from "node:path";
+import { detectDevTree } from "./InstallEngine";
+
+/** Directories inside a system tree that are never overlaid, at any depth. */
+const SKIP_DIRS = new Set(["node_modules", ".git", "MEMORY", "USER", "out", ".next"]);
+
+/**
+ * System-owned trees: [payload-relative, configRoot-relative].
+ * Anything not listed here is left alone — absence from this list is the safety property.
+ */
+const SYSTEM_TREES: Array<[string, string]> = [
+ ["hooks", "hooks"],
+ ["skills", "skills"],
+ ["agents", "agents"],
+ ["LIFEOS/TOOLS", "LIFEOS/TOOLS"],
+ ["LIFEOS/DOCUMENTATION", "LIFEOS/DOCUMENTATION"],
+ ["LIFEOS/ALGORITHM", "LIFEOS/ALGORITHM"],
+ ["LIFEOS/RULES", "LIFEOS/RULES"],
+ ["LIFEOS/PULSE", "LIFEOS/PULSE"],
+];
+
+/** System-owned single files: [payload-relative, configRoot-relative]. */
+const SYSTEM_FILES: Array<[string, string]> = [
+ ["LIFEOS/LIFEOS_SYSTEM_PROMPT.md", "LIFEOS/LIFEOS_SYSTEM_PROMPT.md"],
+ ["skills/LifeOS/install/CLAUDE.template.md", "CLAUDE.md"],
+];
+
+function arg(a: string[], flag: string): string | undefined {
+ const i = a.indexOf(flag);
+ return i >= 0 && a[i + 1] && !a[i + 1].startsWith("--") ? a[i + 1] : undefined;
+}
+
+function sameBytes(a: string, b: string): boolean {
+ try {
+ const x = readFileSync(a);
+ const y = readFileSync(b);
+ return x.length === y.length && x.equals(y);
+ } catch {
+ return false;
+ }
+}
+
+interface OverlayResult {
+ what: string;
+ src: string;
+ dst: string;
+ present: boolean;
+ /** existed and differed → rewritten */
+ updated: number;
+ /** already byte-identical → untouched */
+ current: number;
+ /** absent → created */
+ created: number;
+ failures: string[];
+}
+
+/**
+ * Walk the payload tree and write each file to the matching relative path under dst.
+ * Updates and creations are both performed; deletions never are.
+ */
+function overlayTree(src: string, dst: string, what: string, apply: boolean): OverlayResult {
+ const r: OverlayResult = { what, src, dst, present: existsSync(src), updated: 0, current: 0, created: 0, failures: [] };
+ if (!r.present) return r;
+
+ const walk = (s: string, d: string): void => {
+ let entries: string[];
+ try {
+ entries = readdirSync(s);
+ } catch (err) {
+ r.failures.push(`readdir ${s}: ${String(err)}`);
+ return;
+ }
+ for (const name of entries) {
+ const sp = join(s, name);
+ const dp = join(d, name);
+ let st;
+ try {
+ st = lstatSync(sp);
+ } catch {
+ continue;
+ }
+ if (st.isSymbolicLink()) continue; // never follow or replace symlinks (USER is one)
+ if (st.isDirectory()) {
+ if (SKIP_DIRS.has(name)) continue;
+ walk(sp, dp);
+ continue;
+ }
+ if (!st.isFile()) continue;
+ if (existsSync(dp)) {
+ if (sameBytes(sp, dp)) {
+ r.current++;
+ continue;
+ }
+ if (apply) {
+ try {
+ cpSync(sp, dp);
+ } catch (err) {
+ r.failures.push(`copy ${sp}: ${String(err)}`);
+ continue;
+ }
+ }
+ r.updated++;
+ } else {
+ if (apply) {
+ try {
+ mkdirSync(dirname(dp), { recursive: true });
+ cpSync(sp, dp);
+ } catch (err) {
+ r.failures.push(`copy ${sp}: ${String(err)}`);
+ continue;
+ }
+ }
+ r.created++;
+ }
+ }
+ };
+
+ walk(src, dst);
+ return r;
+}
+
+function overlayFile(src: string, dst: string, what: string, apply: boolean): OverlayResult {
+ const r: OverlayResult = { what, src, dst, present: existsSync(src), updated: 0, current: 0, created: 0, failures: [] };
+ if (!r.present) return r;
+ const exists = existsSync(dst);
+ if (exists && sameBytes(src, dst)) {
+ r.current = 1;
+ return r;
+ }
+ if (apply) {
+ try {
+ mkdirSync(dirname(dst), { recursive: true });
+ cpSync(src, dst);
+ } catch (err) {
+ r.failures.push(`copy ${src}: ${String(err)}`);
+ return r;
+ }
+ }
+ if (exists) r.updated = 1;
+ else r.created = 1;
+ return r;
+}
+
+function main(): void {
+ const a = process.argv.slice(2);
+ const apply = a.includes("--apply");
+ const allowDev = a.includes("--allow-dev");
+ const configRoot = arg(a, "--config-root") || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
+ const skillRoot = arg(a, "--skill-root") || join(configRoot, "skills", "LifeOS");
+ const payloadInstall = join(skillRoot, "install");
+
+ if (!allowDev && detectDevTree(configRoot)) {
+ console.log(JSON.stringify({ ok: false, refused: "dev-tree", detail: `${configRoot} is a source tree — refusing to overlay.` }, null, 2));
+ process.exit(2);
+ }
+ if (!existsSync(payloadInstall)) {
+ console.log(JSON.stringify({ ok: false, blockers: [`payload absent: ${payloadInstall}`] }, null, 2));
+ process.exit(1);
+ }
+
+ const results: OverlayResult[] = [];
+ for (const [ps, ds] of SYSTEM_TREES) {
+ results.push(overlayTree(join(payloadInstall, ps), join(configRoot, ds), ps, apply));
+ }
+ for (const [ps, ds] of SYSTEM_FILES) {
+ results.push(overlayFile(join(payloadInstall, ps), join(configRoot, ds), ps, apply));
+ }
+
+ const failures = results.flatMap((r) => r.failures);
+ const ok = failures.length === 0;
+ const updated = results.reduce((n, r) => n + r.updated, 0);
+ const created = results.reduce((n, r) => n + r.created, 0);
+ const current = results.reduce((n, r) => n + r.current, 0);
+
+ // VERSION last, and only when everything else succeeded — a partial update must never
+ // leave a marker claiming the new version.
+ let version: { from: string | null; to: string | null; written: boolean } = { from: null, to: null, written: false };
+ const versionSrc = join(payloadInstall, "LIFEOS", "VERSION");
+ const versionDst = join(configRoot, "LIFEOS", "VERSION");
+ if (existsSync(versionSrc)) {
+ version.to = readFileSync(versionSrc, "utf8").trim();
+ version.from = existsSync(versionDst) ? readFileSync(versionDst, "utf8").trim() : null;
+ if (apply && ok) {
+ try {
+ writeFileSync(versionDst, version.to);
+ version.written = true;
+ } catch (err) {
+ failures.push(`write VERSION: ${String(err)}`);
+ }
+ }
+ }
+
+ const notes: string[] = [];
+ if (!apply) notes.push("dry-run — re-run with --apply to overlay (VERSION is written only on a successful apply)");
+ notes.push("USER/, LIFEOS/MEMORY/ and settings.json are never touched; nothing is ever deleted");
+
+ console.log(JSON.stringify({
+ ok: failures.length === 0,
+ dryRun: !apply,
+ configRoot,
+ payloadInstall,
+ updated,
+ created,
+ alreadyCurrent: current,
+ version,
+ failures,
+ results,
+ note: notes.join(" | "),
+ }, null, 2));
+ process.exit(failures.length === 0 ? 0 : 1);
+}
+
+main();
diff --git a/LifeOS/install/skills/LifeOS/Workflows/Update.md b/LifeOS/install/skills/LifeOS/Workflows/Update.md
index 084e6eed45..1259508843 100644
--- a/LifeOS/install/skills/LifeOS/Workflows/Update.md
+++ b/LifeOS/install/skills/LifeOS/Workflows/Update.md
@@ -25,10 +25,12 @@ curl -s -X POST http://localhost:31337/notify -H "Content-Type: application/json
- **Network unreachable** → say so and continue with the on-disk payload; re-applying the current version is safe, it just can't deliver anything newer.
Never diff the on-disk payload's version against the install marker — the payload is what wrote the marker, so that comparison always says "already current" and the update never fetches anything.
-3. **Re-overlay system** — re-copy the system templates (CLAUDE, system prompt, `settings.system.json` minus hooks), and overwrite `/LIFEOS/VERSION` with the payload's version — the copyMissing deploys never touch an existing marker, and a stale marker re-trips step 2 forever. These are system-owned and safe to overwrite.
+3. **Re-overlay system** — `bun Tools/OverlaySystem.ts --apply`. Refreshes system-owned files the copyMissing deploys cannot touch: `hooks/`, `skills/`, `agents/`, `LIFEOS/{TOOLS,DOCUMENTATION,ALGORITHM,RULES,PULSE}/`, plus `CLAUDE.md` and `LIFEOS_SYSTEM_PROMPT.md`. `USER/`, `LIFEOS/MEMORY/` and `settings.json` are never touched, and nothing is ever deleted — a file is written only where the payload ships one at the same relative path, so operator-authored hooks, skills and tools survive. `/LIFEOS/VERSION` is written LAST and only on a fully successful apply, so a partial update can never present as complete. Dry-run by default; `--apply` mutates.
+
+ > Without this step the update is silently partial: `copyMissing` (`InstallEngine.ts`) writes only when the destination is absent, so every file that CHANGED between versions keeps its old contents while `VERSION` claims the new one. Because the dispatchers (`StopGates`, `PreToolGuard`, `PostToolObserver`, `MemoryTurnStart`) are among those files, hooks composed inside them cannot fire even though the constitution mandating them installed fine.
4. **Re-merge hooks** — `bun Tools/InstallHooks.ts --apply` (idempotent): adds new hook entries, leaves existing ones, never duplicates (normalized-command dedup). Backs up `settings.json` first.
5. **Scaffold new USER templates only** — `bun Tools/ScaffoldUser.ts --apply` copyMissing: adds any NEW template files introduced by the version, never overwrites the user's existing files.
-6. **Re-activate imports** — `bun Tools/ActivateImports.ts --apply` for any newly-shipped identity import lines.
+6. **Re-activate imports** — `bun Tools/ActivateImports.ts --apply` for any newly-shipped identity import lines. **Required after step 3**, which restores `CLAUDE.md` from the template and therefore returns the identity imports to their commented state. Verify by reading `CLAUDE.md` back and counting active `@`-import lines, not by the tool's exit code — it reports `ok: true` even when it activates nothing.
7. **Verify** — two evidence classes (hooks fire + imports resolve), same as Setup step 9.
## Rule