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.
- package/MANUAL.md +21 -6
- package/README.md +55 -7
- package/RELEASE.md +16 -1
- package/assets/agents/developer.md +41 -1
- package/assets/agents/slopmachine-claude.md +101 -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 +290 -57
- package/assets/skills/developer-session-lifecycle/SKILL.md +83 -38
- package/assets/skills/development-guidance/SKILL.md +21 -1
- package/assets/skills/evaluation-triage/SKILL.md +34 -23
- package/assets/skills/final-evaluation-orchestration/SKILL.md +88 -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/test-coverage-prompt.md +561 -0
- package/assets/slopmachine/utils/claude_create_session.mjs +3 -2
- package/assets/slopmachine/utils/claude_live_channel.mjs +188 -0
- package/assets/slopmachine/utils/claude_live_common.mjs +411 -0
- package/assets/slopmachine/utils/claude_live_hook.py +47 -0
- package/assets/slopmachine/utils/claude_live_launch.mjs +187 -0
- package/assets/slopmachine/utils/claude_live_status.mjs +25 -0
- package/assets/slopmachine/utils/claude_live_stop.mjs +46 -0
- package/assets/slopmachine/utils/claude_live_turn.mjs +277 -0
- package/assets/slopmachine/utils/claude_resume_session.mjs +3 -2
- 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 +361 -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 +123 -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 +156 -8
- package/src/send-data.js +56 -57
|
@@ -5,10 +5,43 @@ 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
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeResetAtCandidate(value) {
|
|
20
|
+
if (value === null || value === undefined || value === '') {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
25
|
+
const ms = value > 1_000_000_000_000 ? value : value * 1000
|
|
26
|
+
return new Date(ms)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof value === 'string') {
|
|
30
|
+
const trimmed = value.trim()
|
|
31
|
+
if (!trimmed) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
if (/^\d+$/.test(trimmed)) {
|
|
35
|
+
return normalizeResetAtCandidate(Number(trimmed))
|
|
36
|
+
}
|
|
37
|
+
const parsed = Date.parse(trimmed)
|
|
38
|
+
if (!Number.isNaN(parsed)) {
|
|
39
|
+
return new Date(parsed)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
12
45
|
|
|
13
46
|
export function parseArgs(argv) {
|
|
14
47
|
const result = {}
|
|
@@ -39,6 +72,17 @@ export async function writeJsonIfNeeded(filePath, value) {
|
|
|
39
72
|
await writeFileIfNeeded(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
|
40
73
|
}
|
|
41
74
|
|
|
75
|
+
async function readJsonIfExists(filePath) {
|
|
76
|
+
try {
|
|
77
|
+
return await readJsonFile(filePath)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
42
86
|
export async function readJsonFile(filePath) {
|
|
43
87
|
const content = await fs.readFile(filePath, 'utf8')
|
|
44
88
|
return JSON.parse(content)
|
|
@@ -49,6 +93,50 @@ export async function readPrompt(promptFile) {
|
|
|
49
93
|
return content.trim()
|
|
50
94
|
}
|
|
51
95
|
|
|
96
|
+
async function readPromptFromStdin() {
|
|
97
|
+
const chunks = []
|
|
98
|
+
for await (const chunk of process.stdin) {
|
|
99
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
100
|
+
}
|
|
101
|
+
return Buffer.concat(chunks).toString('utf8').trim()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function readPromptInput(argv) {
|
|
105
|
+
const hasPromptFile = typeof argv['prompt-file'] === 'string' && argv['prompt-file'].trim().length > 0
|
|
106
|
+
const hasPromptInline = typeof argv.prompt === 'string'
|
|
107
|
+
const hasPromptStdin = argv['prompt-stdin'] === '1' || process.stdin.isTTY === false
|
|
108
|
+
|
|
109
|
+
if (hasPromptStdin) {
|
|
110
|
+
return {
|
|
111
|
+
prompt: await readPromptFromStdin(),
|
|
112
|
+
promptSource: 'stdin',
|
|
113
|
+
promptSourceKind: 'stdin',
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (hasPromptFile && hasPromptInline) {
|
|
118
|
+
throw new Error('Prompt input is ambiguous: provide only one of legacy fallback inputs --prompt-file or --prompt when stdin is not used')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (hasPromptFile) {
|
|
122
|
+
return {
|
|
123
|
+
prompt: await readPrompt(argv['prompt-file']),
|
|
124
|
+
promptSource: path.resolve(argv['prompt-file']),
|
|
125
|
+
promptSourceKind: 'file',
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (hasPromptInline) {
|
|
130
|
+
return {
|
|
131
|
+
prompt: String(argv.prompt).trim(),
|
|
132
|
+
promptSource: 'inline',
|
|
133
|
+
promptSourceKind: 'inline',
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
throw new Error('Missing prompt input: pipe prompt text through stdin (preferred) or use legacy fallback --prompt-file <file> / --prompt <text>')
|
|
138
|
+
}
|
|
139
|
+
|
|
52
140
|
export async function resolveClaudeSessionPath(sessionId, cwd) {
|
|
53
141
|
const projectsRoot = path.join(os.homedir(), '.claude', 'projects')
|
|
54
142
|
const directKey = cwd.replaceAll(path.sep, '-')
|
|
@@ -236,6 +324,7 @@ export function classifyClaudeFailure(parsed, fallbackMessage = '') {
|
|
|
236
324
|
return {
|
|
237
325
|
code: 'claude_usage_limit',
|
|
238
326
|
msg: 'usage_limit',
|
|
327
|
+
detail: rawMessage,
|
|
239
328
|
retryable: true,
|
|
240
329
|
sid: sessionId,
|
|
241
330
|
}
|
|
@@ -245,6 +334,7 @@ export function classifyClaudeFailure(parsed, fallbackMessage = '') {
|
|
|
245
334
|
return {
|
|
246
335
|
code: 'claude_transport_failed',
|
|
247
336
|
msg: rawMessage || 'claude_transport_failed',
|
|
337
|
+
detail: rawMessage || 'claude_transport_failed',
|
|
248
338
|
retryable: true,
|
|
249
339
|
sid: sessionId,
|
|
250
340
|
}
|
|
@@ -253,11 +343,276 @@ export function classifyClaudeFailure(parsed, fallbackMessage = '') {
|
|
|
253
343
|
return {
|
|
254
344
|
code: 'claude_call_failed',
|
|
255
345
|
msg: rawMessage || 'claude_call_failed',
|
|
346
|
+
detail: rawMessage || 'claude_call_failed',
|
|
256
347
|
retryable: false,
|
|
257
348
|
sid: sessionId,
|
|
258
349
|
}
|
|
259
350
|
}
|
|
260
351
|
|
|
352
|
+
export function extractRateLimitMetadata(payload = null) {
|
|
353
|
+
if (!payload || typeof payload !== 'object') {
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const info = payload.rate_limit_info && typeof payload.rate_limit_info === 'object'
|
|
358
|
+
? payload.rate_limit_info
|
|
359
|
+
: payload.rateLimitInfo && typeof payload.rateLimitInfo === 'object'
|
|
360
|
+
? payload.rateLimitInfo
|
|
361
|
+
: payload.rateLimit && typeof payload.rateLimit === 'object'
|
|
362
|
+
? payload.rateLimit
|
|
363
|
+
: null
|
|
364
|
+
|
|
365
|
+
const rawResetAt =
|
|
366
|
+
info?.resetsAt
|
|
367
|
+
?? info?.resetAt
|
|
368
|
+
?? payload.resetsAt
|
|
369
|
+
?? payload.resetAt
|
|
370
|
+
?? payload.rate_limit_reset_at
|
|
371
|
+
?? payload.limit_reset_at
|
|
372
|
+
?? null
|
|
373
|
+
|
|
374
|
+
const normalizedResetAt = normalizeResetAtCandidate(rawResetAt)
|
|
375
|
+
const rateLimitType =
|
|
376
|
+
info?.rateLimitType
|
|
377
|
+
?? info?.type
|
|
378
|
+
?? payload.rateLimitType
|
|
379
|
+
?? payload.rate_limit_type
|
|
380
|
+
?? null
|
|
381
|
+
|
|
382
|
+
if (!normalizedResetAt && !rateLimitType && rawResetAt === null) {
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
resetAt: normalizedResetAt ? normalizedResetAt.toISOString() : null,
|
|
388
|
+
rawResetAt,
|
|
389
|
+
rateLimitType,
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function normalizeOffset(token) {
|
|
394
|
+
if (!token) return null
|
|
395
|
+
const normalized = token.trim().toUpperCase()
|
|
396
|
+
if (normalized === 'Z' || normalized === 'UTC' || normalized === 'GMT') {
|
|
397
|
+
return 'Z'
|
|
398
|
+
}
|
|
399
|
+
if (/^[+-]\d{2}:?\d{2}$/.test(normalized)) {
|
|
400
|
+
return normalized.includes(':') ? normalized : `${normalized.slice(0, 3)}:${normalized.slice(3)}`
|
|
401
|
+
}
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getDatePartsForOffset(now, offsetToken) {
|
|
406
|
+
const offsetMinutes = offsetToken === 'Z'
|
|
407
|
+
? 0
|
|
408
|
+
: Number(offsetToken.slice(1, 3)) * 60 + Number(offsetToken.slice(4, 6))
|
|
409
|
+
const signedOffsetMinutes = offsetToken.startsWith('-') ? -offsetMinutes : offsetMinutes
|
|
410
|
+
const shifted = new Date(now.getTime() + signedOffsetMinutes * 60 * 1000)
|
|
411
|
+
return {
|
|
412
|
+
year: shifted.getUTCFullYear(),
|
|
413
|
+
month: shifted.getUTCMonth() + 1,
|
|
414
|
+
day: shifted.getUTCDate(),
|
|
415
|
+
offsetToken,
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function buildIsoFromParts({ year, month, day }, hours, minutes, offsetToken) {
|
|
420
|
+
const paddedMonth = String(month).padStart(2, '0')
|
|
421
|
+
const paddedDay = String(day).padStart(2, '0')
|
|
422
|
+
const paddedHours = String(hours).padStart(2, '0')
|
|
423
|
+
const paddedMinutes = String(minutes).padStart(2, '0')
|
|
424
|
+
return `${year}-${paddedMonth}-${paddedDay}T${paddedHours}:${paddedMinutes}:00${offsetToken}`
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function parseTimeOnlyCandidate(candidate, now) {
|
|
428
|
+
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)
|
|
429
|
+
if (!match) {
|
|
430
|
+
return null
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const dayWord = match[1]?.toLowerCase() || null
|
|
434
|
+
let hours = Number(match[2])
|
|
435
|
+
const minutes = Number(match[3] || '0')
|
|
436
|
+
const meridiem = match[4]?.toLowerCase() || null
|
|
437
|
+
const offsetToken = normalizeOffset(match[5] || null)
|
|
438
|
+
|
|
439
|
+
if (meridiem) {
|
|
440
|
+
if (hours === 12) hours = 0
|
|
441
|
+
if (meridiem === 'pm') hours += 12
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (offsetToken) {
|
|
445
|
+
let dateParts = getDatePartsForOffset(now, offsetToken)
|
|
446
|
+
let parsed = Date.parse(buildIsoFromParts(dateParts, hours, minutes, offsetToken))
|
|
447
|
+
if (Number.isNaN(parsed)) {
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
if (dayWord === 'tomorrow' || (dayWord !== 'today' && parsed <= now.getTime())) {
|
|
451
|
+
const retryBase = new Date(parsed + 24 * 60 * 60 * 1000)
|
|
452
|
+
dateParts = { year: retryBase.getUTCFullYear(), month: retryBase.getUTCMonth() + 1, day: retryBase.getUTCDate(), offsetToken }
|
|
453
|
+
parsed = Date.parse(buildIsoFromParts(dateParts, hours, minutes, offsetToken))
|
|
454
|
+
}
|
|
455
|
+
return Number.isNaN(parsed) ? null : new Date(parsed)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const target = new Date(now)
|
|
459
|
+
target.setSeconds(0, 0)
|
|
460
|
+
target.setHours(hours, minutes, 0, 0)
|
|
461
|
+
if (dayWord === 'tomorrow') {
|
|
462
|
+
target.setDate(target.getDate() + 1)
|
|
463
|
+
} else if (dayWord !== 'today' && target.getTime() <= now.getTime()) {
|
|
464
|
+
target.setDate(target.getDate() + 1)
|
|
465
|
+
}
|
|
466
|
+
return target
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function parseRelativeWait(candidate, now) {
|
|
470
|
+
const lowered = candidate.toLowerCase()
|
|
471
|
+
const anchorMatch = lowered.match(/(?:in|after)\s+([^\n]+)/)
|
|
472
|
+
if (!anchorMatch) {
|
|
473
|
+
return null
|
|
474
|
+
}
|
|
475
|
+
const fragment = anchorMatch[1]
|
|
476
|
+
const regex = /(\d+)\s*(milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?)/g
|
|
477
|
+
let totalMs = 0
|
|
478
|
+
let hasMatch = false
|
|
479
|
+
for (const match of fragment.matchAll(regex)) {
|
|
480
|
+
hasMatch = true
|
|
481
|
+
const amount = Number(match[1])
|
|
482
|
+
const unit = match[2].toLowerCase()
|
|
483
|
+
if (unit.startsWith('ms')) totalMs += amount
|
|
484
|
+
else if (unit.startsWith('second') || unit.startsWith('sec')) totalMs += amount * 1000
|
|
485
|
+
else if (unit.startsWith('minute') || unit.startsWith('min')) totalMs += amount * 60 * 1000
|
|
486
|
+
else if (unit.startsWith('hour') || unit.startsWith('hr')) totalMs += amount * 60 * 60 * 1000
|
|
487
|
+
}
|
|
488
|
+
if (!hasMatch || totalMs <= 0) {
|
|
489
|
+
return null
|
|
490
|
+
}
|
|
491
|
+
return new Date(now.getTime() + totalMs)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function parseAbsoluteCandidate(candidate, now) {
|
|
495
|
+
const cleaned = candidate
|
|
496
|
+
.replace(/^[:\s-]+/, '')
|
|
497
|
+
.replace(/[).,;]+$/g, '')
|
|
498
|
+
.trim()
|
|
499
|
+
if (!cleaned) {
|
|
500
|
+
return null
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const direct = Date.parse(cleaned)
|
|
504
|
+
if (!Number.isNaN(direct)) {
|
|
505
|
+
const parsed = new Date(direct)
|
|
506
|
+
if (parsed.getTime() > now.getTime()) {
|
|
507
|
+
return parsed
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return parseTimeOnlyCandidate(cleaned, now)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function calculateRateLimitReset({ message, resetAt = null, rateLimitType = null, rawResetAt = null, now = new Date(), bufferMs = DEFAULT_RATE_LIMIT_BUFFER_MS } = {}) {
|
|
515
|
+
const explicitResetAt = normalizeResetAtCandidate(resetAt)
|
|
516
|
+
if (explicitResetAt) {
|
|
517
|
+
return {
|
|
518
|
+
waitMs: Math.max(explicitResetAt.getTime() - now.getTime() + bufferMs, 0),
|
|
519
|
+
resetAt: explicitResetAt,
|
|
520
|
+
source: 'structured_reset_at',
|
|
521
|
+
rawMessage: String(message || '').trim(),
|
|
522
|
+
rateLimitType: rateLimitType || null,
|
|
523
|
+
rawResetAt,
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const rawMessage = String(message || '').trim()
|
|
528
|
+
if (!rawMessage) {
|
|
529
|
+
const fallbackMs = msUntilNextQuotaReset(now)
|
|
530
|
+
return {
|
|
531
|
+
waitMs: Math.max(fallbackMs + bufferMs, 0),
|
|
532
|
+
resetAt: new Date(now.getTime() + fallbackMs),
|
|
533
|
+
source: 'fallback_default_quota_window',
|
|
534
|
+
rawMessage,
|
|
535
|
+
rateLimitType: rateLimitType || null,
|
|
536
|
+
rawResetAt,
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const relative = parseRelativeWait(rawMessage, now)
|
|
541
|
+
if (relative) {
|
|
542
|
+
return {
|
|
543
|
+
waitMs: Math.max(relative.getTime() - now.getTime() + bufferMs, 0),
|
|
544
|
+
resetAt: relative,
|
|
545
|
+
source: 'relative_duration',
|
|
546
|
+
rawMessage,
|
|
547
|
+
rateLimitType: rateLimitType || null,
|
|
548
|
+
rawResetAt,
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const phraseRegex = /(?:reset(?:s| time)?|try again|available|wait)\s+(?:at|until|on)?\s*([^\n]+)/ig
|
|
553
|
+
for (const match of rawMessage.matchAll(phraseRegex)) {
|
|
554
|
+
const parsed = parseAbsoluteCandidate(match[1], now)
|
|
555
|
+
if (parsed) {
|
|
556
|
+
return {
|
|
557
|
+
waitMs: Math.max(parsed.getTime() - now.getTime() + bufferMs, 0),
|
|
558
|
+
resetAt: parsed,
|
|
559
|
+
source: 'anchored_phrase',
|
|
560
|
+
rawMessage,
|
|
561
|
+
rateLimitType: rateLimitType || null,
|
|
562
|
+
rawResetAt,
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const isoRegex = /\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})\b/g
|
|
568
|
+
for (const match of rawMessage.matchAll(isoRegex)) {
|
|
569
|
+
const parsed = parseAbsoluteCandidate(match[0], now)
|
|
570
|
+
if (parsed) {
|
|
571
|
+
return {
|
|
572
|
+
waitMs: Math.max(parsed.getTime() - now.getTime() + bufferMs, 0),
|
|
573
|
+
resetAt: parsed,
|
|
574
|
+
source: 'iso_datetime',
|
|
575
|
+
rawMessage,
|
|
576
|
+
rateLimitType: rateLimitType || null,
|
|
577
|
+
rawResetAt,
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const fallbackMs = msUntilNextQuotaReset(now)
|
|
583
|
+
return {
|
|
584
|
+
waitMs: Math.max(fallbackMs + bufferMs, 0),
|
|
585
|
+
resetAt: new Date(now.getTime() + fallbackMs),
|
|
586
|
+
source: 'fallback_default_quota_window',
|
|
587
|
+
rawMessage,
|
|
588
|
+
rateLimitType: rateLimitType || null,
|
|
589
|
+
rawResetAt,
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export async function waitForRateLimitReset({ message, resetAt = null, rateLimitType = null, rawResetAt = null, statePath = null, dryRun = false, bufferMs = DEFAULT_RATE_LIMIT_BUFFER_MS } = {}) {
|
|
594
|
+
const waitInfo = calculateRateLimitReset({ message, resetAt, rateLimitType, rawResetAt, bufferMs })
|
|
595
|
+
if (statePath) {
|
|
596
|
+
const existing = (await readJsonIfExists(statePath)) || {}
|
|
597
|
+
await writeJsonIfNeeded(statePath, {
|
|
598
|
+
...existing,
|
|
599
|
+
status: 'blocked',
|
|
600
|
+
rate_limit_wait_started_at: new Date().toISOString(),
|
|
601
|
+
rate_limit_wait_until: waitInfo.resetAt.toISOString(),
|
|
602
|
+
rate_limit_wait_ms: waitInfo.waitMs,
|
|
603
|
+
rate_limit_wait_source: waitInfo.source,
|
|
604
|
+
rate_limit_message: waitInfo.rawMessage || null,
|
|
605
|
+
rate_limit_reset_at: waitInfo.resetAt.toISOString(),
|
|
606
|
+
rate_limit_type: waitInfo.rateLimitType || null,
|
|
607
|
+
rate_limit_raw_reset_at: waitInfo.rawResetAt ?? null,
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
if (!dryRun && waitInfo.waitMs > 0) {
|
|
611
|
+
await sleep(waitInfo.waitMs)
|
|
612
|
+
}
|
|
613
|
+
return waitInfo
|
|
614
|
+
}
|
|
615
|
+
|
|
261
616
|
function getZonedParts(date, timeZone) {
|
|
262
617
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
263
618
|
timeZone,
|
|
@@ -288,7 +643,7 @@ export function msUntilNextQuotaReset(now = new Date(), { hour = DEFAULT_LIMIT_H
|
|
|
288
643
|
if (waitMs <= 0) {
|
|
289
644
|
waitMs += 24 * 60 * 60 * 1000
|
|
290
645
|
}
|
|
291
|
-
return waitMs
|
|
646
|
+
return waitMs
|
|
292
647
|
}
|
|
293
648
|
|
|
294
649
|
export async function runClaudeWithRetry({ claudeCommand, args, cwd, rawOutputPath, rawErrorPath, statePath, retryOnLimit = true, maxAttempts = 2 }) {
|
|
@@ -322,8 +677,10 @@ export async function runClaudeWithRetry({ claudeCommand, args, cwd, rawOutputPa
|
|
|
322
677
|
}
|
|
323
678
|
|
|
324
679
|
if (failure.code === 'claude_usage_limit' && retryOnLimit) {
|
|
325
|
-
|
|
326
|
-
|
|
680
|
+
await waitForRateLimitReset({
|
|
681
|
+
message: failure.detail || failure.msg,
|
|
682
|
+
statePath,
|
|
683
|
+
})
|
|
327
684
|
}
|
|
328
685
|
|
|
329
686
|
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')
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CHANNEL_MESSAGE_RE = re.compile(
|
|
14
|
+
r"^<channel\s+[^>]*source=\"(?P<source>[^\"]+)\"[^>]*>\s*(?P<body>.*?)\s*</channel>\s*$",
|
|
15
|
+
re.DOTALL,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_args() -> argparse.Namespace:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
description="Normalize Claude JSONL session exports by flattening channel-originated user turns."
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("input", help="Path to the input Claude JSONL session file")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"-o",
|
|
26
|
+
"--output",
|
|
27
|
+
required=True,
|
|
28
|
+
help="Path to write the normalized Claude JSONL session file",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--keep-queue-operations",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help="Keep queue-operation transport records instead of dropping them",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--keep-channel-origin",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Keep channel origin metadata fields instead of stripping them",
|
|
39
|
+
)
|
|
40
|
+
return parser.parse_args()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def maybe_flatten_channel_content(content: str) -> tuple[str, bool]:
|
|
44
|
+
match = CHANNEL_MESSAGE_RE.match(content)
|
|
45
|
+
if not match:
|
|
46
|
+
return content, False
|
|
47
|
+
|
|
48
|
+
body = match.group("body")
|
|
49
|
+
return body, True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_record(record: dict[str, Any], *, keep_channel_origin: bool) -> dict[str, Any] | None:
|
|
53
|
+
if record.get("type") == "queue-operation":
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
if record.get("type") != "user":
|
|
57
|
+
return record
|
|
58
|
+
|
|
59
|
+
message = record.get("message")
|
|
60
|
+
if not isinstance(message, dict):
|
|
61
|
+
return record
|
|
62
|
+
|
|
63
|
+
if message.get("role") != "user":
|
|
64
|
+
return record
|
|
65
|
+
|
|
66
|
+
content = message.get("content")
|
|
67
|
+
if not isinstance(content, str):
|
|
68
|
+
return record
|
|
69
|
+
|
|
70
|
+
flattened, changed = maybe_flatten_channel_content(content)
|
|
71
|
+
if not changed:
|
|
72
|
+
return record
|
|
73
|
+
|
|
74
|
+
normalized = dict(record)
|
|
75
|
+
normalized_message = dict(message)
|
|
76
|
+
normalized_message["content"] = flattened
|
|
77
|
+
normalized["message"] = normalized_message
|
|
78
|
+
normalized.pop("isMeta", None)
|
|
79
|
+
|
|
80
|
+
if not keep_channel_origin:
|
|
81
|
+
normalized.pop("origin", None)
|
|
82
|
+
|
|
83
|
+
return normalized
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
args = parse_args()
|
|
88
|
+
input_path = Path(args.input)
|
|
89
|
+
output_path = Path(args.output)
|
|
90
|
+
|
|
91
|
+
if not input_path.exists():
|
|
92
|
+
print(f"Input file not found: {input_path}", file=sys.stderr)
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
total = 0
|
|
98
|
+
queue_dropped = 0
|
|
99
|
+
channel_flattened = 0
|
|
100
|
+
|
|
101
|
+
with input_path.open("r", encoding="utf-8") as src, output_path.open("w", encoding="utf-8") as dst:
|
|
102
|
+
for line_no, line in enumerate(src, start=1):
|
|
103
|
+
stripped = line.strip()
|
|
104
|
+
if not stripped:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
total += 1
|
|
108
|
+
try:
|
|
109
|
+
record = json.loads(stripped)
|
|
110
|
+
except json.JSONDecodeError as exc:
|
|
111
|
+
print(f"Invalid JSON at line {line_no}: {exc}", file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
if not isinstance(record, dict):
|
|
115
|
+
print(f"Expected object at line {line_no}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
normalized = normalize_record(record, keep_channel_origin=args.keep_channel_origin)
|
|
119
|
+
|
|
120
|
+
if normalized is None:
|
|
121
|
+
if record.get("type") == "queue-operation" and not args.keep_queue_operations:
|
|
122
|
+
queue_dropped += 1
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
original_content = (
|
|
126
|
+
record.get("message", {}).get("content")
|
|
127
|
+
if isinstance(record.get("message"), dict)
|
|
128
|
+
else None
|
|
129
|
+
)
|
|
130
|
+
normalized_content = (
|
|
131
|
+
normalized.get("message", {}).get("content")
|
|
132
|
+
if isinstance(normalized.get("message"), dict)
|
|
133
|
+
else None
|
|
134
|
+
)
|
|
135
|
+
if original_content != normalized_content:
|
|
136
|
+
channel_flattened += 1
|
|
137
|
+
|
|
138
|
+
dst.write(json.dumps(normalized, ensure_ascii=False) + "\n")
|
|
139
|
+
|
|
140
|
+
summary = {
|
|
141
|
+
"ok": True,
|
|
142
|
+
"input": str(input_path),
|
|
143
|
+
"output": str(output_path),
|
|
144
|
+
"records_seen": total,
|
|
145
|
+
"queue_operations_dropped": queue_dropped,
|
|
146
|
+
"channel_messages_flattened": channel_flattened,
|
|
147
|
+
}
|
|
148
|
+
print(json.dumps(summary))
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
raise SystemExit(main())
|