@monster-orange/den 0.4.6 → 0.4.8
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 +160 -0
- package/README.md +7 -7
- package/docs/distributions.md +244 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/skills/install-den/SKILL.md +2 -2
- package/src/shared/agents/den-claude.ts +38 -5
- package/src/shared/agents/guardrails/hooks.json +27 -0
- package/src/shared/agents/guardrails/instructions.md +39 -0
- package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
- package/src/shared/agents/guardrails/stop-hook.ts +66 -0
- package/src/shared/agents/guardrails.ts +114 -0
- package/src/shared/agents/handback.ts +241 -0
- package/src/shared/agents/personas.ts +66 -7
- package/src/shared/agents/records.ts +114 -0
- package/src/shared/agents/runtime.ts +12 -0
- package/src/shared/den-config.ts +97 -6
- package/src/shared/dev-metrics/emit.ts +31 -4
- package/src/shared/dev-metrics/polling.ts +135 -0
- package/src/shared/engine.ts +14 -2
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +283 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +262 -0
- package/src/ui/cli/agent-ops.ts +495 -53
- package/src/ui/cli/agents-command.ts +170 -14
- package/src/ui/cli/doctor-command.ts +20 -0
- package/src/ui/cli/flag-parser.ts +1 -0
- package/src/ui/cli/index.ts +119 -2
- package/src/ui/cli/usage-command.ts +256 -0
- package/src/ui/cli/web-command.ts +3 -1
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +230 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +333 -11
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +28 -0
- package/src/ui/server/public/i18n/ja.js +25 -0
- package/src/ui/server/public/shell.js +66 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +23 -0
- package/src/ui/server/session-routes.ts +52 -4
- package/src/ui/server/sudo-store.ts +36 -7
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hand-back check: is a hand-back to a person complete and plain?
|
|
3
|
+
* `<cli> agents handback-check <file|->` runs it, and so does the Stop hook
|
|
4
|
+
* Den gives default-pack personas (guardrails/stop-hook.ts). The template
|
|
5
|
+
* is in guardrails/instructions.md:
|
|
6
|
+
*
|
|
7
|
+
* ## Hand-back
|
|
8
|
+
* - URL: <the link to open>
|
|
9
|
+
* - Machine: <the machine it runs on>
|
|
10
|
+
* - Branch: <the branch>
|
|
11
|
+
* - What changed: <what is different now>
|
|
12
|
+
* - What to click: <the steps to see it>
|
|
13
|
+
* - Decisions:
|
|
14
|
+
* 1. <a yes/no question>?
|
|
15
|
+
*
|
|
16
|
+
* It rejects a hand-back when:
|
|
17
|
+
*
|
|
18
|
+
* 1. A part is missing or empty. A part is a line that starts with its
|
|
19
|
+
* label and a colon (`URL:`, `- **Branch:** …`) or a heading that is its
|
|
20
|
+
* label (`### What changed`). Its content is the rest of the line and
|
|
21
|
+
* the lines after it, up to the next part. URL must hold a link (`://`)
|
|
22
|
+
* or say `none` (work with no page), optionally with a reason.
|
|
23
|
+
* Decisions may say `none`.
|
|
24
|
+
* 2. It has a bare ID. An ID is `#<digits>` or 1-4 capitals, an optional
|
|
25
|
+
* `-`, digits and an optional `.<digits>` (`R12`, `AG-14`, `KB-4.1`).
|
|
26
|
+
* URLs, `inline code` and code blocks, and the URL and Branch parts are
|
|
27
|
+
* not scanned: they are literal. An ID is glossed (not bare) when:
|
|
28
|
+
* a. a parenthetical of at least two words follows it:
|
|
29
|
+
* `#5 (the hooks issue)`;
|
|
30
|
+
* b. a colon or dash, then at least two words, follows it:
|
|
31
|
+
* `R12: plain writing`, `R12 - plain writing`;
|
|
32
|
+
* c. it sits inside a parenthetical right after a word, and that
|
|
33
|
+
* parenthetical holds no other words: `plain writing (R12)`,
|
|
34
|
+
* `the issues (#5, #6)`;
|
|
35
|
+
* d. or the same ID was glossed earlier in the text.
|
|
36
|
+
* A word is a run of letters (an ID is not a word).
|
|
37
|
+
* 3. A decision isn't a numbered yes/no question. Each item under
|
|
38
|
+
* Decisions must be numbered (`1.` or `1)`), end in `?`, not start
|
|
39
|
+
* with a question word (what, which, how, why, when, where, who), and
|
|
40
|
+
* not offer a choice (` or `).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export const HANDBACK_PARTS = [
|
|
44
|
+
"URL",
|
|
45
|
+
"Machine",
|
|
46
|
+
"Branch",
|
|
47
|
+
"What changed",
|
|
48
|
+
"What to click",
|
|
49
|
+
"Decisions",
|
|
50
|
+
] as const;
|
|
51
|
+
export type HandbackPart = (typeof HANDBACK_PARTS)[number];
|
|
52
|
+
|
|
53
|
+
export interface HandbackResult {
|
|
54
|
+
ok: boolean;
|
|
55
|
+
/** One line each, plain, naming what to fix. */
|
|
56
|
+
problems: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The ID patterns the check looks for. */
|
|
60
|
+
export const ID_PATTERNS = [/#\d+/g, /\b[A-Z]{1,4}-?\d+(?:\.\d+)?\b/g];
|
|
61
|
+
|
|
62
|
+
const LABEL = new RegExp(
|
|
63
|
+
`^\\s*(?:[-*+]\\s+)?(#{1,6}\\s+)?(?:\\*\\*|__)?(${HANDBACK_PARTS.map((p) =>
|
|
64
|
+
p.replace(/ /g, "\\s+"),
|
|
65
|
+
).join("|")})(?:\\*\\*|__)?\\s*(:)?(?:\\*\\*|__)?\\s*(.*)$`,
|
|
66
|
+
"i",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** The hand-back's heading, which marks a message as one. */
|
|
70
|
+
export const HANDBACK_HEADING = /^\s*#{1,6}\s*hand-?\s?back\b/im;
|
|
71
|
+
|
|
72
|
+
/** Does this message present itself as a hand-back (its heading)? */
|
|
73
|
+
export function looksLikeHandback(text: string): boolean {
|
|
74
|
+
return HANDBACK_HEADING.test(text);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface Parts {
|
|
78
|
+
found: Map<HandbackPart, string[]>;
|
|
79
|
+
/** Line numbers (0-based) of the URL and Branch parts' content. */
|
|
80
|
+
literal: Set<number>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function canonical(label: string): HandbackPart {
|
|
84
|
+
const l = label.replace(/\s+/g, " ").toLowerCase();
|
|
85
|
+
return HANDBACK_PARTS.find((p) => p.toLowerCase() === l)!;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function splitParts(lines: string[]): Parts {
|
|
89
|
+
const found = new Map<HandbackPart, string[]>();
|
|
90
|
+
const literal = new Set<number>();
|
|
91
|
+
let cur: HandbackPart | null = null;
|
|
92
|
+
let fence = false;
|
|
93
|
+
lines.forEach((line, n) => {
|
|
94
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence;
|
|
95
|
+
const m = fence ? null : LABEL.exec(line);
|
|
96
|
+
// A label needs a colon, or to be the whole heading.
|
|
97
|
+
if (m && (m[3] || (m[1] && !m[4].trim()))) {
|
|
98
|
+
cur = canonical(m[2]);
|
|
99
|
+
const body = m[4].trim();
|
|
100
|
+
found.set(cur, body ? [body] : []);
|
|
101
|
+
if (cur === "URL" || cur === "Branch") literal.add(n);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (/^\s*#{1,6}\s/.test(line)) {
|
|
105
|
+
cur = null;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!cur) return;
|
|
109
|
+
if (line.trim()) found.get(cur)!.push(line.trim());
|
|
110
|
+
if (cur === "URL" || cur === "Branch") literal.add(n);
|
|
111
|
+
});
|
|
112
|
+
return { found, literal };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Text with URLs and code replaced by spaces (offsets kept). */
|
|
116
|
+
function prose(line: string): string {
|
|
117
|
+
const blank = (s: string) => " ".repeat(s.length);
|
|
118
|
+
return line
|
|
119
|
+
.replace(/`[^`]*`/g, blank)
|
|
120
|
+
.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, blank)
|
|
121
|
+
.replace(/\bwww\.\S+/gi, blank);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const WORD = /[A-Za-z][A-Za-z'’]*(?:-[A-Za-z]+)*/g;
|
|
125
|
+
|
|
126
|
+
/** Words in s: runs of letters, once the IDs are taken out. */
|
|
127
|
+
function wordCount(s: string): number {
|
|
128
|
+
let t = s;
|
|
129
|
+
for (const re of ID_PATTERNS) t = t.replace(new RegExp(re.source, "g"), " ");
|
|
130
|
+
return (t.match(WORD) ?? []).length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Is the ID at [start, end) in this line glossed (rules a-c)? */
|
|
134
|
+
export function isGlossed(line: string, start: number, end: number): boolean {
|
|
135
|
+
const after = line.slice(end);
|
|
136
|
+
// a. "(two or more words)" right after.
|
|
137
|
+
const paren = /^\s*\(([^)]*)\)/.exec(after);
|
|
138
|
+
if (paren && wordCount(paren[1]) >= 2) return true;
|
|
139
|
+
// b. ": two words" or " - two words" right after.
|
|
140
|
+
const colon = /^\s*(?::|\s[-–—])\s*(.*)$/.exec(after);
|
|
141
|
+
if (colon && wordCount(colon[1].split(/[.;!?]/)[0]) >= 2) return true;
|
|
142
|
+
// c. inside "(…)" right after a word, the parenthetical only IDs.
|
|
143
|
+
const open = line.lastIndexOf("(", start);
|
|
144
|
+
const close = line.indexOf(")", end);
|
|
145
|
+
if (open >= 0 && close >= 0 && line.slice(open, start).indexOf(")") < 0) {
|
|
146
|
+
const inside = line.slice(open + 1, close);
|
|
147
|
+
const before = line.slice(0, open);
|
|
148
|
+
if (wordCount(inside) === 0 && /[A-Za-z][A-Za-z'’]*\s*$/.test(before))
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Bare IDs in the text, in order, each once. */
|
|
155
|
+
export function bareIds(
|
|
156
|
+
text: string,
|
|
157
|
+
skipLines: Set<number> = new Set(),
|
|
158
|
+
): string[] {
|
|
159
|
+
const glossed = new Set<string>();
|
|
160
|
+
const bare: string[] = [];
|
|
161
|
+
let fence = false;
|
|
162
|
+
text.split(/\r?\n/).forEach((raw, n) => {
|
|
163
|
+
if (/^\s*(```|~~~)/.test(raw)) {
|
|
164
|
+
fence = !fence;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (fence || skipLines.has(n)) return;
|
|
168
|
+
const line = prose(raw);
|
|
169
|
+
const hits: { id: string; start: number; end: number }[] = [];
|
|
170
|
+
for (const re of ID_PATTERNS)
|
|
171
|
+
for (const m of line.matchAll(new RegExp(re.source, "g")))
|
|
172
|
+
hits.push({ id: m[0], start: m.index!, end: m.index! + m[0].length });
|
|
173
|
+
hits.sort((a, b) => a.start - b.start);
|
|
174
|
+
for (const h of hits) {
|
|
175
|
+
if (glossed.has(h.id)) continue;
|
|
176
|
+
if (isGlossed(line, h.start, h.end)) glossed.add(h.id);
|
|
177
|
+
else if (!bare.includes(h.id)) bare.push(h.id);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return bare;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const QUESTION_WORD = /^(what|which|how|why|when|where|who|whom|whose)\b/i;
|
|
184
|
+
|
|
185
|
+
/** Problems with the Decisions part's items. */
|
|
186
|
+
function decisionProblems(items: string[]): string[] {
|
|
187
|
+
if (items.length === 1 && /^(none|no decisions?|nothing)\.?$/i.test(items[0]))
|
|
188
|
+
return [];
|
|
189
|
+
if (!items.length)
|
|
190
|
+
return [
|
|
191
|
+
"Decisions is empty. List each as a numbered yes/no question, or say none.",
|
|
192
|
+
];
|
|
193
|
+
const out: string[] = [];
|
|
194
|
+
for (const it of items) {
|
|
195
|
+
const m = /^(\d+)[.)]\s+(.*)$/.exec(it);
|
|
196
|
+
const q = (m ? m[2] : it.replace(/^[-*+]\s+/, "")).trim();
|
|
197
|
+
const show = q.length > 60 ? `${q.slice(0, 57)}...` : q;
|
|
198
|
+
if (!m)
|
|
199
|
+
out.push(`Decision "${show}" is not numbered. Number it: 1., 2., …`);
|
|
200
|
+
else if (!q.endsWith("?"))
|
|
201
|
+
out.push(`Decision "${show}" is not a question. End it with "?".`);
|
|
202
|
+
else if (QUESTION_WORD.test(q) || /\sor\s/i.test(q))
|
|
203
|
+
out.push(
|
|
204
|
+
`Decision "${show}" is not a yes/no question. Ask it so yes or no answers it.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Check a hand-back. Pure: the text in, the problems out. */
|
|
211
|
+
export function checkHandback(text: string): HandbackResult {
|
|
212
|
+
const lines = String(text ?? "").split(/\r?\n/);
|
|
213
|
+
const { found, literal } = splitParts(lines);
|
|
214
|
+
const problems: string[] = [];
|
|
215
|
+
for (const part of HANDBACK_PARTS) {
|
|
216
|
+
const body = found.get(part);
|
|
217
|
+
if (body === undefined) {
|
|
218
|
+
problems.push(`Missing "${part}:". Add it.`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (part === "Decisions") {
|
|
222
|
+
problems.push(...decisionProblems(body));
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!body.join(" ").trim())
|
|
226
|
+
problems.push(`"${part}:" is empty. Fill it in.`);
|
|
227
|
+
else if (
|
|
228
|
+
part === "URL" &&
|
|
229
|
+
!body.join(" ").includes("://") &&
|
|
230
|
+
!/^\s*none\b/i.test(body.join(" "))
|
|
231
|
+
)
|
|
232
|
+
problems.push(
|
|
233
|
+
`"URL:" has no link. Give the full address, with http:// or https://, or say none if this work has no page.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
for (const id of bareIds(lines.join("\n"), literal))
|
|
237
|
+
problems.push(
|
|
238
|
+
`"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
|
|
239
|
+
);
|
|
240
|
+
return { ok: problems.length === 0, problems };
|
|
241
|
+
}
|
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
* tools "Read, Grep" or a list: hints, never a restriction Den applies
|
|
14
14
|
*
|
|
15
15
|
* Den's own keys sit beside them: `runtime` (default den-claude),
|
|
16
|
-
* `standing` (starts with its workspace, §5
|
|
16
|
+
* `standing` (starts with its workspace, §5; resumes after a restart),
|
|
17
|
+
* `resume_on_restart` (AG-14.3: resumes after a restart too), `context` (what to read
|
|
17
18
|
* first), `argv` (the generic runtime's template), and `permission_mode`
|
|
18
|
-
* (den-claude's Claude Code permission mode; it checks the value)
|
|
19
|
+
* (den-claude's Claude Code permission mode; it checks the value), and
|
|
20
|
+
* `den_hooks` / `den_instructions` (true or false: this persona's
|
|
21
|
+
* guardrails, guardrails.ts, over its pack's). Any other key (a
|
|
19
22
|
* Claude Code `color`) is ignored. A file without front matter (a pack's
|
|
20
23
|
* README) is not a persona.
|
|
21
24
|
*/
|
|
@@ -24,6 +27,12 @@ import * as path from "path";
|
|
|
24
27
|
import * as yaml from "yaml";
|
|
25
28
|
import { WORKSPACE_NAME_RE } from "../agent-actor.js";
|
|
26
29
|
import { enginePath } from "../engine.js";
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_PACK_GUARDRAILS,
|
|
32
|
+
PACK_GUARDRAILS,
|
|
33
|
+
sharedInstructions,
|
|
34
|
+
type Guardrails,
|
|
35
|
+
} from "./guardrails.js";
|
|
27
36
|
|
|
28
37
|
export interface Persona {
|
|
29
38
|
id: string;
|
|
@@ -37,6 +46,11 @@ export interface Persona {
|
|
|
37
46
|
model?: string;
|
|
38
47
|
/** Started with its workspace (planner, orchestrator). */
|
|
39
48
|
standing: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* `resume_on_restart: true` (AG-14.3): resumed on its own when Den or
|
|
51
|
+
* the machine restarted. Standing personas always are.
|
|
52
|
+
*/
|
|
53
|
+
resumeOnRestart?: boolean;
|
|
40
54
|
/** Tool hints. */
|
|
41
55
|
tools: string[];
|
|
42
56
|
/** What to read first (specs, the design system, workspace config). */
|
|
@@ -45,6 +59,13 @@ export interface Persona {
|
|
|
45
59
|
argv?: string[];
|
|
46
60
|
/** `permission_mode`: the runtime's permission mode (den-claude checks it). */
|
|
47
61
|
permissionMode?: string;
|
|
62
|
+
/** `den_hooks` / `den_instructions` as its front matter sets them. */
|
|
63
|
+
guardrailsSet?: Partial<Guardrails>;
|
|
64
|
+
/**
|
|
65
|
+
* Den's guardrails for it (guardrails.ts), resolved by loadPersonas from
|
|
66
|
+
* its pack and its own front matter. Absent: none.
|
|
67
|
+
*/
|
|
68
|
+
guardrails?: Guardrails;
|
|
48
69
|
/** The file it came from. */
|
|
49
70
|
source: string;
|
|
50
71
|
}
|
|
@@ -120,6 +141,9 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
120
141
|
const standing = f.standing ?? false;
|
|
121
142
|
if (typeof standing !== "boolean")
|
|
122
143
|
throw new Error(`${file}: standing must be true or false`);
|
|
144
|
+
const resumeOnRestart = f.resume_on_restart ?? false;
|
|
145
|
+
if (typeof resumeOnRestart !== "boolean")
|
|
146
|
+
throw new Error(`${file}: resume_on_restart must be true or false`);
|
|
123
147
|
let argv: string[] | undefined;
|
|
124
148
|
if (f.argv !== undefined && f.argv !== null) {
|
|
125
149
|
if (
|
|
@@ -133,6 +157,17 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
133
157
|
const model = optString(f, "model", file);
|
|
134
158
|
const runtime = optString(f, "runtime", file);
|
|
135
159
|
const permissionMode = optString(f, "permission_mode", file);
|
|
160
|
+
const guardrailsSet: Partial<Guardrails> = {};
|
|
161
|
+
for (const [key, k] of [
|
|
162
|
+
["den_hooks", "hooks"],
|
|
163
|
+
["den_instructions", "instructions"],
|
|
164
|
+
] as const) {
|
|
165
|
+
const v = f[key];
|
|
166
|
+
if (v === undefined || v === null) continue;
|
|
167
|
+
if (typeof v !== "boolean")
|
|
168
|
+
throw new Error(`${file}: ${key} must be true or false`);
|
|
169
|
+
guardrailsSet[k] = v;
|
|
170
|
+
}
|
|
136
171
|
return {
|
|
137
172
|
id,
|
|
138
173
|
role: optString(f, "role", file) ?? optString(f, "description", file) ?? "",
|
|
@@ -140,10 +175,12 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
140
175
|
...(runtime ? { runtime } : {}),
|
|
141
176
|
...(model && model !== "inherit" ? { model } : {}),
|
|
142
177
|
standing,
|
|
178
|
+
...(resumeOnRestart ? { resumeOnRestart } : {}),
|
|
143
179
|
tools: stringList(f, "tools", file),
|
|
144
180
|
context: stringList(f, "context", file),
|
|
145
181
|
...(argv ? { argv } : {}),
|
|
146
182
|
...(permissionMode ? { permissionMode } : {}),
|
|
183
|
+
...(Object.keys(guardrailsSet).length ? { guardrailsSet } : {}),
|
|
147
184
|
source: file,
|
|
148
185
|
};
|
|
149
186
|
}
|
|
@@ -183,23 +220,42 @@ export function resolvePackDirs(packs: string[], distRoot: string): string[] {
|
|
|
183
220
|
});
|
|
184
221
|
}
|
|
185
222
|
|
|
223
|
+
/** Each pack's guardrails (guardrails.ts), as agents: configures them. */
|
|
224
|
+
export interface PackGuardrails {
|
|
225
|
+
/** Den's default pack (default: both on). */
|
|
226
|
+
defaultPack?: Partial<Guardrails>;
|
|
227
|
+
/** Each of packDirs, in order (default: both off). */
|
|
228
|
+
packs?: (Partial<Guardrails> | undefined)[];
|
|
229
|
+
}
|
|
230
|
+
|
|
186
231
|
/**
|
|
187
232
|
* Every persona: Den's default pack, then each pack in order. A later
|
|
188
|
-
* pack's persona replaces an earlier one of the same id.
|
|
233
|
+
* pack's persona replaces an earlier one of the same id. Each gets its
|
|
234
|
+
* guardrails: its own front matter's, else its pack's.
|
|
189
235
|
*/
|
|
190
236
|
export function loadPersonas(
|
|
191
237
|
packDirs: string[],
|
|
192
238
|
defaultPack: string = DEFAULT_PACK_DIR,
|
|
239
|
+
guardrails: PackGuardrails = {},
|
|
193
240
|
): Map<string, Persona> {
|
|
194
241
|
const all = new Map<string, Persona>();
|
|
195
|
-
|
|
196
|
-
|
|
242
|
+
const packs: [string, Guardrails][] = [
|
|
243
|
+
[defaultPack, { ...DEFAULT_PACK_GUARDRAILS, ...guardrails.defaultPack }],
|
|
244
|
+
...packDirs.map((dir, i): [string, Guardrails] => [
|
|
245
|
+
dir,
|
|
246
|
+
{ ...PACK_GUARDRAILS, ...guardrails.packs?.[i] },
|
|
247
|
+
]),
|
|
248
|
+
];
|
|
249
|
+
for (const [dir, g] of packs)
|
|
250
|
+
for (const p of readPack(dir))
|
|
251
|
+
all.set(p.id, { ...p, guardrails: { ...g, ...p.guardrailsSet } });
|
|
197
252
|
return all;
|
|
198
253
|
}
|
|
199
254
|
|
|
200
255
|
/**
|
|
201
256
|
* The role prompt as an agent gets it: who and where it is, what to read
|
|
202
|
-
* first,
|
|
257
|
+
* first, the persona's own prompt, then Den's shared instructions
|
|
258
|
+
* (guardrails/instructions.md) when its guardrails include them. Runtime-neutral; a runtime only
|
|
203
259
|
* chooses how to pass it in.
|
|
204
260
|
*/
|
|
205
261
|
export function rolePrompt(
|
|
@@ -214,5 +270,8 @@ export function rolePrompt(
|
|
|
214
270
|
];
|
|
215
271
|
if (persona.context.length)
|
|
216
272
|
lines.push(`Read first: ${persona.context.join("; ")}.`);
|
|
217
|
-
|
|
273
|
+
const shared = persona.guardrails?.instructions
|
|
274
|
+
? `\n\n${sharedInstructions()}`
|
|
275
|
+
: "";
|
|
276
|
+
return `${lines.join("\n")}\n\n${persona.prompt}${shared}`;
|
|
218
277
|
}
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* A record never holds the env or the argv: the role prompt, tokens and
|
|
10
10
|
* anything else a launch passes stay out of it. An agent id is unique in a
|
|
11
11
|
* distribution.
|
|
12
|
+
*
|
|
13
|
+
* AG-14: a removed agent's record moves to removed-agents/<id>.json, so
|
|
14
|
+
* Undo can bring it back; restoreRecord does, and purgeRemoved forgets
|
|
15
|
+
* the ones older than the undo window. The runtime's own conversation
|
|
16
|
+
* history is never touched.
|
|
12
17
|
*/
|
|
13
18
|
import * as fs from "fs";
|
|
14
19
|
import * as path from "path";
|
|
@@ -34,6 +39,54 @@ export interface AgentRecord {
|
|
|
34
39
|
resumedAt?: string;
|
|
35
40
|
/** The runtime's handle for resume (den-claude: the conversation id). */
|
|
36
41
|
resumeToken?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The runtime was launched on resumeToken at least once (0.4.8). A
|
|
44
|
+
* resume then always tries the runtime's own resume first, never a new
|
|
45
|
+
* conversation under the same id (which the runtime refuses once the
|
|
46
|
+
* conversation exists).
|
|
47
|
+
*/
|
|
48
|
+
launched?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The workspaces beyond its own its MCP served when it started
|
|
51
|
+
* (agents.<id>.grants then). A resume keeps them, never more than the
|
|
52
|
+
* config still allows (AG-14).
|
|
53
|
+
*/
|
|
54
|
+
grants?: string[];
|
|
55
|
+
/** When its session was seen to end on its own (AG-14: "Exited"). */
|
|
56
|
+
endedAt?: string;
|
|
57
|
+
/** That session's exit code, when the host said it. */
|
|
58
|
+
exitCode?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Set when its session was gone after Den or the machine restarted
|
|
61
|
+
* (AG-14.3: "Interrupted"); cleared by a resume or a start.
|
|
62
|
+
*/
|
|
63
|
+
interruptedAt?: string;
|
|
64
|
+
/** What its run came to (den#6), filled in by `<cli> usage`. */
|
|
65
|
+
outcome?: AgentOutcome;
|
|
66
|
+
/** Its tokens, from the runtime's own transcript (den#6). */
|
|
67
|
+
usage?: AgentUsage;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What an agent's run came to (den#6). Read locally; never sent. */
|
|
71
|
+
export interface AgentOutcome {
|
|
72
|
+
/** The pull request it opened, when it opened one. */
|
|
73
|
+
prOpened?: string;
|
|
74
|
+
/** That pull request merged in the run. */
|
|
75
|
+
prMerged?: boolean;
|
|
76
|
+
/** The last test run it made passed (true) or failed (false). */
|
|
77
|
+
testsGreen?: boolean;
|
|
78
|
+
/** From its start to its last activity, in milliseconds. */
|
|
79
|
+
wallClockMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** An agent's token counts (den#6), summed over its transcripts. */
|
|
83
|
+
export interface AgentUsage {
|
|
84
|
+
calls: number;
|
|
85
|
+
input: number;
|
|
86
|
+
output: number;
|
|
87
|
+
cacheRead: number;
|
|
88
|
+
cacheWrite: number;
|
|
89
|
+
compactions: number;
|
|
37
90
|
}
|
|
38
91
|
|
|
39
92
|
/** An agent id: the same rules as a workspace or session name. */
|
|
@@ -85,6 +138,67 @@ export function readRecord(stateDir: string, id: string): AgentRecord | null {
|
|
|
85
138
|
}
|
|
86
139
|
}
|
|
87
140
|
|
|
141
|
+
/** Where removed records wait for Undo (AG-14.1b). */
|
|
142
|
+
export function removedDir(stateDir: string): string {
|
|
143
|
+
return path.join(stateDir, "removed-agents");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How long a removed record can be brought back. */
|
|
147
|
+
export const UNDO_KEEP_MS = 60 * 60 * 1000;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Forget agent `id` (its record moves aside for Undo). True when there
|
|
151
|
+
* was one. Its files and the runtime's history stay.
|
|
152
|
+
*/
|
|
153
|
+
export function removeRecord(stateDir: string, id: string): boolean {
|
|
154
|
+
const from = recordFile(stateDir, id);
|
|
155
|
+
if (!fs.existsSync(from)) return false;
|
|
156
|
+
ensureDir(removedDir(stateDir));
|
|
157
|
+
const to = path.join(removedDir(stateDir), `${id}.json`);
|
|
158
|
+
fs.renameSync(from, to);
|
|
159
|
+
// Its mtime is when it was removed: purgeRemoved goes by it.
|
|
160
|
+
const now = new Date();
|
|
161
|
+
fs.utimesSync(to, now, now);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Bring a removed record back (Undo). False when there is none to bring
|
|
167
|
+
* back, or an agent of that id exists again.
|
|
168
|
+
*/
|
|
169
|
+
export function restoreRecord(stateDir: string, id: string): boolean {
|
|
170
|
+
if (!AGENT_ID_RE.test(id)) return false;
|
|
171
|
+
const from = path.join(removedDir(stateDir), `${id}.json`);
|
|
172
|
+
const to = recordFile(stateDir, id);
|
|
173
|
+
if (!fs.existsSync(from) || fs.existsSync(to)) return false;
|
|
174
|
+
ensureDir(agentsDir(stateDir));
|
|
175
|
+
fs.renameSync(from, to);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Forget removed records older than `keepMs` for good. */
|
|
180
|
+
export function purgeRemoved(
|
|
181
|
+
stateDir: string,
|
|
182
|
+
keepMs = UNDO_KEEP_MS,
|
|
183
|
+
now = Date.now(),
|
|
184
|
+
): void {
|
|
185
|
+
let names: string[];
|
|
186
|
+
try {
|
|
187
|
+
names = fs.readdirSync(removedDir(stateDir));
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const n of names) {
|
|
192
|
+
if (!n.endsWith(".json") || !AGENT_ID_RE.test(n.slice(0, -5))) continue;
|
|
193
|
+
const f = path.join(removedDir(stateDir), n);
|
|
194
|
+
try {
|
|
195
|
+
if (now - fs.statSync(f).mtimeMs > keepMs) fs.unlinkSync(f);
|
|
196
|
+
} catch {
|
|
197
|
+
/* gone already */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
88
202
|
export function listRecords(stateDir: string): AgentRecord[] {
|
|
89
203
|
let names: string[];
|
|
90
204
|
try {
|
|
@@ -130,6 +130,18 @@ export interface Runtime {
|
|
|
130
130
|
launch(input: LaunchInput): LaunchOutput;
|
|
131
131
|
/** A new run that continues the last one; null when there is nothing to resume. */
|
|
132
132
|
resume?(input: ResumeInput): Omit<LaunchOutput, "resumeToken"> | null;
|
|
133
|
+
/**
|
|
134
|
+
* Whether the runtime has a conversation for `token` on this machine
|
|
135
|
+
* (den-claude: its transcript file). Only ever a fast path: false may
|
|
136
|
+
* just mean it isn't written yet.
|
|
137
|
+
*/
|
|
138
|
+
conversationExists?(token: string, env?: NodeJS.ProcessEnv): boolean;
|
|
139
|
+
/**
|
|
140
|
+
* What the runtime prints when it exits at once (0.4.8): `noConversation`
|
|
141
|
+
* when a resume finds nothing to continue, `idInUse` when a new
|
|
142
|
+
* conversation's id is taken. Den then tries the other way, once.
|
|
143
|
+
*/
|
|
144
|
+
launchFailures?: { noConversation?: RegExp; idInUse?: RegExp };
|
|
133
145
|
/** Where permission prompts surface (Q-AG-2). */
|
|
134
146
|
approvals?: "terminal" | "hook";
|
|
135
147
|
/**
|