@link-assistant/hive-mind 1.73.9 → 1.74.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/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.74.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 59eee9a: feat(interactive-mode): display images the AI reads/writes inline in PR comments (#1843)
8
+
9
+ When `--interactive-mode` posts Claude/Codex tool activity as PR comments, any
10
+ images the AI reads or produces (the `Read` tool on a screenshot, Playwright
11
+ captures, MCP image results) were previously serialized as multi-kilobyte
12
+ base64 blobs inside the "Raw JSON" section — unreadable and pushing comments
13
+ toward GitHub's size limit.
14
+
15
+ Those base64 payloads are now uploaded to hidden custom Git refs
16
+ (`refs/hive-mind-media/pr-...`) via the Git Data API and embedded inline in the
17
+ comment as commit-SHA `![](…?raw=true)` blob URLs, so reviewers see the actual
18
+ image (GitHub's Camo proxy renders `?raw=true` blob URLs inline for both public
19
+ and private repos, whereas `data:` URIs are stripped by the comment sanitizer).
20
+ Uploads are content-hashed (SHA-256) for dedup, and the base64 is redacted from
21
+ the Raw JSON section with a `<image data: N base64 chars>` placeholder.
22
+
23
+ Enabled by default; use `--no-interactive-image-upload` to opt out, in which
24
+ case each image degrades to a compact metadata note instead of being embedded.
25
+ All comment bodies continue to pass through the token sanitizer (#1745).
26
+
27
+ ## 1.74.0
28
+
29
+ ### Minor Changes
30
+
31
+ - b00a51c: feat(cleanup): add a task-aware `cleanup` command to free disk space safely (#1848)
32
+
33
+ Adds a new `cleanup` bin that removes stale hive-mind temporary
34
+ directories/files under the system temp dir while preserving folders that belong
35
+ to currently-running tasks, protected system paths, and any clone with
36
+ uncommitted or unpushed work.
37
+
38
+ Highlights:
39
+ - `--dry-run` / `-n` prints the full list of kept folders and folders that would
40
+ be deleted (with sizes and reasons), deleting nothing.
41
+ - `--keep-active-tasks-folders` (default on) detects active tasks from running
42
+ processes (`/proc`) and live isolation sessions (`screen`/`tmux` +
43
+ `$ --status`), and matches clones to tasks by branch name using the same logic
44
+ as `solve` (issue → `issue-{n}-{hex}` scoped to the repo; PR → its resolved
45
+ head branch). Disable with `--no-keep-active-tasks-folders`.
46
+ - Keeps `/tmp/start-command/` and system-owned temp entries by default;
47
+ `--force-start-command` allows deleting `/tmp/start-command` when needed.
48
+ - Optional Ubuntu/system cleanup behind explicit flags: `--apt`, `--journal`,
49
+ `--docker`, `--npm` (and `--system` shorthand), with `--sudo`.
50
+ - Safe by default: keeps unrecognised entries unless `--all`, never deletes
51
+ paths held open by a running process or used by the cleanup process itself,
52
+ and requires confirmation unless `--force`.
53
+
3
54
  ## 1.73.9
4
55
 
5
56
  ### Patch Changes
package/README.hi.md CHANGED
@@ -717,6 +717,42 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
717
717
  (cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
718
718
  ```
719
719
 
720
+ ### डिस्क क्लीनअप
721
+
722
+ `cleanup` पुरानी hive-mind अस्थायी डायरेक्टरी/फ़ाइलों (जैसे प्रति-कार्य क्लोन
723
+ `/tmp/gh-issue-solver-*`, MCP कॉन्फ़िग फ़ाइलें, लॉग डाउनलोड डायरेक्टरी आदि) को हटाकर
724
+ डिस्क स्थान खाली करता है, जबकि **वर्तमान में चल रहे कार्यों से संबंधित फ़ोल्डर**,
725
+ सुरक्षित सिस्टम पथ, और बिना कमिट या बिना पुश किए बदलावों वाले किसी भी क्लोन को बनाए
726
+ रखता है। यह चल रही प्रक्रियाओं और लाइव आइसोलेशन सेशन से सक्रिय कार्यों का पता लगाता है
727
+ और `solve` के समान लॉजिक का उपयोग करते हुए शाखा नाम द्वारा क्लोन को कार्यों से मिलाता
728
+ है (issue → `issue-{n}-{hex}`; PR → इसकी हल की गई head शाखा)।
729
+
730
+ ```bash
731
+ # पूर्वावलोकन: रखे जाने वाले और हटाए जाने वाले फ़ोल्डरों की सूची (कुछ भी नहीं हटाता)
732
+ cleanup --dry-run
733
+
734
+ # पुरानी अस्थायी फ़ाइलें वास्तव में हटाएँ (पहले पुष्टि माँगता है)
735
+ cleanup
736
+
737
+ # पुष्टि प्रॉम्प्ट के बिना हटाएँ
738
+ cleanup --force
739
+
740
+ # गैर-hive-mind अस्थायी प्रविष्टियों पर भी विचार करें (अधिक आक्रामक)
741
+ cleanup --all --dry-run
742
+
743
+ # /tmp/start-command को हटाने की अनुमति दें (डिफ़ॉल्ट रूप से रखा जाता है; इसमें आइसोलेशन लॉग होते हैं)
744
+ cleanup --force-start-command
745
+
746
+ # Ubuntu / सिस्टम क्लीनअप (apt कैश, journald लॉग, npm कैश)
747
+ cleanup --system --sudo
748
+
749
+ # सक्रिय-कार्य पहचान अक्षम करें (केवल सुरक्षित पथ रखे जाते हैं)
750
+ cleanup --no-keep-active-tasks-folders --dry-run
751
+ ```
752
+
753
+ विकल्पों की पूरी सूची के लिए `cleanup --help` चलाएँ। यह कमांड dry-run के अनुकूल है और
754
+ हर रन के लिए टाइमस्टैम्प वाला `cleanup-*.log` लिखता है।
755
+
720
756
  ## 🔍 निगरानी और लॉगिंग
721
757
 
722
758
  लॉग में resume कमांड खोजें:
package/README.md CHANGED
@@ -737,6 +737,43 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
737
737
  (cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
738
738
  ```
739
739
 
740
+ ### Disk Cleanup
741
+
742
+ `cleanup` frees disk space by removing stale hive-mind temporary
743
+ directories/files (per-task clones like `/tmp/gh-issue-solver-*`, MCP config
744
+ files, log download dirs, …) while **keeping folders that belong to
745
+ currently-running tasks**, protected system paths, and any clone with
746
+ uncommitted or unpushed work. It detects active tasks from running processes and
747
+ live isolation sessions and matches clones to tasks by branch name using the
748
+ same logic as `solve` (issue → `issue-{n}-{hex}`; PR → its resolved head
749
+ branch).
750
+
751
+ ```bash
752
+ # Preview: list kept folders and folders that would be deleted (deletes nothing)
753
+ cleanup --dry-run
754
+
755
+ # Actually delete stale temp artifacts (asks for confirmation first)
756
+ cleanup
757
+
758
+ # Delete without the confirmation prompt
759
+ cleanup --force
760
+
761
+ # Also consider non-hive-mind temp entries (more aggressive)
762
+ cleanup --all --dry-run
763
+
764
+ # Allow deleting /tmp/start-command (kept by default; holds isolation logs)
765
+ cleanup --force-start-command
766
+
767
+ # Ubuntu / system cleanup (apt caches, journald logs, npm cache)
768
+ cleanup --system --sudo
769
+
770
+ # Disable active-task detection (only protected paths are kept)
771
+ cleanup --no-keep-active-tasks-folders --dry-run
772
+ ```
773
+
774
+ Run `cleanup --help` for the full list of options. The command is dry-run
775
+ friendly and writes a timestamped `cleanup-*.log` for every run.
776
+
740
777
  ## 🔍 Monitoring & Logging
741
778
 
742
779
  Find resume commands in logs:
package/README.ru.md CHANGED
@@ -719,6 +719,43 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
719
719
  (cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
720
720
  ```
721
721
 
722
+ ### Очистка диска
723
+
724
+ `cleanup` освобождает место на диске, удаляя устаревшие временные каталоги/файлы
725
+ hive-mind (клоны для каждой задачи вида `/tmp/gh-issue-solver-*`, файлы
726
+ конфигурации MCP, каталоги загрузки логов и т. д.), при этом **сохраняя папки,
727
+ относящиеся к выполняющимся в данный момент задачам**, защищённые системные пути и
728
+ любой клон с незакоммиченными или неотправленными изменениями. Он обнаруживает
729
+ активные задачи по запущенным процессам и активным сессиям изоляции и сопоставляет
730
+ клоны с задачами по имени ветки, используя ту же логику, что и `solve`
731
+ (issue → `issue-{n}-{hex}`; PR → его разрешённая head-ветка).
732
+
733
+ ```bash
734
+ # Предпросмотр: список сохраняемых и удаляемых папок (ничего не удаляет)
735
+ cleanup --dry-run
736
+
737
+ # Реально удалить устаревшие временные файлы (сначала запросит подтверждение)
738
+ cleanup
739
+
740
+ # Удалить без запроса подтверждения
741
+ cleanup --force
742
+
743
+ # Учитывать также не-hive-mind временные записи (более агрессивно)
744
+ cleanup --all --dry-run
745
+
746
+ # Разрешить удаление /tmp/start-command (по умолчанию сохраняется; хранит логи изоляции)
747
+ cleanup --force-start-command
748
+
749
+ # Очистка Ubuntu / системы (кэши apt, логи journald, кэш npm)
750
+ cleanup --system --sudo
751
+
752
+ # Отключить обнаружение активных задач (сохраняются только защищённые пути)
753
+ cleanup --no-keep-active-tasks-folders --dry-run
754
+ ```
755
+
756
+ Запустите `cleanup --help`, чтобы увидеть полный список опций. Команда удобна для
757
+ режима dry-run и записывает лог `cleanup-*.log` с меткой времени при каждом запуске.
758
+
722
759
  ## 🔍 Мониторинг и логирование
723
760
 
724
761
  Найдите команды возобновления в логах:
package/README.zh.md CHANGED
@@ -713,6 +713,41 @@ solve https://github.com/owner/repo/issues/123 --resume 657e6db1-6eb3-4a8d
713
713
  (cd /tmp/gh-issue-solver-123456789 && claude --resume session-id)
714
714
  ```
715
715
 
716
+ ### 磁盘清理
717
+
718
+ `cleanup` 通过删除过时的 hive-mind 临时目录/文件(如每个任务的克隆
719
+ `/tmp/gh-issue-solver-*`、MCP 配置文件、日志下载目录等)来释放磁盘空间,同时
720
+ **保留属于当前正在运行任务的文件夹**、受保护的系统路径,以及任何包含未提交或未推送
721
+ 更改的克隆。它通过运行中的进程和实时隔离会话检测活动任务,并使用与 `solve` 相同的
722
+ 逻辑按分支名称将克隆与任务匹配(issue → `issue-{n}-{hex}`;PR → 其解析出的 head
723
+ 分支)。
724
+
725
+ ```bash
726
+ # 预览:列出保留的文件夹和将被删除的文件夹(不删除任何内容)
727
+ cleanup --dry-run
728
+
729
+ # 实际删除过时的临时文件(会先要求确认)
730
+ cleanup
731
+
732
+ # 删除时不显示确认提示
733
+ cleanup --force
734
+
735
+ # 同时考虑非 hive-mind 临时项(更激进)
736
+ cleanup --all --dry-run
737
+
738
+ # 允许删除 /tmp/start-command(默认保留;其中存放隔离日志)
739
+ cleanup --force-start-command
740
+
741
+ # Ubuntu / 系统清理(apt 缓存、journald 日志、npm 缓存)
742
+ cleanup --system --sudo
743
+
744
+ # 禁用活动任务检测(仅保留受保护的路径)
745
+ cleanup --no-keep-active-tasks-folders --dry-run
746
+ ```
747
+
748
+ 运行 `cleanup --help` 查看完整的选项列表。该命令对 dry-run 友好,并为每次运行写入
749
+ 带时间戳的 `cleanup-*.log` 日志。
750
+
716
751
  ## 🔍 监控与日志
717
752
 
718
753
  在日志中查找恢复命令:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.73.9",
3
+ "version": "1.74.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -8,6 +8,7 @@
8
8
  "hive": "./src/hive.mjs",
9
9
  "solve": "./src/solve.mjs",
10
10
  "task": "./src/task.mjs",
11
+ "cleanup": "./src/cleanup.mjs",
11
12
  "review": "./src/review.mjs",
12
13
  "configure-claude": "./src/configure-claude.mjs",
13
14
  "start-screen": "./src/start-screen.mjs",
@@ -687,6 +687,8 @@ export const executeClaudeCommand = async params => {
687
687
  // so the comment-posting path can honor them; flags default to false.
688
688
  skipOutputSanitization: argv['dangerously-skip-output-sanitization'] === true,
689
689
  skipActiveTokensOutputSanitization: argv['dangerously-skip-active-tokens-output-sanitization'] === true,
690
+ // Issue #1843: upload & embed images by default; --no-interactive-image-upload opts out.
691
+ imageUploadEnabled: argv['interactive-image-upload'] !== false,
690
692
  });
691
693
  } else if (argv.interactiveMode) {
692
694
  await log('⚠️ Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Core, offline-testable logic for the `cleanup` command (issue #1848).
3
+ *
4
+ * This module deliberately avoids any top-level network access (no `use-m`
5
+ * fetch) and any side effects so it can be unit-tested without a network
6
+ * connection or a real filesystem. All OS interaction (reading /tmp, querying
7
+ * `$ --status`, scanning /proc, deleting paths, apt cleanup) lives in
8
+ * `cleanup.os.lib.mjs` / `cleanup.mjs`; this module only contains pure
9
+ * classification, parsing and formatting helpers.
10
+ *
11
+ * The classification mirrors the manual workflow described in the issue: list
12
+ * the temporary directories under the tmp root, figure out which ones belong to
13
+ * currently-running solve tasks (by branch name, the same way solve.mjs derives
14
+ * branches), and keep those while removing the rest. Protected system paths such
15
+ * as `/tmp/start-command/` are always preserved unless explicitly forced.
16
+ *
17
+ * @see https://github.com/link-assistant/hive-mind/issues/1848
18
+ */
19
+
20
+ import { isValidIssueBranchName } from './solve.branch.lib.mjs';
21
+
22
+ /**
23
+ * Directory names directly under the tmp root that must never be removed by
24
+ * default because deleting them would interfere with the system's ability to
25
+ * run or be debugged. `start-command` holds the isolation session logs that
26
+ * `$ --status`, /log and /terminal_watch rely on.
27
+ */
28
+ export const DEFAULT_PROTECTED_NAMES = ['start-command'];
29
+
30
+ /**
31
+ * System-owned temp entries that we never touch even in `--all` mode unless the
32
+ * user explicitly opts in. These are created by the OS / desktop / language
33
+ * runtimes and removing them mid-flight can break unrelated processes.
34
+ */
35
+ export const SYSTEM_PROTECTED_PATTERNS = [/^\.X11-unix$/, /^\.XIM-unix$/, /^\.ICE-unix$/, /^\.font-unix$/, /^\.Test-unix$/, /^systemd-private-/, /^snap-private-tmp$/, /^snap\./, /^\.snap/, /^dbus-/, /^ssh-/, /^hsperfdata_/, /^\.org\.chromium\./, /^\.com\.google\.Chrome\./];
36
+
37
+ /**
38
+ * Patterns for temporary entries that are unambiguously created by hive-mind
39
+ * (solve.mjs, github.lib.mjs, claude.lib.mjs, telegram-*, etc.). These are safe
40
+ * to delete when they are not tied to an active task. Each entry has a `name`
41
+ * (for reporting) and a `regex` matched against the basename under the tmp root.
42
+ *
43
+ * Sources are referenced inline so future maintainers can keep this list in
44
+ * sync with the code that produces the files.
45
+ */
46
+ export const HIVE_MIND_TEMP_PATTERNS = [
47
+ // solve.repository.lib.mjs / solve.execution.lib.mjs workspace clones
48
+ { name: 'solve workspace clone', regex: /^gh-issue-solver-\d+$/ },
49
+ { name: 'solve resume workspace', regex: /^gh-issue-solver-resume-.+$/ },
50
+ // solve.repository.lib.mjs buildWorkspacePath parent dir
51
+ { name: 'solve workspace root', regex: /^hive-mind-solve-gh-/ },
52
+ // github.lib.mjs log download working dirs
53
+ { name: 'solution draft log dir', regex: /^log-tmp-solution-draft-log-/ },
54
+ // claude.lib.mjs MCP config temp files
55
+ { name: 'claude MCP config', regex: /^claude-mcp-no-useless-.+\.json$/ },
56
+ { name: 'claude MCP config', regex: /^claude-mcp-.+\.json$/ },
57
+ // github.lib.mjs comment / body temp files
58
+ { name: 'solution draft log', regex: /^solution-draft-log-.+\.txt$/ },
59
+ { name: 'log upload comment', regex: /^log-upload-comment-.+\.md$/ },
60
+ { name: 'log comment', regex: /^log-comment-.+\.md$/ },
61
+ // github-error-reporter.lib.mjs
62
+ { name: 'issue body temp', regex: /^hive-mind-issue-body-.+\.md$/ },
63
+ // solve.auto-pr.lib.mjs / solve.results.lib.mjs
64
+ { name: 'PR body temp', regex: /^pr-body-.+$/ },
65
+ { name: 'PR title temp', regex: /^pr-title-.+\.txt$/ },
66
+ // solve.progress-monitoring.lib.mjs
67
+ { name: 'PR progress temp', regex: /^pr-progress-.+$/ },
68
+ // telegram-top-command.lib.mjs
69
+ { name: 'telegram top output', regex: /^top-output-.+\.txt$/ },
70
+ // start-screen.mjs
71
+ { name: 'screen ready marker', regex: /^screen-ready-.+\.marker$/ },
72
+ ];
73
+
74
+ /**
75
+ * Parse a GitHub issue/PR URL out of an arbitrary string (e.g. a solve command
76
+ * line). Self-contained so this module stays offline-safe (github.lib.mjs is
77
+ * not import-safe because of its top-level use-m fetch).
78
+ *
79
+ * @param {string} url
80
+ * @returns {{owner: string, repo: string, type: 'issue'|'pull', number: number}|null}
81
+ */
82
+ export function parseTaskUrl(url) {
83
+ if (!url || typeof url !== 'string') return null;
84
+ const match = url.match(/github\.com[/:]([^/\s]+)\/([^/\s#]+?)(?:\.git)?\/(issues|issue|pull|pulls)\/(\d+)/i);
85
+ if (!match) return null;
86
+ const type = /^pull/i.test(match[3]) ? 'pull' : 'issue';
87
+ return {
88
+ owner: match[1],
89
+ repo: match[2],
90
+ type,
91
+ number: Number(match[4]),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Extract all GitHub issue/PR references from a command line string. A solve
97
+ * command typically takes the URL as its first positional argument, but we scan
98
+ * the whole string to be tolerant of flag ordering.
99
+ *
100
+ * @param {string} command
101
+ * @returns {Array<{owner: string, repo: string, type: 'issue'|'pull', number: number}>}
102
+ */
103
+ export function extractTaskRefsFromCommand(command) {
104
+ if (!command || typeof command !== 'string') return [];
105
+ const refs = [];
106
+ const seen = new Set();
107
+ const re = /github\.com[/:]([^/\s]+)\/([^/\s#]+?)(?:\.git)?\/(issues|issue|pull|pulls)\/(\d+)/gi;
108
+ let m;
109
+ while ((m = re.exec(command)) !== null) {
110
+ const ref = parseTaskUrl(m[0]);
111
+ if (!ref) continue;
112
+ const key = `${ref.owner}/${ref.repo}#${ref.number}:${ref.type}`;
113
+ if (seen.has(key)) continue;
114
+ seen.add(key);
115
+ refs.push(ref);
116
+ }
117
+ return refs;
118
+ }
119
+
120
+ /**
121
+ * Normalise an owner/repo pair extracted from a git remote URL.
122
+ *
123
+ * @param {string} remoteUrl
124
+ * @returns {{owner: string, repo: string}|null}
125
+ */
126
+ export function parseRemoteUrl(remoteUrl) {
127
+ if (!remoteUrl || typeof remoteUrl !== 'string') return null;
128
+ // git@github.com:owner/repo.git OR https://github.com/owner/repo(.git)
129
+ const sshMatch = remoteUrl.match(/^[^@]+@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
130
+ if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
131
+ const httpMatch = remoteUrl.match(/^[a-z]+:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/i);
132
+ if (httpMatch) return { owner: httpMatch[1], repo: httpMatch[2] };
133
+ return null;
134
+ }
135
+
136
+ function sameRepo(a, b) {
137
+ if (!a || !b) return false;
138
+ return a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase();
139
+ }
140
+
141
+ /**
142
+ * Build the set of "active task matchers" from the running session task list.
143
+ * For PR tasks the resolved head branch is matched exactly; for issue tasks we
144
+ * fall back to the `issue-{number}-{hex}` prefix (the random hex is unknown from
145
+ * the URL alone) combined with a repo match.
146
+ *
147
+ * @param {Array<{owner, repo, type, number, branch?: string|null}>} activeTasks
148
+ * @returns {Array<{owner, repo, type, issueNumber: number|null, branch: string|null}>}
149
+ */
150
+ export function buildActiveMatchers(activeTasks) {
151
+ const matchers = [];
152
+ for (const task of activeTasks || []) {
153
+ if (!task) continue;
154
+ matchers.push({
155
+ owner: task.owner,
156
+ repo: task.repo,
157
+ type: task.type,
158
+ issueNumber: task.type === 'issue' ? task.number : (task.issueNumber ?? null),
159
+ branch: task.branch || null,
160
+ });
161
+ }
162
+ return matchers;
163
+ }
164
+
165
+ /**
166
+ * Decide whether a folder's git info matches one of the active task matchers.
167
+ *
168
+ * @param {{branch: string|null, remotes: Array<{owner, repo}>}|null} gitInfo
169
+ * @param {Array} matchers - from buildActiveMatchers
170
+ * @returns {Object|null} the matched matcher, or null
171
+ */
172
+ export function folderMatchesActiveTask(gitInfo, matchers) {
173
+ if (!gitInfo || !Array.isArray(matchers)) return null;
174
+ const remotes = gitInfo.remotes || [];
175
+ for (const m of matchers) {
176
+ // 1. Exact branch match (covers PR continue-mode and any known branch).
177
+ if (m.branch && gitInfo.branch && gitInfo.branch === m.branch) {
178
+ return m;
179
+ }
180
+ // 2. issue-{number}-{hex} prefix match scoped to the same repository.
181
+ if (m.issueNumber != null && gitInfo.branch && isValidIssueBranchName(gitInfo.branch, m.issueNumber)) {
182
+ const repoMatches = remotes.length === 0 || remotes.some(r => sameRepo(r, m));
183
+ if (repoMatches) return m;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function matchesAny(name, patterns) {
190
+ return patterns.some(p => (p.regex || p).test(name));
191
+ }
192
+
193
+ /**
194
+ * Identify the hive-mind temp pattern a name matches, if any.
195
+ *
196
+ * @param {string} name
197
+ * @returns {{name: string}|null}
198
+ */
199
+ export function matchHiveMindPattern(name) {
200
+ return HIVE_MIND_TEMP_PATTERNS.find(p => p.regex.test(name)) || null;
201
+ }
202
+
203
+ /**
204
+ * Pure classification of a single temp entry into keep/remove with a reason.
205
+ *
206
+ * Reason precedence (highest first): protected > self > active-process >
207
+ * active-task > dirty-worktree > hive-mind-temp (remove) > all-mode (remove) >
208
+ * unrecognized (keep).
209
+ *
210
+ * @param {{name: string, path: string, isDirectory: boolean}} entry
211
+ * @param {Object} ctx
212
+ * @param {string[]} ctx.protectedNames
213
+ * @param {boolean} ctx.forceStartCommand
214
+ * @param {boolean} ctx.includeSystem - allow classifying system entries in --all
215
+ * @param {boolean} ctx.includeAll - consider non-hive-mind entries for removal
216
+ * @param {boolean} ctx.keepDirty
217
+ * @param {Set<string>} ctx.selfPaths - absolute paths the cleanup process itself uses
218
+ * @param {Set<string>} ctx.heldPaths - absolute paths held by running processes
219
+ * @param {Array} ctx.matchers - active task matchers
220
+ * @param {Map<string,{branch, remotes, dirty}>} ctx.gitInfoByPath
221
+ * @returns {{action: 'keep'|'remove', reason: string}}
222
+ */
223
+ export function classifyEntry(entry, ctx) {
224
+ const { protectedNames = DEFAULT_PROTECTED_NAMES, forceStartCommand = false, includeSystem = false, includeAll = false, keepDirty = true, selfPaths = new Set(), heldPaths = new Set(), matchers = [], gitInfoByPath = new Map() } = ctx || {};
225
+
226
+ const name = entry.name;
227
+
228
+ // 1. Protected names (start-command can be forced).
229
+ const isStartCommand = name === 'start-command';
230
+ if (protectedNames.includes(name)) {
231
+ if (isStartCommand && forceStartCommand) {
232
+ // fall through to deletion logic below
233
+ } else {
234
+ return { action: 'keep', reason: 'protected' };
235
+ }
236
+ }
237
+
238
+ // 1b. System-owned entries are protected unless explicitly included.
239
+ if (!includeSystem && matchesAny(name, SYSTEM_PROTECTED_PATTERNS)) {
240
+ return { action: 'keep', reason: 'system-protected' };
241
+ }
242
+
243
+ // 2. Paths the cleanup process itself depends on (its own clone / cwd).
244
+ if (selfPaths.has(entry.path)) {
245
+ return { action: 'keep', reason: 'self' };
246
+ }
247
+
248
+ // 3. Held open / used as cwd by a running process.
249
+ if (heldPaths.has(entry.path)) {
250
+ return { action: 'keep', reason: 'active-process' };
251
+ }
252
+
253
+ // 4. Matches an active solve task by branch / repo.
254
+ const gitInfo = gitInfoByPath.get(entry.path);
255
+ const matched = folderMatchesActiveTask(gitInfo, matchers);
256
+ if (matched) {
257
+ return { action: 'keep', reason: 'active-task' };
258
+ }
259
+
260
+ // 5. Dirty / unpushed worktree: keep by default to avoid losing work.
261
+ if (keepDirty && gitInfo && gitInfo.dirty) {
262
+ return { action: 'keep', reason: 'dirty-worktree' };
263
+ }
264
+
265
+ // 6. Recognised hive-mind temp artifact -> safe to remove.
266
+ if (matchHiveMindPattern(name)) {
267
+ return { action: 'remove', reason: isStartCommand ? 'forced-start-command' : 'hive-mind-temp' };
268
+ }
269
+
270
+ // start-command forced but not a hive-mind pattern: still remove when forced.
271
+ if (isStartCommand && forceStartCommand) {
272
+ return { action: 'remove', reason: 'forced-start-command' };
273
+ }
274
+
275
+ // 7. --all mode removes anything not otherwise kept.
276
+ if (includeAll) {
277
+ return { action: 'remove', reason: 'all-mode' };
278
+ }
279
+
280
+ // 8. Default: leave unrecognised entries alone.
281
+ return { action: 'keep', reason: 'unrecognized' };
282
+ }
283
+
284
+ /**
285
+ * Classify a list of temp entries.
286
+ *
287
+ * @param {Array<{name, path, isDirectory, size?: number}>} entries
288
+ * @param {Object} ctx - see classifyEntry
289
+ * @returns {{keep: Array, remove: Array}} each item: {name, path, size, reason}
290
+ */
291
+ export function classifyEntries(entries, ctx) {
292
+ const keep = [];
293
+ const remove = [];
294
+ for (const entry of entries || []) {
295
+ const { action, reason } = classifyEntry(entry, ctx);
296
+ const record = { name: entry.name, path: entry.path, size: entry.size ?? null, reason };
297
+ if (action === 'remove') remove.push(record);
298
+ else keep.push(record);
299
+ }
300
+ return { keep, remove };
301
+ }
302
+
303
+ /**
304
+ * Human-readable, base-1024 byte formatting (matches `du -h` style closely
305
+ * enough for reporting).
306
+ *
307
+ * @param {number|null|undefined} bytes
308
+ * @returns {string}
309
+ */
310
+ export function formatBytes(bytes) {
311
+ if (bytes == null || Number.isNaN(bytes)) return '?';
312
+ if (bytes < 1024) return `${bytes}B`;
313
+ const units = ['K', 'M', 'G', 'T', 'P'];
314
+ let value = bytes / 1024;
315
+ let unit = 0;
316
+ while (value >= 1024 && unit < units.length - 1) {
317
+ value /= 1024;
318
+ unit++;
319
+ }
320
+ const rounded = value >= 10 || Number.isInteger(value) ? Math.round(value) : Math.round(value * 10) / 10;
321
+ return `${rounded}${units[unit]}`;
322
+ }
323
+
324
+ /**
325
+ * Aggregate totals for a classification result.
326
+ *
327
+ * @param {{keep: Array, remove: Array}} classified
328
+ * @returns {{keepCount, removeCount, keepBytes, removeBytes}}
329
+ */
330
+ export function summarize(classified) {
331
+ const sum = list => list.reduce((acc, item) => acc + (item.size || 0), 0);
332
+ return {
333
+ keepCount: classified.keep.length,
334
+ removeCount: classified.remove.length,
335
+ keepBytes: sum(classified.keep),
336
+ removeBytes: sum(classified.remove),
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Human-readable label for a keep/remove reason code.
342
+ * @param {string} reason
343
+ * @returns {string}
344
+ */
345
+ export function describeReason(reason) {
346
+ const map = {
347
+ protected: 'protected path',
348
+ 'system-protected': 'system-owned temp',
349
+ self: 'used by this cleanup process',
350
+ 'active-process': 'in use by a running process',
351
+ 'active-task': 'belongs to an active task',
352
+ 'dirty-worktree': 'has uncommitted/unpushed changes',
353
+ 'hive-mind-temp': 'hive-mind temporary artifact',
354
+ 'forced-start-command': 'start-command (forced)',
355
+ 'all-mode': 'removed by --all',
356
+ unrecognized: 'not a recognised hive-mind artifact',
357
+ };
358
+ return map[reason] || reason;
359
+ }