@sigrun/warden 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Warden settings: parsed from the extension's settings document
3
+ * (the extension's own scoped settings document, `settings/sigrun-warden.json`).
4
+ * Every invalid value falls back
5
+ * to its default with ONE `console.warn` naming the key, at most once
6
+ * per key per process — `parseSettings` runs per factory, so the dedup
7
+ * set is module-scoped; tests that need a fresh warn pass new keys or
8
+ * call `resetWarnedKeys()` (test hook).
9
+ */
10
+ export function defaultSettings() {
11
+ return {
12
+ judge: { enabled: true },
13
+ thresholds: { clearAsk: 0.7, card: 0.7, irreversible: 0.9 },
14
+ clearances: [],
15
+ rules: [],
16
+ steerBudget: 3,
17
+ retentionDays: { calls: 90, holds: 365 },
18
+ };
19
+ }
20
+ /** Warned-key dedup, module-scoped ("once per key per process"). */
21
+ const warnedKeys = new Set();
22
+ /** Test hook: clear the once-per-process warn dedup. */
23
+ export function resetWarnedKeys() {
24
+ warnedKeys.clear();
25
+ }
26
+ function warnOnce(warn, key) {
27
+ if (warnedKeys.has(key))
28
+ return;
29
+ warnedKeys.add(key);
30
+ warn(`warden settings: invalid "${key}", using default`);
31
+ }
32
+ function isPlainObject(value) {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+ function isNumber(value) {
36
+ return typeof value === "number" && Number.isFinite(value);
37
+ }
38
+ function booleanAt(warn, key, value, fallback) {
39
+ if (value === undefined)
40
+ return fallback;
41
+ if (typeof value === "boolean")
42
+ return value;
43
+ warnOnce(warn, key);
44
+ return fallback;
45
+ }
46
+ function numberAt(warn, key, value, fallback) {
47
+ if (value === undefined)
48
+ return fallback;
49
+ if (isNumber(value) && value >= 0)
50
+ return value;
51
+ warnOnce(warn, key);
52
+ return fallback;
53
+ }
54
+ function thresholdAt(warn, key, value, fallback) {
55
+ if (value === undefined)
56
+ return fallback;
57
+ if (isNumber(value) && value >= 0 && value <= 1)
58
+ return value;
59
+ warnOnce(warn, key);
60
+ return fallback;
61
+ }
62
+ function stringAt(warn, key, value) {
63
+ if (value === undefined)
64
+ return undefined;
65
+ if (typeof value === "string" && value.length > 0)
66
+ return value;
67
+ warnOnce(warn, key);
68
+ return undefined;
69
+ }
70
+ const RULE_MATCHES = ["command", "path", "tool"];
71
+ const RULE_LEVELS = ["warn", "hold", "deny"];
72
+ /**
73
+ * Parse the raw settings document. `raw` is the settings-store value; an
74
+ * absent or non-object raw value yields all defaults. Each invalid key
75
+ * warns once per process and falls back independently.
76
+ */
77
+ export function parseSettings(raw, warn) {
78
+ const defaults = defaultSettings();
79
+ if (!isPlainObject(raw))
80
+ return defaults;
81
+ const rawJudge = raw.judge;
82
+ const judgeEnabled = rawJudge === undefined
83
+ ? defaults.judge.enabled
84
+ : !isPlainObject(rawJudge)
85
+ ? (warnOnce(warn, "judge"), defaults.judge.enabled)
86
+ : booleanAt(warn, "judge.enabled", rawJudge.enabled, defaults.judge.enabled);
87
+ const rawThresholds = raw.thresholds;
88
+ const thresholds = { ...defaults.thresholds };
89
+ if (raw.thresholds !== undefined && !isPlainObject(rawThresholds)) {
90
+ warnOnce(warn, "thresholds");
91
+ }
92
+ else if (isPlainObject(rawThresholds)) {
93
+ thresholds.clearAsk = thresholdAt(warn, "thresholds.clearAsk", rawThresholds.clearAsk, defaults.thresholds.clearAsk);
94
+ thresholds.card = thresholdAt(warn, "thresholds.card", rawThresholds.card, defaults.thresholds.card);
95
+ thresholds.irreversible = thresholdAt(warn, "thresholds.irreversible", rawThresholds.irreversible, defaults.thresholds.irreversible);
96
+ }
97
+ let clearances = defaults.clearances;
98
+ if (raw.clearances !== undefined) {
99
+ if (Array.isArray(raw.clearances)) {
100
+ const parsed = [];
101
+ for (const entry of raw.clearances) {
102
+ if (isPlainObject(entry) &&
103
+ typeof entry.job === "string" &&
104
+ Array.isArray(entry.tools) &&
105
+ entry.tools.every((tool) => typeof tool === "string")) {
106
+ parsed.push({ job: entry.job, tools: entry.tools });
107
+ }
108
+ else {
109
+ warnOnce(warn, "clearances");
110
+ }
111
+ }
112
+ clearances = parsed;
113
+ }
114
+ else {
115
+ warnOnce(warn, "clearances");
116
+ }
117
+ }
118
+ let rules = defaults.rules;
119
+ if (raw.rules !== undefined) {
120
+ if (Array.isArray(raw.rules)) {
121
+ const parsed = [];
122
+ for (const entry of raw.rules) {
123
+ if (isPlainObject(entry) &&
124
+ RULE_MATCHES.includes(entry.match) &&
125
+ typeof entry.pattern === "string" &&
126
+ RULE_LEVELS.includes(entry.level)) {
127
+ parsed.push({
128
+ match: entry.match,
129
+ pattern: entry.pattern,
130
+ level: entry.level,
131
+ });
132
+ }
133
+ else {
134
+ warnOnce(warn, "rules");
135
+ }
136
+ }
137
+ rules = parsed;
138
+ }
139
+ else {
140
+ warnOnce(warn, "rules");
141
+ }
142
+ }
143
+ const rawRetention = raw.retentionDays;
144
+ const retentionDays = { ...defaults.retentionDays };
145
+ if (rawRetention !== undefined && !isPlainObject(rawRetention)) {
146
+ warnOnce(warn, "retentionDays");
147
+ }
148
+ else if (isPlainObject(rawRetention)) {
149
+ retentionDays.calls = numberAt(warn, "retentionDays.calls", rawRetention.calls, defaults.retentionDays.calls);
150
+ retentionDays.holds = numberAt(warn, "retentionDays.holds", rawRetention.holds, defaults.retentionDays.holds);
151
+ }
152
+ const settings = {
153
+ judge: { enabled: judgeEnabled },
154
+ thresholds,
155
+ clearances,
156
+ rules,
157
+ steerBudget: numberAt(warn, "steerBudget", raw.steerBudget, defaults.steerBudget),
158
+ retentionDays,
159
+ };
160
+ const decisionModel = stringAt(warn, "decisionModel", raw.decisionModel);
161
+ if (decisionModel !== undefined)
162
+ settings.decisionModel = decisionModel;
163
+ const cardModel = stringAt(warn, "cardModel", raw.cardModel);
164
+ if (cardModel !== undefined)
165
+ settings.cardModel = cardModel;
166
+ return settings;
167
+ }
@@ -0,0 +1,33 @@
1
+ import type { EntryRecord } from "@earendil-works/pi-durable";
2
+ export interface SkillSeen {
3
+ name: string;
4
+ location?: string;
5
+ body: string;
6
+ /** sha256 hex of the body. */
7
+ hash: string;
8
+ }
9
+ export interface SkillTag {
10
+ name: string;
11
+ location?: string;
12
+ }
13
+ /** Extract `<skill name="…" location="…">` tags from expanded text. */
14
+ export declare function skillTagsInText(text: string): SkillTag[];
15
+ /**
16
+ * Collect the skills the run saw, newest first, deduped by body hash.
17
+ * Input `entries` may be in either order; the newest copy of a body
18
+ * wins and the result sorts by first-seen recency (the entry id of
19
+ * each body's newest sighting, descending).
20
+ */
21
+ export declare function skillsInRun(entries: readonly EntryRecord[]): SkillSeen[];
22
+ export interface SkillBlock {
23
+ name: string;
24
+ location?: string;
25
+ body: string;
26
+ }
27
+ /**
28
+ * Every `<skill name="…">…</skill>` block in the text, in order. The
29
+ * body is trimmed of one surrounding newline pair and of core's
30
+ * `References are relative to …` preamble, so it equals the SKILL.md
31
+ * text the other channels capture.
32
+ */
33
+ export declare function skillBlocksInText(text: string): SkillBlock[];
package/dist/skills.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Skills in the run (warden spec §3.4 card sources): a skill reaches a
3
+ * run three ways — a `<skill name="…" location="…">` block expanded
4
+ * into a user/job prompt, a `load_skill` result (its call's args name
5
+ * the skill), or a `read` of a path ending SKILL.md. `skillsInRun`
6
+ * collects them from the run's entries, deduped by the sha256 of the
7
+ * body, newest first (the most recent copy governs).
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ const SKILL_TAG_RE = /<skill\s+name="([^"]+)"(?:\s+location="([^"]*)")?\s*>/g;
11
+ /** Extract `<skill name="…" location="…">` tags from expanded text. */
12
+ export function skillTagsInText(text) {
13
+ const tags = [];
14
+ SKILL_TAG_RE.lastIndex = 0;
15
+ for (;;) {
16
+ const match = SKILL_TAG_RE.exec(text);
17
+ if (match === null)
18
+ break;
19
+ const name = match[1];
20
+ const location = match[2];
21
+ tags.push(location === undefined ? { name } : { name, location });
22
+ }
23
+ return tags;
24
+ }
25
+ /** The text blocks of one entry's model messages, joined. */
26
+ function entryText(entry) {
27
+ const messages = entry.model;
28
+ if (messages === undefined || messages.length === 0)
29
+ return "";
30
+ const parts = [];
31
+ for (const message of messages) {
32
+ const content = message.content;
33
+ if (typeof content === "string") {
34
+ parts.push(content);
35
+ }
36
+ else if (Array.isArray(content)) {
37
+ for (const block of content) {
38
+ if (typeof block === "object" &&
39
+ block !== null &&
40
+ block.type === "text" &&
41
+ typeof block.text === "string") {
42
+ parts.push(block.text);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ return parts.join("\n");
48
+ }
49
+ /** Tool calls one entry carries (assistant messages' toolCalls). */
50
+ function entryToolCalls(entry) {
51
+ const calls = [];
52
+ for (const message of entry.model ?? []) {
53
+ const list = message.toolCalls;
54
+ if (!Array.isArray(list))
55
+ continue;
56
+ for (const raw of list) {
57
+ if (typeof raw !== "object" || raw === null)
58
+ continue;
59
+ const call = raw;
60
+ if (typeof call.id !== "string" || typeof call.name !== "string")
61
+ continue;
62
+ calls.push({
63
+ id: call.id,
64
+ name: call.name,
65
+ args: typeof call.arguments === "object" && call.arguments !== null
66
+ ? call.arguments
67
+ : {},
68
+ });
69
+ }
70
+ }
71
+ return calls;
72
+ }
73
+ /** Tool results one entry carries (toolResult messages). */
74
+ function entryToolResults(entry) {
75
+ const results = [];
76
+ for (const message of entry.model ?? []) {
77
+ if (message.role !== "toolResult")
78
+ continue;
79
+ const m = message;
80
+ if (typeof m.toolCallId !== "string" || typeof m.toolName !== "string")
81
+ continue;
82
+ const parts = [];
83
+ if (typeof m.content === "string") {
84
+ parts.push(m.content);
85
+ }
86
+ else if (Array.isArray(m.content)) {
87
+ for (const block of m.content) {
88
+ if (typeof block === "object" &&
89
+ block !== null &&
90
+ block.type === "text" &&
91
+ typeof block.text === "string") {
92
+ parts.push(block.text);
93
+ }
94
+ }
95
+ }
96
+ results.push({
97
+ callId: m.toolCallId,
98
+ toolName: m.toolName,
99
+ text: parts.join("\n"),
100
+ });
101
+ }
102
+ return results;
103
+ }
104
+ function bodyHash(body) {
105
+ return createHash("sha256").update(body, "utf8").digest("hex");
106
+ }
107
+ /**
108
+ * Collect the skills the run saw, newest first, deduped by body hash.
109
+ * Input `entries` may be in either order; the newest copy of a body
110
+ * wins and the result sorts by first-seen recency (the entry id of
111
+ * each body's newest sighting, descending).
112
+ */
113
+ export function skillsInRun(entries) {
114
+ // Pair tool calls with results by callId (results reference calls).
115
+ const calls = new Map();
116
+ for (const entry of entries) {
117
+ for (const call of entryToolCalls(entry)) {
118
+ calls.set(call.id, call);
119
+ }
120
+ }
121
+ // body hash → { skill, newest entry id } — dedup keeps the newest.
122
+ const byHash = new Map();
123
+ const collect = (seen, at) => {
124
+ const existing = byHash.get(seen.hash);
125
+ if (existing === undefined || at > existing.seenAt) {
126
+ byHash.set(seen.hash, { skill: seen, seenAt: at });
127
+ }
128
+ };
129
+ for (const entry of entries) {
130
+ const id = BigInt(entry.id);
131
+ // Channel 1: <skill> blocks in expanded user/job prompts. One
132
+ // scan consumes every block, so two same-named blocks each hash
133
+ // their own body.
134
+ if (entry.kind === "pi.user" || entry.kind === "sigrun.user.expanded") {
135
+ const text = entryText(entry);
136
+ for (const block of skillBlocksInText(text)) {
137
+ collect({
138
+ name: block.name,
139
+ ...(block.location === undefined ? {} : { location: block.location }),
140
+ body: block.body,
141
+ hash: bodyHash(block.body),
142
+ }, id);
143
+ }
144
+ }
145
+ // Channels 2/3: tool results paired with their calls.
146
+ for (const result of entryToolResults(entry)) {
147
+ const call = calls.get(result.callId);
148
+ if (result.toolName === "load_skill") {
149
+ const name = typeof call?.args.skill === "string"
150
+ ? call.args.skill
151
+ : typeof call?.args.name === "string"
152
+ ? call.args.name
153
+ : undefined;
154
+ if (name === undefined || result.text.length === 0)
155
+ continue;
156
+ collect({ name, body: result.text, hash: bodyHash(result.text) }, id);
157
+ }
158
+ else if (result.toolName === "read") {
159
+ const path = typeof call?.args.path === "string" ? call.args.path : undefined;
160
+ if (path === undefined || !path.endsWith("SKILL.md"))
161
+ continue;
162
+ if (result.text.length === 0)
163
+ continue;
164
+ // The skill name is the parent directory's leaf.
165
+ const segments = path.split("/").filter((part) => part.length > 0);
166
+ const name = segments.length >= 2 ? segments[segments.length - 2] : segments[0];
167
+ collect({ name, location: path, body: result.text, hash: bodyHash(result.text) }, id);
168
+ }
169
+ }
170
+ }
171
+ return [...byHash.values()]
172
+ .sort((a, b) => (a.seenAt > b.seenAt ? -1 : a.seenAt < b.seenAt ? 1 : 0))
173
+ .map((entry) => entry.skill);
174
+ }
175
+ const SKILL_BLOCK_RE = /<skill\s+name="([^"]+)"(?:\s+location="([^"]*)")?\s*>([\s\S]*?)<\/skill>/g;
176
+ // Core's expansion (dist/commands/expand.js) prefixes the body with a
177
+ // `References are relative to <dir>.` preamble line and a blank line —
178
+ // neither is part of the skill body (and the absolute dir must never
179
+ // reach a card prompt).
180
+ const CORE_PREAMBLE_RE = /^References are relative to \S+\.\n\n/;
181
+ /**
182
+ * Every `<skill name="…">…</skill>` block in the text, in order. The
183
+ * body is trimmed of one surrounding newline pair and of core's
184
+ * `References are relative to …` preamble, so it equals the SKILL.md
185
+ * text the other channels capture.
186
+ */
187
+ export function skillBlocksInText(text) {
188
+ const blocks = [];
189
+ SKILL_BLOCK_RE.lastIndex = 0;
190
+ for (;;) {
191
+ const match = SKILL_BLOCK_RE.exec(text);
192
+ if (match === null)
193
+ break;
194
+ const body = match[3]
195
+ .replace(/^\n/, "")
196
+ .replace(/\n$/, "")
197
+ .replace(CORE_PREAMBLE_RE, "");
198
+ const name = match[1];
199
+ const location = match[2];
200
+ blocks.push(location === undefined ? { name, body } : { name, location, body });
201
+ }
202
+ return blocks;
203
+ }
@@ -0,0 +1,59 @@
1
+ import type { EntryId } from "@earendil-works/pi-durable";
2
+ /**
3
+ * The escalation key / call fingerprint: `warden:` + the first 16 hex of
4
+ * sha256(tool + "\n" + canonical JSON of the arguments, keys sorted).
5
+ * Canonicalization is recursive so argument order never splits a grant.
6
+ */
7
+ export declare function grantKey(tool: string, args: Record<string, unknown>): string;
8
+ export interface ConversationState {
9
+ /** The newest user-input entry of the current run (run boundary). Serialized as a number. */
10
+ runStart?: EntryId;
11
+ /** Set when a deny/destructive call was blocked this run (Task 14/15). */
12
+ tainted?: boolean;
13
+ steers: number;
14
+ /**
15
+ * Held calls, kept across runs for 24 h, consumed on release.
16
+ * `afterEntry` carries the hold NONCE (the interaction callId suffix:
17
+ * `warden-<fingerprint>-<nonce>`); `status` tracks the hold lifecycle:
18
+ * pending → answered (consumed) → released (one call released).
19
+ */
20
+ blocked: {
21
+ fingerprint: string;
22
+ at: string;
23
+ /** The hold NONCE (the interaction callId suffix) for the Task 11 hold path. */
24
+ afterEntry?: string;
25
+ /** The newest entry id the block sits after (Task 12: the reply anchor). */
26
+ atEntry?: string;
27
+ status?: "pending" | "answered" | "released";
28
+ }[];
29
+ /** Answered allows consumed once; a reply releases one call. */
30
+ released: {
31
+ fingerprint: string;
32
+ replyEntry: string;
33
+ }[];
34
+ /** One in-flight execution per grant (job presence). */
35
+ reservations: {
36
+ grantKey: string;
37
+ callId: string;
38
+ escalationId: string;
39
+ }[];
40
+ }
41
+ /** The fingerprint of one call: the grant key WITHOUT the `warden:` prefix. */
42
+ export declare function callFingerprint(call: {
43
+ name: string;
44
+ arguments: Record<string, unknown>;
45
+ }): string;
46
+ export declare function emptyState(): ConversationState;
47
+ /**
48
+ * The on-disk store. `update` runs `fn` under a per-conversation
49
+ * in-process lock and persists the result atomically. A change of
50
+ * `runStart` (a new run) resets the run-scoped fields — `steers` and
51
+ * `taint` — while 24 h `blocked` records and `reservations` persist;
52
+ * `released` entries prune with their blocked record.
53
+ */
54
+ export declare class StateStore {
55
+ #private;
56
+ constructor(stateDir: string, now?: () => Date);
57
+ load(id: number): Promise<ConversationState>;
58
+ update<T>(id: number, fn: (s: ConversationState) => T | Promise<T>): Promise<T>;
59
+ }
package/dist/state.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Per-conversation warden state (`paths.state/conv/<id>.json`): blocked
3
+ * records (24 h), live-question releases, and job grant reservations.
4
+ * Writes are serialized in-process then atomic (temp + rename).
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ /**
10
+ * The escalation key / call fingerprint: `warden:` + the first 16 hex of
11
+ * sha256(tool + "\n" + canonical JSON of the arguments, keys sorted).
12
+ * Canonicalization is recursive so argument order never splits a grant.
13
+ */
14
+ export function grantKey(tool, args) {
15
+ const canonical = canonicalize(args);
16
+ const digest = createHash("sha256")
17
+ .update(tool)
18
+ .update("\n")
19
+ .update(canonical)
20
+ .digest("hex");
21
+ return `warden:${digest.slice(0, 16)}`;
22
+ }
23
+ function canonicalize(value) {
24
+ if (value === null || typeof value !== "object")
25
+ return JSON.stringify(value) ?? "undefined";
26
+ if (Array.isArray(value))
27
+ return `[${value.map(canonicalize).join(",")}]`;
28
+ const record = value;
29
+ const keys = Object.keys(record).sort();
30
+ const body = keys
31
+ .map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
32
+ .join(",");
33
+ return `{${body}}`;
34
+ }
35
+ /** The fingerprint of one call: the grant key WITHOUT the `warden:` prefix. */
36
+ export function callFingerprint(call) {
37
+ return grantKey(call.name, call.arguments).slice("warden:".length);
38
+ }
39
+ export function emptyState() {
40
+ return { steers: 0, blocked: [], released: [], reservations: [] };
41
+ }
42
+ const BLOCKED_TTL_MS = 24 * 60 * 60 * 1000;
43
+ /**
44
+ * The on-disk store. `update` runs `fn` under a per-conversation
45
+ * in-process lock and persists the result atomically. A change of
46
+ * `runStart` (a new run) resets the run-scoped fields — `steers` and
47
+ * `taint` — while 24 h `blocked` records and `reservations` persist;
48
+ * `released` entries prune with their blocked record.
49
+ */
50
+ export class StateStore {
51
+ #dir;
52
+ #now;
53
+ #locks = new Map();
54
+ constructor(stateDir, now = () => new Date()) {
55
+ this.#dir = join(stateDir, "conv");
56
+ this.#now = now;
57
+ }
58
+ async load(id) {
59
+ let text;
60
+ try {
61
+ text = await readFile(this.#path(id), "utf8");
62
+ }
63
+ catch (error) {
64
+ if (error.code === "ENOENT")
65
+ return emptyState();
66
+ throw error;
67
+ }
68
+ try {
69
+ return { ...emptyState(), ...JSON.parse(text) };
70
+ }
71
+ catch {
72
+ // A torn write must never brick the warden (fail closed on state).
73
+ return emptyState();
74
+ }
75
+ }
76
+ async update(id, fn) {
77
+ const prior = this.#locks.get(id) ?? Promise.resolve();
78
+ const run = prior.then(() => this.#updateInner(id, fn));
79
+ this.#locks.set(id, run.catch(() => {
80
+ // A failed update must not poison the chain for the next caller.
81
+ }));
82
+ return run;
83
+ }
84
+ async #updateInner(id, fn) {
85
+ const state = await this.load(id);
86
+ this.#prune(state);
87
+ const result = await fn(state);
88
+ await mkdir(this.#dir, { recursive: true });
89
+ const tmp = `${this.#path(id)}.tmp`;
90
+ await writeFile(tmp, `${JSON.stringify(state)}\n`, "utf8");
91
+ await rename(tmp, this.#path(id));
92
+ return result;
93
+ }
94
+ /** Drop expired 24 h blocked records (and their releases). */
95
+ #prune(state) {
96
+ const cutoff = this.#now().getTime() - BLOCKED_TTL_MS;
97
+ const kept = state.blocked.filter((record) => {
98
+ const at = Date.parse(record.at);
99
+ return !Number.isFinite(at) || at >= cutoff;
100
+ });
101
+ if (kept.length === state.blocked.length)
102
+ return;
103
+ const keptFingerprints = new Set(kept.map((record) => record.fingerprint));
104
+ state.blocked = kept;
105
+ state.released = state.released.filter((release) => keptFingerprints.has(release.fingerprint));
106
+ }
107
+ #path(id) {
108
+ return join(this.#dir, `${id}.json`);
109
+ }
110
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The card-check targets of one call (warden spec §3.2): the elements
3
+ * of the FIRST array-of-scalars argument, as strings — the ids the call
4
+ * acts on. With no array-of-scalars argument, the whole call is ONE
5
+ * target: the canonical JSON of its arguments.
6
+ */
7
+ /** One call's card-check targets. */
8
+ export declare function targetsOf(args: Record<string, unknown>): readonly string[];
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The card-check targets of one call (warden spec §3.2): the elements
3
+ * of the FIRST array-of-scalars argument, as strings — the ids the call
4
+ * acts on. With no array-of-scalars argument, the whole call is ONE
5
+ * target: the canonical JSON of its arguments.
6
+ */
7
+ /** One call's card-check targets. */
8
+ export function targetsOf(args) {
9
+ for (const value of Object.values(args)) {
10
+ if (Array.isArray(value) &&
11
+ value.every((item) => typeof item === "string" ||
12
+ typeof item === "number" ||
13
+ typeof item === "boolean")) {
14
+ return value.map(String);
15
+ }
16
+ }
17
+ // state.ts's canonicalize is private; grantKey's digest is not the
18
+ // target TEXT, so re-derive the canonical JSON here (keys sorted,
19
+ // matching state.ts's shape).
20
+ return [canonicalize(args)];
21
+ }
22
+ function canonicalize(value) {
23
+ if (value === null || typeof value !== "object")
24
+ return JSON.stringify(value) ?? "undefined";
25
+ if (Array.isArray(value))
26
+ return `[${value.map(canonicalize).join(",")}]`;
27
+ const record = value;
28
+ const keys = Object.keys(record).sort();
29
+ return `{${keys
30
+ .map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
31
+ .join(",")}}`;
32
+ }
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
- "name": "@sigrun/warden",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@sigrun/warden",
3
+ "version": "0.1.0",
4
+ "description": "Guardrail extension for sigrun: action classification, holds, and judge-backed confirmations (pi-warden port)",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "test": "node scripts/check-manifest.mjs --expect-name @sigrun/warden && vitest run",
23
+ "typecheck": "tsc --noEmit",
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "prepare": "npm run build"
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/chord": "1.4.0",
29
+ "@earendil-works/pi-ai": "1.4.0",
30
+ "@earendil-works/pi-durable": "1.4.0",
31
+ "@sigrun/shared": "0.8.0 || 0.9.0"
32
+ },
33
+ "dependencies": {
34
+ "pi-warden": "^2.6.1",
35
+ "pi-typesafe": "^0.14.0"
36
+ },
37
+ "devDependencies": {
38
+ "@earendil-works/chord": "1.4.0",
39
+ "@earendil-works/pi-ai": "1.4.0",
40
+ "@earendil-works/pi-durable": "1.4.0",
41
+ "@sigrun/shared": "file:../sigrun/shared",
42
+ "@sigrun/sigrun": "file:../sigrun",
43
+ "@types/node": "^26.6.4",
44
+ "tsx": "^4.23.15",
45
+ "typescript": "^7.0.2",
46
+ "vitest": "^5.0.3"
47
+ },
48
+ "license": "MIT",
49
+ "repository": {
50
+ "type": "github",
51
+ "url": "git+https://github.com/sigrun-ai/sigrun-warden.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/sigrun-ai/sigrun-warden/issues"
55
+ },
56
+ "homepage": "https://github.com/sigrun-ai/sigrun-warden#readme",
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "provenance": true
60
+ }
61
+ }