@wipcomputer/wip-ldm-os 0.4.85-alpha.32 → 0.4.85-alpha.33

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.
package/bin/ldm.js CHANGED
@@ -24,6 +24,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync
24
24
  import { join, basename, resolve, dirname } from 'node:path';
25
25
  import { execSync, spawnSync } from 'node:child_process';
26
26
  import { fileURLToPath } from 'node:url';
27
+ import { createHash } from 'node:crypto';
27
28
 
28
29
  const __filename = fileURLToPath(import.meta.url);
29
30
  const __dirname = dirname(__filename);
@@ -409,9 +410,39 @@ function cleanStaleHooks() {
409
410
 
410
411
  // ── Boot hook sync (#49) ──
411
412
 
413
+ // The registered SessionStart hook command is the single source of truth for
414
+ // WHERE the boot hook actually executes from. Historically syncBootHook()
415
+ // deployed to ~/.ldm/library/boot while the registered command pointed at
416
+ // ~/.ldm/shared/boot (installer.mjs BOOT_DIR): new code landed in library/,
417
+ // sessions ran the stale copy in shared/, and the install reported success.
418
+ // Resolve the deploy target from the registered entry so the two can never
419
+ // diverge again. Fallback is shared/boot, matching installer.mjs BOOT_DIR and
420
+ // configureSessionStartHook() so a fresh machine stays consistent.
421
+ // Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
422
+ function getRegisteredBootHookTarget() {
423
+ const settings = readJSON(join(HOME, '.claude', 'settings.json'));
424
+ const entries = settings?.hooks?.SessionStart;
425
+ if (!Array.isArray(entries)) return null;
426
+ for (const entry of entries) {
427
+ for (const h of (entry?.hooks || [])) {
428
+ const cmd = h?.command || '';
429
+ const m = cmd.match(/(\S*boot-hook\.mjs)/);
430
+ if (m) return m[1].replace(/^["']|["']$/g, '');
431
+ }
432
+ }
433
+ return null;
434
+ }
435
+
436
+ function resolveBootExecDir() {
437
+ const target = getRegisteredBootHookTarget();
438
+ if (target) return dirname(target);
439
+ // Canonical default: same as src/boot/installer.mjs BOOT_DIR.
440
+ return join(LDM_ROOT, 'shared', 'boot');
441
+ }
442
+
412
443
  function syncBootHook() {
413
444
  const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
414
- const destBoot = join(LDM_ROOT, 'library', 'boot', 'boot-hook.mjs');
445
+ const destBoot = join(resolveBootExecDir(), 'boot-hook.mjs');
415
446
 
416
447
  if (!existsSync(srcBoot)) return false;
417
448
 
@@ -3202,6 +3233,58 @@ async function cmdDoctor() {
3202
3233
  }
3203
3234
  }
3204
3235
 
3236
+ // Boot hook stale at execution path.
3237
+ //
3238
+ // Split-brain deploys are silent: version tracking can say the boot hook
3239
+ // updated while the SessionStart hook still executes an older copy on disk
3240
+ // (2026-07-05: install reported alpha.32's boot trim active, but the live
3241
+ // payload was the untrimmed 49KB because syncBootHook wrote library/boot
3242
+ // while the hook ran shared/boot). Hash-compare the file the registered
3243
+ // hook actually runs against this CLI's src/boot/boot-hook.mjs (the code the
3244
+ // installer would deploy). Divergence means the hook is running stale code.
3245
+ // Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
3246
+ {
3247
+ const execTarget = getRegisteredBootHookTarget();
3248
+ const srcBoot = join(__dirname, '..', 'src', 'boot', 'boot-hook.mjs');
3249
+ const hashFile = (p) => {
3250
+ try { return createHash('sha256').update(readFileSync(p)).digest('hex'); } catch { return null; }
3251
+ };
3252
+ if (execTarget && existsSync(srcBoot)) {
3253
+ const srcHash = hashFile(srcBoot);
3254
+ const execExists = existsSync(execTarget);
3255
+ const execHash = execExists ? hashFile(execTarget) : null;
3256
+ // Informational: which of the known deploy locations already matches src.
3257
+ const freshCandidates = [
3258
+ join(LDM_ROOT, 'shared', 'boot', 'boot-hook.mjs'),
3259
+ join(LDM_ROOT, 'library', 'boot', 'boot-hook.mjs'),
3260
+ ].filter((p) => existsSync(p) && hashFile(p) === srcHash);
3261
+ if (!execExists || execHash !== srcHash) {
3262
+ const short = (h) => (h ? h.slice(0, 12) : 'missing');
3263
+ console.log(` ! boot hook stale at execution path: ${execTarget.replace(HOME, '~')}`);
3264
+ console.log(` the registered SessionStart hook runs ${short(execHash)}, current is ${short(srcHash)}`);
3265
+ if (freshCandidates.length > 0) {
3266
+ console.log(` a current copy exists at: ${freshCandidates[0].replace(HOME, '~')}`);
3267
+ }
3268
+ if (FIX_FLAG) {
3269
+ try {
3270
+ if (execExists) {
3271
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3272
+ copyFileSync(execTarget, `${execTarget}.bak-${stamp}`);
3273
+ }
3274
+ mkdirSync(dirname(execTarget), { recursive: true });
3275
+ copyFileSync(srcBoot, execTarget);
3276
+ console.log(' + Redeployed the current boot hook to the execution path');
3277
+ } catch (e) {
3278
+ console.log(` x Could not redeploy boot hook to execution path: ${e.message}`);
3279
+ }
3280
+ } else {
3281
+ console.log(' Run: ldm doctor --fix to redeploy the current boot hook to the execution path');
3282
+ issues++;
3283
+ }
3284
+ }
3285
+ }
3286
+ }
3287
+
3205
3288
  // --fix: clean registry entries with /tmp/ sources or ldm-install- names (#54)
3206
3289
  if (FIX_FLAG) {
3207
3290
  const registry = readJSON(REGISTRY_PATH);
package/lib/deploy.mjs CHANGED
@@ -19,6 +19,7 @@ import { join, basename, resolve, dirname } from 'node:path';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { detectInterfaces, describeInterfaces, detectToolbox } from './detect.mjs';
21
21
  import { moveToTrash, appendToManifest } from './safe.mjs';
22
+ import { configureSessionStartHook } from '../src/boot/installer.mjs';
22
23
 
23
24
  const HOME = process.env.HOME || '';
24
25
  const LDM_ROOT = join(HOME, '.ldm');
@@ -1117,6 +1118,40 @@ function installClaudeCodeHook(repoPath, doorOrDoors, toolName = basename(repoPa
1117
1118
  }
1118
1119
 
1119
1120
  function installClaudeCodeHookEvent(repoPath, door, toolName = basename(repoPath)) {
1121
+ // Boot hook special case: single-owner delegation.
1122
+ //
1123
+ // The SessionStart boot hook's deployed command lives in ~/.ldm/shared/boot,
1124
+ // NOT under ~/.ldm/extensions/<toolName>/. The extension-dir ownership match
1125
+ // below keys on `/<toolName>/` in the command string, so it can never
1126
+ // recognize an existing boot entry and appends a fresh duplicate on every
1127
+ // manifest-driven install (10 copies accumulated 2026-07-04, ~450KB of boot
1128
+ // context per session). configureSessionStartHook() in src/boot/installer.mjs
1129
+ // is the single canonical registrar: it owns the whole boot-hook set,
1130
+ // collapses duplicates, canonicalizes the command to BOOT_DIR, persists, and
1131
+ // no-ops when already correct. Route boot-hook doors there so there is one
1132
+ // writer, not two. Discriminating on the command (not the event alone) keeps
1133
+ // other SessionStart hooks, e.g. wip-branch-guard's, on the normal path.
1134
+ //
1135
+ // Ticket: ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--deploy-hook-ownership-misses-boot-hook.md
1136
+ const declaredCommand = door.command || '';
1137
+ const isBootHookDoor =
1138
+ (door.event || 'PreToolUse') === 'SessionStart' &&
1139
+ (declaredCommand.includes('boot-hook') || /shared[/\\]boot/.test(declaredCommand));
1140
+ if (isBootHookDoor) {
1141
+ if (DRY_RUN) {
1142
+ ok('Claude Code: would configure SessionStart boot hook (single-owner)');
1143
+ return true;
1144
+ }
1145
+ try {
1146
+ const result = configureSessionStartHook();
1147
+ ok(`Claude Code: ${result}`);
1148
+ return true;
1149
+ } catch (e) {
1150
+ fail(`Claude Code: boot hook registration failed. ${e.message}`);
1151
+ return false;
1152
+ }
1153
+ }
1154
+
1120
1155
  const settingsPath = join(HOME, '.claude', 'settings.json');
1121
1156
  let settings = readJSON(settingsPath);
1122
1157
 
@@ -1702,4 +1737,4 @@ export function disableExtension(name) {
1702
1737
 
1703
1738
  // ── Exports for ldm CLI ──
1704
1739
 
1705
- export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, buildSourceInfo, CORE_EXTENSIONS };
1740
+ export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, buildSourceInfo, CORE_EXTENSIONS, installClaudeCodeHook };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.85-alpha.32",
3
+ "version": "0.4.85-alpha.33",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -48,7 +48,9 @@
48
48
  "fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
49
49
  "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
50
50
  "test:boot-hook-registration": "node scripts/test-boot-hook-registration.mjs",
51
- "test:doctor-hook-dedupe": "node scripts/test-doctor-hook-dedupe.mjs"
51
+ "test:doctor-hook-dedupe": "node scripts/test-doctor-hook-dedupe.mjs",
52
+ "test:boot-dir-truth": "node scripts/test-boot-dir-truth.mjs",
53
+ "test:deploy-hook-ownership": "node scripts/test-deploy-hook-ownership.mjs"
52
54
  },
53
55
  "claudeCode": {
54
56
  "hook": {
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // Regression test: `ldm doctor` boot-hook stale-at-execution-path check.
3
+ //
4
+ // Covers:
5
+ // ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--shared-library-split-brain-boot-deploy.md
6
+ //
7
+ // The split-brain bug: syncBootHook() deployed boot-hook.mjs to
8
+ // ~/.ldm/library/boot while the registered SessionStart hook executed
9
+ // ~/.ldm/shared/boot. New code landed in library/, sessions ran the stale
10
+ // copy in shared/, and the install reported success. This test drives the
11
+ // doctor check that makes the drift visible (and the --fix that repairs the
12
+ // execution path), plus the no-false-positive cases.
13
+ //
14
+ // Follows the test-doctor-hook-dedupe.mjs pattern: real bin/ldm.js doctor
15
+ // against a temp HOME, crontab/npm shimmed on PATH so operator state and the
16
+ // network are never touched. LDM_SELF_UPDATED=1 skips CLI self-update.
17
+
18
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { execFileSync } from 'node:child_process';
21
+ import { tmpdir } from 'node:os';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
25
+ const cli = join(repo, 'bin', 'ldm.js');
26
+ const pkg = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8'));
27
+ // The current boot hook this CLI would deploy: doctor compares against it.
28
+ const srcBootContent = readFileSync(join(repo, 'src', 'boot', 'boot-hook.mjs'), 'utf8');
29
+
30
+ let failed = 0;
31
+ function assert(cond, label, output = '') {
32
+ if (cond) {
33
+ console.log(` [PASS] ${label}`);
34
+ } else {
35
+ console.log(` [FAIL] ${label}`);
36
+ if (output) console.log(` --- output (last lines) ---\n ${output.trim().split('\n').slice(-25).join('\n ')}`);
37
+ failed++;
38
+ }
39
+ }
40
+
41
+ // Build a fixture HOME. The registered SessionStart hook points at
42
+ // ~/.ldm/shared/boot/boot-hook.mjs (the execution path). `execContent`
43
+ // seeds that file; `libraryContent` seeds the parallel ~/.ldm/library/boot
44
+ // copy (null to skip).
45
+ function setupHome({ execContent, libraryContent }) {
46
+ const home = mkdtempSync(join(tmpdir(), 'ldm-bootdir-'));
47
+ const ldmRoot = join(home, '.ldm');
48
+ const fakeBin = join(home, 'fakebin');
49
+ mkdirSync(join(ldmRoot, 'extensions'), { recursive: true });
50
+ mkdirSync(join(home, '.claude'), { recursive: true });
51
+ mkdirSync(fakeBin, { recursive: true });
52
+
53
+ writeFileSync(join(ldmRoot, 'version.json'), JSON.stringify({ version: pkg.version }, null, 2) + '\n');
54
+ writeFileSync(join(ldmRoot, 'extensions', 'registry.json'), JSON.stringify({ _format: 'v2', extensions: {} }, null, 2) + '\n');
55
+
56
+ const execTarget = join(ldmRoot, 'shared', 'boot', 'boot-hook.mjs');
57
+ mkdirSync(dirname(execTarget), { recursive: true });
58
+ writeFileSync(execTarget, execContent);
59
+ if (libraryContent !== null && libraryContent !== undefined) {
60
+ const lib = join(ldmRoot, 'library', 'boot', 'boot-hook.mjs');
61
+ mkdirSync(dirname(lib), { recursive: true });
62
+ writeFileSync(lib, libraryContent);
63
+ }
64
+
65
+ // Registered SessionStart hook runs the shared/boot (execution) copy.
66
+ const settings = {
67
+ hooks: {
68
+ SessionStart: [
69
+ { matcher: '*', hooks: [{ type: 'command', command: `node ${execTarget}`, timeout: 15 }] },
70
+ ],
71
+ },
72
+ };
73
+ const settingsPath = join(home, '.claude', 'settings.json');
74
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
75
+
76
+ writeFileSync(join(fakeBin, 'crontab'), '#!/bin/sh\nexit 0\n');
77
+ chmodSync(join(fakeBin, 'crontab'), 0o755);
78
+ writeFileSync(join(fakeBin, 'npm'), '#!/bin/sh\nexit 0\n');
79
+ chmodSync(join(fakeBin, 'npm'), 0o755);
80
+
81
+ return { home, fakeBin, execTarget };
82
+ }
83
+
84
+ function runDoctor({ home, fakeBin }, fix = false) {
85
+ const args = ['doctor'];
86
+ if (fix) args.push('--fix');
87
+ try {
88
+ return execFileSync('node', [cli, ...args], {
89
+ env: { ...process.env, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, LDM_SELF_UPDATED: '1' },
90
+ encoding: 'utf-8',
91
+ timeout: 30000,
92
+ });
93
+ } catch (err) {
94
+ return (err.stdout || '') + (err.stderr || '');
95
+ }
96
+ }
97
+
98
+ const STALE = '// stale boot hook code (pre-trim)\nprocess.exit(0);\n';
99
+
100
+ console.log('Test 1: stale execution path is reported, not written without --fix');
101
+ {
102
+ const w = setupHome({ execContent: STALE, libraryContent: srcBootContent });
103
+ const before = readFileSync(w.execTarget, 'utf-8');
104
+ const out = runDoctor(w);
105
+ const after = readFileSync(w.execTarget, 'utf-8');
106
+ assert(/boot hook stale at execution path/.test(out), 'reports stale at execution path', out);
107
+ assert(/a current copy exists at:.*library\/boot/.test(out), 'points at the fresh library/boot copy', out);
108
+ assert(before === after, 'execution-path file untouched without --fix');
109
+ rmSync(w.home, { recursive: true, force: true });
110
+ }
111
+
112
+ console.log('Test 2: --fix redeploys the current boot hook to the execution path');
113
+ {
114
+ const w = setupHome({ execContent: STALE, libraryContent: srcBootContent });
115
+ const out = runDoctor(w, true);
116
+ const after = readFileSync(w.execTarget, 'utf-8');
117
+ const backups = readdirSync(dirname(w.execTarget)).filter((f) => f.startsWith('boot-hook.mjs.bak-'));
118
+ assert(/Redeployed the current boot hook to the execution path/.test(out), 'reports redeploy under --fix', out);
119
+ assert(after === srcBootContent, 'execution-path file now matches current src/boot/boot-hook.mjs');
120
+ assert(backups.length === 1, `stale copy backed up before overwrite (found ${backups.length})`);
121
+ const again = runDoctor(w, true);
122
+ assert(!/boot hook stale at execution path/.test(again), 'second --fix run finds nothing stale', again);
123
+ rmSync(w.home, { recursive: true, force: true });
124
+ }
125
+
126
+ console.log('Test 3: in-sync execution path is not flagged');
127
+ {
128
+ const w = setupHome({ execContent: srcBootContent, libraryContent: null });
129
+ const out = runDoctor(w);
130
+ assert(!/boot hook stale at execution path/.test(out), 'no stale report when exec path matches src', out);
131
+ rmSync(w.home, { recursive: true, force: true });
132
+ }
133
+
134
+ console.log('Test 4: no registered boot hook is a no-op (no crash, no false positive)');
135
+ {
136
+ const home = mkdtempSync(join(tmpdir(), 'ldm-bootdir-none-'));
137
+ const ldmRoot = join(home, '.ldm');
138
+ const fakeBin = join(home, 'fakebin');
139
+ mkdirSync(join(ldmRoot, 'extensions'), { recursive: true });
140
+ mkdirSync(join(home, '.claude'), { recursive: true });
141
+ mkdirSync(fakeBin, { recursive: true });
142
+ writeFileSync(join(ldmRoot, 'version.json'), JSON.stringify({ version: pkg.version }, null, 2) + '\n');
143
+ writeFileSync(join(ldmRoot, 'extensions', 'registry.json'), JSON.stringify({ _format: 'v2', extensions: {} }, null, 2) + '\n');
144
+ writeFileSync(join(home, '.claude', 'settings.json'), JSON.stringify({ hooks: {} }, null, 2) + '\n');
145
+ writeFileSync(join(fakeBin, 'crontab'), '#!/bin/sh\nexit 0\n');
146
+ chmodSync(join(fakeBin, 'crontab'), 0o755);
147
+ writeFileSync(join(fakeBin, 'npm'), '#!/bin/sh\nexit 0\n');
148
+ chmodSync(join(fakeBin, 'npm'), 0o755);
149
+ const out = runDoctor({ home, fakeBin });
150
+ assert(!/boot hook stale at execution path/.test(out), 'no stale report when no boot hook is registered', out);
151
+ rmSync(home, { recursive: true, force: true });
152
+ }
153
+
154
+ if (failed > 0) {
155
+ console.log(`\n${failed} assertion(s) failed`);
156
+ process.exit(1);
157
+ }
158
+ console.log('\nAll boot-dir-truth tests passed');
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // Regression test: manifest-driven boot-hook registration is single-owner.
3
+ //
4
+ // Covers:
5
+ // ai/product/bugs/installer/open-tickets/2026-07-05--cc-mini--deploy-hook-ownership-misses-boot-hook.md
6
+ //
7
+ // Before this fix, lib/deploy.mjs installClaudeCodeHookEvent() matched owned
8
+ // hook entries by an extension-dir tag (`/<toolName>/`). The boot hook's
9
+ // deployed command is `node ~/.ldm/shared/boot/boot-hook.mjs`, which contains
10
+ // no `/wip-ldm-os/` segment, so the ownership match found nothing and appended
11
+ // a fresh SessionStart entry on every manifest-driven `ldm install` (the real
12
+ // mechanism behind the 10-entry accumulation on 2026-07-04).
13
+ //
14
+ // The fix routes boot-hook doors to configureSessionStartHook() (the single
15
+ // canonical registrar), so the deploy path no longer appends.
16
+ //
17
+ // Cases:
18
+ // 1. Fresh manifest install: exactly one boot entry, canonical command.
19
+ // 2. Repeat manifest install: byte-identical settings.json (true no-op).
20
+ // 3. Pre-accumulated + unrelated SessionStart hook: collapses boot entries
21
+ // to one, preserves the non-boot SessionStart entry (discriminates on the
22
+ // command, not the event).
23
+ // 4. Dry run: reports intent, writes nothing.
24
+ //
25
+ // HOME must be set before importing deploy.mjs: both deploy.mjs (process.env.HOME)
26
+ // and the transitively-imported src/boot/installer.mjs (os.homedir()) resolve
27
+ // their HOME-based constants at module-load time.
28
+
29
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { tmpdir } from 'node:os';
32
+
33
+ // The temp prefix must not contain "boot-hook" or "shared/boot": those are the
34
+ // ownership matchers, and the temp HOME ends up inside every fixture command.
35
+ const tempHome = mkdtempSync(join(tmpdir(), 'ldm-deployown-'));
36
+ process.env.HOME = tempHome;
37
+
38
+ const { installClaudeCodeHook, setFlags } = await import('../lib/deploy.mjs');
39
+
40
+ // Mirror detect.mjs: the wip-ldm-os package.json declares a single SessionStart
41
+ // boot-hook door. deploy.mjs receives it as a one-element array.
42
+ const bootDoor = {
43
+ event: 'SessionStart',
44
+ matcher: '*',
45
+ command: `node ${join(tempHome, '.ldm', 'shared', 'boot', 'boot-hook.mjs')}`,
46
+ timeout: 15,
47
+ };
48
+ const expectedCommand = `node ${join(tempHome, '.ldm', 'shared', 'boot', 'boot-hook.mjs')}`;
49
+
50
+ let failed = 0;
51
+ function assert(cond, label, detail = '') {
52
+ if (cond) {
53
+ console.log(` [PASS] ${label}`);
54
+ } else {
55
+ console.log(` [FAIL] ${label}`);
56
+ if (detail) console.log(` ${detail}`);
57
+ failed++;
58
+ }
59
+ }
60
+
61
+ const settingsPath = join(tempHome, '.claude', 'settings.json');
62
+ function seedSettings(obj) {
63
+ mkdirSync(join(tempHome, '.claude'), { recursive: true });
64
+ writeFileSync(settingsPath, JSON.stringify(obj, null, 2) + '\n');
65
+ }
66
+ function readSettings() {
67
+ return JSON.parse(readFileSync(settingsPath, 'utf-8'));
68
+ }
69
+ function bootEntries(settings) {
70
+ return (settings.hooks?.SessionStart || []).filter((entry) =>
71
+ (entry?.hooks || []).some((h) => h?.command?.includes('boot-hook'))
72
+ );
73
+ }
74
+ // Install through the manifest wrapper exactly as detect.mjs feeds it: an array.
75
+ function manifestInstall() {
76
+ return installClaudeCodeHook(join(tempHome, 'repo'), [bootDoor], 'wip-ldm-os');
77
+ }
78
+
79
+ try {
80
+ console.log('Test 1: fresh manifest install adds exactly one boot entry');
81
+ {
82
+ seedSettings({ hooks: {} });
83
+ manifestInstall();
84
+ const s = readSettings();
85
+ assert(bootEntries(s).length === 1, 'exactly one boot-hook SessionStart entry');
86
+ assert(
87
+ s.hooks.SessionStart[0].hooks[0].command === expectedCommand,
88
+ 'command canonicalized to BOOT_DIR boot-hook.mjs',
89
+ `on disk: ${s.hooks.SessionStart[0].hooks[0].command}`
90
+ );
91
+ }
92
+
93
+ console.log('Test 2: repeat manifest install is a byte-identical no-op');
94
+ {
95
+ const before = readFileSync(settingsPath, 'utf-8');
96
+ manifestInstall();
97
+ const after = readFileSync(settingsPath, 'utf-8');
98
+ assert(before === after, 'settings.json byte-identical after second manifest install');
99
+ assert(bootEntries(readSettings()).length === 1, 'still exactly one boot-hook entry (no append)');
100
+ }
101
+
102
+ console.log('Test 3: pre-accumulated boot entries collapse; unrelated SessionStart hook preserved');
103
+ {
104
+ // Three legacy boot appends (the pre-fix accumulation) using the old
105
+ // hardcoded absolute path, plus an unrelated SessionStart guard entry.
106
+ const legacyBoot = {
107
+ matcher: '*',
108
+ hooks: [{ type: 'command', command: 'node /Users/someone/.ldm/shared/boot/boot-hook.mjs', timeout: 15 }],
109
+ };
110
+ const guardSessionStart = {
111
+ matcher: 'Read|Glob',
112
+ hooks: [{ type: 'command', command: `node ${join(tempHome, '.ldm', 'extensions', 'wip-branch-guard', 'guard.mjs')}`, timeout: 10 }],
113
+ };
114
+ seedSettings({
115
+ hooks: {
116
+ SessionStart: [
117
+ structuredClone(legacyBoot),
118
+ structuredClone(legacyBoot),
119
+ guardSessionStart,
120
+ structuredClone(legacyBoot),
121
+ ],
122
+ },
123
+ });
124
+ manifestInstall();
125
+ const s = readSettings();
126
+ assert(bootEntries(s).length === 1, 'boot entries collapsed to one');
127
+ assert(
128
+ s.hooks.SessionStart[0].hooks[0].command === expectedCommand,
129
+ 'surviving boot entry canonicalized to BOOT_DIR'
130
+ );
131
+ assert(
132
+ s.hooks.SessionStart.some((e) => e.hooks?.[0]?.command?.includes('wip-branch-guard')),
133
+ 'unrelated SessionStart guard entry preserved (discriminates on command, not event)'
134
+ );
135
+ assert(s.hooks.SessionStart.length === 2, 'exactly boot + guard remain');
136
+ }
137
+
138
+ console.log('Test 4: dry run reports intent and writes nothing');
139
+ {
140
+ seedSettings({ hooks: {} });
141
+ const before = readFileSync(settingsPath, 'utf-8');
142
+ setFlags({ dryRun: true });
143
+ try {
144
+ manifestInstall();
145
+ } finally {
146
+ setFlags({ dryRun: false });
147
+ }
148
+ const after = readFileSync(settingsPath, 'utf-8');
149
+ assert(before === after, 'settings.json untouched under dry run');
150
+ assert(bootEntries(readSettings()).length === 0, 'no boot entry written under dry run');
151
+ }
152
+ } finally {
153
+ rmSync(tempHome, { recursive: true, force: true });
154
+ }
155
+
156
+ if (failed > 0) {
157
+ console.log(`\n${failed} assertion(s) failed`);
158
+ process.exit(1);
159
+ }
160
+ console.log('\nAll deploy-hook ownership tests passed');