@wipcomputer/wip-ldm-os 0.2.1

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/lib/detect.mjs ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * lib/detect.mjs
3
+ * Interface detection logic. Scans a repo and reports which interfaces it exposes.
4
+ * Adapted from wip-universal-installer/detect.mjs. Zero dependencies.
5
+ */
6
+
7
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
8
+ import { join, basename } from 'node:path';
9
+
10
+ function readJSON(path) {
11
+ try {
12
+ return JSON.parse(readFileSync(path, 'utf8'));
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Detect all interfaces in a repo.
20
+ * Returns { interfaces, pkg } where interfaces is an object keyed by interface type.
21
+ */
22
+ export function detectInterfaces(repoPath) {
23
+ const interfaces = {};
24
+ const pkg = readJSON(join(repoPath, 'package.json'));
25
+
26
+ // 1. CLI: package.json has bin entry
27
+ if (pkg?.bin) {
28
+ interfaces.cli = { bin: pkg.bin, name: pkg.name };
29
+ }
30
+
31
+ // 2. Module: package.json has main or exports
32
+ if (pkg?.main || pkg?.exports) {
33
+ interfaces.module = { main: pkg.main || pkg.exports };
34
+ }
35
+
36
+ // 3. MCP Server: mcp-server.mjs/js/ts or dist/mcp-server.js
37
+ const mcpFiles = ['mcp-server.mjs', 'mcp-server.js', 'mcp-server.ts', 'dist/mcp-server.js'];
38
+ for (const f of mcpFiles) {
39
+ if (existsSync(join(repoPath, f))) {
40
+ interfaces.mcp = { file: f, name: pkg?.name || basename(repoPath) };
41
+ break;
42
+ }
43
+ }
44
+
45
+ // 4. OpenClaw Plugin: openclaw.plugin.json exists
46
+ const ocPlugin = join(repoPath, 'openclaw.plugin.json');
47
+ if (existsSync(ocPlugin)) {
48
+ interfaces.openclaw = { config: readJSON(ocPlugin), path: ocPlugin };
49
+ }
50
+
51
+ // 5. Skill: SKILL.md exists
52
+ if (existsSync(join(repoPath, 'SKILL.md'))) {
53
+ interfaces.skill = { path: join(repoPath, 'SKILL.md') };
54
+ }
55
+
56
+ // 6. Claude Code Hook: guard.mjs or claudeCode.hook in package.json
57
+ if (pkg?.claudeCode?.hook) {
58
+ interfaces.claudeCodeHook = pkg.claudeCode.hook;
59
+ } else if (existsSync(join(repoPath, 'guard.mjs'))) {
60
+ interfaces.claudeCodeHook = {
61
+ event: 'PreToolUse',
62
+ matcher: 'Edit|Write',
63
+ command: `node "${join(repoPath, 'guard.mjs')}"`,
64
+ timeout: 5,
65
+ };
66
+ }
67
+
68
+ return { interfaces, pkg };
69
+ }
70
+
71
+ /**
72
+ * Describe detected interfaces as a human-readable summary.
73
+ */
74
+ export function describeInterfaces(interfaces) {
75
+ const lines = [];
76
+ const names = Object.keys(interfaces);
77
+
78
+ if (names.length === 0) {
79
+ return 'No interfaces detected.';
80
+ }
81
+
82
+ if (interfaces.cli) {
83
+ const bins = typeof interfaces.cli.bin === 'string' ? [interfaces.cli.name] : Object.keys(interfaces.cli.bin);
84
+ lines.push(`CLI: ${bins.join(', ')}`);
85
+ }
86
+ if (interfaces.module) lines.push(`Module: ${JSON.stringify(interfaces.module.main)}`);
87
+ if (interfaces.mcp) lines.push(`MCP Server: ${interfaces.mcp.file}`);
88
+ if (interfaces.openclaw) lines.push(`OpenClaw Plugin: ${interfaces.openclaw.config?.name || 'detected'}`);
89
+ if (interfaces.skill) lines.push(`Skill: SKILL.md`);
90
+ if (interfaces.claudeCodeHook) lines.push(`Claude Code Hook: ${interfaces.claudeCodeHook.event || 'PreToolUse'}`);
91
+
92
+ return `${names.length} interface(s): ${names.join(', ')}\n${lines.map(l => ` ${l}`).join('\n')}`;
93
+ }
94
+
95
+ /**
96
+ * Detect if a repo is a toolbox (has tools/ subdirectories with package.json).
97
+ * Returns array of { name, path } for each sub-tool, or empty array if not a toolbox.
98
+ */
99
+ export function detectToolbox(repoPath) {
100
+ const toolsDir = join(repoPath, 'tools');
101
+ if (!existsSync(toolsDir)) return [];
102
+
103
+ try {
104
+ const entries = readdirSync(toolsDir, { withFileTypes: true });
105
+ return entries
106
+ .filter(e => e.isDirectory() && existsSync(join(toolsDir, e.name, 'package.json')))
107
+ .map(e => ({ name: e.name, path: join(toolsDir, e.name) }));
108
+ } catch {
109
+ return [];
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Detect interfaces and return a structured JSON-serializable result.
115
+ */
116
+ export function detectInterfacesJSON(repoPath) {
117
+ const { interfaces, pkg } = detectInterfaces(repoPath);
118
+ return {
119
+ repo: basename(repoPath),
120
+ package: pkg?.name || null,
121
+ version: pkg?.version || null,
122
+ interfaces: Object.fromEntries(
123
+ Object.entries(interfaces).map(([type, info]) => [type, {
124
+ detected: true,
125
+ ...info,
126
+ }])
127
+ ),
128
+ interfaceCount: Object.keys(interfaces).length,
129
+ };
130
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@wipcomputer/wip-ldm-os",
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
+ "main": "src/boot/boot-hook.mjs",
7
+ "bin": {
8
+ "ldm": "./bin/ldm.mjs",
9
+ "wip-ldm-os": "./bin/ldm.mjs",
10
+ "ldm-scaffold": "./bin/scaffold.sh",
11
+ "ldm-boot-install": "./src/boot/install-cli.mjs"
12
+ },
13
+ "claudeCode": {
14
+ "hook": {
15
+ "event": "SessionStart",
16
+ "matcher": "*",
17
+ "command": "node /Users/lesa/.ldm/shared/boot/boot-hook.mjs",
18
+ "timeout": 15
19
+ }
20
+ },
21
+ "files": [
22
+ "src/",
23
+ "lib/",
24
+ "bin/",
25
+ "templates/",
26
+ "catalog.json",
27
+ "SKILL.md"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/wipcomputer/wip-ldm-os-private.git"
32
+ },
33
+ "author": "WIP Computer, Inc.",
34
+ "license": "MIT"
35
+ }
@@ -0,0 +1,61 @@
1
+ # LDM OS Boot Sequence Hook
2
+
3
+ SessionStart hook for Claude Code. Reads boot files and injects them into the agent's context before the first user message. No dependencies. No build step.
4
+
5
+ ## What It Does
6
+
7
+ Reads 9 files from the Dream Weaver Boot Sequence (SHARED-CONTEXT.md, SOUL.md, CONTEXT.md, daily logs, journals, repo-locations.md) and injects them as `additionalContext` in the SessionStart response. The agent wakes up already knowing who it is, what's happening, and where things live.
8
+
9
+ ## Content Budget
10
+
11
+ ~700 lines, ~3,500 tokens. Under 2% of the context window. Large files (journals, daily logs) are truncated. Missing files are skipped gracefully.
12
+
13
+ ## Deploy
14
+
15
+ ```bash
16
+ mkdir -p ~/.ldm/shared/boot
17
+ cp src/boot/boot-hook.mjs ~/.ldm/shared/boot/
18
+ cp src/boot/boot-config.json ~/.ldm/shared/boot/
19
+ ```
20
+
21
+ Then add to `~/.claude/settings.json` inside the `hooks` object:
22
+
23
+ ```json
24
+ "SessionStart": [
25
+ {
26
+ "matcher": "*",
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "node /Users/lesa/.ldm/shared/boot/boot-hook.mjs",
31
+ "timeout": 15
32
+ }
33
+ ]
34
+ }
35
+ ]
36
+ ```
37
+
38
+ Restart Claude Code to pick up the hook.
39
+
40
+ ## Test
41
+
42
+ ```bash
43
+ echo '{"session_id":"test","hook_event_name":"SessionStart"}' | node ~/.ldm/shared/boot/boot-hook.mjs
44
+ ```
45
+
46
+ Should output JSON with `hookSpecificOutput.additionalContext` containing all boot content. Check stderr for the load summary.
47
+
48
+ ## Config
49
+
50
+ `boot-config.json` defines paths and limits for each boot step. Uses `~` shorthand (resolved at runtime). To support a different agent (cc-air), deploy a different config alongside the same script.
51
+
52
+ ## Adding a Boot Step
53
+
54
+ 1. Add an entry to `boot-config.json` under `steps`
55
+ 2. Set `path` (single file) or `dir` + `strategy` (directory scan)
56
+ 3. Set `stepNumber`, `label`, and optionally `maxLines` and `critical`
57
+ 4. The hook picks it up automatically. No code changes needed.
58
+
59
+ ## Error Philosophy
60
+
61
+ Partial boot > no boot > blocked session. The hook exits 0 no matter what. Missing files are logged to stderr and skipped. The session always starts.
@@ -0,0 +1,65 @@
1
+ {
2
+ "agentId": "cc-mini",
3
+ "timezone": "America/Los_Angeles",
4
+ "maxTotalLines": 2000,
5
+ "steps": {
6
+ "sharedContext": {
7
+ "path": "~/.openclaw/workspace/SHARED-CONTEXT.md",
8
+ "label": "SHARED-CONTEXT.md",
9
+ "stepNumber": 2,
10
+ "critical": true
11
+ },
12
+ "journals": {
13
+ "dir": "~/Documents/wipcomputer--mac-mini-01/staff/Parker/Claude Code - Mini/documents/journals",
14
+ "label": "Most Recent Journal (Parker)",
15
+ "stepNumber": 3,
16
+ "maxLines": 80,
17
+ "strategy": "most-recent"
18
+ },
19
+ "workspaceDailyLogs": {
20
+ "dir": "~/.openclaw/workspace/memory",
21
+ "label": "Workspace Daily Logs",
22
+ "stepNumber": 4,
23
+ "maxLines": 40,
24
+ "strategy": "daily-logs",
25
+ "days": ["today", "yesterday"]
26
+ },
27
+ "fullHistory": {
28
+ "label": "Full History",
29
+ "stepNumber": 5,
30
+ "reminder": "Read on cold start: staff/Parker/Claude Code - Mini/documents/cc-full-history.md"
31
+ },
32
+ "context": {
33
+ "path": "~/.ldm/agents/cc-mini/CONTEXT.md",
34
+ "label": "CC CONTEXT.md",
35
+ "stepNumber": 6,
36
+ "critical": true
37
+ },
38
+ "soul": {
39
+ "path": "~/.ldm/agents/cc-mini/SOUL.md",
40
+ "label": "CC SOUL.md",
41
+ "stepNumber": 7
42
+ },
43
+ "ccJournals": {
44
+ "dir": "~/.ldm/agents/cc-mini/memory/journals",
45
+ "label": "Most Recent CC Journal",
46
+ "stepNumber": 8,
47
+ "maxLines": 80,
48
+ "strategy": "most-recent"
49
+ },
50
+ "ccDailyLog": {
51
+ "dir": "~/.ldm/agents/cc-mini/memory/daily",
52
+ "label": "CC Daily Log",
53
+ "stepNumber": 9,
54
+ "maxLines": 60,
55
+ "strategy": "daily-logs",
56
+ "days": ["today", "yesterday"]
57
+ },
58
+ "repoLocations": {
59
+ "path": "~/.claude/projects/-Users-lesa--openclaw/memory/repo-locations.md",
60
+ "label": "repo-locations.md",
61
+ "stepNumber": 10,
62
+ "critical": true
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ // LDM OS Boot Sequence Hook
3
+ // SessionStart hook for Claude Code.
4
+ // Reads boot files and injects them into context via additionalContext.
5
+ // Follows guard.mjs pattern: stdin JSON in, stdout JSON out, exit 0 always.
6
+
7
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
8
+ import { join, dirname, resolve } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const HOME = homedir();
14
+ const TAG = '[boot-hook]';
15
+
16
+ function resolvePath(p) {
17
+ if (p.startsWith('~/')) return join(HOME, p.slice(2));
18
+ return p;
19
+ }
20
+
21
+ function readFileSafe(filePath) {
22
+ try {
23
+ const resolved = resolvePath(filePath);
24
+ if (!existsSync(resolved)) return null;
25
+ return readFileSync(resolved, 'utf-8');
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function listDirSafe(dirPath) {
32
+ try {
33
+ const resolved = resolvePath(dirPath);
34
+ if (!existsSync(resolved)) return [];
35
+ return readdirSync(resolved).sort();
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ function truncateTop(content, maxLines) {
42
+ if (!maxLines || !content) return content;
43
+ const lines = content.split('\n');
44
+ if (lines.length <= maxLines) return content;
45
+ return lines.slice(0, maxLines).join('\n') + `\n[... truncated at ${maxLines} lines, ${lines.length} total ...]`;
46
+ }
47
+
48
+ function truncateBottom(content, maxLines) {
49
+ if (!maxLines || !content) return content;
50
+ const lines = content.split('\n');
51
+ if (lines.length <= maxLines) return content;
52
+ return `[... showing last ${maxLines} of ${lines.length} lines ...]\n` + lines.slice(-maxLines).join('\n');
53
+ }
54
+
55
+ function getTodayAndYesterday(timezone) {
56
+ const now = new Date();
57
+ const formatter = new Intl.DateTimeFormat('en-CA', {
58
+ timeZone: timezone,
59
+ year: 'numeric',
60
+ month: '2-digit',
61
+ day: '2-digit',
62
+ });
63
+ const today = formatter.format(now);
64
+
65
+ const yesterday = new Date(now);
66
+ yesterday.setDate(yesterday.getDate() - 1);
67
+ const yesterdayStr = formatter.format(yesterday);
68
+
69
+ return { today, yesterday: yesterdayStr };
70
+ }
71
+
72
+ function findMostRecent(dirPath) {
73
+ const files = listDirSafe(dirPath);
74
+ // Filter to .md files with date-like names, sort descending
75
+ const dated = files
76
+ .filter(f => f.endsWith('.md') && /^\d{4}-\d{2}-\d{2}/.test(f))
77
+ .sort()
78
+ .reverse();
79
+
80
+ if (dated.length > 0) return dated[0];
81
+
82
+ // Fallback: any .md file, most recent by name
83
+ const mds = files.filter(f => f.endsWith('.md')).sort().reverse();
84
+ return mds[0] || null;
85
+ }
86
+
87
+ function loadConfig() {
88
+ const configPath = join(__dirname, 'boot-config.json');
89
+ try {
90
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
91
+ } catch {
92
+ process.stderr.write(`${TAG} boot-config.json not found, using hardcoded defaults\n`);
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function getDefaultConfig() {
98
+ return {
99
+ agentId: 'cc-mini',
100
+ timezone: 'America/Los_Angeles',
101
+ maxTotalLines: 2000,
102
+ steps: {
103
+ sharedContext: { path: '~/.openclaw/workspace/SHARED-CONTEXT.md', label: 'SHARED-CONTEXT.md', stepNumber: 2, critical: true },
104
+ journals: { dir: '~/Documents/wipcomputer--mac-mini-01/staff/Parker/Claude Code - Mini/documents/journals', label: 'Most Recent Journal (Parker)', stepNumber: 3, maxLines: 80, strategy: 'most-recent' },
105
+ workspaceDailyLogs: { dir: '~/.openclaw/workspace/memory', label: 'Workspace Daily Logs', stepNumber: 4, maxLines: 40, strategy: 'daily-logs', days: ['today', 'yesterday'] },
106
+ fullHistory: { label: 'Full History', stepNumber: 5, reminder: 'Read on cold start: staff/Parker/Claude Code - Mini/documents/cc-full-history.md' },
107
+ context: { path: '~/.ldm/agents/cc-mini/CONTEXT.md', label: 'CC CONTEXT.md', stepNumber: 6, critical: true },
108
+ soul: { path: '~/.ldm/agents/cc-mini/SOUL.md', label: 'CC SOUL.md', stepNumber: 7 },
109
+ ccJournals: { dir: '~/.ldm/agents/cc-mini/memory/journals', label: 'Most Recent CC Journal', stepNumber: 8, maxLines: 80, strategy: 'most-recent' },
110
+ ccDailyLog: { dir: '~/.ldm/agents/cc-mini/memory/daily', label: 'CC Daily Log', stepNumber: 9, maxLines: 60, strategy: 'daily-logs', days: ['today', 'yesterday'] },
111
+ repoLocations: { path: '~/.claude/projects/-Users-lesa--openclaw/memory/repo-locations.md', label: 'repo-locations.md', stepNumber: 10, critical: true },
112
+ },
113
+ };
114
+ }
115
+
116
+ function processStep(key, step, dates) {
117
+ // Reminder-only step (e.g. full history)
118
+ if (step.reminder) {
119
+ return { content: step.reminder, loaded: true, fileName: null };
120
+ }
121
+
122
+ // Single file step
123
+ if (step.path) {
124
+ const content = readFileSafe(step.path);
125
+ if (!content) return { content: null, loaded: false, fileName: resolvePath(step.path) };
126
+ const trimmed = step.maxLines ? truncateTop(content, step.maxLines) : content;
127
+ return { content: trimmed, loaded: true, fileName: resolvePath(step.path) };
128
+ }
129
+
130
+ // Directory-based step
131
+ if (step.dir) {
132
+ if (step.strategy === 'most-recent') {
133
+ const fileName = findMostRecent(step.dir);
134
+ if (!fileName) return { content: null, loaded: false, fileName: resolvePath(step.dir) };
135
+ const fullPath = join(resolvePath(step.dir), fileName);
136
+ const content = readFileSafe(fullPath);
137
+ if (!content) return { content: null, loaded: false, fileName: fullPath };
138
+ const trimmed = step.maxLines ? truncateTop(content, step.maxLines) : content;
139
+ return { content: trimmed, loaded: true, fileName: `${fileName}` };
140
+ }
141
+
142
+ if (step.strategy === 'daily-logs') {
143
+ const parts = [];
144
+ let anyLoaded = false;
145
+ for (const day of (step.days || ['today'])) {
146
+ const dateStr = day === 'today' ? dates.today : dates.yesterday;
147
+ const fileName = `${dateStr}.md`;
148
+ const fullPath = join(resolvePath(step.dir), fileName);
149
+ const content = readFileSafe(fullPath);
150
+ if (content) {
151
+ const trimmed = step.maxLines ? truncateBottom(content, step.maxLines) : content;
152
+ parts.push(`--- ${day} (${dateStr}) ---\n${trimmed}`);
153
+ anyLoaded = true;
154
+ }
155
+ }
156
+ if (!anyLoaded) return { content: null, loaded: false, fileName: resolvePath(step.dir) };
157
+ return { content: parts.join('\n\n'), loaded: true, fileName: null };
158
+ }
159
+ }
160
+
161
+ return { content: null, loaded: false, fileName: null };
162
+ }
163
+
164
+ async function main() {
165
+ const startTime = Date.now();
166
+ let raw = '';
167
+ for await (const chunk of process.stdin) {
168
+ raw += chunk;
169
+ }
170
+
171
+ let input;
172
+ try {
173
+ input = JSON.parse(raw);
174
+ } catch {
175
+ process.exit(0);
176
+ }
177
+
178
+ const config = loadConfig() || getDefaultConfig();
179
+ const dates = getTodayAndYesterday(config.timezone || 'America/Los_Angeles');
180
+
181
+ const sections = [];
182
+ const loaded = [];
183
+ const skipped = [];
184
+ let totalLines = 0;
185
+
186
+ // Sort steps by stepNumber
187
+ const stepEntries = Object.entries(config.steps).sort(
188
+ ([, a], [, b]) => (a.stepNumber || 0) - (b.stepNumber || 0)
189
+ );
190
+
191
+ for (const [key, step] of stepEntries) {
192
+ const result = processStep(key, step, dates);
193
+
194
+ if (result.loaded && result.content) {
195
+ const criticalTag = step.critical ? ' (CRITICAL)' : '';
196
+ const fileTag = result.fileName ? `: ${result.fileName}` : '';
197
+ const header = `== [Step ${step.stepNumber}] ${step.label}${criticalTag}${fileTag} ==`;
198
+ sections.push(`${header}\n${result.content}`);
199
+ loaded.push(`Step ${step.stepNumber}: ${step.label}`);
200
+ totalLines += result.content.split('\n').length;
201
+ } else {
202
+ skipped.push(`Step ${step.stepNumber}: ${step.label}`);
203
+ if (result.fileName) {
204
+ process.stderr.write(`${TAG} skipped step ${step.stepNumber}: ${result.fileName} not found\n`);
205
+ }
206
+ }
207
+
208
+ // Safety cap
209
+ if (totalLines > (config.maxTotalLines || 2000)) {
210
+ process.stderr.write(`${TAG} hit line cap at ${totalLines} lines, stopping\n`);
211
+ break;
212
+ }
213
+ }
214
+
215
+ const elapsed = Date.now() - startTime;
216
+ const footer = `== Boot complete. Loaded ${loaded.length}/9 files in ${elapsed}ms. ==`;
217
+ if (skipped.length > 0) {
218
+ sections.push(`${footer}\nSkipped: ${skipped.join(', ')}`);
219
+ } else {
220
+ sections.push(footer);
221
+ }
222
+
223
+ const additionalContext =
224
+ `== LDM OS BOOT SEQUENCE (loaded automatically by SessionStart hook) ==\n\n` +
225
+ sections.join('\n\n');
226
+
227
+ const output = {
228
+ hookSpecificOutput: {
229
+ hookEventName: 'SessionStart',
230
+ additionalContext,
231
+ },
232
+ };
233
+
234
+ process.stdout.write(JSON.stringify(output));
235
+ process.stderr.write(`${TAG} loaded ${loaded.length}/9 files in ${elapsed}ms\n`);
236
+ process.exit(0);
237
+ }
238
+
239
+ main().catch((err) => {
240
+ process.stderr.write(`${TAG} fatal: ${err.message}\n`);
241
+ process.exit(0);
242
+ });
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // LDM OS Boot Hook Installer CLI
3
+ // Usage:
4
+ // node install-cli.mjs # install or update
5
+ // node install-cli.mjs --status # show current state
6
+ // node install-cli.mjs --dry-run # preview without changes
7
+
8
+ import { detectInstallState, runInstallOrUpdate, formatStatus, formatResult } from './installer.mjs';
9
+
10
+ const args = process.argv.slice(2);
11
+
12
+ if (args.includes('--status')) {
13
+ const state = detectInstallState();
14
+ console.log(formatStatus(state));
15
+ process.exit(0);
16
+ }
17
+
18
+ if (args.includes('--dry-run')) {
19
+ const result = runInstallOrUpdate({ dryRun: true });
20
+ console.log(formatResult(result));
21
+ process.exit(0);
22
+ }
23
+
24
+ // Run install/update
25
+ const result = runInstallOrUpdate();
26
+ console.log(formatResult(result));
27
+ process.exit(0);