@monster-orange/den 0.4.6 → 0.4.7
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 +66 -0
- package/README.md +7 -7
- package/docs/distributions.md +124 -0
- package/package.json +1 -1
- package/skills/install-den/SKILL.md +2 -2
- package/src/shared/agents/den-claude.ts +19 -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 +55 -6
- package/src/shared/den-config.ts +67 -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/ui/cli/agent-ops.ts +7 -3
- package/src/ui/cli/agents-command.ts +51 -1
- package/src/ui/cli/doctor-command.ts +20 -0
- package/src/ui/cli/flag-parser.ts +1 -0
- package/src/ui/cli/index.ts +106 -1
- package/src/ui/cli/web-command.ts +3 -1
|
@@ -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
|
+
}
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* Den's own keys sit beside them: `runtime` (default den-claude),
|
|
16
16
|
* `standing` (starts with its workspace, §5), `context` (what to read
|
|
17
17
|
* first), `argv` (the generic runtime's template), and `permission_mode`
|
|
18
|
-
* (den-claude's Claude Code permission mode; it checks the value)
|
|
18
|
+
* (den-claude's Claude Code permission mode; it checks the value), and
|
|
19
|
+
* `den_hooks` / `den_instructions` (true or false: this persona's
|
|
20
|
+
* guardrails, guardrails.ts, over its pack's). Any other key (a
|
|
19
21
|
* Claude Code `color`) is ignored. A file without front matter (a pack's
|
|
20
22
|
* README) is not a persona.
|
|
21
23
|
*/
|
|
@@ -24,6 +26,12 @@ import * as path from "path";
|
|
|
24
26
|
import * as yaml from "yaml";
|
|
25
27
|
import { WORKSPACE_NAME_RE } from "../agent-actor.js";
|
|
26
28
|
import { enginePath } from "../engine.js";
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_PACK_GUARDRAILS,
|
|
31
|
+
PACK_GUARDRAILS,
|
|
32
|
+
sharedInstructions,
|
|
33
|
+
type Guardrails,
|
|
34
|
+
} from "./guardrails.js";
|
|
27
35
|
|
|
28
36
|
export interface Persona {
|
|
29
37
|
id: string;
|
|
@@ -45,6 +53,13 @@ export interface Persona {
|
|
|
45
53
|
argv?: string[];
|
|
46
54
|
/** `permission_mode`: the runtime's permission mode (den-claude checks it). */
|
|
47
55
|
permissionMode?: string;
|
|
56
|
+
/** `den_hooks` / `den_instructions` as its front matter sets them. */
|
|
57
|
+
guardrailsSet?: Partial<Guardrails>;
|
|
58
|
+
/**
|
|
59
|
+
* Den's guardrails for it (guardrails.ts), resolved by loadPersonas from
|
|
60
|
+
* its pack and its own front matter. Absent: none.
|
|
61
|
+
*/
|
|
62
|
+
guardrails?: Guardrails;
|
|
48
63
|
/** The file it came from. */
|
|
49
64
|
source: string;
|
|
50
65
|
}
|
|
@@ -133,6 +148,17 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
133
148
|
const model = optString(f, "model", file);
|
|
134
149
|
const runtime = optString(f, "runtime", file);
|
|
135
150
|
const permissionMode = optString(f, "permission_mode", file);
|
|
151
|
+
const guardrailsSet: Partial<Guardrails> = {};
|
|
152
|
+
for (const [key, k] of [
|
|
153
|
+
["den_hooks", "hooks"],
|
|
154
|
+
["den_instructions", "instructions"],
|
|
155
|
+
] as const) {
|
|
156
|
+
const v = f[key];
|
|
157
|
+
if (v === undefined || v === null) continue;
|
|
158
|
+
if (typeof v !== "boolean")
|
|
159
|
+
throw new Error(`${file}: ${key} must be true or false`);
|
|
160
|
+
guardrailsSet[k] = v;
|
|
161
|
+
}
|
|
136
162
|
return {
|
|
137
163
|
id,
|
|
138
164
|
role: optString(f, "role", file) ?? optString(f, "description", file) ?? "",
|
|
@@ -144,6 +170,7 @@ export function parsePersona(text: string, file: string): Persona | null {
|
|
|
144
170
|
context: stringList(f, "context", file),
|
|
145
171
|
...(argv ? { argv } : {}),
|
|
146
172
|
...(permissionMode ? { permissionMode } : {}),
|
|
173
|
+
...(Object.keys(guardrailsSet).length ? { guardrailsSet } : {}),
|
|
147
174
|
source: file,
|
|
148
175
|
};
|
|
149
176
|
}
|
|
@@ -183,23 +210,42 @@ export function resolvePackDirs(packs: string[], distRoot: string): string[] {
|
|
|
183
210
|
});
|
|
184
211
|
}
|
|
185
212
|
|
|
213
|
+
/** Each pack's guardrails (guardrails.ts), as agents: configures them. */
|
|
214
|
+
export interface PackGuardrails {
|
|
215
|
+
/** Den's default pack (default: both on). */
|
|
216
|
+
defaultPack?: Partial<Guardrails>;
|
|
217
|
+
/** Each of packDirs, in order (default: both off). */
|
|
218
|
+
packs?: (Partial<Guardrails> | undefined)[];
|
|
219
|
+
}
|
|
220
|
+
|
|
186
221
|
/**
|
|
187
222
|
* 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.
|
|
223
|
+
* pack's persona replaces an earlier one of the same id. Each gets its
|
|
224
|
+
* guardrails: its own front matter's, else its pack's.
|
|
189
225
|
*/
|
|
190
226
|
export function loadPersonas(
|
|
191
227
|
packDirs: string[],
|
|
192
228
|
defaultPack: string = DEFAULT_PACK_DIR,
|
|
229
|
+
guardrails: PackGuardrails = {},
|
|
193
230
|
): Map<string, Persona> {
|
|
194
231
|
const all = new Map<string, Persona>();
|
|
195
|
-
|
|
196
|
-
|
|
232
|
+
const packs: [string, Guardrails][] = [
|
|
233
|
+
[defaultPack, { ...DEFAULT_PACK_GUARDRAILS, ...guardrails.defaultPack }],
|
|
234
|
+
...packDirs.map((dir, i): [string, Guardrails] => [
|
|
235
|
+
dir,
|
|
236
|
+
{ ...PACK_GUARDRAILS, ...guardrails.packs?.[i] },
|
|
237
|
+
]),
|
|
238
|
+
];
|
|
239
|
+
for (const [dir, g] of packs)
|
|
240
|
+
for (const p of readPack(dir))
|
|
241
|
+
all.set(p.id, { ...p, guardrails: { ...g, ...p.guardrailsSet } });
|
|
197
242
|
return all;
|
|
198
243
|
}
|
|
199
244
|
|
|
200
245
|
/**
|
|
201
246
|
* The role prompt as an agent gets it: who and where it is, what to read
|
|
202
|
-
* first,
|
|
247
|
+
* first, the persona's own prompt, then Den's shared instructions
|
|
248
|
+
* (guardrails/instructions.md) when its guardrails include them. Runtime-neutral; a runtime only
|
|
203
249
|
* chooses how to pass it in.
|
|
204
250
|
*/
|
|
205
251
|
export function rolePrompt(
|
|
@@ -214,5 +260,8 @@ export function rolePrompt(
|
|
|
214
260
|
];
|
|
215
261
|
if (persona.context.length)
|
|
216
262
|
lines.push(`Read first: ${persona.context.join("; ")}.`);
|
|
217
|
-
|
|
263
|
+
const shared = persona.guardrails?.instructions
|
|
264
|
+
? `\n\n${sharedInstructions()}`
|
|
265
|
+
: "";
|
|
266
|
+
return `${lines.join("\n")}\n\n${persona.prompt}${shared}`;
|
|
218
267
|
}
|
package/src/shared/den-config.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { OrgConfig } from "./org.js";
|
|
|
19
19
|
import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
|
|
20
20
|
import { ConfigError, isConfigError } from "./config-error.js";
|
|
21
21
|
import { validateCatalog, type CatalogEntry } from "./agents/catalog.js";
|
|
22
|
+
import type { Guardrails } from "./agents/guardrails.js";
|
|
22
23
|
import type { TestAccountsConfig } from "./auth/test-accounts.js";
|
|
23
24
|
|
|
24
25
|
import { product } from "./product.js";
|
|
@@ -833,9 +834,16 @@ export interface AgentsConfig {
|
|
|
833
834
|
/**
|
|
834
835
|
* Persona packs (AG-2a): directories of persona files, relative to the
|
|
835
836
|
* distribution root. Den's default pack is always read first; a pack's
|
|
836
|
-
* persona replaces one of the same id.
|
|
837
|
+
* persona replaces one of the same id. An entry may be a mapping,
|
|
838
|
+
* `{ path, hooks, instructions }`, to turn Den's guardrails on for that
|
|
839
|
+
* pack (agents/guardrails.ts; off for a distribution's pack by default).
|
|
837
840
|
*/
|
|
838
|
-
persona_packs?: string[];
|
|
841
|
+
persona_packs?: (string | PersonaPackConfig)[];
|
|
842
|
+
/**
|
|
843
|
+
* Den's default pack's guardrails, `{ hooks, instructions }` (both on
|
|
844
|
+
* by default): false turns that one off for every persona of the pack.
|
|
845
|
+
*/
|
|
846
|
+
default_pack?: Partial<Guardrails>;
|
|
839
847
|
/**
|
|
840
848
|
* More runtime catalog entries (AG-0.5, agents/catalog.ts): a vendor CLI
|
|
841
849
|
* Den doesn't ship, with the same fields and checks as a built-in one.
|
|
@@ -849,6 +857,13 @@ export interface AgentsConfig {
|
|
|
849
857
|
[agentId: string]: unknown;
|
|
850
858
|
}
|
|
851
859
|
|
|
860
|
+
/** A `persona_packs` entry written as a mapping. */
|
|
861
|
+
export interface PersonaPackConfig {
|
|
862
|
+
path: string;
|
|
863
|
+
hooks?: boolean;
|
|
864
|
+
instructions?: boolean;
|
|
865
|
+
}
|
|
866
|
+
|
|
852
867
|
export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
|
|
853
868
|
|
|
854
869
|
/** The keys of `agents:` that are not an agent id. */
|
|
@@ -856,6 +871,7 @@ export const AGENTS_RESERVED_KEYS = [
|
|
|
856
871
|
"max_concurrent",
|
|
857
872
|
"persona_packs",
|
|
858
873
|
"runtime_catalog",
|
|
874
|
+
"default_pack",
|
|
859
875
|
];
|
|
860
876
|
|
|
861
877
|
/** One agent's own block, `agents.<id>`. */
|
|
@@ -869,6 +885,10 @@ export interface ResolvedAgentsConfig {
|
|
|
869
885
|
maxConcurrent: number;
|
|
870
886
|
/** As configured (relative to the distribution root). */
|
|
871
887
|
personaPacks: string[];
|
|
888
|
+
/** Each pack's guardrails as configured, by index of personaPacks. */
|
|
889
|
+
packGuardrails?: (Partial<Guardrails> | undefined)[];
|
|
890
|
+
/** agents.default_pack as configured. */
|
|
891
|
+
defaultPackGuardrails?: Partial<Guardrails>;
|
|
872
892
|
/** `agents.<id>` blocks, by agent id. */
|
|
873
893
|
agents: Record<string, AgentGrantsConfig>;
|
|
874
894
|
/** The distribution's runtime catalog entries (AG-0.5), checked. */
|
|
@@ -881,6 +901,36 @@ const AGENT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
881
901
|
const isMapping = (v: unknown): v is Record<string, unknown> =>
|
|
882
902
|
v !== null && typeof v === "object" && !Array.isArray(v);
|
|
883
903
|
|
|
904
|
+
/**
|
|
905
|
+
* `{ hooks, instructions }` (agents.default_pack, or a persona_packs
|
|
906
|
+
* mapping with its `path`): each true or false; any other key throws.
|
|
907
|
+
*/
|
|
908
|
+
function guardrailsBlock(
|
|
909
|
+
v: unknown,
|
|
910
|
+
where: string,
|
|
911
|
+
withPath: boolean,
|
|
912
|
+
): Partial<Guardrails> {
|
|
913
|
+
if (v === undefined || v === null) return {};
|
|
914
|
+
if (!isMapping(v))
|
|
915
|
+
throw new Error(
|
|
916
|
+
`${where}: must be a mapping ({ hooks: true|false, instructions: true|false })`,
|
|
917
|
+
);
|
|
918
|
+
const out: Partial<Guardrails> = {};
|
|
919
|
+
for (const [k, x] of Object.entries(v)) {
|
|
920
|
+
if (withPath && k === "path") continue;
|
|
921
|
+
if (k !== "hooks" && k !== "instructions")
|
|
922
|
+
throw new Error(
|
|
923
|
+
`${where}.${k}: unknown key (takes ${withPath ? "path, " : ""}hooks, instructions)`,
|
|
924
|
+
);
|
|
925
|
+
if (typeof x !== "boolean")
|
|
926
|
+
throw new Error(
|
|
927
|
+
`${where}.${k}: must be true or false (got ${JSON.stringify(x)})`,
|
|
928
|
+
);
|
|
929
|
+
out[k] = x;
|
|
930
|
+
}
|
|
931
|
+
return out;
|
|
932
|
+
}
|
|
933
|
+
|
|
884
934
|
/**
|
|
885
935
|
* The effective agents config. Anything malformed throws, naming the key:
|
|
886
936
|
* a typo must not lift the cap, drop a pack or widen a grant.
|
|
@@ -894,6 +944,12 @@ export function readAgentsConfig(
|
|
|
894
944
|
const out: ResolvedAgentsConfig = {
|
|
895
945
|
maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
|
|
896
946
|
personaPacks: [],
|
|
947
|
+
packGuardrails: [],
|
|
948
|
+
defaultPackGuardrails: guardrailsBlock(
|
|
949
|
+
raw.default_pack,
|
|
950
|
+
"agents.default_pack",
|
|
951
|
+
false,
|
|
952
|
+
),
|
|
897
953
|
agents: {},
|
|
898
954
|
runtimeCatalog: validateCatalog(
|
|
899
955
|
raw.runtime_catalog,
|
|
@@ -915,19 +971,24 @@ export function readAgentsConfig(
|
|
|
915
971
|
`agents.persona_packs: must be a list of paths (got ${JSON.stringify(packs)})`,
|
|
916
972
|
);
|
|
917
973
|
packs.forEach((p, i) => {
|
|
918
|
-
|
|
974
|
+
const where = `agents.persona_packs[${i}]`;
|
|
975
|
+
const dir = isMapping(p) ? p.path : p;
|
|
976
|
+
if (typeof dir !== "string" || !dir.trim())
|
|
919
977
|
throw new Error(
|
|
920
|
-
|
|
978
|
+
`${where}: must be a non-empty path or { path, hooks, instructions } (got ${JSON.stringify(p)})`,
|
|
921
979
|
);
|
|
980
|
+
out.personaPacks.push(dir);
|
|
981
|
+
out.packGuardrails!.push(
|
|
982
|
+
isMapping(p) ? guardrailsBlock(p, where, true) : undefined,
|
|
983
|
+
);
|
|
922
984
|
});
|
|
923
|
-
out.personaPacks = packs as string[];
|
|
924
985
|
}
|
|
925
986
|
for (const [id, block] of Object.entries(raw)) {
|
|
926
987
|
if (AGENTS_RESERVED_KEYS.includes(id)) continue;
|
|
927
988
|
const where = `agents.${id}`;
|
|
928
989
|
if (!AGENT_KEY_RE.test(id) || !isMapping(block))
|
|
929
990
|
throw new Error(
|
|
930
|
-
`${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, ` +
|
|
991
|
+
`${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, default_pack, ` +
|
|
931
992
|
`or an agent id mapping to { grants: [...] })`,
|
|
932
993
|
);
|
|
933
994
|
for (const k of Object.keys(block))
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* else). A DSN, in the env or a synced dev-metrics.config, is where to send
|
|
20
20
|
* once opted in; on its own it never sends.
|
|
21
21
|
*
|
|
22
|
+
* Each line also has `cwd` (den#5): where the command ran, relative to the
|
|
23
|
+
* distribution root ("." for the root itself), or "outside" for anywhere
|
|
24
|
+
* else, never an absolute or home path. `<cli> doctor` reads it to spot
|
|
25
|
+
* polling loops (polling.ts). Like every field, it is sent only when opted
|
|
26
|
+
* in; the envelope format is unchanged.
|
|
27
|
+
*
|
|
22
28
|
* Guarantees: never throws, never blocks the caller (the Sentry ship is
|
|
23
29
|
* fire-and-forget, bounded to ~2s). CI=true / DEV_METRICS_DISABLED=1 suppress
|
|
24
30
|
* shipping but still buffer the JSONL locally.
|
|
@@ -115,6 +121,29 @@ function commonFields(event: string, root: string): Record<string, string> {
|
|
|
115
121
|
};
|
|
116
122
|
}
|
|
117
123
|
|
|
124
|
+
/** The JSONL file, under the repo root. */
|
|
125
|
+
export function eventsFile(root: string = repoRoot()): string {
|
|
126
|
+
return path.join(root, ".dev-metrics", "events.jsonl");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `cwd` for a local line: the path from the root to cwd ("." for the root,
|
|
131
|
+
* "/"-separated), or "outside" when cwd is not under the root.
|
|
132
|
+
*/
|
|
133
|
+
export function cwdField(root: string, cwd: string = process.cwd()): string {
|
|
134
|
+
const real = (p: string): string => {
|
|
135
|
+
try {
|
|
136
|
+
return fs.realpathSync(p);
|
|
137
|
+
} catch {
|
|
138
|
+
return path.resolve(p);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const rel = path.relative(real(root), real(cwd));
|
|
142
|
+
if (rel === "") return ".";
|
|
143
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return "outside";
|
|
144
|
+
return rel.split(path.sep).join("/");
|
|
145
|
+
}
|
|
146
|
+
|
|
118
147
|
function isoNoMillis(): string {
|
|
119
148
|
return new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
120
149
|
}
|
|
@@ -334,14 +363,12 @@ export function record(
|
|
|
334
363
|
for (const [k, v] of Object.entries(fields)) {
|
|
335
364
|
obj[k] = v == null ? "" : String(v);
|
|
336
365
|
}
|
|
366
|
+
obj.cwd = cwdField(root);
|
|
337
367
|
|
|
338
368
|
try {
|
|
339
369
|
const dir = path.join(root, ".dev-metrics");
|
|
340
370
|
fs.mkdirSync(dir, { recursive: true });
|
|
341
|
-
fs.appendFileSync(
|
|
342
|
-
path.join(dir, "events.jsonl"),
|
|
343
|
-
JSON.stringify(obj) + "\n",
|
|
344
|
-
);
|
|
371
|
+
fs.appendFileSync(eventsFile(root), JSON.stringify(obj) + "\n");
|
|
345
372
|
} catch {
|
|
346
373
|
/* non-writable dir → fail-open */
|
|
347
374
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polling-loop warning (den#5) for `<cli> doctor`: one command, run from
|
|
3
|
+
* one place, more than POLL_LIMIT times in any POLL_WINDOW_MS window looks
|
|
4
|
+
* like an agent polling it. Local only: it reads the tail of the local
|
|
5
|
+
* .dev-metrics/events.jsonl and sends nothing anywhere.
|
|
6
|
+
*
|
|
7
|
+
* A line's place is its `cwd` (emit.ts). An older line has none, so it is
|
|
8
|
+
* keyed by its `repo` instead.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
|
|
12
|
+
/** More calls than this in one window is a polling loop. */
|
|
13
|
+
export const POLL_LIMIT = 600;
|
|
14
|
+
/** The window: an hour. */
|
|
15
|
+
export const POLL_WINDOW_MS = 60 * 60 * 1000;
|
|
16
|
+
/** At most this much of the file's end is read, however big it is. */
|
|
17
|
+
export const TAIL_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The file's last `max` bytes as whole lines: a line cut by the start of
|
|
21
|
+
* the slice is dropped. A missing or unreadable file is no lines.
|
|
22
|
+
*/
|
|
23
|
+
export function readTailLines(
|
|
24
|
+
file: string,
|
|
25
|
+
max: number = TAIL_BYTES,
|
|
26
|
+
): string[] {
|
|
27
|
+
let fd: number | undefined;
|
|
28
|
+
try {
|
|
29
|
+
fd = fs.openSync(file, "r");
|
|
30
|
+
const size = fs.fstatSync(fd).size;
|
|
31
|
+
const len = Math.min(size, max);
|
|
32
|
+
const buf = Buffer.alloc(len);
|
|
33
|
+
let got = 0;
|
|
34
|
+
while (got < len) {
|
|
35
|
+
const n = fs.readSync(fd, buf, got, len - got, size - len + got);
|
|
36
|
+
if (n <= 0) break;
|
|
37
|
+
got += n;
|
|
38
|
+
}
|
|
39
|
+
const lines = buf.subarray(0, got).toString("utf-8").split("\n");
|
|
40
|
+
if (len < size) lines.shift();
|
|
41
|
+
return lines.filter((l) => l.trim());
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
} finally {
|
|
45
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PollingLoop {
|
|
50
|
+
command: string;
|
|
51
|
+
/** Where: the line's cwd, or `repo <name>` for an older line. */
|
|
52
|
+
where: string;
|
|
53
|
+
/** Calls in the busiest window. */
|
|
54
|
+
calls: number;
|
|
55
|
+
/** The busiest window's first and last call (ISO, no millis). */
|
|
56
|
+
from: string;
|
|
57
|
+
to: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const iso = (ms: number): string =>
|
|
61
|
+
new Date(ms).toISOString().replace(/\.\d+Z$/, "Z");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every command+place with more than `limit` calls in some `windowMs`
|
|
65
|
+
* window, with its busiest window. Lines that aren't JSON, or have no
|
|
66
|
+
* command or time, are skipped.
|
|
67
|
+
*/
|
|
68
|
+
export function findPollingLoops(
|
|
69
|
+
lines: string[],
|
|
70
|
+
limit: number = POLL_LIMIT,
|
|
71
|
+
windowMs: number = POLL_WINDOW_MS,
|
|
72
|
+
): PollingLoop[] {
|
|
73
|
+
const byKey = new Map<
|
|
74
|
+
string,
|
|
75
|
+
{ command: string; where: string; times: number[] }
|
|
76
|
+
>();
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
let o: Record<string, unknown>;
|
|
79
|
+
try {
|
|
80
|
+
o = JSON.parse(line) as Record<string, unknown>;
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!o || typeof o !== "object") continue;
|
|
85
|
+
const command = typeof o.command === "string" ? o.command : "";
|
|
86
|
+
const t = typeof o.ts === "string" ? Date.parse(o.ts) : NaN;
|
|
87
|
+
if (!command || Number.isNaN(t)) continue;
|
|
88
|
+
const where =
|
|
89
|
+
typeof o.cwd === "string" && o.cwd
|
|
90
|
+
? o.cwd
|
|
91
|
+
: `repo ${typeof o.repo === "string" && o.repo ? o.repo : "?"}`;
|
|
92
|
+
const key = `${command}\u0000${where}`;
|
|
93
|
+
let e = byKey.get(key);
|
|
94
|
+
if (!e) byKey.set(key, (e = { command, where, times: [] }));
|
|
95
|
+
e.times.push(t);
|
|
96
|
+
}
|
|
97
|
+
const out: PollingLoop[] = [];
|
|
98
|
+
for (const { command, where, times } of byKey.values()) {
|
|
99
|
+
if (times.length <= limit) continue;
|
|
100
|
+
times.sort((a, b) => a - b);
|
|
101
|
+
let best = 0;
|
|
102
|
+
let bi = 0;
|
|
103
|
+
let bj = 0;
|
|
104
|
+
for (let i = 0, j = 0; j < times.length; j++) {
|
|
105
|
+
while (times[j] - times[i] >= windowMs) i++;
|
|
106
|
+
if (j - i + 1 > best) {
|
|
107
|
+
best = j - i + 1;
|
|
108
|
+
bi = i;
|
|
109
|
+
bj = j;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (best > limit)
|
|
113
|
+
out.push({
|
|
114
|
+
command,
|
|
115
|
+
where,
|
|
116
|
+
calls: best,
|
|
117
|
+
from: iso(times[bi]),
|
|
118
|
+
to: iso(times[bj]),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return out.sort((a, b) => b.calls - a.calls);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One warning line, as doctor prints it. */
|
|
125
|
+
export function pollingWarning(p: PollingLoop): string {
|
|
126
|
+
return `looks like a polling loop: ${p.command} in ${p.where}, ${p.calls} calls in an hour (${p.from}–${p.to})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** doctor's warnings for this events file (none when it is fine or absent). */
|
|
130
|
+
export function pollingWarnings(
|
|
131
|
+
file: string,
|
|
132
|
+
max: number = TAIL_BYTES,
|
|
133
|
+
): string[] {
|
|
134
|
+
return findPollingLoops(readTailLines(file, max)).map(pollingWarning);
|
|
135
|
+
}
|