theslopmachine 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL.md +21 -6
- package/README.md +55 -7
- package/RELEASE.md +15 -0
- package/assets/agents/developer.md +41 -1
- package/assets/agents/slopmachine-claude.md +100 -60
- package/assets/agents/slopmachine.md +40 -17
- package/assets/claude/agents/developer.md +42 -5
- package/assets/skills/clarification-gate/SKILL.md +25 -5
- package/assets/skills/claude-worker-management/SKILL.md +280 -57
- package/assets/skills/developer-session-lifecycle/SKILL.md +81 -37
- package/assets/skills/development-guidance/SKILL.md +21 -1
- package/assets/skills/evaluation-triage/SKILL.md +32 -23
- package/assets/skills/final-evaluation-orchestration/SKILL.md +86 -50
- package/assets/skills/hardening-gate/SKILL.md +17 -3
- package/assets/skills/integrated-verification/SKILL.md +3 -3
- package/assets/skills/planning-gate/SKILL.md +32 -3
- package/assets/skills/planning-guidance/SKILL.md +72 -13
- package/assets/skills/retrospective-analysis/SKILL.md +2 -2
- package/assets/skills/scaffold-guidance/SKILL.md +129 -124
- package/assets/skills/submission-packaging/SKILL.md +33 -27
- package/assets/skills/verification-gates/SKILL.md +44 -14
- package/assets/slopmachine/backend-evaluation-prompt.md +1 -1
- package/assets/slopmachine/frontend-evaluation-prompt.md +5 -5
- package/assets/slopmachine/scaffold-playbooks/android-kotlin-compose.md +81 -0
- package/assets/slopmachine/scaffold-playbooks/android-kotlin-views.md +191 -0
- package/assets/slopmachine/scaffold-playbooks/android-native-java.md +203 -0
- package/assets/slopmachine/scaffold-playbooks/angular-default.md +181 -0
- package/assets/slopmachine/scaffold-playbooks/backend-baseline.md +142 -0
- package/assets/slopmachine/scaffold-playbooks/backend-family-matrix.md +80 -0
- package/assets/slopmachine/scaffold-playbooks/database-module-matrix.md +80 -0
- package/assets/slopmachine/scaffold-playbooks/django-default.md +166 -0
- package/assets/slopmachine/scaffold-playbooks/docker-baseline.md +189 -0
- package/assets/slopmachine/scaffold-playbooks/docker-shared-contract.md +334 -0
- package/assets/slopmachine/scaffold-playbooks/electron-vite-default.md +124 -0
- package/assets/slopmachine/scaffold-playbooks/expo-react-native-default.md +73 -0
- package/assets/slopmachine/scaffold-playbooks/fastapi-default.md +134 -0
- package/assets/slopmachine/scaffold-playbooks/frontend-baseline.md +160 -0
- package/assets/slopmachine/scaffold-playbooks/frontend-family-matrix.md +134 -0
- package/assets/slopmachine/scaffold-playbooks/generic-unknown-tech-guide.md +136 -0
- package/assets/slopmachine/scaffold-playbooks/go-chi-default.md +160 -0
- package/assets/slopmachine/scaffold-playbooks/ios-linux-portable.md +93 -0
- package/assets/slopmachine/scaffold-playbooks/ios-native-objective-c.md +151 -0
- package/assets/slopmachine/scaffold-playbooks/ios-native-swift.md +188 -0
- package/assets/slopmachine/scaffold-playbooks/laravel-default.md +216 -0
- package/assets/slopmachine/scaffold-playbooks/livewire-default.md +265 -0
- package/assets/slopmachine/scaffold-playbooks/overlay-module-matrix.md +130 -0
- package/assets/slopmachine/scaffold-playbooks/platform-family-matrix.md +79 -0
- package/assets/slopmachine/scaffold-playbooks/selection-matrix.md +72 -0
- package/assets/slopmachine/scaffold-playbooks/spring-boot-default.md +182 -0
- package/assets/slopmachine/scaffold-playbooks/tauri-default.md +80 -0
- package/assets/slopmachine/scaffold-playbooks/vue-vite-default.md +162 -0
- package/assets/slopmachine/scaffold-playbooks/web-default.md +96 -0
- package/assets/slopmachine/templates/AGENTS.md +41 -3
- package/assets/slopmachine/templates/CLAUDE.md +111 -0
- package/assets/slopmachine/utils/claude_create_session.mjs +1 -0
- package/assets/slopmachine/utils/claude_live_channel.mjs +188 -0
- package/assets/slopmachine/utils/claude_live_common.mjs +406 -0
- package/assets/slopmachine/utils/claude_live_hook.py +47 -0
- package/assets/slopmachine/utils/claude_live_launch.mjs +181 -0
- package/assets/slopmachine/utils/claude_live_status.mjs +25 -0
- package/assets/slopmachine/utils/claude_live_stop.mjs +45 -0
- package/assets/slopmachine/utils/claude_live_turn.mjs +250 -0
- package/assets/slopmachine/utils/claude_resume_session.mjs +1 -0
- package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.mjs +23 -0
- package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.sh +5 -0
- package/assets/slopmachine/utils/claude_worker_common.mjs +224 -4
- package/assets/slopmachine/utils/cleanup_delivery_artifacts.py +4 -0
- package/assets/slopmachine/utils/export_ai_session.mjs +1 -1
- package/assets/slopmachine/utils/normalize_claude_session.py +153 -0
- package/assets/slopmachine/utils/package_claude_session.mjs +96 -0
- package/assets/slopmachine/utils/prepare_strict_audit_workspace.mjs +65 -0
- package/package.json +1 -1
- package/src/constants.js +42 -3
- package/src/init.js +173 -28
- package/src/install.js +75 -0
- package/src/send-data.js +56 -57
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_TURN_TIMEOUT_MS,
|
|
7
|
+
buildFailureResult,
|
|
8
|
+
buildRuntimePaths,
|
|
9
|
+
buildTurnId,
|
|
10
|
+
classifyStopFailure,
|
|
11
|
+
emitFailure,
|
|
12
|
+
emitSuccess,
|
|
13
|
+
postPromptToChannel,
|
|
14
|
+
parseArgs,
|
|
15
|
+
readJsonl,
|
|
16
|
+
readPrompt,
|
|
17
|
+
readState,
|
|
18
|
+
tmuxHasSession,
|
|
19
|
+
waitForRateLimitReset,
|
|
20
|
+
waitForChannelReady,
|
|
21
|
+
waitForHookEvent,
|
|
22
|
+
writeState,
|
|
23
|
+
writeTurnArtifacts,
|
|
24
|
+
} from './claude_live_common.mjs'
|
|
25
|
+
|
|
26
|
+
const argv = parseArgs(process.argv.slice(2))
|
|
27
|
+
const runtimeDir = argv['runtime-dir']
|
|
28
|
+
const promptFile = argv['prompt-file']
|
|
29
|
+
const turnTimeoutMs = Number.parseInt(argv['timeout-ms'] || String(DEFAULT_TURN_TIMEOUT_MS), 10)
|
|
30
|
+
const retryOnLimit = argv['retry-on-limit'] !== '0'
|
|
31
|
+
|
|
32
|
+
if (!runtimeDir || !promptFile) {
|
|
33
|
+
emitFailure('claude_live_turn_invalid_args', 'Missing required turn arguments', {
|
|
34
|
+
state_file: runtimeDir ? buildRuntimePaths(runtimeDir).stateFile : null,
|
|
35
|
+
})
|
|
36
|
+
process.exit(1)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const paths = buildRuntimePaths(runtimeDir)
|
|
40
|
+
|
|
41
|
+
function emitTurnFailure(resultPayload) {
|
|
42
|
+
emitFailure(resultPayload.code, resultPayload.msg, {
|
|
43
|
+
sid: resultPayload.sid || null,
|
|
44
|
+
result_file: argv['result-file'] || paths.resultFile,
|
|
45
|
+
state_file: paths.stateFile,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const state = await readState(runtimeDir)
|
|
51
|
+
if (!state) {
|
|
52
|
+
const resultPayload = buildFailureResult('claude_live_turn_uninitialized', 'Claude live lane is not initialized')
|
|
53
|
+
emitTurnFailure(resultPayload)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (state.status !== 'idle') {
|
|
58
|
+
const resultPayload = buildFailureResult('claude_live_turn_not_idle', `Claude live lane is not idle: ${state.status}`, state.sid || null)
|
|
59
|
+
emitTurnFailure(resultPayload)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!state.sid) {
|
|
64
|
+
const resultPayload = buildFailureResult('claude_live_turn_unregistered', 'Claude live lane does not have a session id yet')
|
|
65
|
+
emitTurnFailure(resultPayload)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!await tmuxHasSession(state.tmux_session)) {
|
|
70
|
+
await writeState(runtimeDir, {
|
|
71
|
+
status: 'failed',
|
|
72
|
+
last_error: 'Claude tmux session is no longer running',
|
|
73
|
+
})
|
|
74
|
+
const resultPayload = buildFailureResult('claude_session_dead', 'Claude tmux session is no longer running', state.sid)
|
|
75
|
+
emitTurnFailure(resultPayload)
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const prompt = await readPrompt(promptFile)
|
|
80
|
+
let hookStartIndex = (await readJsonl(paths.hookEventsFile)).length
|
|
81
|
+
const turnNumber = Number(state.last_turn_number || 0)
|
|
82
|
+
const turnId = buildTurnId(turnNumber)
|
|
83
|
+
|
|
84
|
+
await writeState(runtimeDir, {
|
|
85
|
+
status: 'running',
|
|
86
|
+
current_turn_id: turnId,
|
|
87
|
+
current_turn_prompt_file: path.resolve(promptFile),
|
|
88
|
+
current_turn_started_at: new Date().toISOString(),
|
|
89
|
+
last_error: null,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
while (true) {
|
|
93
|
+
const liveState = await readState(runtimeDir) || state
|
|
94
|
+
|
|
95
|
+
await waitForChannelReady(paths, 15000)
|
|
96
|
+
|
|
97
|
+
const sendResult = await postPromptToChannel({
|
|
98
|
+
state: liveState,
|
|
99
|
+
prompt,
|
|
100
|
+
turnId,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (sendResult.statusCode !== 200) {
|
|
104
|
+
const resultPayload = buildFailureResult('channel_send_failed', sendResult.body || `Channel send failed with status ${sendResult.statusCode}`, liveState.sid || state.sid)
|
|
105
|
+
await writeTurnArtifacts(paths, turnId, prompt, resultPayload, argv['result-file'] || null)
|
|
106
|
+
await writeState(runtimeDir, {
|
|
107
|
+
status: 'failed',
|
|
108
|
+
current_turn_id: null,
|
|
109
|
+
current_turn_prompt_file: null,
|
|
110
|
+
current_turn_started_at: null,
|
|
111
|
+
last_completed_turn_id: turnId,
|
|
112
|
+
last_turn_number: turnNumber + 1,
|
|
113
|
+
last_error: resultPayload.msg,
|
|
114
|
+
rate_limit_wait_started_at: null,
|
|
115
|
+
rate_limit_wait_until: null,
|
|
116
|
+
rate_limit_wait_ms: null,
|
|
117
|
+
rate_limit_wait_source: null,
|
|
118
|
+
rate_limit_message: null,
|
|
119
|
+
})
|
|
120
|
+
emitTurnFailure(resultPayload)
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { event, eventsLength } = await waitForHookEvent(paths, hookStartIndex, new Set(['Stop', 'StopFailure']), turnTimeoutMs)
|
|
125
|
+
hookStartIndex = eventsLength
|
|
126
|
+
|
|
127
|
+
if (event.label === 'Stop') {
|
|
128
|
+
const sessionId = event.payload?.session_id || liveState.sid || state.sid
|
|
129
|
+
const resultPayload = {
|
|
130
|
+
ok: true,
|
|
131
|
+
sid: sessionId,
|
|
132
|
+
res: event.payload?.last_assistant_message || '',
|
|
133
|
+
}
|
|
134
|
+
await writeTurnArtifacts(paths, turnId, prompt, resultPayload, argv['result-file'] || null)
|
|
135
|
+
await writeState(runtimeDir, {
|
|
136
|
+
status: 'idle',
|
|
137
|
+
sid: sessionId,
|
|
138
|
+
transcript_path: event.payload?.transcript_path || liveState.transcript_path || state.transcript_path || null,
|
|
139
|
+
current_turn_id: null,
|
|
140
|
+
current_turn_prompt_file: null,
|
|
141
|
+
current_turn_started_at: null,
|
|
142
|
+
last_completed_turn_id: turnId,
|
|
143
|
+
last_hook_event: 'Stop',
|
|
144
|
+
last_error: null,
|
|
145
|
+
last_turn_number: turnNumber + 1,
|
|
146
|
+
last_result_summary: resultPayload.res.slice(0, 500),
|
|
147
|
+
rate_limit_wait_started_at: null,
|
|
148
|
+
rate_limit_wait_until: null,
|
|
149
|
+
rate_limit_wait_ms: null,
|
|
150
|
+
rate_limit_wait_source: null,
|
|
151
|
+
rate_limit_message: null,
|
|
152
|
+
})
|
|
153
|
+
emitSuccess(sessionId, {
|
|
154
|
+
result_file: argv['result-file'] || paths.resultFile,
|
|
155
|
+
state_file: paths.stateFile,
|
|
156
|
+
})
|
|
157
|
+
process.exit(0)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { result: resultPayload, nextStatus } = classifyStopFailure(event, liveState.sid || state.sid)
|
|
161
|
+
if (resultPayload.code === 'claude_usage_limit' && retryOnLimit) {
|
|
162
|
+
await writeState(runtimeDir, {
|
|
163
|
+
status: 'blocked',
|
|
164
|
+
sid: resultPayload.sid || liveState.sid || state.sid,
|
|
165
|
+
transcript_path: event.payload?.transcript_path || liveState.transcript_path || state.transcript_path || null,
|
|
166
|
+
last_hook_event: 'StopFailure',
|
|
167
|
+
last_error: resultPayload.msg,
|
|
168
|
+
})
|
|
169
|
+
await waitForRateLimitReset({
|
|
170
|
+
message: resultPayload.detail || resultPayload.msg,
|
|
171
|
+
statePath: paths.stateFile,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
if (!await tmuxHasSession((await readState(runtimeDir))?.tmux_session || liveState.tmux_session)) {
|
|
175
|
+
const deadPayload = buildFailureResult('claude_session_dead', 'Claude tmux session is no longer running', resultPayload.sid || liveState.sid || state.sid)
|
|
176
|
+
await writeTurnArtifacts(paths, turnId, prompt, deadPayload, argv['result-file'] || null)
|
|
177
|
+
await writeState(runtimeDir, {
|
|
178
|
+
status: 'failed',
|
|
179
|
+
current_turn_id: null,
|
|
180
|
+
current_turn_prompt_file: null,
|
|
181
|
+
current_turn_started_at: null,
|
|
182
|
+
last_completed_turn_id: turnId,
|
|
183
|
+
last_turn_number: turnNumber + 1,
|
|
184
|
+
last_error: deadPayload.msg,
|
|
185
|
+
rate_limit_wait_started_at: null,
|
|
186
|
+
rate_limit_wait_until: null,
|
|
187
|
+
rate_limit_wait_ms: null,
|
|
188
|
+
rate_limit_wait_source: null,
|
|
189
|
+
rate_limit_message: null,
|
|
190
|
+
})
|
|
191
|
+
emitTurnFailure(deadPayload)
|
|
192
|
+
process.exit(1)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await writeState(runtimeDir, {
|
|
196
|
+
status: 'running',
|
|
197
|
+
current_turn_id: turnId,
|
|
198
|
+
current_turn_prompt_file: path.resolve(promptFile),
|
|
199
|
+
current_turn_started_at: new Date().toISOString(),
|
|
200
|
+
last_error: null,
|
|
201
|
+
})
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await writeTurnArtifacts(paths, turnId, prompt, resultPayload, argv['result-file'] || null)
|
|
206
|
+
await writeState(runtimeDir, {
|
|
207
|
+
status: nextStatus,
|
|
208
|
+
sid: resultPayload.sid || liveState.sid || state.sid,
|
|
209
|
+
transcript_path: event.payload?.transcript_path || liveState.transcript_path || state.transcript_path || null,
|
|
210
|
+
current_turn_id: null,
|
|
211
|
+
current_turn_prompt_file: null,
|
|
212
|
+
current_turn_started_at: null,
|
|
213
|
+
last_completed_turn_id: turnId,
|
|
214
|
+
last_hook_event: 'StopFailure',
|
|
215
|
+
last_error: resultPayload.msg,
|
|
216
|
+
last_turn_number: turnNumber + 1,
|
|
217
|
+
rate_limit_wait_started_at: null,
|
|
218
|
+
rate_limit_wait_until: null,
|
|
219
|
+
rate_limit_wait_ms: null,
|
|
220
|
+
rate_limit_wait_source: null,
|
|
221
|
+
rate_limit_message: null,
|
|
222
|
+
})
|
|
223
|
+
emitTurnFailure(resultPayload)
|
|
224
|
+
process.exit(1)
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const state = await readState(runtimeDir)
|
|
228
|
+
const code = state?.tmux_session && !await tmuxHasSession(state.tmux_session) ? 'claude_session_dead' : 'claude_bridge_timeout'
|
|
229
|
+
const resultPayload = buildFailureResult(code, error instanceof Error ? error.message : String(error), state?.sid || null)
|
|
230
|
+
const turnNumber = Number(state?.last_turn_number || 0)
|
|
231
|
+
const turnId = buildTurnId(turnNumber)
|
|
232
|
+
const prompt = await readPrompt(promptFile).catch(() => '')
|
|
233
|
+
await writeTurnArtifacts(paths, turnId, prompt, resultPayload, argv['result-file'] || null)
|
|
234
|
+
await writeState(runtimeDir, {
|
|
235
|
+
status: 'failed',
|
|
236
|
+
current_turn_id: null,
|
|
237
|
+
current_turn_prompt_file: null,
|
|
238
|
+
current_turn_started_at: null,
|
|
239
|
+
last_completed_turn_id: turnId,
|
|
240
|
+
last_turn_number: turnNumber + 1,
|
|
241
|
+
last_error: resultPayload.msg,
|
|
242
|
+
rate_limit_wait_started_at: null,
|
|
243
|
+
rate_limit_wait_until: null,
|
|
244
|
+
rate_limit_wait_ms: null,
|
|
245
|
+
rate_limit_wait_source: null,
|
|
246
|
+
rate_limit_message: null,
|
|
247
|
+
})
|
|
248
|
+
emitTurnFailure(resultPayload)
|
|
249
|
+
process.exit(1)
|
|
250
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { emitFailure, emitSuccess, parseArgs, waitForRateLimitReset } from './claude_worker_common.mjs'
|
|
4
|
+
|
|
5
|
+
const argv = parseArgs(process.argv.slice(2))
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const waitInfo = await waitForRateLimitReset({
|
|
9
|
+
message: argv.message || '',
|
|
10
|
+
statePath: argv['state-file'] || null,
|
|
11
|
+
dryRun: argv['dry-run'] === '1',
|
|
12
|
+
bufferMs: argv['buffer-ms'] ? Number.parseInt(argv['buffer-ms'], 10) : undefined,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
emitSuccess(null, {
|
|
16
|
+
wait_ms: waitInfo.waitMs,
|
|
17
|
+
reset_at: waitInfo.resetAt.toISOString(),
|
|
18
|
+
source: waitInfo.source,
|
|
19
|
+
})
|
|
20
|
+
} catch (error) {
|
|
21
|
+
emitFailure('claude_wait_for_rate_limit_reset_failed', error instanceof Error ? error.message : String(error))
|
|
22
|
+
process.exitCode = 1
|
|
23
|
+
}
|
|
@@ -5,10 +5,16 @@ import { createWriteStream } from 'node:fs'
|
|
|
5
5
|
import os from 'node:os'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { spawn } from 'node:child_process'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
8
9
|
|
|
9
|
-
const DEFAULT_ALLOWED_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep']
|
|
10
|
+
const DEFAULT_ALLOWED_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'Task', 'TaskOutput', 'TaskStop']
|
|
10
11
|
const DEFAULT_LIMIT_TIMEZONE = 'Africa/Addis_Ababa'
|
|
11
12
|
const DEFAULT_LIMIT_HOUR = 5
|
|
13
|
+
const DEFAULT_RATE_LIMIT_BUFFER_MS = 60 * 1000
|
|
14
|
+
|
|
15
|
+
function utilsDir() {
|
|
16
|
+
return path.dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
}
|
|
12
18
|
|
|
13
19
|
export function parseArgs(argv) {
|
|
14
20
|
const result = {}
|
|
@@ -39,6 +45,17 @@ export async function writeJsonIfNeeded(filePath, value) {
|
|
|
39
45
|
await writeFileIfNeeded(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
async function readJsonIfExists(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
return await readJsonFile(filePath)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
export async function readJsonFile(filePath) {
|
|
43
60
|
const content = await fs.readFile(filePath, 'utf8')
|
|
44
61
|
return JSON.parse(content)
|
|
@@ -236,6 +253,7 @@ export function classifyClaudeFailure(parsed, fallbackMessage = '') {
|
|
|
236
253
|
return {
|
|
237
254
|
code: 'claude_usage_limit',
|
|
238
255
|
msg: 'usage_limit',
|
|
256
|
+
detail: rawMessage,
|
|
239
257
|
retryable: true,
|
|
240
258
|
sid: sessionId,
|
|
241
259
|
}
|
|
@@ -245,6 +263,7 @@ export function classifyClaudeFailure(parsed, fallbackMessage = '') {
|
|
|
245
263
|
return {
|
|
246
264
|
code: 'claude_transport_failed',
|
|
247
265
|
msg: rawMessage || 'claude_transport_failed',
|
|
266
|
+
detail: rawMessage || 'claude_transport_failed',
|
|
248
267
|
retryable: true,
|
|
249
268
|
sid: sessionId,
|
|
250
269
|
}
|
|
@@ -253,11 +272,210 @@ export function classifyClaudeFailure(parsed, fallbackMessage = '') {
|
|
|
253
272
|
return {
|
|
254
273
|
code: 'claude_call_failed',
|
|
255
274
|
msg: rawMessage || 'claude_call_failed',
|
|
275
|
+
detail: rawMessage || 'claude_call_failed',
|
|
256
276
|
retryable: false,
|
|
257
277
|
sid: sessionId,
|
|
258
278
|
}
|
|
259
279
|
}
|
|
260
280
|
|
|
281
|
+
function normalizeOffset(token) {
|
|
282
|
+
if (!token) return null
|
|
283
|
+
const normalized = token.trim().toUpperCase()
|
|
284
|
+
if (normalized === 'Z' || normalized === 'UTC' || normalized === 'GMT') {
|
|
285
|
+
return 'Z'
|
|
286
|
+
}
|
|
287
|
+
if (/^[+-]\d{2}:?\d{2}$/.test(normalized)) {
|
|
288
|
+
return normalized.includes(':') ? normalized : `${normalized.slice(0, 3)}:${normalized.slice(3)}`
|
|
289
|
+
}
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getDatePartsForOffset(now, offsetToken) {
|
|
294
|
+
const offsetMinutes = offsetToken === 'Z'
|
|
295
|
+
? 0
|
|
296
|
+
: Number(offsetToken.slice(1, 3)) * 60 + Number(offsetToken.slice(4, 6))
|
|
297
|
+
const signedOffsetMinutes = offsetToken.startsWith('-') ? -offsetMinutes : offsetMinutes
|
|
298
|
+
const shifted = new Date(now.getTime() + signedOffsetMinutes * 60 * 1000)
|
|
299
|
+
return {
|
|
300
|
+
year: shifted.getUTCFullYear(),
|
|
301
|
+
month: shifted.getUTCMonth() + 1,
|
|
302
|
+
day: shifted.getUTCDate(),
|
|
303
|
+
offsetToken,
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildIsoFromParts({ year, month, day }, hours, minutes, offsetToken) {
|
|
308
|
+
const paddedMonth = String(month).padStart(2, '0')
|
|
309
|
+
const paddedDay = String(day).padStart(2, '0')
|
|
310
|
+
const paddedHours = String(hours).padStart(2, '0')
|
|
311
|
+
const paddedMinutes = String(minutes).padStart(2, '0')
|
|
312
|
+
return `${year}-${paddedMonth}-${paddedDay}T${paddedHours}:${paddedMinutes}:00${offsetToken}`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function parseTimeOnlyCandidate(candidate, now) {
|
|
316
|
+
const match = candidate.match(/\b(today|tomorrow)?\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*(UTC|GMT|Z|[+-]\d{2}:?\d{2}))?\b/i)
|
|
317
|
+
if (!match) {
|
|
318
|
+
return null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const dayWord = match[1]?.toLowerCase() || null
|
|
322
|
+
let hours = Number(match[2])
|
|
323
|
+
const minutes = Number(match[3] || '0')
|
|
324
|
+
const meridiem = match[4]?.toLowerCase() || null
|
|
325
|
+
const offsetToken = normalizeOffset(match[5] || null)
|
|
326
|
+
|
|
327
|
+
if (meridiem) {
|
|
328
|
+
if (hours === 12) hours = 0
|
|
329
|
+
if (meridiem === 'pm') hours += 12
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (offsetToken) {
|
|
333
|
+
let dateParts = getDatePartsForOffset(now, offsetToken)
|
|
334
|
+
let parsed = Date.parse(buildIsoFromParts(dateParts, hours, minutes, offsetToken))
|
|
335
|
+
if (Number.isNaN(parsed)) {
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
if (dayWord === 'tomorrow' || (dayWord !== 'today' && parsed <= now.getTime())) {
|
|
339
|
+
const retryBase = new Date(parsed + 24 * 60 * 60 * 1000)
|
|
340
|
+
dateParts = { year: retryBase.getUTCFullYear(), month: retryBase.getUTCMonth() + 1, day: retryBase.getUTCDate(), offsetToken }
|
|
341
|
+
parsed = Date.parse(buildIsoFromParts(dateParts, hours, minutes, offsetToken))
|
|
342
|
+
}
|
|
343
|
+
return Number.isNaN(parsed) ? null : new Date(parsed)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const target = new Date(now)
|
|
347
|
+
target.setSeconds(0, 0)
|
|
348
|
+
target.setHours(hours, minutes, 0, 0)
|
|
349
|
+
if (dayWord === 'tomorrow') {
|
|
350
|
+
target.setDate(target.getDate() + 1)
|
|
351
|
+
} else if (dayWord !== 'today' && target.getTime() <= now.getTime()) {
|
|
352
|
+
target.setDate(target.getDate() + 1)
|
|
353
|
+
}
|
|
354
|
+
return target
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parseRelativeWait(candidate, now) {
|
|
358
|
+
const lowered = candidate.toLowerCase()
|
|
359
|
+
const anchorMatch = lowered.match(/(?:in|after)\s+([^\n]+)/)
|
|
360
|
+
if (!anchorMatch) {
|
|
361
|
+
return null
|
|
362
|
+
}
|
|
363
|
+
const fragment = anchorMatch[1]
|
|
364
|
+
const regex = /(\d+)\s*(milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?)/g
|
|
365
|
+
let totalMs = 0
|
|
366
|
+
let hasMatch = false
|
|
367
|
+
for (const match of fragment.matchAll(regex)) {
|
|
368
|
+
hasMatch = true
|
|
369
|
+
const amount = Number(match[1])
|
|
370
|
+
const unit = match[2].toLowerCase()
|
|
371
|
+
if (unit.startsWith('ms')) totalMs += amount
|
|
372
|
+
else if (unit.startsWith('second') || unit.startsWith('sec')) totalMs += amount * 1000
|
|
373
|
+
else if (unit.startsWith('minute') || unit.startsWith('min')) totalMs += amount * 60 * 1000
|
|
374
|
+
else if (unit.startsWith('hour') || unit.startsWith('hr')) totalMs += amount * 60 * 60 * 1000
|
|
375
|
+
}
|
|
376
|
+
if (!hasMatch || totalMs <= 0) {
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
return new Date(now.getTime() + totalMs)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function parseAbsoluteCandidate(candidate, now) {
|
|
383
|
+
const cleaned = candidate
|
|
384
|
+
.replace(/^[:\s-]+/, '')
|
|
385
|
+
.replace(/[).,;]+$/g, '')
|
|
386
|
+
.trim()
|
|
387
|
+
if (!cleaned) {
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const direct = Date.parse(cleaned)
|
|
392
|
+
if (!Number.isNaN(direct)) {
|
|
393
|
+
const parsed = new Date(direct)
|
|
394
|
+
if (parsed.getTime() > now.getTime()) {
|
|
395
|
+
return parsed
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return parseTimeOnlyCandidate(cleaned, now)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function calculateRateLimitReset({ message, now = new Date(), bufferMs = DEFAULT_RATE_LIMIT_BUFFER_MS } = {}) {
|
|
403
|
+
const rawMessage = String(message || '').trim()
|
|
404
|
+
if (!rawMessage) {
|
|
405
|
+
const fallbackMs = msUntilNextQuotaReset(now)
|
|
406
|
+
return {
|
|
407
|
+
waitMs: Math.max(fallbackMs + bufferMs, 0),
|
|
408
|
+
resetAt: new Date(now.getTime() + fallbackMs),
|
|
409
|
+
source: 'fallback_default_quota_window',
|
|
410
|
+
rawMessage,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const relative = parseRelativeWait(rawMessage, now)
|
|
415
|
+
if (relative) {
|
|
416
|
+
return {
|
|
417
|
+
waitMs: Math.max(relative.getTime() - now.getTime() + bufferMs, 0),
|
|
418
|
+
resetAt: relative,
|
|
419
|
+
source: 'relative_duration',
|
|
420
|
+
rawMessage,
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const phraseRegex = /(?:reset(?:s| time)?|try again|available|wait)\s+(?:at|until|on)?\s*([^\n]+)/ig
|
|
425
|
+
for (const match of rawMessage.matchAll(phraseRegex)) {
|
|
426
|
+
const parsed = parseAbsoluteCandidate(match[1], now)
|
|
427
|
+
if (parsed) {
|
|
428
|
+
return {
|
|
429
|
+
waitMs: Math.max(parsed.getTime() - now.getTime() + bufferMs, 0),
|
|
430
|
+
resetAt: parsed,
|
|
431
|
+
source: 'anchored_phrase',
|
|
432
|
+
rawMessage,
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const isoRegex = /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})\b/g
|
|
438
|
+
for (const match of rawMessage.matchAll(isoRegex)) {
|
|
439
|
+
const parsed = parseAbsoluteCandidate(match[0], now)
|
|
440
|
+
if (parsed) {
|
|
441
|
+
return {
|
|
442
|
+
waitMs: Math.max(parsed.getTime() - now.getTime() + bufferMs, 0),
|
|
443
|
+
resetAt: parsed,
|
|
444
|
+
source: 'iso_datetime',
|
|
445
|
+
rawMessage,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const fallbackMs = msUntilNextQuotaReset(now)
|
|
451
|
+
return {
|
|
452
|
+
waitMs: Math.max(fallbackMs + bufferMs, 0),
|
|
453
|
+
resetAt: new Date(now.getTime() + fallbackMs),
|
|
454
|
+
source: 'fallback_default_quota_window',
|
|
455
|
+
rawMessage,
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function waitForRateLimitReset({ message, statePath = null, dryRun = false, bufferMs = DEFAULT_RATE_LIMIT_BUFFER_MS } = {}) {
|
|
460
|
+
const waitInfo = calculateRateLimitReset({ message, bufferMs })
|
|
461
|
+
if (statePath) {
|
|
462
|
+
const existing = (await readJsonIfExists(statePath)) || {}
|
|
463
|
+
await writeJsonIfNeeded(statePath, {
|
|
464
|
+
...existing,
|
|
465
|
+
status: 'blocked',
|
|
466
|
+
rate_limit_wait_started_at: new Date().toISOString(),
|
|
467
|
+
rate_limit_wait_until: waitInfo.resetAt.toISOString(),
|
|
468
|
+
rate_limit_wait_ms: waitInfo.waitMs,
|
|
469
|
+
rate_limit_wait_source: waitInfo.source,
|
|
470
|
+
rate_limit_message: waitInfo.rawMessage || null,
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
if (!dryRun && waitInfo.waitMs > 0) {
|
|
474
|
+
await sleep(waitInfo.waitMs)
|
|
475
|
+
}
|
|
476
|
+
return waitInfo
|
|
477
|
+
}
|
|
478
|
+
|
|
261
479
|
function getZonedParts(date, timeZone) {
|
|
262
480
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
263
481
|
timeZone,
|
|
@@ -288,7 +506,7 @@ export function msUntilNextQuotaReset(now = new Date(), { hour = DEFAULT_LIMIT_H
|
|
|
288
506
|
if (waitMs <= 0) {
|
|
289
507
|
waitMs += 24 * 60 * 60 * 1000
|
|
290
508
|
}
|
|
291
|
-
return waitMs
|
|
509
|
+
return waitMs
|
|
292
510
|
}
|
|
293
511
|
|
|
294
512
|
export async function runClaudeWithRetry({ claudeCommand, args, cwd, rawOutputPath, rawErrorPath, statePath, retryOnLimit = true, maxAttempts = 2 }) {
|
|
@@ -322,8 +540,10 @@ export async function runClaudeWithRetry({ claudeCommand, args, cwd, rawOutputPa
|
|
|
322
540
|
}
|
|
323
541
|
|
|
324
542
|
if (failure.code === 'claude_usage_limit' && retryOnLimit) {
|
|
325
|
-
|
|
326
|
-
|
|
543
|
+
await waitForRateLimitReset({
|
|
544
|
+
message: failure.detail || failure.msg,
|
|
545
|
+
statePath,
|
|
546
|
+
})
|
|
327
547
|
}
|
|
328
548
|
|
|
329
549
|
attempt += 1
|
|
@@ -13,10 +13,12 @@ KNOWN_DIR_NAMES = {
|
|
|
13
13
|
'.parcel-cache',
|
|
14
14
|
'.pytest_cache',
|
|
15
15
|
'.svelte-kit',
|
|
16
|
+
'.venv',
|
|
16
17
|
'.tmp',
|
|
17
18
|
'.turbo',
|
|
18
19
|
'.vite',
|
|
19
20
|
'.vscode',
|
|
21
|
+
'.net',
|
|
20
22
|
'__pycache__',
|
|
21
23
|
'build',
|
|
22
24
|
'coverage',
|
|
@@ -38,6 +40,8 @@ JAVA_BUILD_MARKERS = {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
KNOWN_FILE_NAMES = {
|
|
43
|
+
'AGENTS.md',
|
|
44
|
+
'CLAUDE.md',
|
|
41
45
|
'.DS_Store',
|
|
42
46
|
'.env',
|
|
43
47
|
'.env.development',
|
|
@@ -22,7 +22,7 @@ async function run(command, args, cwd) {
|
|
|
22
22
|
|
|
23
23
|
try {
|
|
24
24
|
await fs.mkdir(path.dirname(argv.output), { recursive: true })
|
|
25
|
-
if (argv.backend === 'claude') {
|
|
25
|
+
if (argv.backend === 'claude' || argv.backend === 'claude-live') {
|
|
26
26
|
const sourcePath = await resolveClaudeSessionPath(argv['session-id'], argv.cwd)
|
|
27
27
|
const content = await fs.readFile(sourcePath, 'utf8')
|
|
28
28
|
await fs.writeFile(argv.output, content, 'utf8')
|