sf-forcekit 1.0.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 (32) hide show
  1. package/License +21 -0
  2. package/README.md +106 -0
  3. package/bin/cli.js +85 -0
  4. package/package.json +44 -0
  5. package/templates/ai-dir/README.md +155 -0
  6. package/templates/ai-dir/agentforce.md +213 -0
  7. package/templates/ai-dir/architecture.md +123 -0
  8. package/templates/ai-dir/commands.md +276 -0
  9. package/templates/ai-dir/context-snapshots/TEMPLATE.md +64 -0
  10. package/templates/ai-dir/conventions.md +242 -0
  11. package/templates/ai-dir/current-state.md +113 -0
  12. package/templates/ai-dir/debugging-notes.md +165 -0
  13. package/templates/ai-dir/deployment.md +161 -0
  14. package/templates/ai-dir/integrations.md +199 -0
  15. package/templates/ai-dir/inventory.md +209 -0
  16. package/templates/ai-dir/known-issues.md +124 -0
  17. package/templates/ai-dir/org-context.md +110 -0
  18. package/templates/ai-dir/performance.md +312 -0
  19. package/templates/ai-dir/prompts/agentforce.md +163 -0
  20. package/templates/ai-dir/prompts/apex.md +165 -0
  21. package/templates/ai-dir/prompts/flows.md +125 -0
  22. package/templates/ai-dir/prompts/lwc.md +230 -0
  23. package/templates/ai-dir/prompts/security.md +181 -0
  24. package/templates/ai-dir/prompts/testing.md +269 -0
  25. package/templates/ai-dir/rules.md +238 -0
  26. package/templates/ai-dir/scripts/update_state.py +1406 -0
  27. package/templates/ai-dir/source-of-truth.md +180 -0
  28. package/templates/ai-dir/templates/Selector.cls +113 -0
  29. package/templates/ai-dir/templates/Service.cls +132 -0
  30. package/templates/ai-dir/templates/TestClass.cls +143 -0
  31. package/templates/ai-dir/templates/TriggerHandler.cls +67 -0
  32. package/templates/ai-dir/testing-strategy.md +342 -0
@@ -0,0 +1,1406 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import re
5
+ import glob
6
+ import json
7
+ import subprocess
8
+ from datetime import datetime
9
+
10
+ # Paths
11
+ AI_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
12
+ PROJECT_ROOT = os.path.dirname(AI_DIR)
13
+ CURRENT_STATE_PATH = os.path.join(AI_DIR, 'current-state.md')
14
+ INVENTORY_PATH = os.path.join(AI_DIR, 'inventory.md')
15
+
16
+ def print_help():
17
+ print("""Salesforce AI Context Helper Tool
18
+ Usage:
19
+ python3 .ai/scripts/update_state.py scan
20
+ Scans the force-app directory and updates inventory.md automatically.
21
+
22
+ python3 .ai/scripts/update_state.py sync-org [--target-org <alias>]
23
+ Queries the Salesforce org to fetch active org details (edition, org ID, instance, my domain) and limits (API requests, storage), updating org-context.md and current-state.md automatically.
24
+
25
+ python3 .ai/scripts/update_state.py check [--files "<file1,file2>"]
26
+ Performs static analysis (linting) on Apex/JS files for design violations (hardcoded IDs, missing USER_MODE, DML in loops, etc.).
27
+
28
+ python3 .ai/scripts/update_state.py verify --type object|field|class|flow --name <name> [--object <object_name>] [--target-org <alias>] [--force]
29
+ Queries the Salesforce org via CLI to verify if metadata exists.
30
+
31
+ python3 .ai/scripts/update_state.py cache-schema [--target-org <alias>]
32
+ Pre-caches all custom objects, custom fields, and primary standard fields from the org to prevent hallucinations offline.
33
+
34
+ python3 .ai/scripts/update_state.py session-start --agent <name> --goal "<goal>"
35
+ Logs the start of a new session in current-state.md.
36
+
37
+ python3 .ai/scripts/update_state.py session-end --summary "<summary>" [--files "<file1,file2>"]
38
+ Logs the end of a session, lists files changed, and updates tasks.
39
+
40
+ python3 .ai/scripts/update_state.py task-add "<description>" [--priority P0|P1|P2] [--deps "<deps>"]
41
+ Adds a task to upcoming in current-state.md.
42
+
43
+ python3 .ai/scripts/update_state.py task-start "<description>" [--branch <branch>]
44
+ Moves a task to In Progress in current-state.md.
45
+
46
+ python3 .ai/scripts/update_state.py task-complete "<description>" [--files "<file1,file2>"]
47
+ Moves a task to Recently Completed in current-state.md.
48
+
49
+ python3 .ai/scripts/update_state.py decision "<decision>" --rationale "<rationale>"
50
+ Logs a design decision in current-state.md.
51
+
52
+ python3 .ai/scripts/update_state.py blocker "<blocker>" --impact "<impact>" --severity High|Medium|Low
53
+ Logs a blocker in current-state.md.
54
+
55
+ python3 .ai/scripts/update_state.py blocker-resolve "<blocker>"
56
+ Resolves/removes a blocker in current-state.md.
57
+
58
+ python3 .ai/scripts/update_state.py clean
59
+ Removes local org-context.local.md, clears metadata-cache, and resets
60
+ current-state.md to prepare the project for open source distribution.
61
+ """)
62
+
63
+ def get_target_org():
64
+ local_path = os.path.join(AI_DIR, 'org-context.local.md')
65
+ org_context_path = local_path if os.path.exists(local_path) else os.path.join(AI_DIR, 'org-context.md')
66
+ if os.path.exists(org_context_path):
67
+ content = read_file(org_context_path)
68
+ match = re.search(r'\|\s*(?:\*\*)?Org Alias(?:\*\*)?\s*\|\s*`?([^`|\s]+)`?\s*\|', content, re.IGNORECASE)
69
+ if match:
70
+ val = match.group(1).strip()
71
+ if val and val != '<!--' and val != '-->' and val != '[Insert' and 'YOUR_' not in val.upper() and '<' not in val:
72
+ return val
73
+ return None
74
+
75
+ def get_default_sf_org():
76
+ try:
77
+ res = subprocess.run(["sf", "config", "get", "target-org", "--json"], capture_output=True, text=True, check=False)
78
+ if res.returncode == 0:
79
+ data = json.loads(res.stdout)
80
+ results = data.get('result', [])
81
+ if isinstance(results, list) and results:
82
+ return results[0].get('value')
83
+ elif isinstance(results, dict):
84
+ return results.get('value')
85
+ except Exception:
86
+ pass
87
+ return None
88
+
89
+ def get_cache_path(category):
90
+ return os.path.join(AI_DIR, 'metadata-cache', f"{category}.json")
91
+
92
+ def read_cache(category):
93
+ path = get_cache_path(category)
94
+ if os.path.exists(path):
95
+ try:
96
+ with open(path, 'r', encoding='utf-8') as f:
97
+ return json.load(f)
98
+ except Exception:
99
+ return {}
100
+ return {}
101
+
102
+ def write_cache(category, data):
103
+ os.makedirs(os.path.join(AI_DIR, 'metadata-cache'), exist_ok=True)
104
+ path = get_cache_path(category)
105
+ with open(path, 'w', encoding='utf-8') as f:
106
+ json.dump(data, f, indent=2)
107
+
108
+ def strip_comments(content):
109
+ content = re.sub(r'/\*[\s\S]*?\*/', '', content)
110
+ content = re.sub(r'//.*$', '', content, flags=re.MULTILINE)
111
+ return content
112
+
113
+ def get_line_number(full_text, char_index):
114
+ return full_text[:char_index].count('\n') + 1
115
+
116
+ def extract_loop_body(text, loop_start_idx):
117
+ header_start = text.find('(', loop_start_idx)
118
+ if header_start == -1:
119
+ return "", loop_start_idx
120
+ paren_count = 1
121
+ idx = header_start + 1
122
+ while paren_count > 0 and idx < len(text):
123
+ if text[idx] == '(':
124
+ paren_count += 1
125
+ elif text[idx] == ')':
126
+ paren_count -= 1
127
+ idx += 1
128
+ if paren_count != 0:
129
+ return "", loop_start_idx
130
+ non_ws_idx = idx
131
+ while non_ws_idx < len(text) and text[non_ws_idx].isspace():
132
+ non_ws_idx += 1
133
+ if non_ws_idx >= len(text):
134
+ return "", loop_start_idx
135
+ if text[non_ws_idx] == '{':
136
+ brace_count = 1
137
+ current_idx = non_ws_idx + 1
138
+ while brace_count > 0 and current_idx < len(text):
139
+ if text[current_idx] == '{':
140
+ brace_count += 1
141
+ elif text[current_idx] == '}':
142
+ brace_count -= 1
143
+ current_idx += 1
144
+ if brace_count == 0:
145
+ return text[non_ws_idx:current_idx], non_ws_idx
146
+ else:
147
+ semi_idx = text.find(';', non_ws_idx)
148
+ if semi_idx != -1:
149
+ return text[non_ws_idx:semi_idx+1], non_ws_idx
150
+ return "", loop_start_idx
151
+
152
+ def check_file(file_path):
153
+ issues = []
154
+ content = read_file(file_path)
155
+ clean_content = strip_comments(content)
156
+
157
+ is_apex = file_path.endswith('.cls')
158
+ is_trigger = file_path.endswith('.trigger')
159
+
160
+ if is_apex:
161
+ # Sharing keyword check (exclude interfaces and tests)
162
+ if 'interface' not in clean_content.lower() and '@istest' not in content.lower():
163
+ class_matches = re.finditer(r'\b(?:public|private|global|virtual|abstract)\s+class\s+(\w+)\b', clean_content, re.IGNORECASE)
164
+ for m in class_matches:
165
+ class_name = m.group(1)
166
+ if not re.search(r'\b(?:with|without|inherited)\s+sharing\b', clean_content, re.IGNORECASE):
167
+ issues.append({
168
+ 'rule': 'Sharing Keyword Enforced',
169
+ 'severity': 'Error',
170
+ 'message': f"Class '{class_name}' is missing an explicit sharing keyword (with sharing, without sharing, inherited sharing).",
171
+ 'line': get_line_number(content, m.start())
172
+ })
173
+
174
+ # SeeAllData=true check for test classes
175
+ if '@istest' in content.lower():
176
+ m = re.search(r'@isTest\s*\(\s*SeeAllData\s*=\s*true\s*\)', content, re.IGNORECASE)
177
+ if m:
178
+ issues.append({
179
+ 'rule': 'SeeAllData Banned',
180
+ 'severity': 'Error',
181
+ 'message': "SeeAllData=true is banned in tests. Create test data instead.",
182
+ 'line': get_line_number(content, m.start())
183
+ })
184
+
185
+ # Hardcoded endpoint check (exclude tests)
186
+ if '@istest' not in content.lower():
187
+ endpoint_matches = re.finditer(r'[\'"]https?://[^\'"]+[\'"]', clean_content, re.IGNORECASE)
188
+ for m in endpoint_matches:
189
+ url = m.group(0)
190
+ if not any(x in url for x in ['soap.sforce.com', 'metadata.tooling', 'schemas.xmlsoap.org']):
191
+ issues.append({
192
+ 'rule': 'No Hardcoded Endpoints',
193
+ 'severity': 'Error',
194
+ 'message': f"Hardcoded endpoint URL detected: {url}. Use Named Credentials instead.",
195
+ 'line': get_line_number(content, m.start())
196
+ })
197
+
198
+ soql_matches = re.finditer(r'\[\s*(SELECT[\s\S]*?)\]', clean_content, re.IGNORECASE)
199
+ for m in soql_matches:
200
+ soql_text = m.group(1)
201
+ if not re.search(r'\bWITH\s+(?:USER_MODE|SECURITY_ENFORCED|SYSTEM_MODE)\b', soql_text, re.IGNORECASE):
202
+ issues.append({
203
+ 'rule': 'Enforce Security in SOQL',
204
+ 'severity': 'Error',
205
+ 'message': f"SOQL query missing WITH USER_MODE: {soql_text.strip()[:60]}...",
206
+ 'line': get_line_number(content, m.start())
207
+ })
208
+
209
+ catch_matches = re.finditer(r'\bcatch\s*\(\s*\w+\s+\w+\s*\)\s*\{\s*\}', clean_content)
210
+ for m in catch_matches:
211
+ issues.append({
212
+ 'rule': 'Swallowed Exception Warning',
213
+ 'severity': 'Warning',
214
+ 'message': "Empty catch block detected. Swallowing exceptions silently is banned.",
215
+ 'line': get_line_number(content, m.start())
216
+ })
217
+
218
+ if is_trigger:
219
+ # Check trigger logic bulkiness
220
+ if len(clean_content.split('\n')) > 30:
221
+ issues.append({
222
+ 'rule': 'Keep Triggers Simple',
223
+ 'severity': 'Warning',
224
+ 'message': "Trigger has more than 30 lines. Triggers should delegate immediately to a handler class.",
225
+ 'line': 1
226
+ })
227
+
228
+ if is_apex or is_trigger:
229
+ for loop_match in re.finditer(r'\b(for|while)\b\s*\(', clean_content, re.IGNORECASE):
230
+ loop_body, loop_start_idx = extract_loop_body(clean_content, loop_match.start())
231
+ if loop_body:
232
+ soql_in_loop = re.finditer(r'\[\s*(SELECT[\s\S]*?)\]', loop_body, re.IGNORECASE)
233
+ for sm in soql_in_loop:
234
+ issues.append({
235
+ 'rule': 'No SOQL in Loops',
236
+ 'severity': 'Error',
237
+ 'message': f"SOQL query found inside a {loop_match.group(1)} loop: {sm.group(1).strip()[:60]}...",
238
+ 'line': get_line_number(content, loop_start_idx + sm.start())
239
+ })
240
+
241
+ dml_in_loop = re.finditer(r'\b(insert|update|upsert|delete|undelete|merge)\s+(?:new\s+\w+|\w+)\b', loop_body, re.IGNORECASE)
242
+ for dm in dml_in_loop:
243
+ issues.append({
244
+ 'rule': 'No DML in Loops',
245
+ 'severity': 'Error',
246
+ 'message': f"DML statement '{dm.group(1)}' found inside a {loop_match.group(1)} loop.",
247
+ 'line': get_line_number(content, loop_start_idx + dm.start())
248
+ })
249
+
250
+ db_dml_in_loop = re.finditer(r'\bDatabase\.(insert|update|upsert|delete|undelete|merge|convertLead|emptyRecycleBin)\b', loop_body, re.IGNORECASE)
251
+ for dbm in db_dml_in_loop:
252
+ issues.append({
253
+ 'rule': 'No DML in Loops',
254
+ 'severity': 'Error',
255
+ 'message': f"Database DML call 'Database.{dbm.group(1)}' found inside a {loop_match.group(1)} loop.",
256
+ 'line': get_line_number(content, loop_start_idx + dbm.start())
257
+ })
258
+
259
+ id_matches = re.finditer(r'\b(001|003|005|006|00Q|012|01t|500|701|801|a[0-9][0-9a-zA-Z])[0-9a-zA-Z]{12}(?:[0-9a-zA-Z]{3})?\b', clean_content)
260
+ for m in id_matches:
261
+ issues.append({
262
+ 'rule': 'No Hardcoded IDs',
263
+ 'severity': 'Error',
264
+ 'message': f"Hardcoded Salesforce ID detected: '{m.group(0)}'. Use Custom Labels or Metadata instead.",
265
+ 'line': get_line_number(content, m.start())
266
+ })
267
+
268
+ # LWC directory naming check
269
+ if '/lwc/' in file_path.replace('\\', '/') and file_path.endswith('.js'):
270
+ parts = file_path.replace('\\', '/').split('/')
271
+ try:
272
+ lwc_idx = parts.index('lwc')
273
+ if lwc_idx + 1 < len(parts):
274
+ folder_name = parts[lwc_idx + 1]
275
+ if folder_name and folder_name[0].isupper():
276
+ issues.append({
277
+ 'rule': 'LWC Naming Standard',
278
+ 'severity': 'Error',
279
+ 'message': f"LWC component folder '{folder_name}' must be camelCase, not PascalCase.",
280
+ 'line': 1
281
+ })
282
+ except ValueError:
283
+ pass
284
+
285
+ return issues
286
+
287
+ def check_all_files(files_arg=None):
288
+ if files_arg:
289
+ file_paths = [f.strip() for f in files_arg.split(',') if f.strip()]
290
+ else:
291
+ force_app = os.path.join(PROJECT_ROOT, 'force-app')
292
+ file_paths = []
293
+ if os.path.exists(force_app):
294
+ for ext in ('/**/*.cls', '/**/*.trigger', '/**/*.js'):
295
+ file_paths.extend(glob.glob(force_app + ext, recursive=True))
296
+ else:
297
+ print("Error: 'force-app' directory not found.")
298
+ sys.exit(1)
299
+
300
+ all_issues = {}
301
+ total_errors = 0
302
+ total_warnings = 0
303
+
304
+ for file_path in sorted(file_paths):
305
+ rel_path = os.path.relpath(file_path, PROJECT_ROOT)
306
+ issues = check_file(file_path)
307
+ if issues:
308
+ all_issues[rel_path] = issues
309
+ for issue in issues:
310
+ if issue['severity'] == 'Error':
311
+ total_errors += 1
312
+ else:
313
+ total_warnings += 1
314
+
315
+ if not all_issues:
316
+ print("✅ No design violations or code quality issues found!")
317
+ sys.exit(0)
318
+
319
+ print(f"🔍 Found {total_errors} errors and {total_warnings} warnings in {len(all_issues)} files:\n")
320
+ for file_path, issues in all_issues.items():
321
+ print(f"📂 {file_path}")
322
+ for issue in sorted(issues, key=lambda x: x['line']):
323
+ severity_str = "🔴 ERROR" if issue['severity'] == 'Error' else "🟡 WARNING"
324
+ print(f" Line {issue['line']}: [{severity_str}] ({issue['rule']}) {issue['message']}")
325
+ print()
326
+
327
+ if total_errors > 0:
328
+ print("❌ Static analysis failed due to design rule errors. Please fix them before deploying.")
329
+ sys.exit(1)
330
+ else:
331
+ sys.exit(0)
332
+
333
+ def verify_metadata(meta_type, meta_name, obj_name=None, target_org_override=None, force=False):
334
+ if not meta_type or not meta_name:
335
+ print("Error: --type and --name are required parameters for 'verify'.")
336
+ sys.exit(1)
337
+
338
+ meta_type = meta_type.lower()
339
+ if meta_type == 'field' and not obj_name:
340
+ print("Error: --object <object_name> is required when --type is 'field'.")
341
+ sys.exit(1)
342
+
343
+ category_map = {
344
+ 'object': 'objects',
345
+ 'field': 'fields',
346
+ 'class': 'classes',
347
+ 'flow': 'flows'
348
+ }
349
+ category = category_map.get(meta_type)
350
+ if not category:
351
+ print(f"Error: Invalid metadata type '{meta_type}'")
352
+ sys.exit(1)
353
+
354
+ cache_key = f"{obj_name}.{meta_name}" if meta_type == 'field' else meta_name
355
+
356
+ if not force:
357
+ cache = read_cache(category)
358
+ if cache_key in cache:
359
+ entry = cache[cache_key]
360
+ if entry.get('verified'):
361
+ print(f"✅ [Cache Hit] Verified: {meta_type} '{cache_key}' exists in Salesforce (last checked {entry.get('verified_at')})")
362
+ sys.exit(0)
363
+ else:
364
+ print(f"❌ [Cache Hit] Verified: {meta_type} '{cache_key}' DOES NOT exist in Salesforce (last checked {entry.get('verified_at')})")
365
+ sys.exit(1)
366
+
367
+ # Determine target org
368
+ org = target_org_override or get_target_org()
369
+ if not org:
370
+ print("Error: Target org not specified. Update Org Alias in .ai/org-context.md, or pass it via --target-org")
371
+ sys.exit(1)
372
+
373
+ # Build query
374
+ if meta_type == 'object':
375
+ query = f"SELECT QualifiedApiName FROM EntityDefinition WHERE QualifiedApiName = '{meta_name}'"
376
+ elif meta_type == 'field':
377
+ query = f"SELECT QualifiedApiName, DataType FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName = '{obj_name}' AND QualifiedApiName = '{meta_name}'"
378
+ elif meta_type == 'class':
379
+ query = f"SELECT Name, Status FROM ApexClass WHERE Name = '{meta_name}'"
380
+ elif meta_type == 'flow':
381
+ query = f"SELECT DeveloperName, Status FROM FlowDefinitionView WHERE DeveloperName = '{meta_name}'"
382
+
383
+ cmd = ["sf", "data", "query", "--query", query, "--target-org", org, "--use-tooling-api", "--json"]
384
+ print(f"Executing Salesforce CLI query targeting '{org}' to verify {meta_type} '{cache_key}'...")
385
+ try:
386
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
387
+ if result.returncode != 0:
388
+ print(f"Error executing sf CLI: {result.stderr.strip()}")
389
+ print(f"Ensure that you are logged into target-org '{org}' (run: sf org login web --alias {org})")
390
+ sys.exit(1)
391
+
392
+ data = json.loads(result.stdout)
393
+ records = data.get('result', {}).get('records', [])
394
+ exists = len(records) > 0
395
+
396
+ cache = read_cache(category)
397
+ cache[cache_key] = {
398
+ "verified": exists,
399
+ "verified_at": datetime.now().isoformat()
400
+ }
401
+ if exists and meta_type == 'field':
402
+ cache[cache_key]['dataType'] = records[0].get('DataType', 'Unknown')
403
+ elif exists and meta_type == 'class':
404
+ cache[cache_key]['status'] = records[0].get('Status', 'Unknown')
405
+ write_cache(category, cache)
406
+
407
+ if exists:
408
+ print(f"✅ Verified: {meta_type} '{cache_key}' exists in Salesforce org '{org}'")
409
+ sys.exit(0)
410
+ else:
411
+ print(f"❌ Verification Failed: {meta_type} '{cache_key}' does not exist in Salesforce org '{org}'")
412
+ sys.exit(1)
413
+ except Exception as e:
414
+ print(f"Error running verification command: {str(e)}")
415
+ sys.exit(1)
416
+
417
+ def cache_schema(target_org_override=None):
418
+ org = target_org_override or get_target_org()
419
+ if not org:
420
+ print("Error: Target org not specified. Update Org Alias in .ai/org-context.md, or pass it via --target-org")
421
+ sys.exit(1)
422
+
423
+ print(f"Pre-caching Salesforce schema from org '{org}' to prevent hallucinations...")
424
+
425
+ # Fetch Objects using split queries (avoid disjunctions not supported by Tooling API)
426
+ objects_queries = [
427
+ "SELECT QualifiedApiName, Label FROM EntityDefinition WHERE QualifiedApiName LIKE '%__c'",
428
+ "SELECT QualifiedApiName, Label FROM EntityDefinition WHERE QualifiedApiName IN ('Account', 'Contact', 'Opportunity', 'Lead', 'Case', 'User')"
429
+ ]
430
+
431
+ objects_cache = read_cache("objects")
432
+ total_objects = 0
433
+ print("Fetching custom and standard objects...")
434
+ for query in objects_queries:
435
+ cmd = ["sf", "data", "query", "--query", query, "--target-org", org, "--use-tooling-api", "--json"]
436
+ try:
437
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
438
+ if result.returncode == 0:
439
+ data = json.loads(result.stdout)
440
+ records = data.get('result', {}).get('records', [])
441
+ for r in records:
442
+ name = r.get('QualifiedApiName')
443
+ label = r.get('Label')
444
+ if name:
445
+ objects_cache[name] = {
446
+ "label": label or name,
447
+ "verified": True,
448
+ "verified_at": datetime.now().isoformat()
449
+ }
450
+ total_objects += 1
451
+ else:
452
+ print(f"Warning: Failed execution for query: {query}. Error: {result.stderr.strip()}")
453
+ except Exception as e:
454
+ print(f"Error executing query {query}: {str(e)}")
455
+
456
+ write_cache("objects", objects_cache)
457
+ print(f"Successfully cached {total_objects} objects.")
458
+
459
+ # Fetch Fields using split queries
460
+ fields_queries = [
461
+ "SELECT EntityDefinition.QualifiedApiName, QualifiedApiName, DataType, Label FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName LIKE '%__c'",
462
+ "SELECT EntityDefinition.QualifiedApiName, QualifiedApiName, DataType, Label FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName IN ('Account', 'Contact', 'Opportunity', 'Lead', 'Case')"
463
+ ]
464
+
465
+ fields_cache = read_cache("fields")
466
+ total_fields = 0
467
+ print("Fetching fields (this may take a moment)...")
468
+ for query in fields_queries:
469
+ cmd = ["sf", "data", "query", "--query", query, "--target-org", org, "--use-tooling-api", "--json"]
470
+ try:
471
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
472
+ if result.returncode == 0:
473
+ data = json.loads(result.stdout)
474
+ records = data.get('result', {}).get('records', [])
475
+ for r in records:
476
+ obj_name = r.get('EntityDefinition', {}).get('QualifiedApiName')
477
+ field_name = r.get('QualifiedApiName')
478
+ data_type = r.get('DataType')
479
+ label = r.get('Label')
480
+ if obj_name and field_name:
481
+ key = f"{obj_name}.{field_name}"
482
+ fields_cache[key] = {
483
+ "label": label or field_name,
484
+ "dataType": data_type or "Unknown",
485
+ "verified": True,
486
+ "verified_at": datetime.now().isoformat()
487
+ }
488
+ total_fields += 1
489
+ else:
490
+ print(f"Warning: Failed execution for query: {query}. Error: {result.stderr.strip()}")
491
+ except Exception as e:
492
+ print(f"Error executing query {query}: {str(e)}")
493
+
494
+ write_cache("fields", fields_cache)
495
+ print(f"Successfully cached {total_fields} fields.")
496
+
497
+ def sync_org(target_org_override=None):
498
+ org = target_org_override or get_target_org() or get_default_sf_org()
499
+ if not org:
500
+ print("Error: Target org not specified and no default target org found. Update Org Alias in .ai/org-context.md, authorize an org via 'sf org login web', or pass it via --target-org")
501
+ sys.exit(1)
502
+
503
+ print(f"Synchronizing Salesforce org details and limits from '{org}'...")
504
+
505
+ # 1. sf org display --json
506
+ cmd_display = ["sf", "org", "display", "--target-org", org, "--json"]
507
+ org_id = None
508
+ username = None
509
+ my_domain = None
510
+ alias = org
511
+
512
+ try:
513
+ res = subprocess.run(cmd_display, capture_output=True, text=True, check=False)
514
+ if res.returncode == 0:
515
+ data = json.loads(res.stdout)
516
+ result = data.get('result', {})
517
+ org_id = result.get('id')
518
+ username = result.get('username')
519
+ instance_url = result.get('instanceUrl', '')
520
+ if instance_url:
521
+ my_domain = instance_url.replace('https://', '').replace('http://', '').strip('/')
522
+ alias = result.get('alias') or org
523
+ else:
524
+ print(f"Warning: sf org display failed: {res.stderr.strip()}")
525
+ except Exception as e:
526
+ print(f"Warning: Failed to run sf org display: {str(e)}")
527
+
528
+ # 2. sf data query for Organization details
529
+ query = "SELECT Name, OrganizationType, InstanceName FROM Organization"
530
+ cmd_query = ["sf", "data", "query", "--query", query, "--target-org", org, "--json"]
531
+ org_name = None
532
+ org_type = None
533
+ instance_name = None
534
+
535
+ try:
536
+ res = subprocess.run(cmd_query, capture_output=True, text=True, check=False)
537
+ if res.returncode == 0:
538
+ data = json.loads(res.stdout)
539
+ records = data.get('result', {}).get('records', [])
540
+ if records:
541
+ org_name = records[0].get('Name')
542
+ org_type = records[0].get('OrganizationType')
543
+ instance_name = records[0].get('InstanceName')
544
+ else:
545
+ print(f"Warning: Query to Organization object failed: {res.stderr.strip()}")
546
+ except Exception as e:
547
+ print(f"Warning: Failed to query Organization: {str(e)}")
548
+
549
+ # 3. sf limits api display --json
550
+ cmd_limits = ["sf", "limits", "api", "display", "--target-org", org, "--json"]
551
+ limits_data = {}
552
+ try:
553
+ res = subprocess.run(cmd_limits, capture_output=True, text=True, check=False)
554
+ if res.returncode == 0:
555
+ data = json.loads(res.stdout)
556
+ for item in data.get('result', []):
557
+ name = item.get('name')
558
+ limits_data[name] = {
559
+ 'max': item.get('max'),
560
+ 'remaining': item.get('remaining')
561
+ }
562
+ else:
563
+ print(f"Warning: sf limits api display failed: {res.stderr.strip()}")
564
+ except Exception as e:
565
+ print(f"Warning: Failed to display limits: {str(e)}")
566
+
567
+ if not org_type:
568
+ org_type = "Developer Edition"
569
+ if not instance_name:
570
+ instance_name = "N/A"
571
+ if not org_id:
572
+ org_id = "N/A"
573
+ if not username:
574
+ username = "N/A"
575
+ if not my_domain:
576
+ my_domain = "N/A"
577
+
578
+ api_max = limits_data.get('DailyApiRequests', {}).get('max', 15000)
579
+ api_rem = limits_data.get('DailyApiRequests', {}).get('remaining', 15000)
580
+ api_used = api_max - api_rem
581
+
582
+ data_max = limits_data.get('DataStorageMB', {}).get('max', 5)
583
+ data_rem = limits_data.get('DataStorageMB', {}).get('remaining', 5)
584
+ data_used = data_max - data_rem
585
+
586
+ file_max = limits_data.get('FileStorageMB', {}).get('max', 20)
587
+ file_rem = limits_data.get('FileStorageMB', {}).get('remaining', 20)
588
+ file_used = file_max - file_rem
589
+
590
+ # 4. Update org-context.local.md
591
+ org_context_path = os.path.join(AI_DIR, 'org-context.md')
592
+ org_context_local_path = os.path.join(AI_DIR, 'org-context.local.md')
593
+
594
+ if os.path.exists(org_context_path):
595
+ content = read_file(org_context_path)
596
+
597
+ # Replace Org Details rows
598
+ content = re.sub(r'^(\|\s*(?:\*\*)?Org Alias(?:\*\*)?\s*\|)([^|\r\n]+)(\|)', f'\\1 `{alias}` \\3', content, flags=re.IGNORECASE | re.MULTILINE)
599
+ content = re.sub(r'^(\|\s*(?:\*\*)?Username(?:\*\*)?\s*\|)([^|\r\n]+)(\|)', f'\\1 `{username}` \\3', content, flags=re.IGNORECASE | re.MULTILINE)
600
+ content = re.sub(r'^(\|\s*(?:\*\*)?Edition(?:\*\*)?\s*\|)([^|\r\n]+)(\|)', f'\\1 `{org_type}` \\3', content, flags=re.IGNORECASE | re.MULTILINE)
601
+ content = re.sub(r'^(\|\s*(?:\*\*)?Instance(?:\*\*)?\s*\|)([^|\r\n]+)(\|)', f'\\1 `{instance_name}` \\3', content, flags=re.IGNORECASE | re.MULTILINE)
602
+ content = re.sub(r'^(\|\s*(?:\*\*)?Org ID(?:\*\*)?\s*\|)([^|\r\n]+)(\|)', f'\\1 `{org_id}` \\3', content, flags=re.IGNORECASE | re.MULTILINE)
603
+ content = re.sub(r'^(\|\s*(?:\*\*)?My Domain(?:\*\*)?\s*\|)([^|\r\n]+)(\|)', f'\\1 `{my_domain}` \\3', content, flags=re.IGNORECASE | re.MULTILINE)
604
+
605
+ # Replace default target org config
606
+ content = re.sub(r'^(\|\s*(?:\*\*)?Default target org(?:\*\*)?\s*\|)([^|\r\n]+)(\|)', f'\\1 `--target-org {alias}` \\3', content, flags=re.IGNORECASE | re.MULTILINE)
607
+
608
+ # Update Governor limits header and rows in org-context.md
609
+ content = re.sub(r'## Governor Limits \([^)]*\)', f'## Governor Limits ({org_type})', content)
610
+ content = re.sub(r'(\|\s*Daily API Requests\s*\|)([^|\r\n]+)(\|)', f'\\1 {api_max:,} \\3', content, flags=re.IGNORECASE)
611
+ content = re.sub(r'(\|\s*Data Storage\s*\|)([^|\r\n]+)(\|)', f'\\1 {data_max} MB \\3', content, flags=re.IGNORECASE)
612
+ content = re.sub(r'(\|\s*File Storage\s*\|)([^|\r\n]+)(\|)', f'\\1 {file_max} MB \\3', content, flags=re.IGNORECASE)
613
+
614
+ # Replace Connected Orgs / Sandboxes row for this dev org
615
+ dev_row_pattern = r'\|\s*[^|\r\n]+\(Dev\)\s*\|[^|\r\n]+\|[^|\r\n]+\|\s*Primary dev\s*\|'
616
+ if re.search(dev_row_pattern, content):
617
+ content = re.sub(dev_row_pattern, f'| {alias} (Dev) | {org_type} | {username} | Primary dev |', content)
618
+ else:
619
+ content = re.sub(r'(\|\s*<insert_org_alias> \(Dev\)\s*\|)([^|\r\n]+)(\|)([^|\r\n]+)(\|)(\s*Primary dev\s*\|)',
620
+ f'| {alias} (Dev) | {org_type} | {username} | Primary dev |', content)
621
+
622
+ # Replace remaining placeholders
623
+ content = content.replace('<insert_org_alias>', alias)
624
+ content = content.replace('<insert_edition>', org_type)
625
+ content = content.replace('<insert_username>', username)
626
+
627
+ write_file(org_context_local_path, content)
628
+ print("Updated .ai/org-context.local.md with active org details and limits.")
629
+
630
+ # 5. Update current-state.md
631
+ current_state_path = os.path.join(AI_DIR, 'current-state.md')
632
+ if os.path.exists(current_state_path):
633
+ content = read_file(current_state_path)
634
+
635
+ def get_risk(used, total):
636
+ if total <= 0: return '🟢'
637
+ pct = (used / total) * 100
638
+ if pct >= 80: return '🔴'
639
+ if pct >= 50: return '🟡'
640
+ return '🟢'
641
+
642
+ api_risk = get_risk(api_used, api_max)
643
+ data_risk = get_risk(data_used, data_max)
644
+ file_risk = get_risk(file_used, file_max)
645
+
646
+ content = re.sub(r'(\|\s*API Calls \(Daily\)\s*\|)([^|\r\n]+)(\|)([^|\r\n]+)(\|)([^|\r\n]+)(\|)',
647
+ f'\\1 {api_used:,} \\3 {api_max:,} \\5 {api_risk} \\7', content, flags=re.IGNORECASE)
648
+ content = re.sub(r'(\|\s*Data Storage\s*\|)([^|\r\n]+)(\|)([^|\r\n]+)(\|)([^|\r\n]+)(\|)',
649
+ f'\\1 {data_used} MB \\3 {data_max} MB \\5 {data_risk} \\7', content, flags=re.IGNORECASE)
650
+ content = re.sub(r'(\|\s*File Storage\s*\|)([^|\r\n]+)(\|)([^|\r\n]+)(\|)([^|\r\n]+)(\|)',
651
+ f'\\1 {file_used} MB \\3 {file_max} MB \\5 {file_risk} \\7', content, flags=re.IGNORECASE)
652
+
653
+ write_file(current_state_path, content)
654
+ print("Updated .ai/current-state.md Governor Limit Watch.")
655
+
656
+ # 6. Update .gitignore to exclude local org context & cache files
657
+ gitignore_path = os.path.join(os.path.dirname(AI_DIR), '.gitignore')
658
+ try:
659
+ ignores_to_add = ['.ai/org-context.local.md', '.ai/metadata-cache/']
660
+ existing_lines = []
661
+ if os.path.exists(gitignore_path):
662
+ with open(gitignore_path, 'r', encoding='utf-8') as f:
663
+ existing_lines = f.read().splitlines()
664
+
665
+ added_any = False
666
+ with open(gitignore_path, 'a', encoding='utf-8') as f:
667
+ for ignore_item in ignores_to_add:
668
+ if ignore_item not in existing_lines and f"{ignore_item}/" not in existing_lines:
669
+ if not added_any and not existing_lines:
670
+ f.write(f"# Salesforce AI Context Local Files\n")
671
+ elif not added_any:
672
+ f.write(f"\n# Salesforce AI Context Local Files\n")
673
+ f.write(f"{ignore_item}\n")
674
+ added_any = True
675
+ print(f"Added {ignore_item} to .gitignore")
676
+ except Exception as e:
677
+ print(f"Warning: Failed to update .gitignore: {str(e)}")
678
+
679
+ def read_file(path):
680
+ if not os.path.exists(path):
681
+ return ""
682
+ with open(path, 'r', encoding='utf-8') as f:
683
+ return f.read()
684
+
685
+ def write_file(path, content):
686
+ with open(path, 'w', encoding='utf-8') as f:
687
+ f.write(content)
688
+
689
+ # --- INVENTORY SCANNING LOGIC ---
690
+ def get_object_label(object_dir):
691
+ # Try to find object label in object-meta.xml
692
+ meta_files = glob.glob(os.path.join(object_dir, '*.object-meta.xml'))
693
+ if meta_files:
694
+ content = read_file(meta_files[0])
695
+ label_match = re.search(r'<label>(.*?)</label>', content)
696
+ if label_match:
697
+ return label_match.group(1)
698
+ return ""
699
+
700
+ def scan_inventory():
701
+ print("Scanning project metadata...")
702
+ force_app = os.path.join(PROJECT_ROOT, 'force-app')
703
+ if not os.path.exists(force_app):
704
+ print(f"Error: force-app directory not found at {force_app}")
705
+ sys.exit(1)
706
+
707
+ # 1. Custom Objects
708
+ objects_path = os.path.join(force_app, 'main', 'default', 'objects')
709
+ objects = []
710
+ if os.path.exists(objects_path):
711
+ for name in sorted(os.listdir(objects_path)):
712
+ full_path = os.path.join(objects_path, name)
713
+ if os.path.isdir(full_path):
714
+ label = get_object_label(full_path)
715
+ objects.append((name, label))
716
+
717
+ # 2. LWC
718
+ lwc_path = os.path.join(force_app, 'main', 'default', 'lwc')
719
+ lwc_components = []
720
+ if os.path.exists(lwc_path):
721
+ for name in sorted(os.listdir(lwc_path)):
722
+ full_path = os.path.join(lwc_path, name)
723
+ if os.path.isdir(full_path) and not name.startswith('.'):
724
+ lwc_components.append(name)
725
+
726
+ # 3. Flows
727
+ flows_path = os.path.join(force_app, 'main', 'default', 'flows')
728
+ flows = []
729
+ if os.path.exists(flows_path):
730
+ for file in sorted(glob.glob(os.path.join(flows_path, '*.flow-meta.xml'))):
731
+ name = os.path.basename(file).replace('.flow-meta.xml', '')
732
+ # Try to read flow type and status
733
+ content = read_file(file)
734
+ flow_type = "Autolaunched"
735
+ if '<processType>Flow</processType>' in content:
736
+ flow_type = "Screen Flow"
737
+ elif '<processType>Workflow</processType>' in content:
738
+ flow_type = "Record-Triggered"
739
+ status = "Active" if '<status>Active</status>' in content else "Draft"
740
+ flows.append((name, flow_type, status))
741
+
742
+ # 4. Permission Sets
743
+ perms_path = os.path.join(force_app, 'main', 'default', 'permissionsets')
744
+ permission_sets = []
745
+ if os.path.exists(perms_path):
746
+ for file in sorted(glob.glob(os.path.join(perms_path, '*.permissionset-meta.xml'))):
747
+ name = os.path.basename(file).replace('.permissionset-meta.xml', '')
748
+ permission_sets.append(name)
749
+
750
+ # 5. Triggers
751
+ triggers_path = os.path.join(force_app, 'main', 'default', 'triggers')
752
+ triggers = []
753
+ if os.path.exists(triggers_path):
754
+ for file in sorted(glob.glob(os.path.join(triggers_path, '*.trigger'))):
755
+ name = os.path.basename(file).replace('.trigger', '')
756
+ content = read_file(file)
757
+ obj_match = re.search(r'on\s+(\w+)\s*\(', content, re.IGNORECASE)
758
+ obj = obj_match.group(1) if obj_match else "Unknown"
759
+ triggers.append((name, obj))
760
+
761
+ # 6. Apex Classes
762
+ classes_path = os.path.join(force_app, 'main', 'default', 'classes')
763
+ apex_classes = {
764
+ 'handlers': [],
765
+ 'services': [],
766
+ 'selectors': [],
767
+ 'controllers': [],
768
+ 'batch_async': [],
769
+ 'utilities': [],
770
+ 'tests': []
771
+ }
772
+ if os.path.exists(classes_path):
773
+ for file in sorted(glob.glob(os.path.join(classes_path, '*.cls'))):
774
+ name = os.path.basename(file).replace('.cls', '')
775
+ content = read_file(file)
776
+
777
+ # Simple heuristic classification
778
+ if name.endswith('Test') or '@isTest' in content.lower():
779
+ apex_classes['tests'].append(name)
780
+ elif name.endswith('Handler') or 'TriggerHandler' in content:
781
+ apex_classes['handlers'].append(name)
782
+ elif name.endswith('Service'):
783
+ apex_classes['services'].append(name)
784
+ elif name.endswith('Selector') or 'Selector' in name:
785
+ apex_classes['selectors'].append(name)
786
+ elif name.endswith('Controller'):
787
+ apex_classes['controllers'].append(name)
788
+ elif any(x in content for x in ['Database.Batchable', 'Queueable', 'Schedulable']):
789
+ apex_classes['batch_async'].append(name)
790
+ else:
791
+ apex_classes['utilities'].append(name)
792
+
793
+ # Now update inventory.md
794
+ inventory_content = read_file(INVENTORY_PATH)
795
+ if not inventory_content:
796
+ print("Error: inventory.md not found or empty.")
797
+ sys.exit(1)
798
+
799
+ # Update Custom Objects
800
+ obj_rows = ""
801
+ if objects:
802
+ for name, label in objects:
803
+ obj_rows += f"| {name} | {label} | | |\n"
804
+ else:
805
+ obj_rows = "| _None_ | | | |\n"
806
+ inventory_content = re.sub(
807
+ r'(## Custom Objects\s*\n\s*<!--.*?-->\s*\n\s*\|.*?\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)',
808
+ r'\1' + obj_rows,
809
+ inventory_content
810
+ )
811
+
812
+ # Update Triggers
813
+ trigger_rows = ""
814
+ if triggers:
815
+ for name, obj in triggers:
816
+ trigger_rows += f"| {name} | {obj} | |\n"
817
+ else:
818
+ trigger_rows = "| | | |\n"
819
+ inventory_content = re.sub(
820
+ r'(### Triggers\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n###|\Z)',
821
+ r'\1' + trigger_rows,
822
+ inventory_content
823
+ )
824
+
825
+ # Update Handlers
826
+ handler_rows = ""
827
+ if apex_classes['handlers']:
828
+ for name in apex_classes['handlers']:
829
+ handler_rows += f"| {name} | | |\n"
830
+ else:
831
+ handler_rows = "| | | |\n"
832
+ inventory_content = re.sub(
833
+ r'(### Handlers\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n###|\Z)',
834
+ r'\1' + handler_rows,
835
+ inventory_content
836
+ )
837
+
838
+ # Update Services
839
+ service_rows = ""
840
+ if apex_classes['services']:
841
+ for name in apex_classes['services']:
842
+ service_rows += f"| {name} | | |\n"
843
+ else:
844
+ service_rows = "| | | |\n"
845
+ inventory_content = re.sub(
846
+ r'(### Services\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n###|\Z)',
847
+ r'\1' + service_rows,
848
+ inventory_content
849
+ )
850
+
851
+ # Update Selectors
852
+ selector_rows = ""
853
+ if apex_classes['selectors']:
854
+ for name in apex_classes['selectors']:
855
+ selector_rows += f"| {name} | | |\n"
856
+ else:
857
+ selector_rows = "| | | |\n"
858
+ inventory_content = re.sub(
859
+ r'(### Selectors\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n###|\Z)',
860
+ r'\1' + selector_rows,
861
+ inventory_content
862
+ )
863
+
864
+ # Update Controllers
865
+ controller_rows = ""
866
+ if apex_classes['controllers']:
867
+ for name in apex_classes['controllers']:
868
+ controller_rows += f"| {name} | | |\n"
869
+ else:
870
+ controller_rows = "| | | |\n"
871
+ inventory_content = re.sub(
872
+ r'(### Controllers \(LWC / Aura\)\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n###|\Z)',
873
+ r'\1' + controller_rows,
874
+ inventory_content
875
+ )
876
+
877
+ # Update Batch/Async
878
+ batch_rows = ""
879
+ if apex_classes['batch_async']:
880
+ for name in apex_classes['batch_async']:
881
+ batch_rows += f"| {name} | | | |\n"
882
+ else:
883
+ batch_rows = "| | | | |\n"
884
+ inventory_content = re.sub(
885
+ r'(### Batch / Queueable / Schedulable\s*\n\s*\|.*?\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n###|\Z)',
886
+ r'\1' + batch_rows,
887
+ inventory_content
888
+ )
889
+
890
+ # Update Utilities
891
+ utility_rows = ""
892
+ if apex_classes['utilities']:
893
+ for name in apex_classes['utilities']:
894
+ utility_rows += f"| {name} | |\n"
895
+ else:
896
+ utility_rows = "| | |\n"
897
+ inventory_content = re.sub(
898
+ r'(### Utilities / Helpers\s*\n\s*\|.*?\|.*?\|\n\s*\|.*?\|.*?\|\n)([\s\S]*?)(?=\n###|\Z)',
899
+ r'\1' + utility_rows,
900
+ inventory_content
901
+ )
902
+
903
+ # Update Test Classes
904
+ test_rows = ""
905
+ if apex_classes['tests']:
906
+ for name in apex_classes['tests']:
907
+ test_rows += f"| {name} | | |\n"
908
+ else:
909
+ test_rows = "| | | |\n"
910
+ inventory_content = re.sub(
911
+ r'(### Test Classes\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)',
912
+ r'\1' + test_rows,
913
+ inventory_content
914
+ )
915
+
916
+ # Update LWC
917
+ lwc_rows = ""
918
+ if lwc_components:
919
+ for name in lwc_components:
920
+ lwc_rows += f"| {name} | | | |\n"
921
+ else:
922
+ lwc_rows = "| | | | |\n"
923
+ inventory_content = re.sub(
924
+ r'(## LWC Components\s*\n\s*<!--.*?-->\s*\n\s*\|.*?\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)',
925
+ r'\1' + lwc_rows,
926
+ inventory_content
927
+ )
928
+
929
+ # Update Flows
930
+ flow_rows = ""
931
+ if flows:
932
+ for name, flow_type, status in flows:
933
+ flow_rows += f"| {name} | {flow_type} | | {status} | |\n"
934
+ else:
935
+ flow_rows = "| | | | | |\n"
936
+ inventory_content = re.sub(
937
+ r'(## Flows\s*\n\s*<!--.*?-->\s*\n\s*\|.*?\|.*?\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)',
938
+ r'\1' + flow_rows,
939
+ inventory_content
940
+ )
941
+
942
+ # Update Permission Sets
943
+ perm_rows = ""
944
+ if permission_sets:
945
+ for name in permission_sets:
946
+ perm_rows += f"| {name} | | |\n"
947
+ else:
948
+ perm_rows = "| | | |\n"
949
+ inventory_content = re.sub(
950
+ r'(## Permission Sets\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)',
951
+ r'\1' + perm_rows,
952
+ inventory_content
953
+ )
954
+
955
+ # Update Scan Info
956
+ scan_info = f"**Date:** {datetime.today().strftime('%Y-%m-%d')}\n**Agent:** Auto-Scan Script\n**Method:** python3 .ai/scripts/update_state.py scan\n"
957
+ inventory_content = re.sub(
958
+ r'(## Last Full Scan\s*\n\s*<!--.*?-->\s*\n)([\s\S]*?)$',
959
+ r'\1' + scan_info,
960
+ inventory_content
961
+ )
962
+
963
+ write_file(INVENTORY_PATH, inventory_content)
964
+ print("inventory.md updated successfully!")
965
+
966
+ # --- CURRENT STATE UPDATING LOGIC ---
967
+ def session_start(agent, goal):
968
+ state_content = read_file(CURRENT_STATE_PATH)
969
+ if not state_content:
970
+ print("Error: current-state.md not found or empty.")
971
+ sys.exit(1)
972
+
973
+ date_str = datetime.today().strftime('%Y-%m-%d')
974
+ new_entry = f"### {date_str} | {agent} | {goal}\n- 🟡 Session started\n- **Files:** _None_\n\n"
975
+
976
+ # Prepend to Session Log section
977
+ match = re.search(r'(## Session Log\s*\n\s*<!--[\s\S]*?-->\n\n)', state_content)
978
+ if match:
979
+ idx = match.end()
980
+ state_content = state_content[:idx] + new_entry + state_content[idx:]
981
+ write_file(CURRENT_STATE_PATH, state_content)
982
+ print("Session start logged in current-state.md")
983
+ else:
984
+ print("Error: Could not locate '## Session Log' in current-state.md")
985
+
986
+ def session_end(summary, files):
987
+ state_content = read_file(CURRENT_STATE_PATH)
988
+ if not state_content:
989
+ print("Error: current-state.md not found.")
990
+ sys.exit(1)
991
+
992
+ date_str = datetime.today().strftime('%Y-%m-%d')
993
+ # Update files changed list in session log
994
+ files_str = files if files else "_None_"
995
+
996
+ # Find the first entry in Session Log
997
+ log_match = re.search(r'(### \d{4}-\d{2}-\d{2} \| [^|]+ \| [^\n]+)\n([\s\S]*?)(?=\n###|\n---|\Z)', state_content)
998
+ if log_match:
999
+ header = log_match.group(1)
1000
+ body = log_match.group(2)
1001
+ new_body = f"- ✅ {summary}\n- **Files:** {files_str}\n"
1002
+ state_content = state_content.replace(header + "\n" + body, header + "\n" + new_body)
1003
+
1004
+ # Update Files Changed Table
1005
+ if files:
1006
+ file_list = [f.strip() for f in files.split(',')]
1007
+ table_rows = ""
1008
+ for file in file_list:
1009
+ table_rows += f"| {file} | Modified | Session update |\n"
1010
+
1011
+ table_match = re.search(r'(## Files Changed This Session\s*\n\s*<!--[\s\S]*?-->\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)', state_content)
1012
+ if table_match:
1013
+ existing = table_match.group(2).strip()
1014
+ if existing and existing != "| | | |":
1015
+ new_table = existing + "\n" + table_rows
1016
+ else:
1017
+ new_table = table_rows
1018
+ state_content = state_content.replace(table_match.group(0), table_match.group(1) + new_table)
1019
+
1020
+ # Clear Files Changed This Session if wanted, or just write
1021
+ write_file(CURRENT_STATE_PATH, state_content)
1022
+ print("Session end logged in current-state.md")
1023
+
1024
+ def task_add(desc, priority, deps):
1025
+ state_content = read_file(CURRENT_STATE_PATH)
1026
+ if not state_content: sys.exit(1)
1027
+
1028
+ task_row = f"- [ ] {desc} | Priority: {priority} | Dependencies: {deps}\n"
1029
+ match = re.search(r'(### Upcoming\s*\n\s*<!--[\s\S]*?-->\n\n)', state_content)
1030
+ if match:
1031
+ idx = match.end()
1032
+ state_content = state_content[:idx] + task_row + state_content[idx:]
1033
+ write_file(CURRENT_STATE_PATH, state_content)
1034
+ print(f"Task added: {desc}")
1035
+ else:
1036
+ print("Error: Could not locate '### Upcoming' section")
1037
+
1038
+ def task_start(desc, branch):
1039
+ state_content = read_file(CURRENT_STATE_PATH)
1040
+ if not state_content: sys.exit(1)
1041
+
1042
+ date_str = datetime.today().strftime('%Y-%m-%d')
1043
+ # Remove from Upcoming
1044
+ lines = state_content.split('\n')
1045
+ new_lines = []
1046
+ found = False
1047
+ for line in lines:
1048
+ if desc in line and '- [ ]' in line:
1049
+ found = True
1050
+ continue
1051
+ new_lines.append(line)
1052
+ state_content = '\n'.join(new_lines)
1053
+
1054
+ # Add to In Progress
1055
+ in_progress_row = f"- [/] {desc} | Branch: `{branch}` | Started: {date_str}\n"
1056
+ match = re.search(r'(### In Progress\s*\n\s*<!--[\s\S]*?-->\n\n)', state_content)
1057
+ if match:
1058
+ idx = match.end()
1059
+ state_content = state_content[:idx] + in_progress_row + state_content[idx:]
1060
+ write_file(CURRENT_STATE_PATH, state_content)
1061
+ print(f"Task started: {desc}")
1062
+ else:
1063
+ print("Error: Could not locate '### In Progress' section")
1064
+
1065
+ def task_complete(desc, files):
1066
+ state_content = read_file(CURRENT_STATE_PATH)
1067
+ if not state_content: sys.exit(1)
1068
+
1069
+ date_str = datetime.today().strftime('%Y-%m-%d')
1070
+ # Remove from In Progress
1071
+ lines = state_content.split('\n')
1072
+ new_lines = []
1073
+ for line in lines:
1074
+ if desc in line and '- [/' in line:
1075
+ continue
1076
+ new_lines.append(line)
1077
+ state_content = '\n'.join(new_lines)
1078
+
1079
+ # Add to Recently Completed
1080
+ completed_row = f"- [x] {desc} | Completed: {date_str} | Files: {files}\n"
1081
+ match = re.search(r'(### Recently Completed\s*\n\s*<!--[\s\S]*?-->\n\n)', state_content)
1082
+ if match:
1083
+ idx = match.end()
1084
+ state_content = state_content[:idx] + completed_row + state_content[idx:]
1085
+ write_file(CURRENT_STATE_PATH, state_content)
1086
+ print(f"Task completed: {desc}")
1087
+ else:
1088
+ print("Error: Could not locate '### Recently Completed' section")
1089
+
1090
+ def decision_add(decision, rationale):
1091
+ state_content = read_file(CURRENT_STATE_PATH)
1092
+ if not state_content: sys.exit(1)
1093
+
1094
+ date_str = datetime.today().strftime('%Y-%m-%d')
1095
+ row = f"| {date_str} | {decision} | {rationale} |\n"
1096
+
1097
+ table_match = re.search(r'(## Decisions\s*\n\s*<!--[\s\S]*?-->\s*\n\s*\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)', state_content)
1098
+ if table_match:
1099
+ existing = table_match.group(2).strip()
1100
+ if existing and existing != "| | | |":
1101
+ new_table = row + existing
1102
+ else:
1103
+ new_table = row
1104
+ state_content = state_content.replace(table_match.group(0), table_match.group(1) + new_table)
1105
+ write_file(CURRENT_STATE_PATH, state_content)
1106
+ print(f"Decision logged: {decision}")
1107
+ else:
1108
+ print("Error: Could not find Decisions section")
1109
+
1110
+ def blocker_add(blocker, impact, severity):
1111
+ state_content = read_file(CURRENT_STATE_PATH)
1112
+ if not state_content: sys.exit(1)
1113
+
1114
+ date_str = datetime.today().strftime('%Y-%m-%d')
1115
+ row = f"| {blocker} | {impact} | {severity} | {date_str} |\n"
1116
+
1117
+ table_match = re.search(r'(## Blockers\s*\n\s*<!--[\s\S]*?-->\s*\n\s*\|.*?\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)', state_content)
1118
+ if table_match:
1119
+ existing = table_match.group(2).strip()
1120
+ if existing and "_None_" not in existing:
1121
+ new_table = row + existing
1122
+ else:
1123
+ new_table = row
1124
+ state_content = state_content.replace(table_match.group(0), table_match.group(1) + new_table)
1125
+ write_file(CURRENT_STATE_PATH, state_content)
1126
+ print(f"Blocker logged: {blocker}")
1127
+ else:
1128
+ print("Error: Could not find Blockers section")
1129
+
1130
+ def blocker_resolve(blocker):
1131
+ state_content = read_file(CURRENT_STATE_PATH)
1132
+ if not state_content: sys.exit(1)
1133
+
1134
+ lines = state_content.split('\n')
1135
+ new_lines = []
1136
+ removed = False
1137
+ for line in lines:
1138
+ if blocker in line and '|' in line:
1139
+ removed = True
1140
+ continue
1141
+ new_lines.append(line)
1142
+ state_content = '\n'.join(new_lines)
1143
+
1144
+ # If table is empty, restore _None_
1145
+ table_match = re.search(r'(## Blockers\s*\n\s*<!--[\s\S]*?-->\s*\n\s*\|.*?\|.*?\|.*?\|.*?\|\n\s*\|.*?\|.*?\|.*?\|.*?\|\n)([\s\S]*?)(?=\n---|\Z)', state_content)
1146
+ if table_match:
1147
+ content = table_match.group(2).strip()
1148
+ if not content:
1149
+ state_content = state_content.replace(table_match.group(0), table_match.group(1) + "| _None_ | | | |\n")
1150
+
1151
+ write_file(CURRENT_STATE_PATH, state_content)
1152
+ if removed:
1153
+ print(f"Blocker resolved: {blocker}")
1154
+ else:
1155
+ print(f"Blocker not found: {blocker}")
1156
+
1157
+ def sanitize_project():
1158
+ print("Sanitizing local and org-specific details for open source sharing...")
1159
+
1160
+ # 1. Delete local org context
1161
+ local_org_path = os.path.join(AI_DIR, 'org-context.local.md')
1162
+ if os.path.exists(local_org_path):
1163
+ try:
1164
+ os.remove(local_org_path)
1165
+ print(f"Removed local file: {local_org_path}")
1166
+ except Exception as e:
1167
+ print(f"Error removing {local_org_path}: {e}")
1168
+
1169
+ # 2. Clear metadata-cache directory
1170
+ cache_dir = os.path.join(AI_DIR, 'metadata-cache')
1171
+ if os.path.exists(cache_dir):
1172
+ try:
1173
+ for f in glob.glob(os.path.join(cache_dir, '*')):
1174
+ if os.path.isfile(f):
1175
+ os.remove(f)
1176
+ os.rmdir(cache_dir)
1177
+ print("Cleared metadata cache.")
1178
+ except Exception as e:
1179
+ print(f"Error clearing metadata cache: {e}")
1180
+
1181
+ # 3. Reset current-state.md to clean template
1182
+ if os.path.exists(CURRENT_STATE_PATH):
1183
+ try:
1184
+ default_current_state = """# Current State
1185
+
1186
+ > 🤖 **AUTO-UPDATED BY AI AGENTS** — This file is the project's living memory.
1187
+ > Agents MUST update this file as they work (see `rules.md` for protocol).
1188
+ > Humans: review periodically, correct if needed.
1189
+
1190
+ ---
1191
+
1192
+ ## Project Status
1193
+
1194
+ **Last Updated:** 2026-05-22
1195
+ **Sprint/Phase:** [Update with current sprint]
1196
+ **Overall Status:** 🟢 On Track
1197
+
1198
+ ---
1199
+
1200
+ ## Active Work
1201
+
1202
+ ### In Progress
1203
+
1204
+
1205
+ ### Upcoming
1206
+
1207
+
1208
+ ### Recently Completed
1209
+
1210
+
1211
+ ---
1212
+
1213
+ ## Blockers
1214
+
1215
+ | Blocker | Impact | Severity | Added |
1216
+ |---------|--------|----------|-------|
1217
+ | _None_ | | | |
1218
+
1219
+ ---
1220
+
1221
+ ## Decisions
1222
+
1223
+ | Date | Decision | Rationale |
1224
+ |------|----------|-----------|
1225
+ | | | |
1226
+
1227
+ ---
1228
+
1229
+ ## Files Changed This Session
1230
+
1231
+ | File | Action | Notes |
1232
+ |------|--------|-------|
1233
+ | | | |
1234
+
1235
+ ---
1236
+
1237
+ ## Environment State
1238
+
1239
+ | Environment | Last Deploy | Status | Notes |
1240
+ |-------------|-----------|--------|-------|
1241
+ | Dev Sandbox | — | 🟢 | |
1242
+ | QA Sandbox | — | 🟢 | |
1243
+ | UAT Sandbox | — | 🟢 | |
1244
+ | Production | — | 🟢 | |
1245
+
1246
+ ---
1247
+
1248
+ ## Governor Limit Watch
1249
+
1250
+ | Limit | Current Usage | Max | Risk |
1251
+ |-------|-------------|-----|------|
1252
+ | API Calls (Daily) | 0 | 15,000 | 🟢 |
1253
+ | Data Storage | 0 MB | 5 MB | 🟢 |
1254
+ | File Storage | 0 MB | 20 MB | 🟢 |
1255
+
1256
+ ---
1257
+
1258
+ ## Session Log
1259
+
1260
+ <!-- Agent: Prepend a new entry at the TOP of this section every session.
1261
+ Use the format below. This is the project's history. NEVER delete old entries.
1262
+
1263
+ ### YYYY-MM-DD | Agent Name | Session Goal
1264
+ - ✅ What was completed
1265
+ - 🟡 What's partially done
1266
+ - ❌ What failed and why
1267
+ - 💡 Key insight or decision
1268
+ - **Files:** comma-separated list of files touched
1269
+ -->
1270
+
1271
+ ### [First session entry will appear here]
1272
+
1273
+ _No sessions recorded yet. The AI agent will begin logging here automatically._
1274
+ """
1275
+ write_file(CURRENT_STATE_PATH, default_current_state)
1276
+ print("Reset current-state.md to clean template.")
1277
+ except Exception as e:
1278
+ print(f"Error resetting current-state.md: {e}")
1279
+
1280
+ # 4. Clean inventory.md Last Full Scan
1281
+ if os.path.exists(INVENTORY_PATH):
1282
+ try:
1283
+ inventory_content = read_file(INVENTORY_PATH)
1284
+ if inventory_content:
1285
+ # Reset last scan info to generic details
1286
+ scan_info = "**Date:** 2026-05-22\n**Agent:** Auto-Scan Script\n**Method:** python3 .ai/scripts/update_state.py scan\n"
1287
+ inventory_content = re.sub(
1288
+ r'(## Last Full Scan\s*\n\s*<!--.*?-->\s*\n)([\s\S]*?)$',
1289
+ r'\1' + scan_info,
1290
+ inventory_content
1291
+ )
1292
+ write_file(INVENTORY_PATH, inventory_content)
1293
+ print("Reset inventory.md last scan details.")
1294
+ except Exception as e:
1295
+ print(f"Error resetting inventory.md: {e}")
1296
+
1297
+ print("Sanitization complete! Repository is safe to commit and share.")
1298
+
1299
+ def main():
1300
+ if len(sys.argv) < 2:
1301
+ print_help()
1302
+ sys.exit(1)
1303
+
1304
+ cmd = sys.argv[1]
1305
+
1306
+ if cmd == 'scan':
1307
+ scan_inventory()
1308
+ elif cmd == 'check':
1309
+ files_arg = None
1310
+ args = sys.argv[2:]
1311
+ for i in range(len(args)):
1312
+ if args[i] == '--files' and i + 1 < len(args):
1313
+ files_arg = args[i+1]
1314
+ check_all_files(files_arg)
1315
+ elif cmd == 'verify':
1316
+ meta_type = None
1317
+ meta_name = None
1318
+ obj_name = None
1319
+ target_org = None
1320
+ force = False
1321
+ args = sys.argv[2:]
1322
+ for i in range(len(args)):
1323
+ if args[i] == '--type' and i + 1 < len(args): meta_type = args[i+1]
1324
+ if args[i] == '--name' and i + 1 < len(args): meta_name = args[i+1]
1325
+ if args[i] == '--object' and i + 1 < len(args): obj_name = args[i+1]
1326
+ if args[i] == '--target-org' and i + 1 < len(args): target_org = args[i+1]
1327
+ if args[i] == '--force': force = True
1328
+ verify_metadata(meta_type, meta_name, obj_name, target_org, force)
1329
+ elif cmd == 'cache-schema':
1330
+ target_org = None
1331
+ args = sys.argv[2:]
1332
+ for i in range(len(args)):
1333
+ if args[i] == '--target-org' and i + 1 < len(args): target_org = args[i+1]
1334
+ cache_schema(target_org)
1335
+ elif cmd == 'sync-org':
1336
+ target_org = None
1337
+ args = sys.argv[2:]
1338
+ for i in range(len(args)):
1339
+ if args[i] == '--target-org' and i + 1 < len(args): target_org = args[i+1]
1340
+ sync_org(target_org)
1341
+ elif cmd == 'session-start':
1342
+ agent = 'Agent'
1343
+ goal = ''
1344
+ args = sys.argv[2:]
1345
+ for i in range(len(args)):
1346
+ if args[i] == '--agent' and i + 1 < len(args): agent = args[i+1]
1347
+ if args[i] == '--goal' and i + 1 < len(args): goal = args[i+1]
1348
+ session_start(agent, goal)
1349
+ elif cmd == 'session-end':
1350
+ summary = ''
1351
+ files = ''
1352
+ args = sys.argv[2:]
1353
+ for i in range(len(args)):
1354
+ if args[i] == '--summary' and i + 1 < len(args): summary = args[i+1]
1355
+ if args[i] == '--files' and i + 1 < len(args): files = args[i+1]
1356
+ session_end(summary, files)
1357
+ elif cmd == 'task-add':
1358
+ desc = sys.argv[2]
1359
+ priority = 'P1'
1360
+ deps = 'none'
1361
+ args = sys.argv[3:]
1362
+ for i in range(len(args)):
1363
+ if args[i] == '--priority' and i + 1 < len(args): priority = args[i+1]
1364
+ if args[i] == '--deps' and i + 1 < len(args): deps = args[i+1]
1365
+ task_add(desc, priority, deps)
1366
+ elif cmd == 'task-start':
1367
+ desc = sys.argv[2]
1368
+ branch = 'main'
1369
+ args = sys.argv[3:]
1370
+ for i in range(len(args)):
1371
+ if args[i] == '--branch' and i + 1 < len(args): branch = args[i+1]
1372
+ task_start(desc, branch)
1373
+ elif cmd == 'task-complete':
1374
+ desc = sys.argv[2]
1375
+ files = 'none'
1376
+ args = sys.argv[3:]
1377
+ for i in range(len(args)):
1378
+ if args[i] == '--files' and i + 1 < len(args): files = args[i+1]
1379
+ task_complete(desc, files)
1380
+ elif cmd == 'decision':
1381
+ decision = sys.argv[2]
1382
+ rationale = ''
1383
+ args = sys.argv[3:]
1384
+ for i in range(len(args)):
1385
+ if args[i] == '--rationale' and i + 1 < len(args): rationale = args[i+1]
1386
+ decision_add(decision, rationale)
1387
+ elif cmd == 'blocker':
1388
+ blocker = sys.argv[2]
1389
+ impact = ''
1390
+ severity = 'Medium'
1391
+ args = sys.argv[3:]
1392
+ for i in range(len(args)):
1393
+ if args[i] == '--impact' and i + 1 < len(args): impact = args[i+1]
1394
+ if args[i] == '--severity' and i + 1 < len(args): severity = args[i+1]
1395
+ blocker_add(blocker, impact, severity)
1396
+ elif cmd == 'blocker-resolve':
1397
+ blocker = sys.argv[2]
1398
+ blocker_resolve(blocker)
1399
+ elif cmd == 'clean':
1400
+ sanitize_project()
1401
+ else:
1402
+ print_help()
1403
+ sys.exit(1)
1404
+
1405
+ if __name__ == '__main__':
1406
+ main()