onkol 0.1.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.
@@ -0,0 +1,253 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Parse arguments
5
+ while [[ $# -gt 0 ]]; do
6
+ case $1 in
7
+ --name) WORKER_NAME="$2"; shift 2 ;;
8
+ --dir) WORK_DIR="$2"; shift 2 ;;
9
+ --task) TASK_DESC="$2"; shift 2 ;;
10
+ --intent) INTENT="$2"; shift 2 ;;
11
+ --context) CONTEXT="$2"; shift 2 ;;
12
+ *) echo "Unknown arg: $1"; exit 1 ;;
13
+ esac
14
+ done
15
+
16
+ # Validate required args
17
+ : "${WORKER_NAME:?--name is required}"
18
+ : "${WORK_DIR:?--dir is required}"
19
+ : "${TASK_DESC:?--task is required}"
20
+ : "${INTENT:=fix}"
21
+ : "${CONTEXT:=No additional context.}"
22
+
23
+ # Load config
24
+ ONKOL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
25
+ CONFIG="$ONKOL_DIR/config.json"
26
+ BOT_TOKEN=$(jq -r '.botToken' "$CONFIG")
27
+ GUILD_ID=$(jq -r '.guildId' "$CONFIG")
28
+ CATEGORY_ID=$(jq -r '.categoryId' "$CONFIG")
29
+ ALLOWED_USERS=$(jq -c '.allowedUsers' "$CONFIG")
30
+ NODE_NAME=$(jq -r '.nodeName' "$CONFIG")
31
+ MAX_WORKERS=$(jq -r '.maxWorkers // 3' "$CONFIG")
32
+ TMUX_SESSION="onkol-${NODE_NAME}"
33
+
34
+ # Check concurrency limit
35
+ TRACKING="$ONKOL_DIR/workers/tracking.json"
36
+ if [ -f "$TRACKING" ]; then
37
+ ACTIVE_COUNT=$(jq '[.[] | select(.status == "active")] | length' "$TRACKING")
38
+ if [ "$ACTIVE_COUNT" -ge "$MAX_WORKERS" ]; then
39
+ echo "ERROR: Worker limit reached ($ACTIVE_COUNT/$MAX_WORKERS). Task queued."
40
+ exit 1
41
+ fi
42
+ fi
43
+
44
+ # Create Discord channel
45
+ CHANNEL_RESPONSE=$(curl -s -X POST \
46
+ "https://discord.com/api/v10/guilds/${GUILD_ID}/channels" \
47
+ -H "Authorization: Bot ${BOT_TOKEN}" \
48
+ -H "Content-Type: application/json" \
49
+ -d "{\"name\": \"$(echo "$WORKER_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')\", \"type\": 0, \"parent_id\": \"${CATEGORY_ID}\"}")
50
+
51
+ CHANNEL_ID=$(echo "$CHANNEL_RESPONSE" | jq -r '.id')
52
+ if [ "$CHANNEL_ID" = "null" ] || [ -z "$CHANNEL_ID" ]; then
53
+ echo "ERROR: Failed to create Discord channel: $CHANNEL_RESPONSE"
54
+ exit 1
55
+ fi
56
+
57
+ # Create worker directory
58
+ WORKER_DIR="$ONKOL_DIR/workers/$WORKER_NAME"
59
+ mkdir -p "$WORKER_DIR"
60
+
61
+ # Write task.md (using printf to prevent heredoc injection from user input)
62
+ printf '%s\n' "# Task: $WORKER_NAME" "" \
63
+ "**Intent:** $INTENT" \
64
+ "**Working directory:** $WORK_DIR" \
65
+ "**Created:** $(date -Iseconds)" "" \
66
+ "## Description" "" > "$WORKER_DIR/task.md"
67
+ printf '%s' "$TASK_DESC" >> "$WORKER_DIR/task.md"
68
+
69
+ # Write context.md (using printf to prevent heredoc injection from user input)
70
+ printf '%s\n' "# Context for $WORKER_NAME" "" > "$WORKER_DIR/context.md"
71
+ printf '%s' "$CONTEXT" >> "$WORKER_DIR/context.md"
72
+
73
+ # Write .mcp.json (DISCORD_ALLOWED_USERS must be a string, not raw JSON array)
74
+ ALLOWED_USERS_ESCAPED=$(echo "$ALLOWED_USERS" | sed 's/\\/\\\\/g; s/"/\\"/g')
75
+ PLUGIN_PATH="$ONKOL_DIR/plugins/discord-filtered/index.ts"
76
+ cat > "$WORKER_DIR/.mcp.json" << MCPEOF
77
+ {
78
+ "mcpServers": {
79
+ "discord-filtered": {
80
+ "command": "bun",
81
+ "args": ["$PLUGIN_PATH"],
82
+ "env": {
83
+ "DISCORD_BOT_TOKEN": "$BOT_TOKEN",
84
+ "DISCORD_CHANNEL_ID": "$CHANNEL_ID",
85
+ "DISCORD_ALLOWED_USERS": "$ALLOWED_USERS_ESCAPED"
86
+ }
87
+ }
88
+ }
89
+ }
90
+ MCPEOF
91
+
92
+ # Write worker CLAUDE.md
93
+ INTENT_INSTRUCTION=$(case $INTENT in
94
+ fix) echo "- Diagnose the issue, fix it, run tests, commit to a branch (not main), report results" ;;
95
+ investigate) echo "- Analyze the issue, gather data, report findings. Do NOT modify any files." ;;
96
+ build) echo "- Implement the feature, write tests, create a branch, show diff, wait for approval" ;;
97
+ analyze) echo "- Read logs/data/code, produce analysis, report. Do NOT modify any files." ;;
98
+ override) echo "- Full autonomy including push and deploy. Before deploying: ask 'About to deploy. Confirm?' and wait." ;;
99
+ esac)
100
+
101
+ cat > "$WORKER_DIR/CLAUDE.md" << CLEOF
102
+ You are an Onkol worker session for "$NODE_NAME".
103
+
104
+ ## Your Task
105
+ Read your task brief: $WORKER_DIR/task.md
106
+ Read your context: $WORKER_DIR/context.md
107
+
108
+ ## Intent: $INTENT
109
+ $INTENT_INSTRUCTION
110
+
111
+ ## CRITICAL: How to Communicate
112
+ You are connected to Discord via the discord-filtered MCP channel.
113
+ ALL your output must go through the reply tool — the user CANNOT see your terminal.
114
+
115
+ - Use the \`reply\` tool from the discord-filtered MCP server to send ALL messages to the user.
116
+ - NEVER just print output to the terminal. The user only sees Discord.
117
+ - Send progress updates via reply tool as you work.
118
+ - Send your final report/results via reply tool.
119
+ - If you need to ask a question, use the reply tool. The user will respond via Discord.
120
+ - For long reports, split into multiple reply calls (Discord has a 2000 char limit per message).
121
+
122
+ ## Rules
123
+ - If you get stuck, ask via the reply tool. A human will respond via Discord.
124
+ - Update your status in $WORKER_DIR/status.json periodically
125
+ - Before dissolution, write learnings to $WORKER_DIR/learnings.md
126
+ CLEOF
127
+
128
+ # Write initial status.json
129
+ cat > "$WORKER_DIR/status.json" << STATUSEOF
130
+ {
131
+ "status": "starting",
132
+ "updated": "$(date -Iseconds)",
133
+ "task": "$WORKER_NAME",
134
+ "intent": "$INTENT"
135
+ }
136
+ STATUSEOF
137
+
138
+ # Write per-worker .claude/settings.json with PostToolUse hook for bash logging
139
+ mkdir -p "$WORKER_DIR/.claude"
140
+ cat > "$WORKER_DIR/.claude/settings.json" << SETTINGSEOF
141
+ {
142
+ "hooks": {
143
+ "PostToolUse": [
144
+ {
145
+ "hooks": [
146
+ {
147
+ "type": "command",
148
+ "command": "jq -r 'if .tool_name == \"Bash\" then \"[\"+.tool_input.command+\"] => \"+(.tool_result.stdout // \"\" | tostring) else empty end' >> $WORKER_DIR/bash-log.txt"
149
+ }
150
+ ]
151
+ }
152
+ ]
153
+ }
154
+ }
155
+ SETTINGSEOF
156
+
157
+ # Determine allowed tools based on intent
158
+ case $INTENT in
159
+ fix|build|override) ALLOWED_TOOLS="Bash,Read,Edit,Write,Glob,Grep" ;;
160
+ investigate|analyze) ALLOWED_TOOLS="Bash,Read,Glob,Grep" ;;
161
+ *) ALLOWED_TOOLS="Bash,Read,Edit,Write,Glob,Grep" ;;
162
+ esac
163
+
164
+ # Pre-accept trust dialog for the working directory
165
+ CLAUDE_JSON="$HOME/.claude/.claude.json"
166
+ if [ -f "$CLAUDE_JSON" ]; then
167
+ UPDATED_CLAUDE=$(jq --arg dir "$WORK_DIR" '
168
+ .projects[$dir] = (.projects[$dir] // {}) + {hasTrustDialogAccepted: true, allowedTools: []}
169
+ ' "$CLAUDE_JSON")
170
+ echo "$UPDATED_CLAUDE" > "$CLAUDE_JSON"
171
+ fi
172
+
173
+ # Add startup instructions to the worker CLAUDE.md so it acts immediately
174
+ cat >> "$WORKER_DIR/CLAUDE.md" << STARTEOF
175
+
176
+ ## On Startup
177
+ Immediately when you start:
178
+ 1. Read $WORKER_DIR/task.md for your task
179
+ 2. Read $WORKER_DIR/context.md for context
180
+ 3. Begin work according to your intent
181
+ 4. Report progress and results using the reply tool to your Discord channel
182
+ Do NOT wait for a message. Start working as soon as you boot.
183
+ STARTEOF
184
+
185
+ # Create a self-contained wrapper script with all paths baked in
186
+ WRAPPER="$WORKER_DIR/start-worker.sh"
187
+ cat > "$WRAPPER" << WRAPEOF
188
+ #!/bin/bash
189
+ TMUX_TARGET="${TMUX_SESSION}:${WORKER_NAME}"
190
+
191
+ # Auto-accept prompts in the background
192
+ (
193
+ for i in \$(seq 1 10); do
194
+ sleep 2
195
+ PANE_CONTENT=\$(tmux capture-pane -t "\$TMUX_TARGET" -p 2>/dev/null || echo "")
196
+ if echo "\$PANE_CONTENT" | grep -q "^❯"; then
197
+ # Claude is ready — send the initial prompt via tmux keys
198
+ sleep 1
199
+ tmux send-keys -t "\$TMUX_TARGET" "Read $WORKER_DIR/task.md and $WORKER_DIR/context.md, then begin work per CLAUDE.md." Enter
200
+ break
201
+ fi
202
+ tmux send-keys -t "\$TMUX_TARGET" Enter 2>/dev/null || true
203
+ done
204
+ ) &
205
+
206
+ # Copy .mcp.json to work directory so claude auto-discovers the MCP server.
207
+ # --mcp-config registers servers under a different namespace that
208
+ # --dangerously-load-development-channels doesn't find. The .mcp.json in cwd works.
209
+ # Save any existing .mcp.json and restore on exit.
210
+ WORK_MCP="$WORK_DIR/.mcp.json"
211
+ WORK_MCP_BACKUP=""
212
+ if [ -f "\$WORK_MCP" ]; then
213
+ WORK_MCP_BACKUP="\${WORK_MCP}.onkol-backup"
214
+ cp "\$WORK_MCP" "\$WORK_MCP_BACKUP"
215
+ fi
216
+ cp "$WORKER_DIR/.mcp.json" "\$WORK_MCP"
217
+
218
+ cleanup() {
219
+ if [ -n "\$WORK_MCP_BACKUP" ]; then
220
+ mv "\$WORK_MCP_BACKUP" "\$WORK_MCP"
221
+ else
222
+ rm -f "\$WORK_MCP"
223
+ fi
224
+ }
225
+ trap cleanup EXIT
226
+
227
+ # Start claude (no positional prompt — startup instructions are in CLAUDE.md,
228
+ # and the auto-acceptor sends the first prompt via tmux keys once claude is ready)
229
+ cd "$WORK_DIR" && claude \\
230
+ --dangerously-skip-permissions \\
231
+ --dangerously-load-development-channels server:discord-filtered
232
+ WRAPEOF
233
+ chmod +x "$WRAPPER"
234
+
235
+ # Start the worker in tmux
236
+ tmux new-window -t "$TMUX_SESSION" -n "$WORKER_NAME" "bash '$WRAPPER'"
237
+
238
+ # Update tracking.json
239
+ if [ ! -f "$TRACKING" ]; then
240
+ echo '[]' > "$TRACKING"
241
+ fi
242
+ UPDATED=$(jq ". + [{
243
+ \"name\": \"$WORKER_NAME\",
244
+ \"channelId\": \"$CHANNEL_ID\",
245
+ \"workDir\": \"$WORK_DIR\",
246
+ \"intent\": \"$INTENT\",
247
+ \"status\": \"active\",
248
+ \"started\": \"$(date -Iseconds)\"
249
+ }]" "$TRACKING")
250
+ echo "$UPDATED" > "$TRACKING"
251
+
252
+ echo "Worker '$WORKER_NAME' spawned. Discord channel: $CHANNEL_ID"
253
+ echo "Talk to it in the new Discord channel."
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ ONKOL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
3
+ CONFIG="$ONKOL_DIR/config.json"
4
+ NODE_NAME=$(jq -r '.nodeName' "$CONFIG")
5
+ TMUX_SESSION="onkol-${NODE_NAME}"
6
+
7
+ if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then
8
+ echo "Session $TMUX_SESSION already running."
9
+ exit 0
10
+ fi
11
+
12
+ tmux new-session -d -s "$TMUX_SESSION" \
13
+ "cd '$ONKOL_DIR' && claude \
14
+ --dangerously-skip-permissions \
15
+ --dangerously-load-development-channels server:discord-filtered \
16
+ --mcp-config '$ONKOL_DIR/.mcp.json'"
17
+
18
+ # Auto-accept interactive prompts (trust dialog + dev channels warning)
19
+ # Background loop sends Enter every 2 seconds until claude reaches the ❯ prompt
20
+ (
21
+ for i in $(seq 1 10); do
22
+ sleep 2
23
+ PANE_CONTENT=$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || echo "")
24
+ if echo "$PANE_CONTENT" | grep -q "^❯"; then
25
+ break
26
+ fi
27
+ tmux send-keys -t "$TMUX_SESSION" Enter 2>/dev/null || true
28
+ done
29
+ ) &
30
+
31
+ echo "Orchestrator started in tmux session '$TMUX_SESSION'."
32
+ echo "Attach with: tmux attach -t $TMUX_SESSION"
@@ -0,0 +1,68 @@
1
+ import { Client, GatewayIntentBits, type Message } from 'discord.js'
2
+
3
+ export interface DiscordClientConfig {
4
+ botToken: string
5
+ channelId: string
6
+ allowedUsers: string[]
7
+ }
8
+
9
+ export function shouldForwardMessage(
10
+ messageChannelId: string,
11
+ authorId: string,
12
+ isBot: boolean,
13
+ targetChannelId: string,
14
+ allowedUsers: string[]
15
+ ): boolean {
16
+ if (isBot) return false
17
+ if (messageChannelId !== targetChannelId) return false
18
+ if (allowedUsers.length > 0 && !allowedUsers.includes(authorId)) return false
19
+ return true
20
+ }
21
+
22
+ export function createDiscordClient(
23
+ config: DiscordClientConfig,
24
+ onMessage: (message: Message) => void
25
+ ) {
26
+ const client = new Client({
27
+ intents: [
28
+ GatewayIntentBits.Guilds,
29
+ GatewayIntentBits.GuildMessages,
30
+ GatewayIntentBits.MessageContent,
31
+ ],
32
+ })
33
+
34
+ client.on('messageCreate', (message) => {
35
+ if (
36
+ shouldForwardMessage(
37
+ message.channel.id,
38
+ message.author.id,
39
+ message.author.bot,
40
+ config.channelId,
41
+ config.allowedUsers
42
+ )
43
+ ) {
44
+ onMessage(message)
45
+ }
46
+ })
47
+
48
+ client.on('ready', () => {
49
+ console.error(`[discord-filtered] Connected as ${client.user?.tag}, filtering to channel ${config.channelId}`)
50
+ })
51
+
52
+ return {
53
+ login: () => client.login(config.botToken),
54
+ client,
55
+ async sendMessage(channelId: string, text: string) {
56
+ const channel = await client.channels.fetch(channelId)
57
+ if (channel?.isTextBased() && 'send' in channel) {
58
+ await channel.send(text)
59
+ }
60
+ },
61
+ async sendMessageWithFile(channelId: string, text: string, filePath: string) {
62
+ const channel = await client.channels.fetch(channelId)
63
+ if (channel?.isTextBased() && 'send' in channel) {
64
+ await channel.send({ content: text, files: [{ attachment: filePath }] })
65
+ }
66
+ },
67
+ }
68
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bun
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
+ import { createMcpServer } from './mcp-server.js'
4
+ import { createDiscordClient } from './discord-client.js'
5
+ import { MessageBatcher } from './message-batcher.js'
6
+
7
+ const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN
8
+ const CHANNEL_ID = process.env.DISCORD_CHANNEL_ID
9
+ const ALLOWED_USERS: string[] = JSON.parse(process.env.DISCORD_ALLOWED_USERS || '[]')
10
+
11
+ if (!BOT_TOKEN) {
12
+ console.error('[discord-filtered] DISCORD_BOT_TOKEN is required')
13
+ process.exit(1)
14
+ }
15
+ if (!CHANNEL_ID) {
16
+ console.error('[discord-filtered] DISCORD_CHANNEL_ID is required')
17
+ process.exit(1)
18
+ }
19
+
20
+ const discord = createDiscordClient(
21
+ { botToken: BOT_TOKEN, channelId: CHANNEL_ID, allowedUsers: ALLOWED_USERS },
22
+ async (message) => {
23
+ await mcpServer.notification({
24
+ method: 'notifications/claude/channel',
25
+ params: {
26
+ content: message.content,
27
+ meta: {
28
+ channel_id: message.channel.id,
29
+ sender: message.author.username,
30
+ sender_id: message.author.id,
31
+ message_id: message.id,
32
+ },
33
+ },
34
+ })
35
+ }
36
+ )
37
+
38
+ const batcher = new MessageBatcher(async (text) => {
39
+ await discord.sendMessage(CHANNEL_ID, text)
40
+ })
41
+
42
+ const mcpServer = createMcpServer({
43
+ async reply(_channelId: string, text: string) {
44
+ batcher.enqueue(text)
45
+ },
46
+ async replyWithFile(_channelId: string, text: string, filePath: string) {
47
+ await discord.sendMessageWithFile(CHANNEL_ID, text, filePath)
48
+ },
49
+ })
50
+
51
+ async function main() {
52
+ await mcpServer.connect(new StdioServerTransport())
53
+ await discord.login()
54
+ console.error(`[discord-filtered] Ready. Listening to channel ${CHANNEL_ID}`)
55
+ }
56
+
57
+ main().catch((err) => {
58
+ console.error('[discord-filtered] Fatal error:', err)
59
+ process.exit(1)
60
+ })
@@ -0,0 +1,79 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2
+ import {
3
+ ListToolsRequestSchema,
4
+ CallToolRequestSchema,
5
+ } from '@modelcontextprotocol/sdk/types.js'
6
+
7
+ export interface McpToolHandlers {
8
+ reply: (channelId: string, text: string) => Promise<void>
9
+ replyWithFile: (channelId: string, text: string, filePath: string) => Promise<void>
10
+ }
11
+
12
+ export function createMcpServer(handlers?: McpToolHandlers) {
13
+ const server = new Server(
14
+ { name: 'discord-filtered', version: '0.1.0' },
15
+ {
16
+ capabilities: {
17
+ experimental: { 'claude/channel': {} },
18
+ tools: {},
19
+ },
20
+ instructions:
21
+ 'Messages arrive as <channel source="discord-filtered">. Reply using the reply tool. Use reply_with_file to attach files.',
22
+ }
23
+ )
24
+
25
+ const channelId = process.env.DISCORD_CHANNEL_ID || ''
26
+
27
+ const tools = [
28
+ {
29
+ name: 'reply',
30
+ description: 'Send a text message back to the Discord channel',
31
+ inputSchema: {
32
+ type: 'object' as const,
33
+ properties: {
34
+ text: { type: 'string', description: 'The message text to send' },
35
+ },
36
+ required: ['text'],
37
+ },
38
+ },
39
+ {
40
+ name: 'reply_with_file',
41
+ description: 'Send a text message with a file attachment to the Discord channel',
42
+ inputSchema: {
43
+ type: 'object' as const,
44
+ properties: {
45
+ text: { type: 'string', description: 'The message text to send' },
46
+ file_path: { type: 'string', description: 'Absolute path to the file to attach' },
47
+ },
48
+ required: ['text', 'file_path'],
49
+ },
50
+ },
51
+ ]
52
+
53
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
54
+ tools,
55
+ }))
56
+
57
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
58
+ const { name, arguments: args } = req.params
59
+ if (name === 'reply' && handlers) {
60
+ await handlers.reply(channelId, (args as any).text)
61
+ return { content: [{ type: 'text' as const, text: 'sent' }] }
62
+ }
63
+ if (name === 'reply_with_file' && handlers) {
64
+ await handlers.replyWithFile(channelId, (args as any).text, (args as any).file_path)
65
+ return { content: [{ type: 'text' as const, text: 'sent' }] }
66
+ }
67
+ return { content: [{ type: 'text' as const, text: `unknown tool: ${name}` }] }
68
+ })
69
+
70
+ // Expose listTools for testing
71
+ ;(server as any).listTools = async () => {
72
+ const handler = (server as any)._requestHandlers.get('tools/list')
73
+ if (!handler) return []
74
+ const result = await handler({ method: 'tools/list', params: {} }, {})
75
+ return result?.tools || []
76
+ }
77
+
78
+ return server
79
+ }
@@ -0,0 +1,33 @@
1
+ const DISCORD_MAX_LENGTH = 2000
2
+ const TRUNCATION_SUFFIX = '\n... (truncated)'
3
+
4
+ export class MessageBatcher {
5
+ private buffer: string[] = []
6
+ private timer: ReturnType<typeof setTimeout> | null = null
7
+ private sendFn: (text: string) => Promise<void>
8
+ private delayMs: number
9
+
10
+ constructor(sendFn: (text: string) => Promise<void>, delayMs = 3000) {
11
+ this.sendFn = sendFn
12
+ this.delayMs = delayMs
13
+ }
14
+
15
+ enqueue(text: string): void {
16
+ this.buffer.push(text)
17
+ if (this.timer) clearTimeout(this.timer)
18
+ this.timer = setTimeout(() => this.flush(), this.delayMs)
19
+ }
20
+
21
+ private async flush(): Promise<void> {
22
+ if (this.buffer.length === 0) return
23
+ let combined = this.buffer.join('\n')
24
+ this.buffer = []
25
+ this.timer = null
26
+
27
+ if (combined.length > DISCORD_MAX_LENGTH) {
28
+ combined = combined.slice(0, DISCORD_MAX_LENGTH - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX
29
+ }
30
+
31
+ await this.sendFn(combined)
32
+ }
33
+ }
@@ -0,0 +1,95 @@
1
+ You are the Onkol orchestrator for "{{nodeName}}" on this VM.
2
+
3
+ ## Your Role
4
+ You do NOT solve tasks yourself. You ALWAYS spawn worker Claude Code sessions using the spawn-worker.sh script.
5
+
6
+ ## CRITICAL RULES — DO NOT VIOLATE
7
+ - NEVER run `claude` commands directly. NEVER use `claude --print`, `claude -p`, or any variation.
8
+ - NEVER craft your own worker spawning logic. ALWAYS use `./scripts/spawn-worker.sh`.
9
+ - NEVER investigate codebases, read project files, or run project commands yourself.
10
+ - If `spawn-worker.sh` fails, report the error to the user and ask what to do. Do NOT try to work around it.
11
+ - You are a DISPATCHER. Your only tools are: spawn-worker.sh, dissolve-worker.sh, list-workers.sh, check-worker.sh, and reading your own state files (config.json, registry.json, services.md, knowledge/, tracking.json).
12
+
13
+ ## When a message arrives
14
+ 1. Understand the task and its intent (fix, investigate, build, analyze, override)
15
+ 2. Determine which project directory the task relates to (check registry.json and services.md)
16
+ 3. Prepare a task brief with relevant context from:
17
+ - registry.json (secrets, endpoints, ports)
18
+ - services.md (what runs where, how to access logs)
19
+ - knowledge/ (past learnings from dissolved workers)
20
+ 4. Run `./scripts/spawn-worker.sh` to create a worker — this is the ONLY way to create workers
21
+ 5. Report back with the Discord channel name
22
+
23
+ ## Intent Detection
24
+ - "fix..." / "resolve..." / "patch..." → intent: fix (autonomous — diagnose, fix, test, commit to branch)
25
+ - "look into..." / "investigate..." / "check why..." → intent: investigate (report only — no code changes)
26
+ - "add..." / "build..." / "create..." / "implement..." → intent: build (semi-autonomous — implement, test, show diff, wait for approval)
27
+ - "just ship it" / "deploy" / "push it" → intent: override (fully autonomous — requires confirmation before deploy)
28
+ - "analyze..." / "show me..." / "report on..." → intent: analyze (read-only)
29
+
30
+ ## Spawning a Worker
31
+ ```bash
32
+ ./scripts/spawn-worker.sh \
33
+ --name "short-task-name" \
34
+ --dir "/path/to/project" \
35
+ --task "Full task description" \
36
+ --intent "fix|investigate|build|analyze|override" \
37
+ --context "relevant context excerpts"
38
+ ```
39
+
40
+ ## Monitoring Workers
41
+ - Read `workers/tracking.json` to see active workers
42
+ - Run `./scripts/check-worker.sh --name "worker-name"` to check status
43
+ - Run `./scripts/list-workers.sh` to see all workers
44
+
45
+ ## Dissolving Workers
46
+ When a worker is done or you are asked to dissolve:
47
+ ```bash
48
+ ./scripts/dissolve-worker.sh --name "worker-name"
49
+ ```
50
+
51
+ ## On Startup
52
+ Read these files to understand your current state:
53
+ 1. config.json — who you are
54
+ 2. registry.json — VM-specific endpoints, secrets, ports
55
+ 3. services.md — what runs on this VM, how to access logs
56
+ 4. workers/tracking.json — any active workers
57
+ 5. knowledge/index.json — past learnings (include relevant ones in worker context)
58
+ 6. state.md — any pending decisions from before restart
59
+
60
+ Then post: "{{nodeName}} is online. [N] active workers."
61
+
62
+ ## Setup Prompts (First Boot)
63
+ After reading your state files, check if `setup-prompts.json` exists and has entries with status "pending".
64
+ If so, for each pending prompt:
65
+ 1. Read the prompt
66
+ 2. Execute it — run commands, discover information, read files as needed
67
+ 3. Write the result to the target file (registry.json, services.md, or CLAUDE.md)
68
+ 4. For registry.json: output must be valid JSON with key-value pairs
69
+ 5. For services.md: output should be structured markdown documenting services
70
+ 6. For CLAUDE.md: convert the plain language description into a well-structured CLAUDE.md with sections for project overview, tech stack, key files, dos and don'ts, deploy process
71
+ 7. Mark the prompt's status as "completed" in setup-prompts.json
72
+ 8. Report what was generated in the Discord channel
73
+
74
+ ## Health Checks
75
+ Every time you receive a message, also check:
76
+ 1. Read tracking.json for active workers
77
+ 2. Run `tmux list-windows -t onkol-{{nodeName}}` to verify workers are alive
78
+ 3. If a worker's window is gone, report it and ask: respawn, dissolve, or investigate?
79
+
80
+ ## Adaptive Communication
81
+ - Quick tasks (< 5 min): just report results
82
+ - Medium tasks (5-15 min): report at start and finish
83
+ - Long tasks (15+ min): milestone updates every 10 minutes
84
+ - If stuck: ask immediately, block until human responds
85
+
86
+ ## Worker Concurrency
87
+ Maximum {{maxWorkers}} concurrent workers. If at capacity, queue the task and notify.
88
+
89
+ ## Important
90
+ - You do NOT write code yourself
91
+ - You do NOT access project codebases directly — that's what workers are for
92
+ - You do NOT run `claude` commands directly — ONLY use spawn-worker.sh
93
+ - You are a dispatcher and manager, nothing more
94
+ - All your state is in files — your conversation history is ephemeral
95
+ - If spawn-worker.sh fails, report the exact error. Do NOT improvise alternatives.
@@ -0,0 +1,20 @@
1
+ {
2
+ "hooks": {
3
+ "PreCompact": [{
4
+ "hooks": [{
5
+ "type": "command",
6
+ "command": "echo '{\"systemMessage\": \"Before compacting: write any in-flight task state to workers/tracking.json and pending decisions to state.md\"}'"
7
+ }]
8
+ }],
9
+ "PostToolUse": [{
10
+ "matcher": "Bash",
11
+ "hooks": [{
12
+ "type": "command",
13
+ "command": "jq -r '.tool_input.command' >> {{bashLogPath}}"
14
+ }]
15
+ }]
16
+ },
17
+ "permissions": {
18
+ "defaultMode": "acceptEdits"
19
+ }
20
+ }