arkaos 4.20.0 → 4.22.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/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +2 -1
- package/config/claude-agents/marta-cqo.md +9 -0
- package/config/standards/{claude-md-overlays → stack-rules}/laravel.md +11 -0
- package/config/standards/{claude-md-overlays → stack-rules}/node.md +10 -1
- package/config/standards/{claude-md-overlays → stack-rules}/nuxt.md +12 -1
- package/config/standards/stack-rules/php.md +13 -0
- package/config/standards/{claude-md-overlays → stack-rules}/python.md +5 -0
- package/config/standards/stack-rules/react.md +14 -0
- package/config/standards/stack-rules/vue.md +13 -0
- package/core/cognition/evolve.py +299 -0
- package/core/cognition/evolve_cli.py +55 -0
- package/core/governance/stop_lint.py +266 -0
- package/core/governance/stop_lint_telemetry.py +132 -0
- package/core/governance/tool_loop_check.py +111 -0
- package/core/hooks/stop.py +73 -22
- package/core/runtime/pricing.py +53 -19
- package/core/synapse/layers.py +49 -476
- package/core/synapse/layers_base.py +99 -0
- package/core/synapse/layers_kb.py +395 -0
- package/core/sync/content_syncer.py +79 -10
- package/dashboard/app/components/AgentDependencyGraph.vue +5 -5
- package/dashboard/app/components/AgentEditDrawer.vue +120 -52
- package/dashboard/app/components/AgentSuggestionsCard.vue +4 -2
- package/dashboard/app/components/ConfirmDialog.vue +2 -2
- package/dashboard/app/components/DashboardState.vue +5 -3
- package/dashboard/app/components/GlobalSearch.vue +11 -7
- package/dashboard/app/components/KeyboardShortcutsHelp.vue +8 -6
- package/dashboard/app/components/KnowledgeSourcesList.vue +13 -9
- package/dashboard/app/components/MarkdownEditor.vue +1 -1
- package/dashboard/app/components/NotificationsBell.vue +8 -4
- package/dashboard/app/components/OnboardingTour.vue +24 -11
- package/dashboard/app/components/PersonaCloneDialog.vue +15 -8
- package/dashboard/app/components/PersonaWizard.vue +105 -72
- package/dashboard/app/components/SidebarFavoritesWidget.vue +5 -5
- package/dashboard/app/components/SidebarStatsWidget.vue +3 -1
- package/dashboard/app/components/Terminal.vue +9 -8
- package/dashboard/app/components/TextDiff.vue +27 -15
- package/dashboard/app/composables/useActivityFeed.ts +3 -3
- package/dashboard/app/composables/useApi.ts +4 -2
- package/dashboard/app/composables/useConfirmDialog.ts +1 -1
- package/dashboard/app/composables/useFavorites.ts +8 -8
- package/dashboard/app/composables/useTerminalThemes.ts +13 -13
- package/dashboard/app/composables/useThemeColor.ts +7 -4
- package/dashboard/app/pages/agents/[id].vue +319 -159
- package/dashboard/app/pages/agents/compare.vue +152 -52
- package/dashboard/app/pages/agents/index.vue +68 -55
- package/dashboard/app/pages/agents/new.vue +125 -58
- package/dashboard/app/pages/audit.vue +13 -6
- package/dashboard/app/pages/budget.vue +20 -18
- package/dashboard/app/pages/commands.vue +26 -22
- package/dashboard/app/pages/departments/[dept].vue +67 -31
- package/dashboard/app/pages/departments/compare.vue +85 -27
- package/dashboard/app/pages/departments/index.vue +31 -7
- package/dashboard/app/pages/health.vue +19 -12
- package/dashboard/app/pages/knowledge/index.vue +190 -78
- package/dashboard/app/pages/models.vue +74 -28
- package/dashboard/app/pages/personas/[id].vue +322 -219
- package/dashboard/app/pages/personas/archetypes.vue +20 -6
- package/dashboard/app/pages/personas/compare-with-agent.vue +109 -39
- package/dashboard/app/pages/personas/compare.vue +196 -124
- package/dashboard/app/pages/personas/index.vue +67 -60
- package/dashboard/app/pages/personas/new.vue +1 -1
- package/dashboard/app/pages/settings.vue +1 -0
- package/dashboard/app/pages/tasks.vue +80 -38
- package/dashboard/app/pages/trash.vue +11 -9
- package/dashboard/app/pages/workflows.vue +29 -19
- package/dashboard/nuxt.config.ts +2 -1
- package/dashboard/package.json +5 -0
- package/departments/kb/skills/doc-extraction/SKILL.md +99 -0
- package/departments/kb/skills/doc-redaction/SKILL.md +103 -0
- package/departments/kb/skills/knowledge-ops/SKILL.md +88 -0
- package/departments/ops/skills/github-ops/SKILL.md +89 -0
- package/departments/ops/skills/terminal-ops/SKILL.md +83 -0
- package/installer/index.js +1 -1
- package/knowledge/commands-registry.json +16 -3
- package/knowledge/skills-manifest.json +66 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""L2.5 KB Context layer (Obsidian) — extracted from layers.py.
|
|
2
|
+
|
|
3
|
+
Semantic + keyword retrieval over the operator's Obsidian vault with a
|
|
4
|
+
grounding policy that quarantines inferred (Dreaming-written) notes.
|
|
5
|
+
All names are re-exported by core.synapse.layers for backward
|
|
6
|
+
compatibility — import from there unless you need the module itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from core.synapse.layers_base import Layer, LayerResult, PromptContext
|
|
17
|
+
|
|
18
|
+
_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:\|[^\]]+)?\]\]")
|
|
19
|
+
_FRONTMATTER_RE = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
|
|
20
|
+
_KB_CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
|
|
21
|
+
# Cap fallback-note scanning to avoid O(vault size) blow-ups on large
|
|
22
|
+
# Obsidian vaults. The cap is above any realistic top-N retrieval need
|
|
23
|
+
# (Jaccard ranks the top few notes; scanning 2000 sorted-by-name first
|
|
24
|
+
# is plenty — see `_load_fallback_notes`) while still bounding worst-case latency.
|
|
25
|
+
_MAX_FALLBACK_NOTES = 2000
|
|
26
|
+
_KB_STOPWORDS: frozenset[str] = frozenset({
|
|
27
|
+
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of",
|
|
28
|
+
"with", "by", "from", "as", "is", "was", "are", "were", "be", "been", "being",
|
|
29
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "should", "could",
|
|
30
|
+
"may", "might", "must", "can", "this", "that", "these", "those", "it", "its",
|
|
31
|
+
"about", "into", "over", "under", "up", "down", "out", "than", "then", "so",
|
|
32
|
+
"if", "because", "while", "where", "when", "what", "which", "who", "whom",
|
|
33
|
+
"how", "why", "all", "some", "any", "no", "not", "very", "just", "also",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _l25_feature_flag_on() -> bool:
|
|
38
|
+
if os.environ.get("ARKA_BYPASS_L25", "").strip() == "1":
|
|
39
|
+
return False
|
|
40
|
+
if not _KB_CONFIG_PATH.exists():
|
|
41
|
+
return True
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(_KB_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
44
|
+
except (json.JSONDecodeError, OSError):
|
|
45
|
+
return True
|
|
46
|
+
synapse_cfg = data.get("synapse") or {}
|
|
47
|
+
return bool(synapse_cfg.get("l25KbContext", True))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _tokenize_for_jaccard(text: str) -> set[str]:
|
|
51
|
+
if not text:
|
|
52
|
+
return set()
|
|
53
|
+
words = re.findall(r"[a-zA-Z0-9]{3,}", text.lower())
|
|
54
|
+
return {w for w in words if w not in _KB_STOPWORDS}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
58
|
+
if not a or not b:
|
|
59
|
+
return 0.0
|
|
60
|
+
inter = len(a & b)
|
|
61
|
+
union = len(a | b)
|
|
62
|
+
return inter / union if union else 0.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_note_body(raw: str) -> str:
|
|
66
|
+
return _FRONTMATTER_RE.sub("", raw, count=1).lstrip()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_title(raw: str, fallback: str) -> str:
|
|
70
|
+
body = _extract_note_body(raw)
|
|
71
|
+
for line in body.splitlines():
|
|
72
|
+
stripped = line.strip()
|
|
73
|
+
if stripped.startswith("#"):
|
|
74
|
+
return stripped.lstrip("#").strip() or fallback
|
|
75
|
+
if stripped:
|
|
76
|
+
return fallback
|
|
77
|
+
return fallback
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extract_excerpt(raw: str, max_lines: int = 2) -> str:
|
|
81
|
+
body = _extract_note_body(raw)
|
|
82
|
+
lines: list[str] = []
|
|
83
|
+
for line in body.splitlines():
|
|
84
|
+
stripped = line.strip()
|
|
85
|
+
if not stripped or stripped.startswith("#"):
|
|
86
|
+
continue
|
|
87
|
+
lines.append(stripped)
|
|
88
|
+
if len(lines) >= max_lines:
|
|
89
|
+
break
|
|
90
|
+
return " ".join(lines)[:240]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _extract_wikilinks(raw: str, limit: int = 3) -> list[str]:
|
|
94
|
+
body = _extract_note_body(raw)
|
|
95
|
+
seen: list[str] = []
|
|
96
|
+
for match in _WIKILINK_RE.finditer(body):
|
|
97
|
+
target = match.group(1).strip()
|
|
98
|
+
if target and target not in seen:
|
|
99
|
+
seen.append(target)
|
|
100
|
+
if len(seen) >= limit:
|
|
101
|
+
break
|
|
102
|
+
return seen
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _format_kb_block(notes: list[dict], degraded: bool = False) -> str:
|
|
106
|
+
lines: list[str] = [
|
|
107
|
+
f"[arka:kb-context] O teu cérebro (Obsidian) tem {len(notes)} "
|
|
108
|
+
f"nota{'s' if len(notes) != 1 else ''} relevante{'s' if len(notes) != 1 else ''} "
|
|
109
|
+
f"para este pedido:",
|
|
110
|
+
"",
|
|
111
|
+
]
|
|
112
|
+
if degraded:
|
|
113
|
+
lines.insert(1, "")
|
|
114
|
+
lines.insert(
|
|
115
|
+
1,
|
|
116
|
+
"Atenção: correspondência por palavras-chave (pesquisa semântica "
|
|
117
|
+
"indisponível) — NÃO é similaridade semântica.",
|
|
118
|
+
)
|
|
119
|
+
for note in notes:
|
|
120
|
+
title = note.get("title", "")
|
|
121
|
+
path = note.get("path", "")
|
|
122
|
+
excerpt = note.get("excerpt", "")
|
|
123
|
+
relates = note.get("relates", []) or []
|
|
124
|
+
suffix = " (inferred — not authoritative)" if note.get("inferred") else ""
|
|
125
|
+
lines.append(f"- [[{title}]]{suffix} (path: `{path}`)")
|
|
126
|
+
if excerpt:
|
|
127
|
+
lines.append(f" Excerto: {excerpt}")
|
|
128
|
+
if relates:
|
|
129
|
+
rel = ", ".join(f"[[{r}]]" for r in relates)
|
|
130
|
+
lines.append(f" Relacionada: {rel}")
|
|
131
|
+
lines.append("")
|
|
132
|
+
lines.append(
|
|
133
|
+
"Consulta-as antes de ir a Context7/Web. Se preencherem o pedido, "
|
|
134
|
+
"usa-as e cita. Se tiverem lacuna, investiga externamente e "
|
|
135
|
+
"documenta de volta."
|
|
136
|
+
)
|
|
137
|
+
return "\n".join(lines).strip()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _vector_search(store: Any, prompt: str, top_k: int) -> list[dict]:
|
|
141
|
+
if store is None:
|
|
142
|
+
return []
|
|
143
|
+
try:
|
|
144
|
+
return list(store.search(prompt, top_k=top_k)) or []
|
|
145
|
+
except Exception:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _jaccard_fallback(
|
|
150
|
+
prompt: str, notes: list[dict], top_k: int, threshold: float
|
|
151
|
+
) -> list[dict]:
|
|
152
|
+
prompt_tokens = _tokenize_for_jaccard(prompt)
|
|
153
|
+
scored: list[tuple[float, dict]] = []
|
|
154
|
+
for note in notes:
|
|
155
|
+
title_tokens = _tokenize_for_jaccard(note.get("title", ""))
|
|
156
|
+
score = _jaccard(prompt_tokens, title_tokens)
|
|
157
|
+
if score >= threshold:
|
|
158
|
+
scored.append((score, note))
|
|
159
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
160
|
+
return [n for _, n in scored[:top_k]]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _load_fallback_notes(vault_path: Optional[Path]) -> list[dict]:
|
|
164
|
+
if vault_path is None or not vault_path.exists() or not vault_path.is_dir():
|
|
165
|
+
return []
|
|
166
|
+
notes: list[dict] = []
|
|
167
|
+
for md in sorted(vault_path.rglob("*.md")):
|
|
168
|
+
if len(notes) >= _MAX_FALLBACK_NOTES:
|
|
169
|
+
break
|
|
170
|
+
try:
|
|
171
|
+
raw = md.read_text(encoding="utf-8")
|
|
172
|
+
except (OSError, UnicodeDecodeError):
|
|
173
|
+
continue
|
|
174
|
+
notes.append(
|
|
175
|
+
{
|
|
176
|
+
"title": _extract_title(raw, md.stem),
|
|
177
|
+
"path": str(md),
|
|
178
|
+
"raw": raw,
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
return notes
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_GROUNDING_INFERRED_RE = re.compile(r"^grounding:\s*inferred\s*$", re.MULTILINE)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _frontmatter_marks_inferred(raw: str) -> bool:
|
|
188
|
+
"""Cheap check: does the YAML frontmatter carry `grounding: inferred`?
|
|
189
|
+
|
|
190
|
+
Parses ONLY the frontmatter block (the note content is already in
|
|
191
|
+
hand) — no YAML library, no full-document scan. Dreaming-written
|
|
192
|
+
notes carry this marker (see core/cognition/dreaming.py, PR-3 v4.1).
|
|
193
|
+
"""
|
|
194
|
+
match = _FRONTMATTER_RE.match(raw or "")
|
|
195
|
+
if not match:
|
|
196
|
+
return False
|
|
197
|
+
return bool(_GROUNDING_INFERRED_RE.search(match.group(0)))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _hit_is_inferred(hit: dict) -> bool:
|
|
201
|
+
"""Inferred check for a vector-store hit.
|
|
202
|
+
|
|
203
|
+
Chunk text has frontmatter stripped by the chunker, so check the hit
|
|
204
|
+
metadata first, then read just the head of the source file (cheap:
|
|
205
|
+
frontmatter lives in the first bytes).
|
|
206
|
+
"""
|
|
207
|
+
metadata = hit.get("metadata") or {}
|
|
208
|
+
if isinstance(metadata, dict) and metadata.get("grounding") == "inferred":
|
|
209
|
+
return True
|
|
210
|
+
source = hit.get("source", "") or ""
|
|
211
|
+
if not source:
|
|
212
|
+
return False
|
|
213
|
+
try:
|
|
214
|
+
with open(source, "r", encoding="utf-8", errors="ignore") as fh:
|
|
215
|
+
head = fh.read(2048)
|
|
216
|
+
except OSError:
|
|
217
|
+
return False
|
|
218
|
+
return _frontmatter_marks_inferred(head)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _build_note_entry(
|
|
222
|
+
raw: str, title: str, path: str, score: float, inferred: bool = False
|
|
223
|
+
) -> dict:
|
|
224
|
+
return {
|
|
225
|
+
"title": title,
|
|
226
|
+
"path": path,
|
|
227
|
+
"excerpt": _extract_excerpt(raw),
|
|
228
|
+
"relates": _extract_wikilinks(raw),
|
|
229
|
+
"score": float(score),
|
|
230
|
+
"inferred": inferred,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _note_from_vector_hit(hit: dict) -> dict:
|
|
235
|
+
source = hit.get("source", "") or ""
|
|
236
|
+
raw = hit.get("text", "") or ""
|
|
237
|
+
title = hit.get("heading") or Path(source).stem or "note"
|
|
238
|
+
score_val = hit.get("score", 0.0) or 0.0
|
|
239
|
+
return _build_note_entry(
|
|
240
|
+
raw, str(title), str(source), float(score_val),
|
|
241
|
+
inferred=_hit_is_inferred(hit),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _apply_grounding_policy(notes: list[dict], max_notes: int) -> list[dict]:
|
|
246
|
+
"""Quarantine inferred notes (Dreaming output) from grounded context.
|
|
247
|
+
|
|
248
|
+
Policy (PR-3 v4.1): inferred notes are EXCLUDED by default; they are
|
|
249
|
+
only included — explicitly suffixed `(inferred — not authoritative)`
|
|
250
|
+
by the formatter — when fewer than 2 grounded notes matched.
|
|
251
|
+
"""
|
|
252
|
+
grounded = [n for n in notes if not n.get("inferred")]
|
|
253
|
+
if len(grounded) >= 2:
|
|
254
|
+
return grounded[:max_notes]
|
|
255
|
+
inferred = [n for n in notes if n.get("inferred")]
|
|
256
|
+
return (grounded + inferred)[:max_notes]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class KBContextLayer(Layer):
|
|
260
|
+
"""L2.5: Obsidian KB context injection before the model thinks.
|
|
261
|
+
|
|
262
|
+
Design (see plan ``2026-04-20-intelligence-v2.md``):
|
|
263
|
+
1. Semantic search the user prompt against the vector store.
|
|
264
|
+
2. If store empty or embedder unavailable, fall back to Jaccard
|
|
265
|
+
keyword similarity against cached note titles.
|
|
266
|
+
3. Keep notes with similarity ≥ ``min_similarity`` (default 0.5),
|
|
267
|
+
up to ``max_notes``.
|
|
268
|
+
4. Format as ``[arka:kb-context]`` block with title, path, 2-line
|
|
269
|
+
excerpt, and top 3 wikilinks per note.
|
|
270
|
+
5. Call ``record_obsidian_query`` so research_gate (Task #6) can
|
|
271
|
+
verify KB-first was respected this turn.
|
|
272
|
+
|
|
273
|
+
Feature flag: ``synapse.l25KbContext`` in ``~/.arkaos/config.json``
|
|
274
|
+
(default ``true``). ``ARKA_BYPASS_L25=1`` env disables for debugging.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
vector_store: Any = None,
|
|
280
|
+
vault_path: Optional[str] = None,
|
|
281
|
+
max_notes: int = 5,
|
|
282
|
+
min_similarity: float = 0.5,
|
|
283
|
+
) -> None:
|
|
284
|
+
self._store = vector_store
|
|
285
|
+
self._vault_path = Path(vault_path) if vault_path else None
|
|
286
|
+
self._max_notes = max_notes
|
|
287
|
+
self._min_similarity = min_similarity
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def id(self) -> str:
|
|
291
|
+
return "L2.5"
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def name(self) -> str:
|
|
295
|
+
return "KBContext"
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def input_sensitive(self) -> bool:
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def cache_ttl(self) -> int:
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def priority(self) -> int:
|
|
307
|
+
return 25
|
|
308
|
+
|
|
309
|
+
def _empty(self, start: float) -> LayerResult:
|
|
310
|
+
ms = int((time.time() - start) * 1000)
|
|
311
|
+
return LayerResult(
|
|
312
|
+
layer_id=self.id, tag="", content="", tokens_est=0, compute_ms=ms, cached=False
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def _session_id(self, ctx: PromptContext) -> str:
|
|
316
|
+
return ctx.extra.get("session_id", "") if ctx.extra else ""
|
|
317
|
+
|
|
318
|
+
def _record(self, ctx: PromptContext, hit_count: int) -> None:
|
|
319
|
+
session_id = self._session_id(ctx)
|
|
320
|
+
if not session_id:
|
|
321
|
+
return
|
|
322
|
+
try:
|
|
323
|
+
from core.synapse.kb_cache import record_obsidian_query
|
|
324
|
+
|
|
325
|
+
record_obsidian_query(session_id, ctx.user_input, hit_count)
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
def _retrieve(self, prompt: str) -> tuple[list[dict], bool]:
|
|
330
|
+
"""Return (notes, degraded). Degraded = keyword-only retrieval.
|
|
331
|
+
|
|
332
|
+
Degraded hits carry no similarity score, so the min_similarity
|
|
333
|
+
threshold does not apply to them — they are included but labeled
|
|
334
|
+
(never presented as semantic matches).
|
|
335
|
+
"""
|
|
336
|
+
hits = _vector_search(self._store, prompt, top_k=self._max_notes * 2)
|
|
337
|
+
degraded = any(h.get("retrieval") == "keyword-degraded" for h in hits)
|
|
338
|
+
notes: list[dict] = []
|
|
339
|
+
for h in hits:
|
|
340
|
+
if not degraded:
|
|
341
|
+
score = float(h.get("score", 0.0) or 0.0)
|
|
342
|
+
if score < self._min_similarity:
|
|
343
|
+
continue
|
|
344
|
+
notes.append(_note_from_vector_hit(h))
|
|
345
|
+
notes = _apply_grounding_policy(notes, self._max_notes)
|
|
346
|
+
if notes:
|
|
347
|
+
return notes, degraded
|
|
348
|
+
candidates = _load_fallback_notes(self._vault_path)
|
|
349
|
+
if not candidates:
|
|
350
|
+
return [], False
|
|
351
|
+
picked = _jaccard_fallback(
|
|
352
|
+
prompt, candidates, self._max_notes * 2, self._min_similarity
|
|
353
|
+
)
|
|
354
|
+
fallback_notes = [
|
|
355
|
+
_build_note_entry(
|
|
356
|
+
n["raw"], n["title"], n["path"], 0.0,
|
|
357
|
+
inferred=_frontmatter_marks_inferred(n["raw"]),
|
|
358
|
+
)
|
|
359
|
+
for n in picked
|
|
360
|
+
]
|
|
361
|
+
return _apply_grounding_policy(fallback_notes, self._max_notes), False
|
|
362
|
+
|
|
363
|
+
def build(self, prompt: str) -> Optional[str]:
|
|
364
|
+
"""Public entrypoint — returns the formatted block or None."""
|
|
365
|
+
if not prompt or not _l25_feature_flag_on():
|
|
366
|
+
return None
|
|
367
|
+
notes, degraded = self._retrieve(prompt[:2000])
|
|
368
|
+
if not notes:
|
|
369
|
+
return None
|
|
370
|
+
return _format_kb_block(notes[: self._max_notes], degraded=degraded)
|
|
371
|
+
|
|
372
|
+
def compute(self, ctx: PromptContext) -> LayerResult:
|
|
373
|
+
start = time.time()
|
|
374
|
+
if not ctx.user_input or not _l25_feature_flag_on():
|
|
375
|
+
return self._empty(start)
|
|
376
|
+
try:
|
|
377
|
+
notes, degraded = self._retrieve(ctx.user_input[:2000])
|
|
378
|
+
except Exception:
|
|
379
|
+
return self._empty(start)
|
|
380
|
+
self._record(ctx, len(notes))
|
|
381
|
+
if not notes:
|
|
382
|
+
return self._empty(start)
|
|
383
|
+
block = _format_kb_block(notes[: self._max_notes], degraded=degraded)
|
|
384
|
+
ms = int((time.time() - start) * 1000)
|
|
385
|
+
tag = f"[kb-context:{len(notes)}]"
|
|
386
|
+
if degraded:
|
|
387
|
+
tag = f"[kb-context:{len(notes)} degraded=keyword]"
|
|
388
|
+
return LayerResult(
|
|
389
|
+
layer_id=self.id,
|
|
390
|
+
tag=tag,
|
|
391
|
+
content=block,
|
|
392
|
+
tokens_est=len(block.split()),
|
|
393
|
+
compute_ms=ms,
|
|
394
|
+
cached=False,
|
|
395
|
+
)
|
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Syncs CLAUDE.md (with intelligent merge), rules, hooks, and a generated
|
|
4
4
|
constitution excerpt into each project's .claude/ directory.
|
|
5
|
+
|
|
6
|
+
Stack conventions deploy as path-scoped rule files
|
|
7
|
+
(``.claude/rules/arkaos-stack-<stack>.md``, source
|
|
8
|
+
``config/standards/stack-rules/``) instead of being concatenated into
|
|
9
|
+
the CLAUDE.md managed block: the ``paths:`` frontmatter makes the
|
|
10
|
+
runtime load each convention only when a matching file is read, and the
|
|
11
|
+
``arkaos-stack-`` namespace lets the syncer remove stale files when a
|
|
12
|
+
project's stack changes. Stack slugs are case-folded and resolved
|
|
13
|
+
through ``_STACK_ALIASES`` (project descriptors say ``javascript`` 14x,
|
|
14
|
+
``vue`` 9x, ``php`` 9x — none of which matched an overlay filename
|
|
15
|
+
under the old exact-name scheme).
|
|
5
16
|
"""
|
|
6
17
|
|
|
7
18
|
from __future__ import annotations
|
|
@@ -28,7 +39,7 @@ def sync_project_content(project: Project) -> ContentSyncResult:
|
|
|
28
39
|
"""Sync CLAUDE.md, rules, hooks, and constitution excerpt for a project."""
|
|
29
40
|
try:
|
|
30
41
|
return _do_sync(project)
|
|
31
|
-
except Exception as exc:
|
|
42
|
+
except Exception as exc:
|
|
32
43
|
return ContentSyncResult(
|
|
33
44
|
path=project.path, status="error", error=str(exc)
|
|
34
45
|
)
|
|
@@ -46,6 +57,7 @@ def _do_sync(project: Project) -> ContentSyncResult:
|
|
|
46
57
|
|
|
47
58
|
_sync_claude_md(core, project, project_claude, version, updated, unchanged, errored)
|
|
48
59
|
_sync_rules(core, project_claude, updated, unchanged, errored)
|
|
60
|
+
_sync_stack_rules(core, project, project_claude, updated, unchanged, errored)
|
|
49
61
|
_sync_hooks(core, project_claude, updated, unchanged, errored)
|
|
50
62
|
_sync_constitution(core, project_claude, updated, unchanged, errored)
|
|
51
63
|
|
|
@@ -73,15 +85,11 @@ def _sync_claude_md(
|
|
|
73
85
|
unchanged: list[str],
|
|
74
86
|
errored: list[str],
|
|
75
87
|
) -> None:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if overlay.exists():
|
|
82
|
-
overlays.append(overlay.read_text(encoding="utf-8"))
|
|
83
|
-
|
|
84
|
-
managed_content = "\n\n".join([base, *overlays]).strip()
|
|
88
|
+
# Stack conventions live in path-scoped rule files (_sync_stack_rules);
|
|
89
|
+
# the managed block carries only the shared base.
|
|
90
|
+
managed_content = (
|
|
91
|
+
(core / "config" / "user-claude.md").read_text(encoding="utf-8").strip()
|
|
92
|
+
)
|
|
85
93
|
target_file = project_claude / "CLAUDE.md"
|
|
86
94
|
target_text = target_file.read_text(encoding="utf-8") if target_file.exists() else ""
|
|
87
95
|
|
|
@@ -98,6 +106,67 @@ def _sync_claude_md(
|
|
|
98
106
|
updated.append("CLAUDE.md")
|
|
99
107
|
|
|
100
108
|
|
|
109
|
+
# Descriptor slug -> stack-rules basename (no .md). Slugs are case-folded first.
|
|
110
|
+
_STACK_ALIASES: dict[str, str] = {
|
|
111
|
+
"js": "node",
|
|
112
|
+
"javascript": "node",
|
|
113
|
+
"ts": "node",
|
|
114
|
+
"typescript": "node",
|
|
115
|
+
"vuejs": "vue",
|
|
116
|
+
"vue3": "vue",
|
|
117
|
+
"next": "react",
|
|
118
|
+
"nextjs": "react",
|
|
119
|
+
"next.js": "react",
|
|
120
|
+
"fastapi": "python",
|
|
121
|
+
"django": "python",
|
|
122
|
+
"flask": "python",
|
|
123
|
+
}
|
|
124
|
+
_STACK_RULE_PREFIX = "arkaos-stack-"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_stacks(stacks: list[str], rules_src: Path) -> list[str]:
|
|
128
|
+
"""Case-folded, alias-resolved, deduped stacks that have a rule file."""
|
|
129
|
+
resolved: list[str] = []
|
|
130
|
+
for raw in stacks:
|
|
131
|
+
slug = raw.strip().casefold()
|
|
132
|
+
slug = _STACK_ALIASES.get(slug, slug)
|
|
133
|
+
if slug and slug not in resolved and (rules_src / f"{slug}.md").is_file():
|
|
134
|
+
resolved.append(slug)
|
|
135
|
+
return resolved
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _sync_stack_rules(
|
|
139
|
+
core: Path,
|
|
140
|
+
project: Project,
|
|
141
|
+
project_claude: Path,
|
|
142
|
+
updated: list[str],
|
|
143
|
+
unchanged: list[str],
|
|
144
|
+
errored: list[str],
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Deploy path-scoped stack rules; remove stale arkaos-stack files."""
|
|
147
|
+
src = core / "config" / "standards" / "stack-rules"
|
|
148
|
+
dst = project_claude / "rules"
|
|
149
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
stacks = _resolve_stacks(project.stack, src) if src.is_dir() else []
|
|
151
|
+
expected = {f"{_STACK_RULE_PREFIX}{stack}.md" for stack in stacks}
|
|
152
|
+
for stack in stacks:
|
|
153
|
+
name = f"{_STACK_RULE_PREFIX}{stack}.md"
|
|
154
|
+
target = dst / name
|
|
155
|
+
src_text = (src / f"{stack}.md").read_text(encoding="utf-8")
|
|
156
|
+
if target.exists() and target.read_text(encoding="utf-8") == src_text:
|
|
157
|
+
unchanged.append(f"rules/{name}")
|
|
158
|
+
continue
|
|
159
|
+
target.write_text(src_text, encoding="utf-8")
|
|
160
|
+
updated.append(f"rules/{name}")
|
|
161
|
+
# The arkaos-stack- namespace is syncer-owned: files for stacks the
|
|
162
|
+
# project no longer declares are removed (unlike generic rules,
|
|
163
|
+
# which never delete orphans).
|
|
164
|
+
for stale in dst.glob(f"{_STACK_RULE_PREFIX}*.md"):
|
|
165
|
+
if stale.name not in expected:
|
|
166
|
+
stale.unlink(missing_ok=True)
|
|
167
|
+
updated.append(f"rules/{stale.name} (removed)")
|
|
168
|
+
|
|
169
|
+
|
|
101
170
|
def _sync_rules(
|
|
102
171
|
core: Path,
|
|
103
172
|
project_claude: Path,
|
|
@@ -25,7 +25,7 @@ const { data: personasData } = fetchApi<{
|
|
|
25
25
|
}>('/api/personas')
|
|
26
26
|
|
|
27
27
|
function personaName(id: string): string {
|
|
28
|
-
return personasData.value?.personas?.find(
|
|
28
|
+
return personasData.value?.personas?.find(p => p.id === id)?.name ?? id
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const siblings = computed<string[]>(() => {
|
|
@@ -60,15 +60,15 @@ const personaPositions = computed(() =>
|
|
|
60
60
|
props.linkedPersonas.map((id, i) => ({
|
|
61
61
|
id,
|
|
62
62
|
x: distributeX(props.linkedPersonas.length, i),
|
|
63
|
-
y: 32
|
|
64
|
-
}))
|
|
63
|
+
y: 32
|
|
64
|
+
}))
|
|
65
65
|
)
|
|
66
66
|
const siblingPositions = computed(() =>
|
|
67
67
|
siblings.value.map((id, i) => ({
|
|
68
68
|
id,
|
|
69
69
|
x: distributeX(siblings.value.length, i),
|
|
70
|
-
y: HEIGHT - 32
|
|
71
|
-
}))
|
|
70
|
+
y: HEIGHT - 32
|
|
71
|
+
}))
|
|
72
72
|
)
|
|
73
73
|
</script>
|
|
74
74
|
|