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
@@ -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 + 60 * 1000
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
- const waitMs = msUntilNextQuotaReset()
326
- await sleep(waitMs)
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())