marmot-logger 1.0.0 → 1.0.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/README.md CHANGED
@@ -1,170 +1,349 @@
1
1
  # Marmot
2
2
 
3
- Activity monitoring tool for developer workflows. Tracks file changes, terminal commands, git operations, and Claude Code hooks.
3
+ Activity monitoring and logging tool for developer workflows. Creates tamper-evident audit trails by tracking file changes, terminal commands, git operations, and Claude Code hooks with optional cryptographic signing.
4
4
 
5
- ## Installation
5
+ ## Project Structure
6
6
 
7
- ```bash
8
- npm install -g marmot-logger
7
+ ```
8
+ marmot/
9
+ ├── bin/
10
+ │ └── marmot.js # CLI entry point (Commander.js)
11
+ ├── src/
12
+ │ ├── index.js # Public API exports
13
+ │ ├── cli/ # Command handlers
14
+ │ │ ├── init.js # marmot init
15
+ │ │ ├── enable.js # marmot enable <plugin>
16
+ │ │ ├── disable.js # marmot disable <plugin>
17
+ │ │ ├── status.js # marmot status
18
+ │ │ ├── logs.js # marmot logs
19
+ │ │ ├── monitor.js # marmot monitor
20
+ │ │ ├── verify.js # marmot verify
21
+ │ │ ├── log.js # marmot log <event> (internal, used by hooks)
22
+ │ │ └── login.js # marmot login
23
+ │ ├── core/
24
+ │ │ ├── config.js # Config management (load/save/paths)
25
+ │ │ ├── logger.js # Log entry persistence
26
+ │ │ ├── signer.js # Remote signing service client
27
+ │ │ └── gitignore.js # .gitignore pattern parsing
28
+ │ └── plugins/
29
+ │ ├── index.js # Plugin registry
30
+ │ ├── file-monitor.js # File change detection via rsync + git diff
31
+ │ ├── terminal.js # Bash command logging via PROMPT_COMMAND
32
+ │ ├── git-hooks.js # Git hook integration
33
+ │ ├── claude-hooks.js # Claude Code IDE integration
34
+ │ └── makefile.js # Makefile target logging (manual setup)
35
+ ├── openapi.yaml # Signing service API specification
36
+ └── package.json
9
37
  ```
10
38
 
11
- Or use with npx:
39
+ ## Tech Stack
12
40
 
13
- ```bash
14
- npx marmot-logger init
15
- ```
41
+ - **Runtime**: Node.js >= 16.0.0
42
+ - **Dependencies**: `commander` (CLI), `chalk` (terminal colors)
43
+ - **External Tools**: `rsync`, `git` (for file-monitor plugin)
16
44
 
17
- ## Quick Start
45
+ ## Development Setup
18
46
 
19
47
  ```bash
20
- # Initialize marmot in your project
21
- marmot init
48
+ # Clone and install
49
+ git clone <repo>
50
+ cd marmot
51
+ npm install
22
52
 
23
- # Enable plugins
24
- marmot enable file-monitor # Track file changes
25
- marmot enable terminal # Log terminal commands
26
- marmot enable git-hooks # Log git operations
27
- marmot enable claude-hooks # Log Claude Code activity
53
+ # Link for local development
54
+ npm link
28
55
 
29
- # Check status
30
- marmot status
31
-
32
- # View recent logs
33
- marmot logs --last 20
56
+ # Now 'marmot' command is available globally
57
+ marmot --help
34
58
  ```
35
59
 
36
- ## Configuration
60
+ ## Architecture
37
61
 
38
- ### Environment Variables
62
+ ### Configuration Storage
39
63
 
40
- - `MARMOT_URL` - Signing service URL (default: `https://logging.drazou.net`)
41
- - `MARMOT_API_KEY` - API key for signing service (required)
64
+ Config is stored in `/tmp/marmot/<hash>/` where `<hash>` is MD5 of the absolute project path. This keeps marmot data out of the project directory.
42
65
 
43
- ```bash
44
- export MARMOT_API_KEY=your-api-key
66
+ ```
67
+ /tmp/marmot/<hash>/
68
+ ├── .marmotrc.json # Project config
69
+ ├── snapshot/ # File monitor snapshot (rsync copy)
70
+ └── terminal-hook.sh # Generated bash hook script
45
71
  ```
46
72
 
47
- ### Project Configuration
48
-
49
- Marmot creates `.marmotrc.json` in your project root:
73
+ Logs are written to `./marmot-logs/` in the project directory.
74
+
75
+ ### Config Resolution Priority
76
+
77
+ 1. Environment variables (`MARMOT_API_KEY`, `MARMOT_URL`)
78
+ 2. `.env` file in project root
79
+ 3. Cached values in `.marmotrc.json`
80
+ 4. Defaults (`https://logging.drazou.net`, `./marmot-logs/`)
81
+
82
+ ### Core Modules
83
+
84
+ **[config.js](src/core/config.js)** - Configuration management
85
+ - `loadConfig(projectDir)` / `saveConfig(config, projectDir)` - JSON persistence
86
+ - `getSigningUrl(projectDir)` / `getApiKey(projectDir)` - Multi-source resolution
87
+ - `getMarmotDir(projectDir)` - Returns `/tmp/marmot/<hash>`
88
+ - `enablePlugin(config, name)` / `disablePlugin(config, name)` - Toggle plugins
89
+
90
+ **[logger.js](src/core/logger.js)** - Activity logging
91
+ - `logEvent(eventType, path, size, extra, projectDir)` - Main logging API
92
+ - `readLogs(logFile, options)` - Parse JSON Lines log files
93
+ - `verifyLogs(logFile, projectDir)` - Verify signatures with backend
94
+ - Logs to `./marmot-logs/file_events_YYYY-MM-DD.log` (JSON Lines format)
95
+ - Falls back to unsigned entries if signing service unavailable
96
+
97
+ **[signer.js](src/core/signer.js)** - Remote signing client
98
+ - `getToken(projectDir)` / `refreshToken(projectDir)` - Token management
99
+ - `sign(entry, projectDir)` - Sign entry with backend service
100
+ - `verify(entry, projectDir)` - Verify entry signature
101
+ - `healthCheck(projectDir)` - Service connectivity check
102
+ - 10-second request timeout, automatic token refresh on 401
103
+
104
+ **[gitignore.js](src/core/gitignore.js)** - Exclusion patterns
105
+ - `parseGitignore(projectDir)` - Parse .gitignore file
106
+ - `shouldExclude(filePath, projectDir)` - Check if path matches exclusions
107
+ - `buildRsyncExcludes(projectDir)` - Generate rsync --exclude args
108
+ - Always excludes: `.git`, `logs/`
109
+
110
+ ### Plugin System
111
+
112
+ All plugins export `enable(projectConfig)` and `disable(projectConfig)` functions.
113
+
114
+ **[file-monitor.js](src/plugins/file-monitor.js)**
115
+ - Uses rsync to create snapshot, git diff to detect changes
116
+ - Installs cron job: `* * * * * cd <project> && marmot monitor`
117
+ - Events: `created`, `modified`, `deleted`, `monitor_initialized`
118
+ - Modified events include `additions` and `deletions` line counts
119
+
120
+ **[terminal.js](src/plugins/terminal.js)**
121
+ - Generates bash hook using `PROMPT_COMMAND`
122
+ - Adds source line to `~/.bashrc`
123
+ - De-duplicates consecutive identical commands
124
+ - Events: `terminal` with `command` field
125
+
126
+ **[git-hooks.js](src/plugins/git-hooks.js)**
127
+ - Installs hooks in `.git/hooks/`: `post-commit`, `pre-push`, `post-checkout`, `post-merge`
128
+ - Can coexist with existing hooks (appends marmot commands)
129
+ - Events: `git_commit`, `git_push`, `git_checkout`, `git_merge`
130
+
131
+ **[claude-hooks.js](src/plugins/claude-hooks.js)**
132
+ - Configures hooks in `.claude/settings.local.json`
133
+ - Events: `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `UserPromptSubmit`, `SessionStart`, `SessionEnd`
134
+ - Logged as `claude_hook_<EventType>`
135
+
136
+ **[makefile.js](src/plugins/makefile.js)**
137
+ - Non-intrusive: only prints manual setup instructions
138
+ - Users add logging commands to their Makefile
139
+ - Events: `make_command`
140
+
141
+ ### CLI Commands
142
+
143
+ | Command | Handler | Description |
144
+ |---------|---------|-------------|
145
+ | `marmot init` | [init.js](src/cli/init.js) | Create config, log directory, update .gitignore |
146
+ | `marmot enable <plugin>` | [enable.js](src/cli/enable.js) | Enable plugin and run setup |
147
+ | `marmot disable <plugin>` | [disable.js](src/cli/disable.js) | Disable plugin and cleanup |
148
+ | `marmot status` | [status.js](src/cli/status.js) | Show config, plugin status, signing health |
149
+ | `marmot logs [--today] [--last N]` | [logs.js](src/cli/logs.js) | Display log entries with color coding |
150
+ | `marmot monitor` | [monitor.js](src/cli/monitor.js) | Run file monitor once (for cron) |
151
+ | `marmot verify [--file PATH]` | [verify.js](src/cli/verify.js) | Verify log signatures |
152
+ | `marmot log <event> [-d JSON]` | [log.js](src/cli/log.js) | Log event (used internally by hooks) |
153
+ | `marmot login` | [login.js](src/cli/login.js) | Set API key interactively |
154
+
155
+ ### Log Format
156
+
157
+ JSON Lines format - one JSON object per line:
50
158
 
51
159
  ```json
52
- {
53
- "logDir": "./logs",
54
- "snapshotDir": "./.marmot-snapshot",
55
- "plugins": {
56
- "file-monitor": {
57
- "enabled": true,
58
- "exclude": [".git", "node_modules", ".marmot-snapshot"]
59
- },
60
- "terminal": { "enabled": true },
61
- "git-hooks": { "enabled": true },
62
- "claude-hooks": { "enabled": true }
63
- }
64
- }
160
+ {"timestamp":"2025-12-22T14:30:00Z","uuid":"550e8400-e29b-41d4-a716-446655440000","event":"modified","path":"/project/src/index.js","size":1234,"additions":10,"deletions":5,"signed":true}
161
+ {"timestamp":"2025-12-22T14:31:00Z","uuid":"...","event":"terminal","path":"/project","command":"npm test","size":0,"signed":true}
162
+ {"timestamp":"2025-12-22T14:32:00Z","uuid":"...","event":"git_commit","path":"abc1234: Fix bug","size":0,"signed":true}
65
163
  ```
66
164
 
67
- ## Plugins
165
+ Fields added by signing service: `timestamp`, `uuid`, `signed: true`
68
166
 
69
- ### File Monitor
167
+ ### Signing Service API
70
168
 
71
- Detects file changes using git diff against a snapshot.
169
+ See [openapi.yaml](openapi.yaml) for full specification. Endpoints:
72
170
 
73
- ```bash
74
- marmot enable file-monitor
171
+ | Endpoint | Method | Auth | Description |
172
+ |----------|--------|------|-------------|
173
+ | `/token` | POST | None | Exchange API key for bearer token |
174
+ | `/sign` | POST | Bearer | Sign a log entry, returns entry with uuid/timestamp |
175
+ | `/verify` | POST | Bearer | Verify a signed entry |
176
+ | `/health` | GET | None | Service health check |
75
177
 
76
- # Run manually or via cron
77
- marmot monitor
178
+ ## Programmatic API
78
179
 
79
- # Add to crontab for every minute:
80
- * * * * * cd /path/to/project && npx marmot monitor >> ./logs/cron.log 2>&1
81
- ```
180
+ ```javascript
181
+ const marmot = require('marmot-logger');
82
182
 
83
- ### Terminal
183
+ // Configuration
184
+ const config = marmot.loadConfig();
185
+ marmot.saveConfig(config);
186
+ marmot.createDefaultConfig();
84
187
 
85
- Logs terminal commands executed in the project directory.
188
+ // Logging
189
+ await marmot.log({
190
+ event: 'custom_event',
191
+ path: '/path/to/file',
192
+ size: 1234,
193
+ metadata: { custom: 'data' }
194
+ });
86
195
 
87
- ```bash
88
- marmot enable terminal
196
+ const entries = marmot.readLogs('./marmot-logs/file_events_2025-12-22.log');
197
+ const results = await marmot.verifyLogs('./marmot-logs/file_events_2025-12-22.log');
89
198
 
90
- # Add to ~/.bashrc:
91
- source "/path/to/project/.marmot/terminal-hook.sh"
199
+ // Signing
200
+ const signed = await marmot.sign(entry);
201
+ const result = await marmot.verify(entry);
202
+ const health = await marmot.healthCheck();
92
203
  ```
93
204
 
94
- ### Git Hooks
205
+ ## Environment Variables
95
206
 
96
- Logs git operations (commit, push, checkout, merge).
207
+ | Variable | Description | Default |
208
+ |----------|-------------|---------|
209
+ | `MARMOT_API_KEY` | API key for signing service | (required for signing) |
210
+ | `MARMOT_URL` | Signing service URL | `https://logging.drazou.net` |
97
211
 
98
- ```bash
99
- marmot enable git-hooks
212
+ ## Default Config
213
+
214
+ ```json
215
+ {
216
+ "logDir": "./marmot-logs",
217
+ "bearerToken": "broken-bearer",
218
+ "plugins": {
219
+ "file-monitor": { "enabled": false },
220
+ "terminal": { "enabled": false },
221
+ "git-hooks": {
222
+ "enabled": false,
223
+ "events": ["commit", "push", "checkout", "merge"]
224
+ },
225
+ "makefile": { "enabled": false },
226
+ "claude-hooks": {
227
+ "enabled": false,
228
+ "events": ["PreToolUse", "PostToolUse", "Stop", "SubagentStop", "UserPromptSubmit", "SessionStart", "SessionEnd"]
229
+ }
230
+ }
231
+ }
100
232
  ```
101
233
 
102
- Hooks are automatically installed in `.git/hooks/`.
234
+ ## Adding a New Plugin
103
235
 
104
- ### Claude Hooks
236
+ 1. Create `src/plugins/my-plugin.js`:
105
237
 
106
- Logs Claude Code IDE activity.
238
+ ```javascript
239
+ const chalk = require('chalk');
107
240
 
108
- ```bash
109
- marmot enable claude-hooks
110
- ```
241
+ async function enable(projectConfig) {
242
+ const projectDir = process.cwd();
243
+ // Setup logic: install hooks, cron jobs, etc.
244
+ console.log(chalk.bold('My Plugin Setup:'));
245
+ console.log(' Plugin enabled successfully');
246
+ }
111
247
 
112
- Hooks are configured in `.claude/settings.local.json`.
248
+ async function disable(projectConfig) {
249
+ // Cleanup logic: remove hooks, cron jobs, etc.
250
+ console.log(chalk.bold('My Plugin Disabled'));
251
+ }
113
252
 
114
- ## CLI Commands
253
+ module.exports = { enable, disable };
254
+ ```
115
255
 
116
- | Command | Description |
117
- |---------|-------------|
118
- | `marmot init` | Initialize marmot in current project |
119
- | `marmot enable <plugin>` | Enable a plugin |
120
- | `marmot disable <plugin>` | Disable a plugin |
121
- | `marmot status` | Show status and enabled plugins |
122
- | `marmot logs` | View recent log entries |
123
- | `marmot logs --today` | View today's logs |
124
- | `marmot logs --last N` | View last N entries |
125
- | `marmot verify` | Verify log signatures |
126
- | `marmot monitor` | Run file monitor once |
256
+ 2. Register in [src/plugins/index.js](src/plugins/index.js):
127
257
 
128
- ## Log Format
258
+ ```javascript
259
+ module.exports = {
260
+ // ... existing plugins
261
+ 'my-plugin': require('./my-plugin')
262
+ };
263
+ ```
129
264
 
130
- Logs are stored as JSON Lines in `./logs/file_events_YYYY-MM-DD.log`:
265
+ 3. Add default config in [src/core/config.js](src/core/config.js) `DEFAULT_CONFIG.plugins`:
131
266
 
132
- ```json
133
- {"timestamp":"2025-12-22T14:30:00Z","uuid":"...","event":"modified","path":"/project/src/index.js","size":1234,"additions":10,"deletions":5,"signed":true}
134
- {"timestamp":"2025-12-22T14:31:00Z","uuid":"...","event":"terminal","path":"/project","command":"npm test","size":0,"signed":true}
135
- {"timestamp":"2025-12-22T14:32:00Z","uuid":"...","event":"git_commit","path":"abc1234: Fix bug","size":0,"signed":true}
267
+ ```javascript
268
+ 'my-plugin': {
269
+ enabled: false,
270
+ // plugin-specific options
271
+ }
136
272
  ```
137
273
 
138
- ## Event Types
274
+ 4. Update CLI help in [bin/marmot.js](bin/marmot.js) (enable command description).
139
275
 
140
- | Event | Description |
141
- |-------|-------------|
142
- | `created` | File created |
143
- | `modified` | File modified (includes additions/deletions) |
144
- | `deleted` | File deleted |
145
- | `terminal` | Terminal command executed |
146
- | `git_commit` | Git commit made |
147
- | `git_push` | Git push executed |
148
- | `git_checkout` | Git branch checkout |
149
- | `git_merge` | Git merge completed |
150
- | `make_command` | Make target executed |
151
- | `claude_hook_*` | Claude Code hook events |
276
+ ## Adding a New CLI Command
152
277
 
153
- ## API
278
+ 1. Create handler in `src/cli/my-command.js`:
154
279
 
155
280
  ```javascript
156
- const marmot = require('marmot-logger');
281
+ const chalk = require('chalk');
282
+ const config = require('../core/config');
283
+
284
+ module.exports = async function myCommand(options) {
285
+ const projectConfig = config.loadConfig();
286
+ if (!projectConfig) {
287
+ console.log(chalk.red('Marmot not initialized. Run: marmot init'));
288
+ process.exit(1);
289
+ }
290
+ // Command logic
291
+ };
292
+ ```
157
293
 
158
- // Log an event programmatically
159
- await marmot.log({
160
- event: 'custom_event',
161
- path: '/path/to/file',
162
- size: 1234,
163
- metadata: { custom: 'data' }
164
- });
294
+ 2. Register in [bin/marmot.js](bin/marmot.js):
295
+
296
+ ```javascript
297
+ const myCommand = require('../src/cli/my-command');
165
298
 
166
- // Verify logs
167
- const results = await marmot.verifyLogs('./logs/file_events_2025-12-22.log');
299
+ program
300
+ .command('my-command')
301
+ .description('Description of my command')
302
+ .option('-f, --flag', 'Option description')
303
+ .action(myCommand);
304
+ ```
305
+
306
+ ## Event Types Reference
307
+
308
+ | Event | Source | Extra Fields |
309
+ |-------|--------|--------------|
310
+ | `created` | file-monitor | - |
311
+ | `modified` | file-monitor | `additions`, `deletions` |
312
+ | `deleted` | file-monitor | - |
313
+ | `monitor_initialized` | file-monitor | - |
314
+ | `terminal` | terminal | `command` |
315
+ | `git_commit` | git-hooks | - |
316
+ | `git_push` | git-hooks | - |
317
+ | `git_checkout` | git-hooks | - |
318
+ | `git_merge` | git-hooks | - |
319
+ | `make_command` | makefile | - |
320
+ | `claude_hook_PreToolUse` | claude-hooks | tool details in path |
321
+ | `claude_hook_PostToolUse` | claude-hooks | tool details in path |
322
+ | `claude_hook_Stop` | claude-hooks | stop reason in path |
323
+ | `claude_hook_SubagentStop` | claude-hooks | stop reason in path |
324
+ | `claude_hook_UserPromptSubmit` | claude-hooks | prompt preview in path |
325
+ | `claude_hook_SessionStart` | claude-hooks | session ID in path |
326
+ | `claude_hook_SessionEnd` | claude-hooks | session ID in path |
327
+
328
+ ## Data Flow
329
+
330
+ ```
331
+ Plugin detects activity
332
+
333
+
334
+ logger.logEvent(type, path, size, extra)
335
+
336
+
337
+ signer.sign(entry) ──────► Signing Service
338
+ │ │
339
+ │ (fallback if unavailable) │
340
+ ▼ ▼
341
+ Unsigned entry Signed entry with uuid/timestamp
342
+ │ │
343
+ └───────────┬───────────────┘
344
+
345
+
346
+ Append to ./marmot-logs/file_events_YYYY-MM-DD.log
168
347
  ```
169
348
 
170
349
  ## License
package/bin/marmot.js CHANGED
@@ -11,6 +11,7 @@ const status = require('../src/cli/status');
11
11
  const verify = require('../src/cli/verify');
12
12
  const logs = require('../src/cli/logs');
13
13
  const monitor = require('../src/cli/monitor');
14
+ const processMonitorCmd = require('../src/cli/process-monitor');
14
15
  const log = require('../src/cli/log');
15
16
  const login = require('../src/cli/login');
16
17
 
@@ -26,7 +27,7 @@ program
26
27
 
27
28
  program
28
29
  .command('enable <plugin>')
29
- .description('Enable a plugin (file-monitor, terminal, git-hooks, makefile, claude-hooks)')
30
+ .description('Enable a plugin (file-monitor, terminal, git-hooks, makefile, claude-hooks, process-monitor)')
30
31
  .action(enable);
31
32
 
32
33
  program
@@ -57,6 +58,11 @@ program
57
58
  .description('Run file monitor once (for cron/scripts)')
58
59
  .action(monitor);
59
60
 
61
+ program
62
+ .command('process-monitor')
63
+ .description('Run process monitor once (for cron/scripts)')
64
+ .action(processMonitorCmd);
65
+
60
66
  program
61
67
  .command('log <event>')
62
68
  .description('Log an event (used by hooks)')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "marmot-logger",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Activity monitoring tool for developer workflows - tracks file changes, terminal commands, git operations, and Claude Code hooks",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -2,7 +2,7 @@ const chalk = require('chalk');
2
2
  const config = require('../core/config');
3
3
  const plugins = require('../plugins');
4
4
 
5
- const VALID_PLUGINS = ['file-monitor', 'terminal', 'git-hooks', 'makefile', 'claude-hooks'];
5
+ const VALID_PLUGINS = ['file-monitor', 'terminal', 'git-hooks', 'makefile', 'claude-hooks', 'process-monitor'];
6
6
 
7
7
  module.exports = async function disable(pluginName) {
8
8
  if (!VALID_PLUGINS.includes(pluginName)) {
package/src/cli/enable.js CHANGED
@@ -2,7 +2,7 @@ const chalk = require('chalk');
2
2
  const config = require('../core/config');
3
3
  const plugins = require('../plugins');
4
4
 
5
- const VALID_PLUGINS = ['file-monitor', 'terminal', 'git-hooks', 'makefile', 'claude-hooks'];
5
+ const VALID_PLUGINS = ['file-monitor', 'terminal', 'git-hooks', 'makefile', 'claude-hooks', 'process-monitor'];
6
6
 
7
7
  module.exports = async function enable(pluginName) {
8
8
  if (!VALID_PLUGINS.includes(pluginName)) {
@@ -0,0 +1,32 @@
1
+ const chalk = require('chalk');
2
+ const config = require('../core/config');
3
+ const processMonitor = require('../plugins/process-monitor');
4
+
5
+ module.exports = async function processMonitorCommand() {
6
+ const projectConfig = config.loadConfig();
7
+
8
+ if (!projectConfig) {
9
+ console.log(chalk.red('Marmot not initialized. Run: marmot init'));
10
+ process.exit(1);
11
+ }
12
+
13
+ if (!config.isPluginEnabled(projectConfig, 'process-monitor')) {
14
+ console.log(chalk.yellow('Process monitor plugin is not enabled.'));
15
+ console.log('Run:', chalk.cyan('marmot enable process-monitor'));
16
+ process.exit(1);
17
+ }
18
+
19
+ try {
20
+ const changes = await processMonitor.run(projectConfig);
21
+
22
+ if (changes === 0) {
23
+ // No changes, silent exit for cron usage
24
+ process.exit(0);
25
+ }
26
+
27
+ console.log(chalk.green(`Detected ${changes} process change(s)`));
28
+ } catch (err) {
29
+ console.error(chalk.red(`Process monitor error: ${err.message}`));
30
+ process.exit(1);
31
+ }
32
+ };
@@ -25,6 +25,9 @@ const DEFAULT_CONFIG = {
25
25
  'claude-hooks': {
26
26
  enabled: false,
27
27
  events: ['PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop', 'UserPromptSubmit', 'SessionStart', 'SessionEnd']
28
+ },
29
+ 'process-monitor': {
30
+ enabled: false
28
31
  }
29
32
  }
30
33
  };
@@ -3,5 +3,6 @@ module.exports = {
3
3
  'terminal': require('./terminal'),
4
4
  'git-hooks': require('./git-hooks'),
5
5
  'makefile': require('./makefile'),
6
- 'claude-hooks': require('./claude-hooks')
6
+ 'claude-hooks': require('./claude-hooks'),
7
+ 'process-monitor': require('./process-monitor')
7
8
  };
@@ -0,0 +1,303 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+ const chalk = require('chalk');
5
+ const config = require('../core/config');
6
+ const logger = require('../core/logger');
7
+
8
+ const MARMOT_CRON_MARKER = '# marmot-process-monitor';
9
+ const SNAPSHOT_FILENAME = 'process-snapshot.json';
10
+
11
+ function getCrontab() {
12
+ try {
13
+ return execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
14
+ } catch (err) {
15
+ return '';
16
+ }
17
+ }
18
+
19
+ function setCrontab(content) {
20
+ try {
21
+ execSync(`echo "${content.replace(/"/g, '\\"')}" | crontab -`, { encoding: 'utf8' });
22
+ return true;
23
+ } catch (err) {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function getProcessSnapshotPath(projectDir) {
29
+ const marmotDir = config.getMarmotDir(projectDir);
30
+ return path.join(marmotDir, SNAPSHOT_FILENAME);
31
+ }
32
+
33
+ function getProcessInfo(pid) {
34
+ try {
35
+ const procDir = `/proc/${pid}`;
36
+
37
+ // Read cwd (working directory)
38
+ let cwd = null;
39
+ try {
40
+ cwd = fs.readlinkSync(path.join(procDir, 'cwd'));
41
+ } catch (e) {
42
+ // Permission denied or process ended
43
+ return null;
44
+ }
45
+
46
+ // Read cmdline (command)
47
+ let cmdline = '';
48
+ try {
49
+ const cmdlineRaw = fs.readFileSync(path.join(procDir, 'cmdline'), 'utf8');
50
+ cmdline = cmdlineRaw.split('\0').filter(s => s).join(' ');
51
+ } catch (e) {
52
+ cmdline = '[unknown]';
53
+ }
54
+
55
+ // Read stat for ppid and starttime
56
+ let ppid = null;
57
+ let starttime = null;
58
+ try {
59
+ const stat = fs.readFileSync(path.join(procDir, 'stat'), 'utf8');
60
+ // stat format: pid (comm) state ppid ... field 22 is starttime
61
+ // Handle comm containing spaces/parens by finding last )
62
+ const lastParen = stat.lastIndexOf(')');
63
+ if (lastParen !== -1) {
64
+ const afterComm = stat.slice(lastParen + 2); // skip ') '
65
+ const fields = afterComm.split(/\s+/);
66
+ // fields[0] = state, fields[1] = ppid, ..., fields[19] = starttime
67
+ ppid = parseInt(fields[1]) || null;
68
+ starttime = parseInt(fields[19]) || null;
69
+ }
70
+ } catch (e) {
71
+ // Ignore stat errors
72
+ }
73
+
74
+ return {
75
+ pid,
76
+ cwd,
77
+ cmdline,
78
+ ppid,
79
+ starttime,
80
+ key: `${pid}-${starttime || 0}`
81
+ };
82
+ } catch (e) {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function getProjectProcesses(projectDir) {
88
+ const processes = {};
89
+ const resolvedProjectDir = path.resolve(projectDir);
90
+
91
+ try {
92
+ const entries = fs.readdirSync('/proc');
93
+
94
+ for (const entry of entries) {
95
+ if (!/^\d+$/.test(entry)) continue;
96
+
97
+ const pid = parseInt(entry);
98
+ const info = getProcessInfo(pid);
99
+
100
+ if (!info || !info.cwd) continue;
101
+
102
+ // Check if cwd is within project directory
103
+ if (info.cwd === resolvedProjectDir ||
104
+ info.cwd.startsWith(resolvedProjectDir + '/')) {
105
+ processes[info.key] = {
106
+ pid: info.pid,
107
+ cwd: info.cwd,
108
+ cmdline: info.cmdline,
109
+ ppid: info.ppid,
110
+ starttime: info.starttime,
111
+ key: info.key,
112
+ firstSeen: Date.now()
113
+ };
114
+ }
115
+ }
116
+ } catch (e) {
117
+ // /proc read error
118
+ }
119
+
120
+ return processes;
121
+ }
122
+
123
+ function loadSnapshot(projectDir) {
124
+ const snapshotPath = getProcessSnapshotPath(projectDir);
125
+
126
+ if (!fs.existsSync(snapshotPath)) {
127
+ return null;
128
+ }
129
+
130
+ try {
131
+ const content = fs.readFileSync(snapshotPath, 'utf8');
132
+ return JSON.parse(content);
133
+ } catch (e) {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function saveSnapshot(snapshot, projectDir) {
139
+ const snapshotPath = getProcessSnapshotPath(projectDir);
140
+ const marmotDir = config.getMarmotDir(projectDir);
141
+
142
+ if (!fs.existsSync(marmotDir)) {
143
+ fs.mkdirSync(marmotDir, { recursive: true });
144
+ }
145
+
146
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
147
+ }
148
+
149
+ async function run(projectConfig, projectDir = process.cwd()) {
150
+ const logDir = config.getLogDir(projectConfig, projectDir);
151
+
152
+ // Ensure log directory exists
153
+ if (!fs.existsSync(logDir)) {
154
+ fs.mkdirSync(logDir, { recursive: true });
155
+ }
156
+
157
+ // Get current processes in project directory
158
+ const currentProcesses = getProjectProcesses(projectDir);
159
+
160
+ // Load previous snapshot
161
+ const previousSnapshot = loadSnapshot(projectDir);
162
+
163
+ // First run: initialize snapshot
164
+ if (!previousSnapshot) {
165
+ const snapshot = {
166
+ processes: currentProcesses,
167
+ lastRun: Date.now()
168
+ };
169
+ saveSnapshot(snapshot, projectDir);
170
+ await logger.logEvent('process_monitor_initialized', projectDir, 0, {
171
+ processCount: Object.keys(currentProcesses).length
172
+ }, projectDir);
173
+ return 1;
174
+ }
175
+
176
+ const previousProcesses = previousSnapshot.processes || {};
177
+ let changesFound = 0;
178
+
179
+ // Detect new processes (process_started)
180
+ for (const key of Object.keys(currentProcesses)) {
181
+ if (!previousProcesses[key]) {
182
+ const proc = currentProcesses[key];
183
+ changesFound++;
184
+ await logger.logEvent('process_started', proc.cwd, 0, {
185
+ pid: proc.pid,
186
+ cmdline: proc.cmdline,
187
+ ppid: proc.ppid
188
+ }, projectDir);
189
+ currentProcesses[key].firstSeen = Date.now();
190
+ } else {
191
+ // Preserve firstSeen from previous snapshot
192
+ currentProcesses[key].firstSeen = previousProcesses[key].firstSeen;
193
+ }
194
+ }
195
+
196
+ // Detect ended processes (process_ended)
197
+ for (const key of Object.keys(previousProcesses)) {
198
+ if (!currentProcesses[key]) {
199
+ const proc = previousProcesses[key];
200
+ changesFound++;
201
+
202
+ // Calculate duration in seconds
203
+ const duration = Math.round((Date.now() - (proc.firstSeen || previousSnapshot.lastRun)) / 1000);
204
+
205
+ await logger.logEvent('process_ended', proc.cwd, 0, {
206
+ pid: proc.pid,
207
+ cmdline: proc.cmdline,
208
+ ppid: proc.ppid,
209
+ duration
210
+ }, projectDir);
211
+ }
212
+ }
213
+
214
+ // Update snapshot
215
+ const newSnapshot = {
216
+ processes: currentProcesses,
217
+ lastRun: Date.now()
218
+ };
219
+ saveSnapshot(newSnapshot, projectDir);
220
+
221
+ return changesFound;
222
+ }
223
+
224
+ async function enable(projectConfig) {
225
+ const projectDir = process.cwd();
226
+ const marmotDir = config.getMarmotDir(projectDir);
227
+ const logDir = config.getLogDir(projectConfig, projectDir);
228
+ const snapshotPath = getProcessSnapshotPath(projectDir);
229
+
230
+ console.log('');
231
+ console.log(chalk.bold('Process Monitor Plugin Setup:'));
232
+ console.log(` Marmot directory: ${chalk.cyan(marmotDir)}`);
233
+ console.log(` Snapshot file: ${chalk.cyan(snapshotPath)}`);
234
+ console.log(` Tracking: ${chalk.cyan('Processes with cwd in project directory')}`);
235
+
236
+ // Install cron job
237
+ const cronJob = `* * * * * cd "${projectDir}" && marmot process-monitor >> "${logDir}/process-cron.log" 2>&1 ${MARMOT_CRON_MARKER}`;
238
+ const currentCrontab = getCrontab();
239
+
240
+ if (currentCrontab.includes(projectDir) && currentCrontab.includes('marmot process-monitor')) {
241
+ console.log(` Cron job: ${chalk.yellow('Already installed')}`);
242
+ } else {
243
+ const newCrontab = currentCrontab.trim() + '\n' + cronJob + '\n';
244
+ if (setCrontab(newCrontab)) {
245
+ console.log(` Cron job: ${chalk.green('Installed (runs every minute)')}`);
246
+ } else {
247
+ console.log(` Cron job: ${chalk.red('Failed to install')}`);
248
+ console.log('');
249
+ console.log('To add manually:');
250
+ console.log(chalk.cyan(` crontab -e`));
251
+ console.log('Then add:');
252
+ console.log(chalk.cyan(` ${cronJob}`));
253
+ }
254
+ }
255
+
256
+ console.log('');
257
+ console.log('Or run manually with:');
258
+ console.log(chalk.cyan(' marmot process-monitor'));
259
+ }
260
+
261
+ async function disable(projectConfig) {
262
+ const projectDir = process.cwd();
263
+ const snapshotPath = getProcessSnapshotPath(projectDir);
264
+
265
+ console.log('');
266
+ console.log(chalk.bold('Process Monitor Plugin Disabled:'));
267
+
268
+ // Remove cron job
269
+ const currentCrontab = getCrontab();
270
+ if (currentCrontab.includes(projectDir) && currentCrontab.includes('marmot process-monitor')) {
271
+ const lines = currentCrontab.split('\n');
272
+ const filteredLines = lines.filter(line => {
273
+ if (line.includes(projectDir) && line.includes('marmot process-monitor')) return false;
274
+ if (line.includes(MARMOT_CRON_MARKER)) return false;
275
+ return true;
276
+ });
277
+
278
+ const newCrontab = filteredLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
279
+
280
+ if (setCrontab(newCrontab + '\n')) {
281
+ console.log(` Cron job: ${chalk.green('Removed')}`);
282
+ } else {
283
+ console.log(` Cron job: ${chalk.red('Failed to remove')}`);
284
+ console.log(' Remove manually with: crontab -e');
285
+ }
286
+ } else {
287
+ console.log(` Cron job: ${chalk.gray('Not found')}`);
288
+ }
289
+
290
+ // Keep snapshot but inform user
291
+ if (fs.existsSync(snapshotPath)) {
292
+ console.log(` Snapshot: ${chalk.cyan(snapshotPath)} (preserved)`);
293
+ console.log(' To remove snapshot: rm ' + snapshotPath);
294
+ }
295
+ }
296
+
297
+ module.exports = {
298
+ run,
299
+ enable,
300
+ disable,
301
+ getProjectProcesses,
302
+ getProcessInfo
303
+ };