agentic-loop 3.10.2 → 3.11.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/.claude/commands/api.md +496 -0
- package/.claude/commands/aws.md +408 -0
- package/bin/ralph.sh +2 -1
- package/package.json +2 -1
- package/ralph/code-check.sh +307 -0
- package/ralph/loop.sh +80 -27
- package/ralph/prd-check.sh +498 -0
- package/ralph/utils.sh +66 -351
- package/templates/config/elixir.json +1 -1
- package/templates/config/fastmcp.json +1 -1
- package/templates/config/fullstack.json +1 -1
- package/templates/config/go.json +1 -1
- package/templates/config/minimal.json +1 -1
- package/templates/config/node.json +1 -1
- package/templates/config/python.json +1 -1
- package/templates/config/rust.json +1 -1
- package/ralph/verify.sh +0 -106
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: AWS patterns and best practices. Use when working with S3, Lambda, DynamoDB, IAM, SQS, or other AWS services.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# AWS Patterns
|
|
6
|
+
|
|
7
|
+
Best practices for AWS services. Use these patterns when writing infrastructure or application code that interacts with AWS.
|
|
8
|
+
|
|
9
|
+
## IAM
|
|
10
|
+
|
|
11
|
+
### Least Privilege
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
// ❌ NEVER: Wildcard permissions
|
|
15
|
+
{
|
|
16
|
+
"Effect": "Allow",
|
|
17
|
+
"Action": "s3:*",
|
|
18
|
+
"Resource": "*"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ✅ ALWAYS: Specific actions and resources
|
|
22
|
+
{
|
|
23
|
+
"Effect": "Allow",
|
|
24
|
+
"Action": ["s3:GetObject", "s3:PutObject"],
|
|
25
|
+
"Resource": "arn:aws:s3:::my-bucket/uploads/*"
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Service Roles
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
// ❌ NEVER: Use root credentials or long-lived keys
|
|
33
|
+
const client = new S3Client({
|
|
34
|
+
credentials: {
|
|
35
|
+
accessKeyId: 'AKIA...',
|
|
36
|
+
secretAccessKey: '...'
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ✅ ALWAYS: Use IAM roles (SDK auto-discovers)
|
|
41
|
+
const client = new S3Client({ region: 'us-east-1' });
|
|
42
|
+
// In Lambda/ECS/EC2 - credentials come from execution role
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Cross-Account Access
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
// Trust policy for cross-account role assumption
|
|
49
|
+
{
|
|
50
|
+
"Version": "2012-10-17",
|
|
51
|
+
"Statement": [{
|
|
52
|
+
"Effect": "Allow",
|
|
53
|
+
"Principal": {
|
|
54
|
+
"AWS": "arn:aws:iam::OTHER_ACCOUNT:role/service-role"
|
|
55
|
+
},
|
|
56
|
+
"Action": "sts:AssumeRole",
|
|
57
|
+
"Condition": {
|
|
58
|
+
"StringEquals": {
|
|
59
|
+
"sts:ExternalId": "${external_id}" // Prevent confused deputy
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}]
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## S3
|
|
67
|
+
|
|
68
|
+
### Secure Defaults
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
// ✅ Create buckets with secure defaults
|
|
72
|
+
const bucket = new s3.Bucket(this, 'Bucket', {
|
|
73
|
+
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
74
|
+
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
75
|
+
enforceSSL: true,
|
|
76
|
+
versioned: true,
|
|
77
|
+
removalPolicy: RemovalPolicy.RETAIN // Don't delete data
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Presigned URLs
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// ✅ For temporary access without exposing credentials
|
|
85
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
86
|
+
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
87
|
+
|
|
88
|
+
// Download URL (15 min expiry)
|
|
89
|
+
const downloadUrl = await getSignedUrl(s3Client,
|
|
90
|
+
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
|
91
|
+
{ expiresIn: 900 }
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Upload URL with constraints
|
|
95
|
+
const uploadUrl = await getSignedUrl(s3Client,
|
|
96
|
+
new PutObjectCommand({
|
|
97
|
+
Bucket: bucket,
|
|
98
|
+
Key: `uploads/${userId}/${filename}`,
|
|
99
|
+
ContentType: 'image/jpeg' // Restrict file type
|
|
100
|
+
}),
|
|
101
|
+
{ expiresIn: 300 }
|
|
102
|
+
);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Multipart Uploads
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// ✅ For files > 100MB
|
|
109
|
+
import { Upload } from '@aws-sdk/lib-storage';
|
|
110
|
+
|
|
111
|
+
const upload = new Upload({
|
|
112
|
+
client: s3Client,
|
|
113
|
+
params: { Bucket: bucket, Key: key, Body: stream },
|
|
114
|
+
partSize: 10 * 1024 * 1024, // 10MB parts
|
|
115
|
+
leavePartsOnError: false // Clean up on failure
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
upload.on('httpUploadProgress', (progress) => {
|
|
119
|
+
console.log(`${progress.loaded}/${progress.total}`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await upload.done();
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Lambda
|
|
126
|
+
|
|
127
|
+
### Handler Pattern
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// ✅ Initialize outside handler (reused across invocations)
|
|
131
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
132
|
+
|
|
133
|
+
const ddb = new DynamoDBClient({}); // Cold start only
|
|
134
|
+
|
|
135
|
+
export const handler = async (event: APIGatewayEvent) => {
|
|
136
|
+
// Handler code uses pre-initialized client
|
|
137
|
+
try {
|
|
138
|
+
const result = await processEvent(event);
|
|
139
|
+
return { statusCode: 200, body: JSON.stringify(result) };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error('Handler error:', error); // CloudWatch logging
|
|
142
|
+
return { statusCode: 500, body: JSON.stringify({ error: 'Internal error' }) };
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Environment Variables
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
// ✅ Validate at cold start, not per-request
|
|
151
|
+
const TABLE_NAME = process.env.TABLE_NAME;
|
|
152
|
+
const QUEUE_URL = process.env.QUEUE_URL;
|
|
153
|
+
|
|
154
|
+
if (!TABLE_NAME || !QUEUE_URL) {
|
|
155
|
+
throw new Error('Missing required environment variables');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const handler = async (event) => {
|
|
159
|
+
// Use TABLE_NAME, QUEUE_URL directly
|
|
160
|
+
};
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Timeouts and Memory
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// ✅ CDK configuration
|
|
167
|
+
new lambda.Function(this, 'Handler', {
|
|
168
|
+
runtime: lambda.Runtime.NODEJS_20_X,
|
|
169
|
+
handler: 'index.handler',
|
|
170
|
+
code: lambda.Code.fromAsset('dist'),
|
|
171
|
+
timeout: Duration.seconds(30), // Always set explicitly
|
|
172
|
+
memorySize: 256, // More memory = more CPU
|
|
173
|
+
reservedConcurrentExecutions: 10, // Prevent runaway scaling
|
|
174
|
+
environment: {
|
|
175
|
+
TABLE_NAME: table.tableName,
|
|
176
|
+
NODE_OPTIONS: '--enable-source-maps'
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Dead Letter Queues
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
// ✅ Capture failed invocations
|
|
185
|
+
const dlq = new sqs.Queue(this, 'DLQ', {
|
|
186
|
+
retentionPeriod: Duration.days(14)
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
new lambda.Function(this, 'Handler', {
|
|
190
|
+
// ...
|
|
191
|
+
deadLetterQueue: dlq,
|
|
192
|
+
retryAttempts: 2
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## DynamoDB
|
|
197
|
+
|
|
198
|
+
### Table Design
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// ✅ Single-table design with composite keys
|
|
202
|
+
const table = new dynamodb.Table(this, 'Table', {
|
|
203
|
+
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
|
|
204
|
+
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
|
|
205
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
|
|
206
|
+
pointInTimeRecovery: true,
|
|
207
|
+
encryption: dynamodb.TableEncryption.AWS_MANAGED
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Add GSI for access patterns
|
|
211
|
+
table.addGlobalSecondaryIndex({
|
|
212
|
+
indexName: 'GSI1',
|
|
213
|
+
partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
|
|
214
|
+
sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING }
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Key Patterns
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// ✅ Composite keys for flexible queries
|
|
222
|
+
// User: PK=USER#123, SK=PROFILE
|
|
223
|
+
// User's orders: PK=USER#123, SK=ORDER#2024-01-15#456
|
|
224
|
+
// Order by ID: GSI1PK=ORDER#456, GSI1SK=ORDER#456
|
|
225
|
+
|
|
226
|
+
await ddb.put({
|
|
227
|
+
TableName: TABLE_NAME,
|
|
228
|
+
Item: {
|
|
229
|
+
PK: `USER#${userId}`,
|
|
230
|
+
SK: `ORDER#${date}#${orderId}`,
|
|
231
|
+
GSI1PK: `ORDER#${orderId}`,
|
|
232
|
+
GSI1SK: `ORDER#${orderId}`,
|
|
233
|
+
// ... other attributes
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Query user's recent orders
|
|
238
|
+
await ddb.query({
|
|
239
|
+
TableName: TABLE_NAME,
|
|
240
|
+
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
|
|
241
|
+
ExpressionAttributeValues: {
|
|
242
|
+
':pk': `USER#${userId}`,
|
|
243
|
+
':sk': 'ORDER#'
|
|
244
|
+
},
|
|
245
|
+
ScanIndexForward: false, // Newest first
|
|
246
|
+
Limit: 10
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Transactions
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
// ✅ Atomic operations across items
|
|
254
|
+
await ddb.transactWrite({
|
|
255
|
+
TransactItems: [
|
|
256
|
+
{
|
|
257
|
+
Update: {
|
|
258
|
+
TableName: TABLE_NAME,
|
|
259
|
+
Key: { PK: `USER#${userId}`, SK: 'BALANCE' },
|
|
260
|
+
UpdateExpression: 'SET balance = balance - :amount',
|
|
261
|
+
ConditionExpression: 'balance >= :amount',
|
|
262
|
+
ExpressionAttributeValues: { ':amount': amount }
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
Put: {
|
|
267
|
+
TableName: TABLE_NAME,
|
|
268
|
+
Item: {
|
|
269
|
+
PK: `USER#${userId}`,
|
|
270
|
+
SK: `TXN#${txnId}`,
|
|
271
|
+
amount,
|
|
272
|
+
timestamp: Date.now()
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
]
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## SQS
|
|
281
|
+
|
|
282
|
+
### Producer Pattern
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
// ✅ Batch sends for efficiency
|
|
286
|
+
import { SendMessageBatchCommand } from '@aws-sdk/client-sqs';
|
|
287
|
+
|
|
288
|
+
const entries = items.map((item, i) => ({
|
|
289
|
+
Id: String(i),
|
|
290
|
+
MessageBody: JSON.stringify(item),
|
|
291
|
+
MessageGroupId: item.groupId, // For FIFO queues
|
|
292
|
+
MessageDeduplicationId: item.id // For FIFO queues
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
// Send in batches of 10 (SQS limit)
|
|
296
|
+
for (let i = 0; i < entries.length; i += 10) {
|
|
297
|
+
await sqs.send(new SendMessageBatchCommand({
|
|
298
|
+
QueueUrl: QUEUE_URL,
|
|
299
|
+
Entries: entries.slice(i, i + 10)
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Consumer Pattern
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
// ✅ Lambda SQS trigger with partial batch response
|
|
308
|
+
export const handler = async (event: SQSEvent) => {
|
|
309
|
+
const failures: SQSBatchItemFailure[] = [];
|
|
310
|
+
|
|
311
|
+
for (const record of event.Records) {
|
|
312
|
+
try {
|
|
313
|
+
const body = JSON.parse(record.body);
|
|
314
|
+
await processMessage(body);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
console.error(`Failed: ${record.messageId}`, error);
|
|
317
|
+
failures.push({ itemIdentifier: record.messageId });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return { batchItemFailures: failures }; // Only retry failed messages
|
|
322
|
+
};
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Secrets Manager
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
// ✅ Cache secrets, don't fetch per-request
|
|
329
|
+
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
|
|
330
|
+
|
|
331
|
+
const sm = new SecretsManagerClient({});
|
|
332
|
+
let cachedSecret: string | null = null;
|
|
333
|
+
|
|
334
|
+
async function getSecret(): Promise<string> {
|
|
335
|
+
if (!cachedSecret) {
|
|
336
|
+
const response = await sm.send(new GetSecretValueCommand({
|
|
337
|
+
SecretId: process.env.SECRET_ARN
|
|
338
|
+
}));
|
|
339
|
+
cachedSecret = response.SecretString!;
|
|
340
|
+
}
|
|
341
|
+
return cachedSecret;
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Error Handling
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
// ✅ Handle AWS-specific errors
|
|
349
|
+
import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
|
|
350
|
+
import { S3ServiceException } from '@aws-sdk/client-s3';
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
await ddb.put({ /* ... */, ConditionExpression: 'attribute_not_exists(PK)' });
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error instanceof ConditionalCheckFailedException) {
|
|
356
|
+
throw new ConflictError('Item already exists');
|
|
357
|
+
}
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
await s3.getObject({ Bucket, Key });
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error instanceof S3ServiceException && error.name === 'NoSuchKey') {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Cost Optimization
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// ✅ Use appropriate storage classes
|
|
375
|
+
new s3.Bucket(this, 'Bucket', {
|
|
376
|
+
lifecycleRules: [{
|
|
377
|
+
transitions: [
|
|
378
|
+
{ storageClass: s3.StorageClass.INFREQUENT_ACCESS, transitionAfter: Duration.days(30) },
|
|
379
|
+
{ storageClass: s3.StorageClass.GLACIER, transitionAfter: Duration.days(90) }
|
|
380
|
+
],
|
|
381
|
+
expiration: Duration.days(365)
|
|
382
|
+
}]
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ✅ DynamoDB on-demand for unpredictable workloads
|
|
386
|
+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST
|
|
387
|
+
|
|
388
|
+
// ✅ Lambda ARM64 for better price/performance
|
|
389
|
+
architecture: lambda.Architecture.ARM_64
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
## Testing
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
// ✅ Use local emulators for unit tests
|
|
396
|
+
// docker run -p 8000:8000 amazon/dynamodb-local
|
|
397
|
+
|
|
398
|
+
const ddb = new DynamoDBClient({
|
|
399
|
+
endpoint: process.env.DYNAMODB_ENDPOINT || undefined, // Local in tests
|
|
400
|
+
region: 'us-east-1'
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ✅ Or use mocks
|
|
404
|
+
import { mockClient } from 'aws-sdk-client-mock';
|
|
405
|
+
|
|
406
|
+
const ddbMock = mockClient(DynamoDBClient);
|
|
407
|
+
ddbMock.on(GetItemCommand).resolves({ Item: { /* ... */ } });
|
|
408
|
+
```
|
package/bin/ralph.sh
CHANGED
|
@@ -87,7 +87,8 @@ source "$RALPH_LIB/utils.sh"
|
|
|
87
87
|
source "$RALPH_LIB/init.sh"
|
|
88
88
|
source "$RALPH_LIB/setup.sh"
|
|
89
89
|
source "$RALPH_LIB/loop.sh"
|
|
90
|
-
source "$RALPH_LIB/
|
|
90
|
+
source "$RALPH_LIB/prd-check.sh" # PRD validation before loop
|
|
91
|
+
source "$RALPH_LIB/code-check.sh" # Code verification after Claude writes
|
|
91
92
|
source "$RALPH_LIB/prd.sh"
|
|
92
93
|
source "$RALPH_LIB/signs.sh"
|
|
93
94
|
source "$RALPH_LIB/test.sh"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentic-loop",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "Autonomous AI coding loop - PRD-driven development with Claude Code",
|
|
5
5
|
"author": "Allie Jones <allie@allthrive.ai>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"types": "dist/index.d.ts",
|
|
30
30
|
"bin": {
|
|
31
31
|
"agentic-loop": "./bin/agentic-loop.sh",
|
|
32
|
+
"ralph": "./bin/ralph.sh",
|
|
32
33
|
"vibe-check": "./bin/vibe-check.js"
|
|
33
34
|
},
|
|
34
35
|
"exports": {
|