@wipcomputer/wip-ldm-os 0.4.86 → 0.4.87-alpha.2

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.
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ // Regression test: `ldm doctor --commit-deployed`.
3
+ //
4
+ // Covers:
5
+ // wip-tracking-private-only/bugs/installer/open-tickets/2026-07-06--cc-mini--ldm-doctor-commit-deployed.md
6
+ //
7
+ // The command records installer-deployed config drift in a tracked home tree so
8
+ // the tree stops blocking `git pull`, gitignores runtime/transient junk, and
9
+ // leaves personal choices. settings.json is split at JSON-key granularity:
10
+ // installer-owned keys (hooks) commit, personal keys (model, permissions) stay.
11
+ //
12
+ // Follows the test-doctor-hook-dedupe.mjs harness: real bin/ldm.js against a
13
+ // temp HOME with a real, disposable local git repo for the home tree. gh / npm
14
+ // / crontab are shimmed on PATH and LDM_SELF_UPDATED=1 so no network, no PR, no
15
+ // operator state is touched. No push, no merge: the command records locally.
16
+
17
+ import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { execFileSync } from 'node:child_process';
20
+ import { tmpdir } from 'node:os';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
24
+ const cli = join(repo, 'bin', 'ldm.js');
25
+
26
+ let failed = 0;
27
+ function assert(cond, label, output = '') {
28
+ if (cond) {
29
+ console.log(` [PASS] ${label}`);
30
+ } else {
31
+ console.log(` [FAIL] ${label}`);
32
+ if (output) console.log(` ${String(output).trim().split('\n').slice(-20).join('\n ')}`);
33
+ failed++;
34
+ }
35
+ }
36
+
37
+ const OLD_HOOKS = { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: 'node /old/boot-hook.mjs', timeout: 15 }] }] };
38
+ const NEW_HOOKS = { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: 'node /new/boot-hook.mjs', timeout: 15 }] }] };
39
+
40
+ function git(dir, ...a) {
41
+ return execFileSync('git', ['-C', dir, ...a], { encoding: 'utf8' });
42
+ }
43
+ function writeJSON(p, obj) {
44
+ writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
45
+ }
46
+
47
+ // Build a fixture HOME with a real local git repo at HOME/.claude. `driftFn`
48
+ // receives the repo dir and mutates it into the post-install dirty state.
49
+ function setupHome(driftFn) {
50
+ const home = mkdtempSync(join(tmpdir(), 'ldm-commitdep-'));
51
+ const claude = join(home, '.claude');
52
+ const fakeBin = join(home, 'fakebin');
53
+ mkdirSync(join(claude, 'rules'), { recursive: true });
54
+ mkdirSync(fakeBin, { recursive: true });
55
+
56
+ // Baseline committed state.
57
+ writeJSON(join(claude, 'settings.json'), { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: OLD_HOOKS });
58
+ writeFileSync(join(claude, 'rules', 'base.md'), 'old rule\n');
59
+ // projects/ and cache/ already ignored; plans/ deliberately NOT, so the
60
+ // command has a new transient path to gitignore.
61
+ writeFileSync(join(claude, '.gitignore'), 'projects/\ncache/\n');
62
+
63
+ git(claude, 'init', '-q');
64
+ git(claude, 'config', 'user.email', 'test@example.com');
65
+ git(claude, 'config', 'user.name', 'Test');
66
+ git(claude, 'config', 'commit.gpgsign', 'false');
67
+ // Isolate the fixture from any global core.hooksPath (the real machine sets a
68
+ // pre-commit hook there that blocks commits on main). Kept OUTSIDE the repo so
69
+ // an added hook file never pollutes the repo's status. Tests that want a hook
70
+ // drop one into this dir explicitly.
71
+ const hooksDir = join(home, 'githooks');
72
+ mkdirSync(hooksDir, { recursive: true });
73
+ git(claude, 'config', 'core.hooksPath', hooksDir);
74
+ git(claude, 'add', '-A');
75
+ git(claude, 'commit', '-q', '-m', 'baseline');
76
+
77
+ driftFn(claude);
78
+
79
+ // Shims: never touched by this command, present for parity with doctor tests.
80
+ for (const b of ['gh', 'npm', 'crontab']) {
81
+ writeFileSync(join(fakeBin, b), '#!/bin/sh\nexit 0\n');
82
+ chmodSync(join(fakeBin, b), 0o755);
83
+ }
84
+ return { home, claude, fakeBin, hooksDir };
85
+ }
86
+
87
+ function run({ home, claude, fakeBin }, extra = []) {
88
+ const args = ['doctor', '--commit-deployed', '--home', claude, ...extra];
89
+ try {
90
+ return execFileSync('node', [cli, ...args], {
91
+ env: { ...process.env, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, LDM_SELF_UPDATED: '1' },
92
+ encoding: 'utf-8',
93
+ timeout: 30000,
94
+ });
95
+ } catch (err) {
96
+ return (err.stdout || '') + (err.stderr || '');
97
+ }
98
+ }
99
+
100
+ function headJSON(claude, path) {
101
+ return JSON.parse(git(claude, 'show', `HEAD:${path}`));
102
+ }
103
+ function statusPorcelain(claude) {
104
+ return git(claude, 'status', '--porcelain').trim();
105
+ }
106
+
107
+ console.log('Test 1: deployed + transient drift, no personal drift -> clean tree');
108
+ {
109
+ const w = setupHome((c) => {
110
+ const s = { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS };
111
+ writeJSON(join(c, 'settings.json'), s); // deployed key (hooks) changed
112
+ writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n'); // deployed file changed
113
+ mkdirSync(join(c, 'plans'), { recursive: true });
114
+ writeFileSync(join(c, 'plans', 'session.json'), '{}\n'); // transient, NOT yet ignored
115
+ mkdirSync(join(c, 'projects'), { recursive: true });
116
+ writeFileSync(join(c, 'projects', 'x.json'), '{}\n'); // transient, already ignored
117
+ });
118
+ const out = run(w);
119
+ assert(/record installer-deployed config drift/.test(git(w.claude, 'log', '-1', '--format=%s')), 'recorded a chore(deploy) commit', out);
120
+ assert(headJSON(w.claude, 'settings.json').hooks.SessionStart[0].hooks[0].command === 'node /new/boot-hook.mjs', 'committed settings.json has the new hooks', out);
121
+ assert(headJSON(w.claude, 'settings.json').model === 'sonnet', 'committed settings.json keeps model (personal untouched)');
122
+ assert(git(w.claude, 'show', 'HEAD:rules/base.md') === 'new rule\n', 'committed the deployed rules/ change');
123
+ assert(readFileSync(join(w.claude, '.gitignore'), 'utf8').includes('plans/'), 'gitignored the new transient path (plans/)', out);
124
+ assert(statusPorcelain(w.claude) === '', 'tree is CLEAN after recording', statusPorcelain(w.claude));
125
+ const backups = existsSync(join(w.home, '.ldm', 'backups')) ? readdirSync(join(w.home, '.ldm', 'backups')) : [];
126
+ assert(backups.length === 1 && existsSync(join(w.home, '.ldm', 'backups', backups[0], 'MANIFEST.txt')), 'timestamped backup with manifest taken before mutation', backups.join(','));
127
+
128
+ // Idempotent second run.
129
+ const headBefore = git(w.claude, 'rev-parse', 'HEAD').trim();
130
+ const out2 = run(w);
131
+ assert(/already clean, nothing to record/.test(out2), 'second run is a no-op (tree already clean)', out2);
132
+ assert(git(w.claude, 'rev-parse', 'HEAD').trim() === headBefore, 'no extra commit on the clean second run');
133
+ rmSync(w.home, { recursive: true, force: true });
134
+ }
135
+
136
+ console.log('Test 2: mixed settings.json (hooks + model) -> key granularity, personal left');
137
+ {
138
+ const w = setupHome((c) => {
139
+ writeJSON(join(c, 'settings.json'), { model: 'opus', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
140
+ writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n');
141
+ });
142
+ const out = run(w);
143
+ assert(headJSON(w.claude, 'settings.json').hooks.SessionStart[0].hooks[0].command === 'node /new/boot-hook.mjs', 'committed the hooks key', out);
144
+ assert(headJSON(w.claude, 'settings.json').model === 'sonnet', 'did NOT commit the personal model key (still old on HEAD)', out);
145
+ assert(JSON.parse(readFileSync(join(w.claude, 'settings.json'), 'utf8')).model === 'opus', 'working settings.json keeps the new personal model');
146
+ const dirty = statusPorcelain(w.claude).split('\n').filter(Boolean);
147
+ assert(dirty.length === 1 && /settings\.json$/.test(dirty[0]), 'only settings.json remains dirty (the personal delta)', statusPorcelain(w.claude));
148
+ assert(/Left uncommitted/.test(out) && /settings\.json \(model\)/.test(out), 'reports model left as personal', out);
149
+ rmSync(w.home, { recursive: true, force: true });
150
+ }
151
+
152
+ console.log('Test 3: --include-personal folds personal keys in -> clean tree');
153
+ {
154
+ const w = setupHome((c) => {
155
+ writeJSON(join(c, 'settings.json'), { model: 'opus', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
156
+ });
157
+ const out = run(w, ['--include-personal']);
158
+ assert(headJSON(w.claude, 'settings.json').model === 'opus', 'committed the personal model under --include-personal', out);
159
+ assert(headJSON(w.claude, 'settings.json').hooks.SessionStart[0].hooks[0].command === 'node /new/boot-hook.mjs', 'committed the hooks too');
160
+ assert(statusPorcelain(w.claude) === '', 'tree clean with --include-personal', statusPorcelain(w.claude));
161
+ rmSync(w.home, { recursive: true, force: true });
162
+ }
163
+
164
+ console.log('Test 4: personal-only drift -> nothing committed, left for the user');
165
+ {
166
+ const w = setupHome((c) => {
167
+ writeJSON(join(c, 'settings.json'), { model: 'opus', permissions: { defaultMode: 'default' }, hooks: OLD_HOOKS });
168
+ });
169
+ const headBefore = git(w.claude, 'rev-parse', 'HEAD').trim();
170
+ const out = run(w);
171
+ assert(/No deployed drift or new runtime paths to record/.test(out), 'reports nothing deployed to record', out);
172
+ assert(git(w.claude, 'rev-parse', 'HEAD').trim() === headBefore, 'no commit created for personal-only drift');
173
+ assert(JSON.parse(readFileSync(join(w.claude, 'settings.json'), 'utf8')).model === 'opus', 'personal model left in the working tree');
174
+ rmSync(w.home, { recursive: true, force: true });
175
+ }
176
+
177
+ console.log('Test 5: dry run records nothing');
178
+ {
179
+ const w = setupHome((c) => {
180
+ writeJSON(join(c, 'settings.json'), { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
181
+ });
182
+ const headBefore = git(w.claude, 'rev-parse', 'HEAD').trim();
183
+ const out = run(w, ['--dry-run']);
184
+ assert(/\[DRY RUN\] Would record/.test(out), 'dry run reports intent', out);
185
+ assert(git(w.claude, 'rev-parse', 'HEAD').trim() === headBefore, 'dry run creates no commit');
186
+ assert(/ M settings\.json|settings\.json/.test(statusPorcelain(w.claude)), 'dry run leaves the tree unchanged (still dirty)');
187
+ rmSync(w.home, { recursive: true, force: true });
188
+ }
189
+
190
+ console.log('Test 6: non-git home is reported, not crashed');
191
+ {
192
+ const home = mkdtempSync(join(tmpdir(), 'ldm-commitdep-nogit-'));
193
+ const notRepo = join(home, 'plain');
194
+ mkdirSync(notRepo, { recursive: true });
195
+ const fakeBin = join(home, 'fakebin');
196
+ mkdirSync(fakeBin, { recursive: true });
197
+ for (const b of ['gh', 'npm', 'crontab']) { writeFileSync(join(fakeBin, b), '#!/bin/sh\nexit 0\n'); chmodSync(join(fakeBin, b), 0o755); }
198
+ let out = '';
199
+ try {
200
+ out = execFileSync('node', [cli, 'doctor', '--commit-deployed', '--home', notRepo], {
201
+ env: { ...process.env, HOME: home, PATH: `${fakeBin}:${process.env.PATH}`, LDM_SELF_UPDATED: '1' },
202
+ encoding: 'utf-8', timeout: 30000,
203
+ });
204
+ } catch (err) { out = (err.stdout || '') + (err.stderr || ''); }
205
+ assert(/Not a git work tree/.test(out), 'reports a non-git home cleanly', out);
206
+ rmSync(home, { recursive: true, force: true });
207
+ }
208
+
209
+ console.log('Test 7: pre-existing personal .gitignore edit is NOT swept into the deploy commit');
210
+ {
211
+ const w = setupHome((c) => {
212
+ writeJSON(join(c, 'settings.json'), { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
213
+ // User has an uncommitted personal .gitignore edit...
214
+ writeFileSync(join(c, '.gitignore'), 'projects/\ncache/\npersonal-local/\n');
215
+ // ...and there is a new transient path that forces a gitignore addition.
216
+ mkdirSync(join(c, 'plans'), { recursive: true });
217
+ writeFileSync(join(c, 'plans', 'session.json'), '{}\n');
218
+ });
219
+ const out = run(w);
220
+ const committedGitignore = git(w.claude, 'show', 'HEAD:.gitignore');
221
+ assert(committedGitignore.includes('plans/'), 'the new transient pattern was committed', out);
222
+ assert(!committedGitignore.includes('personal-local/'), 'the pre-existing personal .gitignore edit was NOT committed', committedGitignore);
223
+ assert(readFileSync(join(w.claude, '.gitignore'), 'utf8').includes('personal-local/'), 'personal .gitignore edit still present in working tree');
224
+ const dirty = statusPorcelain(w.claude).split('\n').filter(Boolean);
225
+ assert(dirty.length === 1 && /\.gitignore$/.test(dirty[0]), 'only .gitignore (the personal edit) remains dirty', statusPorcelain(w.claude));
226
+ assert(/\.gitignore \(your existing edits/.test(out), 'reports .gitignore personal edits left', out);
227
+ rmSync(w.home, { recursive: true, force: true });
228
+ }
229
+
230
+ console.log('Test 8: malformed settings.json is skipped, never deletes committed hooks');
231
+ {
232
+ const w = setupHome((c) => {
233
+ writeFileSync(join(c, 'settings.json'), '{ this is not valid json\n'); // corrupt working file
234
+ writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n'); // a legit deployed change
235
+ });
236
+ const headBefore = git(w.claude, 'rev-parse', 'HEAD').trim();
237
+ const out = run(w);
238
+ assert(/settings\.json skipped/.test(out) && /not valid JSON/.test(out), 'warns and skips malformed settings.json', out);
239
+ // The committed settings.json must still carry the original hooks (not deleted).
240
+ const committed = headJSON(w.claude, 'settings.json');
241
+ assert(committed.hooks && committed.hooks.SessionStart, 'committed settings.json still has its hooks (not clobbered)', JSON.stringify(committed));
242
+ assert(committed.hooks.SessionStart[0].hooks[0].command === 'node /old/boot-hook.mjs', 'hooks unchanged at HEAD (no deletion commit)');
243
+ // The legit deployed rules/ change still got recorded (skip is scoped to settings.json).
244
+ assert(git(w.claude, 'rev-parse', 'HEAD').trim() !== headBefore, 'a commit was still made for the deployed rules/ change');
245
+ assert(git(w.claude, 'show', 'HEAD:rules/base.md') === 'new rule\n', 'rules/ change committed despite settings.json skip');
246
+ assert(/settings\.json/.test(statusPorcelain(w.claude)), 'malformed settings.json left dirty for manual repair');
247
+ rmSync(w.home, { recursive: true, force: true });
248
+ }
249
+
250
+ console.log('Test 9: a tracked file under a transient prefix is left, not falsely claimed clean');
251
+ {
252
+ const w = setupHome((c) => {
253
+ // Commit a tracked file under a transient prefix in the baseline drift...
254
+ mkdirSync(join(c, 'plans'), { recursive: true });
255
+ writeFileSync(join(c, 'plans', 'tracked.md'), 'v1\n');
256
+ git(c, 'add', '--', 'plans/tracked.md');
257
+ git(c, 'commit', '-q', '-m', 'add tracked transient');
258
+ // ...then modify it (tracked-transient drift) plus a real deployed change.
259
+ writeFileSync(join(c, 'plans', 'tracked.md'), 'v2\n');
260
+ writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n');
261
+ });
262
+ const out = run(w);
263
+ assert(git(w.claude, 'show', 'HEAD:rules/base.md') === 'new rule\n', 'deployed rules/ change committed', out);
264
+ assert(git(w.claude, 'show', 'HEAD:plans/tracked.md') === 'v1\n', 'tracked-transient file was NOT committed (still v1 at HEAD)');
265
+ assert(/tracked transient/.test(out), 'reports the tracked-transient file as left', out);
266
+ const dirty = statusPorcelain(w.claude).split('\n').filter(Boolean);
267
+ assert(dirty.length === 1 && /plans\/tracked\.md$/.test(dirty[0]), 'tracked-transient file remains dirty (honestly not claimed clean)', statusPorcelain(w.claude));
268
+ rmSync(w.home, { recursive: true, force: true });
269
+ }
270
+
271
+ console.log('Test 10: records even when a pre-commit hook blocks commits on main');
272
+ {
273
+ const w = setupHome((c) => {
274
+ writeJSON(join(c, 'settings.json'), { model: 'sonnet', permissions: { defaultMode: 'default' }, hooks: NEW_HOOKS });
275
+ writeFileSync(join(c, 'rules', 'base.md'), 'new rule\n');
276
+ });
277
+ // Simulate the real machine: a pre-commit hook that hard-blocks `git commit`
278
+ // on main. The command lands its commit via plumbing, so it must still work.
279
+ const hook = join(w.hooksDir, 'pre-commit');
280
+ writeFileSync(hook, '#!/bin/sh\nif [ "$(git symbolic-ref --short HEAD)" = "main" ]; then echo "BLOCKED: no commits on main"; exit 1; fi\n');
281
+ chmodSync(hook, 0o755);
282
+ // Ensure the fixture is actually on main and the hook really blocks a plain commit.
283
+ execFileSync('git', ['-C', w.claude, 'branch', '-M', 'main'], { encoding: 'utf8' });
284
+ let blocked = false;
285
+ try { execFileSync('git', ['-C', w.claude, 'commit', '-q', '--allow-empty', '-m', 'probe'], { encoding: 'utf8' }); }
286
+ catch { blocked = true; }
287
+ assert(blocked, 'sanity: a plain git commit on main is blocked by the hook');
288
+
289
+ const out = run(w);
290
+ assert(git(w.claude, 'log', '-1', '--format=%s').includes('record installer-deployed config drift'), 'command still recorded the commit on main via plumbing', out);
291
+ assert(git(w.claude, 'show', 'HEAD:rules/base.md') === 'new rule\n', 'deployed change landed despite the blocking hook');
292
+ assert(statusPorcelain(w.claude) === '', 'tree clean after recording past the hook', statusPorcelain(w.claude));
293
+ rmSync(w.home, { recursive: true, force: true });
294
+ }
295
+
296
+ if (failed > 0) {
297
+ console.log(`\n${failed} assertion(s) failed`);
298
+ process.exit(1);
299
+ }
300
+ console.log('\nAll doctor commit-deployed tests passed');
@@ -102,6 +102,11 @@ exit 0
102
102
  assert((readFileSync(shimPath).length > 0), 'foreign shim remains non-empty', output);
103
103
  assert((statSync(shimPath).mode & 0o111) !== 0, 'foreign shim remains executable', output);
104
104
  assert((existsSync(join(binDir, 'ldm-backup.sh'))), 'ldm-owned scripts still deploy into ~/.ldm/bin', output);
105
+ assert((existsSync(join(binDir, 'ldm-backup-engine.py'))), 'backup engine deploys into ~/.ldm/bin', output);
106
+ assert((existsSync(join(binDir, 'ldm-backup-policy.json'))), 'backup policy deploys into ~/.ldm/bin', output);
107
+ assert((statSync(join(binDir, 'ldm-backup-engine.py')).mode & 0o111) !== 0, 'backup engine is executable', output);
108
+ assert((statSync(join(binDir, 'ldm-backup-policy.json')).mode & 0o111) === 0, 'backup policy is not executable', output);
109
+ assert(readFileSync(join(binDir, 'ldm-backup-policy.json'), 'utf8') === readFileSync(join(repo, 'scripts', 'ldm-backup-policy.json'), 'utf8'), 'backup policy content matches package source', output);
105
110
 
106
111
  const restored = readFileSync(shimPath);
107
112
  assert(restored.length === Buffer.byteLength(shimContent), 'foreign shim size is unchanged', output);
@@ -16,7 +16,7 @@ If something is wrong, update `~/.ldm/config.json` or run `ldm install`. The doc
16
16
  | `what-is-dotldm.md` | The `~/.ldm/` runtime directory explained |
17
17
  | `directory-map.md` | What lives where and why |
18
18
  | `how-worktrees-work.md` | Git worktrees, the _worktrees/ convention |
19
- | `how-backup-works.md` | Daily backup, iCloud offsite, restore |
19
+ | `how-backup-works.md` | Safe dry run, verified internal snapshots, and later-tier gates |
20
20
  | `how-releases-work.md` | The full release pipeline |
21
21
  | `how-install-works.md` | ldm install, the install prompt, dogfooding |
22
22
  | `how-agents-work.md` | Agents, harnesses, bridge, memory |
@@ -43,7 +43,7 @@
43
43
  hooks/ <- Claude Code hooks
44
44
  state/ <- runtime state
45
45
  tmp/ <- install staging
46
- backups/ <- daily backups (ldm-backup.sh) + iCloud offsite tars
46
+ backups/v1/internal/ <- verified internal snapshots and in-progress staging
47
47
  ```
48
48
 
49
49
  ## Harness Directories (deployment targets)
@@ -1,111 +1,78 @@
1
1
  # How Backup Works
2
2
 
3
- ## One Script, One Place
3
+ ## Safe planning first
4
4
 
5
- `~/.ldm/bin/ldm-backup.sh` runs daily at 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`. It backs up everything to `~/.ldm/backups/`, then tars it to iCloud for offsite.
5
+ The backup engine has a strictly read-only dry run:
6
6
 
7
- ## What Gets Backed Up
7
+ ```bash
8
+ ~/.ldm/bin/ldm-backup.sh --dry-run
9
+ ```
8
10
 
9
- | Source | Method | What's in it |
10
- |--------|--------|-------------|
11
- | `~/.ldm/memory/crystal.db` | sqlite3 .backup | Irreplaceable memory (all agents) |
12
- | `~/.ldm/agents/` | cp -a | Identity files, journals, daily logs |
13
- | `~/.ldm/state/` | cp -a | Config, version, registry |
14
- | `~/.ldm/config.json` | cp | Workspace pointer, org |
15
- | `~/.openclaw/memory/main.sqlite` | sqlite3 .backup | OC conversations |
16
- | `~/.openclaw/memory/context-embeddings.sqlite` | sqlite3 .backup | Embeddings |
17
- | `~/.openclaw/workspace/` | tar | Shared context, daily logs |
18
- | `~/.openclaw/agents/main/sessions/` | tar | OC session JSONL |
19
- | `~/.openclaw/openclaw.json` | cp | OC config |
20
- | `~/.claude/CLAUDE.md` | cp | CC instructions |
21
- | `~/.claude/settings.json` | cp | CC settings |
22
- | `~/.claude/projects/` | tar | CC auto-memory + transcripts |
23
- | `~/wipcomputerinc/` | tar (excludes node_modules, .git/objects, old backups, _trash) | Entire workspace |
11
+ It shows every policy source, classification, exclusion reason, per-source estimate, total estimated new bytes, available disk capacity, required reserve, planned staging path, planned internal destination, blocked requirements, and the final preflight verdict.
24
12
 
25
- **NOT backed up:** node_modules/, .git/objects/ (reconstructable), extensions (reinstallable), ~/.claude/cache.
13
+ Dry run does not create files or directories. It does not acquire the persistent lock, write state, copy data, create a manifest, create a completion marker, rotate snapshots, write to Vault or iCloud, change the schedule, enable the LaunchAgent, or modify containment state. Sensitive filenames and contents are not printed.
26
14
 
27
- ## Backup Structure
15
+ Exit zero means the proposed run passes the same policy and preflight used by the real engine. Exit nonzero means a required source, encryption gate, destination, or disk reserve blocks the run.
28
16
 
29
- ```
30
- ~/.ldm/backups/2026-03-24--09-50-22/
31
- ldm/
32
- memory/crystal.db
33
- agents/
34
- state/
35
- config.json
36
- openclaw/
37
- memory/main.sqlite
38
- memory/context-embeddings.sqlite
39
- workspace.tar
40
- sessions.tar
41
- openclaw.json
42
- claude/
43
- CLAUDE.md
44
- settings.json
45
- projects.tar
46
- wipcomputerinc.tar
47
- ```
17
+ ## Canonical source policy
48
18
 
49
- ## iCloud Offsite
19
+ `~/.ldm/bin/ldm-backup-policy.json` defines required, rebuildable, sensitive, blocked, manifest-only, and excluded sources. Estimation, capture, manifest generation, and exclusion records all use this one policy.
50
20
 
51
- After local backup, the entire dated folder is compressed and copied to iCloud. The iCloud path is read from `~/.ldm/config.json` at `paths.icloudBackup`.
21
+ Sources that require encryption remain visibly blocked until that later gate ships. The engine never turns a blocked or missing required source into a partial success.
52
22
 
53
- ```
54
- ~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/
55
- wipcomputerinc-lesa-2026-03-24--09-50-22.tar.gz
56
- ```
23
+ The policy has one explicit ID for every approved source-inventory row. The test suite compares the exact ID list so a future omission cannot silently pass.
57
24
 
58
- One file per backup. iCloud syncs it across devices. Rotates to {{backup.keep}} days.
25
+ ## Runtime and changing files
59
26
 
60
- ## How to Run
27
+ The engine uses Python 3 standard library only. No third-party Python packages are required. If `python3` is unavailable, the runner exits with `Backup failed [python3-missing]`. Owner acceptance of Python 3 as a release prerequisite must be recorded separately at the release gate.
61
28
 
62
- ```bash
63
- ~/.ldm/bin/ldm-backup.sh # run backup now
64
- ~/.ldm/bin/ldm-backup.sh --dry-run # preview what would be backed up
65
- ~/.ldm/bin/ldm-backup.sh --keep 14 # keep 14 days instead of 7
66
- ~/.ldm/bin/ldm-backup.sh --include-secrets # include ~/.ldm/secrets/
67
- ```
29
+ A real run observes each source again immediately before capture. Changes since dry-run or initial planning are recorded and captured from the newer observation. Files and trees must then remain stable while that source is copied. Same-size changes are detected by checksum, and any during-capture mutation fails the run. SQLite uses the SQLite backup API for a transactionally consistent snapshot.
68
30
 
69
- ## How to Restore
31
+ The engine recalculates the reserve before copying a changed source. It includes staged bytes, stat-only refreshed remaining sources, a full checksum observation of the source about to be copied, projected metadata, and the required 10 percent reserve. Growth that crosses the floor fails before that source is copied.
70
32
 
71
- ```bash
72
- ~/.ldm/bin/ldm-restore.sh # list available backups
73
- ~/.ldm/bin/ldm-restore.sh 2026-03-24--09-50-22 # restore everything
74
- ~/.ldm/bin/ldm-restore.sh --only ldm <backup> # restore only crystal.db + agents
75
- ~/.ldm/bin/ldm-restore.sh --only openclaw <backup> # restore only OC data
76
- ~/.ldm/bin/ldm-restore.sh --from-icloud <file> # restore from iCloud tar
77
- ~/.ldm/bin/ldm-restore.sh --dry-run <backup> # preview
33
+ Manifest-only requirements must produce validated machine-readable evidence. An entry without an implemented generator is marked blocked before its source path is walked. When a required evidence generator is not implemented, including repo-aware Git/worktree and system inventories, the source stays blocked instead of being treated as fulfilled.
34
+
35
+ ## Successful snapshot
36
+
37
+ A completed internal snapshot lives under:
38
+
39
+ ```text
40
+ ~/.ldm/backups/v1/internal/<snapshot-id>/
78
41
  ```
79
42
 
80
- After restore: `openclaw gateway restart` then `crystal status` to verify.
43
+ It is built first as `.in-progress-<snapshot-id>`. Promotion happens only after every required capture succeeds, SQLite copies pass `PRAGMA quick_check`, checksums validate, the manifest validates, and `completion.json` is written last.
44
+
45
+ Any required failure exits nonzero, writes no completion marker, and never prints `Backup complete`.
46
+
47
+ ## Retention
48
+
49
+ The engine retains {{backup.keep}} unpinned verified snapshots plus every pinned snapshot. Pinned snapshots do not consume an unpinned retention slot. In-progress and unverified directories are never rotated.
50
+
51
+ ## Structured state
52
+
53
+ Diagnostic state lives under `~/.ldm/state/backup/v1/`:
54
+
55
+ - `schedule.json`
56
+ - `attempt.json`
57
+ - `result.json`
58
+ - `success-local.json`
81
59
 
82
- ## Schedule
60
+ State records are atomic, but they are not completion proof. A snapshot requires a valid manifest and completion marker.
83
61
 
84
- | What | When | How |
85
- |------|------|-----|
86
- | Backup | 3:00 AM | LaunchAgent `ai.openclaw.ldm-backup` runs `~/.ldm/bin/ldm-backup.sh` |
62
+ Snapshot identifiers use UTC and end in `Z`. If no valid containment marker is present, schedule state records evidence status as `unknown`.
87
63
 
88
- One LaunchAgent. One script. No Full Disk Access currently (target: midnight via LDMDevTools.app once PID error is fixed). Verify is built into the script (exit code + log).
64
+ ## Current boundaries
89
65
 
90
- ## Config
66
+ This engine correctness slice covers the internal tier. Vault, iCloud, monitoring, isolated restore, migration, and schedule resumption remain separately gated. Do not run a real backup until those gates are approved.
91
67
 
92
- All backup settings live in `~/.ldm/config.json`:
93
- - `paths.workspace` ... workspace path
94
- - `paths.icloudBackup` ... iCloud offsite destination
95
- - `backup.keep` ... retention days (default: 7)
96
- - `backup.includeSecrets` ... whether to include `~/.ldm/secrets/`
97
- - `org` ... used for tar filename prefix
68
+ ## Your system
98
69
 
99
- ## Logs
70
+ **Internal destination:** `~/.ldm/backups/v1/internal/`
100
71
 
101
- `~/.ldm/logs/backup.log` (LaunchAgent stdout/stderr)
72
+ **Configured iCloud path:** `~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/` (not written by this engine slice)
102
73
 
103
- ---
74
+ **Schedule label:** `ai.openclaw.ldm-backup`
104
75
 
105
- ## Your System
76
+ **Retention:** {{backup.keep}} unpinned verified snapshots
106
77
 
107
- **Local backups:** `~/.ldm/backups/`
108
- **iCloud offsite:** `~/Library/Mobile Documents/com~apple~CloudDocs/wipcomputerinc-icloud/backups/`
109
- **Schedule:** 3:00 AM via LaunchAgent `ai.openclaw.ldm-backup`
110
- **Retention:** {{backup.keep}} days local, {{backup.keep}} days iCloud
111
- **Script:** `~/.ldm/bin/ldm-backup.sh` (deployed by `ldm install`)
78
+ **Runner:** `~/.ldm/bin/ldm-backup.sh`
@@ -33,7 +33,7 @@ Where LDM OS runs. Rules, commands, and skills are authored here and deployed to
33
33
  | `~/.ldm/hooks/` | Claude Code hooks | ldm install |
34
34
  | `~/.ldm/state/` | Runtime state | Tools (watermarks, markers) |
35
35
  | `~/.ldm/tmp/` | Install staging | ldm install (cleaned after) |
36
- | `~/.ldm/backups/` | Local backups + iCloud offsite tars | ldm-backup.sh |
36
+ | `~/.ldm/backups/v1/internal/` | Verified internal snapshots and in-progress staging | ldm-backup.sh |
37
37
 
38
38
  ## Harness Directories (deployment targets)
39
39
 
@@ -41,7 +41,7 @@ The LDM OS runtime directory. Everything the system needs to run lives here. You
41
41
  hooks/ Claude Code hooks
42
42
  state/ runtime state (last release marker, watermarks)
43
43
  tmp/ install staging (cleaned after install)
44
- backups/ daily backup output (local + iCloud offsite)
44
+ backups/v1/internal/ verified internal snapshots and in-progress staging
45
45
  ```
46
46
 
47
47
  ## What Gets Backed Up