@sigrun/warden 0.0.0-stage → 0.1.1
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 +49 -0
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/dist/after.d.ts +55 -0
- package/dist/after.js +189 -0
- package/dist/card-check.d.ts +44 -0
- package/dist/card-check.js +68 -0
- package/dist/cards.d.ts +34 -0
- package/dist/cards.js +141 -0
- package/dist/classify.d.ts +76 -0
- package/dist/classify.js +196 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +104 -0
- package/dist/context.d.ts +42 -0
- package/dist/context.js +103 -0
- package/dist/escalate.d.ts +38 -0
- package/dist/escalate.js +72 -0
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +54 -0
- package/dist/glob.d.ts +10 -0
- package/dist/glob.js +23 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +194 -0
- package/dist/interaction.d.ts +28 -0
- package/dist/interaction.js +93 -0
- package/dist/judge.d.ts +43 -0
- package/dist/judge.js +75 -0
- package/dist/live.d.ts +31 -0
- package/dist/live.js +311 -0
- package/dist/log.d.ts +28 -0
- package/dist/log.js +109 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.js +18 -0
- package/dist/route.d.ts +64 -0
- package/dist/route.js +513 -0
- package/dist/settings.d.ts +48 -0
- package/dist/settings.js +167 -0
- package/dist/skills.d.ts +33 -0
- package/dist/skills.js +203 -0
- package/dist/state.d.ts +59 -0
- package/dist/state.js +110 -0
- package/dist/targets.d.ts +8 -0
- package/dist/targets.js +32 -0
- package/package.json +60 -5
package/dist/cards.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill cards (warden spec §3.4): a compact digest of one skill body,
|
|
3
|
+
* built once per body hash by `host.models.complete` and stored under
|
|
4
|
+
* the warden's data root. A build yields `undefined` on any failure or
|
|
5
|
+
* an answer over 8192 bytes; builds are single-flight per hash.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
export const CARD_MAX_BYTES = 8192;
|
|
10
|
+
export const CARD_TIMEOUT_MS = 60_000;
|
|
11
|
+
/** The system prompt for one card build: quote the skill, invent nothing. */
|
|
12
|
+
export const CARD_SYSTEM_PROMPT = `You are writing a rule card for one skill, from the skill text alone.
|
|
13
|
+
|
|
14
|
+
For EACH tool the skill names as destructive (remove, delete, import, deny, unmatch, purge, blocklist, and similar actions that change or destroy data), write one heading:
|
|
15
|
+
|
|
16
|
+
## <tool>
|
|
17
|
+
|
|
18
|
+
Under each heading write exactly three bullet lists:
|
|
19
|
+
|
|
20
|
+
**Allowed without asking when:** the exact conditions the skill states.
|
|
21
|
+
**Evidence required first:** which earlier lookups the skill requires, and what they must show. Quote the skill's own check names and values.
|
|
22
|
+
**Escalate instead when:** the exact conditions the skill states.
|
|
23
|
+
|
|
24
|
+
Quote the skill's own thresholds, limits, and names verbatim. Do not invent conditions, thresholds, tools, or evidence steps the skill does not name. If the skill names no destructive tools, answer exactly: no destructive tools.`;
|
|
25
|
+
/** The warden's card store: one JSON file per card at `<dataDir>/cards/<hash>.json`. */
|
|
26
|
+
export class CardStore {
|
|
27
|
+
#dir;
|
|
28
|
+
/** `dataDir` is the warden's `host.stores.paths.data`. */
|
|
29
|
+
constructor(dataDir) {
|
|
30
|
+
this.#dir = join(dataDir, "cards");
|
|
31
|
+
}
|
|
32
|
+
async get(hash) {
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = await readFile(join(this.#dir, `${hash}.json`), "utf8");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async put(card) {
|
|
48
|
+
await mkdir(this.#dir, { recursive: true });
|
|
49
|
+
await writeFile(join(this.#dir, `${card.hash}.json`), `${JSON.stringify(card, null, 2)}\n`, "utf8");
|
|
50
|
+
}
|
|
51
|
+
async list() {
|
|
52
|
+
let names;
|
|
53
|
+
try {
|
|
54
|
+
names = await readdir(this.#dir);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const cards = [];
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
if (!name.endsWith(".json"))
|
|
62
|
+
continue;
|
|
63
|
+
const card = await this.get(name.slice(0, -".json".length));
|
|
64
|
+
if (card !== undefined)
|
|
65
|
+
cards.push(card);
|
|
66
|
+
}
|
|
67
|
+
// Newest build first.
|
|
68
|
+
cards.sort((a, b) => (a.builtAt < b.builtAt ? 1 : a.builtAt > b.builtAt ? -1 : 0));
|
|
69
|
+
return cards;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* In-flight builds, keyed by body hash: concurrent builds for one hash
|
|
74
|
+
* call the model once (spec §3.4 single-flight). Module-scoped — one
|
|
75
|
+
* process, one warden instance.
|
|
76
|
+
*/
|
|
77
|
+
const inflight = new Map();
|
|
78
|
+
/** Test hook: drop the single-flight map (a failed build must not pin a test). */
|
|
79
|
+
export function resetInflightForTests() {
|
|
80
|
+
inflight.clear();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build (or fetch the in-flight build of) the card for one skill body.
|
|
84
|
+
* `undefined` on any failure or an answer over the byte cap; the caller
|
|
85
|
+
* (an afterTool background task or `/warden card --rebuild`) treats
|
|
86
|
+
* undefined as "no card" and never blocks on it.
|
|
87
|
+
*/
|
|
88
|
+
export function buildCard(host, skill, settings) {
|
|
89
|
+
const existing = inflight.get(skill.hash);
|
|
90
|
+
if (existing !== undefined)
|
|
91
|
+
return existing;
|
|
92
|
+
const build = buildCardUncached(host, skill, settings).finally(() => {
|
|
93
|
+
inflight.delete(skill.hash);
|
|
94
|
+
});
|
|
95
|
+
inflight.set(skill.hash, build);
|
|
96
|
+
return build;
|
|
97
|
+
}
|
|
98
|
+
async function buildCardUncached(host, skill, settings) {
|
|
99
|
+
// Store first: a card already built for this body is returned
|
|
100
|
+
// without a model call.
|
|
101
|
+
const store = new CardStore(host.stores.paths.data);
|
|
102
|
+
try {
|
|
103
|
+
const stored = await store.get(skill.hash);
|
|
104
|
+
if (stored !== undefined)
|
|
105
|
+
return stored;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// An unreadable store falls through to a fresh build.
|
|
109
|
+
}
|
|
110
|
+
let answer;
|
|
111
|
+
try {
|
|
112
|
+
answer = await host.models.complete({
|
|
113
|
+
...(settings.cardModel === undefined
|
|
114
|
+
? { role: "utility" }
|
|
115
|
+
: { model: settings.cardModel }),
|
|
116
|
+
systemPrompt: CARD_SYSTEM_PROMPT,
|
|
117
|
+
prompt: `Skill: ${skill.name}\n\n${skill.body}`,
|
|
118
|
+
timeoutMs: CARD_TIMEOUT_MS,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
if (Buffer.byteLength(answer.text, "utf8") > CARD_MAX_BYTES)
|
|
125
|
+
return undefined;
|
|
126
|
+
const card = {
|
|
127
|
+
hash: skill.hash,
|
|
128
|
+
skill: skill.name,
|
|
129
|
+
...(skill.location === undefined ? {} : { location: skill.location }),
|
|
130
|
+
builtAt: new Date().toISOString(),
|
|
131
|
+
model: answer.model,
|
|
132
|
+
text: answer.text,
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
await store.put(card);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return card;
|
|
141
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { WardenSettings } from "./settings.js";
|
|
2
|
+
export type Classification = {
|
|
3
|
+
kind: "allow";
|
|
4
|
+
reason: string;
|
|
5
|
+
warns?: readonly string[];
|
|
6
|
+
} | {
|
|
7
|
+
kind: "deny";
|
|
8
|
+
reason: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "destructive";
|
|
11
|
+
reasons: readonly string[];
|
|
12
|
+
mcp?: {
|
|
13
|
+
server: string;
|
|
14
|
+
mcpName: string;
|
|
15
|
+
};
|
|
16
|
+
warns?: readonly string[];
|
|
17
|
+
} | {
|
|
18
|
+
kind: "bash-unknown";
|
|
19
|
+
command: string;
|
|
20
|
+
warns?: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The annotation answer for one MCP tool name (`server/mcpName`):
|
|
24
|
+
* `found` carries the published annotation entry, `unknown` means the
|
|
25
|
+
* provider answered but the tool carried no trustable annotation
|
|
26
|
+
* (`unknown: true`), and `no-provider` means no extension publishes the
|
|
27
|
+
* `mcp.tool-annotations` hook at all.
|
|
28
|
+
*/
|
|
29
|
+
export type AnnotationLookup = {
|
|
30
|
+
status: "found";
|
|
31
|
+
server: string;
|
|
32
|
+
mcpName: string;
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
destructive?: boolean;
|
|
35
|
+
} | {
|
|
36
|
+
status: "unknown";
|
|
37
|
+
} | {
|
|
38
|
+
status: "no-provider";
|
|
39
|
+
};
|
|
40
|
+
export interface ClassifyDeps {
|
|
41
|
+
annotations(tool: string): Promise<AnnotationLookup>;
|
|
42
|
+
settings: WardenSettings;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The published-hook call Task 11's host wiring uses, kept here so both
|
|
46
|
+
* the factory and future tests share the mapping: the hook is
|
|
47
|
+
* `mcp.tool-annotations`; `absent` maps to `no-provider`, an `ok` value
|
|
48
|
+
* with `unknown: true` maps to `unknown`, anything else `ok` is the
|
|
49
|
+
* annotation entry.
|
|
50
|
+
*/
|
|
51
|
+
export declare function annotationFromHookResult(tool: string, result: {
|
|
52
|
+
status: "absent";
|
|
53
|
+
} | {
|
|
54
|
+
status: "ok";
|
|
55
|
+
value: unknown;
|
|
56
|
+
} | {
|
|
57
|
+
status: string;
|
|
58
|
+
}): AnnotationLookup;
|
|
59
|
+
/**
|
|
60
|
+
* Classify one tool call. The order is the warden spec's:
|
|
61
|
+
* 1. deny rule → deny;
|
|
62
|
+
* 2. core read-only names → allow;
|
|
63
|
+
* 3. bash: read-only → allow; `matchPatterns` on the RAW command →
|
|
64
|
+
* destructive; else bash-unknown;
|
|
65
|
+
* 4. a name containing `/` → the annotations lookup (readOnly or
|
|
66
|
+
* `destructive === false` → allow; destructive / unknown /
|
|
67
|
+
* no-provider → destructive with the mcp pair);
|
|
68
|
+
* 5. hold rules → destructive;
|
|
69
|
+
* 6. otherwise allow.
|
|
70
|
+
* `warn` rules never change the verdict; the matched patterns come back
|
|
71
|
+
* in `warns` so the caller writes the warn log records.
|
|
72
|
+
*/
|
|
73
|
+
export declare function classify(call: {
|
|
74
|
+
name: string;
|
|
75
|
+
arguments: Record<string, unknown>;
|
|
76
|
+
}, deps: ClassifyDeps): Promise<Classification>;
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action classification (warden spec §3.1). Pure over injected deps: the
|
|
3
|
+
* MCP annotations lookup is provided by the caller (Task 11 wires it to
|
|
4
|
+
* `host.publishedHooks.call("mcp.tool-annotations", …)`; tests inject
|
|
5
|
+
* fakes).
|
|
6
|
+
*/
|
|
7
|
+
import { isReadOnlyCommand, matchPatterns } from "pi-warden";
|
|
8
|
+
import { matchGlob } from "./glob.js";
|
|
9
|
+
/** Core tool names that are always read-only. */
|
|
10
|
+
const CORE_READ_ONLY = new Set(["read", "ls", "find", "grep", "load_skill"]);
|
|
11
|
+
function splitMcpName(name) {
|
|
12
|
+
const slash = name.indexOf("/");
|
|
13
|
+
return { server: name.slice(0, slash), mcpName: name.slice(slash + 1) };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The published-hook call Task 11's host wiring uses, kept here so both
|
|
17
|
+
* the factory and future tests share the mapping: the hook is
|
|
18
|
+
* `mcp.tool-annotations`; `absent` maps to `no-provider`, an `ok` value
|
|
19
|
+
* with `unknown: true` maps to `unknown`, anything else `ok` is the
|
|
20
|
+
* annotation entry.
|
|
21
|
+
*/
|
|
22
|
+
export function annotationFromHookResult(tool, result) {
|
|
23
|
+
if (result.status === "absent")
|
|
24
|
+
return { status: "no-provider" };
|
|
25
|
+
if (result.status !== "ok")
|
|
26
|
+
return { status: "no-provider" };
|
|
27
|
+
const value = result.value;
|
|
28
|
+
if (typeof value !== "object" || value === null)
|
|
29
|
+
return { status: "unknown" };
|
|
30
|
+
const record = value;
|
|
31
|
+
if (record.unknown === true)
|
|
32
|
+
return { status: "unknown" };
|
|
33
|
+
const { server, mcpName } = splitMcpName(tool);
|
|
34
|
+
// The hook's published output schema (ext-mcp 0.2.3's
|
|
35
|
+
// `mcp.tool-annotations`) nests the hints under `annotations`, spelled
|
|
36
|
+
// `readOnlyHint` / `destructiveHint`. The flat `readOnly` / `destructive`
|
|
37
|
+
// keys were the warden spec's shorthand and no provider ever served
|
|
38
|
+
// them; they stay accepted defensively, but the nested shape is the
|
|
39
|
+
// wire truth and wins when both appear.
|
|
40
|
+
const hints = typeof record.annotations === "object" && record.annotations !== null
|
|
41
|
+
? record.annotations
|
|
42
|
+
: {};
|
|
43
|
+
const readOnlyHint = hints.readOnlyHint;
|
|
44
|
+
const destructiveHint = hints.destructiveHint;
|
|
45
|
+
return {
|
|
46
|
+
status: "found",
|
|
47
|
+
server,
|
|
48
|
+
mcpName,
|
|
49
|
+
...(typeof readOnlyHint === "boolean"
|
|
50
|
+
? { readOnly: readOnlyHint }
|
|
51
|
+
: typeof record.readOnly === "boolean"
|
|
52
|
+
? { readOnly: record.readOnly }
|
|
53
|
+
: {}),
|
|
54
|
+
...(typeof destructiveHint === "boolean"
|
|
55
|
+
? { destructive: destructiveHint }
|
|
56
|
+
: typeof record.destructive === "boolean"
|
|
57
|
+
? { destructive: record.destructive }
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Every string argument of a tool call (the `path`-rule match surface). */
|
|
62
|
+
function stringArguments(args) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const value of Object.values(args)) {
|
|
65
|
+
if (typeof value === "string")
|
|
66
|
+
out.push(value);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Classify one tool call. The order is the warden spec's:
|
|
72
|
+
* 1. deny rule → deny;
|
|
73
|
+
* 2. core read-only names → allow;
|
|
74
|
+
* 3. bash: read-only → allow; `matchPatterns` on the RAW command →
|
|
75
|
+
* destructive; else bash-unknown;
|
|
76
|
+
* 4. a name containing `/` → the annotations lookup (readOnly or
|
|
77
|
+
* `destructive === false` → allow; destructive / unknown /
|
|
78
|
+
* no-provider → destructive with the mcp pair);
|
|
79
|
+
* 5. hold rules → destructive;
|
|
80
|
+
* 6. otherwise allow.
|
|
81
|
+
* `warn` rules never change the verdict; the matched patterns come back
|
|
82
|
+
* in `warns` so the caller writes the warn log records.
|
|
83
|
+
*/
|
|
84
|
+
export async function classify(call, deps) {
|
|
85
|
+
const { name, arguments: args } = call;
|
|
86
|
+
const rules = deps.settings.rules;
|
|
87
|
+
const command = typeof args.command === "string" ? args.command : undefined;
|
|
88
|
+
const ruleTargets = (match) => {
|
|
89
|
+
if (match === "tool")
|
|
90
|
+
return [name];
|
|
91
|
+
if (match === "command")
|
|
92
|
+
return command === undefined ? [] : [command];
|
|
93
|
+
return stringArguments(args);
|
|
94
|
+
};
|
|
95
|
+
for (const rule of rules) {
|
|
96
|
+
if (rule.level !== "deny")
|
|
97
|
+
continue;
|
|
98
|
+
const target = ruleTargets(rule.match).find((value) => matchGlob(value, [rule.pattern]) !== undefined);
|
|
99
|
+
if (target !== undefined) {
|
|
100
|
+
return {
|
|
101
|
+
kind: "deny",
|
|
102
|
+
reason: `deny rule ${JSON.stringify(rule.pattern)} matched ${JSON.stringify(target)}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const warns = [];
|
|
107
|
+
for (const rule of rules) {
|
|
108
|
+
if (rule.level !== "warn")
|
|
109
|
+
continue;
|
|
110
|
+
const target = ruleTargets(rule.match).find((value) => matchGlob(value, [rule.pattern]) !== undefined);
|
|
111
|
+
if (target !== undefined)
|
|
112
|
+
warns.push(rule.pattern);
|
|
113
|
+
}
|
|
114
|
+
const withWarns = (classification) => warns.length === 0 ? classification : { ...classification, warns };
|
|
115
|
+
// 2. Core read-only names.
|
|
116
|
+
if (CORE_READ_ONLY.has(name)) {
|
|
117
|
+
return withWarns({ kind: "allow", reason: `core tool ${name} is read-only` });
|
|
118
|
+
}
|
|
119
|
+
// 3. Bash. A script containing operators (`< & | ; \` $(`) is not plain
|
|
120
|
+
// read-only syntax: skip the read-only head check and go straight to
|
|
121
|
+
// the destructive patterns, whose matcher treats heredoc bodies as
|
|
122
|
+
// data. An unmatched compound lands as bash-unknown (held), which is
|
|
123
|
+
// the safe class for "we cannot tell".
|
|
124
|
+
if (name === "bash") {
|
|
125
|
+
if (command !== undefined &&
|
|
126
|
+
!/[<&|;`]/.test(command) &&
|
|
127
|
+
!command.includes("$(") &&
|
|
128
|
+
isReadOnlyCommand(command)) {
|
|
129
|
+
return withWarns({ kind: "allow", reason: "read-only shell command" });
|
|
130
|
+
}
|
|
131
|
+
// matchPatterns does its own comment/heredoc handling — never pre-strip.
|
|
132
|
+
if (command !== undefined) {
|
|
133
|
+
const hits = matchPatterns("bash", { command });
|
|
134
|
+
if (hits.length > 0) {
|
|
135
|
+
return withWarns({
|
|
136
|
+
kind: "destructive",
|
|
137
|
+
reasons: hits.map((hit) => hit.label),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return withWarns({ kind: "bash-unknown", command: command ?? "" });
|
|
142
|
+
}
|
|
143
|
+
// 4. MCP tools (`server/mcpName`) via the annotations lookup.
|
|
144
|
+
if (name.includes("/")) {
|
|
145
|
+
const annotation = await deps.annotations(name);
|
|
146
|
+
if (annotation.status === "found") {
|
|
147
|
+
if (annotation.readOnly === true) {
|
|
148
|
+
return withWarns({
|
|
149
|
+
kind: "allow",
|
|
150
|
+
reason: `MCP tool ${name} is annotated readOnly`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (annotation.destructive === false) {
|
|
154
|
+
return withWarns({
|
|
155
|
+
kind: "allow",
|
|
156
|
+
reason: `MCP tool ${name} is annotated non-destructive`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return withWarns({
|
|
160
|
+
kind: "destructive",
|
|
161
|
+
reasons: [`MCP tool ${name} is annotated destructive`],
|
|
162
|
+
mcp: { server: annotation.server, mcpName: annotation.mcpName },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (annotation.status === "unknown") {
|
|
166
|
+
return withWarns({
|
|
167
|
+
kind: "destructive",
|
|
168
|
+
reasons: [`MCP tool ${name} has no trustable annotation`],
|
|
169
|
+
mcp: splitMcpName(name),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return withWarns({
|
|
173
|
+
kind: "destructive",
|
|
174
|
+
reasons: [
|
|
175
|
+
`no MCP annotation provider is available; treating ${name} as destructive`,
|
|
176
|
+
],
|
|
177
|
+
mcp: splitMcpName(name),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// 5. Hold rules.
|
|
181
|
+
for (const rule of rules) {
|
|
182
|
+
if (rule.level !== "hold")
|
|
183
|
+
continue;
|
|
184
|
+
const target = ruleTargets(rule.match).find((value) => matchGlob(value, [rule.pattern]) !== undefined);
|
|
185
|
+
if (target !== undefined) {
|
|
186
|
+
return withWarns({
|
|
187
|
+
kind: "destructive",
|
|
188
|
+
reasons: [
|
|
189
|
+
`hold rule ${JSON.stringify(rule.pattern)} matched ${JSON.stringify(target)}`,
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// 6. Otherwise allow.
|
|
195
|
+
return withWarns({ kind: "allow", reason: `no rule or annotation covers ${name}` });
|
|
196
|
+
}
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { buildCard, CardStore } from "./cards.js";
|
|
2
|
+
import { HoldLog } from "./log.js";
|
|
3
|
+
import { parseSettings } from "./settings.js";
|
|
4
|
+
import { skillsInRun } from "./skills.js";
|
|
5
|
+
const RUN_PAGE = 500;
|
|
6
|
+
/** Build the warden's slash commands over the host (one per factory). */
|
|
7
|
+
export function createWardenCommands(host) {
|
|
8
|
+
return [
|
|
9
|
+
{
|
|
10
|
+
name: "warden",
|
|
11
|
+
description: "Skill cards: list, show, rebuild; hold log: list",
|
|
12
|
+
args: "cards | card <skill> [--rebuild] | holds [n]",
|
|
13
|
+
run: async (ctx) => ({
|
|
14
|
+
notice: await runWardenCommands(host, ctx.conversationId, ctx.args),
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
async function runWardenCommands(host, conversationId, args) {
|
|
20
|
+
const words = args.trim().split(/\s+/).filter((word) => word.length > 0);
|
|
21
|
+
const [sub, ...rest] = words;
|
|
22
|
+
if (sub === "cards")
|
|
23
|
+
return cardsList(host);
|
|
24
|
+
if (sub === "card") {
|
|
25
|
+
const rebuild = rest.includes("--rebuild");
|
|
26
|
+
const skill = rest.find((word) => word !== "--rebuild");
|
|
27
|
+
if (skill === undefined)
|
|
28
|
+
return "usage: /warden card <skill> [--rebuild]";
|
|
29
|
+
if (rebuild)
|
|
30
|
+
return cardRebuild(host, conversationId, skill);
|
|
31
|
+
return cardShow(host, skill);
|
|
32
|
+
}
|
|
33
|
+
if (sub === "holds")
|
|
34
|
+
return holdsList(host, rest[0]);
|
|
35
|
+
return "usage: /warden cards | /warden card <skill> [--rebuild] | /warden holds [n]";
|
|
36
|
+
}
|
|
37
|
+
const HOLDS_DEFAULT = 20;
|
|
38
|
+
/**
|
|
39
|
+
* `/warden holds [n]` (warden spec §3.6): the newest `n` hold-log
|
|
40
|
+
* records, NEWEST FIRST (default 20; n <= 0 → an empty listing). The
|
|
41
|
+
* log's `call` field is stored redacted (route.ts appends through
|
|
42
|
+
* pi-warden's `redact`), so the listing renders it verbatim — never
|
|
43
|
+
* re-derive from anything unmasked.
|
|
44
|
+
*/
|
|
45
|
+
async function holdsList(host, countArg) {
|
|
46
|
+
let n = HOLDS_DEFAULT;
|
|
47
|
+
if (countArg !== undefined) {
|
|
48
|
+
const parsed = Number.parseInt(countArg, 10);
|
|
49
|
+
if (!Number.isFinite(parsed))
|
|
50
|
+
return "usage: /warden holds [n]";
|
|
51
|
+
n = parsed;
|
|
52
|
+
}
|
|
53
|
+
if (n <= 0)
|
|
54
|
+
return "";
|
|
55
|
+
const log = new HoldLog(`${host.stores.paths.state}/holds.jsonl`, () => new Date());
|
|
56
|
+
const records = await log.recent(n);
|
|
57
|
+
if (records.length === 0)
|
|
58
|
+
return "no holds recorded";
|
|
59
|
+
// `recent` is oldest-first within the window; the listing is newest first.
|
|
60
|
+
return records.reverse().map(holdLine).join("\n");
|
|
61
|
+
}
|
|
62
|
+
function holdLine(record) {
|
|
63
|
+
const parts = [
|
|
64
|
+
record.at,
|
|
65
|
+
`conv ${String(record.conversationId)}`,
|
|
66
|
+
record.tool,
|
|
67
|
+
record.action,
|
|
68
|
+
];
|
|
69
|
+
if (record.outcome !== undefined)
|
|
70
|
+
parts.push(record.outcome);
|
|
71
|
+
return parts.join(" · ");
|
|
72
|
+
}
|
|
73
|
+
async function cardsList(host) {
|
|
74
|
+
const store = new CardStore(host.stores.paths.data);
|
|
75
|
+
const cards = await store.list();
|
|
76
|
+
if (cards.length === 0)
|
|
77
|
+
return "no cards";
|
|
78
|
+
return cards.map((card) => cardLine(card)).join("\n");
|
|
79
|
+
}
|
|
80
|
+
function cardLine(card) {
|
|
81
|
+
return `${card.skill} · ${card.hash.slice(0, 8)} · ${card.builtAt} · ${card.model}`;
|
|
82
|
+
}
|
|
83
|
+
async function cardShow(host, skill) {
|
|
84
|
+
const store = new CardStore(host.stores.paths.data);
|
|
85
|
+
const cards = (await store.list()).filter((card) => card.skill === skill);
|
|
86
|
+
if (cards.length === 0)
|
|
87
|
+
return `no card for ${skill}`;
|
|
88
|
+
return cards[0].text;
|
|
89
|
+
}
|
|
90
|
+
async function cardRebuild(host, conversationId, skill) {
|
|
91
|
+
// Newest page first: a skill loaded late in a long run must be found.
|
|
92
|
+
const page = await host.conversations.entries(conversationId, { order: "descending" }, RUN_PAGE, undefined);
|
|
93
|
+
const seen = skillsInRun(page.items);
|
|
94
|
+
const copy = seen.find((entry) => entry.name === skill);
|
|
95
|
+
if (copy === undefined) {
|
|
96
|
+
return `no copy of ${skill} in this conversation; load it first`;
|
|
97
|
+
}
|
|
98
|
+
const raw = await host.stores.settings.get();
|
|
99
|
+
const settings = parseSettings(raw, (message) => console.warn(message));
|
|
100
|
+
const card = await buildCard(host, copy, settings);
|
|
101
|
+
if (card === undefined)
|
|
102
|
+
return `card build failed for ${skill}`;
|
|
103
|
+
return card.text;
|
|
104
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the tool call is happening (warden spec §3.2 presence classes):
|
|
3
|
+
* an internal curator run, a scheduled job run, or a live conversation.
|
|
4
|
+
*/
|
|
5
|
+
import type { SigrunHost } from "@sigrun/sigrun/extension";
|
|
6
|
+
import type { ConversationId, EntryId } from "@earendil-works/pi-durable";
|
|
7
|
+
import type { EntryRecord } from "@earendil-works/pi-durable";
|
|
8
|
+
export type Presence = {
|
|
9
|
+
kind: "live";
|
|
10
|
+
owner?: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "job";
|
|
13
|
+
jobId: string;
|
|
14
|
+
owner?: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "internal";
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Classify the conversation the hook is running in. `meta.internal` wins
|
|
20
|
+
* (a curator run never escalates); otherwise the job whose fire is
|
|
21
|
+
* currently running in this conversation decides job-vs-live.
|
|
22
|
+
*/
|
|
23
|
+
export declare function presenceOf(host: SigrunHost, conversationId: ConversationId): Promise<Presence>;
|
|
24
|
+
/** The current run's context, from the newest user input entry. */
|
|
25
|
+
export interface RunInfo {
|
|
26
|
+
/** The newest user-input entry (this run's start), absent when none. */
|
|
27
|
+
runStart: EntryId | undefined;
|
|
28
|
+
/** The newest user input's text ("" when unreachable). */
|
|
29
|
+
request: string;
|
|
30
|
+
/** The tail of `request` (last 3000 chars) for prompt-style use. */
|
|
31
|
+
asked: string;
|
|
32
|
+
/** This run's entries (from `runStart` on), oldest first. */
|
|
33
|
+
entries: readonly EntryRecord[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read the run a held call belongs to. Tolerant by contract: it feeds
|
|
37
|
+
* the judge's clear-ask input (Task 12), so an unreadable history yields
|
|
38
|
+
* an empty run rather than throwing (beforeTool never fails through it —
|
|
39
|
+
* the hook still wraps everything, but this keeps the failure mode a
|
|
40
|
+
* simple degraded context).
|
|
41
|
+
*/
|
|
42
|
+
export declare function runInfo(host: SigrunHost, conversationId: ConversationId): Promise<RunInfo>;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify the conversation the hook is running in. `meta.internal` wins
|
|
3
|
+
* (a curator run never escalates); otherwise the job whose fire is
|
|
4
|
+
* currently running in this conversation decides job-vs-live.
|
|
5
|
+
*/
|
|
6
|
+
export async function presenceOf(host, conversationId) {
|
|
7
|
+
const meta = await host.conversations.meta(conversationId);
|
|
8
|
+
if (meta.internal === true)
|
|
9
|
+
return { kind: "internal" };
|
|
10
|
+
const run = await host.jobs.activeRun(conversationId);
|
|
11
|
+
if (run !== undefined) {
|
|
12
|
+
return {
|
|
13
|
+
kind: "job",
|
|
14
|
+
jobId: run.jobId,
|
|
15
|
+
...(run.owner === undefined ? {} : { owner: run.owner }),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
kind: "live",
|
|
20
|
+
...(meta.owner === undefined ? {} : { owner: meta.owner }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const ASKED_MAX = 3000;
|
|
24
|
+
/** Extract the visible text of one entry's first user-model message. */
|
|
25
|
+
function userText(entry) {
|
|
26
|
+
const messages = entry.model;
|
|
27
|
+
if (messages === undefined || messages.length === 0)
|
|
28
|
+
return "";
|
|
29
|
+
const parts = [];
|
|
30
|
+
for (const message of messages) {
|
|
31
|
+
if (message.role !== "user")
|
|
32
|
+
continue;
|
|
33
|
+
const content = message.content;
|
|
34
|
+
if (typeof content === "string") {
|
|
35
|
+
parts.push(content);
|
|
36
|
+
}
|
|
37
|
+
else if (Array.isArray(content)) {
|
|
38
|
+
for (const block of content) {
|
|
39
|
+
if (typeof block === "object" &&
|
|
40
|
+
block !== null &&
|
|
41
|
+
block.type === "text" &&
|
|
42
|
+
typeof block.text === "string") {
|
|
43
|
+
parts.push(block.text);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts.join("\n");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Read the run a held call belongs to. Tolerant by contract: it feeds
|
|
52
|
+
* the judge's clear-ask input (Task 12), so an unreadable history yields
|
|
53
|
+
* an empty run rather than throwing (beforeTool never fails through it —
|
|
54
|
+
* the hook still wraps everything, but this keeps the failure mode a
|
|
55
|
+
* simple degraded context).
|
|
56
|
+
*/
|
|
57
|
+
export async function runInfo(host, conversationId) {
|
|
58
|
+
let records;
|
|
59
|
+
try {
|
|
60
|
+
// Newest-first probe: find the newest "pi.user" entry id. Anchoring
|
|
61
|
+
// the window at it keeps the run current even when the transcript
|
|
62
|
+
// outgrows a single page — a plain ascending page would read the
|
|
63
|
+
// OLDEST entries and freeze runStart near the conversation start.
|
|
64
|
+
const probe = await host.conversations.entries(conversationId, { order: "descending" }, 200, undefined);
|
|
65
|
+
const newestUser = probe.items.find((entry) => entry.kind === "pi.user");
|
|
66
|
+
if (newestUser === undefined) {
|
|
67
|
+
// No user input in the newest page: fall back to the plain
|
|
68
|
+
// ascending read (whole page / empty run).
|
|
69
|
+
const page = await host.conversations.entries(conversationId, { order: "ascending" }, 200, undefined);
|
|
70
|
+
records = [...page.items];
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// The run window: from the newest user input onward, oldest
|
|
74
|
+
// first — what runStart/request/asked/entries derive from.
|
|
75
|
+
const page = await host.conversations.entries(conversationId, { order: "ascending", minEntryId: newestUser.id }, 200, undefined);
|
|
76
|
+
records = [...page.items];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { runStart: undefined, request: "", asked: "", entries: [] };
|
|
81
|
+
}
|
|
82
|
+
// The window starts at the newest user input, so the first "pi.user"
|
|
83
|
+
// entry walking forward is the run start (kept as a scan so the
|
|
84
|
+
// fallback page — which may predate it — still anchors correctly).
|
|
85
|
+
let startIndex = -1;
|
|
86
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
87
|
+
if (records[i].kind === "pi.user") {
|
|
88
|
+
startIndex = i;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (startIndex < 0) {
|
|
93
|
+
return { runStart: undefined, request: "", asked: "", entries: records };
|
|
94
|
+
}
|
|
95
|
+
const runEntries = records.slice(startIndex);
|
|
96
|
+
const request = userText(runEntries[0]);
|
|
97
|
+
return {
|
|
98
|
+
runStart: runEntries[0].id,
|
|
99
|
+
request,
|
|
100
|
+
asked: request.slice(-ASKED_MAX),
|
|
101
|
+
entries: runEntries,
|
|
102
|
+
};
|
|
103
|
+
}
|