@monster-orange/den 0.4.12 → 0.4.13
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/CHANGELOG.md +102 -0
- package/README.md +7 -7
- package/docs/distributions.md +146 -10
- package/package.json +1 -1
- package/src/shared/agents/default-pack/be-dev.md +1 -0
- package/src/shared/agents/default-pack/devops.md +1 -0
- package/src/shared/agents/default-pack/fe-dev.md +1 -0
- package/src/shared/agents/default-pack/orchestrator.md +3 -1
- package/src/shared/agents/default-pack/planner.md +1 -0
- package/src/shared/agents/default-pack/reviewer.md +1 -0
- package/src/shared/agents/default-pack/tester.md +16 -0
- package/src/shared/agents/default-pack/ux-designer.md +1 -0
- package/src/shared/agents/den-claude.ts +119 -13
- package/src/shared/agents/permissions.ts +108 -0
- package/src/shared/agents/persona-store.ts +286 -0
- package/src/shared/agents/personas.ts +116 -12
- package/src/shared/agents/records.ts +28 -0
- package/src/shared/agents/roles.ts +288 -0
- package/src/shared/agents/runtime.ts +41 -0
- package/src/shared/den-config.ts +20 -1
- package/src/ui/cli/agent-ops.ts +535 -17
- package/src/ui/cli/agents-command.ts +12 -2
- package/src/ui/cli/env-probe.ts +4 -1
- package/src/ui/mcp/tools.ts +1 -1
- package/src/ui/server/agent-routes.ts +260 -21
- package/src/ui/server/public/agent-start.js +3 -2
- package/src/ui/server/public/agents-page.js +499 -48
- package/src/ui/server/public/agents.html +58 -15
- package/src/ui/server/public/i18n/es.js +120 -0
- package/src/ui/server/public/i18n/ja.js +120 -0
- package/src/ui/server/public/persona-ui.css +539 -0
- package/src/ui/server/public/persona-ui.js +796 -0
- package/src/ui/server/public/settings-page.js +4 -9
- package/src/ui/server/public/settings-registry.js +22 -0
- package/src/ui/server/public/settings.html +1 -0
- package/src/ui/server/public/shell.js +117 -0
- package/src/ui/server/public/topbar.js +2 -2
- package/src/ui/server/route-classes.ts +18 -0
- package/src/ui/server/session-routes.ts +21 -5
- package/src/ui/server/ui-assets.ts +3 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissions as intent (AG-13): what a person chooses, before any
|
|
3
|
+
* runtime's syntax.
|
|
4
|
+
*
|
|
5
|
+
* ask Ask for everything (the default)
|
|
6
|
+
* edits Auto-approve edits in the worktree
|
|
7
|
+
* all Auto-approve all (a session started so is always tagged)
|
|
8
|
+
*
|
|
9
|
+
* And extra allowed commands: plain command prefixes ("npm test"), one per
|
|
10
|
+
* entry, no patterns. Each runtime translates both into its own launch
|
|
11
|
+
* config (den-claude.ts); a runtime that can't express one says so, and it
|
|
12
|
+
* is never silently dropped. What a persona pack shares is this intent
|
|
13
|
+
* only, never vendor syntax.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const PERMISSION_INTENTS = ["ask", "edits", "all"] as const;
|
|
17
|
+
export type PermissionIntent = (typeof PERMISSION_INTENTS)[number];
|
|
18
|
+
export const DEFAULT_INTENT: PermissionIntent = "ask";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A persona's `permission_mode` as an intent. Den's three intents are
|
|
22
|
+
* themselves; Claude Code's own modes from before AG-13 read as the intent
|
|
23
|
+
* they meant (default, acceptEdits, bypassPermissions). Anything else
|
|
24
|
+
* (Claude Code's plan, a typo) is not an intent: undefined, and the
|
|
25
|
+
* runtime decides (den-claude refuses a mode it doesn't know).
|
|
26
|
+
*/
|
|
27
|
+
export function intentOf(
|
|
28
|
+
mode: string | undefined,
|
|
29
|
+
): PermissionIntent | undefined {
|
|
30
|
+
if (mode === undefined) return DEFAULT_INTENT;
|
|
31
|
+
if ((PERMISSION_INTENTS as readonly string[]).includes(mode))
|
|
32
|
+
return mode as PermissionIntent;
|
|
33
|
+
if (mode === "default") return "ask";
|
|
34
|
+
if (mode === "acceptEdits") return "edits";
|
|
35
|
+
if (mode === "bypassPermissions") return "all";
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The most extra commands one persona or launch takes. */
|
|
40
|
+
export const MAX_COMMANDS = 32;
|
|
41
|
+
/** The longest command prefix. */
|
|
42
|
+
export const MAX_COMMAND_LEN = 120;
|
|
43
|
+
/**
|
|
44
|
+
* What a plain prefix is made of: letters, digits, spaces and . _ / : = @
|
|
45
|
+
* % + , -. No shell syntax (quotes, ;, &, |, $, backticks, redirects) and
|
|
46
|
+
* no pattern characters (* ? [ ] ^ ( ) { }): Den translates a prefix, it
|
|
47
|
+
* never passes one through a shell.
|
|
48
|
+
*/
|
|
49
|
+
const PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9 ._/:=@%+,-]*$/;
|
|
50
|
+
const PATTERN_RE = /[*?[\]^$(){}|]/;
|
|
51
|
+
|
|
52
|
+
export type CommandProblem = "pattern" | "invalid" | "long";
|
|
53
|
+
|
|
54
|
+
/** Why `c` isn't a plain prefix, or null when it is. */
|
|
55
|
+
export function commandProblem(c: string): CommandProblem | null {
|
|
56
|
+
if ([...c].length > MAX_COMMAND_LEN) return "long";
|
|
57
|
+
if (PATTERN_RE.test(c)) return "pattern";
|
|
58
|
+
if (!PREFIX_RE.test(c) || /\s{2,}/.test(c) || c !== c.trim())
|
|
59
|
+
return "invalid";
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const shown = (v: unknown) =>
|
|
64
|
+
(JSON.stringify(v) ?? "undefined").replace(/[^\x20-\x7e]/g, "?").slice(0, 60);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A list of extra allowed commands, checked: an array of plain prefixes
|
|
68
|
+
* (each trimmed, duplicates dropped), at most MAX_COMMANDS. undefined or
|
|
69
|
+
* null is none. Anything else throws, naming the entry and why.
|
|
70
|
+
*/
|
|
71
|
+
export function checkCommands(v: unknown, where: string): string[] {
|
|
72
|
+
if (v === undefined || v === null) return [];
|
|
73
|
+
if (!Array.isArray(v))
|
|
74
|
+
throw new Error(`${where}: must be a list of command prefixes`);
|
|
75
|
+
if (v.length > MAX_COMMANDS)
|
|
76
|
+
throw new Error(`${where}: at most ${MAX_COMMANDS} commands`);
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
v.forEach((x, i) => {
|
|
79
|
+
if (typeof x !== "string")
|
|
80
|
+
throw new Error(`${where}[${i}]: must be a string`);
|
|
81
|
+
const c = x.trim();
|
|
82
|
+
const why = c ? commandProblem(c) : "invalid";
|
|
83
|
+
if (why === "pattern")
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${where}[${i}]: ${shown(c)} is a pattern: use a plain prefix ("npm test")`,
|
|
86
|
+
);
|
|
87
|
+
if (why === "long")
|
|
88
|
+
throw new Error(`${where}[${i}]: at most ${MAX_COMMAND_LEN} characters`);
|
|
89
|
+
if (why)
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${where}[${i}]: ${shown(c)} must be a plain prefix: letters, digits, single spaces and . _ / : = @ % + , -`,
|
|
92
|
+
);
|
|
93
|
+
if (!out.includes(c)) out.push(c);
|
|
94
|
+
});
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An intent, checked; a usage message naming `where` otherwise. */
|
|
99
|
+
export function checkIntent(v: unknown, where: string): PermissionIntent {
|
|
100
|
+
if (
|
|
101
|
+
typeof v === "string" &&
|
|
102
|
+
(PERMISSION_INTENTS as readonly string[]).includes(v)
|
|
103
|
+
)
|
|
104
|
+
return v as PermissionIntent;
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${where}: ${shown(v)} must be one of ${PERMISSION_INTENTS.join(", ")}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's own personas (AG-10.3, AG-10.7): <state_dir>/personas/<id>.md,
|
|
3
|
+
* each 0600 in a 0700 directory, written in the same format a pack uses
|
|
4
|
+
* (Claude Code's agent markdown plus Den's keys: personas.ts), so a file
|
|
5
|
+
* made here reads like one made by hand.
|
|
6
|
+
*
|
|
7
|
+
* Only these files are ever written or removed: a built-in or a
|
|
8
|
+
* distribution's persona is read-only, and editing one makes a local
|
|
9
|
+
* duplicate first (the caller's choice: the routes do). Every field is
|
|
10
|
+
* checked strictly (checkPersonaInput): an unknown key, a control
|
|
11
|
+
* character, a role, category, runtime, model or version that isn't
|
|
12
|
+
* registered, or a command that isn't a plain prefix is refused, naming
|
|
13
|
+
* the field. Nothing here runs a command or passes text to a shell.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import * as yaml from "yaml";
|
|
18
|
+
import { writePrivate } from "./records.js";
|
|
19
|
+
import { PERSONA_ID_RE } from "./personas.js";
|
|
20
|
+
import {
|
|
21
|
+
checkCommands,
|
|
22
|
+
checkIntent,
|
|
23
|
+
type PermissionIntent,
|
|
24
|
+
} from "./permissions.js";
|
|
25
|
+
import type { RuntimeModel } from "./runtime.js";
|
|
26
|
+
|
|
27
|
+
/** Where a person's own personas live. */
|
|
28
|
+
export function localPersonasDir(stateDir: string): string {
|
|
29
|
+
return path.join(stateDir, "personas");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What New persona and Edit send, checked. */
|
|
33
|
+
export interface PersonaInput {
|
|
34
|
+
displayName?: string;
|
|
35
|
+
role: string;
|
|
36
|
+
category: string;
|
|
37
|
+
description: string;
|
|
38
|
+
instructions: string;
|
|
39
|
+
runtime: string;
|
|
40
|
+
model?: string;
|
|
41
|
+
version: string;
|
|
42
|
+
permission: PermissionIntent;
|
|
43
|
+
allowedCommands: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What a field may name: the registries and runtimes it is checked against. */
|
|
47
|
+
export interface PersonaChoices {
|
|
48
|
+
roles: readonly string[];
|
|
49
|
+
categories: readonly string[];
|
|
50
|
+
runtimes: readonly { id: string; models: readonly RuntimeModel[] }[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const MAX_INSTRUCTIONS = 20_000;
|
|
54
|
+
export const MAX_DESCRIPTION = 200;
|
|
55
|
+
export const MAX_PERSONA_NAME = 40;
|
|
56
|
+
|
|
57
|
+
const KEYS = [
|
|
58
|
+
"displayName",
|
|
59
|
+
"role",
|
|
60
|
+
"category",
|
|
61
|
+
"description",
|
|
62
|
+
"instructions",
|
|
63
|
+
"runtime",
|
|
64
|
+
"model",
|
|
65
|
+
"version",
|
|
66
|
+
"permission",
|
|
67
|
+
"allowedCommands",
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
/** A usage error: a field a person sent isn't what it may be. */
|
|
71
|
+
export class PersonaInputError extends Error {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "PersonaInputError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const shown = (v: unknown) =>
|
|
79
|
+
(JSON.stringify(v) ?? "undefined").replace(/[^\x20-\x7e]/g, "?").slice(0, 60);
|
|
80
|
+
|
|
81
|
+
function oneLine(
|
|
82
|
+
key: string,
|
|
83
|
+
v: unknown,
|
|
84
|
+
max: number,
|
|
85
|
+
required: boolean,
|
|
86
|
+
): string | undefined {
|
|
87
|
+
if (v === undefined || v === null || v === "") {
|
|
88
|
+
if (required) throw new PersonaInputError(`${key} is required`);
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (typeof v !== "string")
|
|
92
|
+
throw new PersonaInputError(`${key} must be a string`);
|
|
93
|
+
const s = v.trim();
|
|
94
|
+
if (!s) {
|
|
95
|
+
if (required) throw new PersonaInputError(`${key} is required`);
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
if ([...s].length > max)
|
|
99
|
+
throw new PersonaInputError(`${key} must be at most ${max} characters`);
|
|
100
|
+
if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(s))
|
|
101
|
+
throw new PersonaInputError(`${key} can't have control characters`);
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function oneOf(key: string, v: unknown, list: readonly string[]): string {
|
|
106
|
+
if (typeof v !== "string" || !list.includes(v))
|
|
107
|
+
throw new PersonaInputError(
|
|
108
|
+
`${key}: ${shown(v)} is not one of ${list.join(", ") || "(none)"}`,
|
|
109
|
+
);
|
|
110
|
+
return v;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A model and version for runtime `rt` (AG-12.3): the model one it offers
|
|
115
|
+
* (none: no model), the version "latest" or one of that model's.
|
|
116
|
+
*/
|
|
117
|
+
export function checkModelChoice(
|
|
118
|
+
rt: { id: string; models: readonly RuntimeModel[] },
|
|
119
|
+
model: unknown,
|
|
120
|
+
version: unknown,
|
|
121
|
+
): { model?: string; version: string } {
|
|
122
|
+
const ver =
|
|
123
|
+
version === undefined || version === null || version === ""
|
|
124
|
+
? "latest"
|
|
125
|
+
: version;
|
|
126
|
+
if (model === undefined || model === null || model === "") {
|
|
127
|
+
if (ver !== "latest") throw new PersonaInputError("version needs a model");
|
|
128
|
+
return { version: "latest" };
|
|
129
|
+
}
|
|
130
|
+
if (!rt.models.length)
|
|
131
|
+
throw new PersonaInputError(`${rt.id} takes no model choice from Den`);
|
|
132
|
+
const m = rt.models.find((x) => x.id === model);
|
|
133
|
+
if (!m)
|
|
134
|
+
throw new PersonaInputError(
|
|
135
|
+
`model: ${shown(model)} is not one of ${rt.id}'s (${rt.models.map((x) => x.id).join(", ")})`,
|
|
136
|
+
);
|
|
137
|
+
if (
|
|
138
|
+
ver !== "latest" &&
|
|
139
|
+
!(typeof ver === "string" && m.versions.includes(ver))
|
|
140
|
+
)
|
|
141
|
+
throw new PersonaInputError(
|
|
142
|
+
`version: ${shown(ver)} is not "latest" or one of ${m.id}'s (${m.versions.join(", ")})`,
|
|
143
|
+
);
|
|
144
|
+
return { model: m.id, version: ver as string };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A persona as New persona or Edit sends it, checked against `choices`.
|
|
149
|
+
* Throws PersonaInputError naming the first field that is wrong.
|
|
150
|
+
*/
|
|
151
|
+
export function checkPersonaInput(
|
|
152
|
+
raw: unknown,
|
|
153
|
+
choices: PersonaChoices,
|
|
154
|
+
): PersonaInput {
|
|
155
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
156
|
+
throw new PersonaInputError("a persona must be a JSON object");
|
|
157
|
+
const r = raw as Record<string, unknown>;
|
|
158
|
+
for (const k of Object.keys(r))
|
|
159
|
+
if (!(KEYS as readonly string[]).includes(k))
|
|
160
|
+
throw new PersonaInputError(
|
|
161
|
+
`${k}: unknown field (a persona takes ${KEYS.join(", ")})`,
|
|
162
|
+
);
|
|
163
|
+
const role = oneOf("role", r.role, choices.roles);
|
|
164
|
+
const category = oneOf("category", r.category, choices.categories);
|
|
165
|
+
const runtime = oneOf(
|
|
166
|
+
"runtime",
|
|
167
|
+
r.runtime,
|
|
168
|
+
choices.runtimes.map((x) => x.id),
|
|
169
|
+
);
|
|
170
|
+
const rt = choices.runtimes.find((x) => x.id === runtime)!;
|
|
171
|
+
const { model, version } = checkModelChoice(rt, r.model, r.version);
|
|
172
|
+
let permission: PermissionIntent;
|
|
173
|
+
let allowedCommands: string[];
|
|
174
|
+
try {
|
|
175
|
+
permission = checkIntent(r.permission ?? "ask", "permission");
|
|
176
|
+
allowedCommands = checkCommands(r.allowedCommands, "allowedCommands");
|
|
177
|
+
} catch (e) {
|
|
178
|
+
throw new PersonaInputError((e as Error).message);
|
|
179
|
+
}
|
|
180
|
+
const instructions = r.instructions ?? "";
|
|
181
|
+
if (typeof instructions !== "string")
|
|
182
|
+
throw new PersonaInputError("instructions must be a string");
|
|
183
|
+
const text = instructions.replace(/\r\n?/g, "\n").trim();
|
|
184
|
+
if (!text) throw new PersonaInputError("instructions are required");
|
|
185
|
+
if (text.length > MAX_INSTRUCTIONS)
|
|
186
|
+
throw new PersonaInputError(
|
|
187
|
+
`instructions must be at most ${MAX_INSTRUCTIONS} characters`,
|
|
188
|
+
);
|
|
189
|
+
// Newlines and tabs only: no escape sequences, no bidi overrides.
|
|
190
|
+
// eslint-disable-next-line no-control-regex
|
|
191
|
+
if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/.test(text))
|
|
192
|
+
throw new PersonaInputError("instructions can't have control characters");
|
|
193
|
+
const displayName = oneLine(
|
|
194
|
+
"displayName",
|
|
195
|
+
r.displayName,
|
|
196
|
+
MAX_PERSONA_NAME,
|
|
197
|
+
false,
|
|
198
|
+
);
|
|
199
|
+
return {
|
|
200
|
+
...(displayName ? { displayName } : {}),
|
|
201
|
+
role,
|
|
202
|
+
category,
|
|
203
|
+
description: oneLine("description", r.description, MAX_DESCRIPTION, true)!,
|
|
204
|
+
instructions: text,
|
|
205
|
+
runtime,
|
|
206
|
+
...(model ? { model } : {}),
|
|
207
|
+
version,
|
|
208
|
+
permission,
|
|
209
|
+
allowedCommands,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The persona file for `input`: front matter (YAML) and its instructions. */
|
|
214
|
+
export function personaMarkdown(id: string, input: PersonaInput): string {
|
|
215
|
+
const fm: Record<string, unknown> = {
|
|
216
|
+
name: id,
|
|
217
|
+
...(input.displayName ? { display_name: input.displayName } : {}),
|
|
218
|
+
role: input.role,
|
|
219
|
+
category: input.category,
|
|
220
|
+
description: input.description,
|
|
221
|
+
runtime: input.runtime,
|
|
222
|
+
...(input.model ? { model: input.model } : {}),
|
|
223
|
+
version: input.version,
|
|
224
|
+
permission_mode: input.permission,
|
|
225
|
+
...(input.allowedCommands.length
|
|
226
|
+
? { allowed_commands: input.allowedCommands }
|
|
227
|
+
: {}),
|
|
228
|
+
};
|
|
229
|
+
return `---\n${yaml.stringify(fm).trimEnd()}\n---\n\n${input.instructions}\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function fileOf(stateDir: string, id: string): string {
|
|
233
|
+
if (!PERSONA_ID_RE.test(id))
|
|
234
|
+
throw new PersonaInputError(`"${shown(id)}" is not a persona id`);
|
|
235
|
+
return path.join(localPersonasDir(stateDir), `${id}.md`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether a local persona file `id` exists. */
|
|
239
|
+
export function localPersonaExists(stateDir: string, id: string): boolean {
|
|
240
|
+
return PERSONA_ID_RE.test(id) && fs.existsSync(fileOf(stateDir, id));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Write local persona `id` (0600, atomically). */
|
|
244
|
+
export function writeLocalPersona(
|
|
245
|
+
stateDir: string,
|
|
246
|
+
id: string,
|
|
247
|
+
input: PersonaInput,
|
|
248
|
+
): string {
|
|
249
|
+
const file = fileOf(stateDir, id);
|
|
250
|
+
writePrivate(file, personaMarkdown(id, input));
|
|
251
|
+
return file;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Remove local persona `id`; false when there was none. Only that file. */
|
|
255
|
+
export function deleteLocalPersona(stateDir: string, id: string): boolean {
|
|
256
|
+
const file = fileOf(stateDir, id);
|
|
257
|
+
try {
|
|
258
|
+
fs.unlinkSync(file);
|
|
259
|
+
return true;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A new persona's id: from its name (else its role), lowercased, made of
|
|
268
|
+
* what an id may hold; "-2", "-3", … when taken.
|
|
269
|
+
*/
|
|
270
|
+
export function newPersonaId(
|
|
271
|
+
input: Pick<PersonaInput, "displayName" | "role">,
|
|
272
|
+
taken: ReadonlySet<string>,
|
|
273
|
+
): string {
|
|
274
|
+
const base =
|
|
275
|
+
(input.displayName ?? input.role)
|
|
276
|
+
.toLowerCase()
|
|
277
|
+
.normalize("NFKD")
|
|
278
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
279
|
+
.replace(/^[^a-z0-9]+|-+$/g, "")
|
|
280
|
+
.slice(0, 56) || "persona";
|
|
281
|
+
if (!taken.has(base)) return base;
|
|
282
|
+
for (let i = 2; ; i++) {
|
|
283
|
+
const id = `${base}-${i}`;
|
|
284
|
+
if (!taken.has(id)) return id;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -12,20 +12,35 @@
|
|
|
12
12
|
* model the default model; `inherit` means none of its own
|
|
13
13
|
* tools "Read, Grep" or a list: hints, never a restriction Den applies
|
|
14
14
|
*
|
|
15
|
-
* Den's own keys sit beside them: `
|
|
15
|
+
* Den's own keys sit beside them (AG-10.2): `display_name` ("Rita", shown
|
|
16
|
+
* as "Rita (reviewer)"), `role` (a key into the role registry, roles.ts:
|
|
17
|
+
* planner, sde, …; a sentence there, as before AG-10, is read as the
|
|
18
|
+
* description), `category` (engineering, design, …; default from the
|
|
19
|
+
* role), `version` ("latest" or a pinned model version),
|
|
20
|
+
* `allowed_commands` (plain command prefixes run without asking: AG-13.4),
|
|
21
|
+
* `runtime` (default den-claude),
|
|
16
22
|
* `standing` (starts with its workspace, §5; resumes after a restart),
|
|
17
23
|
* `resume_on_restart` (AG-14.3: resumes after a restart too), `context` (what to read
|
|
18
24
|
* first), `argv` (the generic runtime's template), and `permission_mode`
|
|
19
|
-
* (
|
|
25
|
+
* (Den's intent: ask, edits or all; or a Claude Code mode, which
|
|
26
|
+
* den-claude checks), and
|
|
20
27
|
* `den_hooks` / `den_instructions` (true or false: this persona's
|
|
21
28
|
* guardrails, guardrails.ts, over its pack's). Any other key (a
|
|
22
29
|
* Claude Code `color`) is ignored. A file without front matter (a pack's
|
|
23
30
|
* README) is not a persona.
|
|
31
|
+
*
|
|
32
|
+
* Where personas come from (AG-10.3), each with its origin: built-in
|
|
33
|
+
* (Den's default pack), distribution (agents.persona_packs) and local
|
|
34
|
+
* (<state_dir>/personas/*.md, made in the UI or by hand: persona-store.ts).
|
|
35
|
+
* Of two with one id, local wins over distribution, and distribution over
|
|
36
|
+
* built-in. Hub comes later; only its origin value exists now.
|
|
24
37
|
*/
|
|
25
38
|
import * as fs from "fs";
|
|
26
39
|
import * as path from "path";
|
|
27
40
|
import * as yaml from "yaml";
|
|
28
41
|
import { WORKSPACE_NAME_RE } from "../agent-actor.js";
|
|
42
|
+
import { checkCommands } from "./permissions.js";
|
|
43
|
+
import { DEN_ROLES, REGISTRY_ID_RE } from "./roles.js";
|
|
29
44
|
import { enginePath } from "../engine.js";
|
|
30
45
|
import {
|
|
31
46
|
DEFAULT_PACK_GUARDRAILS,
|
|
@@ -34,10 +49,31 @@ import {
|
|
|
34
49
|
type Guardrails,
|
|
35
50
|
} from "./guardrails.js";
|
|
36
51
|
|
|
52
|
+
/** Where a persona came from (AG-10.3). */
|
|
53
|
+
export type PersonaOrigin = "builtin" | "distribution" | "local" | "hub";
|
|
54
|
+
export const PERSONA_ORIGINS: readonly PersonaOrigin[] = [
|
|
55
|
+
"builtin",
|
|
56
|
+
"distribution",
|
|
57
|
+
"local",
|
|
58
|
+
"hub",
|
|
59
|
+
];
|
|
60
|
+
|
|
37
61
|
export interface Persona {
|
|
38
62
|
id: string;
|
|
63
|
+
/** A person's name for it ("Rita"), shown as "Rita (reviewer)". */
|
|
64
|
+
displayName?: string;
|
|
65
|
+
/** Its role: a key into the role registry (roles.ts), when it has one. */
|
|
66
|
+
role?: string;
|
|
39
67
|
/** One line: what it does. */
|
|
40
|
-
|
|
68
|
+
description: string;
|
|
69
|
+
/** Its category (roles.ts): the role's, else engineering. */
|
|
70
|
+
category: string;
|
|
71
|
+
/** "latest" (default) or a pinned version of its model. */
|
|
72
|
+
version?: string;
|
|
73
|
+
/** Plain command prefixes it may run without asking (AG-13.4). */
|
|
74
|
+
allowedCommands: string[];
|
|
75
|
+
/** Where it came from (loadPersonas sets it). */
|
|
76
|
+
origin: PersonaOrigin;
|
|
41
77
|
/** The role prompt: the file's body. */
|
|
42
78
|
prompt: string;
|
|
43
79
|
/** The runtime it runs on unless an agent says otherwise. */
|
|
@@ -70,6 +106,9 @@ export interface Persona {
|
|
|
70
106
|
source: string;
|
|
71
107
|
}
|
|
72
108
|
|
|
109
|
+
/** A model version as a persona pins it: "latest" or a version id. */
|
|
110
|
+
export const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/;
|
|
111
|
+
|
|
73
112
|
/** A persona id: the same rules as an agent id or a workspace name. */
|
|
74
113
|
export const PERSONA_ID_RE = WORKSPACE_NAME_RE;
|
|
75
114
|
|
|
@@ -115,7 +154,11 @@ function stringList(
|
|
|
115
154
|
}
|
|
116
155
|
|
|
117
156
|
/** One persona file, or null when it has no front matter. */
|
|
118
|
-
export function parsePersona(
|
|
157
|
+
export function parsePersona(
|
|
158
|
+
text: string,
|
|
159
|
+
file: string,
|
|
160
|
+
origin: PersonaOrigin = "builtin",
|
|
161
|
+
): Persona | null {
|
|
119
162
|
const m = FRONT_MATTER.exec(text);
|
|
120
163
|
if (!m) return null;
|
|
121
164
|
let fm: unknown;
|
|
@@ -155,6 +198,35 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
155
198
|
argv = f.argv as string[];
|
|
156
199
|
}
|
|
157
200
|
const model = optString(f, "model", file);
|
|
201
|
+
const displayName = optString(f, "display_name", file);
|
|
202
|
+
if (
|
|
203
|
+
displayName &&
|
|
204
|
+
([...displayName].length > 40 ||
|
|
205
|
+
/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(displayName))
|
|
206
|
+
)
|
|
207
|
+
throw new Error(
|
|
208
|
+
`${file}: display_name must be at most 40 characters, with no control characters`,
|
|
209
|
+
);
|
|
210
|
+
// AG-10.2: `role` is a role key; a sentence there (before AG-10) is the
|
|
211
|
+
// description it always was.
|
|
212
|
+
const roleRaw = optString(f, "role", file);
|
|
213
|
+
const role = roleRaw && REGISTRY_ID_RE.test(roleRaw) ? roleRaw : undefined;
|
|
214
|
+
const category = optString(f, "category", file);
|
|
215
|
+
if (category && !REGISTRY_ID_RE.test(category))
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${file}: category "${category.slice(0, 40)}" must be lowercase letters, digits and "-"`,
|
|
218
|
+
);
|
|
219
|
+
const version = optString(f, "version", file);
|
|
220
|
+
if (version && !VERSION_RE.test(version))
|
|
221
|
+
throw new Error(
|
|
222
|
+
`${file}: version must be "latest" or a version id (letters, digits, . _ @ : -)`,
|
|
223
|
+
);
|
|
224
|
+
let allowedCommands: string[];
|
|
225
|
+
try {
|
|
226
|
+
allowedCommands = checkCommands(f.allowed_commands, "allowed_commands");
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw new Error(`${file}: ${(e as Error).message}`);
|
|
229
|
+
}
|
|
158
230
|
const runtime = optString(f, "runtime", file);
|
|
159
231
|
const permissionMode = optString(f, "permission_mode", file);
|
|
160
232
|
const guardrailsSet: Partial<Guardrails> = {};
|
|
@@ -170,7 +242,19 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
170
242
|
}
|
|
171
243
|
return {
|
|
172
244
|
id,
|
|
173
|
-
|
|
245
|
+
...(displayName ? { displayName } : {}),
|
|
246
|
+
...(role ? { role } : {}),
|
|
247
|
+
description:
|
|
248
|
+
(roleRaw && !role ? roleRaw : undefined) ??
|
|
249
|
+
optString(f, "description", file) ??
|
|
250
|
+
"",
|
|
251
|
+
category:
|
|
252
|
+
category ??
|
|
253
|
+
DEN_ROLES.find((r) => r.id === role)?.category ??
|
|
254
|
+
"engineering",
|
|
255
|
+
...(version && version !== "latest" ? { version } : {}),
|
|
256
|
+
allowedCommands,
|
|
257
|
+
origin,
|
|
174
258
|
prompt: text.slice(m[0].length).trim(),
|
|
175
259
|
...(runtime ? { runtime } : {}),
|
|
176
260
|
...(model && model !== "inherit" ? { model } : {}),
|
|
@@ -189,7 +273,10 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
189
273
|
* A pack's personas, by file name. A directory with an `agents/` inside (a
|
|
190
274
|
* Claude Code plugin) is read from there.
|
|
191
275
|
*/
|
|
192
|
-
export function readPack(
|
|
276
|
+
export function readPack(
|
|
277
|
+
dir: string,
|
|
278
|
+
origin: PersonaOrigin = "builtin",
|
|
279
|
+
): Persona[] {
|
|
193
280
|
const agents = path.join(dir, "agents");
|
|
194
281
|
const from = fs.statSync(agents, { throwIfNoEntry: false })?.isDirectory()
|
|
195
282
|
? agents
|
|
@@ -200,7 +287,7 @@ export function readPack(dir: string): Persona[] {
|
|
|
200
287
|
if (!name.endsWith(".md")) continue;
|
|
201
288
|
const file = path.join(from, name);
|
|
202
289
|
if (!fs.statSync(file).isFile()) continue;
|
|
203
|
-
const p = parsePersona(fs.readFileSync(file, "utf-8"), file);
|
|
290
|
+
const p = parsePersona(fs.readFileSync(file, "utf-8"), file, origin);
|
|
204
291
|
if (!p) continue;
|
|
205
292
|
const prev = seen.get(p.id);
|
|
206
293
|
if (prev) throw new Error(`${prev} and ${file} both define "${p.id}"`);
|
|
@@ -237,17 +324,34 @@ export function loadPersonas(
|
|
|
237
324
|
packDirs: string[],
|
|
238
325
|
defaultPack: string = DEFAULT_PACK_DIR,
|
|
239
326
|
guardrails: PackGuardrails = {},
|
|
327
|
+
/**
|
|
328
|
+
* <state_dir>/personas (AG-10.3): a person's own, read last so one wins
|
|
329
|
+
* over a pack's of the same id. Absent: none. They get the default
|
|
330
|
+
* pack's guardrails (Den's hooks and instructions) unless their own
|
|
331
|
+
* front matter says otherwise.
|
|
332
|
+
*/
|
|
333
|
+
localDir?: string,
|
|
240
334
|
): Map<string, Persona> {
|
|
241
335
|
const all = new Map<string, Persona>();
|
|
242
|
-
const packs: [string, Guardrails][] = [
|
|
243
|
-
[
|
|
244
|
-
|
|
336
|
+
const packs: [string, Guardrails, PersonaOrigin][] = [
|
|
337
|
+
[
|
|
338
|
+
defaultPack,
|
|
339
|
+
{ ...DEFAULT_PACK_GUARDRAILS, ...guardrails.defaultPack },
|
|
340
|
+
"builtin",
|
|
341
|
+
],
|
|
342
|
+
...packDirs.map((dir, i): [string, Guardrails, PersonaOrigin] => [
|
|
245
343
|
dir,
|
|
246
344
|
{ ...PACK_GUARDRAILS, ...guardrails.packs?.[i] },
|
|
345
|
+
"distribution",
|
|
247
346
|
]),
|
|
248
347
|
];
|
|
249
|
-
|
|
250
|
-
|
|
348
|
+
if (
|
|
349
|
+
localDir &&
|
|
350
|
+
fs.statSync(localDir, { throwIfNoEntry: false })?.isDirectory()
|
|
351
|
+
)
|
|
352
|
+
packs.push([localDir, { ...DEFAULT_PACK_GUARDRAILS }, "local"]);
|
|
353
|
+
for (const [dir, g, origin] of packs)
|
|
354
|
+
for (const p of readPack(dir, origin))
|
|
251
355
|
all.set(p.id, { ...p, guardrails: { ...g, ...p.guardrailsSet } });
|
|
252
356
|
return all;
|
|
253
357
|
}
|
|
@@ -21,6 +21,7 @@ import * as path from "path";
|
|
|
21
21
|
import { WORKSPACE_NAME_RE } from "../agent-actor.js";
|
|
22
22
|
import type { SessionRef } from "../sessions/types.js";
|
|
23
23
|
import type { RotationEntry } from "./rotation.js";
|
|
24
|
+
import type { PermissionIntent } from "./permissions.js";
|
|
24
25
|
|
|
25
26
|
export interface AgentRecord {
|
|
26
27
|
id: string;
|
|
@@ -80,8 +81,35 @@ export interface AgentRecord {
|
|
|
80
81
|
displayName?: string;
|
|
81
82
|
/** When displayName was given (ISO 8601): the latest rename wins (AG-4b). */
|
|
82
83
|
displayNameAt?: string;
|
|
84
|
+
/**
|
|
85
|
+
* The model version it runs (AG-12.3): "latest" (the model's newest), or
|
|
86
|
+
* a pinned version. Absent: before AG-12, `model` as given.
|
|
87
|
+
*/
|
|
88
|
+
version?: string;
|
|
89
|
+
/** The permission intent it was started with (AG-13), when one was set. */
|
|
90
|
+
permission?: PermissionIntent;
|
|
91
|
+
/**
|
|
92
|
+
* The extra allowed commands it was started with (AG-13.4: plain
|
|
93
|
+
* prefixes; its role's preset comes on top), when set for it.
|
|
94
|
+
*/
|
|
95
|
+
allow?: string[];
|
|
96
|
+
/**
|
|
97
|
+
* What it was started with that differs from its persona (AG-10.9):
|
|
98
|
+
* the list tags it. Start fresh and Relaunch keep the choices.
|
|
99
|
+
*/
|
|
100
|
+
overrides?: Override[];
|
|
83
101
|
}
|
|
84
102
|
|
|
103
|
+
/** A setting a session can override for one launch (AG-10.9). */
|
|
104
|
+
export type Override = "runtime" | "model" | "version" | "permission" | "allow";
|
|
105
|
+
export const OVERRIDES: readonly Override[] = [
|
|
106
|
+
"runtime",
|
|
107
|
+
"model",
|
|
108
|
+
"version",
|
|
109
|
+
"permission",
|
|
110
|
+
"allow",
|
|
111
|
+
];
|
|
112
|
+
|
|
85
113
|
/** The longest name a person may give an agent (AG-4b). */
|
|
86
114
|
export const MAX_DISPLAY_NAME = 40;
|
|
87
115
|
|