@pellux/goodvibes-agent 0.1.1 → 0.1.3

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 (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +12 -1
  3. package/docs/README.md +2 -0
  4. package/docs/getting-started.md +19 -1
  5. package/docs/release-and-publishing.md +3 -1
  6. package/package.json +10 -1
  7. package/src/agent/persona-registry.ts +379 -0
  8. package/src/agent/skill-registry.ts +360 -0
  9. package/src/audio/spoken-turn-model-routing.ts +2 -1
  10. package/src/cli/agent-knowledge-command.ts +525 -0
  11. package/src/cli/help.ts +35 -0
  12. package/src/cli/management-commands.ts +3 -1
  13. package/src/cli/management.ts +33 -9
  14. package/src/cli/parser.ts +7 -0
  15. package/src/cli/types.ts +3 -0
  16. package/src/config/surface.ts +1 -0
  17. package/src/input/agent-workspace.ts +33 -3
  18. package/src/input/command-registry.ts +4 -1
  19. package/src/input/commands/agent-skills-runtime.ts +216 -0
  20. package/src/input/commands/delegation-runtime.ts +129 -0
  21. package/src/input/commands/knowledge.ts +18 -18
  22. package/src/input/commands/personas-runtime.ts +219 -0
  23. package/src/input/commands/shell-core.ts +9 -6
  24. package/src/input/commands/skills-runtime.ts +7 -2
  25. package/src/input/commands.ts +6 -0
  26. package/src/input/panel-integration-actions.ts +0 -52
  27. package/src/input/submission-router.ts +1 -1
  28. package/src/main.ts +2 -1
  29. package/src/panels/builtin/agent.ts +0 -14
  30. package/src/panels/builtin/session.ts +4 -3
  31. package/src/panels/index.ts +0 -5
  32. package/src/panels/orchestration-panel.ts +4 -5
  33. package/src/panels/qr-panel.ts +3 -2
  34. package/src/panels/tasks-panel.ts +4 -4
  35. package/src/renderer/agent-workspace.ts +2 -0
  36. package/src/runtime/bootstrap-command-context.ts +3 -0
  37. package/src/runtime/bootstrap-command-parts.ts +6 -2
  38. package/src/runtime/bootstrap-core.ts +8 -4
  39. package/src/runtime/bootstrap-shell.ts +5 -2
  40. package/src/runtime/bootstrap.ts +10 -2
  41. package/src/runtime/cloudflare-control-plane.ts +2 -1
  42. package/src/version.ts +1 -1
  43. package/src/daemon/cli.ts +0 -55
  44. package/src/daemon/safe-serve.ts +0 -61
  45. package/src/panels/diff-panel.ts +0 -520
  46. package/src/panels/file-explorer-panel.ts +0 -584
  47. package/src/panels/file-preview-panel.ts +0 -434
  48. package/src/panels/git-panel.ts +0 -638
  49. package/src/panels/sandbox-panel.ts +0 -283
  50. package/src/panels/symbol-outline-panel.ts +0 -486
  51. package/src/panels/worktree-panel.ts +0 -182
  52. package/src/panels/wrfc-panel.ts +0 -609
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to GoodVibes Agent will be recorded here.
4
4
 
5
+ ## 0.1.3 - 2026-05-31
6
+
7
+ - Added local Agent personas with `/personas`: create/list/search/show/use/review/stale/delete, secret-looking value rejection, active persona prompt injection, and operator workspace status.
8
+ - Added local Agent skills with `/agent-skills` and `/skills local`: create/list/search/show/enable/disable/review/stale/delete, secret-looking value rejection, enabled skill prompt injection, and operator workspace status.
9
+ - Kept persona and skill state Agent-local with no default Knowledge/Wiki or HomeGraph fallback.
10
+
11
+ ## 0.1.2 - 2026-05-30
12
+
13
+ - Added `goodvibes-agent compat` for package SDK pin, external daemon version, auth presence, and isolated Agent Knowledge route readiness.
14
+ - Added `goodvibes-agent knowledge ...` commands for the isolated `/api/goodvibes-agent/knowledge/*` environment with no default Knowledge/Wiki or HomeGraph fallback.
15
+ - Added explicit GoodVibes TUI build delegation through `goodvibes-agent delegate` and `/delegate`; WRFC is requested only through explicit `--wrfc`, `/wrfc`, or `/review` delegation.
16
+ - Removed the copied WRFC panel from the default Agent panel registry while preserving explicit TUI delegation for build/fix/review work.
17
+ - Hardened the Agent release helper and CLI help output for the current Agent changelog and command set.
18
+
5
19
  ## 0.1.1 - 2026-05-30
6
20
 
7
21
  - Reissued the first public alpha package after the initial `0.1.0` registry publish produced an install-blocking npm packument inconsistency.
package/README.md CHANGED
@@ -43,6 +43,15 @@ bun run publish:check
43
43
 
44
44
  Inside the Agent TUI, use `/agent`, `/home`, or `/operator` to open the operator workspace. It is the Agent-first fullscreen surface for setup, status, knowledge, local memory/skills, work-plan/approval review, automation observability, and explicit build delegation to GoodVibes TUI.
45
45
 
46
+ Local Agent behavior is editable from the TUI:
47
+
48
+ ```text
49
+ /personas create --name Research --description "Source-backed research" --body "Check sources, call out uncertainty, keep answers concise."
50
+ /personas use research
51
+ /agent-skills create --name "Morning Brief" --description "Daily briefing flow" --procedure "Check tasks, approvals, calendar, and unread state before summarizing." --enabled true
52
+ /skills local list
53
+ ```
54
+
46
55
  ## Daemon Prerequisite
47
56
 
48
57
  Start or restart the daemon from GoodVibes TUI or the daemon host before launching Agent. Agent status and companion/knowledge routes connect to that external daemon, normally on `http://127.0.0.1:3421`.
@@ -59,7 +68,9 @@ Those commands should return explicit external-daemon guidance instead of mutati
59
68
 
60
69
  ## Product Boundary
61
70
 
62
- GoodVibes Agent owns the operator assistant surface: serial assistant flow, proactive safe actions, local memory/skills/personas until stable shared registries exist, Agent knowledge routes, companion chat, approvals/automation observability, and explicit build delegation.
71
+ GoodVibes Agent owns the operator assistant surface: serial assistant flow, proactive safe actions, local memory/skills/personas, Agent knowledge routes, companion chat, approvals/automation observability, and explicit build delegation.
72
+
73
+ Agent Knowledge/Wiki is its own product segment. Agent uses `/api/goodvibes-agent/knowledge/*` and must not fall back to default Knowledge/Wiki, HomeGraph, or Home Assistant routes.
63
74
 
64
75
  GoodVibes TUI owns coding execution: file edits, git/worktree workflows, coding panels, sandbox/QEMU UX, and WRFC execution. Agent may delegate explicit build/fix/review work to TUI through public daemon/session contracts; normal assistant chat must not use shared coding sessions.
65
76
 
package/docs/README.md CHANGED
@@ -17,6 +17,8 @@ Important baseline constraints:
17
17
  - Agent depends on `@pellux/goodvibes-sdk@0.33.35`.
18
18
  - Agent connects to an externally managed daemon.
19
19
  - Agent does not start, stop, restart, install, uninstall, or own daemon/listener/web/service lifecycle.
20
+ - Agent Knowledge/Wiki uses only `/api/goodvibes-agent/knowledge/*`; there is no default Knowledge/Wiki, HomeGraph, or Home Assistant fallback.
21
+ - Local personas and Agent skills are stored under the Agent surface root and are injected only into the serial Agent conversation.
20
22
  - Normal assistant chat is not coding-session delegation.
21
23
  - Build/fix/review delegation to GoodVibes TUI must be explicit; WRFC is not the default Agent behavior.
22
24
 
@@ -1,6 +1,6 @@
1
1
  # Getting Started
2
2
 
3
- GoodVibes Agent `0.1.1` is the first installable public alpha of the personal operator assistant built on the GoodVibes TUI foundation.
3
+ GoodVibes Agent `0.1.3` is the current installable public alpha of the personal operator assistant built on the GoodVibes TUI foundation.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -37,6 +37,21 @@ bun run dev
37
37
 
38
38
  Once the TUI opens, run `/agent`, `/home`, or `/operator` to open the Agent operator workspace. That fullscreen workspace is the current front door for setup/config, knowledge status, local memory and skills, read-only work/approval/automation views, and explicit GoodVibes TUI build delegation.
39
39
 
40
+ ## Local Personas And Skills
41
+
42
+ Personas and reusable Agent skills are local to GoodVibes Agent. They do not write into default Knowledge/Wiki or HomeGraph.
43
+
44
+ ```text
45
+ /personas list
46
+ /personas create --name Research --description "Source-backed research" --body "Check sources, call out uncertainty, keep answers concise."
47
+ /personas use research
48
+ /agent-skills create --name "Morning Brief" --description "Daily briefing flow" --procedure "Check tasks, approvals, calendar, and unread state before summarizing." --enabled true
49
+ /agent-skills enabled
50
+ /skills local list
51
+ ```
52
+
53
+ The active persona and enabled Agent skills are injected into the main serial assistant conversation. They do not spawn background agents.
54
+
40
55
  ## External Daemon
41
56
 
42
57
  Start the daemon from GoodVibes TUI or the daemon host before using daemon-backed Agent features. Agent expects the daemon to expose the public operator/Agent routes, including:
@@ -45,6 +60,9 @@ Start the daemon from GoodVibes TUI or the daemon host before using daemon-backe
45
60
  - `/api/goodvibes-agent/knowledge/status`
46
61
  - `/api/goodvibes-agent/knowledge/ask`
47
62
  - `/api/goodvibes-agent/knowledge/search`
63
+ - `/api/goodvibes-agent/knowledge/ingest/url`
64
+
65
+ Agent Knowledge/Wiki is an Agent-owned product segment. Agent commands must not fall back to default Knowledge/Wiki, HomeGraph, or Home Assistant spaces.
48
66
 
49
67
  Agent lifecycle commands that would start or mutate daemon posture are blocked intentionally. Use `goodvibes-agent status`, `goodvibes-agent doctor`, and read-only surface checks for diagnostics.
50
68
 
@@ -1,6 +1,6 @@
1
1
  # Release And Publishing
2
2
 
3
- GoodVibes Agent `0.1.1` is the first installable public alpha release.
3
+ GoodVibes Agent `0.1.3` is the current installable public alpha release.
4
4
 
5
5
  ## Package Identity
6
6
 
@@ -39,6 +39,8 @@ Also run the package install smoke from a packed artifact. It must prove:
39
39
 
40
40
  Do not publish if package-facing docs or install commands refer to another package name, another executable, or Agent-owned daemon lifecycle.
41
41
 
42
+ Do not publish if Agent Knowledge commands can fall back to default Knowledge/Wiki, HomeGraph, or Home Assistant routes. Agent Knowledge must use the isolated `/api/goodvibes-agent/knowledge/*` segment.
43
+
42
44
  Do not ship daemon binaries from this package. If Agent later gets compiled artifacts, they must use Agent artifact names and remain separate from daemon ownership.
43
45
 
44
46
  ## Near-Fork Baseline Rule
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "description": "Near-fork GoodVibes operator assistant with the GoodVibes TUI shell, renderer, input, fullscreen workspace, and daemon-connected Agent product brain.",
6
6
  "type": "module",
@@ -18,6 +18,15 @@
18
18
  "!src/test",
19
19
  "!src/**/*.test.ts",
20
20
  "!src/**/__tests__",
21
+ "!src/daemon/**",
22
+ "!src/panels/diff-panel.ts",
23
+ "!src/panels/file-explorer-panel.ts",
24
+ "!src/panels/file-preview-panel.ts",
25
+ "!src/panels/git-panel.ts",
26
+ "!src/panels/sandbox-panel.ts",
27
+ "!src/panels/symbol-outline-panel.ts",
28
+ "!src/panels/worktree-panel.ts",
29
+ "!src/panels/wrfc-panel.ts",
21
30
  "scripts/check-bun.sh",
22
31
  "tsconfig.json",
23
32
  "README.md",
@@ -0,0 +1,379 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { ShellPathService } from '@/runtime/index.ts';
4
+ import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
5
+
6
+ export type AgentPersonaSource = 'user' | 'agent' | 'imported' | 'system';
7
+ export type AgentPersonaReviewState = 'fresh' | 'reviewed' | 'stale';
8
+
9
+ export interface AgentPersonaRecord {
10
+ readonly id: string;
11
+ readonly name: string;
12
+ readonly description: string;
13
+ readonly body: string;
14
+ readonly tags: readonly string[];
15
+ readonly triggers: readonly string[];
16
+ readonly source: AgentPersonaSource;
17
+ readonly provenance: string;
18
+ readonly reviewState: AgentPersonaReviewState;
19
+ readonly staleReason?: string;
20
+ readonly createdAt: string;
21
+ readonly updatedAt: string;
22
+ readonly reviewedAt?: string;
23
+ }
24
+
25
+ export interface AgentPersonaCreateInput {
26
+ readonly name: string;
27
+ readonly description: string;
28
+ readonly body: string;
29
+ readonly tags?: readonly string[];
30
+ readonly triggers?: readonly string[];
31
+ readonly source?: AgentPersonaSource;
32
+ readonly provenance?: string;
33
+ }
34
+
35
+ export interface AgentPersonaUpdateInput {
36
+ readonly name?: string;
37
+ readonly description?: string;
38
+ readonly body?: string;
39
+ readonly tags?: readonly string[];
40
+ readonly triggers?: readonly string[];
41
+ readonly provenance?: string;
42
+ }
43
+
44
+ export interface AgentPersonaSnapshot {
45
+ readonly path: string;
46
+ readonly personas: readonly AgentPersonaRecord[];
47
+ readonly activePersonaId: string | null;
48
+ readonly activePersona: AgentPersonaRecord | null;
49
+ }
50
+
51
+ interface PersonaStoreFile {
52
+ readonly version: 1;
53
+ readonly activePersonaId: string | null;
54
+ readonly personas: readonly AgentPersonaRecord[];
55
+ }
56
+
57
+ const STORE_VERSION = 1;
58
+ const SECRET_PATTERNS: readonly RegExp[] = [
59
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
60
+ /\bsk-[A-Za-z0-9_-]{16,}\b/,
61
+ /\bgh[pousr]_[A-Za-z0-9_]{16,}\b/i,
62
+ /\b(?:password|passwd|api[_-]?key|token|secret)\s*[:=]\s*\S{6,}/i,
63
+ ];
64
+
65
+ function isRecord(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
67
+ }
68
+
69
+ function readString(value: unknown, fallback = ''): string {
70
+ return typeof value === 'string' ? value : fallback;
71
+ }
72
+
73
+ function readStringArray(value: unknown): string[] {
74
+ if (!Array.isArray(value)) return [];
75
+ return value.filter((entry): entry is string => typeof entry === 'string').map((entry) => entry.trim()).filter(Boolean);
76
+ }
77
+
78
+ function normalizeName(name: string): string {
79
+ return name.trim().replace(/\s+/g, ' ');
80
+ }
81
+
82
+ function normalizeList(values: readonly string[] | undefined): string[] {
83
+ const seen = new Set<string>();
84
+ const result: string[] = [];
85
+ for (const value of values ?? []) {
86
+ const trimmed = value.trim();
87
+ if (!trimmed) continue;
88
+ const key = trimmed.toLowerCase();
89
+ if (seen.has(key)) continue;
90
+ seen.add(key);
91
+ result.push(trimmed);
92
+ }
93
+ return result;
94
+ }
95
+
96
+ function slugify(value: string): string {
97
+ const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
98
+ return slug || 'persona';
99
+ }
100
+
101
+ function nowIso(): string {
102
+ return new Date().toISOString();
103
+ }
104
+
105
+ function containsSecretLikeText(text: string): boolean {
106
+ return SECRET_PATTERNS.some((pattern) => pattern.test(text));
107
+ }
108
+
109
+ export function assertNoSecretLikeText(fields: readonly string[]): void {
110
+ if (fields.some((field) => containsSecretLikeText(field))) {
111
+ throw new Error('Personas cannot store secret-looking values. Store a secret reference or remove the sensitive text.');
112
+ }
113
+ }
114
+
115
+ function parsePersona(value: unknown): AgentPersonaRecord | null {
116
+ if (!isRecord(value)) return null;
117
+ const id = readString(value.id).trim();
118
+ const name = normalizeName(readString(value.name));
119
+ const description = readString(value.description).trim();
120
+ const body = readString(value.body).trim();
121
+ if (!id || !name || !description || !body) return null;
122
+ const reviewState = value.reviewState === 'reviewed' || value.reviewState === 'stale' ? value.reviewState : 'fresh';
123
+ const source = value.source === 'agent' || value.source === 'imported' || value.source === 'system' ? value.source : 'user';
124
+ const staleReason = readString(value.staleReason).trim();
125
+ const reviewedAt = readString(value.reviewedAt).trim();
126
+ return {
127
+ id,
128
+ name,
129
+ description,
130
+ body,
131
+ tags: readStringArray(value.tags),
132
+ triggers: readStringArray(value.triggers),
133
+ source,
134
+ provenance: readString(value.provenance, source).trim() || source,
135
+ reviewState,
136
+ staleReason: staleReason || undefined,
137
+ createdAt: readString(value.createdAt, nowIso()),
138
+ updatedAt: readString(value.updatedAt, nowIso()),
139
+ reviewedAt: reviewedAt || undefined,
140
+ };
141
+ }
142
+
143
+ function parseStore(raw: string): PersonaStoreFile {
144
+ const parsed: unknown = JSON.parse(raw);
145
+ if (!isRecord(parsed)) return { version: STORE_VERSION, activePersonaId: null, personas: [] };
146
+ const personas = Array.isArray(parsed.personas)
147
+ ? parsed.personas.map(parsePersona).filter((entry): entry is AgentPersonaRecord => entry !== null)
148
+ : [];
149
+ const activePersonaId = readString(parsed.activePersonaId).trim() || null;
150
+ return {
151
+ version: STORE_VERSION,
152
+ activePersonaId: activePersonaId && personas.some((persona) => persona.id === activePersonaId) ? activePersonaId : null,
153
+ personas,
154
+ };
155
+ }
156
+
157
+ function formatStore(store: PersonaStoreFile): string {
158
+ return `${JSON.stringify(store, null, 2)}\n`;
159
+ }
160
+
161
+ export function personaStorePath(shellPaths: ShellPathService): string {
162
+ return shellPaths.resolveUserPath(GOODVIBES_AGENT_SURFACE_ROOT, 'personas', 'personas.json');
163
+ }
164
+
165
+ export class AgentPersonaRegistry {
166
+ public constructor(private readonly storePath: string) {}
167
+
168
+ public static fromShellPaths(shellPaths: ShellPathService): AgentPersonaRegistry {
169
+ return new AgentPersonaRegistry(personaStorePath(shellPaths));
170
+ }
171
+
172
+ public snapshot(): AgentPersonaSnapshot {
173
+ const store = this.readStore();
174
+ const activePersona = store.activePersonaId
175
+ ? store.personas.find((persona) => persona.id === store.activePersonaId) ?? null
176
+ : null;
177
+ return {
178
+ path: this.storePath,
179
+ personas: [...store.personas],
180
+ activePersonaId: activePersona?.id ?? null,
181
+ activePersona,
182
+ };
183
+ }
184
+
185
+ public list(): readonly AgentPersonaRecord[] {
186
+ return this.snapshot().personas;
187
+ }
188
+
189
+ public search(query: string): readonly AgentPersonaRecord[] {
190
+ const normalized = query.trim().toLowerCase();
191
+ if (!normalized) return this.list();
192
+ return this.list().filter((persona) => [
193
+ persona.id,
194
+ persona.name,
195
+ persona.description,
196
+ persona.body,
197
+ ...persona.tags,
198
+ ...persona.triggers,
199
+ ].some((field) => field.toLowerCase().includes(normalized)));
200
+ }
201
+
202
+ public get(idOrName: string): AgentPersonaRecord | null {
203
+ const lookup = idOrName.trim().toLowerCase();
204
+ if (!lookup) return null;
205
+ return this.list().find((persona) => persona.id.toLowerCase() === lookup || persona.name.toLowerCase() === lookup) ?? null;
206
+ }
207
+
208
+ public create(input: AgentPersonaCreateInput): AgentPersonaRecord {
209
+ const store = this.readStore();
210
+ const name = normalizeName(input.name);
211
+ const description = input.description.trim();
212
+ const body = input.body.trim();
213
+ this.validateRequired(name, description, body);
214
+ assertNoSecretLikeText([name, description, body, ...(input.tags ?? []), ...(input.triggers ?? [])]);
215
+ const duplicate = store.personas.find((persona) => persona.name.toLowerCase() === name.toLowerCase());
216
+ if (duplicate) throw new Error(`Persona already exists: ${duplicate.id}`);
217
+ const timestamp = nowIso();
218
+ const persona: AgentPersonaRecord = {
219
+ id: this.nextId(name, store.personas),
220
+ name,
221
+ description,
222
+ body,
223
+ tags: normalizeList(input.tags),
224
+ triggers: normalizeList(input.triggers),
225
+ source: input.source ?? 'user',
226
+ provenance: input.provenance?.trim() || input.source || 'user',
227
+ reviewState: 'fresh',
228
+ createdAt: timestamp,
229
+ updatedAt: timestamp,
230
+ };
231
+ this.writeStore({ ...store, personas: [...store.personas, persona] });
232
+ return persona;
233
+ }
234
+
235
+ public update(idOrName: string, input: AgentPersonaUpdateInput): AgentPersonaRecord {
236
+ const store = this.readStore();
237
+ const existing = this.findInStore(store, idOrName);
238
+ if (!existing) throw new Error(`Unknown persona: ${idOrName}`);
239
+ const name = input.name === undefined ? existing.name : normalizeName(input.name);
240
+ const description = input.description === undefined ? existing.description : input.description.trim();
241
+ const body = input.body === undefined ? existing.body : input.body.trim();
242
+ this.validateRequired(name, description, body);
243
+ assertNoSecretLikeText([name, description, body, ...(input.tags ?? []), ...(input.triggers ?? [])]);
244
+ const duplicate = store.personas.find((persona) => persona.id !== existing.id && persona.name.toLowerCase() === name.toLowerCase());
245
+ if (duplicate) throw new Error(`Persona already exists: ${duplicate.id}`);
246
+ const updated: AgentPersonaRecord = {
247
+ ...existing,
248
+ name,
249
+ description,
250
+ body,
251
+ tags: input.tags === undefined ? existing.tags : normalizeList(input.tags),
252
+ triggers: input.triggers === undefined ? existing.triggers : normalizeList(input.triggers),
253
+ provenance: input.provenance === undefined ? existing.provenance : input.provenance.trim() || existing.provenance,
254
+ reviewState: 'fresh',
255
+ staleReason: undefined,
256
+ reviewedAt: undefined,
257
+ updatedAt: nowIso(),
258
+ };
259
+ this.writeStore({
260
+ ...store,
261
+ personas: store.personas.map((persona) => persona.id === existing.id ? updated : persona),
262
+ });
263
+ return updated;
264
+ }
265
+
266
+ public setActive(idOrName: string): AgentPersonaRecord {
267
+ const store = this.readStore();
268
+ const persona = this.findInStore(store, idOrName);
269
+ if (!persona) throw new Error(`Unknown persona: ${idOrName}`);
270
+ this.writeStore({ ...store, activePersonaId: persona.id });
271
+ return persona;
272
+ }
273
+
274
+ public clearActive(): void {
275
+ const store = this.readStore();
276
+ this.writeStore({ ...store, activePersonaId: null });
277
+ }
278
+
279
+ public markReviewed(idOrName: string): AgentPersonaRecord {
280
+ const store = this.readStore();
281
+ const existing = this.findInStore(store, idOrName);
282
+ if (!existing) throw new Error(`Unknown persona: ${idOrName}`);
283
+ const updated: AgentPersonaRecord = {
284
+ ...existing,
285
+ reviewState: 'reviewed',
286
+ staleReason: undefined,
287
+ reviewedAt: nowIso(),
288
+ updatedAt: nowIso(),
289
+ };
290
+ this.writeStore({
291
+ ...store,
292
+ personas: store.personas.map((persona) => persona.id === existing.id ? updated : persona),
293
+ });
294
+ return updated;
295
+ }
296
+
297
+ public markStale(idOrName: string, reason: string): AgentPersonaRecord {
298
+ const store = this.readStore();
299
+ const existing = this.findInStore(store, idOrName);
300
+ if (!existing) throw new Error(`Unknown persona: ${idOrName}`);
301
+ const updated: AgentPersonaRecord = {
302
+ ...existing,
303
+ reviewState: 'stale',
304
+ staleReason: reason.trim() || 'Marked stale by user.',
305
+ updatedAt: nowIso(),
306
+ };
307
+ this.writeStore({
308
+ ...store,
309
+ personas: store.personas.map((persona) => persona.id === existing.id ? updated : persona),
310
+ });
311
+ return updated;
312
+ }
313
+
314
+ public deletePersona(idOrName: string): AgentPersonaRecord {
315
+ const store = this.readStore();
316
+ const existing = this.findInStore(store, idOrName);
317
+ if (!existing) throw new Error(`Unknown persona: ${idOrName}`);
318
+ this.writeStore({
319
+ ...store,
320
+ activePersonaId: store.activePersonaId === existing.id ? null : store.activePersonaId,
321
+ personas: store.personas.filter((persona) => persona.id !== existing.id),
322
+ });
323
+ return existing;
324
+ }
325
+
326
+ private validateRequired(name: string, description: string, body: string): void {
327
+ if (!name) throw new Error('Persona name is required.');
328
+ if (!description) throw new Error('Persona description is required.');
329
+ if (!body) throw new Error('Persona body is required.');
330
+ }
331
+
332
+ private nextId(name: string, personas: readonly AgentPersonaRecord[]): string {
333
+ const base = slugify(name);
334
+ const ids = new Set(personas.map((persona) => persona.id));
335
+ if (!ids.has(base)) return base;
336
+ for (let index = 2; index < 1000; index += 1) {
337
+ const candidate = `${base}-${index}`;
338
+ if (!ids.has(candidate)) return candidate;
339
+ }
340
+ throw new Error(`Could not allocate persona id for ${name}.`);
341
+ }
342
+
343
+ private findInStore(store: PersonaStoreFile, idOrName: string): AgentPersonaRecord | null {
344
+ const lookup = idOrName.trim().toLowerCase();
345
+ if (!lookup) return null;
346
+ return store.personas.find((persona) => persona.id.toLowerCase() === lookup || persona.name.toLowerCase() === lookup) ?? null;
347
+ }
348
+
349
+ private readStore(): PersonaStoreFile {
350
+ if (!existsSync(this.storePath)) return { version: STORE_VERSION, activePersonaId: null, personas: [] };
351
+ try {
352
+ return parseStore(readFileSync(this.storePath, 'utf-8'));
353
+ } catch (error) {
354
+ throw new Error(`Could not read Agent persona store: ${error instanceof Error ? error.message : String(error)}`);
355
+ }
356
+ }
357
+
358
+ private writeStore(store: PersonaStoreFile): void {
359
+ mkdirSync(dirname(this.storePath), { recursive: true });
360
+ const tmpPath = `${this.storePath}.tmp`;
361
+ writeFileSync(tmpPath, formatStore(store), 'utf-8');
362
+ renameSync(tmpPath, this.storePath);
363
+ }
364
+ }
365
+
366
+ export function buildActivePersonaPrompt(shellPaths: ShellPathService): string | null {
367
+ const active = AgentPersonaRegistry.fromShellPaths(shellPaths).snapshot().activePersona;
368
+ if (!active) return null;
369
+ return [
370
+ '## Active GoodVibes Agent Persona',
371
+ `Name: ${active.name}`,
372
+ `Description: ${active.description}`,
373
+ `Review state: ${active.reviewState}`,
374
+ '',
375
+ active.body,
376
+ '',
377
+ 'Apply this persona inside the same serial assistant conversation. Do not spawn background agents because a persona is active.',
378
+ ].join('\n');
379
+ }