loki-mode 6.77.2 → 6.80.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +34 -0
- package/docs/INSTALLATION.md +1 -1
- package/docs/architecture/STATE-MACHINES.md +10 -10
- package/magic/__init__.py +7 -0
- package/magic/core/__init__.py +0 -0
- package/magic/core/debate.py +781 -0
- package/magic/core/design_tokens.py +469 -0
- package/magic/core/freshness.py +86 -0
- package/magic/core/generator.py +755 -0
- package/magic/core/memory_bridge.py +220 -0
- package/magic/core/prd_scanner.py +265 -0
- package/magic/core/registry.py +340 -0
- package/magic/core/spec.py +337 -0
- package/magic/debate/personas/a11y.md +95 -0
- package/magic/debate/personas/conservative.md +83 -0
- package/magic/debate/personas/creative.md +73 -0
- package/magic/debate/personas/performance.md +93 -0
- package/magic/registry/schema.json +38 -0
- package/magic/testing/__init__.py +0 -0
- package/magic/testing/snapshot.py +224 -0
- package/magic/testing/test_generator.py +453 -0
- package/magic/tokens/README.md +83 -0
- package/magic/tokens/defaults.json +59 -0
- package/mcp/__init__.py +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Memory bridge for Magic Modules.
|
|
2
|
+
|
|
3
|
+
Feeds component-generation outcomes into Loki's memory system so agents
|
|
4
|
+
benefit from prior work across iterations and projects:
|
|
5
|
+
|
|
6
|
+
- Episodic memory: component generation events with debate results
|
|
7
|
+
- Semantic memory: stable tag clusters from repeated successful generations
|
|
8
|
+
- Retrieval: pull similar past generations during REASON phase
|
|
9
|
+
|
|
10
|
+
The memory system may or may not be available; every function here degrades
|
|
11
|
+
gracefully if memory imports fail.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _try_import_memory():
|
|
22
|
+
"""Attempt to import the memory subsystem. Returns (engine_cls, trace_cls, pattern_cls, err)."""
|
|
23
|
+
try:
|
|
24
|
+
from memory.engine import MemoryEngine
|
|
25
|
+
from memory.schemas import EpisodeTrace, SemanticPattern
|
|
26
|
+
return MemoryEngine, EpisodeTrace, SemanticPattern, None
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
return None, None, None, str(exc)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_registry(project_dir: str) -> Optional[dict]:
|
|
32
|
+
reg = Path(project_dir) / ".loki" / "magic" / "registry.json"
|
|
33
|
+
if not reg.exists():
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(reg.read_text())
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _engine_for(project_dir: str, MemoryEngine):
|
|
42
|
+
"""Instantiate MemoryEngine rooted at project_dir/.loki/memory."""
|
|
43
|
+
base = str(Path(project_dir) / ".loki" / "memory")
|
|
44
|
+
return MemoryEngine(base_path=base)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def capture_component_generation(
|
|
48
|
+
project_dir: str,
|
|
49
|
+
component_name: str,
|
|
50
|
+
spec_path: str,
|
|
51
|
+
targets: list,
|
|
52
|
+
debate_result: Optional[dict] = None,
|
|
53
|
+
iteration: int = 0,
|
|
54
|
+
duration_seconds: float = 0,
|
|
55
|
+
) -> dict:
|
|
56
|
+
"""Record an episode for a single component generation event.
|
|
57
|
+
|
|
58
|
+
Returns summary dict {stored: bool, reason: str, episode_id: str?}.
|
|
59
|
+
"""
|
|
60
|
+
ME, EpisodeTrace, _SemPat, err = _try_import_memory()
|
|
61
|
+
if ME is None:
|
|
62
|
+
return {"stored": False, "reason": f"memory unavailable: {err}"}
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
engine = _engine_for(project_dir, ME)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
return {"stored": False, "reason": f"engine init failed: {exc}"}
|
|
68
|
+
|
|
69
|
+
# Build episode content
|
|
70
|
+
debate_summary = ""
|
|
71
|
+
if debate_result:
|
|
72
|
+
c_count = len(debate_result.get("critiques", []))
|
|
73
|
+
consensus = debate_result.get("consensus", False)
|
|
74
|
+
blocks = len(debate_result.get("blocks", []))
|
|
75
|
+
debate_summary = (
|
|
76
|
+
f" Debate: {c_count} personas, consensus={consensus}, "
|
|
77
|
+
f"blocks={blocks}"
|
|
78
|
+
)
|
|
79
|
+
goal = (
|
|
80
|
+
f"Generate magic component '{component_name}' "
|
|
81
|
+
f"(targets={','.join(targets) if targets else 'unknown'})."
|
|
82
|
+
f"{debate_summary}"
|
|
83
|
+
)
|
|
84
|
+
outcome = "success"
|
|
85
|
+
if debate_result and debate_result.get("blocks"):
|
|
86
|
+
outcome = "failure"
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
ep_id = f"magic-{component_name.lower()}-{uuid.uuid4().hex[:8]}"
|
|
90
|
+
trace = EpisodeTrace(
|
|
91
|
+
id=ep_id,
|
|
92
|
+
task_id=f"magic-gen-{component_name}",
|
|
93
|
+
timestamp=datetime.now(timezone.utc),
|
|
94
|
+
duration_seconds=int(max(0, duration_seconds)),
|
|
95
|
+
agent="magic",
|
|
96
|
+
phase="ACT",
|
|
97
|
+
goal=goal,
|
|
98
|
+
outcome=outcome,
|
|
99
|
+
artifacts_produced=[spec_path] if spec_path else [],
|
|
100
|
+
files_modified=[spec_path] if spec_path else [],
|
|
101
|
+
importance=0.6 if outcome == "success" else 0.8,
|
|
102
|
+
)
|
|
103
|
+
stored_id = engine.store_episode(trace)
|
|
104
|
+
return {"stored": True, "reason": "episode saved", "episode_id": stored_id}
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
return {"stored": False, "reason": f"store failed: {exc}"}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def capture_iteration_compound(project_dir: str, iteration: int = 0) -> dict:
|
|
110
|
+
"""Called in COMPOUND phase: aggregate component stats and record semantic pattern.
|
|
111
|
+
|
|
112
|
+
Reads registry, computes per-tag pass rates, and stores as semantic memory
|
|
113
|
+
for tag clusters that hit the stability threshold.
|
|
114
|
+
"""
|
|
115
|
+
data = _load_registry(project_dir)
|
|
116
|
+
if not data:
|
|
117
|
+
return {"recorded": False, "reason": "no registry"}
|
|
118
|
+
|
|
119
|
+
components = data.get("components", [])
|
|
120
|
+
if isinstance(components, dict):
|
|
121
|
+
components = [{"name": k, **(v or {})} for k, v in components.items()]
|
|
122
|
+
|
|
123
|
+
if not components:
|
|
124
|
+
return {"recorded": False, "reason": "no components"}
|
|
125
|
+
|
|
126
|
+
# Build per-tag pass rate
|
|
127
|
+
tag_stats = {}
|
|
128
|
+
for c in components:
|
|
129
|
+
tags = c.get("tags", []) or []
|
|
130
|
+
debate_passed = bool(c.get("debate_passed"))
|
|
131
|
+
for tag in tags:
|
|
132
|
+
bucket = tag_stats.setdefault(tag, {"total": 0, "passed": 0})
|
|
133
|
+
bucket["total"] += 1
|
|
134
|
+
if debate_passed:
|
|
135
|
+
bucket["passed"] += 1
|
|
136
|
+
|
|
137
|
+
# Derive patterns: tags with >=3 components and >=80% pass rate are "stable"
|
|
138
|
+
stable_tags = [
|
|
139
|
+
t for t, s in tag_stats.items()
|
|
140
|
+
if s["total"] >= 3 and s["passed"] / s["total"] >= 0.8
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
ME, _Ep, SemanticPattern, err = _try_import_memory()
|
|
144
|
+
if ME is None:
|
|
145
|
+
return {
|
|
146
|
+
"recorded": False,
|
|
147
|
+
"reason": f"memory unavailable: {err}",
|
|
148
|
+
"stable_tags": stable_tags,
|
|
149
|
+
"tag_stats": tag_stats,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
engine = _engine_for(project_dir, ME)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
return {"recorded": False, "reason": f"engine init failed: {exc}"}
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
stored = []
|
|
159
|
+
for tag in stable_tags:
|
|
160
|
+
s = tag_stats[tag]
|
|
161
|
+
pct = s["passed"] / s["total"]
|
|
162
|
+
pattern = SemanticPattern(
|
|
163
|
+
id=f"sem-magic-{tag}-{uuid.uuid4().hex[:6]}",
|
|
164
|
+
pattern=(
|
|
165
|
+
f"Magic components tagged '{tag}' pass debate reliably "
|
|
166
|
+
f"({s['passed']}/{s['total']}, {pct:.0%})."
|
|
167
|
+
),
|
|
168
|
+
category="magic-components",
|
|
169
|
+
conditions=[f"component has tag '{tag}'"],
|
|
170
|
+
correct_approach=(
|
|
171
|
+
f"Follow prior successful '{tag}' specs; accessibility "
|
|
172
|
+
f"and design-token usage have been consistently present."
|
|
173
|
+
),
|
|
174
|
+
confidence=min(0.95, 0.5 + pct * 0.5),
|
|
175
|
+
)
|
|
176
|
+
stored.append(engine.store_pattern(pattern))
|
|
177
|
+
return {
|
|
178
|
+
"recorded": True,
|
|
179
|
+
"stable_tags": stable_tags,
|
|
180
|
+
"component_count": len(components),
|
|
181
|
+
"patterns_stored": stored,
|
|
182
|
+
}
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
return {"recorded": False, "reason": f"store failed: {exc}"}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def recall_similar_components(project_dir: str, tags: list = None, query: str = "") -> list:
|
|
188
|
+
"""Query memory for similar prior components. Used at REASON phase.
|
|
189
|
+
|
|
190
|
+
Returns list of remembered components (possibly empty).
|
|
191
|
+
"""
|
|
192
|
+
ME, _Ep, _SemPat, _err = _try_import_memory()
|
|
193
|
+
if ME is None:
|
|
194
|
+
return []
|
|
195
|
+
try:
|
|
196
|
+
engine = _engine_for(project_dir, ME)
|
|
197
|
+
context = {
|
|
198
|
+
"goal": query or "magic component generation",
|
|
199
|
+
"tags": tags or ["magic"],
|
|
200
|
+
"phase": "REASON",
|
|
201
|
+
}
|
|
202
|
+
results = engine.retrieve_relevant(context=context, top_k=5) or []
|
|
203
|
+
return [r if isinstance(r, dict) else {"content": str(r)} for r in results]
|
|
204
|
+
except Exception:
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _format_tag_stats(stats: dict, limit: int = 8) -> str:
|
|
209
|
+
items = sorted(stats.items(), key=lambda x: x[1]["total"], reverse=True)[:limit]
|
|
210
|
+
parts = []
|
|
211
|
+
for tag, s in items:
|
|
212
|
+
pct = (s["passed"] / s["total"] * 100.0) if s["total"] else 0.0
|
|
213
|
+
parts.append(f"{tag}={s['passed']}/{s['total']} ({pct:.0f}%)")
|
|
214
|
+
return ", ".join(parts) if parts else "none"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
import sys
|
|
219
|
+
project = sys.argv[1] if len(sys.argv) > 1 else "."
|
|
220
|
+
print(json.dumps(capture_iteration_compound(project), indent=2))
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""PRD scanner for Magic Modules.
|
|
2
|
+
|
|
3
|
+
Runs during the REASON phase of a build. Reads the PRD, detects
|
|
4
|
+
UI component mentions (Button, Modal, Form, Table, Card, Nav, etc.),
|
|
5
|
+
and creates bare-minimum markdown specs at .loki/magic/specs/<Name>.md.
|
|
6
|
+
|
|
7
|
+
The generated specs are stubs -- placeholder for agents to refine
|
|
8
|
+
during the ACT phase. The goal is to make component needs explicit
|
|
9
|
+
and trackable from iteration 1, not discovered mid-build.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
# Component keywords to detect in PRDs. Map lowercase keyword -> default PascalCase name.
|
|
17
|
+
UI_COMPONENT_VOCAB = {
|
|
18
|
+
"button": "Button",
|
|
19
|
+
"modal": "Modal",
|
|
20
|
+
"dialog": "Dialog",
|
|
21
|
+
"form": "Form",
|
|
22
|
+
"input": "Input",
|
|
23
|
+
"textarea": "Textarea",
|
|
24
|
+
"select": "Select",
|
|
25
|
+
"dropdown": "Dropdown",
|
|
26
|
+
"checkbox": "Checkbox",
|
|
27
|
+
"radio": "Radio",
|
|
28
|
+
"toggle": "Toggle",
|
|
29
|
+
"switch": "Switch",
|
|
30
|
+
"slider": "Slider",
|
|
31
|
+
"table": "Table",
|
|
32
|
+
"list": "List",
|
|
33
|
+
"card": "Card",
|
|
34
|
+
"tile": "Tile",
|
|
35
|
+
"navigation": "Navigation",
|
|
36
|
+
"navbar": "Navbar",
|
|
37
|
+
"sidebar": "Sidebar",
|
|
38
|
+
"header": "Header",
|
|
39
|
+
"footer": "Footer",
|
|
40
|
+
"tabs": "Tabs",
|
|
41
|
+
"accordion": "Accordion",
|
|
42
|
+
"badge": "Badge",
|
|
43
|
+
"avatar": "Avatar",
|
|
44
|
+
"toast": "Toast",
|
|
45
|
+
"notification": "Notification",
|
|
46
|
+
"tooltip": "Tooltip",
|
|
47
|
+
"popover": "Popover",
|
|
48
|
+
"menu": "Menu",
|
|
49
|
+
"breadcrumb": "Breadcrumb",
|
|
50
|
+
"pagination": "Pagination",
|
|
51
|
+
"progress bar": "ProgressBar",
|
|
52
|
+
"spinner": "Spinner",
|
|
53
|
+
"loader": "Loader",
|
|
54
|
+
"chart": "Chart",
|
|
55
|
+
"graph": "Graph",
|
|
56
|
+
"search bar": "SearchBar",
|
|
57
|
+
"filter": "FilterPanel",
|
|
58
|
+
"login": "LoginForm",
|
|
59
|
+
"signup": "SignupForm",
|
|
60
|
+
"profile": "ProfileCard",
|
|
61
|
+
"dashboard": "Dashboard",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Patterns that strongly suggest component intent (vs incidental mentions)
|
|
65
|
+
INTENT_MARKERS = [
|
|
66
|
+
r"\badd\s+(?:a|an|the)\s+",
|
|
67
|
+
r"\bbuild\s+(?:a|an|the)\s+",
|
|
68
|
+
r"\bcreate\s+(?:a|an|the)\s+",
|
|
69
|
+
r"\bneed\s+(?:a|an|the)\s+",
|
|
70
|
+
r"\bwith\s+(?:a|an|the)\s+",
|
|
71
|
+
r"\binclude\s+(?:a|an|the)\s+",
|
|
72
|
+
r"\bshould\s+have\s+(?:a|an|the)\s+",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
INTENT_RE = re.compile("|".join(INTENT_MARKERS), re.IGNORECASE)
|
|
76
|
+
|
|
77
|
+
STUB_TEMPLATE = """# {name}
|
|
78
|
+
|
|
79
|
+
## Description
|
|
80
|
+
Auto-seeded by the PRD scanner from phrase: "{evidence}".
|
|
81
|
+
Agents: refine this spec during the REASON/ACT phases. Fill in props,
|
|
82
|
+
behavior, visual details, and accessibility requirements based on the
|
|
83
|
+
PRD context. The spec is the source of truth; implementation regenerates
|
|
84
|
+
from it.
|
|
85
|
+
|
|
86
|
+
## Props
|
|
87
|
+
- (to be determined by agent based on PRD context)
|
|
88
|
+
|
|
89
|
+
## Behavior
|
|
90
|
+
(to be determined)
|
|
91
|
+
|
|
92
|
+
## Visual / Styling
|
|
93
|
+
(to be determined -- use design tokens from .loki/magic/tokens.json)
|
|
94
|
+
|
|
95
|
+
## Accessibility
|
|
96
|
+
- Keyboard navigation: (to be determined)
|
|
97
|
+
- Screen reader: (to be determined)
|
|
98
|
+
- Focus management: (to be determined)
|
|
99
|
+
|
|
100
|
+
## Examples
|
|
101
|
+
```tsx
|
|
102
|
+
<{name} />
|
|
103
|
+
```
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def scan_prd(prd_text: str, limit: int = 20) -> list:
|
|
108
|
+
"""Scan a PRD and return a list of detected component intents.
|
|
109
|
+
|
|
110
|
+
Returns list of dicts: {name, keyword, evidence, confidence}
|
|
111
|
+
Confidence is 'high' when intent marker + keyword co-occur in same sentence,
|
|
112
|
+
'medium' for keyword alone, 'low' for case-sensitive matches only.
|
|
113
|
+
"""
|
|
114
|
+
if not prd_text:
|
|
115
|
+
return []
|
|
116
|
+
detected = []
|
|
117
|
+
seen_names = set()
|
|
118
|
+
|
|
119
|
+
# Split into sentences (approximate)
|
|
120
|
+
sentences = re.split(r"[.!?\n]+", prd_text)
|
|
121
|
+
for sent in sentences:
|
|
122
|
+
sent_clean = sent.strip()
|
|
123
|
+
if not sent_clean:
|
|
124
|
+
continue
|
|
125
|
+
sent_lower = sent_clean.lower()
|
|
126
|
+
has_intent = bool(INTENT_RE.search(sent_clean))
|
|
127
|
+
|
|
128
|
+
for keyword, default_name in UI_COMPONENT_VOCAB.items():
|
|
129
|
+
if keyword not in sent_lower:
|
|
130
|
+
continue
|
|
131
|
+
# Try to extract a more specific name from context, e.g.
|
|
132
|
+
# "add a Submit button" -> "SubmitButton"
|
|
133
|
+
name = _extract_compound_name(sent_clean, keyword, default_name)
|
|
134
|
+
if name in seen_names:
|
|
135
|
+
continue
|
|
136
|
+
seen_names.add(name)
|
|
137
|
+
confidence = "high" if has_intent else "medium"
|
|
138
|
+
detected.append({
|
|
139
|
+
"name": name,
|
|
140
|
+
"keyword": keyword,
|
|
141
|
+
"evidence": sent_clean[:200],
|
|
142
|
+
"confidence": confidence,
|
|
143
|
+
})
|
|
144
|
+
if len(detected) >= limit:
|
|
145
|
+
return detected
|
|
146
|
+
return detected
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extract_compound_name(sentence: str, keyword: str, default: str) -> str:
|
|
150
|
+
"""Try to derive a PascalCase compound name from context.
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
"add a submit button" + "button" -> "SubmitButton"
|
|
154
|
+
"create a user profile card" + "card" -> "UserProfileCard"
|
|
155
|
+
"the search bar" + "search bar" -> "SearchBar"
|
|
156
|
+
Falls back to the default PascalCase form of the keyword.
|
|
157
|
+
"""
|
|
158
|
+
# Find the keyword position
|
|
159
|
+
low = sentence.lower()
|
|
160
|
+
idx = low.find(keyword)
|
|
161
|
+
if idx < 0:
|
|
162
|
+
return default
|
|
163
|
+
# Look at 1-3 words before the keyword
|
|
164
|
+
before = sentence[:idx].strip()
|
|
165
|
+
tokens = re.findall(r"[A-Za-z]+", before)
|
|
166
|
+
# Filter out stop words. Verb forms of intent markers ("includes",
|
|
167
|
+
# "contains", "built", etc.) are stops -- otherwise phrases like
|
|
168
|
+
# "dashboard includes navigation" produce "DashboardIncludesNavigation".
|
|
169
|
+
stop = {
|
|
170
|
+
"a", "an", "the", "with", "and", "or", "of", "for", "to", "in",
|
|
171
|
+
"on", "at", "by", "from", "into", "as",
|
|
172
|
+
"add", "adds", "added", "adding",
|
|
173
|
+
"build", "builds", "built", "building",
|
|
174
|
+
"create", "creates", "created", "creating",
|
|
175
|
+
"need", "needs", "needed", "needing",
|
|
176
|
+
"include", "includes", "included", "including",
|
|
177
|
+
"contain", "contains", "contained", "containing",
|
|
178
|
+
"have", "has", "had", "having",
|
|
179
|
+
"require", "requires", "required", "requiring",
|
|
180
|
+
"should", "must", "will", "can", "may",
|
|
181
|
+
"our", "my", "their", "its", "this", "that", "these", "those",
|
|
182
|
+
"new", "simple", "basic", "complex", "main", "primary", "user",
|
|
183
|
+
"also", "plus", "along",
|
|
184
|
+
}
|
|
185
|
+
# Another UI component keyword in the modifier slot means we're spanning
|
|
186
|
+
# two separate components (e.g. "navigation sidebar search bar" produces
|
|
187
|
+
# name "NavigationSidebarSearchBar"). Stop before that token.
|
|
188
|
+
other_component_keywords = {
|
|
189
|
+
k.replace(" ", "") for k in UI_COMPONENT_VOCAB.keys() if k != keyword
|
|
190
|
+
} | set(UI_COMPONENT_VOCAB.keys())
|
|
191
|
+
# Take last 1-2 non-stop, non-component tokens
|
|
192
|
+
modifiers = []
|
|
193
|
+
for tok in reversed(tokens):
|
|
194
|
+
tok_low = tok.lower()
|
|
195
|
+
if tok_low in stop:
|
|
196
|
+
if modifiers:
|
|
197
|
+
break
|
|
198
|
+
continue
|
|
199
|
+
if tok_low in other_component_keywords:
|
|
200
|
+
# Another distinct component name -- stop scanning modifiers.
|
|
201
|
+
break
|
|
202
|
+
modifiers.append(tok)
|
|
203
|
+
if len(modifiers) >= 2:
|
|
204
|
+
break
|
|
205
|
+
modifiers.reverse()
|
|
206
|
+
if not modifiers:
|
|
207
|
+
return default
|
|
208
|
+
# PascalCase the modifiers + default
|
|
209
|
+
parts = [m.capitalize() for m in modifiers] + [default]
|
|
210
|
+
# Deduplicate adjacent parts (e.g. "UserUser")
|
|
211
|
+
out = [parts[0]]
|
|
212
|
+
for p in parts[1:]:
|
|
213
|
+
if p != out[-1]:
|
|
214
|
+
out.append(p)
|
|
215
|
+
name = "".join(out)
|
|
216
|
+
# Validate against name regex
|
|
217
|
+
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", name):
|
|
218
|
+
return default
|
|
219
|
+
return name
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def seed_specs(detected: list, project_dir: str = ".", overwrite: bool = False) -> list:
|
|
223
|
+
"""Write stub specs to .loki/magic/specs/. Returns list of paths written.
|
|
224
|
+
|
|
225
|
+
If overwrite=False (default), existing specs are never replaced.
|
|
226
|
+
"""
|
|
227
|
+
specs_dir = Path(project_dir) / ".loki" / "magic" / "specs"
|
|
228
|
+
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
written = []
|
|
230
|
+
for item in detected:
|
|
231
|
+
if item.get("confidence") == "low":
|
|
232
|
+
continue
|
|
233
|
+
name = item["name"]
|
|
234
|
+
spec_path = specs_dir / f"{name}.md"
|
|
235
|
+
if spec_path.exists() and not overwrite:
|
|
236
|
+
continue
|
|
237
|
+
stub = STUB_TEMPLATE.format(name=name, evidence=item["evidence"])
|
|
238
|
+
spec_path.write_text(stub)
|
|
239
|
+
written.append(str(spec_path))
|
|
240
|
+
return written
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def scan_and_seed(prd_text: str, project_dir: str = ".", overwrite: bool = False) -> dict:
|
|
244
|
+
"""Convenience: scan PRD, seed specs, return summary dict."""
|
|
245
|
+
detected = scan_prd(prd_text)
|
|
246
|
+
written = seed_specs(detected, project_dir=project_dir, overwrite=overwrite)
|
|
247
|
+
return {
|
|
248
|
+
"detected_count": len(detected),
|
|
249
|
+
"detected": detected,
|
|
250
|
+
"seeded_count": len(written),
|
|
251
|
+
"seeded_paths": written,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
import sys
|
|
257
|
+
import json
|
|
258
|
+
if len(sys.argv) < 2:
|
|
259
|
+
print("Usage: python -m magic.core.prd_scanner <PRD_PATH> [project_dir]")
|
|
260
|
+
sys.exit(1)
|
|
261
|
+
prd_path = sys.argv[1]
|
|
262
|
+
project = sys.argv[2] if len(sys.argv) > 2 else "."
|
|
263
|
+
prd_text = Path(prd_path).read_text()
|
|
264
|
+
result = scan_and_seed(prd_text, project_dir=project)
|
|
265
|
+
print(json.dumps(result, indent=2))
|