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.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +2 -1
  4. package/config/claude-agents/marta-cqo.md +9 -0
  5. package/config/standards/{claude-md-overlays → stack-rules}/laravel.md +11 -0
  6. package/config/standards/{claude-md-overlays → stack-rules}/node.md +10 -1
  7. package/config/standards/{claude-md-overlays → stack-rules}/nuxt.md +12 -1
  8. package/config/standards/stack-rules/php.md +13 -0
  9. package/config/standards/{claude-md-overlays → stack-rules}/python.md +5 -0
  10. package/config/standards/stack-rules/react.md +14 -0
  11. package/config/standards/stack-rules/vue.md +13 -0
  12. package/core/cognition/evolve.py +299 -0
  13. package/core/cognition/evolve_cli.py +55 -0
  14. package/core/governance/stop_lint.py +266 -0
  15. package/core/governance/stop_lint_telemetry.py +132 -0
  16. package/core/governance/tool_loop_check.py +111 -0
  17. package/core/hooks/stop.py +73 -22
  18. package/core/runtime/pricing.py +53 -19
  19. package/core/synapse/layers.py +49 -476
  20. package/core/synapse/layers_base.py +99 -0
  21. package/core/synapse/layers_kb.py +395 -0
  22. package/core/sync/content_syncer.py +79 -10
  23. package/dashboard/app/components/AgentDependencyGraph.vue +5 -5
  24. package/dashboard/app/components/AgentEditDrawer.vue +120 -52
  25. package/dashboard/app/components/AgentSuggestionsCard.vue +4 -2
  26. package/dashboard/app/components/ConfirmDialog.vue +2 -2
  27. package/dashboard/app/components/DashboardState.vue +5 -3
  28. package/dashboard/app/components/GlobalSearch.vue +11 -7
  29. package/dashboard/app/components/KeyboardShortcutsHelp.vue +8 -6
  30. package/dashboard/app/components/KnowledgeSourcesList.vue +13 -9
  31. package/dashboard/app/components/MarkdownEditor.vue +1 -1
  32. package/dashboard/app/components/NotificationsBell.vue +8 -4
  33. package/dashboard/app/components/OnboardingTour.vue +24 -11
  34. package/dashboard/app/components/PersonaCloneDialog.vue +15 -8
  35. package/dashboard/app/components/PersonaWizard.vue +105 -72
  36. package/dashboard/app/components/SidebarFavoritesWidget.vue +5 -5
  37. package/dashboard/app/components/SidebarStatsWidget.vue +3 -1
  38. package/dashboard/app/components/Terminal.vue +9 -8
  39. package/dashboard/app/components/TextDiff.vue +27 -15
  40. package/dashboard/app/composables/useActivityFeed.ts +3 -3
  41. package/dashboard/app/composables/useApi.ts +4 -2
  42. package/dashboard/app/composables/useConfirmDialog.ts +1 -1
  43. package/dashboard/app/composables/useFavorites.ts +8 -8
  44. package/dashboard/app/composables/useTerminalThemes.ts +13 -13
  45. package/dashboard/app/composables/useThemeColor.ts +7 -4
  46. package/dashboard/app/pages/agents/[id].vue +319 -159
  47. package/dashboard/app/pages/agents/compare.vue +152 -52
  48. package/dashboard/app/pages/agents/index.vue +68 -55
  49. package/dashboard/app/pages/agents/new.vue +125 -58
  50. package/dashboard/app/pages/audit.vue +13 -6
  51. package/dashboard/app/pages/budget.vue +20 -18
  52. package/dashboard/app/pages/commands.vue +26 -22
  53. package/dashboard/app/pages/departments/[dept].vue +67 -31
  54. package/dashboard/app/pages/departments/compare.vue +85 -27
  55. package/dashboard/app/pages/departments/index.vue +31 -7
  56. package/dashboard/app/pages/health.vue +19 -12
  57. package/dashboard/app/pages/knowledge/index.vue +190 -78
  58. package/dashboard/app/pages/models.vue +74 -28
  59. package/dashboard/app/pages/personas/[id].vue +322 -219
  60. package/dashboard/app/pages/personas/archetypes.vue +20 -6
  61. package/dashboard/app/pages/personas/compare-with-agent.vue +109 -39
  62. package/dashboard/app/pages/personas/compare.vue +196 -124
  63. package/dashboard/app/pages/personas/index.vue +67 -60
  64. package/dashboard/app/pages/personas/new.vue +1 -1
  65. package/dashboard/app/pages/settings.vue +1 -0
  66. package/dashboard/app/pages/tasks.vue +80 -38
  67. package/dashboard/app/pages/trash.vue +11 -9
  68. package/dashboard/app/pages/workflows.vue +29 -19
  69. package/dashboard/nuxt.config.ts +2 -1
  70. package/dashboard/package.json +5 -0
  71. package/departments/kb/skills/doc-extraction/SKILL.md +99 -0
  72. package/departments/kb/skills/doc-redaction/SKILL.md +103 -0
  73. package/departments/kb/skills/knowledge-ops/SKILL.md +88 -0
  74. package/departments/ops/skills/github-ops/SKILL.md +89 -0
  75. package/departments/ops/skills/terminal-ops/SKILL.md +83 -0
  76. package/installer/index.js +1 -1
  77. package/knowledge/commands-registry.json +16 -3
  78. package/knowledge/skills-manifest.json +66 -1
  79. package/package.json +1 -1
  80. 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: # noqa: BLE001
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
- base = (core / "config" / "user-claude.md").read_text(encoding="utf-8")
77
- overlays_dir = core / "config" / "standards" / "claude-md-overlays"
78
- overlays: list[str] = []
79
- for stack in project.stack:
80
- overlay = overlays_dir / f"{stack}.md"
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((p) => p.id === id)?.name ?? id
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