bosun 0.42.4 → 0.42.6

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.
Files changed (166) hide show
  1. package/.env.example +24 -50
  2. package/README.md +28 -2
  3. package/agent/agent-custom-tools.mjs +148 -38
  4. package/agent/agent-endpoint.mjs +1 -2
  5. package/agent/agent-hooks.mjs +53 -5
  6. package/agent/agent-pool.mjs +216 -37
  7. package/agent/agent-prompt-catalog.mjs +6 -6
  8. package/agent/agent-prompts.mjs +82 -6
  9. package/agent/agent-sdk.mjs +133 -0
  10. package/agent/autofix-prompts.mjs +2 -2
  11. package/agent/autofix.mjs +2 -2
  12. package/agent/bosun-skills.mjs +212 -862
  13. package/agent/fleet-coordinator.mjs +4 -4
  14. package/agent/hook-library.mjs +2 -2
  15. package/agent/hook-profiles.mjs +106 -31
  16. package/agent/primary-agent.mjs +10 -61
  17. package/agent/review-agent.mjs +1 -1
  18. package/bench/swebench/bosun-swebench.mjs +20 -6
  19. package/bosun.config.example.json +55 -2
  20. package/bosun.schema.json +241 -5
  21. package/cli.mjs +87 -41
  22. package/config/config-doctor.mjs +4 -24
  23. package/config/config.mjs +356 -111
  24. package/config/repo-config.mjs +48 -40
  25. package/desktop/main.mjs +116 -16
  26. package/git/git-editor-fix.mjs +2 -42
  27. package/github/github-app-auth.mjs +6 -0
  28. package/github/github-oauth-portal.mjs +20 -0
  29. package/infra/anomaly-detector.mjs +7 -7
  30. package/infra/container-runner.mjs +0 -1
  31. package/infra/error-detector.mjs +1 -1
  32. package/infra/library-manager.mjs +239 -17
  33. package/infra/maintenance.mjs +26 -14
  34. package/infra/monitor.mjs +563 -1924
  35. package/infra/preflight.mjs +37 -2
  36. package/infra/session-tracker.mjs +251 -99
  37. package/infra/tracing.mjs +326 -23
  38. package/infra/worktree-recovery-state.mjs +5 -2
  39. package/kanban/kanban-adapter.mjs +28 -281
  40. package/lib/logger.mjs +21 -25
  41. package/lib/session-insights.mjs +64 -0
  42. package/lib/skill-markdown-safety.mjs +394 -0
  43. package/monitor-tail-sanitizer.mjs +1 -2
  44. package/package.json +19 -25
  45. package/postinstall.mjs +0 -1
  46. package/server/bosun-mcp-server.mjs +15 -7
  47. package/server/setup-web-server.mjs +277 -23
  48. package/server/ui-server.mjs +1452 -291
  49. package/setup.mjs +181 -296
  50. package/shared-workspaces.json +1 -1
  51. package/shell/codex-config.mjs +176 -252
  52. package/shell/codex-model-profiles.mjs +108 -14
  53. package/shell/codex-shell.mjs +144 -21
  54. package/shell/copilot-shell.mjs +23 -5
  55. package/shell/opencode-providers.mjs +393 -70
  56. package/shell/pwsh-runtime.mjs +9 -2
  57. package/task/task-claims.mjs +1 -1
  58. package/task/task-cli.mjs +11 -10
  59. package/task/task-complexity.mjs +6 -6
  60. package/task/task-executor.mjs +103 -74
  61. package/task/task-store.mjs +36 -4
  62. package/telegram/telegram-bot.mjs +195 -118
  63. package/telegram/telegram-sentinel.mjs +1 -1
  64. package/tools/import-check.mjs +234 -0
  65. package/tools/packed-cli-smoke.mjs +67 -6
  66. package/tools/prompt-lint.mjs +161 -0
  67. package/tools/site-serve.mjs +112 -0
  68. package/tools/syntax-check.mjs +29 -0
  69. package/tools/test-kanban-enhancement.mjs +7 -7
  70. package/tools/test-shared-state-integration.mjs +6 -27
  71. package/tools/vitest-runner.mjs +32 -5
  72. package/tui/app.mjs +105 -49
  73. package/tui/components/status-header.mjs +4 -1
  74. package/tui/lib/fuzzy-score.mjs +61 -0
  75. package/tui/lib/header-config.mjs +0 -2
  76. package/tui/lib/navigation.mjs +16 -0
  77. package/tui/lib/sparkline.mjs +38 -0
  78. package/tui/lib/ws-bridge.mjs +93 -22
  79. package/tui/screens/agents-screen-helpers.mjs +1 -1
  80. package/tui/screens/agents.mjs +112 -90
  81. package/tui/screens/logs.mjs +329 -0
  82. package/tui/screens/status.mjs +4 -1
  83. package/tui/screens/tasks-screen-helpers.mjs +78 -0
  84. package/ui/app.js +412 -122
  85. package/ui/app.monolith.js +2 -3
  86. package/ui/components/commit-graph.js +648 -0
  87. package/ui/components/diff-viewer.js +88 -21
  88. package/ui/components/forms.js +13 -2
  89. package/ui/components/kanban-board.js +36 -6
  90. package/ui/components/session-list.js +11 -1
  91. package/ui/components/shared.js +9 -1
  92. package/ui/components/workspace-executor-settings.js +142 -0
  93. package/ui/components/workspace-switcher.js +24 -77
  94. package/ui/demo-defaults.js +3003 -2788
  95. package/ui/demo.html +6102 -5186
  96. package/ui/index.html +164 -113
  97. package/ui/modules/api.js +109 -34
  98. package/ui/modules/icon-utils.js +9 -1
  99. package/ui/modules/icons.js +9 -1
  100. package/ui/modules/repo-area-contention.js +97 -0
  101. package/ui/modules/settings-schema.js +4 -3
  102. package/ui/modules/state.js +153 -46
  103. package/ui/setup.html +3152 -2531
  104. package/ui/styles/components.css +32 -0
  105. package/ui/styles/layout.css +246 -34
  106. package/ui/styles.css +1 -5
  107. package/ui/tabs/agents.js +209 -9
  108. package/ui/tabs/control.js +226 -61
  109. package/ui/tabs/dashboard.js +92 -41
  110. package/ui/tabs/infra.js +91 -12
  111. package/ui/tabs/library.js +142 -17
  112. package/ui/tabs/logs.js +410 -52
  113. package/ui/tabs/settings.js +513 -1
  114. package/ui/tabs/tasks.js +328 -24
  115. package/ui/tabs/telemetry.js +144 -2
  116. package/ui/tabs/workflow-canvas-utils.mjs +555 -13
  117. package/ui/tabs/workflows.js +919 -161
  118. package/ui/tui/App.js +10 -2
  119. package/ui/tui/TasksScreen.js +11 -2
  120. package/ui/tui/logs-screen-helpers.js +292 -0
  121. package/ui/tui/useTasks.js +6 -1
  122. package/ui/tui/useWebSocket.js +7 -1
  123. package/ui/tui/useWorkflows.js +6 -1
  124. package/ui/vendor/preact-jsx-runtime.js +5 -0
  125. package/utils.mjs +24 -11
  126. package/voice/vision-session-state.mjs +317 -2
  127. package/voice/voice-action-dispatcher.mjs +184 -9
  128. package/voice/voice-relay.mjs +33 -0
  129. package/workflow/execution-ledger.mjs +534 -3
  130. package/workflow/heavy-runner-pool.mjs +479 -0
  131. package/workflow/mcp-discovery-proxy.mjs +14 -6
  132. package/workflow/mcp-registry.mjs +177 -39
  133. package/workflow/workflow-cli.mjs +45 -1
  134. package/workflow/workflow-engine.mjs +565 -118
  135. package/workflow/workflow-migration.mjs +0 -1
  136. package/workflow/workflow-nodes/custom-loader.mjs +259 -56
  137. package/workflow/workflow-nodes/definitions.mjs +34 -14
  138. package/workflow/workflow-nodes/transforms.mjs +33 -6
  139. package/workflow/workflow-nodes.mjs +1458 -276
  140. package/workflow/workflow-templates.mjs +114 -4
  141. package/workflow-templates/code-quality.mjs +216 -0
  142. package/workflow-templates/continuation-loop.mjs +2 -1
  143. package/workflow-templates/github.mjs +412 -128
  144. package/workflow-templates/planning.mjs +22 -1
  145. package/workflow-templates/reliability.mjs +82 -25
  146. package/workflow-templates/task-batch.mjs +4 -12
  147. package/workflow-templates/task-lifecycle.mjs +27 -256
  148. package/workspace/command-diagnostics.mjs +111 -0
  149. package/workspace/context-cache.mjs +404 -52
  150. package/workspace/shared-workspace-registry.mjs +2 -2
  151. package/workspace/workspace-monitor.mjs +1 -1
  152. package/workspace/worktree-manager.mjs +92 -43
  153. package/workspace/worktree-setup.mjs +231 -0
  154. package/git/sdk-conflict-resolver.mjs +0 -971
  155. package/infra/sync-engine.mjs +0 -1160
  156. package/kanban/ve-kanban.mjs +0 -664
  157. package/kanban/ve-kanban.ps1 +0 -1365
  158. package/kanban/ve-kanban.sh +0 -18
  159. package/kanban/ve-orchestrator.mjs +0 -340
  160. package/kanban/ve-orchestrator.ps1 +0 -6762
  161. package/kanban/ve-orchestrator.sh +0 -18
  162. package/kanban/vibe-kanban-wrapper.mjs +0 -41
  163. package/kanban/vk-error-resolver.mjs +0 -474
  164. package/kanban/vk-log-stream.mjs +0 -932
  165. package/task/task-archiver.mjs +0 -813
  166. package/tools/publish.mjs +0 -239
package/.env.example CHANGED
@@ -1,3 +1,6 @@
1
+ # Max characters of matched built-in/local skills injected into an agent task prompt.
2
+ # Skills are only injected when task title/description/labels match skill tags.
3
+ BOSUN_SKILLS_MAX_CHARS=4000
1
4
  # ─── Bosun — Environment Configuration ───────────────────────────────
2
5
  # Copy this file to .env and fill in your values.
3
6
  # Or run: bosun --setup
@@ -88,6 +91,8 @@ TELEGRAM_MINIAPP_ENABLED=false
88
91
  # BOSUN_UI_BROWSER_OPEN_MODE=manual
89
92
  # Legacy auto-open toggle for UI server (requires BOSUN_UI_BROWSER_OPEN_MODE=auto)
90
93
  # BOSUN_UI_AUTO_OPEN_BROWSER=false
94
+ # Daemon startup keeps browser auto-open disabled unless this is explicitly true.
95
+ # BOSUN_UI_AUTO_OPEN_ON_DAEMON=false
91
96
  # Show full /?token=... browser URL in logs (default: false; token is hidden)
92
97
  # BOSUN_UI_LOG_TOKENIZED_BROWSER_URL=false
93
98
  # Setup wizard browser auto-open (default: true when mode=auto)
@@ -350,12 +355,8 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk
350
355
  # FAILOVER_DISABLE_AFTER=3
351
356
 
352
357
  # ─── Internal Executor ───────────────────────────────────────────────────────
353
- # Controls whether tasks are executed locally via agent-pool instead of
354
- # (or alongside) VK's cloud executor. Modes:
355
- # "vk" — all tasks via VK executor (default, existing behavior)
356
- # "internal" — all tasks via local agent-pool (bypass wrapper orchestrator script)
357
- # "hybrid" — both VK and internal run simultaneously for overflow
358
- # EXECUTOR_MODE=vk
358
+ # Controls whether tasks are executed locally via agent-pool.
359
+ # EXECUTOR_MODE=internal
359
360
  # Max concurrent agent slots for internal executor (default: 3)
360
361
  # INTERNAL_EXECUTOR_PARALLEL=3
361
362
  # INTERNAL_EXECUTOR_BASE_BRANCH_PARALLEL=0
@@ -495,7 +496,6 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk
495
496
  # ─── Kanban Backend ──────────────────────────────────────────────────────────
496
497
  # Task-board backend:
497
498
  # internal - local task-store source of truth (recommended primary)
498
- # vk - Vibe-Kanban (secondary adapter)
499
499
  # github - GitHub Issues
500
500
  # jira - Jira Issues
501
501
  # KANBAN_BACKEND=internal
@@ -661,37 +661,6 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk
661
661
  # Stop auto-restarts after this many instant failures in a row (default: 3)
662
662
  # BOSUN_DAEMON_MAX_INSTANT_RESTARTS=3
663
663
 
664
- # ─── Vibe-Kanban ──────────────────────────────────────────────────────────────
665
- # Base URL for the Vibe-Kanban API (default: http://127.0.0.1:54089)
666
- VK_BASE_URL=http://127.0.0.1:54089
667
- # Alternate endpoint URL for VK (overrides VK_BASE_URL if set)
668
- # VK_ENDPOINT_URL=http://127.0.0.1:54089
669
- # Port for vibe-kanban API (default: 54089)
670
- VK_RECOVERY_PORT=54089
671
- # Host for VK recovery (default: 0.0.0.0)
672
- # VK_RECOVERY_HOST=0.0.0.0
673
- # VK_HOST=0.0.0.0
674
- # Public URL shown in Telegram links (optional)
675
- # VK_PUBLIC_URL=https://kanban.yoursite.com
676
- # VK_WEB_URL=https://kanban.yoursite.com
677
- # VK HTTP timeout/retry controls (used by ve-kanban.ps1)
678
- # VK_HTTP_TIMEOUT_SEC=45
679
- # VK_HTTP_RETRIES=2
680
- # VK_HTTP_RETRY_DELAY_MS=1500
681
- # Set to true to prevent the monitor from spawning vibe-kanban automatically
682
- # VK_NO_SPAWN=false
683
- # Cooldown minutes between VK recovery attempts (default: 10)
684
- # VK_RECOVERY_COOLDOWN_MIN=10
685
- # VK health check interval in ms (default: 60000)
686
- # VK_ENSURE_INTERVAL=60000
687
- # VK project name (auto-detected)
688
- # VK_PROJECT_NAME=my-project
689
- # Explicit VK project/repo IDs (auto-detected if empty)
690
- # VK_PROJECT_ID=
691
- # VK_REPO_ID=
692
- # Override task URL template (optional)
693
- # VK_TASK_URL_TEMPLATE=https://kanban.yoursite.com/projects/{projectId}/tasks/{taskId}
694
-
695
664
  # ─── Shared Workspace Registry ───────────────────────────────────────────────
696
665
  # Optional registry path for shared workspace leasing
697
666
  # VE_SHARED_WORKSPACE_REGISTRY=.cache/bosun/shared-workspaces.json
@@ -713,13 +682,8 @@ VK_RECOVERY_PORT=54089
713
682
  # GITHUB_TOKEN=
714
683
  # GH_TOKEN=
715
684
  # GITHUB_PAT=
716
- # Owner/repo for gh CLI in ve-kanban
717
- # GH_OWNER=virtengine
718
- # GH_REPO=virtengine
719
685
  # Target branch for PR checks/merge (default: origin/main)
720
- # VK_TARGET_BRANCH=origin/main
721
- # Default upstream/base branch for bosun tasks (overrides VK_TARGET_BRANCH)
722
- # BOSUN_TASK_UPSTREAM=origin/ve/bosun-generic
686
+ # BOSUN_TASK_UPSTREAM=origin/main
723
687
 
724
688
  # ─── Codex / AI Provider ─────────────────────────────────────────────────────
725
689
  # The Codex SDK uses OpenAI-compatible configuration that has been setup in ~/.codex/config.toml -
@@ -1024,7 +988,7 @@ COPILOT_CLOUD_DISABLED=true
1024
988
 
1025
989
  # ─── Task Planner ─────────────────────────────────────────────────────────────
1026
990
  # How to plan new tasks when backlog is empty:
1027
- # "kanban" - (default) create a VK planning task for an agent to refine
991
+ # "kanban" - (default) create a planning task for an agent to refine
1028
992
  # "codex-sdk" - run Codex SDK directly to generate tasks
1029
993
  # "disabled" - do nothing, wait for manual task creation
1030
994
  # TASK_PLANNER_MODE=kanban
@@ -1056,6 +1020,17 @@ COPILOT_CLOUD_DISABLED=true
1056
1020
  # WORKFLOW_RECOVERY_BACKOFF_MAX_MS=60000
1057
1021
  # Random jitter ratio (0.0-0.9) applied to backoff to prevent retry storms.
1058
1022
  # WORKFLOW_RECOVERY_BACKOFF_JITTER_RATIO=0.2
1023
+ # Delay startup workflow recovery actions so the daemon can settle before
1024
+ # resuming interrupted runs or firing schedule/task-poll recovery.
1025
+ # WORKFLOW_RECOVERY_STARTUP_GRACE_MS=30000
1026
+ # Additional delay inserted between each startup recovery action.
1027
+ # WORKFLOW_RECOVERY_STARTUP_STEP_DELAY_MS=15000
1028
+
1029
+ # Bosun MCP policy: by default Bosun-launched agents only receive validated
1030
+ # library-managed MCP servers, with required auth pulled from environment.
1031
+ # BOSUN_MCP_REQUIRE_AUTH=true
1032
+ # BOSUN_MCP_ALLOW_EXTERNAL_SOURCES=false
1033
+ # BOSUN_MCP_ALLOW_DEFAULT_SERVERS=false
1059
1034
 
1060
1035
  # ─── GitHub Issue Reconciler ─────────────────────────────────────────────────
1061
1036
  # Periodically reconciles open GitHub issues against open/merged PRs.
@@ -1135,12 +1110,12 @@ COPILOT_CLOUD_DISABLED=true
1135
1110
  # Repository root (auto-detected from git; setup writes this)
1136
1111
  # REPO_ROOT=/path/to/repo
1137
1112
  # Watch path to trigger restarts (default: script path)
1138
- # WATCH_PATH=/path/to/ve-orchestrator.sh
1113
+ # WATCH_PATH=/path/to/orchestrator.sh
1139
1114
  # Monitor source hot-reload watcher. Default: enabled in devmode, disabled otherwise.
1140
1115
  # Set to true to force-enable monitor source hot-restart, false to force-disable.
1141
1116
  # SELF_RESTART_WATCH_ENABLED=true
1142
- # Status file path (default: .cache/ve-orchestrator-status.json)
1143
- # STATUS_FILE=.cache/ve-orchestrator-status.json
1117
+ # Status file path (default: .cache/orchestrator-status.json)
1118
+ # STATUS_FILE=.cache/orchestrator-status.json
1144
1119
  # Log directory (default: ./logs)
1145
1120
  # LOG_DIR=./logs
1146
1121
  # Max total log folder size in MB. Oldest logs are deleted when exceeded. 0 = unlimited.
@@ -1161,8 +1136,6 @@ COPILOT_CLOUD_DISABLED=true
1161
1136
  # AGENT_WORK_LOGGING_ENABLED=true
1162
1137
  # Enable/disable live stream analyzer (default: true)
1163
1138
  # AGENT_WORK_ANALYZER_ENABLED=true
1164
- # Enrich missing task metadata from VK for agent work logs (default: true)
1165
- # AGENT_WORK_LOGGING_ENRICH_VK=true
1166
1139
  # Task metadata cache (auto-managed): .cache/agent-work-logs/task-metadata.json
1167
1140
  # Log directory (default: .cache/agent-work-logs)
1168
1141
  # AGENT_WORK_LOG_DIR=.cache/agent-work-logs
@@ -1184,3 +1157,4 @@ COPILOT_CLOUD_DISABLED=true
1184
1157
  # OpenTelemetry tracing (optional)
1185
1158
  # BOSUN_OTEL_ENDPOINT=http://localhost:4318/v1/traces
1186
1159
 
1160
+
package/README.md CHANGED
@@ -108,7 +108,7 @@ Fallback admin auth (secondary path) is available and stores only Argon2id hash
108
108
  - Persists workflow runs to disk and auto-resumes on restart
109
109
  - Monitors runs and recovers from stalled or broken states
110
110
  - Provides Telegram control and a Mini App dashboard
111
- - Integrates with GitHub, Jira, and Vibe-Kanban boards
111
+ - Integrates with GitHub and Jira boards
112
112
 
113
113
  ## Autonomous Engineer Workflow Capabilities
114
114
 
@@ -144,6 +144,16 @@ Set `primaryAgent` in `.bosun/bosun.config.json` or choose an executor preset du
144
144
  - `bosun --daemon --sentinel` starts daemon + sentinel together (recommended for unattended operation).
145
145
  - `bosun --terminate` is the clean reset command when you suspect stale/ghost processes.
146
146
 
147
+ ## VS Code debugging
148
+
149
+ Bosun now includes workspace debug entries in `.vscode/launch.json` and helper tasks in `.vscode/tasks.json`.
150
+
151
+ - `Debug Bosun CLI` launches `cli.mjs` with the repo-local `.bosun` config and attaches the debugger to the real CLI entry path.
152
+ - `Debug Bosun Monitor Direct` launches `infra/monitor.mjs` directly when you want to debug monitor logic without stepping through the CLI worker bootstrap.
153
+ - `Debug Bosun Daemon Child (foreground)` runs the daemon-child path without detaching, which is useful for restart-loop and daemon-specific behavior.
154
+ - `Attach to Bosun CLI Startup (9229)` and `Attach to Bosun Monitor Startup (9230)` start Bosun under `--inspect-brk` so you can catch startup failures before normal breakpoints would bind.
155
+ - `Bosun: Terminate Runtime` is the cleanup task to use if a stale monitor/daemon is holding the lock before a debug session.
156
+
147
157
  Telegram operators can pull the weekly agent work summary with `/weekly [days]` or `/report weekly [days]`. To post it automatically once per week, set `TELEGRAM_WEEKLY_REPORT_ENABLED=true` together with `TELEGRAM_WEEKLY_REPORT_DAY`, `TELEGRAM_WEEKLY_REPORT_HOUR`, and optional `TELEGRAM_WEEKLY_REPORT_DAYS`.
148
158
 
149
159
  ## Documentation
@@ -162,6 +172,20 @@ Key places to start:
162
172
  - `docs/agent-logging-quickstart.md` - agent work logging quickstart
163
173
  - `docs/agent-work-logging-design.md` - logging design and event model
164
174
 
175
+ ## Troubleshooting
176
+
177
+ ### Preflight warns about an interactive git editor
178
+
179
+ If preflight reports an interactive git editor such as `code --wait`, `vim`, or `nano`, Bosun can deadlock while Git waits for an editor session to close.
180
+
181
+ Run this from the repo root to switch the local repo config to a non-interactive editor:
182
+
183
+ ```bash
184
+ node git-editor-fix.mjs
185
+ ```
186
+
187
+ Preflight checks both `GIT_EDITOR` and `git config --get core.editor`. No warning is shown when `core.editor` is already non-interactive, for example `:`.
188
+
165
189
  ---
166
190
 
167
191
  ## CI/CD and quality gates
@@ -228,7 +252,7 @@ npm run hooks:install
228
252
  - `server/` — setup server, Mini App backend, and API endpoints
229
253
  - `ui/` — Mini App frontend assets and operator dashboard modules
230
254
  - `telegram/` — Telegram bot, sentinel, and channel integrations
231
- - `github/` and `kanban/` — GitHub auth/webhooks and Vibe-Kanban adapters
255
+ - `github/` and `kanban/` — GitHub auth/webhooks and kanban adapters
232
256
  - `workspace/` — shared workspace registry, context indexing, and worktree lifecycle
233
257
  - `shell/` and `agent/` — executor integrations, prompts, hooks, and fleet coordination
234
258
  - `site/` — marketing site and generated docs website assets
@@ -251,3 +275,5 @@ If you find this project useful or would like to stay up to date with new releas
251
275
  ## License
252
276
 
253
277
  Apache-2.0
278
+
279
+
@@ -47,7 +47,7 @@ import {
47
47
  rmSync,
48
48
  writeFileSync,
49
49
  } from "node:fs";
50
- import { copyFile, writeFile } from "node:fs/promises";
50
+ import { copyFile } from "node:fs/promises";
51
51
  import { homedir } from "node:os";
52
52
  import { basename, dirname, extname, resolve } from "node:path";
53
53
  import { fileURLToPath } from "node:url";
@@ -212,7 +212,10 @@ function safeReadIndex(storeDir) {
212
212
  if (!existsSync(idx)) return [];
213
213
  try {
214
214
  const parsed = JSON.parse(readFileSync(idx, "utf8"));
215
- return Array.isArray(parsed) ? parsed : [];
215
+ if (!Array.isArray(parsed)) return [];
216
+ return parsed
217
+ .map((entry) => sanitizeIndexEntry(entry))
218
+ .filter(Boolean);
216
219
  } catch {
217
220
  return [];
218
221
  }
@@ -243,6 +246,77 @@ function scriptPath(storeDir, id, lang) {
243
246
  return resolve(storeDir, `${id}.${lang}`);
244
247
  }
245
248
 
249
+ function isPlainObject(value) {
250
+ return value != null && typeof value === "object" && !Array.isArray(value);
251
+ }
252
+
253
+ function normalizeStringList(values, { lowercase = true } = {}) {
254
+ if (!Array.isArray(values)) return [];
255
+ const normalized = values
256
+ .map((value) => String(value ?? "").trim())
257
+ .filter(Boolean)
258
+ .map((value) => (lowercase ? value.toLowerCase() : value));
259
+ return Array.from(new Set(normalized));
260
+ }
261
+
262
+ function normalizeUsageCount(value) {
263
+ const numeric = Number(value);
264
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : 0;
265
+ }
266
+
267
+ function normalizeToolId(toolId) {
268
+ if (typeof toolId !== "string") return null;
269
+ const trimmed = toolId.trim();
270
+ if (!trimmed) return null;
271
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/.test(trimmed)) return null;
272
+ return trimmed;
273
+ }
274
+
275
+ function sanitizeIndexEntry(raw) {
276
+ if (!isPlainObject(raw)) return null;
277
+
278
+ const id = normalizeToolId(raw.id);
279
+ const lang = VALID_LANGS.includes(raw.lang) ? raw.lang : null;
280
+ if (!id || !lang) return null;
281
+
282
+ const category = TOOL_CATEGORIES.includes(raw.category)
283
+ ? raw.category
284
+ : "utility";
285
+ const skills = normalizeStringList(raw.skills, { lowercase: false });
286
+ const agents = normalizeStringList(raw.agents, { lowercase: false });
287
+ const templates = normalizeStringList(raw.templates, { lowercase: false });
288
+
289
+ return {
290
+ ...raw,
291
+ id,
292
+ title: typeof raw.title === "string" && raw.title.trim() ? raw.title : id,
293
+ description: typeof raw.description === "string" ? raw.description : "",
294
+ tags: normalizeStringList(raw.tags),
295
+ category,
296
+ lang,
297
+ createdBy: typeof raw.createdBy === "string" && raw.createdBy.trim()
298
+ ? raw.createdBy
299
+ : "agent",
300
+ createdAt: typeof raw.createdAt === "string" && raw.createdAt.trim()
301
+ ? raw.createdAt
302
+ : nowISO(),
303
+ updatedAt: typeof raw.updatedAt === "string" && raw.updatedAt.trim()
304
+ ? raw.updatedAt
305
+ : nowISO(),
306
+ usageCount: normalizeUsageCount(raw.usageCount),
307
+ ...(typeof raw.lastUsed === "string" && raw.lastUsed.trim()
308
+ ? { lastUsed: raw.lastUsed }
309
+ : {}),
310
+ ...(skills.length > 0 ? { skills } : {}),
311
+ ...(agents.length > 0 ? { agents } : {}),
312
+ ...(templates.length > 0 ? { templates } : {}),
313
+ ...(raw.autoInject ? { autoInject: true } : {}),
314
+ ...(typeof raw.version === "string" && raw.version.trim()
315
+ ? { version: raw.version }
316
+ : {}),
317
+ };
318
+ }
319
+
246
320
  // ── Types ─────────────────────────────────────────────────────────────────────
247
321
 
248
322
  /**
@@ -302,6 +376,9 @@ export function listCustomTools(rootDir, opts = {}) {
302
376
  includeBuiltins = true,
303
377
  } = opts;
304
378
 
379
+ const requestedTags = normalizeStringList(tags);
380
+ const searchQuery = typeof search === "string" ? search.trim().toLowerCase() : "";
381
+
305
382
  let entries = [];
306
383
 
307
384
  // Workspace tools
@@ -339,23 +416,24 @@ export function listCustomTools(rootDir, opts = {}) {
339
416
  if (category) {
340
417
  entries = entries.filter((e) => e.category === category);
341
418
  }
342
- if (tags.length > 0) {
419
+ if (requestedTags.length > 0) {
343
420
  entries = entries.filter((e) =>
344
- tags.some((t) => (e.tags || []).includes(t)),
421
+ requestedTags.some((t) => (e.tags || []).includes(t)),
345
422
  );
346
423
  }
347
- if (search) {
348
- const q = search.toLowerCase();
424
+ if (searchQuery) {
349
425
  entries = entries.filter(
350
426
  (e) =>
351
- e.id.includes(q) ||
352
- e.title.toLowerCase().includes(q) ||
353
- e.description.toLowerCase().includes(q) ||
354
- (e.tags || []).some((t) => t.includes(q)),
427
+ e.id.toLowerCase().includes(searchQuery) ||
428
+ e.title.toLowerCase().includes(searchQuery) ||
429
+ e.description.toLowerCase().includes(searchQuery) ||
430
+ (e.tags || []).some((t) => t.toLowerCase().includes(searchQuery)),
355
431
  );
356
432
  }
357
433
 
358
- return entries.sort((a, b) => b.usageCount - a.usageCount);
434
+ return entries.sort(
435
+ (a, b) => normalizeUsageCount(b.usageCount) - normalizeUsageCount(a.usageCount),
436
+ );
359
437
  }
360
438
 
361
439
  /**
@@ -366,11 +444,14 @@ export function listCustomTools(rootDir, opts = {}) {
366
444
  * @returns {{ entry: CustomToolEntry, script: string }|null}
367
445
  */
368
446
  export function getCustomTool(rootDir, toolId) {
447
+ const normalizedToolId = normalizeToolId(toolId);
448
+ if (!normalizedToolId) return null;
449
+
369
450
  // Workspace-scoped takes precedence, then global, then builtin
370
451
  for (const isGlobal of [false, true]) {
371
452
  const storeDir = getToolStore(rootDir, { global: isGlobal });
372
453
  const index = safeReadIndex(storeDir);
373
- const entry = index.find((e) => e.id === toolId);
454
+ const entry = index.find((e) => e.id === normalizedToolId);
374
455
  if (!entry) continue;
375
456
 
376
457
  const sPath = scriptPath(storeDir, entry.id, entry.lang);
@@ -383,7 +464,7 @@ export function getCustomTool(rootDir, toolId) {
383
464
  }
384
465
 
385
466
  // Fall back to built-in tools shipped with bosun
386
- const builtinDef = BUILTIN_TOOLS.find((b) => b.id === toolId);
467
+ const builtinDef = BUILTIN_TOOLS.find((b) => b.id === normalizedToolId);
387
468
  if (builtinDef) {
388
469
  const sPath = resolve(BUILTIN_TOOLS_DIR, `${builtinDef.id}.${builtinDef.lang}`);
389
470
  if (existsSync(sPath)) {
@@ -414,6 +495,10 @@ export function getCustomTool(rootDir, toolId) {
414
495
  * @returns {CustomToolEntry}
415
496
  */
416
497
  export function registerCustomTool(rootDir, def) {
498
+ if (!isPlainObject(def)) {
499
+ throw new TypeError("registerCustomTool: definition object is required");
500
+ }
501
+
417
502
  const {
418
503
  title,
419
504
  description,
@@ -449,19 +534,29 @@ export function registerCustomTool(rootDir, def) {
449
534
  );
450
535
  }
451
536
 
537
+ const explicitId = def.id == null ? null : normalizeToolId(def.id);
538
+ if (def.id != null && !explicitId) {
539
+ throw new TypeError(
540
+ "registerCustomTool: id must match /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/ and must not contain path separators",
541
+ );
542
+ }
543
+
452
544
  const storeDir = getToolStore(rootDir, { global: isGlobal });
453
545
  const index = safeReadIndex(storeDir);
454
546
 
455
- const id = def.id || slugify(title) || `tool-${Date.now()}`;
547
+ const id = explicitId || slugify(title) || `tool-${Date.now()}`;
456
548
  const existingIdx = index.findIndex((e) => e.id === id);
457
549
  const now = nowISO();
550
+ const normalizedSkills = normalizeStringList(skills, { lowercase: false });
551
+ const normalizedAgents = normalizeStringList(agents, { lowercase: false });
552
+ const normalizedTemplates = normalizeStringList(templates, { lowercase: false });
458
553
 
459
554
  /** @type {CustomToolEntry} */
460
555
  const entry = {
461
556
  id,
462
557
  title,
463
558
  description: description || "",
464
- tags: Array.from(new Set(tags.map((t) => String(t).toLowerCase()))),
559
+ tags: normalizeStringList(tags),
465
560
  category,
466
561
  lang,
467
562
  createdBy,
@@ -474,9 +569,9 @@ export function registerCustomTool(rootDir, def) {
474
569
  : {}),
475
570
  scope: isGlobal ? "global" : "workspace",
476
571
  // Affinity metadata (persisted for future skill/agent matching)
477
- ...(skills.length > 0 ? { skills } : {}),
478
- ...(agents.length > 0 ? { agents } : {}),
479
- ...(templates.length > 0 ? { templates } : {}),
572
+ ...(normalizedSkills.length > 0 ? { skills: normalizedSkills } : {}),
573
+ ...(normalizedAgents.length > 0 ? { agents: normalizedAgents } : {}),
574
+ ...(normalizedTemplates.length > 0 ? { templates: normalizedTemplates } : {}),
480
575
  ...(autoInject ? { autoInject } : {}),
481
576
  ...(version ? { version } : {}),
482
577
  };
@@ -518,6 +613,10 @@ export async function invokeCustomTool(rootDir, toolId, args = [], opts = {}) {
518
613
  throw new Error(`invokeCustomTool: tool "${toolId}" not found`);
519
614
  }
520
615
 
616
+ const cliArgs = Array.isArray(args)
617
+ ? args.map((arg) => String(arg))
618
+ : [String(args)];
619
+
521
620
  const { entry } = result;
522
621
  let sPath;
523
622
  if (entry.scope === "builtin") {
@@ -536,15 +635,15 @@ export async function invokeCustomTool(rootDir, toolId, args = [], opts = {}) {
536
635
  switch (entry.lang) {
537
636
  case "mjs":
538
637
  cmd = process.execPath; // use same node binary
539
- cmdArgs = [sPath, ...args];
638
+ cmdArgs = [sPath, ...cliArgs];
540
639
  break;
541
640
  case "sh":
542
641
  cmd = process.platform === "win32" ? "bash" : "/bin/sh";
543
- cmdArgs = [sPath, ...args];
642
+ cmdArgs = [sPath, ...cliArgs];
544
643
  break;
545
644
  case "py":
546
- cmd = "python3";
547
- cmdArgs = [sPath, ...args];
645
+ cmd = process.platform === "win32" ? "python" : "python3";
646
+ cmdArgs = [sPath, ...cliArgs];
548
647
  break;
549
648
  default:
550
649
  throw new Error(`invokeCustomTool: unsupported lang "${entry.lang}"`);
@@ -601,10 +700,13 @@ export async function invokeCustomTool(rootDir, toolId, args = [], opts = {}) {
601
700
  * @returns {Promise<void>}
602
701
  */
603
702
  export async function recordToolUsage(rootDir, toolId) {
703
+ const normalizedToolId = normalizeToolId(toolId);
704
+ if (!normalizedToolId) return;
705
+
604
706
  for (const isGlobal of [false, true]) {
605
707
  const storeDir = getToolStore(rootDir, { global: isGlobal });
606
708
  const index = safeReadIndex(storeDir);
607
- const idx = index.findIndex((e) => e.id === toolId);
709
+ const idx = index.findIndex((e) => e.id === normalizedToolId);
608
710
  if (idx < 0) continue;
609
711
  index[idx].usageCount = (index[idx].usageCount ?? 0) + 1;
610
712
  index[idx].lastUsed = nowISO();
@@ -622,9 +724,12 @@ export async function recordToolUsage(rootDir, toolId) {
622
724
  * @returns {boolean} true if the tool was found and removed
623
725
  */
624
726
  export function deleteCustomTool(rootDir, toolId, { global: isGlobal = false } = {}) {
727
+ const normalizedToolId = normalizeToolId(toolId);
728
+ if (!normalizedToolId) return false;
729
+
625
730
  const storeDir = getToolStore(rootDir, { global: isGlobal });
626
731
  const index = safeReadIndex(storeDir);
627
- const idx = index.findIndex((e) => e.id === toolId);
732
+ const idx = index.findIndex((e) => e.id === normalizedToolId);
628
733
  if (idx < 0) return false;
629
734
 
630
735
  const entry = index[idx];
@@ -651,9 +756,14 @@ export function deleteCustomTool(rootDir, toolId, { global: isGlobal = false } =
651
756
  * @returns {Promise<CustomToolEntry>} the entry as it now exists in global scope
652
757
  */
653
758
  export async function promoteToGlobal(rootDir, toolId) {
759
+ const normalizedToolId = normalizeToolId(toolId);
760
+ if (!normalizedToolId) {
761
+ throw new Error(`promoteToGlobal: workspace tool "${toolId}" not found`);
762
+ }
763
+
654
764
  const wsStore = getToolStore(rootDir, { global: false });
655
765
  const wsIndex = safeReadIndex(wsStore);
656
- const wsEntry = wsIndex.find((e) => e.id === toolId);
766
+ const wsEntry = wsIndex.find((e) => e.id === normalizedToolId);
657
767
  if (!wsEntry) {
658
768
  throw new Error(
659
769
  `promoteToGlobal: workspace tool "${toolId}" not found`,
@@ -676,7 +786,7 @@ export async function promoteToGlobal(rootDir, toolId) {
676
786
 
677
787
  // Upsert in global index
678
788
  const globalEntry = { ...wsEntry, scope: "global", updatedAt: nowISO() };
679
- const existingIdx = globalIndex.findIndex((e) => e.id === toolId);
789
+ const existingIdx = globalIndex.findIndex((e) => e.id === normalizedToolId);
680
790
  if (existingIdx >= 0) {
681
791
  globalIndex[existingIdx] = globalEntry;
682
792
  } else {
@@ -722,6 +832,8 @@ export function getToolsPromptBlock(rootDir, opts = {}) {
722
832
  template,
723
833
  limit,
724
834
  includeBuiltins,
835
+ category,
836
+ tags,
725
837
  });
726
838
  const affinityIds = new Set(affinityTools.map((t) => t.id));
727
839
  const remaining = listCustomTools(rootDir, { category, tags, includeBuiltins })
@@ -746,12 +858,12 @@ export function getToolsPromptBlock(rootDir, opts = {}) {
746
858
  "## Custom Tools Library",
747
859
  "",
748
860
  discoveryMode
749
- ? "Only eagerly-loaded tools are listed below. Use the MCP discovery tools to find the rest at runtime."
750
- : "The following reusable helper scripts are available. Run them via",
861
+ ? "- Eager tools only below. Discover the rest at runtime."
862
+ : "- Run tools via `node <tool>.mjs`, `bash <tool>.sh`, or `python3 <tool>.py`.",
751
863
  discoveryMode
752
- ? "Use `search`, then `get_schema`, then `execute` for tools not listed here. Use `call_discovered_tool` only for simple direct calls."
753
- : "`node <tool>.mjs`, `bash <tool>.sh`, or `python3 <tool>.py`.",
754
- "Built-in tools live in `bosun/tools/`; workspace tools in `.bosun/tools/`.",
864
+ ? "- Use `search`, then `get_schema`, then `execute` for tools not listed here."
865
+ : "- Check this library before writing new helper code.",
866
+ "- Built-in tools: `bosun/tools/`; workspace tools: `.bosun/tools/`.",
755
867
  "",
756
868
  ];
757
869
 
@@ -791,16 +903,13 @@ export function getToolsPromptBlock(rootDir, opts = {}) {
791
903
  lines.push(
792
904
  "---",
793
905
  "",
794
- "**Reflect:** Before writing repetitive inline code, check if an existing",
795
- "custom tool covers the need. If you encounter a pattern that future agents",
796
- "(or yourself on retry) would benefit from having as a persistent script,",
797
- "save it to `.bosun/tools/` and register it via the Bosun SDK so the whole",
798
- "team benefits. Good candidates: analysis helpers, test generators, codemods,",
799
- "build/lint wrappers that differ from what `npm run *` provides.",
906
+ "Reflect:",
907
+ "- Check existing tools before writing new helpers.",
908
+ "- Promote repeated analysis, test, build, transform, or search logic into `.bosun/tools/`.",
909
+ "- Skip one-off scripts.",
800
910
  "",
801
911
  );
802
912
  }
803
-
804
913
  return lines.join("\n");
805
914
  }
806
915
 
@@ -931,3 +1040,4 @@ export function getAffinityTools(rootDir, opts = {}) {
931
1040
  .slice(0, limit)
932
1041
  .map((s) => s.tool);
933
1042
  }
1043
+
@@ -201,8 +201,7 @@ function isLikelyBosunCommandLine(commandLine) {
201
201
  normalized.includes("/bosun/") &&
202
202
  (normalized.includes("monitor.mjs") ||
203
203
  normalized.includes("cli.mjs") ||
204
- normalized.includes("agent-endpoint.mjs") ||
205
- normalized.includes("ve-orchestrator"))
204
+ normalized.includes("agent-endpoint.mjs"))
206
205
  ) {
207
206
  return true;
208
207
  }
@@ -51,6 +51,9 @@ const MAX_OUTPUT_BYTES = 64 * 1024;
51
51
  /** Whether we're running on Windows */
52
52
  const IS_WINDOWS = process.platform === "win32";
53
53
 
54
+ /** Preferred Windows shell for hook execution. */
55
+ const WINDOWS_SHELL = process.env.ComSpec || "cmd.exe";
56
+
54
57
  /** Default max retries for retryable hooks */
55
58
  const DEFAULT_MAX_RETRIES = 2;
56
59
 
@@ -643,7 +646,7 @@ export function registerBuiltinHooks(options = {}) {
643
646
 
644
647
  // ── PrePush: agent preflight quality gate ──
645
648
  if (!skipPrePush) {
646
- const preflightScript = "node preflight.mjs";
649
+ const preflightScript = "node infra/preflight.mjs";
647
650
 
648
651
  registerHook("PrePush", {
649
652
  id: "builtin-prepush-preflight",
@@ -841,6 +844,42 @@ function _buildEnv(ctx) {
841
844
  return env;
842
845
  }
843
846
 
847
+ function _getSpawnCommand(command) {
848
+ const trimmed = String(command ?? "").trim();
849
+
850
+ if (IS_WINDOWS) {
851
+ const lower = trimmed.toLowerCase();
852
+ if (lower.startsWith("powershell ") || lower.startsWith("powershell.exe ")) {
853
+ const inlineCommand = trimmed
854
+ .replace(/^powershell(?:\.exe)?\s+-NoProfile\s+-Command\s+/i, "")
855
+ .replace(/^powershell(?:\.exe)?\s+-Command\s+/i, "")
856
+ .replace(/^"|"$/g, "");
857
+ return {
858
+ file: "powershell.exe",
859
+ args: ["-NoProfile", "-Command", inlineCommand],
860
+ };
861
+ }
862
+ if (lower.startsWith("cmd ") || lower.startsWith("cmd.exe ")) {
863
+ const inlineCommand = trimmed
864
+ .replace(/^cmd(?:\.exe)?\s+\/d\s+\/s\s+\/c\s+/i, "")
865
+ .replace(/^cmd(?:\.exe)?\s+\/c\s+/i, "");
866
+ return {
867
+ file: WINDOWS_SHELL,
868
+ args: ["/d", "/s", "/c", inlineCommand],
869
+ };
870
+ }
871
+ return {
872
+ file: WINDOWS_SHELL,
873
+ args: ["/d", "/s", "/c", trimmed],
874
+ };
875
+ }
876
+
877
+ return {
878
+ file: "/bin/sh",
879
+ args: ["-c", trimmed],
880
+ };
881
+ }
882
+
844
883
  // ── Internal: Synchronous Hook Execution ────────────────────────────────────
845
884
 
846
885
  /**
@@ -892,12 +931,13 @@ function _executeHookSync(hook, ctx, env) {
892
931
  };
893
932
 
894
933
  try {
895
- const result = spawnSync(hook.command, {
934
+ const spawnTarget = _getSpawnCommand(hook.command);
935
+ const result = spawnSync(spawnTarget.file, spawnTarget.args, {
896
936
  cwd,
897
937
  env: hookEnv,
898
938
  encoding: "utf8",
899
939
  timeout,
900
- shell: true,
940
+ shell: false,
901
941
  windowsHide: true,
902
942
  maxBuffer: MAX_OUTPUT_BYTES,
903
943
  });
@@ -999,10 +1039,11 @@ function _executeHookAsyncOnce(hook, ctx, env, attempt) {
999
1039
 
1000
1040
  let child;
1001
1041
  try {
1002
- child = spawn(hook.command, {
1042
+ const spawnTarget = _getSpawnCommand(hook.command);
1043
+ child = spawn(spawnTarget.file, spawnTarget.args, {
1003
1044
  cwd,
1004
1045
  env: hookEnv,
1005
- shell: true,
1046
+ shell: false,
1006
1047
  windowsHide: true,
1007
1048
  stdio: ["ignore", "pipe", "pipe"],
1008
1049
  });
@@ -1253,3 +1294,10 @@ export function registerLibraryHooks(hooksByEvent) {
1253
1294
  }
1254
1295
  return { registered, skipped };
1255
1296
  }
1297
+
1298
+
1299
+
1300
+
1301
+
1302
+
1303
+