z-clean 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/zclean.js CHANGED
@@ -2,11 +2,25 @@
2
2
  'use strict';
3
3
 
4
4
  const os = require('os');
5
- const { scan } = require('../src/scanner');
5
+ const { scan, hasScanErrors } = require('../src/scanner');
6
6
  const { killZombies } = require('../src/killer');
7
- const { loadConfig, saveConfig, readLogs, pruneLogs, CONFIG_FILE, DEFAULT_CONFIG, appendLog } = require('../src/config');
7
+ const {
8
+ loadConfig,
9
+ saveConfig,
10
+ readLogs,
11
+ pruneLogs,
12
+ DEFAULT_CONFIG,
13
+ appendLog,
14
+ getCumulativeStats,
15
+ getConfigFile,
16
+ } = require('../src/config');
8
17
  const { reportDryRun, reportKill, reportStatus, reportLogs, reportConfig, c, bold } = require('../src/reporter');
9
18
  const { installHook, removeHook } = require('../src/installer/hook');
19
+ const { runHistory, runProtect } = require('../src/commands/trust');
20
+ const { installScheduler, uninstallScheduler } = require('../src/commands/scheduler');
21
+ const { runDoctor } = require('../src/doctor');
22
+ const { runAudit } = require('../src/audit');
23
+ const { runCache } = require('../src/cache');
10
24
 
11
25
  // Platform-specific installers (lazy loaded)
12
26
  const platform = os.platform();
@@ -58,12 +72,30 @@ async function main() {
58
72
  case 'logs':
59
73
  return cmdLogs(config);
60
74
 
75
+ case 'history':
76
+ return runHistory(flags);
77
+
78
+ case 'protect':
79
+ return runProtect(config, flags, positional);
80
+
61
81
  case 'uninstall':
62
82
  return cmdUninstall();
63
83
 
64
84
  case 'config':
65
85
  return cmdConfig(config);
66
86
 
87
+ case 'doctor':
88
+ return cmdDoctor(config);
89
+
90
+ case 'report':
91
+ return cmdAudit(config, 'report');
92
+
93
+ case 'audit':
94
+ return cmdAudit(config, 'audit');
95
+
96
+ case 'cache':
97
+ return cmdCache();
98
+
67
99
  case null:
68
100
  // Default: scan (dry-run unless --yes)
69
101
  return cmdScan(config);
@@ -82,13 +114,21 @@ async function main() {
82
114
  * Dry-run unless --yes is passed.
83
115
  */
84
116
  function cmdScan(config) {
85
- const sessionPid = flags['session-pid'] ? parseInt(flags['session-pid'], 10) : null;
117
+ const sessionPid = parseSessionPidFlag();
86
118
  const force = flags.yes || flags.y;
87
119
 
88
120
  console.log(bold('\n zclean') + c('gray', ' — scanning for zombie processes...\n'));
89
121
 
90
122
  const zombies = scan(config, { sessionPid });
91
123
 
124
+ if (hasScanErrors(zombies)) {
125
+ reportDryRun(zombies);
126
+ appendLog({ action: 'scan-failed', errors: zombies.errors.length });
127
+ pruneLogs(config);
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+
92
132
  if (force) {
93
133
  // Kill mode
94
134
  if (zombies.length === 0) {
@@ -98,9 +138,13 @@ function cmdScan(config) {
98
138
  }
99
139
  appendLog({ action: 'scan', found: zombies.length });
100
140
  const results = killZombies(zombies, config);
141
+ results.cumulative = getCumulativeStats();
101
142
  reportKill(results);
102
143
  } else {
103
144
  // Dry-run mode
145
+ if (config.dryRunDefault === false) {
146
+ console.log(c('yellow', ' dryRunDefault=false is not an auto-kill switch; pass --yes to clean.\n'));
147
+ }
104
148
  reportDryRun(zombies);
105
149
  if (zombies.length > 0) {
106
150
  appendLog({ action: 'dry-run', found: zombies.length });
@@ -121,9 +165,9 @@ function cmdInit(config) {
121
165
  const existingConfig = loadConfig();
122
166
  if (JSON.stringify(existingConfig) === JSON.stringify(DEFAULT_CONFIG)) {
123
167
  saveConfig(DEFAULT_CONFIG);
124
- console.log(c('green', ' Config created:') + ` ${CONFIG_FILE}`);
168
+ console.log(c('green', ' Config created:') + ` ${getConfigFile()}`);
125
169
  } else {
126
- console.log(c('gray', ' Config exists:') + ` ${CONFIG_FILE}`);
170
+ console.log(c('gray', ' Config exists:') + ` ${getConfigFile()}`);
127
171
  }
128
172
 
129
173
  // 2. Install Claude Code hook
@@ -132,7 +176,7 @@ function cmdInit(config) {
132
176
  console.log(`${hookIcon} ${hookResult.message}`);
133
177
 
134
178
  // 3. Install platform-specific scheduler
135
- installScheduler();
179
+ installScheduler(platform);
136
180
 
137
181
  console.log();
138
182
  }
@@ -144,6 +188,7 @@ function cmdStatus(config) {
144
188
  const zombies = scan(config);
145
189
  const logs = readLogs(100);
146
190
  reportStatus(zombies, logs);
191
+ if (hasScanErrors(zombies)) process.exitCode = 1;
147
192
  }
148
193
 
149
194
  /**
@@ -165,7 +210,7 @@ function cmdUninstall() {
165
210
  console.log(` Hook: ${hookResult.message}`);
166
211
 
167
212
  // Remove scheduler
168
- uninstallScheduler();
213
+ uninstallScheduler(platform);
169
214
 
170
215
  console.log(c('gray', `\n Config and logs preserved at ~/.zclean/`));
171
216
  console.log(c('gray', ` To fully remove: rm -rf ~/.zclean\n`));
@@ -175,69 +220,48 @@ function cmdUninstall() {
175
220
  * config: Show current config.
176
221
  */
177
222
  function cmdConfig(config) {
178
- reportConfig(config, CONFIG_FILE);
223
+ reportConfig(config, getConfigFile());
179
224
  }
180
225
 
181
- // ─── Platform Scheduler Install/Uninstall ───────────────────────────────────
226
+ /**
227
+ * doctor: Self-diagnosis — check if zclean is properly set up and running.
228
+ */
229
+ function cmdDoctor(config) {
230
+ const report = runDoctor(config, { json: Boolean(flags.json) });
231
+ if (report.exitCode !== 0) process.exitCode = report.exitCode;
232
+ }
182
233
 
183
- function installScheduler() {
184
- switch (platform) {
185
- case 'darwin': {
186
- const { installLaunchd } = require('../src/installer/launchd');
187
- const result = installLaunchd();
188
- const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
189
- console.log(`${icon} ${result.message}`);
190
- break;
191
- }
192
- case 'linux': {
193
- const { installSystemd } = require('../src/installer/systemd');
194
- const result = installSystemd();
195
- const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
196
- console.log(`${icon} ${result.message}`);
197
- break;
198
- }
199
- case 'win32': {
200
- const { installTaskScheduler } = require('../src/installer/taskscheduler');
201
- const result = installTaskScheduler();
202
- const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
203
- console.log(`${icon} ${result.message}`);
204
- break;
205
- }
206
- default:
207
- console.log(c('yellow', ` Scheduler: Unsupported platform (${platform}). Install a cron job manually.`));
208
- }
234
+ function cmdAudit(config, commandName = 'audit') {
235
+ const sessionPid = parseSessionPidFlag();
236
+ const report = runAudit(config, { json: Boolean(flags.json), sessionPid, commandName });
237
+ if (!report.risk.enumerationComplete) process.exitCode = 1;
209
238
  }
210
239
 
211
- function uninstallScheduler() {
212
- switch (platform) {
213
- case 'darwin': {
214
- const { removeLaunchd } = require('../src/installer/launchd');
215
- const result = removeLaunchd();
216
- console.log(` Scheduler: ${result.message}`);
217
- break;
218
- }
219
- case 'linux': {
220
- const { removeSystemd } = require('../src/installer/systemd');
221
- const result = removeSystemd();
222
- console.log(` Scheduler: ${result.message}`);
223
- break;
224
- }
225
- case 'win32': {
226
- const { removeTaskScheduler } = require('../src/installer/taskscheduler');
227
- const result = removeTaskScheduler();
228
- console.log(` Scheduler: ${result.message}`);
229
- break;
230
- }
231
- default:
232
- console.log(c('yellow', ` Scheduler: Remove manually for ${platform}.`));
240
+ function cmdCache() {
241
+ runCache({
242
+ root: typeof flags.path === 'string' ? flags.path : process.cwd(),
243
+ yes: Boolean(flags.yes || flags.y),
244
+ json: Boolean(flags.json),
245
+ });
246
+ }
247
+
248
+ function parseSessionPidFlag() {
249
+ if (flags['session-pid'] === undefined) return null;
250
+ if (flags['session-pid'] === true || flags['session-pid'] === '') {
251
+ console.error(c('red', ' --session-pid must be a positive integer'));
252
+ process.exit(1);
233
253
  }
254
+ const pid = Number(flags['session-pid']);
255
+ if (Number.isInteger(pid) && pid > 0) return pid;
256
+ console.error(c('red', ' --session-pid must be a positive integer'));
257
+ process.exit(1);
234
258
  }
235
259
 
236
260
  // ─── Help ───────────────────────────────────────────────────────────────────
237
261
 
238
262
  function printHelp() {
239
263
  console.log(`
240
- ${bold('zclean')} — Automatic zombie process cleaner for AI coding tools
264
+ ${bold('zclean')} — AI coding runtime hygiene for agent sessions
241
265
 
242
266
  ${bold('Usage:')}
243
267
  zclean Scan for zombies (dry-run)
@@ -245,19 +269,31 @@ function printHelp() {
245
269
  zclean init Install hooks + scheduler
246
270
  zclean status Show current zombies and last cleanup
247
271
  zclean logs Show recent cleanup history
272
+ zclean history [--json] Show cleanup history
273
+ zclean protect list [--json] Show protected whitelist entries
274
+ zclean protect add <entry> Add a whitelist entry
275
+ zclean protect remove <entry|--index=N>
276
+ Remove a whitelist entry
248
277
  zclean uninstall Remove hooks + scheduler
249
278
  zclean config Show current configuration
279
+ zclean doctor [--json] Check if zclean is properly set up
280
+ zclean report [--json] Show AI runtime hygiene report
281
+ zclean audit [--json] Alias for report
282
+ zclean cache [--json] Show safe workspace cache candidates
283
+ zclean cache --yes Remove supported workspace cache directories
250
284
 
251
285
  ${bold('Options:')}
252
286
  --yes, -y Kill found zombies (default: dry-run)
253
287
  --session-pid=PID Filter by parent session PID
288
+ --path=DIR Workspace path for zclean cache
289
+ --json Print machine-readable output for supported commands
254
290
  --version, -v Show version
255
291
  --help, -h Show this help
256
292
 
257
293
  ${bold('Config:')} ~/.zclean/config.json
258
294
  ${bold('Logs:')} ~/.zclean/history.jsonl
259
295
 
260
- ${bold('Docs:')} https://github.com/whynowlab/zclean
296
+ ${bold('Docs:')} https://github.com/TheStack-ai/zclean
261
297
  `);
262
298
  }
263
299
 
package/package.json CHANGED
@@ -1,12 +1,49 @@
1
1
  {
2
2
  "name": "z-clean",
3
- "version": "0.1.1",
4
- "description": "Automatic zombie process cleaner for AI coding tools (Claude Code, Codex)",
5
- "bin": { "zclean": "./bin/zclean.js" },
6
- "keywords": ["claude-code", "codex", "cleanup", "zombie", "orphan", "process", "ai-tools", "mcp"],
7
- "author": "whynowlab",
3
+ "version": "0.3.0",
4
+ "description": "AI coding runtime hygiene reports, zombie cleanup, and safe workspace cache cleanup",
5
+ "bin": {
6
+ "zclean": "bin/zclean.js"
7
+ },
8
+ "scripts": {
9
+ "test": "node --test test/patterns.test.js test/scanner.test.js test/process-tree.test.js test/killer.test.js test/config.test.js test/installer-fallback.test.js test/cli.test.js test/cli-history-protect.test.js test/cli-doctor.test.js test/audit.test.js test/cache.test.js",
10
+ "syntax": "node -e \"const fs=require('node:fs');const path=require('node:path');const {execFileSync}=require('node:child_process');const files=['bin/zclean.js'];function walk(dir){if(!fs.existsSync(dir))return;for(const entry of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,entry.name);if(entry.isDirectory())walk(file);else if(entry.name.endsWith('.js'))files.push(file);}}for(const dir of ['src','test','scripts'])walk(dir);for(const file of files)execFileSync(process.execPath,['-c',file],{stdio:'inherit'});\"",
11
+ "smoke": "node scripts/smoke.js",
12
+ "pack:check": "npm pack --dry-run --json"
13
+ },
14
+ "keywords": [
15
+ "claude-code",
16
+ "codex",
17
+ "cleanup",
18
+ "zombie",
19
+ "orphan",
20
+ "process",
21
+ "ai-tools",
22
+ "mcp",
23
+ "cache-cleaner",
24
+ "developer-tools",
25
+ "ai-coding"
26
+ ],
27
+ "author": "TheStack-ai",
8
28
  "license": "MIT",
9
- "repository": { "type": "git", "url": "https://github.com/whynowlab/zclean.git" },
10
- "engines": { "node": ">=18" },
11
- "files": ["bin/", "src/", "LICENSE", "README.md"]
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/TheStack-ai/zclean.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/TheStack-ai/zclean/issues"
35
+ },
36
+ "homepage": "https://github.com/TheStack-ai/zclean#readme",
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "files": [
41
+ "bin/",
42
+ "src/",
43
+ "assets/zclean-hero.png",
44
+ "LICENSE",
45
+ "README.md",
46
+ "README.ko.md",
47
+ "README.zh.md"
48
+ ]
12
49
  }
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ function buildRecommendations({ zombieCount, errors, warnings, commandName }) {
4
+ if (errors.length > 0) {
5
+ return [
6
+ 'Run `zclean doctor` before cleaning; process enumeration is incomplete.',
7
+ `Do not trust a zero-candidate ${commandName} until enumeration errors are resolved.`,
8
+ ];
9
+ }
10
+
11
+ const recommendations = [
12
+ 'Review candidates before cleanup. Manual cleanup still requires `zclean --yes`.',
13
+ ];
14
+ if (zombieCount > 0) {
15
+ recommendations.push('Run `zclean --yes` only after confirming these are leftover AI coding runtimes.');
16
+ } else {
17
+ recommendations.push('No AI runtime leftovers are currently visible.');
18
+ }
19
+ if (warnings.length > 0) {
20
+ recommendations.push('Review scan warnings; some candidates may need manual attribution.');
21
+ }
22
+ recommendations.push(`Use \`zclean ${commandName} --json\` for dashboards, CI notes, or local automation.`);
23
+ return recommendations;
24
+ }
25
+
26
+ function buildNextActions({ zombieCount, errors, warnings, commandName }) {
27
+ if (errors.length > 0) {
28
+ return [
29
+ {
30
+ id: 'run-doctor',
31
+ priority: 'high',
32
+ command: 'zclean doctor',
33
+ description: 'Fix process enumeration before trusting the report.',
34
+ },
35
+ {
36
+ id: 'avoid-cleanup',
37
+ priority: 'high',
38
+ command: null,
39
+ description: `Do not run cleanup from this ${commandName} result while enumeration is incomplete.`,
40
+ },
41
+ ];
42
+ }
43
+
44
+ const actions = [];
45
+ if (zombieCount > 0) {
46
+ actions.push({
47
+ id: 'review-top-candidates',
48
+ priority: 'high',
49
+ command: null,
50
+ description: 'Review topCandidates, largestCandidate, and oldestCandidate before cleanup.',
51
+ });
52
+ actions.push({
53
+ id: 'manual-cleanup-requires-yes',
54
+ priority: 'high',
55
+ command: 'zclean --yes',
56
+ description: 'Cleanup remains opt-in and requires an explicit --yes run after review.',
57
+ });
58
+ } else {
59
+ actions.push({
60
+ id: 'monitor-runtime-hygiene',
61
+ priority: 'normal',
62
+ command: `zclean ${commandName}`,
63
+ description: 'No AI runtime leftovers are visible; rerun the report when sessions change.',
64
+ });
65
+ }
66
+
67
+ if (warnings.length > 0) {
68
+ actions.push({
69
+ id: 'review-warnings',
70
+ priority: 'normal',
71
+ command: null,
72
+ description: 'Review scan warnings before making cleanup decisions.',
73
+ });
74
+ }
75
+
76
+ actions.push({
77
+ id: 'export-json',
78
+ priority: 'low',
79
+ command: `zclean ${commandName} --json`,
80
+ description: 'Use the JSON report for local dashboards, CI notes, or automation.',
81
+ });
82
+ return actions;
83
+ }
84
+
85
+ module.exports = {
86
+ buildNextActions,
87
+ buildRecommendations,
88
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ function buildCandidateReview(item) {
4
+ const memory = item.mem || 0;
5
+ const age = item.age || 0;
6
+ const riskScore = Math.min(100, 30 + Math.floor(memory / (100 * 1024 * 1024)) * 5 + Math.floor(age / 3600000));
7
+ return {
8
+ pid: item.pid,
9
+ name: item.name || item.pattern || 'unknown',
10
+ pattern: item.pattern || item.name || 'unknown',
11
+ memoryBytes: memory,
12
+ ageMs: age,
13
+ reason: item.reason || '',
14
+ risk: {
15
+ score: riskScore,
16
+ level: riskScore >= 75 ? 'high' : riskScore >= 50 ? 'medium' : 'low',
17
+ },
18
+ };
19
+ }
20
+
21
+ function groupCandidatesByPattern(candidates) {
22
+ const grouped = {};
23
+ for (const candidate of candidates) {
24
+ grouped[candidate.pattern] = (grouped[candidate.pattern] || 0) + 1;
25
+ }
26
+ return grouped;
27
+ }
28
+
29
+ function groupCandidateSourcesByPattern(candidates) {
30
+ const grouped = {};
31
+ for (const candidate of candidates) {
32
+ const pattern = candidate.pattern;
33
+ if (!grouped[pattern]) {
34
+ grouped[pattern] = {
35
+ count: 0,
36
+ memoryBytes: 0,
37
+ pids: [],
38
+ };
39
+ }
40
+ grouped[pattern].count++;
41
+ grouped[pattern].memoryBytes += candidate.memoryBytes;
42
+ grouped[pattern].pids.push(candidate.pid);
43
+ }
44
+ return grouped;
45
+ }
46
+
47
+ function sortCandidatesByRisk(candidates) {
48
+ return [...candidates].sort((left, right) => {
49
+ if (right.risk.score !== left.risk.score) return right.risk.score - left.risk.score;
50
+ if (right.memoryBytes !== left.memoryBytes) return right.memoryBytes - left.memoryBytes;
51
+ if (right.ageMs !== left.ageMs) return right.ageMs - left.ageMs;
52
+ return (left.pid || 0) - (right.pid || 0);
53
+ });
54
+ }
55
+
56
+ function pickCandidate(candidates, field) {
57
+ if (candidates.length === 0) return null;
58
+ return candidates.reduce((best, candidate) => {
59
+ if (candidate[field] > best[field]) return candidate;
60
+ if (candidate[field] === best[field] && (candidate.pid || 0) < (best.pid || 0)) return candidate;
61
+ return best;
62
+ });
63
+ }
64
+
65
+ function calculateScore(candidates, warnings, errors) {
66
+ if (errors.length > 0) return 0;
67
+ const candidatePenalty = candidates.reduce((sum, item) => sum + Math.min(20, Math.ceil(item.risk.score / 10)), 0);
68
+ const warningPenalty = warnings.length * 5;
69
+ return Math.max(0, 100 - candidatePenalty - warningPenalty);
70
+ }
71
+
72
+ function riskLevel(candidateCount, warningCount) {
73
+ if (candidateCount === 0 && warningCount === 0) return 'clean';
74
+ if (candidateCount <= 2 && warningCount === 0) return 'watch';
75
+ return 'attention';
76
+ }
77
+
78
+ module.exports = {
79
+ buildCandidateReview,
80
+ calculateScore,
81
+ groupCandidateSourcesByPattern,
82
+ groupCandidatesByPattern,
83
+ pickCandidate,
84
+ riskLevel,
85
+ sortCandidatesByRisk,
86
+ };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ function buildHistory(logs, stats) {
4
+ const lastCleanup = logs.filter((entry) => entry.action === 'cleanup-summary').pop() || null;
5
+ return {
6
+ totalKilled: stats.totalKilled || 0,
7
+ totalMemFreed: stats.totalMemFreed || 0,
8
+ weekKilled: stats.weekKilled || 0,
9
+ weekMemFreed: stats.weekMemFreed || 0,
10
+ lastRun: stats.lastRun || (lastCleanup ? lastCleanup.timestamp : null),
11
+ lastDryRun: findLastTimestamp(logs, 'dry-run'),
12
+ recentFailures: findRecentFailures(logs),
13
+ recent: summarizeLogs(logs),
14
+ };
15
+ }
16
+
17
+ function findLastTimestamp(logs, action) {
18
+ const entry = logs.filter((item) => item.action === action && item.timestamp).pop();
19
+ return entry ? entry.timestamp : null;
20
+ }
21
+
22
+ function findRecentFailures(logs) {
23
+ return logs
24
+ .filter((entry) => entry.action === 'scan-failed' || entry.action === 'kill-failed' || (entry.action === 'cleanup-summary' && entry.failed > 0))
25
+ .slice(-5)
26
+ .map((entry) => ({
27
+ timestamp: entry.timestamp || null,
28
+ action: entry.action,
29
+ pid: entry.pid || null,
30
+ message: entry.message || entry.error || null,
31
+ failed: entry.failed || 0,
32
+ }));
33
+ }
34
+
35
+ function summarizeLogs(logs) {
36
+ const summary = {
37
+ dryRuns: 0,
38
+ scans: 0,
39
+ scanFailures: 0,
40
+ cleanupSummaries: 0,
41
+ failedKills: 0,
42
+ };
43
+
44
+ for (const entry of logs) {
45
+ switch (entry.action) {
46
+ case 'dry-run':
47
+ summary.dryRuns++;
48
+ break;
49
+ case 'scan':
50
+ summary.scans++;
51
+ break;
52
+ case 'scan-failed':
53
+ summary.scanFailures++;
54
+ break;
55
+ case 'cleanup-summary':
56
+ summary.cleanupSummaries++;
57
+ break;
58
+ case 'kill-failed':
59
+ summary.failedKills++;
60
+ break;
61
+ }
62
+ }
63
+
64
+ return summary;
65
+ }
66
+
67
+ module.exports = {
68
+ buildHistory,
69
+ };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const { c, bold, formatBytes, formatDuration } = require('./reporter');
4
+
5
+ function reportAudit(report, options = {}) {
6
+ const commandName = options.commandName || 'audit';
7
+ console.log(bold(`\n zclean ${commandName}`) + c('gray', ' - AI runtime hygiene review\n'));
8
+ console.log(` Score: ${formatRiskScore(report.risk)}`);
9
+ console.log(` Status: ${report.summary.status}`);
10
+ console.log(` Candidates: ${report.summary.zombieCount}`);
11
+ console.log(` Memory: ${formatBytes(report.summary.reclaimableBytes)} reclaimable`);
12
+ console.log(' Scope: AI coding runtime hygiene plus safe workspace caches; not app uninstall or whole-disk cleanup');
13
+ console.log();
14
+
15
+ console.log(c('cyan', ' Safety'));
16
+ console.log(` Manual scans are dry-run: ${report.proGradeReview.guardrails.dryRunDefault ? 'yes' : 'configured false, but --yes is still required'}`);
17
+ console.log(` Cleanup requires --yes: ${report.proGradeReview.guardrails.cleanupRequiresYes ? 'yes' : 'no'}`);
18
+ console.log(` Whitelist entries: ${report.proGradeReview.guardrails.whitelistCount}`);
19
+ console.log(` Custom AI dirs: ${report.proGradeReview.guardrails.customAiDirCount}`);
20
+ console.log(` Telemetry: none`);
21
+ console.log();
22
+
23
+ if (!report.risk.enumerationComplete) {
24
+ console.log(c('red', ' Enumeration incomplete'));
25
+ for (const error of report.diagnostics.errors) {
26
+ console.log(` ${formatDiagnostic(error)}`);
27
+ }
28
+ console.log();
29
+ }
30
+
31
+ if (report.diagnostics.warnings.length > 0) {
32
+ console.log(c('yellow', ' Warnings'));
33
+ for (const warning of report.diagnostics.warnings) {
34
+ console.log(` ${formatDiagnostic(warning)}`);
35
+ }
36
+ console.log();
37
+ }
38
+
39
+ if (report.proGradeReview.candidates.length > 0) {
40
+ console.log(c('cyan', ' Top candidates'));
41
+ for (const item of report.proGradeReview.topCandidates.slice(0, 5)) {
42
+ console.log(` PID ${String(item.pid).padStart(6)} ${item.pattern.padEnd(16)} ${formatBytes(item.memoryBytes).padStart(8)} ${formatDuration(item.ageMs).padStart(6)}`);
43
+ console.log(` ${item.reason}`);
44
+ }
45
+ console.log();
46
+ }
47
+
48
+ console.log(c('cyan', ' Recent history'));
49
+ console.log(` This week: ${report.history.weekKilled} cleaned, ${formatBytes(report.history.weekMemFreed)} freed`);
50
+ console.log(` All time: ${report.history.totalKilled} cleaned, ${formatBytes(report.history.totalMemFreed)} freed`);
51
+ console.log(` Last run: ${report.history.lastRun || 'never'}`);
52
+ console.log(` Last dry-run: ${report.history.lastDryRun || 'never'}`);
53
+ console.log(` Recent failures: ${report.history.recentFailures.length}`);
54
+ console.log();
55
+
56
+ console.log(c('cyan', ' Positioning'));
57
+ console.log(` ${report.differentiation}`);
58
+ console.log();
59
+
60
+ console.log(c('cyan', ' Recommendations'));
61
+ for (const action of report.nextActions) {
62
+ const command = action.command ? ` (${action.command})` : '';
63
+ console.log(` - [${action.priority}] ${action.description}${command}`);
64
+ }
65
+ console.log();
66
+ }
67
+
68
+ function formatRiskScore(risk) {
69
+ const text = `${risk.score}/100 (${risk.level})`;
70
+ if (risk.level === 'clean' || risk.level === 'watch') return c('green', text);
71
+ if (risk.level === 'unknown') return c('red', text);
72
+ return c('yellow', text);
73
+ }
74
+
75
+ function formatDiagnostic(item) {
76
+ if (typeof item === 'string') return item;
77
+ const code = item.code ? `${item.code}: ` : '';
78
+ return `${code}${item.message || 'scan diagnostic'}`;
79
+ }
80
+
81
+ module.exports = {
82
+ reportAudit,
83
+ };