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.
Files changed (76) hide show
  1. package/MANUAL.md +21 -6
  2. package/README.md +55 -7
  3. package/RELEASE.md +15 -0
  4. package/assets/agents/developer.md +41 -1
  5. package/assets/agents/slopmachine-claude.md +100 -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 +280 -57
  10. package/assets/skills/developer-session-lifecycle/SKILL.md +81 -37
  11. package/assets/skills/development-guidance/SKILL.md +21 -1
  12. package/assets/skills/evaluation-triage/SKILL.md +32 -23
  13. package/assets/skills/final-evaluation-orchestration/SKILL.md +86 -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/utils/claude_create_session.mjs +1 -0
  56. package/assets/slopmachine/utils/claude_live_channel.mjs +188 -0
  57. package/assets/slopmachine/utils/claude_live_common.mjs +406 -0
  58. package/assets/slopmachine/utils/claude_live_hook.py +47 -0
  59. package/assets/slopmachine/utils/claude_live_launch.mjs +181 -0
  60. package/assets/slopmachine/utils/claude_live_status.mjs +25 -0
  61. package/assets/slopmachine/utils/claude_live_stop.mjs +45 -0
  62. package/assets/slopmachine/utils/claude_live_turn.mjs +250 -0
  63. package/assets/slopmachine/utils/claude_resume_session.mjs +1 -0
  64. package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.mjs +23 -0
  65. package/assets/slopmachine/utils/claude_wait_for_rate_limit_reset.sh +5 -0
  66. package/assets/slopmachine/utils/claude_worker_common.mjs +224 -4
  67. package/assets/slopmachine/utils/cleanup_delivery_artifacts.py +4 -0
  68. package/assets/slopmachine/utils/export_ai_session.mjs +1 -1
  69. package/assets/slopmachine/utils/normalize_claude_session.py +153 -0
  70. package/assets/slopmachine/utils/package_claude_session.mjs +96 -0
  71. package/assets/slopmachine/utils/prepare_strict_audit_workspace.mjs +65 -0
  72. package/package.json +1 -1
  73. package/src/constants.js +42 -3
  74. package/src/init.js +173 -28
  75. package/src/install.js +75 -0
  76. 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
+ }
@@ -22,6 +22,7 @@ try {
22
22
  ok: false,
23
23
  code: failure?.code || 'claude_resume_failed',
24
24
  msg: failure?.msg || 'claude_resume_failed',
25
+ detail: failure?.detail || null,
25
26
  sid: failure?.sid || null,
26
27
  }
27
28
  await writeJsonIfNeeded(argv['result-file'], resultPayload)
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
5
+ exec node "$SCRIPT_DIR/claude_wait_for_rate_limit_reset.mjs" "$@"
@@ -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 + 60 * 1000
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
- const waitMs = msUntilNextQuotaReset()
326
- await sleep(waitMs)
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')