@dp-pcs/ogp 0.2.31 → 0.3.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/README.md +11 -0
- package/dist/daemon/notify.d.ts +20 -0
- package/dist/daemon/notify.d.ts.map +1 -1
- package/dist/daemon/notify.js +168 -48
- package/dist/daemon/notify.js.map +1 -1
- package/dist/shared/config.d.ts +3 -0
- package/dist/shared/config.d.ts.map +1 -1
- package/dist/shared/config.js.map +1 -1
- package/docs/TESTING-HERMES-BACKEND.md +278 -0
- package/docs/extending-to-hermes.md +462 -0
- package/docs/hermes-implementation-checklist.md +448 -0
- package/docs/hermes-local-testing.md +478 -0
- package/docs/hermes-tunnel-setup.md +214 -0
- package/docs/platform-agnostic-architecture.md +472 -0
- package/package.json +3 -2
- package/scripts/install-skills.js +142 -20
- package/skills/ogp-project/SKILL.md +321 -226
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
# Hermes Integration Implementation Checklist
|
|
2
|
+
|
|
3
|
+
> Developer checklist for adding Hermes support to OGP
|
|
4
|
+
|
|
5
|
+
## Phase 1: Sidecar Integration (Week 1)
|
|
6
|
+
|
|
7
|
+
### Prerequisites
|
|
8
|
+
- [ ] Hermes installed and running
|
|
9
|
+
- [ ] Hermes webhook adapter enabled (port 8644)
|
|
10
|
+
- [ ] OpenClaw+OGP setup working (baseline to not break)
|
|
11
|
+
|
|
12
|
+
### Code Changes
|
|
13
|
+
|
|
14
|
+
#### 1. Add Hermes Notification Backend
|
|
15
|
+
|
|
16
|
+
**File:** `src/daemon/notify.ts`
|
|
17
|
+
|
|
18
|
+
- [ ] Import crypto at the top
|
|
19
|
+
- [ ] Add `NotificationBackend` interface
|
|
20
|
+
- [ ] Implement `HermesBackend` class
|
|
21
|
+
- [ ] Implement `OpenClawBackend` class (wrap existing code)
|
|
22
|
+
- [ ] Add `getNotificationBackend()` factory
|
|
23
|
+
- [ ] Refactor `notifyLocalAgent()` to use backend system
|
|
24
|
+
- [ ] Add HMAC signature generation for Hermes webhook
|
|
25
|
+
|
|
26
|
+
**Code Template:**
|
|
27
|
+
```typescript
|
|
28
|
+
import crypto from 'crypto';
|
|
29
|
+
|
|
30
|
+
interface NotificationBackend {
|
|
31
|
+
name: string;
|
|
32
|
+
notify(context: NotificationContext): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface NotificationContext {
|
|
36
|
+
peerId: string;
|
|
37
|
+
peerDisplayName: string;
|
|
38
|
+
intent: string;
|
|
39
|
+
payload: any;
|
|
40
|
+
timestamp: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class HermesBackend implements NotificationBackend {
|
|
44
|
+
name = "hermes";
|
|
45
|
+
|
|
46
|
+
async notify(ctx: NotificationContext): Promise<void> {
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
const webhookUrl = config.hermesWebhookUrl || 'http://localhost:8644/webhooks/ogp_federation';
|
|
49
|
+
const secret = config.hermesWebhookSecret;
|
|
50
|
+
|
|
51
|
+
if (!secret) {
|
|
52
|
+
throw new Error('hermesWebhookSecret not configured');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const body = {
|
|
56
|
+
peer_id: ctx.peerId,
|
|
57
|
+
peer_display_name: ctx.peerDisplayName,
|
|
58
|
+
intent: ctx.intent,
|
|
59
|
+
topic: ctx.payload.topic || "",
|
|
60
|
+
message: ctx.payload.message || JSON.stringify(ctx.payload),
|
|
61
|
+
priority: ctx.payload.priority || "normal",
|
|
62
|
+
conversation_id: ctx.payload.conversationId,
|
|
63
|
+
timestamp: ctx.timestamp,
|
|
64
|
+
payload: ctx.payload
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const bodyStr = JSON.stringify(body);
|
|
68
|
+
const signature = crypto
|
|
69
|
+
.createHmac('sha256', secret)
|
|
70
|
+
.update(bodyStr)
|
|
71
|
+
.digest('hex');
|
|
72
|
+
|
|
73
|
+
await fetch(webhookUrl, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'X-Hub-Signature-256': `sha256=${signature}`
|
|
78
|
+
},
|
|
79
|
+
body: bodyStr
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class OpenClawBackend implements NotificationBackend {
|
|
85
|
+
name = "openclaw";
|
|
86
|
+
|
|
87
|
+
async notify(ctx: NotificationContext): Promise<void> {
|
|
88
|
+
// Move existing notifyOpenClaw() code here
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getNotificationBackend(): NotificationBackend {
|
|
93
|
+
const config = loadConfig();
|
|
94
|
+
const platform = config.platform || 'openclaw';
|
|
95
|
+
|
|
96
|
+
switch (platform) {
|
|
97
|
+
case 'openclaw':
|
|
98
|
+
return new OpenClawBackend();
|
|
99
|
+
case 'hermes':
|
|
100
|
+
return new HermesBackend();
|
|
101
|
+
default:
|
|
102
|
+
throw new Error(`Unknown platform: ${platform}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function notifyLocalAgent(
|
|
107
|
+
peerId: string,
|
|
108
|
+
peerDisplayName: string,
|
|
109
|
+
intent: string,
|
|
110
|
+
payload: any
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const backend = getNotificationBackend();
|
|
113
|
+
|
|
114
|
+
await backend.notify({
|
|
115
|
+
peerId,
|
|
116
|
+
peerDisplayName,
|
|
117
|
+
intent,
|
|
118
|
+
payload,
|
|
119
|
+
timestamp: new Date().toISOString()
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### 2. Update Config Types
|
|
125
|
+
|
|
126
|
+
**File:** `src/shared/config.ts`
|
|
127
|
+
|
|
128
|
+
- [ ] Add `platform?: string` field to `Config` interface
|
|
129
|
+
- [ ] Add `hermesWebhookUrl?: string` field
|
|
130
|
+
- [ ] Add `hermesWebhookSecret?: string` field
|
|
131
|
+
- [ ] Update config validation
|
|
132
|
+
- [ ] Add migration for existing configs (default platform to 'openclaw')
|
|
133
|
+
|
|
134
|
+
**Code Template:**
|
|
135
|
+
```typescript
|
|
136
|
+
export interface Config {
|
|
137
|
+
// Existing fields...
|
|
138
|
+
daemonPort: number;
|
|
139
|
+
gatewayUrl: string;
|
|
140
|
+
displayName: string;
|
|
141
|
+
email: string;
|
|
142
|
+
stateDir?: string;
|
|
143
|
+
|
|
144
|
+
// Platform selection
|
|
145
|
+
platform?: string; // 'openclaw' | 'hermes'
|
|
146
|
+
|
|
147
|
+
// OpenClaw-specific (existing)
|
|
148
|
+
openclawUrl?: string;
|
|
149
|
+
openclawToken?: string;
|
|
150
|
+
openclawHooksToken?: string;
|
|
151
|
+
agentId?: string;
|
|
152
|
+
notifyTarget?: string;
|
|
153
|
+
notifyTargets?: Record<string, string>;
|
|
154
|
+
|
|
155
|
+
// Hermes-specific (new)
|
|
156
|
+
hermesWebhookUrl?: string;
|
|
157
|
+
hermesWebhookSecret?: string;
|
|
158
|
+
|
|
159
|
+
// Existing fields...
|
|
160
|
+
rendezvous?: RendezvousConfig;
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### 3. Update CLI Setup Wizard
|
|
165
|
+
|
|
166
|
+
**File:** `src/cli/setup.ts`
|
|
167
|
+
|
|
168
|
+
- [ ] Add platform selection prompt (OpenClaw or Hermes)
|
|
169
|
+
- [ ] Conditionally prompt for OpenClaw config OR Hermes config
|
|
170
|
+
- [ ] For Hermes: prompt for webhook URL and secret
|
|
171
|
+
- [ ] For OpenClaw: use existing agent discovery flow
|
|
172
|
+
- [ ] Save appropriate fields to config.json
|
|
173
|
+
|
|
174
|
+
**Code Template:**
|
|
175
|
+
```typescript
|
|
176
|
+
// In setup wizard, after basic info:
|
|
177
|
+
|
|
178
|
+
const platform = await prompt({
|
|
179
|
+
type: 'select',
|
|
180
|
+
name: 'platform',
|
|
181
|
+
message: 'Which AI platform is this gateway for?',
|
|
182
|
+
choices: [
|
|
183
|
+
{ title: 'OpenClaw', value: 'openclaw' },
|
|
184
|
+
{ title: 'Hermes', value: 'hermes' }
|
|
185
|
+
]
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (platform === 'hermes') {
|
|
189
|
+
const hermesWebhookUrl = await prompt({
|
|
190
|
+
type: 'text',
|
|
191
|
+
name: 'hermesWebhookUrl',
|
|
192
|
+
message: 'Hermes webhook URL',
|
|
193
|
+
initial: 'http://localhost:8644/webhooks/ogp_federation'
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const hermesWebhookSecret = await prompt({
|
|
197
|
+
type: 'password',
|
|
198
|
+
name: 'hermesWebhookSecret',
|
|
199
|
+
message: 'Hermes webhook secret (must match Hermes config.yaml)'
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
config.platform = 'hermes';
|
|
203
|
+
config.hermesWebhookUrl = hermesWebhookUrl;
|
|
204
|
+
config.hermesWebhookSecret = hermesWebhookSecret;
|
|
205
|
+
} else {
|
|
206
|
+
// Existing OpenClaw flow
|
|
207
|
+
config.platform = 'openclaw';
|
|
208
|
+
// ... agent discovery, etc.
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
#### 4. Update Status Command
|
|
213
|
+
|
|
214
|
+
**File:** `src/cli/federation.ts` (status command)
|
|
215
|
+
|
|
216
|
+
- [ ] Show platform type in status output
|
|
217
|
+
- [ ] Show appropriate backend info (OpenClaw URL or Hermes webhook URL)
|
|
218
|
+
|
|
219
|
+
### Testing
|
|
220
|
+
|
|
221
|
+
- [ ] Build project: `npm run build`
|
|
222
|
+
- [ ] Test OpenClaw integration still works (regression test)
|
|
223
|
+
- [ ] `ogp status` shows OpenClaw config
|
|
224
|
+
- [ ] Can send messages to existing OpenClaw peers
|
|
225
|
+
- [ ] Notifications arrive in OpenClaw
|
|
226
|
+
- [ ] Test Hermes integration
|
|
227
|
+
- [ ] Configure Hermes webhook route
|
|
228
|
+
- [ ] Create new OGP instance: `mkdir ~/.ogp-hermes`
|
|
229
|
+
- [ ] Run setup: `OGP_STATE_DIR=~/.ogp-hermes ogp setup`
|
|
230
|
+
- [ ] Select "Hermes" platform
|
|
231
|
+
- [ ] Start daemon on port 18791
|
|
232
|
+
- [ ] Verify `.well-known/ogp` endpoint works
|
|
233
|
+
- [ ] Test local federation
|
|
234
|
+
- [ ] Request federation from OpenClaw OGP to Hermes OGP
|
|
235
|
+
- [ ] Approve from Hermes OGP
|
|
236
|
+
- [ ] Send message from OpenClaw to Hermes
|
|
237
|
+
- [ ] Verify message arrives in Hermes webhook logs
|
|
238
|
+
- [ ] Verify signature verification passes
|
|
239
|
+
- [ ] Verify Hermes agent receives and processes message
|
|
240
|
+
|
|
241
|
+
### Documentation
|
|
242
|
+
|
|
243
|
+
- [ ] Update README with Hermes support announcement
|
|
244
|
+
- [ ] Add example Hermes config to docs
|
|
245
|
+
- [ ] Update setup guide with platform selection
|
|
246
|
+
- [ ] Add troubleshooting section for webhook signature issues
|
|
247
|
+
|
|
248
|
+
### Release
|
|
249
|
+
|
|
250
|
+
- [ ] Version bump to 0.3.0 (minor - new feature)
|
|
251
|
+
- [ ] Update CHANGELOG.md
|
|
252
|
+
- [ ] Tag release
|
|
253
|
+
- [ ] Publish to npm: `npm publish`
|
|
254
|
+
- [ ] Announce on Twitter/X
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Phase 2: Native Hermes Adapter (Month 1-2)
|
|
259
|
+
|
|
260
|
+
### Design
|
|
261
|
+
|
|
262
|
+
- [ ] Review Hermes gateway platform adapter pattern
|
|
263
|
+
- [ ] Study `gateway/platforms/base.py`
|
|
264
|
+
- [ ] Study `gateway/platforms/webhook.py` for HTTP server reference
|
|
265
|
+
- [ ] Decide on state storage location (`~/.hermes/ogp/` vs `~/.ogp/`)
|
|
266
|
+
|
|
267
|
+
### Implementation
|
|
268
|
+
|
|
269
|
+
#### 1. Create OGP Platform Adapter
|
|
270
|
+
|
|
271
|
+
**File:** `~/.hermes/hermes-agent/gateway/platforms/ogp.py`
|
|
272
|
+
|
|
273
|
+
- [ ] Import Ed25519 crypto library
|
|
274
|
+
- [ ] Create `OGPAdapter(BasePlatformAdapter)` class
|
|
275
|
+
- [ ] Implement lifecycle methods (`connect()`, `disconnect()`)
|
|
276
|
+
- [ ] Implement HTTP server (aiohttp)
|
|
277
|
+
- [ ] Implement `.well-known/ogp` endpoint
|
|
278
|
+
- [ ] Implement `/federation/request` endpoint
|
|
279
|
+
- [ ] Implement `/federation/approve` endpoint
|
|
280
|
+
- [ ] Implement `/federation/message` endpoint
|
|
281
|
+
- [ ] Implement `/federation/removed` endpoint
|
|
282
|
+
- [ ] Implement keypair management (load/generate)
|
|
283
|
+
- [ ] Implement peer storage (JSON file)
|
|
284
|
+
- [ ] Implement Doorman access control
|
|
285
|
+
- [ ] Implement scope enforcement
|
|
286
|
+
- [ ] Implement rate limiting (sliding window)
|
|
287
|
+
|
|
288
|
+
**File Structure:**
|
|
289
|
+
```python
|
|
290
|
+
class OGPAdapter(BasePlatformAdapter):
|
|
291
|
+
def __init__(self, config: PlatformConfig):
|
|
292
|
+
self._port = int(config.extra.get("port", 18790))
|
|
293
|
+
self._peers_file = Path.home() / ".hermes" / "ogp" / "peers.json"
|
|
294
|
+
self._keypair_file = Path.home() / ".hermes" / "ogp" / "keypair.json"
|
|
295
|
+
self._keypair = self._load_or_generate_keypair()
|
|
296
|
+
self._peers = self._load_peers()
|
|
297
|
+
self._doorman = Doorman(self._peers)
|
|
298
|
+
|
|
299
|
+
async def connect(self) -> bool:
|
|
300
|
+
# Start HTTP server
|
|
301
|
+
app = web.Application()
|
|
302
|
+
app.router.add_get("/.well-known/ogp", self._handle_well_known)
|
|
303
|
+
app.router.add_post("/federation/request", self._handle_request)
|
|
304
|
+
app.router.add_post("/federation/approve", self._handle_approve)
|
|
305
|
+
app.router.add_post("/federation/message", self._handle_message)
|
|
306
|
+
# ...
|
|
307
|
+
await site.start()
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
async def _handle_message(self, request: web.Request) -> web.Response:
|
|
311
|
+
# 1. Read body
|
|
312
|
+
# 2. Verify signature
|
|
313
|
+
# 3. Doorman access check
|
|
314
|
+
# 4. Route to agent
|
|
315
|
+
pass
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
#### 2. Add Ed25519 Crypto
|
|
319
|
+
|
|
320
|
+
- [ ] Use Python `cryptography` library
|
|
321
|
+
- [ ] Generate/load Ed25519 keypair
|
|
322
|
+
- [ ] Sign messages
|
|
323
|
+
- [ ] Verify signatures
|
|
324
|
+
|
|
325
|
+
**Code Template:**
|
|
326
|
+
```python
|
|
327
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
328
|
+
from cryptography.hazmat.primitives import serialization
|
|
329
|
+
|
|
330
|
+
def generate_keypair():
|
|
331
|
+
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
332
|
+
public_key = private_key.public_key()
|
|
333
|
+
|
|
334
|
+
private_bytes = private_key.private_bytes(
|
|
335
|
+
encoding=serialization.Encoding.Raw,
|
|
336
|
+
format=serialization.PrivateFormat.Raw,
|
|
337
|
+
encryption_algorithm=serialization.NoEncryption()
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
public_bytes = public_key.public_bytes(
|
|
341
|
+
encoding=serialization.Encoding.Raw,
|
|
342
|
+
format=serialization.PublicFormat.Raw
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"publicKey": public_bytes.hex(),
|
|
347
|
+
"privateKey": private_bytes.hex()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
def sign_message(message: bytes, private_key_hex: str) -> str:
|
|
351
|
+
private_bytes = bytes.fromhex(private_key_hex)
|
|
352
|
+
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(private_bytes)
|
|
353
|
+
signature = private_key.sign(message)
|
|
354
|
+
return signature.hex()
|
|
355
|
+
|
|
356
|
+
def verify_signature(message: bytes, signature_hex: str, public_key_hex: str) -> bool:
|
|
357
|
+
try:
|
|
358
|
+
public_bytes = bytes.fromhex(public_key_hex)
|
|
359
|
+
public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_bytes)
|
|
360
|
+
signature = bytes.fromhex(signature_hex)
|
|
361
|
+
public_key.verify(signature, message)
|
|
362
|
+
return True
|
|
363
|
+
except Exception:
|
|
364
|
+
return False
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
#### 3. Implement Doorman
|
|
368
|
+
|
|
369
|
+
- [ ] 6-step validation algorithm
|
|
370
|
+
- [ ] Peer lookup by ID
|
|
371
|
+
- [ ] Approval status check
|
|
372
|
+
- [ ] Scope bundle validation
|
|
373
|
+
- [ ] Intent grant check
|
|
374
|
+
- [ ] Topic coverage check (agent-comms)
|
|
375
|
+
- [ ] Rate limit check (sliding window)
|
|
376
|
+
|
|
377
|
+
#### 4. Add Rendezvous Support
|
|
378
|
+
|
|
379
|
+
- [ ] Auto-register on startup
|
|
380
|
+
- [ ] 30-second heartbeat
|
|
381
|
+
- [ ] Auto-deregister on shutdown
|
|
382
|
+
- [ ] Peer lookup by public key
|
|
383
|
+
|
|
384
|
+
### Testing
|
|
385
|
+
|
|
386
|
+
- [ ] Unit tests for crypto functions
|
|
387
|
+
- [ ] Unit tests for Doorman
|
|
388
|
+
- [ ] Integration test: Native Hermes ↔ Node.js OGP
|
|
389
|
+
- [ ] Integration test: Native Hermes ↔ Native Hermes
|
|
390
|
+
- [ ] Interoperability test: Hermes ↔ OpenClaw federation
|
|
391
|
+
- [ ] Load test: 100 concurrent messages
|
|
392
|
+
- [ ] Security test: Invalid signatures rejected
|
|
393
|
+
- [ ] Security test: Scope violations rejected
|
|
394
|
+
|
|
395
|
+
### Migration
|
|
396
|
+
|
|
397
|
+
- [ ] Document migration path from sidecar to native
|
|
398
|
+
- [ ] Provide import script for peers.json
|
|
399
|
+
- [ ] Provide import script for keypair.json
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Verification Checklist
|
|
404
|
+
|
|
405
|
+
### Sidecar Integration (Phase 1)
|
|
406
|
+
|
|
407
|
+
- [ ] OpenClaw integration still works (no regression)
|
|
408
|
+
- [ ] Hermes can receive OGP messages via webhook
|
|
409
|
+
- [ ] Signature verification works
|
|
410
|
+
- [ ] Can run multiple OGP instances on same machine
|
|
411
|
+
- [ ] Local federation works (OpenClaw ↔ Hermes)
|
|
412
|
+
- [ ] Remote federation works (Hermes ↔ remote OpenClaw)
|
|
413
|
+
- [ ] Scope enforcement works
|
|
414
|
+
- [ ] Rate limiting works
|
|
415
|
+
- [ ] Agent-comms intent works
|
|
416
|
+
- [ ] Project intents work
|
|
417
|
+
|
|
418
|
+
### Native Integration (Phase 2)
|
|
419
|
+
|
|
420
|
+
- [ ] Hermes speaks OGP without Node.js daemon
|
|
421
|
+
- [ ] Interoperates with Node.js OGP instances
|
|
422
|
+
- [ ] All protocol features implemented
|
|
423
|
+
- [ ] Performance meets requirements
|
|
424
|
+
- [ ] Security audit passed
|
|
425
|
+
- [ ] Documentation complete
|
|
426
|
+
- [ ] Migration guide published
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## Success Metrics
|
|
431
|
+
|
|
432
|
+
### Phase 1 (Sidecar)
|
|
433
|
+
- ✅ Zero breaking changes to OpenClaw integration
|
|
434
|
+
- ✅ Hermes can federate with OpenClaw instances
|
|
435
|
+
- ✅ Setup time < 10 minutes
|
|
436
|
+
- ✅ Documentation clarity score > 8/10
|
|
437
|
+
|
|
438
|
+
### Phase 2 (Native)
|
|
439
|
+
- ✅ No Node.js dependency for Hermes deployments
|
|
440
|
+
- ✅ 100% protocol compatibility with Node.js OGP
|
|
441
|
+
- ✅ Performance: <50ms message processing latency
|
|
442
|
+
- ✅ Code coverage > 80%
|
|
443
|
+
- ✅ Production deployment success rate > 95%
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
**Last Updated:** 2026-04-04
|
|
448
|
+
**Next Review:** After Phase 1 completion
|