@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.
- package/bin/ldm.js +374 -4
- package/docs/backup/README.md +83 -80
- package/docs/backup/TECHNICAL.md +107 -90
- package/package.json +16 -3
- package/scripts/ldm-backup-engine.py +1176 -0
- package/scripts/ldm-backup-policy.json +1420 -0
- package/scripts/ldm-backup.sh +15 -273
- package/scripts/test-backup-engine.py +609 -0
- package/scripts/test-doctor-commit-deployed.mjs +300 -0
- package/scripts/test-ldm-install-preserves-foreign-bin.mjs +5 -0
- package/shared/docs/README.md.tmpl +1 -1
- package/shared/docs/directory-map.md.tmpl +1 -1
- package/shared/docs/how-backup-works.md.tmpl +50 -83
- package/shared/docs/system-directories.md.tmpl +1 -1
- package/shared/docs/what-is-dotldm.md.tmpl +1 -1
package/bin/ldm.js
CHANGED
|
@@ -198,6 +198,8 @@ const CLEANUP_FLAG = args.includes('--cleanup');
|
|
|
198
198
|
const CHECK_FLAG = args.includes('--check');
|
|
199
199
|
const ALPHA_FLAG = args.includes('--alpha');
|
|
200
200
|
const BETA_FLAG = args.includes('--beta');
|
|
201
|
+
const COMMIT_DEPLOYED_FLAG = args.includes('--commit-deployed');
|
|
202
|
+
const INCLUDE_PERSONAL_FLAG = args.includes('--include-personal');
|
|
201
203
|
|
|
202
204
|
function readJSON(path) {
|
|
203
205
|
try {
|
|
@@ -722,7 +724,7 @@ async function installCatalogComponent(c) {
|
|
|
722
724
|
}
|
|
723
725
|
|
|
724
726
|
// ── Bridge deploy (#245) ──
|
|
725
|
-
// Deploy
|
|
727
|
+
// Deploy runtime-owned backup and shell files from scripts/ to ~/.ldm/bin/
|
|
726
728
|
// Called from both cmdInit() and cmdInstallCatalog() so script fixes land on every update.
|
|
727
729
|
function deployScripts() {
|
|
728
730
|
const scriptsSrc = join(__dirname, '..', 'scripts');
|
|
@@ -730,15 +732,17 @@ function deployScripts() {
|
|
|
730
732
|
mkdirSync(join(LDM_ROOT, 'bin'), { recursive: true });
|
|
731
733
|
let count = 0;
|
|
732
734
|
for (const file of readdirSync(scriptsSrc)) {
|
|
733
|
-
|
|
735
|
+
const isShell = file.endsWith('.sh');
|
|
736
|
+
const isBackupRuntime = file === 'ldm-backup-engine.py' || file === 'ldm-backup-policy.json';
|
|
737
|
+
if (!isShell && !isBackupRuntime) continue;
|
|
734
738
|
const src = join(scriptsSrc, file);
|
|
735
739
|
const dest = join(LDM_ROOT, 'bin', file);
|
|
736
740
|
cpSync(src, dest);
|
|
737
|
-
chmodSync(dest, 0o755);
|
|
741
|
+
chmodSync(dest, file.endsWith('.json') ? 0o644 : 0o755);
|
|
738
742
|
count++;
|
|
739
743
|
}
|
|
740
744
|
if (count > 0) {
|
|
741
|
-
console.log(` + ${count}
|
|
745
|
+
console.log(` + ${count} runtime file(s) deployed to ~/.ldm/bin/`);
|
|
742
746
|
}
|
|
743
747
|
return count;
|
|
744
748
|
}
|
|
@@ -3038,7 +3042,373 @@ async function cmdUpdateAll() {
|
|
|
3038
3042
|
|
|
3039
3043
|
// ── ldm doctor ──
|
|
3040
3044
|
|
|
3045
|
+
// ── ldm doctor --commit-deployed ──
|
|
3046
|
+
//
|
|
3047
|
+
// Sanctioned path to record installer-deployed config drift in a tracked home
|
|
3048
|
+
// tree (~/.claude etc.). `ldm install` / `ldm doctor` rewrite tracked config
|
|
3049
|
+
// (hook wiring in settings.json, rules/, boot config) and nobody commits the
|
|
3050
|
+
// result; the tree stays dirty, which blocks `git pull` and collides with any
|
|
3051
|
+
// new config edit. This classifies each change into three classes and records
|
|
3052
|
+
// them cleanly:
|
|
3053
|
+
//
|
|
3054
|
+
// deployed (installer-owned: rules/, settings.json hook wiring) -> commit
|
|
3055
|
+
// transient (runtime junk: projects/, caches, plans/, ...) -> gitignore
|
|
3056
|
+
// personal (user choices: model, permission flags, statusLine) -> leave
|
|
3057
|
+
//
|
|
3058
|
+
// It runs entirely inside this ldm subprocess using git index + commit
|
|
3059
|
+
// operations, so it needs none of the agent-level file-writes on the primary
|
|
3060
|
+
// tree that the branch-guard blocks by hand. settings.json mixes deployed and
|
|
3061
|
+
// personal in one file, so it is handled at JSON-KEY granularity: only the
|
|
3062
|
+
// installer-owned keys are committed, the rest are left in the working tree.
|
|
3063
|
+
// A timestamped backup of every changed file is taken before any mutation.
|
|
3064
|
+
//
|
|
3065
|
+
// Ticket: wip-tracking-private-only/bugs/installer/open-tickets/2026-07-06--cc-mini--ldm-doctor-commit-deployed.md
|
|
3066
|
+
// Policy parent: wip-ldm-os-private/ai/product/bugs/os-level/open-tickets/2026-07-05--cc-mini--deployed-state-drift-commit-policy.md
|
|
3067
|
+
|
|
3068
|
+
// The deployed set below is audited against what `ldm install` actually writes
|
|
3069
|
+
// into the ~/.claude git repo: `settings.json` hook wiring (SessionStart /
|
|
3070
|
+
// UserPromptSubmit / Stop) and `rules/*.md` (deployRules). Notably CLAUDE.md is
|
|
3071
|
+
// NOT installer-deployed (bin/ldm.js "CLAUDE.md files are NEVER deployed by the
|
|
3072
|
+
// installer"; it is a git-tracked file changed only through branches + PRs), so
|
|
3073
|
+
// CLAUDE.md drift correctly classifies as personal and is left, not swept into
|
|
3074
|
+
// a chore(deploy) commit. The classification fails SAFE: anything unrecognized
|
|
3075
|
+
// falls to personal and is left untouched. A different home (~/.openclaw,
|
|
3076
|
+
// ~/.ldm) would extend these lists to match its own installer-owned surface.
|
|
3077
|
+
|
|
3078
|
+
// Only these top-level settings.json keys are installer-owned and get
|
|
3079
|
+
// committed. Every other changed key (model, permissions, statusLine, env, ...)
|
|
3080
|
+
// is personal and left in the working tree unless --include-personal.
|
|
3081
|
+
const DEPLOYED_SETTINGS_KEYS = ['hooks'];
|
|
3082
|
+
|
|
3083
|
+
// Whole-file deployed path prefixes (installer-owned dirs), committed as-is.
|
|
3084
|
+
const DEPLOYED_PREFIXES = ['rules/'];
|
|
3085
|
+
|
|
3086
|
+
// Transient / runtime paths: content is never committed; untracked ones are
|
|
3087
|
+
// gitignored. `skills/` is here because the ~/.claude installer regenerates it
|
|
3088
|
+
// and .gitignore already ignores it ("Rebuilt by ldm install"); if CC skills
|
|
3089
|
+
// ever become tracked + installer-deployed, move it to the deployed set.
|
|
3090
|
+
const TRANSIENT_PREFIXES = [
|
|
3091
|
+
'projects/', 'sessions/', 'session-env/', 'shell-snapshots/', 'debug/',
|
|
3092
|
+
'file-history/', 'paste-cache/', 'cache/', 'backups/', 'plugins/', 'tasks/',
|
|
3093
|
+
'todos/', 'telemetry/', 'downloads/', 'statsig/', 'plans/', 'skills/',
|
|
3094
|
+
];
|
|
3095
|
+
const TRANSIENT_FILES = ['history.jsonl', 'stats-cache.json', '.DS_Store', '.claude.lock'];
|
|
3096
|
+
|
|
3097
|
+
function gitIn(homeRepo, gitArgs, opts = {}) {
|
|
3098
|
+
return spawnSync('git', ['-C', homeRepo, ...gitArgs], { encoding: 'utf8', ...opts });
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
function safeParseJSON(s) {
|
|
3102
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
// Parse `git status --porcelain -z` (NUL-separated). Rename/copy entries carry
|
|
3106
|
+
// a second NUL-terminated source path token which we skip.
|
|
3107
|
+
function parsePorcelainZ(out) {
|
|
3108
|
+
const parts = (out || '').split('\0').filter((s) => s.length > 0);
|
|
3109
|
+
const entries = [];
|
|
3110
|
+
for (let i = 0; i < parts.length; i++) {
|
|
3111
|
+
const tok = parts[i];
|
|
3112
|
+
const xy = tok.slice(0, 2);
|
|
3113
|
+
const path = tok.slice(3);
|
|
3114
|
+
entries.push({ xy, path });
|
|
3115
|
+
if (xy[0] === 'R' || xy[0] === 'C') i++; // skip source path token
|
|
3116
|
+
}
|
|
3117
|
+
return entries;
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
function classifyHomePath(p) {
|
|
3121
|
+
if (p === 'settings.json') return 'settings';
|
|
3122
|
+
if (TRANSIENT_FILES.includes(p)) return 'transient';
|
|
3123
|
+
if (TRANSIENT_PREFIXES.some((pre) => p.startsWith(pre))) return 'transient';
|
|
3124
|
+
if (DEPLOYED_PREFIXES.some((pre) => p.startsWith(pre))) return 'deployed';
|
|
3125
|
+
return 'personal';
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// Decide which settings.json top-level keys are committed vs left, and build
|
|
3129
|
+
// the exact blob content to stage for the committed subset (HEAD values for
|
|
3130
|
+
// every key, overwritten by working values for the committed keys only).
|
|
3131
|
+
//
|
|
3132
|
+
// Returns { skip: reason } when either side is unparsable or the working file
|
|
3133
|
+
// is gone. Never treat malformed JSON as {}: doing so would drop the HEAD hooks
|
|
3134
|
+
// and commit their deletion. This matches the neighboring doctor behavior
|
|
3135
|
+
// (malformed settings.json is warned about and skipped, not rewritten).
|
|
3136
|
+
function planSettingsSplit(homeRepo, settingsPath) {
|
|
3137
|
+
const headShow = gitIn(homeRepo, ['show', 'HEAD:settings.json']);
|
|
3138
|
+
let head = {};
|
|
3139
|
+
if (headShow.status === 0) {
|
|
3140
|
+
head = safeParseJSON(headShow.stdout);
|
|
3141
|
+
if (head === null) return { skip: 'committed settings.json (HEAD) is not valid JSON' };
|
|
3142
|
+
}
|
|
3143
|
+
if (!existsSync(settingsPath)) return { skip: 'settings.json is missing from the working tree' };
|
|
3144
|
+
const working = safeParseJSON(readFileSync(settingsPath, 'utf8'));
|
|
3145
|
+
if (working === null) return { skip: 'settings.json is not valid JSON' };
|
|
3146
|
+
|
|
3147
|
+
const commitKeys = [];
|
|
3148
|
+
const leaveKeys = [];
|
|
3149
|
+
const allKeys = new Set([...Object.keys(head), ...Object.keys(working)]);
|
|
3150
|
+
for (const k of allKeys) {
|
|
3151
|
+
if (JSON.stringify(head[k]) === JSON.stringify(working[k])) continue; // unchanged
|
|
3152
|
+
if (INCLUDE_PERSONAL_FLAG || DEPLOYED_SETTINGS_KEYS.includes(k)) commitKeys.push(k);
|
|
3153
|
+
else leaveKeys.push(k);
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
const committed = JSON.parse(JSON.stringify(head));
|
|
3157
|
+
for (const k of commitKeys) {
|
|
3158
|
+
if (k in working) committed[k] = working[k];
|
|
3159
|
+
else delete committed[k];
|
|
3160
|
+
}
|
|
3161
|
+
const blobContent = JSON.stringify(committed, null, 2) + '\n';
|
|
3162
|
+
return { commitKeys, leaveKeys, blobContent };
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// Write content into the object store and return its sha. Does NOT touch the
|
|
3166
|
+
// working tree, so personal keys stay in the live settings.json file.
|
|
3167
|
+
function writeGitBlob(homeRepo, relPath, content) {
|
|
3168
|
+
const r = spawnSync('git', ['-C', homeRepo, 'hash-object', '-w', '--path', relPath, '--stdin'],
|
|
3169
|
+
{ input: content, encoding: 'utf8' });
|
|
3170
|
+
return (r.stdout || '').trim();
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// Untracked transient paths surfaced by `git status` are by definition not yet
|
|
3174
|
+
// ignored. Return the .gitignore patterns to add (top-level dir or filename),
|
|
3175
|
+
// deduped and skipping any already present.
|
|
3176
|
+
function computeGitignoreAdds(homeRepo, transientPaths) {
|
|
3177
|
+
const gi = join(homeRepo, '.gitignore');
|
|
3178
|
+
const existing = existsSync(gi)
|
|
3179
|
+
? readFileSync(gi, 'utf8').split('\n').map((s) => s.trim())
|
|
3180
|
+
: [];
|
|
3181
|
+
const adds = new Set();
|
|
3182
|
+
for (const p of transientPaths) {
|
|
3183
|
+
const seg = p.split('/')[0];
|
|
3184
|
+
const pattern = p.includes('/') ? `${seg}/` : seg;
|
|
3185
|
+
if (existing.includes(pattern) || existing.includes(pattern.replace(/\/$/, ''))) continue;
|
|
3186
|
+
adds.add(pattern);
|
|
3187
|
+
}
|
|
3188
|
+
return [...adds];
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
// Append the new ignore patterns and stage ONLY those. The working file keeps
|
|
3192
|
+
// whatever the user already had (so the transient paths are ignored on disk),
|
|
3193
|
+
// but the staged blob is HEAD + our block, never the working file. That way a
|
|
3194
|
+
// pre-existing uncommitted .gitignore edit is NOT swept into this commit: it
|
|
3195
|
+
// stays as a working-tree change the user owns. .gitignore is otherwise not
|
|
3196
|
+
// classified as deployed, so this is the only path that ever commits it.
|
|
3197
|
+
function stageGitignoreAdditions(homeRepo, adds) {
|
|
3198
|
+
const gi = join(homeRepo, '.gitignore');
|
|
3199
|
+
const block = '\n# Added by ldm doctor --commit-deployed (transient/runtime)\n'
|
|
3200
|
+
+ adds.map((p) => `${p}\n`).join('');
|
|
3201
|
+
|
|
3202
|
+
// Working file: append to current content (preserves any user edits).
|
|
3203
|
+
let working = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
|
3204
|
+
if (working.length && !working.endsWith('\n')) working += '\n';
|
|
3205
|
+
writeFileSync(gi, working + block);
|
|
3206
|
+
|
|
3207
|
+
// Staged blob: HEAD content (not working) + our block only.
|
|
3208
|
+
const headShow = gitIn(homeRepo, ['show', 'HEAD:.gitignore']);
|
|
3209
|
+
let headContent = headShow.status === 0 ? headShow.stdout : '';
|
|
3210
|
+
if (headContent.length && !headContent.endsWith('\n')) headContent += '\n';
|
|
3211
|
+
const sha = writeGitBlob(homeRepo, '.gitignore', headContent + block);
|
|
3212
|
+
gitIn(homeRepo, ['update-index', '--cacheinfo', `100644,${sha},.gitignore`]);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
// Copy every changed file's current content plus a manifest (HEAD sha + status)
|
|
3216
|
+
// into ~/.ldm/backups/ before any mutation, so the pre-command state is
|
|
3217
|
+
// recoverable from git + backup.
|
|
3218
|
+
function backupHomeDrift(homeRepo, entries) {
|
|
3219
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3220
|
+
const backupDir = join(LDM_ROOT, 'backups', `commit-deployed-${basename(homeRepo)}-${ts}`);
|
|
3221
|
+
mkdirSync(backupDir, { recursive: true });
|
|
3222
|
+
const headSha = (gitIn(homeRepo, ['rev-parse', 'HEAD']).stdout || '').trim();
|
|
3223
|
+
const manifest = [`home: ${homeRepo}`, `head: ${headSha}`, 'changes:'];
|
|
3224
|
+
for (const e of entries) {
|
|
3225
|
+
manifest.push(` ${e.xy} ${e.path}`);
|
|
3226
|
+
const src = join(homeRepo, e.path);
|
|
3227
|
+
try {
|
|
3228
|
+
if (existsSync(src) && statSync(src).isFile()) {
|
|
3229
|
+
const dest = join(backupDir, e.path);
|
|
3230
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
3231
|
+
copyFileSync(src, dest);
|
|
3232
|
+
}
|
|
3233
|
+
} catch {}
|
|
3234
|
+
}
|
|
3235
|
+
writeFileSync(join(backupDir, 'MANIFEST.txt'), manifest.join('\n') + '\n');
|
|
3236
|
+
return backupDir;
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
function commitDeployedConfig() {
|
|
3240
|
+
const homeIdx = args.indexOf('--home');
|
|
3241
|
+
const homeRepo = (homeIdx !== -1 && args[homeIdx + 1] && !args[homeIdx + 1].startsWith('--'))
|
|
3242
|
+
? resolve(args[homeIdx + 1])
|
|
3243
|
+
: join(HOME, '.claude');
|
|
3244
|
+
|
|
3245
|
+
console.log('');
|
|
3246
|
+
console.log(' ldm doctor --commit-deployed');
|
|
3247
|
+
console.log(' ────────────────────────────────────');
|
|
3248
|
+
console.log(` Home repo: ${homeRepo.replace(HOME, '~')}`);
|
|
3249
|
+
|
|
3250
|
+
const inside = gitIn(homeRepo, ['rev-parse', '--is-inside-work-tree']);
|
|
3251
|
+
if (inside.status !== 0 || (inside.stdout || '').trim() !== 'true') {
|
|
3252
|
+
console.log(` x Not a git work tree: ${homeRepo.replace(HOME, '~')}`);
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
const st = gitIn(homeRepo, ['status', '--porcelain', '-z']);
|
|
3257
|
+
if (st.status !== 0) {
|
|
3258
|
+
console.log(` x git status failed: ${(st.stderr || '').trim()}`);
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
const entries = parsePorcelainZ(st.stdout);
|
|
3262
|
+
if (entries.length === 0) {
|
|
3263
|
+
console.log(' + Working tree already clean, nothing to record.');
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
const deployed = [];
|
|
3268
|
+
const transientUntracked = []; // '??' -> can be gitignored to drop from status
|
|
3269
|
+
const transientTracked = []; // tracked transient -> gitignore can't clean it; leave
|
|
3270
|
+
const personal = [];
|
|
3271
|
+
let settingsChanged = false;
|
|
3272
|
+
let gitignoreDrifted = false; // pre-existing .gitignore drift (user-owned)
|
|
3273
|
+
for (const e of entries) {
|
|
3274
|
+
// .gitignore is handled only through the ignore-add path (HEAD-based blob),
|
|
3275
|
+
// never as deployed/personal, so a pre-existing edit is never swept in.
|
|
3276
|
+
if (e.path === '.gitignore') { gitignoreDrifted = true; continue; }
|
|
3277
|
+
const cls = classifyHomePath(e.path);
|
|
3278
|
+
if (cls === 'settings') settingsChanged = true;
|
|
3279
|
+
else if (cls === 'deployed') deployed.push(e.path);
|
|
3280
|
+
else if (cls === 'transient') (e.xy === '??' ? transientUntracked : transientTracked).push(e.path);
|
|
3281
|
+
else personal.push(e.path);
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
const settingsPath = join(homeRepo, 'settings.json');
|
|
3285
|
+
const settingsPlan = settingsChanged ? planSettingsSplit(homeRepo, settingsPath) : null;
|
|
3286
|
+
const settingsSkip = settingsPlan && settingsPlan.skip ? settingsPlan.skip : null;
|
|
3287
|
+
const willCommitSettings = !!(settingsPlan && !settingsPlan.skip && settingsPlan.commitKeys.length > 0);
|
|
3288
|
+
const gitignoreAdds = computeGitignoreAdds(homeRepo, transientUntracked);
|
|
3289
|
+
const leaveKeys = settingsPlan && !settingsPlan.skip ? settingsPlan.leaveKeys : [];
|
|
3290
|
+
|
|
3291
|
+
const printLeft = () => {
|
|
3292
|
+
const hasLeft = personal.length || leaveKeys.length || transientTracked.length || gitignoreDrifted || settingsSkip;
|
|
3293
|
+
if (!hasLeft) return;
|
|
3294
|
+
console.log(' - Left uncommitted (yours to keep or commit):');
|
|
3295
|
+
for (const p of personal) console.log(` ${p} (personal)`);
|
|
3296
|
+
for (const k of leaveKeys) console.log(` settings.json (${k}) (personal)`);
|
|
3297
|
+
if (gitignoreDrifted) console.log(' .gitignore (your existing edits; only new ignore patterns were committed)');
|
|
3298
|
+
for (const p of transientTracked) console.log(` ${p} (tracked transient; gitignore cannot clean a tracked file, left as-is)`);
|
|
3299
|
+
if (settingsSkip) console.log(` settings.json (skipped: ${settingsSkip})`);
|
|
3300
|
+
if (leaveKeys.length && !INCLUDE_PERSONAL_FLAG) console.log(' Re-run with --include-personal to fold personal settings keys in.');
|
|
3301
|
+
};
|
|
3302
|
+
|
|
3303
|
+
if (settingsSkip) {
|
|
3304
|
+
console.log(` ! settings.json skipped: ${settingsSkip}. Left untouched (repair it, then re-run).`);
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
if (deployed.length === 0 && !willCommitSettings && gitignoreAdds.length === 0) {
|
|
3308
|
+
console.log(' + No deployed drift or new runtime paths to record.');
|
|
3309
|
+
printLeft();
|
|
3310
|
+
return;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
if (DRY_RUN) {
|
|
3314
|
+
console.log(' [DRY RUN] Would record:');
|
|
3315
|
+
if (deployed.length) console.log(` deployed files: ${deployed.join(', ')}`);
|
|
3316
|
+
if (willCommitSettings) console.log(` settings.json keys: ${settingsPlan.commitKeys.join(', ')}`);
|
|
3317
|
+
if (gitignoreAdds.length) console.log(` gitignore: ${gitignoreAdds.join(', ')}`);
|
|
3318
|
+
printLeft();
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
const backupDir = backupHomeDrift(homeRepo, entries);
|
|
3323
|
+
console.log(` + Backup: ${backupDir.replace(HOME, '~')}`);
|
|
3324
|
+
|
|
3325
|
+
for (const p of deployed) gitIn(homeRepo, ['add', '--', p]);
|
|
3326
|
+
|
|
3327
|
+
if (willCommitSettings) {
|
|
3328
|
+
if (leaveKeys.length === 0) {
|
|
3329
|
+
// No personal drift in settings.json: stage the working file byte-exact.
|
|
3330
|
+
gitIn(homeRepo, ['add', '--', 'settings.json']);
|
|
3331
|
+
} else {
|
|
3332
|
+
// Mixed file: stage a key-split blob so personal keys stay in the tree.
|
|
3333
|
+
const sha = writeGitBlob(homeRepo, 'settings.json', settingsPlan.blobContent);
|
|
3334
|
+
gitIn(homeRepo, ['update-index', '--cacheinfo', `100644,${sha},settings.json`]);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
if (gitignoreAdds.length > 0) {
|
|
3339
|
+
stageGitignoreAdditions(homeRepo, gitignoreAdds);
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
const msg = 'chore(deploy): record installer-deployed config drift';
|
|
3343
|
+
const body = [
|
|
3344
|
+
'Recorded by ldm doctor --commit-deployed.',
|
|
3345
|
+
'',
|
|
3346
|
+
'Co-Authored-By: Parker Todd Brooks <parkertoddbrooks@users.noreply.github.com>',
|
|
3347
|
+
'Co-Authored-By: Lēsa <lesaai@icloud.com>',
|
|
3348
|
+
'Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>',
|
|
3349
|
+
'Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>',
|
|
3350
|
+
].join('\n');
|
|
3351
|
+
|
|
3352
|
+
// Land the recorded commit on the home repo's current branch using git
|
|
3353
|
+
// plumbing (write-tree + commit-tree + update-ref) rather than `git commit`.
|
|
3354
|
+
//
|
|
3355
|
+
// Home repos (~/.claude) commit on main, and the global pre-commit hook
|
|
3356
|
+
// blocks hand-commits on main (that is why the by-hand recipe branches). This
|
|
3357
|
+
// command is the sanctioned exception (per the policy parent: record deployed
|
|
3358
|
+
// drift on the home repo's branch), applied to a backed-up, classified,
|
|
3359
|
+
// key-split index. Plumbing lands that index as a commit without invoking the
|
|
3360
|
+
// pre-commit hook. This is NOT a force-past-the-hook bypass of a safety check
|
|
3361
|
+
// being evaded: the hook's intent (no hand-edits on main) is preserved, the
|
|
3362
|
+
// work is tool-driven and recoverable, and it does not push.
|
|
3363
|
+
const branchRef = (gitIn(homeRepo, ['symbolic-ref', 'HEAD']).stdout || '').trim();
|
|
3364
|
+
if (!branchRef) {
|
|
3365
|
+
console.log(' x Home repo is in a detached HEAD state; not recording. Check out a branch first.');
|
|
3366
|
+
console.log(` Nothing lost. Backup at ${backupDir.replace(HOME, '~')}; unstage with: git -C ${homeRepo.replace(HOME, '~')} reset`);
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
const parent = (gitIn(homeRepo, ['rev-parse', 'HEAD']).stdout || '').trim();
|
|
3370
|
+
const treeRes = gitIn(homeRepo, ['write-tree']);
|
|
3371
|
+
const commitRes = treeRes.status === 0
|
|
3372
|
+
? spawnSync('git', ['-C', homeRepo, 'commit-tree', treeRes.stdout.trim(), '-p', parent],
|
|
3373
|
+
{ input: `${msg}\n\n${body}\n`, encoding: 'utf8' })
|
|
3374
|
+
: { status: 1, stderr: treeRes.stderr };
|
|
3375
|
+
const newSha = (commitRes.stdout || '').trim();
|
|
3376
|
+
const upd = commitRes.status === 0
|
|
3377
|
+
? gitIn(homeRepo, ['update-ref', branchRef, newSha, parent])
|
|
3378
|
+
: { status: 1 };
|
|
3379
|
+
if (treeRes.status !== 0 || commitRes.status !== 0 || upd.status !== 0) {
|
|
3380
|
+
const why = ((treeRes.stderr || '') + (commitRes.stderr || '') + (upd.stderr || '')).trim();
|
|
3381
|
+
console.log(` x recording failed: ${why || 'unknown git error'}`);
|
|
3382
|
+
console.log(` Nothing lost. Backup at ${backupDir.replace(HOME, '~')}; unstage with: git -C ${homeRepo.replace(HOME, '~')} reset`);
|
|
3383
|
+
return;
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
console.log(' + Committed deployed config:');
|
|
3387
|
+
for (const p of deployed) console.log(` ${p}`);
|
|
3388
|
+
if (willCommitSettings) console.log(` settings.json (${settingsPlan.commitKeys.join(', ')})`);
|
|
3389
|
+
if (gitignoreAdds.length > 0) {
|
|
3390
|
+
console.log(' + Gitignored transient/runtime paths:');
|
|
3391
|
+
for (const pat of gitignoreAdds) console.log(` ${pat}`);
|
|
3392
|
+
}
|
|
3393
|
+
printLeft();
|
|
3394
|
+
|
|
3395
|
+
const after = gitIn(homeRepo, ['status', '--porcelain']);
|
|
3396
|
+
const dirtyLeft = (after.stdout || '').trim().split('\n').filter(Boolean);
|
|
3397
|
+
if (dirtyLeft.length === 0) {
|
|
3398
|
+
console.log(' + Tree clean. `git pull` is unblocked.');
|
|
3399
|
+
} else {
|
|
3400
|
+
console.log(` + Deployed/transient drift recorded; ${dirtyLeft.length} left change(s) remain (personal/tracked-transient). Pull is unblocked once those are resolved by you.`);
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3041
3404
|
async function cmdDoctor() {
|
|
3405
|
+
// Distinct mode: record installer-deployed config drift in a tracked home.
|
|
3406
|
+
// Runs its own flow and returns before the normal health checks.
|
|
3407
|
+
if (COMMIT_DEPLOYED_FLAG) {
|
|
3408
|
+
commitDeployedConfig();
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3042
3412
|
console.log('');
|
|
3043
3413
|
console.log(' ldm doctor');
|
|
3044
3414
|
console.log(' ────────────────────────────────────');
|
package/docs/backup/README.md
CHANGED
|
@@ -1,108 +1,111 @@
|
|
|
1
1
|
# Backup
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
LDM OS uses one policy-driven backup engine. The scheduled job remains separately controlled by LaunchAgent `ai.openclaw.ldm-backup`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Safe dry run
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Run this after the repaired engine is released and installed:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
| Workspace directory | tar (excludes node_modules, .git/objects, old backups, _trash) | Entire workspace |
|
|
9
|
+
```bash
|
|
10
|
+
~/.ldm/bin/ldm-backup.sh --dry-run
|
|
11
|
+
```
|
|
24
12
|
|
|
25
|
-
|
|
13
|
+
The dry run reads the same canonical policy and estimator as a real run. It reports every source classification, exclusions and reasons, per-source estimates, total estimated new bytes, disk capacity, available bytes, required reserve, planned staging and destination paths, blocked requirements, and the preflight verdict.
|
|
26
14
|
|
|
27
|
-
|
|
15
|
+
Dry run is strictly read-only. It does not create a directory, acquire a persistent lock, write structured state, create a manifest or completion marker, copy data, rotate snapshots, access Vault or iCloud for writing, change the schedule, enable the LaunchAgent, or modify containment state. Sensitive sources use policy display labels. Their filenames and contents are not enumerated.
|
|
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
|
-
<workspace>.tar
|
|
47
|
-
```
|
|
17
|
+
Dry run exits zero only when the same plan would pass real-run preflight. Required missing sources, blocked encryption gates, destination failure, and reserve failure return nonzero.
|
|
48
18
|
|
|
49
|
-
##
|
|
19
|
+
## Canonical policy
|
|
50
20
|
|
|
51
|
-
|
|
21
|
+
The installed `~/.ldm/bin/ldm-backup-policy.json` is the source of truth for:
|
|
52
22
|
|
|
53
|
-
|
|
23
|
+
- Required, rebuildable, sensitive, blocked, manifest-only, and excluded sources.
|
|
24
|
+
- Per-source capture types and destinations.
|
|
25
|
+
- Exact file-selection and exclusion rules.
|
|
26
|
+
- Disk-floor percentages.
|
|
27
|
+
- The two legal evidence states: `complete` and `deferred-after-write-failure`.
|
|
54
28
|
|
|
55
|
-
|
|
29
|
+
Estimation, capture, manifest generation, and exclusion records all consume this policy. Sensitive sources that still require encryption are marked blocked. The engine fails honestly instead of silently omitting them.
|
|
56
30
|
|
|
57
|
-
|
|
58
|
-
~/.ldm/bin/ldm-backup.sh # run backup now
|
|
59
|
-
~/.ldm/bin/ldm-backup.sh --dry-run # preview what would be backed up
|
|
60
|
-
~/.ldm/bin/ldm-backup.sh --keep 14 # keep 14 days instead of 7
|
|
61
|
-
~/.ldm/bin/ldm-backup.sh --include-secrets # include ~/.ldm/secrets/
|
|
62
|
-
```
|
|
31
|
+
The policy explicitly represents every Slice 1 inventory row. The fixture suite asserts the exact source-ID set so deleting or silently omitting a source fails testing.
|
|
63
32
|
|
|
64
|
-
|
|
33
|
+
## Runtime dependency
|
|
65
34
|
|
|
66
|
-
|
|
67
|
-
ldm backup # run backup now
|
|
68
|
-
ldm backup --dry-run # preview with sizes
|
|
69
|
-
ldm backup --pin "before upgrade" # pin latest backup so rotation skips it
|
|
70
|
-
```
|
|
35
|
+
The engine requires Python 3 and uses only the Python standard library. No third-party Python packages are required. The shell entry point checks `command -v python3` first and reports `Backup failed [python3-missing]` when the runtime is unavailable. Parker's operational acceptance of this release prerequisite remains a separate release gate.
|
|
71
36
|
|
|
72
|
-
|
|
37
|
+
Configuration is fail-closed. A missing `~/.ldm/config.json` uses documented defaults, but an unreadable, malformed, or structurally invalid config stops planning with `config-invalid`.
|
|
73
38
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
39
|
+
## Mutation consistency
|
|
40
|
+
|
|
41
|
+
The dry-run estimate is a plan-time observation. A real run freshly observes each source immediately before that source is captured. Changes between planning and capture are allowed, recorded in the manifest through plan-time and capture-time metadata, and captured from the newer observation.
|
|
42
|
+
|
|
43
|
+
Regular files, symlinks, directory membership, and tree metadata must remain stable during their individual source capture. A same-size content change is detected by checksum. Any change during capture is fatal and leaves the snapshot in progress without completion proof. SQLite sources use the SQLite backup API as a transactionally consistent snapshot and are labeled `sqlite-backup-api-transaction-snapshot`.
|
|
44
|
+
|
|
45
|
+
When a source observation changes before capture, the engine recalculates the reserve before copying. The recalculation includes bytes already staged, stat-only refreshed estimates for remaining sources, a full checksum observation of the source about to be copied, projected metadata, and the 10 percent required reserve. Growth that crosses the floor fails with `capture-reserve-gate` before the changed source is copied.
|
|
46
|
+
|
|
47
|
+
## Manifest evidence
|
|
48
|
+
|
|
49
|
+
`manifest` is an evidence-producing action, not a descriptive policy label. Implemented path-inventory generators emit a machine-readable member inventory, logical byte count, and inventory checksum. Every required manifest action must produce evidence that passes completion validation. Entries without an implemented generator are marked blocked before any filesystem walk. Required system and repo-aware Git/worktree manifests whose generators are not implemented remain explicitly blocked.
|
|
50
|
+
|
|
51
|
+
## Successful snapshot contract
|
|
52
|
+
|
|
53
|
+
A real internal-tier run can report success only after:
|
|
54
|
+
|
|
55
|
+
1. The process lock is acquired.
|
|
56
|
+
2. Required sources and policy gates pass.
|
|
57
|
+
3. Free space passes the 10 percent post-write reserve.
|
|
58
|
+
4. Capture completes under `.in-progress-<snapshot-id>`.
|
|
59
|
+
5. SQLite backups pass `PRAGMA quick_check`.
|
|
60
|
+
6. Required manifest actions produce validated evidence.
|
|
61
|
+
7. Every captured file has a manifest checksum.
|
|
62
|
+
8. The manifest validates.
|
|
63
|
+
9. `completion.json` is written last with atomic rename.
|
|
64
|
+
10. The staging directory is atomically promoted.
|
|
65
|
+
|
|
66
|
+
Any required-source or validation failure exits nonzero, writes a fixture-visible failure result when possible, leaves no completion marker, and never prints `Backup complete`.
|
|
67
|
+
|
|
68
|
+
## Snapshot layout
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
~/.ldm/backups/v1/internal/
|
|
72
|
+
.in-progress-2026-07-11--19-00-00Z/
|
|
73
|
+
2026-07-11--19-00-00Z/
|
|
74
|
+
ldm/
|
|
75
|
+
openclaw/
|
|
76
|
+
claude/
|
|
77
|
+
codex/
|
|
78
|
+
manifest.json
|
|
79
|
+
completion.json
|
|
81
80
|
```
|
|
82
81
|
|
|
83
|
-
|
|
82
|
+
Only verified completed snapshots participate in retention. Pinned snapshots never consume an unpinned retention slot. In-progress and unverified directories are never rotated.
|
|
83
|
+
|
|
84
|
+
## Structured state
|
|
85
|
+
|
|
86
|
+
The engine defines atomic records under `~/.ldm/state/backup/v1/`:
|
|
84
87
|
|
|
85
|
-
|
|
88
|
+
- `schedule.json`
|
|
89
|
+
- `attempt.json`
|
|
90
|
+
- `result.json`
|
|
91
|
+
- `success-local.json`
|
|
86
92
|
|
|
87
|
-
|
|
88
|
-
|------|------|-----|
|
|
89
|
-
| Backup | 3:00 AM | LaunchAgent `ai.openclaw.ldm-backup` |
|
|
93
|
+
Vault and iCloud success records are reserved for their later slices. State is diagnostic. Snapshot proof requires the manifest and completion marker.
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
Snapshot identifiers use UTC and end in `Z`, which prevents local DST fall-back collisions. When no valid containment marker exists, `schedule.json` records `evidence_status: unknown`; it never invents a deferred write failure.
|
|
92
96
|
|
|
93
|
-
##
|
|
97
|
+
## Fixture suite
|
|
94
98
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
- `org` ... used for tar filename prefix
|
|
99
|
+
The complete engine suite writes only beneath unique disposable fixture roots:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm run test:backup-engine
|
|
103
|
+
```
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
The suite injects home, LDM, OpenClaw, Claude, Codex, workspace, destination, state, iCloud substitute, and lock paths. It aborts if any path escapes the fixture root.
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
## Current tier boundary
|
|
105
108
|
|
|
106
|
-
|
|
109
|
+
This Slice 2 engine implements internal-tier correctness only. Vault, iCloud, monitoring, restore, migration, and live schedule resumption remain separately gated. Do not run a real backup until those gates are approved.
|
|
107
110
|
|
|
108
|
-
See [TECHNICAL.md](./TECHNICAL.md) for
|
|
111
|
+
See [TECHNICAL.md](./TECHNICAL.md) for schemas and engine details.
|