@wipcomputer/wip-ldm-os 0.4.82-alpha.1 → 0.4.83

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/README.md CHANGED
@@ -96,6 +96,8 @@ The OS connects your AIs. Add-ons are what they actually use. Each one is a full
96
96
 
97
97
  **OpenClaw**
98
98
  - Open-source agent runtime. Run AI agents 24/7 with identity, memory, and tool access. The existence proof for LDM OS.
99
+ - WIP contributions accepted upstream: `before_message_write` plugin hook (#18197), Codex app-server final chat events (#70815 -> maintainer PR #71293), memory-core seed cache streaming/yield (#73067 -> maintainer PR #73118), and fallback vector top-K streaming (#73069 -> maintainer PR #73100).
100
+ - Submitted / superseded: symlink plugin discovery fix (#45744; bug confirmed, superseded by #69971, not landed as submitted).
99
101
  - [Read more about OpenClaw](https://github.com/openclaw/openclaw)
100
102
 
101
103
  [See all skills](docs/skills/README.md)
package/SKILL.md CHANGED
@@ -9,7 +9,7 @@ license: MIT
9
9
  compatibility: Requires git, npm, node. Node.js 18+.
10
10
  metadata:
11
11
  display-name: "LDM OS"
12
- version: "0.4.81"
12
+ version: "0.4.83"
13
13
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
14
14
  author: "Parker Todd Brooks"
15
15
  category: infrastructure
package/bin/ldm.js CHANGED
@@ -1935,6 +1935,36 @@ async function cmdInstallCatalog() {
1935
1935
  console.log(` + Migrated ${migrated} registry entries to v2 format (source info added)`);
1936
1936
  }
1937
1937
 
1938
+ // Aggregate the bin ownership manifest BEFORE seedLocalCatalog,
1939
+ // deployBridge, deployScripts, and the heal walk run. If two
1940
+ // declarers claim the same file in ~/.ldm/bin/ we cannot safely
1941
+ // decide ownership, so install must abort before catalog.json,
1942
+ // bridge files, or any ~/.ldm/bin/ shim is written. See
1943
+ // ai/product/plans-prds/current/2026-04-28--cc-mini--ldm-bin-ownership-manifest-design.md
1944
+ const { aggregateBinManifest, healBinManifest } = await import('../lib/bin-manifest.mjs');
1945
+ const ldmCliRoot = join(__dirname, '..');
1946
+ const binManifestRegistry = readJSON(REGISTRY_PATH) || { extensions: {} };
1947
+ const manifest = aggregateBinManifest({
1948
+ ldmCliRoot,
1949
+ extensionsRoot: LDM_EXTENSIONS,
1950
+ binDir: join(LDM_ROOT, 'bin'),
1951
+ registry: binManifestRegistry,
1952
+ });
1953
+ if (manifest.conflicts.length > 0) {
1954
+ console.log('');
1955
+ console.log(' x bin manifest conflict ... aborting before seedLocalCatalog/deployBridge/deployScripts run:');
1956
+ for (const c of manifest.conflicts) {
1957
+ console.log(` "${c.name}" claimed by ${c.declarers.length} declarers:`);
1958
+ for (const d of c.declarers) {
1959
+ console.log(` ${d.declarer}: ${d.sourcePath.replace(HOME, '~')}`);
1960
+ }
1961
+ }
1962
+ console.log('');
1963
+ console.log(' Resolve ownership in the package manifests, then re-run.');
1964
+ installLog(`ldm install aborted: ${manifest.conflicts.length} bin manifest conflict(s)`);
1965
+ process.exit(1);
1966
+ }
1967
+
1938
1968
  // Seed local catalog if missing (#262)
1939
1969
  if (seedLocalCatalog()) {
1940
1970
  console.log(` + catalog.json seeded to ~/.ldm/catalog.json`);
@@ -1950,6 +1980,24 @@ async function cmdInstallCatalog() {
1950
1980
  deployDocs();
1951
1981
  deployRules();
1952
1982
 
1983
+ // Manifest-driven self-heal. deployScripts() above wrote LDM CLI's own
1984
+ // *.sh files; this pass covers the rest of the manifest (LDM CLI files
1985
+ // deployed elsewhere, e.g. process-monitor.sh, plus extension-owned
1986
+ // shims like crystal-capture.sh). Read-only entries that already match
1987
+ // the manifest are no-ops.
1988
+ if (manifest.entries.length > 0) {
1989
+ const heal = healBinManifest(manifest.entries, { heal: !DRY_RUN });
1990
+ for (const e of heal.healed) {
1991
+ console.log(` + Restored ${e.name} from ${e.sourcePath.replace(HOME, '~')} (declarer: ${e.declarer})`);
1992
+ }
1993
+ for (const f of heal.failed) {
1994
+ console.log(` ! ${f.entry.name}: ${f.reason} (declarer: ${f.entry.declarer})`);
1995
+ }
1996
+ if (heal.ok.length > 0 && heal.healed.length === 0 && heal.failed.length === 0) {
1997
+ console.log(` + bin manifest: ${heal.ok.length} entr${heal.ok.length === 1 ? 'y' : 'ies'} verified`);
1998
+ }
1999
+ }
2000
+
1953
2001
  // Check backup configuration
1954
2002
  checkBackupHealth();
1955
2003
 
@@ -2947,6 +2995,98 @@ async function cmdDoctor() {
2947
2995
  }
2948
2996
  }
2949
2997
 
2998
+ // 3c. Cron target health (parallel to crystal doctor's checkCaptureShim)
2999
+ //
3000
+ // When an extension wires a shim into ~/.ldm/bin/ via cron, the cron
3001
+ // line is sticky: it keeps firing even if the shim file goes missing
3002
+ // for any reason. crystal doctor catches this for crystal-capture.sh,
3003
+ // but any cron line referencing ~/.ldm/bin/<file> is at the same risk
3004
+ // class. Walk the crontab and surface broken bin-targeted entries
3005
+ // explicitly. Read-only by default; --fix restores known shims from
3006
+ // their extension dist when the canonical source is present on disk.
3007
+ {
3008
+ const ldmBinPrefix = join(LDM_ROOT, 'bin') + '/';
3009
+ let crontab = '';
3010
+ try {
3011
+ crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' });
3012
+ } catch {}
3013
+ function expandTilde(p) {
3014
+ return p.startsWith('~') ? join(HOME, p.slice(1)) : p;
3015
+ }
3016
+ function extractCronTarget(line) {
3017
+ const trimmed = line.trim();
3018
+ if (!trimmed.startsWith('*')) return null;
3019
+ const tokens = trimmed.split(/\s+/);
3020
+ if (tokens.length < 6) return null;
3021
+ const expanded = expandTilde(tokens[5]);
3022
+ return expanded.startsWith(ldmBinPrefix) ? expanded : null;
3023
+ }
3024
+ const cronTargets = new Map(); // path → ['missing'|'not executable']
3025
+ const seenTargets = new Set(); // every bin-targeted path we found
3026
+ for (const line of crontab.split('\n')) {
3027
+ const expanded = extractCronTarget(line);
3028
+ if (!expanded) continue;
3029
+ seenTargets.add(expanded);
3030
+ if (cronTargets.has(expanded)) continue;
3031
+ const problems = [];
3032
+ if (!existsSync(expanded)) {
3033
+ problems.push('missing');
3034
+ } else if ((statSync(expanded).mode & 0o111) === 0) {
3035
+ problems.push('not executable');
3036
+ }
3037
+ if (problems.length > 0) cronTargets.set(expanded, problems);
3038
+ }
3039
+ if (cronTargets.size > 0) {
3040
+ // Manifest-driven lookup. Aggregates LDM CLI's wipLdmOs.binFiles
3041
+ // and every registered extension's openclaw.plugin.json#binFiles.
3042
+ // If aggregation reports a conflict, the lookup stays empty and
3043
+ // each broken target becomes "owner unknown" until the conflict
3044
+ // is resolved at the package-manifest layer.
3045
+ const { aggregateBinManifest } = await import('../lib/bin-manifest.mjs');
3046
+ const drManifest = aggregateBinManifest({
3047
+ ldmCliRoot: join(__dirname, '..'),
3048
+ extensionsRoot: LDM_EXTENSIONS,
3049
+ binDir: join(LDM_ROOT, 'bin'),
3050
+ registry: readJSON(REGISTRY_PATH) || { extensions: {} },
3051
+ });
3052
+ const knownSources = {};
3053
+ const declarers = {};
3054
+ if (drManifest.conflicts.length === 0) {
3055
+ for (const e of drManifest.entries) {
3056
+ knownSources[e.name] = e.sourcePath;
3057
+ declarers[e.name] = e.declarer;
3058
+ }
3059
+ } else {
3060
+ console.log(` ! bin manifest conflict: ${drManifest.conflicts.length}; restore disabled until resolved`);
3061
+ }
3062
+ let healed = 0;
3063
+ for (const [path, problems] of cronTargets) {
3064
+ const detail = problems.join(', ');
3065
+ console.log(` ! cron target ${detail}: ${path}`);
3066
+ const base = basename(path);
3067
+ const src = knownSources[base];
3068
+ if (FIX_FLAG && src && existsSync(src)) {
3069
+ try {
3070
+ mkdirSync(dirname(path), { recursive: true });
3071
+ cpSync(src, path);
3072
+ chmodSync(path, 0o755);
3073
+ console.log(` + Restored ${base} from ${src.replace(HOME, '~')}`);
3074
+ healed++;
3075
+ } catch (e) {
3076
+ console.log(` Restore failed: ${e.message}`);
3077
+ }
3078
+ } else if (src) {
3079
+ console.log(` Run: ldm doctor --fix to restore from ${src.replace(HOME, '~')}`);
3080
+ } else {
3081
+ console.log(` Owner unknown; consult the extension that registered this cron entry`);
3082
+ }
3083
+ }
3084
+ issues += cronTargets.size - healed;
3085
+ } else if (seenTargets.size > 0) {
3086
+ console.log(` + Cron targets under ~/.ldm/bin/: ${seenTargets.size} entr${seenTargets.size === 1 ? 'y' : 'ies'}, all exist and executable`);
3087
+ }
3088
+ }
3089
+
2950
3090
  // 4. Check sacred locations
2951
3091
  const sacred = [
2952
3092
  { path: join(LDM_ROOT, 'memory'), label: 'memory/' },
@@ -31,6 +31,8 @@ Your AIs are only as powerful as what you give them. Here's everything available
31
31
 
32
32
  **OpenClaw**
33
33
  - Open-source agent runtime. Run AI agents 24/7 with identity, memory, and tool access. The existence proof for LDM OS.
34
+ - WIP contributions accepted upstream: `before_message_write` plugin hook (#18197), Codex app-server final chat events (#70815 -> maintainer PR #71293), memory-core seed cache streaming/yield (#73067 -> maintainer PR #73118), and fallback vector top-K streaming (#73069 -> maintainer PR #73100).
35
+ - Submitted / superseded: symlink plugin discovery fix (#45744; bug confirmed, superseded by #69971, not landed as submitted).
34
36
  - [Read more about OpenClaw](https://github.com/openclaw/openclaw)
35
37
 
36
38
  ## Identity
@@ -0,0 +1,257 @@
1
+ // lib/bin-manifest.mjs — bin ownership manifest aggregator + heal helpers
2
+ //
3
+ // Implements the design at
4
+ // ai/product/plans-prds/current/2026-04-28--cc-mini--ldm-bin-ownership-manifest-design.md
5
+ //
6
+ // Two declarers contribute entries:
7
+ // - LDM CLI: package.json `wipLdmOs.binFiles`
8
+ // - Extensions: ~/.ldm/extensions/<name>/openclaw.plugin.json `binFiles`
9
+ //
10
+ // Aggregation produces { entries, conflicts }. Conflicts are hard
11
+ // failures: callers MUST check `conflicts.length === 0` before any
12
+ // write. heal() never runs if conflicts exist.
13
+
14
+ import { existsSync, readFileSync, statSync, mkdirSync, copyFileSync, chmodSync } from 'node:fs';
15
+ import { join, dirname, basename } from 'node:path';
16
+
17
+ /**
18
+ * @typedef {Object} BinDeclaration
19
+ * @property {string} name - basename written to <binDir>/<name>
20
+ * @property {string} source - relative to declarer's installed root
21
+ * @property {boolean} [executable] - default true; chmod 0755 after copy
22
+ * @property {string} [purpose] - free-form, surfaces in verbose doctor
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} BinEntry
27
+ * @property {string} name
28
+ * @property {string} destPath - resolved absolute path under binDir
29
+ * @property {string} sourcePath - resolved absolute path
30
+ * @property {boolean} executable
31
+ * @property {string} declarer - 'wip-ldm-os' or extension name
32
+ * @property {string} [purpose]
33
+ */
34
+
35
+ /**
36
+ * @typedef {Object} Conflict
37
+ * @property {string} name
38
+ * @property {{declarer: string, sourcePath: string}[]} declarers
39
+ */
40
+
41
+ /**
42
+ * Validate a single declaration shape. Returns array of error strings (empty = ok).
43
+ * @param {BinDeclaration} decl
44
+ * @returns {string[]}
45
+ */
46
+ export function validateDeclaration(decl) {
47
+ const errors = [];
48
+ if (!decl || typeof decl !== 'object') {
49
+ errors.push('declaration must be an object');
50
+ return errors;
51
+ }
52
+ if (typeof decl.name !== 'string' || !decl.name) {
53
+ errors.push('"name" must be a non-empty string');
54
+ } else if (decl.name !== basename(decl.name) || decl.name.includes('/') || decl.name.includes('\\')) {
55
+ errors.push(`"name" must be a basename, got "${decl.name}"`);
56
+ } else if (decl.name.includes('..')) {
57
+ errors.push(`"name" must not contain "..", got "${decl.name}"`);
58
+ }
59
+ if (typeof decl.source !== 'string' || !decl.source) {
60
+ errors.push('"source" must be a non-empty string');
61
+ } else if (decl.source.includes('..')) {
62
+ errors.push(`"source" must not contain "..", got "${decl.source}"`);
63
+ }
64
+ if (decl.executable !== undefined && typeof decl.executable !== 'boolean') {
65
+ errors.push('"executable" must be a boolean if provided');
66
+ }
67
+ return errors;
68
+ }
69
+
70
+ /**
71
+ * Validate all declarations from one declarer (e.g. the LDM CLI's own
72
+ * `wipLdmOs.binFiles`, or one extension's `binFiles`). Used by both
73
+ * runtime aggregation and prepublish CI gate.
74
+ *
75
+ * Checks: shape per entry, no internal duplicate `name`, `source` exists
76
+ * on disk under `packageRoot`.
77
+ *
78
+ * @param {string} declarer
79
+ * @param {string} packageRoot - the absolute path to resolve `source` against
80
+ * @param {BinDeclaration[]} decls
81
+ * @returns {{valid: boolean, errors: string[]}}
82
+ */
83
+ export function validateDeclarations(declarer, packageRoot, decls) {
84
+ const errors = [];
85
+ if (!Array.isArray(decls)) {
86
+ return { valid: false, errors: [`${declarer}: binFiles must be an array`] };
87
+ }
88
+ const seen = new Set();
89
+ for (let i = 0; i < decls.length; i++) {
90
+ const d = decls[i];
91
+ const ctx = `${declarer}[${i}]${d?.name ? ` ${d.name}` : ''}`;
92
+ for (const e of validateDeclaration(d)) errors.push(`${ctx}: ${e}`);
93
+ if (d && typeof d.name === 'string') {
94
+ if (seen.has(d.name)) errors.push(`${declarer}: duplicate name within declarer: ${d.name}`);
95
+ seen.add(d.name);
96
+ }
97
+ if (d && typeof d.source === 'string') {
98
+ const src = join(packageRoot, d.source);
99
+ if (!existsSync(src)) errors.push(`${ctx}: source not found at ${src}`);
100
+ }
101
+ }
102
+ return { valid: errors.length === 0, errors };
103
+ }
104
+
105
+ /**
106
+ * Read LDM CLI's own bin declarations from its package.json.
107
+ * @param {string} ldmCliRoot
108
+ * @returns {BinDeclaration[]}
109
+ */
110
+ function readLdmCliDeclarations(ldmCliRoot) {
111
+ const pkgPath = join(ldmCliRoot, 'package.json');
112
+ if (!existsSync(pkgPath)) return [];
113
+ try {
114
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
115
+ return Array.isArray(pkg?.wipLdmOs?.binFiles) ? pkg.wipLdmOs.binFiles : [];
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Read one extension's bin declarations from its openclaw.plugin.json.
123
+ * @param {string} extDir - ~/.ldm/extensions/<name>
124
+ * @returns {BinDeclaration[]}
125
+ */
126
+ function readExtensionDeclarations(extDir) {
127
+ const manifestPath = join(extDir, 'openclaw.plugin.json');
128
+ if (!existsSync(manifestPath)) return [];
129
+ try {
130
+ const m = JSON.parse(readFileSync(manifestPath, 'utf-8'));
131
+ return Array.isArray(m?.binFiles) ? m.binFiles : [];
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Aggregate all bin entries across LDM CLI + registered extensions.
139
+ * Returns conflicts for any name claimed by 2+ declarers.
140
+ *
141
+ * IMPORTANT: callers MUST check `conflicts.length === 0` before doing any
142
+ * writes. Conflict means we cannot safely decide who owns the file.
143
+ *
144
+ * @param {Object} opts
145
+ * @param {string} opts.ldmCliRoot - absolute path to LDM CLI package root
146
+ * @param {string} opts.extensionsRoot - absolute path, typically ~/.ldm/extensions
147
+ * @param {string} opts.binDir - absolute path, typically ~/.ldm/bin
148
+ * @param {Object} [opts.registry] - optional ~/.ldm/extensions/registry.json contents
149
+ * @returns {{entries: BinEntry[], conflicts: Conflict[]}}
150
+ */
151
+ export function aggregateBinManifest({ ldmCliRoot, extensionsRoot, binDir, registry } = {}) {
152
+ /** @type {BinEntry[]} */
153
+ const entries = [];
154
+ /** @type {Map<string, {declarer: string, sourcePath: string}[]>} */
155
+ const claims = new Map();
156
+
157
+ function record(declarer, declarerRoot, decls) {
158
+ for (const d of decls) {
159
+ if (!d || typeof d.name !== 'string' || typeof d.source !== 'string') continue;
160
+ const sourcePath = join(declarerRoot, d.source);
161
+ const list = claims.get(d.name) || [];
162
+ list.push({ declarer, sourcePath });
163
+ claims.set(d.name, list);
164
+ entries.push({
165
+ name: d.name,
166
+ destPath: join(binDir, d.name),
167
+ sourcePath,
168
+ executable: d.executable !== false,
169
+ declarer,
170
+ purpose: d.purpose,
171
+ });
172
+ }
173
+ }
174
+
175
+ // 1. LDM CLI
176
+ record('wip-ldm-os', ldmCliRoot, readLdmCliDeclarations(ldmCliRoot));
177
+
178
+ // 2. Registered extensions
179
+ const extNames = registry?.extensions ? Object.keys(registry.extensions) : [];
180
+ for (const name of extNames) {
181
+ const extDir = join(extensionsRoot, name);
182
+ record(name, extDir, readExtensionDeclarations(extDir));
183
+ }
184
+
185
+ /** @type {Conflict[]} */
186
+ const conflicts = [];
187
+ for (const [name, declarers] of claims.entries()) {
188
+ if (declarers.length > 1) conflicts.push({ name, declarers });
189
+ }
190
+
191
+ return { entries, conflicts };
192
+ }
193
+
194
+ /**
195
+ * Verify and (optionally) heal each entry. heal=false is read-only and
196
+ * just classifies. heal=true restores missing/unexecutable files from
197
+ * `sourcePath`. Returns a per-entry result so callers can build their
198
+ * own output.
199
+ *
200
+ * NEVER call this if aggregateBinManifest reported conflicts. The caller
201
+ * must abort instead.
202
+ *
203
+ * @param {BinEntry[]} entries
204
+ * @param {Object} [opts]
205
+ * @param {boolean} [opts.heal] - default false
206
+ * @returns {{
207
+ * ok: BinEntry[],
208
+ * missing: BinEntry[],
209
+ * notExecutable: BinEntry[],
210
+ * sourceMissing: BinEntry[],
211
+ * healed: BinEntry[],
212
+ * failed: {entry: BinEntry, reason: string}[]
213
+ * }}
214
+ */
215
+ export function healBinManifest(entries, opts = {}) {
216
+ const heal = opts.heal === true;
217
+ const ok = [];
218
+ const missing = [];
219
+ const notExecutable = [];
220
+ const sourceMissing = [];
221
+ const healed = [];
222
+ const failed = [];
223
+
224
+ for (const e of entries) {
225
+ const destExists = existsSync(e.destPath);
226
+ const destExecutable = destExists && (statSync(e.destPath).mode & 0o111) !== 0;
227
+ const expectedExec = e.executable !== false;
228
+ const destOk = destExists && (!expectedExec || destExecutable);
229
+
230
+ if (destOk) {
231
+ ok.push(e);
232
+ continue;
233
+ }
234
+
235
+ if (!destExists) missing.push(e);
236
+ else if (expectedExec && !destExecutable) notExecutable.push(e);
237
+
238
+ if (!heal) continue;
239
+
240
+ if (!existsSync(e.sourcePath)) {
241
+ sourceMissing.push(e);
242
+ failed.push({ entry: e, reason: `source missing at ${e.sourcePath}` });
243
+ continue;
244
+ }
245
+
246
+ try {
247
+ mkdirSync(dirname(e.destPath), { recursive: true });
248
+ copyFileSync(e.sourcePath, e.destPath);
249
+ if (expectedExec) chmodSync(e.destPath, 0o755);
250
+ healed.push(e);
251
+ } catch (err) {
252
+ failed.push({ entry: e, reason: err.message });
253
+ }
254
+ }
255
+
256
+ return { ok, missing, notExecutable, sourceMissing, healed, failed };
257
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.82-alpha.1",
3
+ "version": "0.4.83",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -18,9 +18,13 @@
18
18
  "scripts": {
19
19
  "build:bridge": "cd src/bridge && npm install && npx tsup core.ts mcp-server.ts cli.ts openclaw.ts --format esm --dts --clean --outDir ../../dist/bridge",
20
20
  "build": "npm run build:bridge",
21
- "prepublishOnly": "npm run build:bridge",
21
+ "prepublishOnly": "npm run build:bridge && npm run validate:bin-manifest",
22
+ "validate:bin-manifest": "node scripts/validate-bin-manifest.mjs",
22
23
  "test:skill-frontmatter": "node scripts/test-skill-frontmatter.mjs",
23
24
  "test:installer-update-tracks": "node scripts/test-installer-update-tracks.mjs",
25
+ "test:ldm-install-bin-shim": "node scripts/test-ldm-install-preserves-foreign-bin.mjs",
26
+ "test:doctor-cron-target": "node scripts/test-doctor-cron-target.mjs",
27
+ "test:bin-manifest": "node scripts/test-bin-manifest.mjs",
24
28
  "fmt": "npx prettier --write 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'",
25
29
  "fmt:check": "npx prettier --check 'src/**/*.{ts,mjs}' 'lib/**/*.mjs' 'bin/**/*.js'"
26
30
  },
@@ -32,6 +36,35 @@
32
36
  "timeout": 15
33
37
  }
34
38
  },
39
+ "wipLdmOs": {
40
+ "binFiles": [
41
+ {
42
+ "name": "process-monitor.sh",
43
+ "source": "bin/process-monitor.sh",
44
+ "purpose": "kills zombie processes; cron every 3 min"
45
+ },
46
+ {
47
+ "name": "ldm-backup.sh",
48
+ "source": "scripts/ldm-backup.sh",
49
+ "purpose": "daily backup; LaunchAgent at 03:00"
50
+ },
51
+ {
52
+ "name": "ldm-restore.sh",
53
+ "source": "scripts/ldm-restore.sh",
54
+ "purpose": "restore from backup; operator-invoked"
55
+ },
56
+ {
57
+ "name": "ldm-summary.sh",
58
+ "source": "scripts/ldm-summary.sh",
59
+ "purpose": "generate session/daily summaries"
60
+ },
61
+ {
62
+ "name": "backfill-summaries.sh",
63
+ "source": "scripts/backfill-summaries.sh",
64
+ "purpose": "backfill summary cadences"
65
+ }
66
+ ]
67
+ },
35
68
  "files": [
36
69
  "src/",
37
70
  "lib/",