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.
- package/License +21 -0
- package/README.md +106 -0
- package/bin/cli.js +85 -0
- package/package.json +44 -0
- package/templates/ai-dir/README.md +155 -0
- package/templates/ai-dir/agentforce.md +213 -0
- package/templates/ai-dir/architecture.md +123 -0
- package/templates/ai-dir/commands.md +276 -0
- package/templates/ai-dir/context-snapshots/TEMPLATE.md +64 -0
- package/templates/ai-dir/conventions.md +242 -0
- package/templates/ai-dir/current-state.md +113 -0
- package/templates/ai-dir/debugging-notes.md +165 -0
- package/templates/ai-dir/deployment.md +161 -0
- package/templates/ai-dir/integrations.md +199 -0
- package/templates/ai-dir/inventory.md +209 -0
- package/templates/ai-dir/known-issues.md +124 -0
- package/templates/ai-dir/org-context.md +110 -0
- package/templates/ai-dir/performance.md +312 -0
- package/templates/ai-dir/prompts/agentforce.md +163 -0
- package/templates/ai-dir/prompts/apex.md +165 -0
- package/templates/ai-dir/prompts/flows.md +125 -0
- package/templates/ai-dir/prompts/lwc.md +230 -0
- package/templates/ai-dir/prompts/security.md +181 -0
- package/templates/ai-dir/prompts/testing.md +269 -0
- package/templates/ai-dir/rules.md +238 -0
- package/templates/ai-dir/scripts/update_state.py +1406 -0
- package/templates/ai-dir/source-of-truth.md +180 -0
- package/templates/ai-dir/templates/Selector.cls +113 -0
- package/templates/ai-dir/templates/Service.cls +132 -0
- package/templates/ai-dir/templates/TestClass.cls +143 -0
- package/templates/ai-dir/templates/TriggerHandler.cls +67 -0
- 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()
|