theslopmachine 0.6.2 → 0.7.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.
Files changed (77) hide show
  1. package/MANUAL.md +21 -6
  2. package/README.md +55 -7
  3. package/RELEASE.md +16 -1
  4. package/assets/agents/developer.md +41 -1
  5. package/assets/agents/slopmachine-claude.md +101 -60
  6. package/assets/agents/slopmachine.md +40 -17
  7. package/assets/claude/agents/developer.md +42 -5
  8. package/assets/skills/clarification-gate/SKILL.md +25 -5
  9. package/assets/skills/claude-worker-management/SKILL.md +290 -57
  10. package/assets/skills/developer-session-lifecycle/SKILL.md +83 -38
  11. package/assets/skills/development-guidance/SKILL.md +21 -1
  12. package/assets/skills/evaluation-triage/SKILL.md +34 -23
  13. package/assets/skills/final-evaluation-orchestration/SKILL.md +88 -50
  14. package/assets/skills/hardening-gate/SKILL.md +17 -3
  15. package/assets/skills/integrated-verification/SKILL.md +3 -3
  16. package/assets/skills/planning-gate/SKILL.md +32 -3
  17. package/assets/skills/planning-guidance/SKILL.md +72 -13
  18. package/assets/skills/retrospective-analysis/SKILL.md +2 -2
  19. package/assets/skills/scaffold-guidance/SKILL.md +129 -124
  20. package/assets/skills/submission-packaging/SKILL.md +33 -27
  21. package/assets/skills/verification-gates/SKILL.md +44 -14
  22. package/assets/slopmachine/backend-evaluation-prompt.md +1 -1
  23. package/assets/slopmachine/frontend-evaluation-prompt.md +5 -5
  24. package/assets/slopmachine/scaffold-playbooks/android-kotlin-compose.md +81 -0
  25. package/assets/slopmachine/scaffold-playbooks/android-kotlin-views.md +191 -0
  26. package/assets/slopmachine/scaffold-playbooks/android-native-java.md +203 -0
  27. package/assets/slopmachine/scaffold-playbooks/angular-default.md +181 -0
  28. package/assets/slopmachine/scaffold-playbooks/backend-baseline.md +142 -0
  29. package/assets/slopmachine/scaffold-playbooks/backend-family-matrix.md +80 -0
  30. package/assets/slopmachine/scaffold-playbooks/database-module-matrix.md +80 -0
  31. package/assets/slopmachine/scaffold-playbooks/django-default.md +166 -0
  32. package/assets/slopmachine/scaffold-playbooks/docker-baseline.md +189 -0
  33. package/assets/slopmachine/scaffold-playbooks/docker-shared-contract.md +334 -0
  34. package/assets/slopmachine/scaffold-playbooks/electron-vite-default.md +124 -0
  35. package/assets/slopmachine/scaffold-playbooks/expo-react-native-default.md +73 -0
  36. package/assets/slopmachine/scaffold-playbooks/fastapi-default.md +134 -0
  37. package/assets/slopmachine/scaffold-playbooks/frontend-baseline.md +160 -0
  38. package/assets/slopmachine/scaffold-playbooks/frontend-family-matrix.md +134 -0
  39. package/assets/slopmachine/scaffold-playbooks/generic-unknown-tech-guide.md +136 -0
  40. package/assets/slopmachine/scaffold-playbooks/go-chi-default.md +160 -0
  41. package/assets/slopmachine/scaffold-playbooks/ios-linux-portable.md +93 -0
  42. package/assets/slopmachine/scaffold-playbooks/ios-native-objective-c.md +151 -0
  43. package/assets/slopmachine/scaffold-playbooks/ios-native-swift.md +188 -0
  44. package/assets/slopmachine/scaffold-playbooks/laravel-default.md +216 -0
  45. package/assets/slopmachine/scaffold-playbooks/livewire-default.md +265 -0
  46. package/assets/slopmachine/scaffold-playbooks/overlay-module-matrix.md +130 -0
  47. package/assets/slopmachine/scaffold-playbooks/platform-family-matrix.md +79 -0
  48. package/assets/slopmachine/scaffold-playbooks/selection-matrix.md +72 -0
  49. package/assets/slopmachine/scaffold-playbooks/spring-boot-default.md +182 -0
  50. package/assets/slopmachine/scaffold-playbooks/tauri-default.md +80 -0
  51. package/assets/slopmachine/scaffold-playbooks/vue-vite-default.md +162 -0
  52. package/assets/slopmachine/scaffold-playbooks/web-default.md +96 -0
  53. package/assets/slopmachine/templates/AGENTS.md +41 -3
  54. package/assets/slopmachine/templates/CLAUDE.md +111 -0
  55. package/assets/slopmachine/test-coverage-prompt.md +561 -0
  56. package/assets/slopmachine/utils/claude_create_session.mjs +3 -2
  57. package/assets/slopmachine/utils/claude_live_channel.mjs +188 -0
  58. package/assets/slopmachine/utils/claude_live_common.mjs +411 -0
  59. package/assets/slopmachine/utils/claude_live_hook.py +47 -0
  60. package/assets/slopmachine/utils/claude_live_launch.mjs +187 -0
  61. package/assets/slopmachine/utils/claude_live_status.mjs +25 -0
  62. package/assets/slopmachine/utils/claude_live_stop.mjs +46 -0
  63. package/assets/slopmachine/utils/claude_live_turn.mjs +277 -0
  64. package/assets/slopmachine/utils/claude_resume_session.mjs +3 -2
  65. package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.mjs +23 -0
  66. package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.sh +5 -0
  67. package/assets/slopmachine/utils/claude_worker_common.mjs +361 -4
  68. package/assets/slopmachine/utils/cleanup_delivery_artifacts.py +4 -0
  69. package/assets/slopmachine/utils/export_ai_session.mjs +1 -1
  70. package/assets/slopmachine/utils/normalize_claude_session.py +153 -0
  71. package/assets/slopmachine/utils/package_claude_session.mjs +123 -0
  72. package/assets/slopmachine/utils/prepare_strict_audit_workspace.mjs +65 -0
  73. package/package.json +1 -1
  74. package/src/constants.js +42 -3
  75. package/src/init.js +173 -28
  76. package/src/install.js +156 -8
  77. package/src/send-data.js +56 -57
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from 'node:http'
4
+ import fs from 'node:fs/promises'
5
+ import path from 'node:path'
6
+
7
+ import { parseArgs, writeJsonIfNeeded } from './claude_worker_common.mjs'
8
+
9
+ const argv = parseArgs(process.argv.slice(2))
10
+ const port = Number.parseInt(argv.port, 10)
11
+ const token = argv.token
12
+ const stateFile = argv['state-file']
13
+ const logFile = argv['log-file']
14
+ const channelName = argv['channel-name'] || 'slopmachine-channel'
15
+ const lane = argv.lane || 'lane'
16
+
17
+ const instructions = 'Messages arrive as <channel source="slopmachine" ...>. Treat them as ordinary inbound work requests and respond normally in the current Claude session. Do not look for or use any reply tool for this channel.'
18
+
19
+ let buffer = ''
20
+ let readyForNotifications = false
21
+ let negotiatedProtocolVersion = '2025-11-25'
22
+
23
+ function writeMessage(message) {
24
+ process.stdout.write(`${JSON.stringify(message)}\n`)
25
+ }
26
+
27
+ async function appendLog(record) {
28
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`
29
+ await fs.mkdir(path.dirname(logFile), { recursive: true })
30
+ await fs.appendFile(logFile, line, 'utf8')
31
+ }
32
+
33
+ async function updateState(patch) {
34
+ await writeJsonIfNeeded(stateFile, {
35
+ channel_name: channelName,
36
+ lane,
37
+ pid: process.pid,
38
+ port,
39
+ ready_for_notifications: readyForNotifications,
40
+ protocol_version: negotiatedProtocolVersion,
41
+ updated_at: new Date().toISOString(),
42
+ ...patch,
43
+ })
44
+ }
45
+
46
+ async function handleRpc(message) {
47
+ if (message.method === 'initialize' && Object.prototype.hasOwnProperty.call(message, 'id')) {
48
+ negotiatedProtocolVersion = message.params?.protocolVersion || negotiatedProtocolVersion
49
+ await updateState({ status: 'initialized' })
50
+ writeMessage({
51
+ jsonrpc: '2.0',
52
+ id: message.id,
53
+ result: {
54
+ protocolVersion: negotiatedProtocolVersion,
55
+ capabilities: {
56
+ experimental: {
57
+ 'claude/channel': {},
58
+ },
59
+ },
60
+ serverInfo: {
61
+ name: channelName,
62
+ version: '0.1.0',
63
+ },
64
+ instructions,
65
+ },
66
+ })
67
+ await appendLog({ type: 'initialize', message })
68
+ return
69
+ }
70
+
71
+ if (message.method === 'notifications/initialized') {
72
+ readyForNotifications = true
73
+ await updateState({ status: 'ready' })
74
+ await appendLog({ type: 'initialized' })
75
+ return
76
+ }
77
+
78
+ if (message.method === 'ping' && Object.prototype.hasOwnProperty.call(message, 'id')) {
79
+ writeMessage({ jsonrpc: '2.0', id: message.id, result: {} })
80
+ return
81
+ }
82
+
83
+ if (Object.prototype.hasOwnProperty.call(message, 'id')) {
84
+ writeMessage({
85
+ jsonrpc: '2.0',
86
+ id: message.id,
87
+ error: {
88
+ code: -32601,
89
+ message: `Method not found: ${message.method}`,
90
+ },
91
+ })
92
+ }
93
+ }
94
+
95
+ process.stdin.setEncoding('utf8')
96
+ process.stdin.on('data', async (chunk) => {
97
+ buffer += chunk
98
+ while (true) {
99
+ const newlineIndex = buffer.indexOf('\n')
100
+ if (newlineIndex === -1) {
101
+ break
102
+ }
103
+ const line = buffer.slice(0, newlineIndex).trim()
104
+ buffer = buffer.slice(newlineIndex + 1)
105
+ if (!line) {
106
+ continue
107
+ }
108
+ try {
109
+ const message = JSON.parse(line)
110
+ await handleRpc(message)
111
+ } catch (error) {
112
+ await updateState({ status: 'failed', last_error: error instanceof Error ? error.message : String(error) })
113
+ await appendLog({ type: 'rpc_error', error: error instanceof Error ? error.message : String(error) })
114
+ }
115
+ }
116
+ })
117
+
118
+ process.stdin.on('end', async () => {
119
+ await updateState({ status: 'closed' })
120
+ process.exit(0)
121
+ })
122
+
123
+ await updateState({ status: 'starting' })
124
+
125
+ const server = http.createServer(async (request, response) => {
126
+ if (request.method !== 'POST') {
127
+ response.writeHead(404)
128
+ response.end('not found')
129
+ return
130
+ }
131
+
132
+ if (request.headers['x-bridge-token'] !== token) {
133
+ response.writeHead(403)
134
+ response.end('forbidden')
135
+ return
136
+ }
137
+
138
+ if (!readyForNotifications) {
139
+ response.writeHead(503)
140
+ response.end('channel_not_ready')
141
+ return
142
+ }
143
+
144
+ let body = ''
145
+ for await (const chunk of request) {
146
+ body += chunk.toString()
147
+ }
148
+
149
+ const turnId = String(request.headers['x-turn-id'] || '').trim()
150
+ const notification = {
151
+ jsonrpc: '2.0',
152
+ method: 'notifications/claude/channel',
153
+ params: {
154
+ content: body,
155
+ meta: {
156
+ lane,
157
+ turn_id: turnId,
158
+ method: request.method,
159
+ },
160
+ },
161
+ }
162
+
163
+ writeMessage(notification)
164
+ await updateState({ status: 'ready', last_message_at: new Date().toISOString(), last_turn_id: turnId || null })
165
+ await appendLog({ type: 'notification', turn_id: turnId || null, body })
166
+ response.writeHead(200)
167
+ response.end('ok')
168
+ })
169
+
170
+ server.listen(port, '127.0.0.1', async () => {
171
+ await updateState({ status: 'listening' })
172
+ await appendLog({ type: 'listening' })
173
+ })
174
+
175
+ async function shutdown(code) {
176
+ await updateState({ status: 'closed' })
177
+ server.close(() => {
178
+ process.exit(code)
179
+ })
180
+ }
181
+
182
+ process.on('SIGINT', () => {
183
+ void shutdown(0)
184
+ })
185
+
186
+ process.on('SIGTERM', () => {
187
+ void shutdown(0)
188
+ })
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises'
4
+ import http from 'node:http'
5
+ import net from 'node:net'
6
+ import path from 'node:path'
7
+ import crypto from 'node:crypto'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { spawn } from 'node:child_process'
10
+
11
+ import { emitFailure, emitSuccess, extractRateLimitMetadata, parseArgs, readJsonFile, readPrompt, sleep, waitForRateLimitReset, writeFileIfNeeded, writeJsonIfNeeded } from './claude_worker_common.mjs'
12
+
13
+ export { emitFailure, emitSuccess, parseArgs, readPrompt, sleep, waitForRateLimitReset, writeJsonIfNeeded }
14
+
15
+ export const DEFAULT_LAUNCH_TIMEOUT_MS = 3600000
16
+ export const DEFAULT_TURN_TIMEOUT_MS = 3600000
17
+ export const DEFAULT_CHANNEL_POST_TIMEOUT_MS = 10000
18
+ export const DEFAULT_POLL_INTERVAL_MS = 500
19
+
20
+ export function buildRuntimePaths(runtimeDir) {
21
+ const absoluteRuntimeDir = path.resolve(runtimeDir)
22
+ return {
23
+ runtimeDir: absoluteRuntimeDir,
24
+ stateFile: path.join(absoluteRuntimeDir, 'state.json'),
25
+ resultFile: path.join(absoluteRuntimeDir, 'result.json'),
26
+ settingsFile: path.join(absoluteRuntimeDir, 'settings.json'),
27
+ mcpConfigFile: path.join(absoluteRuntimeDir, 'mcp.json'),
28
+ channelStateFile: path.join(absoluteRuntimeDir, 'channel-state.json'),
29
+ channelLogFile: path.join(absoluteRuntimeDir, 'channel.log'),
30
+ hookEventsFile: path.join(absoluteRuntimeDir, 'hook-events.jsonl'),
31
+ turnsDir: path.join(absoluteRuntimeDir, 'turns'),
32
+ }
33
+ }
34
+
35
+ export function resolveUtilsDir() {
36
+ return path.dirname(fileURLToPath(import.meta.url))
37
+ }
38
+
39
+ export async function ensureRuntimeDirs(paths) {
40
+ await fs.mkdir(paths.runtimeDir, { recursive: true })
41
+ await fs.mkdir(paths.turnsDir, { recursive: true })
42
+ }
43
+
44
+ export async function readJsonIfExists(filePath) {
45
+ try {
46
+ return await readJsonFile(filePath)
47
+ } catch (error) {
48
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
49
+ return null
50
+ }
51
+ throw error
52
+ }
53
+ }
54
+
55
+ export async function readState(runtimeDir) {
56
+ return readJsonIfExists(buildRuntimePaths(runtimeDir).stateFile)
57
+ }
58
+
59
+ export async function writeState(runtimeDir, patch) {
60
+ const paths = buildRuntimePaths(runtimeDir)
61
+ const existing = (await readJsonIfExists(paths.stateFile)) || {}
62
+ const next = {
63
+ ...existing,
64
+ ...patch,
65
+ updated_at: new Date().toISOString(),
66
+ }
67
+ await writeJsonIfNeeded(paths.stateFile, next)
68
+ return next
69
+ }
70
+
71
+ export async function clearRuntimeArtifacts(paths) {
72
+ await writeFileIfNeeded(paths.hookEventsFile, '')
73
+ await writeFileIfNeeded(paths.channelLogFile, '')
74
+ await fs.rm(paths.channelStateFile, { force: true })
75
+ await fs.rm(paths.resultFile, { force: true })
76
+ }
77
+
78
+ export function sanitizeName(value) {
79
+ return String(value || 'lane')
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9]+/g, '-')
82
+ .replace(/^-+|-+$/g, '')
83
+ .slice(0, 40) || 'lane'
84
+ }
85
+
86
+ export function makeSuffix(size = 4) {
87
+ return crypto.randomBytes(size).toString('hex')
88
+ }
89
+
90
+ export function makeToken(size = 24) {
91
+ return crypto.randomBytes(size).toString('hex')
92
+ }
93
+
94
+ export function shellQuote(value) {
95
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
96
+ }
97
+
98
+ export async function allocatePort(host = '127.0.0.1') {
99
+ return new Promise((resolve, reject) => {
100
+ const server = net.createServer()
101
+ server.once('error', reject)
102
+ server.listen(0, host, () => {
103
+ const address = server.address()
104
+ const port = address && typeof address === 'object' ? address.port : null
105
+ server.close((error) => {
106
+ if (error) {
107
+ reject(error)
108
+ return
109
+ }
110
+ resolve(port)
111
+ })
112
+ })
113
+ })
114
+ }
115
+
116
+ export async function runCommand(command, args, options = {}) {
117
+ return new Promise((resolve, reject) => {
118
+ const child = spawn(command, args, {
119
+ cwd: options.cwd,
120
+ env: process.env,
121
+ stdio: ['ignore', 'pipe', 'pipe'],
122
+ })
123
+
124
+ let stdout = ''
125
+ let stderr = ''
126
+
127
+ child.stdout.on('data', (chunk) => {
128
+ stdout += chunk.toString()
129
+ })
130
+
131
+ child.stderr.on('data', (chunk) => {
132
+ stderr += chunk.toString()
133
+ })
134
+
135
+ child.on('error', reject)
136
+ child.on('close', (code) => {
137
+ resolve({ code: code ?? 1, stdout, stderr })
138
+ })
139
+ })
140
+ }
141
+
142
+ export async function tmuxHasSession(sessionName) {
143
+ if (!sessionName) return false
144
+ const result = await runCommand('tmux', ['has-session', '-t', sessionName])
145
+ return result.code === 0
146
+ }
147
+
148
+ export async function tmuxCapturePane(sessionName) {
149
+ const result = await runCommand('tmux', ['capture-pane', '-p', '-t', sessionName])
150
+ return result.code === 0 ? result.stdout : ''
151
+ }
152
+
153
+ export async function tmuxSendEnter(sessionName) {
154
+ return runCommand('tmux', ['send-keys', '-t', sessionName, 'Enter'])
155
+ }
156
+
157
+ export async function tmuxKillSession(sessionName) {
158
+ return runCommand('tmux', ['kill-session', '-t', sessionName])
159
+ }
160
+
161
+ export async function waitFor(predicate, { timeoutMs, intervalMs = DEFAULT_POLL_INTERVAL_MS, errorMessage }) {
162
+ const deadline = Date.now() + timeoutMs
163
+ while (Date.now() < deadline) {
164
+ const value = await predicate()
165
+ if (value) {
166
+ return value
167
+ }
168
+ await sleep(intervalMs)
169
+ }
170
+ throw new Error(errorMessage)
171
+ }
172
+
173
+ export async function maybeAcceptDevelopmentChannelsPrompt(sessionName, timeoutMs = 30000) {
174
+ const deadline = Date.now() + timeoutMs
175
+ while (Date.now() < deadline) {
176
+ const pane = await tmuxCapturePane(sessionName)
177
+ if (pane.includes('Listening for channel messages from:')) {
178
+ return false
179
+ }
180
+ if (pane.includes('I am using this for local development')) {
181
+ await tmuxSendEnter(sessionName)
182
+ return true
183
+ }
184
+ await sleep(DEFAULT_POLL_INTERVAL_MS)
185
+ }
186
+ return false
187
+ }
188
+
189
+ export async function waitForChannelReady(paths, timeoutMs) {
190
+ return waitFor(async () => {
191
+ const channelState = await readJsonIfExists(paths.channelStateFile)
192
+ if (channelState?.ready_for_notifications) {
193
+ return channelState
194
+ }
195
+ return null
196
+ }, {
197
+ timeoutMs,
198
+ errorMessage: 'Timed out waiting for Claude channel readiness',
199
+ })
200
+ }
201
+
202
+ export async function readJsonl(filePath) {
203
+ try {
204
+ const content = await fs.readFile(filePath, 'utf8')
205
+ return content
206
+ .split('\n')
207
+ .map((line) => line.trim())
208
+ .filter(Boolean)
209
+ .map((line) => JSON.parse(line))
210
+ } catch (error) {
211
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
212
+ return []
213
+ }
214
+ throw error
215
+ }
216
+ }
217
+
218
+ export async function waitForHookEvent(paths, startIndex, labels, timeoutMs) {
219
+ return waitFor(async () => {
220
+ const events = await readJsonl(paths.hookEventsFile)
221
+ const pending = events.slice(startIndex)
222
+ const match = pending.find((event) => labels.has(event.label))
223
+ if (!match) {
224
+ return null
225
+ }
226
+ return {
227
+ event: match,
228
+ eventsLength: events.length,
229
+ }
230
+ }, {
231
+ timeoutMs,
232
+ errorMessage: `Timed out waiting for hook event: ${Array.from(labels).join(', ')}`,
233
+ })
234
+ }
235
+
236
+ export function buildHookSettings({ runtimeDir, utilsDir, agentName = 'developer', subagentModel = null }) {
237
+ const buildCommand = (label) => `python3 ${shellQuote(path.join(utilsDir, 'claude_live_hook.py'))} --runtime-dir ${shellQuote(runtimeDir)} ${label}`
238
+ const settings = {
239
+ agent: agentName,
240
+ hooks: {
241
+ SessionStart: [{ hooks: [{ type: 'command', command: buildCommand('SessionStart') }] }],
242
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: buildCommand('UserPromptSubmit') }] }],
243
+ Stop: [{ hooks: [{ type: 'command', command: buildCommand('Stop') }] }],
244
+ StopFailure: [{ hooks: [{ type: 'command', command: buildCommand('StopFailure') }] }],
245
+ },
246
+ }
247
+
248
+ if (subagentModel) {
249
+ settings.env = {
250
+ CLAUDE_CODE_SUBAGENT_MODEL: subagentModel,
251
+ }
252
+ }
253
+
254
+ return settings
255
+ }
256
+
257
+ export function buildMcpConfig({ paths, utilsDir, channelName, lane, port, token }) {
258
+ return {
259
+ mcpServers: {
260
+ [channelName]: {
261
+ command: 'node',
262
+ args: [
263
+ path.join(utilsDir, 'claude_live_channel.mjs'),
264
+ '--port',
265
+ String(port),
266
+ '--token',
267
+ token,
268
+ '--state-file',
269
+ paths.channelStateFile,
270
+ '--log-file',
271
+ paths.channelLogFile,
272
+ '--channel-name',
273
+ channelName,
274
+ '--lane',
275
+ lane,
276
+ ],
277
+ },
278
+ },
279
+ }
280
+ }
281
+
282
+ export function buildClaudeLaunchCommand({ claudeCommand, agentName, displayName, settingsFile, mcpConfigFile, channelName, model, effort = null }) {
283
+ const parts = [
284
+ shellQuote(claudeCommand),
285
+ '--agent',
286
+ shellQuote(agentName),
287
+ '-n',
288
+ shellQuote(displayName),
289
+ '--settings',
290
+ shellQuote(settingsFile),
291
+ '--mcp-config',
292
+ shellQuote(mcpConfigFile),
293
+ '--dangerously-skip-permissions',
294
+ '--dangerously-load-development-channels',
295
+ shellQuote(`server:${channelName}`),
296
+ ]
297
+
298
+ if (model) {
299
+ parts.push('--model', shellQuote(model))
300
+ }
301
+
302
+ if (effort) {
303
+ parts.push('--effort', shellQuote(effort))
304
+ }
305
+
306
+ return parts.join(' ')
307
+ }
308
+
309
+ export async function postPromptToChannel({ state, prompt, turnId, timeoutMs = DEFAULT_CHANNEL_POST_TIMEOUT_MS }) {
310
+ return new Promise((resolve, reject) => {
311
+ const request = http.request({
312
+ hostname: '127.0.0.1',
313
+ port: state.channel_port,
314
+ path: '/',
315
+ method: 'POST',
316
+ headers: {
317
+ 'content-type': 'text/plain; charset=utf-8',
318
+ 'content-length': Buffer.byteLength(prompt, 'utf8'),
319
+ 'x-bridge-token': state.channel_token,
320
+ 'x-lane': state.lane,
321
+ 'x-turn-id': turnId,
322
+ },
323
+ timeout: timeoutMs,
324
+ }, (response) => {
325
+ let body = ''
326
+ response.on('data', (chunk) => {
327
+ body += chunk.toString()
328
+ })
329
+ response.on('end', () => {
330
+ resolve({
331
+ statusCode: response.statusCode || 0,
332
+ body,
333
+ })
334
+ })
335
+ })
336
+
337
+ request.on('error', reject)
338
+ request.on('timeout', () => {
339
+ request.destroy(new Error('channel_post_timeout'))
340
+ })
341
+ request.write(prompt)
342
+ request.end()
343
+ })
344
+ }
345
+
346
+ export function buildTurnId(lastTurnNumber) {
347
+ return String(lastTurnNumber + 1).padStart(4, '0')
348
+ }
349
+
350
+ export async function writeTurnArtifacts(paths, turnId, prompt, resultPayload, extraResultFile = null) {
351
+ const turnDir = path.join(paths.turnsDir, turnId)
352
+ await fs.mkdir(turnDir, { recursive: true })
353
+ await writeFileIfNeeded(path.join(turnDir, 'prompt.txt'), `${prompt}\n`)
354
+ await writeJsonIfNeeded(path.join(turnDir, 'result.json'), resultPayload)
355
+ await writeJsonIfNeeded(paths.resultFile, resultPayload)
356
+ if (extraResultFile) {
357
+ await writeJsonIfNeeded(extraResultFile, resultPayload)
358
+ }
359
+ return turnDir
360
+ }
361
+
362
+ export function extractFailureMessage(payload) {
363
+ if (!payload) return ''
364
+
365
+ const candidates = [
366
+ payload.message,
367
+ payload.error,
368
+ payload.failure_reason,
369
+ payload.reason,
370
+ payload.details,
371
+ ]
372
+
373
+ for (const candidate of candidates) {
374
+ if (typeof candidate === 'string' && candidate.trim()) {
375
+ return candidate.trim()
376
+ }
377
+ if (candidate && typeof candidate === 'object') {
378
+ return JSON.stringify(candidate)
379
+ }
380
+ }
381
+
382
+ return JSON.stringify(payload)
383
+ }
384
+
385
+ export function classifyStopFailure(event, fallbackSid = null) {
386
+ const payload = event?.payload || null
387
+ const sid = payload?.session_id || fallbackSid || null
388
+ const message = extractFailureMessage(payload) || 'claude_stop_failure'
389
+ const rateLimit = extractRateLimitMetadata(payload)
390
+
391
+ if (/hit your limit|usage limit|capacity|overloaded/i.test(message)) {
392
+ return {
393
+ result: { ok: false, code: 'claude_usage_limit', msg: 'usage_limit', detail: message, rate_limit: rateLimit, sid },
394
+ nextStatus: 'blocked',
395
+ }
396
+ }
397
+
398
+ return {
399
+ result: { ok: false, code: 'claude_stop_failure', msg: message, sid },
400
+ nextStatus: 'failed',
401
+ }
402
+ }
403
+
404
+ export function buildFailureResult(code, message, sid = null) {
405
+ return {
406
+ ok: false,
407
+ code,
408
+ msg: message,
409
+ sid,
410
+ }
411
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ import sys
7
+
8
+
9
+ def parse_args(argv: list[str]) -> tuple[Path, str]:
10
+ runtime_dir = None
11
+ label = 'unknown'
12
+ index = 0
13
+ while index < len(argv):
14
+ value = argv[index]
15
+ if value == '--runtime-dir':
16
+ index += 1
17
+ runtime_dir = Path(argv[index])
18
+ else:
19
+ label = value
20
+ index += 1
21
+ if runtime_dir is None:
22
+ raise SystemExit('Missing --runtime-dir')
23
+ return runtime_dir, label
24
+
25
+
26
+ def main() -> None:
27
+ runtime_dir, label = parse_args(sys.argv[1:])
28
+ payload_raw = sys.stdin.read().strip()
29
+ try:
30
+ payload = json.loads(payload_raw) if payload_raw else None
31
+ except Exception:
32
+ payload = {'raw': payload_raw}
33
+
34
+ record = {
35
+ 'ts': datetime.now(timezone.utc).isoformat(),
36
+ 'label': label,
37
+ 'payload': payload,
38
+ }
39
+
40
+ runtime_dir.mkdir(parents=True, exist_ok=True)
41
+ hook_log = runtime_dir / 'hook-events.jsonl'
42
+ with hook_log.open('a', encoding='utf-8') as handle:
43
+ handle.write(json.dumps(record) + '\n')
44
+
45
+
46
+ if __name__ == '__main__':
47
+ main()