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 +291 -112
- package/bin/marmot.js +7 -1
- package/package.json +1 -1
- package/src/cli/disable.js +1 -1
- package/src/cli/enable.js +1 -1
- package/src/cli/process-monitor.js +32 -0
- package/src/core/config.js +3 -0
- package/src/plugins/index.js +2 -1
- package/src/plugins/process-monitor.js +303 -0
package/README.md
CHANGED
|
@@ -1,170 +1,349 @@
|
|
|
1
1
|
# Marmot
|
|
2
2
|
|
|
3
|
-
Activity monitoring tool for developer workflows.
|
|
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
|
-
##
|
|
5
|
+
## Project Structure
|
|
6
6
|
|
|
7
|
-
```
|
|
8
|
-
|
|
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
|
-
|
|
39
|
+
## Tech Stack
|
|
12
40
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
##
|
|
45
|
+
## Development Setup
|
|
18
46
|
|
|
19
47
|
```bash
|
|
20
|
-
#
|
|
21
|
-
|
|
48
|
+
# Clone and install
|
|
49
|
+
git clone <repo>
|
|
50
|
+
cd marmot
|
|
51
|
+
npm install
|
|
22
52
|
|
|
23
|
-
#
|
|
24
|
-
|
|
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
|
-
#
|
|
30
|
-
marmot
|
|
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
|
-
##
|
|
60
|
+
## Architecture
|
|
37
61
|
|
|
38
|
-
###
|
|
62
|
+
### Configuration Storage
|
|
39
63
|
|
|
40
|
-
|
|
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
|
-
```
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
165
|
+
Fields added by signing service: `timestamp`, `uuid`, `signed: true`
|
|
68
166
|
|
|
69
|
-
###
|
|
167
|
+
### Signing Service API
|
|
70
168
|
|
|
71
|
-
|
|
169
|
+
See [openapi.yaml](openapi.yaml) for full specification. Endpoints:
|
|
72
170
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
marmot monitor
|
|
178
|
+
## Programmatic API
|
|
78
179
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
```
|
|
180
|
+
```javascript
|
|
181
|
+
const marmot = require('marmot-logger');
|
|
82
182
|
|
|
83
|
-
|
|
183
|
+
// Configuration
|
|
184
|
+
const config = marmot.loadConfig();
|
|
185
|
+
marmot.saveConfig(config);
|
|
186
|
+
marmot.createDefaultConfig();
|
|
84
187
|
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
205
|
+
## Environment Variables
|
|
95
206
|
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
234
|
+
## Adding a New Plugin
|
|
103
235
|
|
|
104
|
-
|
|
236
|
+
1. Create `src/plugins/my-plugin.js`:
|
|
105
237
|
|
|
106
|
-
|
|
238
|
+
```javascript
|
|
239
|
+
const chalk = require('chalk');
|
|
107
240
|
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
+
module.exports = { enable, disable };
|
|
254
|
+
```
|
|
115
255
|
|
|
116
|
-
|
|
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
|
-
|
|
258
|
+
```javascript
|
|
259
|
+
module.exports = {
|
|
260
|
+
// ... existing plugins
|
|
261
|
+
'my-plugin': require('./my-plugin')
|
|
262
|
+
};
|
|
263
|
+
```
|
|
129
264
|
|
|
130
|
-
|
|
265
|
+
3. Add default config in [src/core/config.js](src/core/config.js) `DEFAULT_CONFIG.plugins`:
|
|
131
266
|
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
267
|
+
```javascript
|
|
268
|
+
'my-plugin': {
|
|
269
|
+
enabled: false,
|
|
270
|
+
// plugin-specific options
|
|
271
|
+
}
|
|
136
272
|
```
|
|
137
273
|
|
|
138
|
-
|
|
274
|
+
4. Update CLI help in [bin/marmot.js](bin/marmot.js) (enable command description).
|
|
139
275
|
|
|
140
|
-
|
|
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
|
-
|
|
278
|
+
1. Create handler in `src/cli/my-command.js`:
|
|
154
279
|
|
|
155
280
|
```javascript
|
|
156
|
-
const
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
package/src/cli/disable.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 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
|
+
};
|
package/src/core/config.js
CHANGED
package/src/plugins/index.js
CHANGED
|
@@ -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
|
+
};
|