arkaos 2.77.0 → 2.79.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/VERSION CHANGED
@@ -1 +1 @@
1
- 2.77.0
1
+ 2.79.0
@@ -0,0 +1,244 @@
1
+ """One-stop /arka update orchestrator (PR61 v2.78.0).
2
+
3
+ The published 2-step process (`npx arkaos@latest update` then
4
+ `/arka update`) is fragile in practice: operators run step 2 inside
5
+ Claude Code without remembering step 1, so the sync engine silently
6
+ runs from whichever npx cache `~/.arkaos/.repo-path` last pointed at.
7
+ When that cache is months old the sync becomes a no-op against
8
+ current versions.
9
+
10
+ This module makes `/arka update` self-sufficient:
11
+
12
+ 1. Read the running ArkaOS version from `<repo>/VERSION`.
13
+ 2. Probe the npm registry for the published latest (5s timeout,
14
+ 1-hour cache on disk to keep repeat runs cheap).
15
+ 3. If the running version is older than the latest, shell out to
16
+ `npx arkaos@latest update` and wait for it to finish before
17
+ touching the sync engine. The npx step rewrites
18
+ ``~/.arkaos/.repo-path`` to the freshly-extracted package so the
19
+ sync engine below reads the right code.
20
+ 4. Re-read VERSION (now updated) and dispatch to ``run_sync``.
21
+
22
+ The orchestrator NEVER raises on transient failures — npm offline,
23
+ slow registry, missing `npx` — it logs and falls through to the sync
24
+ engine using whatever code is currently installed. Worst case the
25
+ operator sees the same behaviour as before PR61.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+ from typing import Optional
37
+
38
+ from core.sync.engine import (
39
+ _read_current_version,
40
+ _read_repo_path,
41
+ run_sync,
42
+ )
43
+ from core.sync.schema import SyncReport
44
+
45
+
46
+ # Cache the npm-view result for an hour so repeated /arka update calls
47
+ # inside the same session don't re-hit the registry.
48
+ _NPM_CACHE_TTL_SECONDS = 3600
49
+ _NPM_TIMEOUT_SECONDS = 5
50
+ _NPM_PROBE_CMD = ("npm", "view", "arkaos", "version")
51
+ _NPX_UPDATE_CMD = ("npx", "-y", "arkaos@latest", "update")
52
+ _NPX_TIMEOUT_SECONDS = 600 # 10 minutes — large installs can be slow
53
+
54
+
55
+ def orchestrate(
56
+ arkaos_home: Path,
57
+ skills_dir: Path,
58
+ home_path: str,
59
+ *,
60
+ npm_probe=None,
61
+ npx_run=None,
62
+ cache_path: Path | None = None,
63
+ ) -> tuple[Optional[str], Optional[str], SyncReport]:
64
+ """Run npm-side update when stale, then the sync engine.
65
+
66
+ Returns ``(installed_version_before, latest_version_seen, report)``.
67
+ The first two are None when probing failed; the third is always a
68
+ SyncReport (the engine itself never raises on individual project
69
+ failures).
70
+ """
71
+ probe = npm_probe or _probe_npm_latest
72
+ runner = npx_run or _run_npx_update
73
+ cache = cache_path or (arkaos_home / "npm-latest.cache.json")
74
+
75
+ installed = _safe_read_version(arkaos_home)
76
+ latest = probe(cache)
77
+
78
+ if installed and latest and _is_older(installed, latest):
79
+ runner(arkaos_home)
80
+
81
+ report = run_sync(
82
+ arkaos_home=arkaos_home,
83
+ skills_dir=skills_dir,
84
+ home_path=home_path,
85
+ )
86
+ return installed, latest, report
87
+
88
+
89
+ def _safe_read_version(arkaos_home: Path) -> Optional[str]:
90
+ try:
91
+ v = _read_current_version(arkaos_home)
92
+ return v if v and v != "unknown" else None
93
+ except Exception: # noqa: BLE001 — never break the orchestrator
94
+ return None
95
+
96
+
97
+ def _probe_npm_latest(cache_path: Path) -> Optional[str]:
98
+ """Return the latest published arkaos version, or None on failure.
99
+
100
+ Reads from disk cache when fresh; otherwise shells out to
101
+ ``npm view`` with a short timeout. Always swallows errors —
102
+ callers fall through to a no-op when probing fails.
103
+ """
104
+ cached = _read_cache(cache_path)
105
+ if cached is not None:
106
+ return cached
107
+ try:
108
+ result = subprocess.run(
109
+ list(_NPM_PROBE_CMD),
110
+ capture_output=True,
111
+ text=True,
112
+ timeout=_NPM_TIMEOUT_SECONDS,
113
+ check=False,
114
+ )
115
+ except (OSError, subprocess.TimeoutExpired):
116
+ return None
117
+ if result.returncode != 0:
118
+ return None
119
+ version = (result.stdout or "").strip()
120
+ if not _looks_like_semver(version):
121
+ return None
122
+ _write_cache(cache_path, version)
123
+ return version
124
+
125
+
126
+ def _read_cache(cache_path: Path) -> Optional[str]:
127
+ if not cache_path.exists():
128
+ return None
129
+ try:
130
+ data = json.loads(cache_path.read_text(encoding="utf-8"))
131
+ except (json.JSONDecodeError, OSError):
132
+ return None
133
+ if not isinstance(data, dict):
134
+ return None
135
+ ts = data.get("ts")
136
+ version = data.get("version")
137
+ if not isinstance(ts, (int, float)) or not isinstance(version, str):
138
+ return None
139
+ if time.time() - ts > _NPM_CACHE_TTL_SECONDS:
140
+ return None
141
+ return version if _looks_like_semver(version) else None
142
+
143
+
144
+ def _write_cache(cache_path: Path, version: str) -> None:
145
+ try:
146
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
147
+ cache_path.write_text(
148
+ json.dumps({"version": version, "ts": time.time()}),
149
+ encoding="utf-8",
150
+ )
151
+ except OSError:
152
+ pass
153
+
154
+
155
+ def _looks_like_semver(value: str) -> bool:
156
+ if not value or len(value) > 32:
157
+ return False
158
+ # Strip any `-prerelease.suffix` so "2.77.0-beta.1" reduces to "2.77.0"
159
+ # before structural validation. npm canonical semver allows almost any
160
+ # ASCII after the dash; we don't try to validate that — we only need
161
+ # to confirm the leading major.minor.patch shape is intact.
162
+ base = value.split("-", 1)[0]
163
+ parts = base.split(".")
164
+ if len(parts) != 3:
165
+ return False
166
+ return all(part.isdigit() for part in parts)
167
+
168
+
169
+ def _is_older(installed: str, latest: str) -> bool:
170
+ return _parse_semver(installed) < _parse_semver(latest)
171
+
172
+
173
+ def _parse_semver(value: str) -> tuple[int, int, int]:
174
+ parts = value.split(".")
175
+ try:
176
+ major = int(parts[0])
177
+ minor = int(parts[1])
178
+ patch_str = parts[2].split("-", 1)[0]
179
+ patch = int(patch_str)
180
+ return major, minor, patch
181
+ except (ValueError, IndexError):
182
+ return (0, 0, 0)
183
+
184
+
185
+ def _run_npx_update(arkaos_home: Path) -> None:
186
+ """Best-effort shell-out to `npx arkaos@latest update`.
187
+
188
+ Inherits the parent's stdout/stderr so the operator sees the same
189
+ installer banner they would in a manual run. Swallows OSError /
190
+ TimeoutExpired so the surrounding sync still runs.
191
+ """
192
+ del arkaos_home # passed for symmetry with _probe_npm_latest signature
193
+ try:
194
+ env = os.environ.copy()
195
+ # Some operators set CI=1 to suppress installer prompts; preserve it.
196
+ subprocess.run(
197
+ list(_NPX_UPDATE_CMD),
198
+ check=False,
199
+ timeout=_NPX_TIMEOUT_SECONDS,
200
+ env=env,
201
+ )
202
+ except (OSError, subprocess.TimeoutExpired) as exc:
203
+ sys.stderr.write(
204
+ f"[arkaos] npx arkaos@latest update failed: {exc}\n"
205
+ "[arkaos] continuing with the sync engine using the "
206
+ "currently-installed core.\n"
207
+ )
208
+
209
+
210
+ def main(argv: list[str]) -> int:
211
+ """CLI entry: python -m core.sync.update_orchestrator --home X --skills Y."""
212
+ import argparse
213
+ parser = argparse.ArgumentParser(description="ArkaOS one-stop /arka update")
214
+ parser.add_argument("--home", required=True)
215
+ parser.add_argument("--skills", required=True)
216
+ parser.add_argument("--output", choices=["text", "json"], default="text")
217
+ args = parser.parse_args(argv[1:])
218
+
219
+ installed, latest, report = orchestrate(
220
+ arkaos_home=Path(args.home),
221
+ skills_dir=Path(args.skills),
222
+ home_path=str(Path.home()),
223
+ )
224
+
225
+ if args.output == "json":
226
+ payload = {
227
+ "installed_version_before": installed,
228
+ "latest_version_seen": latest,
229
+ "report": report.model_dump(),
230
+ }
231
+ print(json.dumps(payload, indent=2))
232
+ else:
233
+ from core.sync.reporter import format_report
234
+ print(f"Installed: {installed or 'unknown'}")
235
+ print(f"Latest published: {latest or 'unknown'}")
236
+ if installed and latest and _is_older(installed, latest):
237
+ print(f"Updated from {installed} → {latest} via npx arkaos@latest")
238
+ print()
239
+ print(format_report(report))
240
+ return 0
241
+
242
+
243
+ if __name__ == "__main__":
244
+ sys.exit(main(sys.argv))
@@ -0,0 +1,504 @@
1
+ <script setup lang="ts">
2
+ import type { Persona } from '~/types'
3
+
4
+ // PR62 v2.79.0 — 4-step AI-powered persona wizard.
5
+ // Replaces the original "fill a 30-field form" UX with:
6
+ // 1. Sources → operator pastes URLs / picks files, optionally types a name
7
+ // 2. Ingest → existing /api/knowledge/ingest-bulk fans the URLs out into
8
+ // background jobs and shows progress via WebSocket
9
+ // 3. Build → /api/personas/build (PR57) reads the indexed chunks and
10
+ // returns a draft Persona; operator reviews + edits
11
+ // 4. Save → POST /api/personas (manual create path), optional
12
+ // clone-to-agent at the end
13
+ //
14
+ // The wizard NEVER auto-saves — every transition is operator-confirmed.
15
+
16
+ const { apiBase } = useApi()
17
+ const toast = useToast()
18
+
19
+ const emit = defineEmits<{
20
+ (e: 'completed', persona: Persona): void
21
+ (e: 'cancelled'): void
22
+ }>()
23
+
24
+ type Step = 1 | 2 | 3 | 4
25
+ const step = ref<Step>(1)
26
+
27
+ // ─── Step 1 state ────────────────────────────────────────────────────────
28
+ const name = ref('')
29
+ const sourceLabel = ref('')
30
+ const sources = ref('')
31
+ const skipIngest = ref(false)
32
+ const sourceLineCount = computed(() =>
33
+ sources.value
34
+ .split('\n')
35
+ .map((s) => s.trim())
36
+ .filter((s) => s.length > 0)
37
+ .length,
38
+ )
39
+
40
+ // ─── Step 2 state ────────────────────────────────────────────────────────
41
+ const ingestJobs = ref<Array<{
42
+ source: string
43
+ job_id?: string
44
+ status: 'queued' | 'processing' | 'completed' | 'failed'
45
+ progress: number
46
+ error?: string
47
+ }>>([])
48
+ let ws: WebSocket | null = null
49
+ const allIngestComplete = computed(() =>
50
+ ingestJobs.value.length > 0
51
+ && ingestJobs.value.every((j) => j.status === 'completed' || j.status === 'failed'),
52
+ )
53
+ const ingestCompletedCount = computed(() =>
54
+ ingestJobs.value.filter((j) => j.status === 'completed').length,
55
+ )
56
+
57
+ // ─── Step 3 state ────────────────────────────────────────────────────────
58
+ const draft = ref<Persona | null>(null)
59
+ const building = ref(false)
60
+ const buildError = ref<string | null>(null)
61
+ const chunksUsed = ref<number | null>(null)
62
+
63
+ // ─── Step 4 state ────────────────────────────────────────────────────────
64
+ const saving = ref(false)
65
+ const saveAndClone = ref(false)
66
+ const cloneDept = ref('strategy')
67
+ const cloneTier = ref<'1' | '2' | '3'>('2')
68
+
69
+ const departmentOptions = [
70
+ 'dev', 'marketing', 'brand', 'finance', 'strategy', 'ecom', 'kb', 'ops',
71
+ 'pm', 'saas', 'landing', 'content', 'community', 'sales', 'leadership', 'org',
72
+ ].map((d) => ({ label: d, value: d }))
73
+
74
+ const tierOptions = [
75
+ { label: 'Tier 1 — Squad Lead', value: '1' },
76
+ { label: 'Tier 2 — Specialist', value: '2' },
77
+ { label: 'Tier 3 — Support', value: '3' },
78
+ ]
79
+
80
+ // ─── Step 1 → 2 transition ───────────────────────────────────────────────
81
+
82
+
83
+ async function startIngest() {
84
+ if (skipIngest.value) {
85
+ // Jump straight to step 3 — operator says content is already indexed.
86
+ step.value = 3
87
+ await runBuild()
88
+ return
89
+ }
90
+ const cleaned = sources.value
91
+ .split('\n')
92
+ .map((s) => s.trim())
93
+ .filter((s) => s.length > 0)
94
+ if (cleaned.length === 0 || !name.value.trim()) return
95
+ step.value = 2
96
+ ingestJobs.value = cleaned.map((source) => ({
97
+ source,
98
+ status: 'queued',
99
+ progress: 0,
100
+ }))
101
+ try {
102
+ const res = await $fetch<{ jobs: Array<{ source: string, job_id?: string, error?: string }>, count: number }>(
103
+ `${apiBase}/api/knowledge/ingest-bulk`,
104
+ { method: 'POST', body: { sources: cleaned } },
105
+ )
106
+ res.jobs.forEach((j) => {
107
+ const row = ingestJobs.value.find((r) => r.source === j.source)
108
+ if (!row) return
109
+ if (j.error) {
110
+ row.status = 'failed'
111
+ row.error = j.error
112
+ } else if (j.job_id) {
113
+ row.job_id = j.job_id
114
+ row.status = 'processing'
115
+ }
116
+ })
117
+ connectWebSocket()
118
+ } catch (err) {
119
+ toast.add({
120
+ title: 'Ingest failed',
121
+ description: err instanceof Error ? err.message : 'unknown error',
122
+ color: 'error',
123
+ })
124
+ step.value = 1
125
+ }
126
+ }
127
+
128
+
129
+ function connectWebSocket() {
130
+ if (ws && ws.readyState === WebSocket.OPEN) return
131
+ const wsUrl = apiBase.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws/tasks'
132
+ ws = new WebSocket(wsUrl)
133
+ ws.onmessage = (event) => {
134
+ try {
135
+ const data = JSON.parse(event.data)
136
+ const row = ingestJobs.value.find((j) => j.job_id === data.job_id)
137
+ if (!row) return
138
+ if (data.type === 'job_progress') {
139
+ row.progress = data.progress
140
+ row.status = 'processing'
141
+ } else if (data.type === 'job_complete') {
142
+ row.status = 'completed'
143
+ row.progress = 100
144
+ } else if (data.type === 'job_failed') {
145
+ row.status = 'failed'
146
+ row.error = data.error
147
+ }
148
+ } catch { /* ignore malformed messages */ }
149
+ }
150
+ }
151
+
152
+
153
+ function disconnectWebSocket() {
154
+ if (ws) {
155
+ try { ws.close() } catch { /* already closed */ }
156
+ ws = null
157
+ }
158
+ }
159
+
160
+
161
+ onBeforeUnmount(() => {
162
+ disconnectWebSocket()
163
+ })
164
+
165
+
166
+ // ─── Step 3: build the persona draft ────────────────────────────────────
167
+
168
+
169
+ async function runBuild() {
170
+ building.value = true
171
+ buildError.value = null
172
+ draft.value = null
173
+ try {
174
+ const res = await $fetch<{ persona: Persona, chunks_used: number, provider_name: string }>(
175
+ `${apiBase}/api/personas/build`,
176
+ {
177
+ method: 'POST',
178
+ body: {
179
+ name: name.value.trim(),
180
+ source_label: sourceLabel.value.trim() || name.value.trim(),
181
+ },
182
+ },
183
+ )
184
+ if ('error' in res && typeof (res as any).error === 'string') {
185
+ throw new Error((res as any).error)
186
+ }
187
+ draft.value = res.persona
188
+ chunksUsed.value = res.chunks_used
189
+ step.value = 4
190
+ } catch (err) {
191
+ buildError.value = err instanceof Error ? err.message : 'unknown error'
192
+ } finally {
193
+ building.value = false
194
+ }
195
+ }
196
+
197
+
198
+ // ─── Step 4: save + optional clone ──────────────────────────────────────
199
+
200
+
201
+ async function savePersona() {
202
+ if (!draft.value) return
203
+ saving.value = true
204
+ try {
205
+ const created = await $fetch<Persona>(`${apiBase}/api/personas`, {
206
+ method: 'POST',
207
+ body: draft.value,
208
+ })
209
+ if (saveAndClone.value && cloneDept.value && cloneTier.value) {
210
+ await $fetch(`${apiBase}/api/personas/${created.id}/clone`, {
211
+ method: 'POST',
212
+ body: { department: cloneDept.value, tier: Number(cloneTier.value) },
213
+ })
214
+ }
215
+ toast.add({
216
+ title: saveAndClone.value ? 'Persona saved + agent cloned' : 'Persona saved',
217
+ description: `${created.name} is now in your board.`,
218
+ color: 'success',
219
+ })
220
+ emit('completed', created)
221
+ } catch (err) {
222
+ toast.add({
223
+ title: 'Save failed',
224
+ description: err instanceof Error ? err.message : 'unknown error',
225
+ color: 'error',
226
+ })
227
+ } finally {
228
+ saving.value = false
229
+ }
230
+ }
231
+
232
+
233
+ // ─── Auto-advance when ingest completes ─────────────────────────────────
234
+
235
+
236
+ watch(allIngestComplete, async (done) => {
237
+ if (done && step.value === 2 && ingestCompletedCount.value > 0) {
238
+ step.value = 3
239
+ await runBuild()
240
+ }
241
+ })
242
+
243
+
244
+ function cancel() {
245
+ disconnectWebSocket()
246
+ emit('cancelled')
247
+ }
248
+
249
+
250
+ function backToStep1() {
251
+ disconnectWebSocket()
252
+ step.value = 1
253
+ ingestJobs.value = []
254
+ }
255
+ </script>
256
+
257
+ <template>
258
+ <UCard>
259
+ <template #header>
260
+ <div class="flex items-center justify-between">
261
+ <div>
262
+ <h3 class="text-lg font-semibold">AI Persona Builder</h3>
263
+ <p class="text-sm text-muted mt-1">
264
+ Step {{ step }} of 4 — {{ {
265
+ 1: 'Sources',
266
+ 2: 'Indexing',
267
+ 3: 'Generating DNA',
268
+ 4: 'Review & save',
269
+ }[step] }}
270
+ </p>
271
+ </div>
272
+ <UButton
273
+ label="Cancel"
274
+ variant="ghost"
275
+ color="neutral"
276
+ size="sm"
277
+ @click="cancel"
278
+ />
279
+ </div>
280
+ </template>
281
+
282
+ <!-- Progress indicator -->
283
+ <div class="flex items-center gap-2 mb-6">
284
+ <div
285
+ v-for="s in [1, 2, 3, 4] as Step[]"
286
+ :key="s"
287
+ class="flex-1 h-1 rounded-full"
288
+ :class="s <= step ? 'bg-primary' : 'bg-muted/30'"
289
+ />
290
+ </div>
291
+
292
+ <!-- Step 1: Sources -->
293
+ <div v-if="step === 1" class="space-y-4">
294
+ <UFormField label="Person name" required>
295
+ <UInput
296
+ v-model="name"
297
+ placeholder="e.g. Alex Hormozi"
298
+ size="lg"
299
+ class="w-full"
300
+ />
301
+ </UFormField>
302
+
303
+ <UFormField label="Source label (optional)" help="How this person should appear in the persona's source field. Defaults to the name above.">
304
+ <UInput
305
+ v-model="sourceLabel"
306
+ placeholder="e.g. Alex Hormozi — $100M Offers / $100M Leads"
307
+ class="w-full"
308
+ />
309
+ </UFormField>
310
+
311
+ <UFormField
312
+ label="Sources (one URL per line)"
313
+ help="YouTube videos, articles, PDFs, blog posts about this person. The builder will search the indexed chunks and synthesise their behavioural DNA. Up to 50 sources per batch."
314
+ >
315
+ <UTextarea
316
+ v-model="sources"
317
+ :rows="6"
318
+ placeholder="https://www.youtube.com/watch?v=...&#10;https://example.com/article&#10;https://example.com/paper.pdf"
319
+ class="w-full font-mono text-sm"
320
+ :disabled="skipIngest"
321
+ />
322
+ </UFormField>
323
+
324
+ <div class="flex items-center justify-between text-xs text-muted">
325
+ <span>{{ sourceLineCount }} source{{ sourceLineCount === 1 ? '' : 's' }} detected</span>
326
+ <UCheckbox
327
+ v-model="skipIngest"
328
+ label="Skip ingest — content for this person is already in the knowledge base"
329
+ />
330
+ </div>
331
+
332
+ <div class="flex justify-end gap-2 pt-4">
333
+ <UButton
334
+ :label="skipIngest ? 'Generate from existing knowledge' : `Index ${sourceLineCount} source${sourceLineCount === 1 ? '' : 's'} & build`"
335
+ icon="i-lucide-arrow-right"
336
+ :disabled="!name.trim() || (!skipIngest && sourceLineCount === 0) || sourceLineCount > 50"
337
+ size="md"
338
+ @click="startIngest"
339
+ />
340
+ </div>
341
+ <p v-if="sourceLineCount > 50" class="text-xs text-red-400">
342
+ Over the 50-source cap. Trim the list before continuing.
343
+ </p>
344
+ </div>
345
+
346
+ <!-- Step 2: Ingest progress -->
347
+ <div v-else-if="step === 2" class="space-y-4">
348
+ <p class="text-sm text-muted">
349
+ Indexing {{ ingestJobs.length }} source{{ ingestJobs.length === 1 ? '' : 's' }} into the knowledge base.
350
+ This auto-advances when complete.
351
+ </p>
352
+ <div class="space-y-2">
353
+ <div
354
+ v-for="(job, idx) in ingestJobs"
355
+ :key="idx"
356
+ class="rounded-lg border border-default p-3"
357
+ >
358
+ <div class="flex items-center gap-3">
359
+ <UIcon
360
+ :name="{
361
+ queued: 'i-lucide-clock',
362
+ processing: 'i-lucide-loader-2 animate-spin',
363
+ completed: 'i-lucide-check-circle',
364
+ failed: 'i-lucide-x-circle',
365
+ }[job.status]"
366
+ :class="{
367
+ queued: 'text-muted',
368
+ processing: 'text-primary',
369
+ completed: 'text-green-500',
370
+ failed: 'text-red-500',
371
+ }[job.status]"
372
+ class="size-4 shrink-0"
373
+ />
374
+ <div class="flex-1 min-w-0">
375
+ <p class="text-sm font-mono truncate">{{ job.source }}</p>
376
+ <UProgress
377
+ v-if="job.status === 'processing' || job.status === 'queued'"
378
+ :value="job.progress"
379
+ :max="100"
380
+ size="xs"
381
+ class="mt-1"
382
+ />
383
+ <p v-if="job.error" class="text-xs text-red-400 mt-1">{{ job.error }}</p>
384
+ </div>
385
+ <span class="text-xs text-muted">{{ job.progress }}%</span>
386
+ </div>
387
+ </div>
388
+ </div>
389
+ </div>
390
+
391
+ <!-- Step 3: Building -->
392
+ <div v-else-if="step === 3" class="space-y-4">
393
+ <div v-if="building" class="flex flex-col items-center gap-4 py-12">
394
+ <UIcon name="i-lucide-loader-2" class="size-12 animate-spin text-primary" />
395
+ <p class="text-sm text-muted">
396
+ Reading indexed chunks about <strong>{{ name }}</strong>…
397
+ </p>
398
+ <p class="text-xs text-muted">
399
+ The builder searches the vector store, joins the top chunks, and asks the configured LLM to extract a behavioural-DNA persona.
400
+ </p>
401
+ </div>
402
+ <div v-else-if="buildError" class="rounded-lg border border-red-500/20 bg-red-500/5 p-4">
403
+ <div class="flex items-start gap-3">
404
+ <UIcon name="i-lucide-alert-circle" class="size-5 text-red-500 mt-0.5 shrink-0" />
405
+ <div class="flex-1">
406
+ <p class="text-sm font-medium text-red-400">Build failed</p>
407
+ <p class="text-xs text-muted mt-1">{{ buildError }}</p>
408
+ </div>
409
+ </div>
410
+ <div class="flex gap-2 mt-3">
411
+ <UButton label="Retry" variant="outline" size="sm" @click="runBuild" />
412
+ <UButton label="Back to sources" variant="ghost" size="sm" @click="backToStep1" />
413
+ </div>
414
+ </div>
415
+ </div>
416
+
417
+ <!-- Step 4: Review & save -->
418
+ <div v-else-if="step === 4 && draft" class="space-y-4">
419
+ <div class="rounded-lg border border-green-500/20 bg-green-500/5 p-3">
420
+ <p class="text-sm text-green-400">
421
+ <UIcon name="i-lucide-sparkles" class="size-4 inline" />
422
+ Built from <strong>{{ chunksUsed }}</strong> knowledge chunk{{ chunksUsed === 1 ? '' : 's' }}. Edit any field below before saving.
423
+ </p>
424
+ </div>
425
+
426
+ <fieldset class="space-y-3">
427
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Identity</legend>
428
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
429
+ <UFormField label="Name">
430
+ <UInput v-model="draft.name" class="w-full" />
431
+ </UFormField>
432
+ <UFormField label="Title">
433
+ <UInput v-model="draft.title" class="w-full" />
434
+ </UFormField>
435
+ </div>
436
+ <UFormField label="Tagline">
437
+ <UInput v-model="draft.tagline" class="w-full" />
438
+ </UFormField>
439
+ </fieldset>
440
+
441
+ <fieldset class="space-y-3">
442
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Behavioural DNA</legend>
443
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
444
+ <UFormField label="MBTI">
445
+ <UInput v-model="draft.mbti" class="w-full" />
446
+ </UFormField>
447
+ <UFormField label="DISC primary">
448
+ <UInput v-model="draft.disc.primary" class="w-full" />
449
+ </UFormField>
450
+ <UFormField label="Enneagram type">
451
+ <UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" />
452
+ </UFormField>
453
+ <UFormField label="Enneagram wing">
454
+ <UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" />
455
+ </UFormField>
456
+ </div>
457
+ </fieldset>
458
+
459
+ <fieldset class="space-y-3">
460
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Knowledge</legend>
461
+ <UFormField label="Mental models" help="comma-separated">
462
+ <UInput
463
+ :model-value="draft.mental_models.join(', ')"
464
+ @update:model-value="(v: string) => draft && (draft.mental_models = v.split(',').map(s => s.trim()).filter(Boolean))"
465
+ class="w-full"
466
+ />
467
+ </UFormField>
468
+ <UFormField label="Expertise domains" help="comma-separated">
469
+ <UInput
470
+ :model-value="draft.expertise_domains.join(', ')"
471
+ @update:model-value="(v: string) => draft && (draft.expertise_domains = v.split(',').map(s => s.trim()).filter(Boolean))"
472
+ class="w-full"
473
+ />
474
+ </UFormField>
475
+ </fieldset>
476
+
477
+ <fieldset class="space-y-3">
478
+ <legend class="text-xs font-bold uppercase tracking-widest text-muted mb-2">Save options</legend>
479
+ <UCheckbox
480
+ v-model="saveAndClone"
481
+ label="Also clone to an agent immediately"
482
+ />
483
+ <div v-if="saveAndClone" class="grid grid-cols-2 gap-3 pl-6">
484
+ <UFormField label="Department">
485
+ <USelect v-model="cloneDept" :items="departmentOptions" class="w-full" />
486
+ </UFormField>
487
+ <UFormField label="Tier">
488
+ <USelect v-model="cloneTier" :items="tierOptions" class="w-full" />
489
+ </UFormField>
490
+ </div>
491
+ </fieldset>
492
+
493
+ <div class="flex justify-end gap-2 pt-4">
494
+ <UButton label="Back" variant="ghost" @click="backToStep1" />
495
+ <UButton
496
+ :label="saveAndClone ? 'Save & clone' : 'Save persona'"
497
+ icon="i-lucide-check"
498
+ :loading="saving"
499
+ @click="savePersona"
500
+ />
501
+ </div>
502
+ </div>
503
+ </UCard>
504
+ </template>
@@ -9,8 +9,30 @@ const { data, status, error, refresh } = fetchApi<{ personas: Persona[]; total:
9
9
 
10
10
  const personas = computed(() => data.value?.personas ?? [])
11
11
 
12
- // --- Form visibility ---
13
- const showForm = ref(false)
12
+ // --- Creation mode ---
13
+ // PR62 v2.79.0 — three modes: list (default), wizard (AI builder), manual.
14
+ // The wizard is the new primary path; manual stays as fallback for
15
+ // operators who want to type every DNA field by hand.
16
+ type CreateMode = 'list' | 'wizard' | 'manual'
17
+ const createMode = ref<CreateMode>('list')
18
+ const showForm = computed(() => createMode.value === 'manual')
19
+
20
+ function startWizard() {
21
+ createMode.value = 'wizard'
22
+ }
23
+
24
+ function startManual() {
25
+ createMode.value = 'manual'
26
+ }
27
+
28
+ function cancelCreation() {
29
+ createMode.value = 'list'
30
+ }
31
+
32
+ async function onWizardComplete() {
33
+ createMode.value = 'list'
34
+ await refresh()
35
+ }
14
36
 
15
37
  // --- Form state ---
16
38
  function defaultForm() {
@@ -216,11 +238,29 @@ function discColor(disc: string): string {
216
238
 
217
239
  <template #right>
218
240
  <UButton
219
- :label="showForm ? 'Cancel' : 'New Persona'"
220
- :icon="showForm ? 'i-lucide-x' : 'i-lucide-plus'"
221
- :variant="showForm ? 'ghost' : 'solid'"
241
+ v-if="createMode === 'list'"
242
+ label="AI Builder"
243
+ icon="i-lucide-sparkles"
244
+ color="primary"
245
+ size="sm"
246
+ @click="startWizard"
247
+ />
248
+ <UButton
249
+ v-if="createMode === 'list'"
250
+ label="Manual"
251
+ icon="i-lucide-plus"
252
+ variant="outline"
253
+ size="sm"
254
+ class="ml-2"
255
+ @click="startManual"
256
+ />
257
+ <UButton
258
+ v-else
259
+ label="Back to list"
260
+ icon="i-lucide-arrow-left"
261
+ variant="ghost"
222
262
  size="sm"
223
- @click="showForm = !showForm"
263
+ @click="cancelCreation"
224
264
  />
225
265
  </template>
226
266
  </UDashboardNavbar>
@@ -242,7 +282,15 @@ function discColor(disc: string): string {
242
282
 
243
283
  <!-- Content -->
244
284
  <template v-else>
245
- <!-- Create Persona Form -->
285
+ <!-- PR62: AI Persona Wizard -->
286
+ <PersonaWizard
287
+ v-if="createMode === 'wizard'"
288
+ class="mb-8"
289
+ @completed="onWizardComplete"
290
+ @cancelled="cancelCreation"
291
+ />
292
+
293
+ <!-- Manual create form (legacy / fallback) -->
246
294
  <UCard v-if="showForm" class="mb-8">
247
295
  <form @submit.prevent="createPersona" class="space-y-8 p-2">
248
296
  <!-- Identity -->
@@ -21,15 +21,31 @@ Hybrid sync engine: Python handles deterministic operations (MCPs, settings, des
21
21
 
22
22
  ## Orchestration (Summary)
23
23
 
24
- 1. **Phases 1–3 + 5 (deterministic, Python):** Run the engine:
24
+ 1. **One-stop: npm refresh + engine (PR61 v2.78.0 orchestrator).**
25
25
  ```bash
26
- cd $ARKAOS_ROOT && python -m core.sync.engine --home ~/.arkaos --skills ~/.claude/skills --output json
26
+ cd $ARKAOS_ROOT && python -m core.sync.update_orchestrator --home ~/.arkaos --skills ~/.claude/skills --output json
27
27
  ```
28
- Handles manifest, discovery, MCP sync, settings sync, descriptors, and writes `~/.arkaos/sync-state.json`.
28
+ The orchestrator detects whether the running ArkaOS is behind npm
29
+ latest. When stale, it shells out to `npx arkaos@latest update`
30
+ first so the sync engine below reads fresh code; when current, it
31
+ skips straight to the engine. Either way it runs the
32
+ deterministic engine (manifest, discovery, MCP sync, settings
33
+ sync, descriptors, content, agents) and writes
34
+ `~/.arkaos/sync-state.json`.
35
+
36
+ Probe is cached for 1 hour in `~/.arkaos/npm-latest.cache.json`
37
+ to keep repeat runs cheap. Offline / `npx` missing → orchestrator
38
+ silently skips the npm step and falls through to the engine.
39
+
40
+ Fallback (no orchestrator): the underlying engine still runs the
41
+ same way via `python -m core.sync.engine ...` for callers that
42
+ don't need the version-drift gate.
29
43
 
30
44
  2. **Phase 4 (intelligent, AI subagent):** After the engine completes, dispatch ONE subagent with the engine's JSON report + the feature registry (`core/sync/features/*.yaml`). The subagent injects/removes feature sections in each `~/.claude/skills/arka-{ecosystem}/SKILL.md` while preserving all custom content.
31
45
 
32
- 3. **Report:** Display the formatted summary returned by the engine.
46
+ 3. **Report:** Display the formatted summary returned by the engine,
47
+ plus `installed_version_before` / `latest_version_seen` from the
48
+ orchestrator so the operator sees what got refreshed.
33
49
 
34
50
  ## Error Handling (Summary)
35
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.77.0",
3
+ "version": "2.79.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.77.0"
3
+ version = "2.79.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}