@deepstrike/sdk 0.2.46 → 0.2.48

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,106 @@
1
+ import type { MemoryPolicy } from "../kernel.js";
2
+ import type { RuntimeOptions } from "../runtime/runner.js";
3
+ import type { NudgeRule } from "./nudge.js";
4
+ export interface InstructionProfile {
5
+ /** Start-up protocol (paper: build_bootstrap_instruction). */
6
+ bootstrap?: string;
7
+ /** Execution protocol. */
8
+ execution?: string;
9
+ /** Closing verification protocol. */
10
+ verification?: string;
11
+ /** Failure-recovery protocol. */
12
+ failureRecovery?: string;
13
+ }
14
+ /**
15
+ * Compose the four instruction slots onto `base` in the fixed order base → bootstrap → execution →
16
+ * verification → failureRecovery, joined with `"\n\n"`, skipping empty slots. All-empty ⇒ `base`
17
+ * unchanged (identity — the zero-instructions run is byte-for-byte the pre-feature run). The order is
18
+ * fixed and empty slots are dropped so the composed prefix is byte-stable (prefix-cache axiom).
19
+ */
20
+ export declare function composeSystemPrompt(base: string | undefined, instructions?: InstructionProfile): string | undefined;
21
+ /**
22
+ * The exact `RuntimeOptions` fields a manifest may drive. Derived via `Pick` so field names and types
23
+ * track `RuntimeOptions` verbatim; anything outside this set is rejected by `applyManifest`/`applyPatch`.
24
+ */
25
+ export type HarnessRuntimePatch = Pick<RuntimeOptions, "maxTurns" | "maxTotalTokens" | "criteriaGate" | "repeatFuse" | "entropyWatch" | "knowledgeBudgetRatio" | "skillLeaseTurns" | "allowedToolIds" | "stableCoreToolIds" | "enablePlanTool" | "skillFilter"> & Pick<MemoryPolicy, "retrievalTopK" | "promotionRecallThreshold">;
26
+ /**
27
+ * The promotion tier of an editable surface — the SECOND axis of the safety boundary (the whitelist is
28
+ * the first). Even a whitelisted surface may need a heavier gate than "typed validation passed".
29
+ */
30
+ export type SurfaceTier = "auto" | "screened" | "human";
31
+ /**
32
+ * Map an editable surface to its promotion tier. Maintained HERE beside the whitelist so a surface can
33
+ * never be whitelisted without also being assigned a tier (spec V2-S3 same-place maintenance):
34
+ * - "auto" (Tier A): every `runtime.*` whitelist surface. Typed validation + the capability
35
+ * ceiling invariant + the v1 acceptance rule already guard them, so promotion is fully
36
+ * automatic — there is no free text and no injection surface.
37
+ * - "screened" (Tier B): `instructions.*` and `nudges`. Free text can smuggle instructions
38
+ * (persistent prompt-injection laundered through the evidence loop), so a screen runs
39
+ * before promotion.
40
+ * - "human" (Tier C): reserved for capability-WIDENING surfaces. None exist in v2 — intersection
41
+ * semantics make widening structurally inexpressible — but the enum value exists so a v3
42
+ * surface cannot be added without consciously assigning it a tier (and building the
43
+ * human gate). `surfaceTier` therefore never returns "human" in v2.
44
+ * An unknown surface / slot / runtime key THROWS (same discipline as applySurfaceEdit): a surface with
45
+ * no tier must never fall through to auto-promotion.
46
+ */
47
+ export declare function surfaceTier(targetSurface: string): SurfaceTier;
48
+ export interface HarnessManifest {
49
+ manifestVersion: 1;
50
+ /** Parent manifest digest; `null` for a seed. */
51
+ parent: string | null;
52
+ /** Target-model identifier (per-model profile scenarios). */
53
+ modelProfile?: string;
54
+ /**
55
+ * Opaque isolation key — host decides its semantics (user / tenant / agent-group). Orthogonal to
56
+ * `modelProfile` (never concatenate the two — that reprises the identity-scoping bug class); absent
57
+ * ⇒ the host treats it as `"default"`. It rides canonical JSON, so digests domain-separate by scope,
58
+ * but an absent scope leaves a v1-shaped manifest's digest byte-identical (canonicalJson skips
59
+ * undefined). Becomes a lineage directory name downstream, hence the path-safe character bound.
60
+ */
61
+ scope?: string;
62
+ instructions?: InstructionProfile;
63
+ nudges?: NudgeRule[];
64
+ runtime?: HarnessRuntimePatch;
65
+ /** The proposer's edit whitelist — patches may only target a surface listed here. */
66
+ editableSurfaces: string[];
67
+ audit?: {
68
+ round: number;
69
+ createdBy: "seed" | "proposer";
70
+ targetCluster?: string;
71
+ rationale?: string;
72
+ deltaHeldIn?: number;
73
+ deltaHeldOut?: number;
74
+ /** Promotion tier of the driving edit (V2-S3). */
75
+ tier?: SurfaceTier;
76
+ /** Injection-screen verdict — present only for a screened (Tier B) promotion (V2-S3). */
77
+ screenVerdict?: "pass" | "screened_out";
78
+ };
79
+ }
80
+ export interface HarnessPatch {
81
+ /** Surface path — must be in the manifest's `editableSurfaces`. */
82
+ targetSurface: string;
83
+ /** `append` applies only to nudges; `remove` clears a slot or drops a nudge by id. */
84
+ op: "set" | "append" | "remove";
85
+ value?: unknown;
86
+ rationale: string;
87
+ /** Failure-cluster key this edit is bound to (paper: one edit per failure mechanism). */
88
+ targetCluster: string;
89
+ expectedEffect: string;
90
+ }
91
+ /** sha-256 hex over the manifest's canonical JSON — the manifest's stable identity. */
92
+ export declare function manifestDigest(manifest: HarnessManifest): string;
93
+ /** Structural load check — throws on anything a manifest is forbidden to carry. */
94
+ export declare function validateManifest(manifest: HarnessManifest): void;
95
+ /**
96
+ * Fold a validated manifest onto `base` runtime options. Instructions ride through as DATA — the
97
+ * runner composes the system prompt once at option normalization so `run_started` and the kernel's
98
+ * AddSystemMessage stay byte-identical. Runtime keys outside the whitelist throw.
99
+ */
100
+ export declare function applyManifest(manifest: HarnessManifest, base: RuntimeOptions): RuntimeOptions;
101
+ /**
102
+ * Apply one structural edit, returning a NEW manifest whose `parent` is the source's digest. Throws
103
+ * when the surface is off-whitelist, the patch is malformed, or the result violates a bound
104
+ * (instruction ≤4000 chars, nudge load rules). The source manifest is never mutated.
105
+ */
106
+ export declare function applyPatch(manifest: HarnessManifest, patch: HarnessPatch): HarnessManifest;
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Self-Harness H1.1 + H1.3 — the harness face as DATA.
3
+ *
4
+ * A `HarnessManifest` is a versioned, hashable lineage node: the editable surfaces a fixed model may
5
+ * rewrite about its OWN harness — instruction slots, nudge rules, and a whitelisted `RuntimeOptions`
6
+ * subset — plus the audit trail binding each edit to the failure cluster it targets. Every function
7
+ * here is pure and deterministic (no clock, no randomness, no I/O), so a manifest digest is a stable
8
+ * identity across processes and the propose→validate→promote loop replays byte-for-byte.
9
+ *
10
+ * The whitelist is the safety boundary: governance / quota / reliability surfaces are deliberately
11
+ * absent, so a proposer can never rewrite them (spec design principle: conservative promotion).
12
+ *
13
+ * Tool/skill surfaces add the SECOND safety invariant (spec design principle A — the capability
14
+ * ceiling): `allowedToolIds`, `stableCoreToolIds`, and `skillFilter` fold onto the host baseline by
15
+ * INTERSECTION, never assignment. A manifest can only NARROW the tools/skills the host already
16
+ * exposes — never widen. Capability expansion (naming a tool the host does not expose) is therefore
17
+ * structurally inexpressible, and the whole security audit stays O(1): read the whitelist, check the
18
+ * one invariant. (`enablePlanTool` is exempt — it toggles a kernel-owned meta-tool, attention-shaping
19
+ * not capability-granting, so it folds by plain assignment.)
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { validateNudgeRules } from "./nudge.js";
23
+ const INSTRUCTION_SLOTS = ["bootstrap", "execution", "verification", "failureRecovery"];
24
+ /** Per-slot upper bound enforced at load and on every `applyPatch` set. */
25
+ const MAX_INSTRUCTION_CHARS = 4000;
26
+ /** A scope key becomes a directory segment; restrict it to a single path-safe token (no separators). */
27
+ const SCOPE_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
28
+ /**
29
+ * Compose the four instruction slots onto `base` in the fixed order base → bootstrap → execution →
30
+ * verification → failureRecovery, joined with `"\n\n"`, skipping empty slots. All-empty ⇒ `base`
31
+ * unchanged (identity — the zero-instructions run is byte-for-byte the pre-feature run). The order is
32
+ * fixed and empty slots are dropped so the composed prefix is byte-stable (prefix-cache axiom).
33
+ */
34
+ export function composeSystemPrompt(base, instructions) {
35
+ const parts = [];
36
+ if (base)
37
+ parts.push(base);
38
+ for (const slot of INSTRUCTION_SLOTS) {
39
+ const text = instructions?.[slot];
40
+ if (text)
41
+ parts.push(text);
42
+ }
43
+ return parts.length === 0 ? base : parts.join("\n\n");
44
+ }
45
+ const MEMORY_POLICY_PATCH_KEYS = ["retrievalTopK", "promotionRecallThreshold"];
46
+ /** Tool/skill surfaces whose fold is intersection-with-baseline (capability ceiling), not assignment. */
47
+ const INTERSECTION_PATCH_KEYS = ["allowedToolIds", "stableCoreToolIds", "skillFilter"];
48
+ const RUNTIME_PATCH_KEYS = [
49
+ "maxTurns",
50
+ "maxTotalTokens",
51
+ "criteriaGate",
52
+ "repeatFuse",
53
+ "entropyWatch",
54
+ "knowledgeBudgetRatio",
55
+ "skillLeaseTurns",
56
+ "allowedToolIds",
57
+ "stableCoreToolIds",
58
+ "enablePlanTool",
59
+ "skillFilter",
60
+ ...MEMORY_POLICY_PATCH_KEYS,
61
+ ];
62
+ /** Bounds for the id-list surfaces (allowedToolIds / stableCoreToolIds / skillFilter). */
63
+ const MAX_TOOL_ID_CHARS = 128;
64
+ const MAX_TOOL_LIST_ENTRIES = 128;
65
+ /**
66
+ * Map an editable surface to its promotion tier. Maintained HERE beside the whitelist so a surface can
67
+ * never be whitelisted without also being assigned a tier (spec V2-S3 same-place maintenance):
68
+ * - "auto" (Tier A): every `runtime.*` whitelist surface. Typed validation + the capability
69
+ * ceiling invariant + the v1 acceptance rule already guard them, so promotion is fully
70
+ * automatic — there is no free text and no injection surface.
71
+ * - "screened" (Tier B): `instructions.*` and `nudges`. Free text can smuggle instructions
72
+ * (persistent prompt-injection laundered through the evidence loop), so a screen runs
73
+ * before promotion.
74
+ * - "human" (Tier C): reserved for capability-WIDENING surfaces. None exist in v2 — intersection
75
+ * semantics make widening structurally inexpressible — but the enum value exists so a v3
76
+ * surface cannot be added without consciously assigning it a tier (and building the
77
+ * human gate). `surfaceTier` therefore never returns "human" in v2.
78
+ * An unknown surface / slot / runtime key THROWS (same discipline as applySurfaceEdit): a surface with
79
+ * no tier must never fall through to auto-promotion.
80
+ */
81
+ export function surfaceTier(targetSurface) {
82
+ const [head, sub] = targetSurface.split(".");
83
+ if (head === "instructions") {
84
+ if (sub === undefined || !INSTRUCTION_SLOTS.includes(sub)) {
85
+ throw new RangeError(`unknown instruction slot: ${targetSurface}`);
86
+ }
87
+ return "screened";
88
+ }
89
+ if (head === "nudges") {
90
+ if (sub !== undefined)
91
+ throw new RangeError(`nudges surface takes no sub-path: ${targetSurface}`);
92
+ return "screened";
93
+ }
94
+ if (head === "runtime") {
95
+ if (sub === undefined || !RUNTIME_PATCH_KEYS.includes(sub)) {
96
+ throw new RangeError(`runtime patch key not in the editable whitelist: ${targetSurface}`);
97
+ }
98
+ return "auto";
99
+ }
100
+ throw new RangeError(`unknown surface path: ${targetSurface}`);
101
+ }
102
+ // ── Canonical JSON + digest ──────────────────────────────────────────────────
103
+ /** Deterministic serialization: recursive key sort, undefined-valued keys skipped, arrays ordered. */
104
+ function canonicalJson(value) {
105
+ if (value === null)
106
+ return "null";
107
+ if (typeof value === "string" || typeof value === "boolean")
108
+ return JSON.stringify(value);
109
+ if (typeof value === "number") {
110
+ if (!Number.isFinite(value))
111
+ throw new TypeError("harness manifest requires finite numbers");
112
+ return JSON.stringify(value);
113
+ }
114
+ if (Array.isArray(value))
115
+ return `[${value.map(canonicalJson).join(",")}]`;
116
+ if (typeof value === "object") {
117
+ const obj = value;
118
+ const keys = Object.keys(obj).filter(key => obj[key] !== undefined).sort();
119
+ return `{${keys.map(key => `${JSON.stringify(key)}:${canonicalJson(obj[key])}`).join(",")}}`;
120
+ }
121
+ throw new TypeError(`harness manifest holds a non-serializable value: ${typeof value}`);
122
+ }
123
+ /** sha-256 hex over the manifest's canonical JSON — the manifest's stable identity. */
124
+ export function manifestDigest(manifest) {
125
+ return createHash("sha256").update(canonicalJson(manifest), "utf8").digest("hex");
126
+ }
127
+ // ── Load validation ──────────────────────────────────────────────────────────
128
+ function validateInstructionProfile(profile) {
129
+ if (typeof profile !== "object" || profile === null || Array.isArray(profile)) {
130
+ throw new TypeError("instructions must be an object");
131
+ }
132
+ for (const slot of INSTRUCTION_SLOTS) {
133
+ const text = profile[slot];
134
+ if (text === undefined)
135
+ continue;
136
+ if (typeof text !== "string")
137
+ throw new TypeError(`instructions.${slot} must be a string`);
138
+ if (text.length > MAX_INSTRUCTION_CHARS) {
139
+ throw new RangeError(`instructions.${slot} exceeds ${MAX_INSTRUCTION_CHARS} chars`);
140
+ }
141
+ }
142
+ }
143
+ function validateRuntimePatch(runtime) {
144
+ if (typeof runtime !== "object" || runtime === null) {
145
+ throw new TypeError("manifest.runtime must be an object");
146
+ }
147
+ for (const [key, value] of Object.entries(runtime)) {
148
+ if (!RUNTIME_PATCH_KEYS.includes(key)) {
149
+ throw new RangeError(`runtime patch key not in the editable whitelist: ${key}`);
150
+ }
151
+ if (value !== undefined)
152
+ validateRuntimeValue(key, value);
153
+ }
154
+ // Same-manifest structural invariant: stable-core keeps tools exposed while a skill narrows, so it
155
+ // must never name a tool outside this manifest's OWN exposure ceiling (`allowedToolIds`). Checked
156
+ // only when both are present; either absent means the ceiling is broader (the whole registered set).
157
+ const allowed = runtime.allowedToolIds;
158
+ const stable = runtime.stableCoreToolIds;
159
+ if (Array.isArray(allowed) && Array.isArray(stable)) {
160
+ const allowedSet = new Set(allowed);
161
+ const outside = stable.filter(id => !allowedSet.has(id));
162
+ if (outside.length > 0) {
163
+ throw new RangeError(`runtime.stableCoreToolIds must be a subset of runtime.allowedToolIds; outside the ceiling: ${outside.join(", ")}`);
164
+ }
165
+ }
166
+ }
167
+ /**
168
+ * Validate an id-list surface: array of unique, non-empty strings (each ≤128 chars), ≤128 entries.
169
+ * `allowEmpty` is the load-bearing asymmetry. For the tool-id arrays it is FALSE: the runner reads an
170
+ * empty/absent `allowedToolIds` as "no gating — expose ALL registered tools", so an empty array would
171
+ * WIDEN exposure to everything if it reached the runner (and a zero-tool run is the v0.2.46 pathology).
172
+ * For `skillFilter` it is TRUE: the runner's no-gating sentinel is ONLY `undefined`, and an empty array
173
+ * legitimately means "no skills available" (a proposer may find skills are a distraction) — a narrowing.
174
+ */
175
+ function validateIdList(key, value, allowEmpty) {
176
+ if (!Array.isArray(value))
177
+ throw new TypeError(`runtime.${key} must be a string[]`);
178
+ if (value.length > MAX_TOOL_LIST_ENTRIES) {
179
+ throw new RangeError(`runtime.${key} exceeds ${MAX_TOOL_LIST_ENTRIES} entries`);
180
+ }
181
+ if (!allowEmpty && value.length === 0) {
182
+ throw new RangeError(`runtime.${key} must be a non-empty list — an empty array is read by the runner as "no gating" (expose all registered tools), which WIDENS exposure`);
183
+ }
184
+ const seen = new Set();
185
+ for (const entry of value) {
186
+ if (typeof entry !== "string" || entry.length === 0) {
187
+ throw new TypeError(`runtime.${key} entries must be non-empty strings`);
188
+ }
189
+ if (entry.length > MAX_TOOL_ID_CHARS) {
190
+ throw new RangeError(`runtime.${key} entry exceeds ${MAX_TOOL_ID_CHARS} chars: ${entry.slice(0, 16)}…`);
191
+ }
192
+ if (seen.has(entry))
193
+ throw new RangeError(`runtime.${key} entries must be unique; duplicate: ${entry}`);
194
+ seen.add(entry);
195
+ }
196
+ }
197
+ /** Per-key value typing for runtime patches. An LLM proposer WILL eventually put instruction prose
198
+ * where a boolean belongs; rejecting it here turns a mid-run kernel `InvalidConfig` crash into a
199
+ * discardable candidate. */
200
+ function validateRuntimeValue(key, value) {
201
+ const positiveInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
202
+ switch (key) {
203
+ case "maxTurns":
204
+ case "maxTotalTokens":
205
+ case "skillLeaseTurns":
206
+ case "retrievalTopK":
207
+ case "promotionRecallThreshold":
208
+ if (!positiveInt(value))
209
+ throw new TypeError(`runtime.${key} must be a positive integer`);
210
+ return;
211
+ case "criteriaGate":
212
+ if (typeof value !== "boolean")
213
+ throw new TypeError("runtime.criteriaGate must be a boolean");
214
+ return;
215
+ case "enablePlanTool":
216
+ if (typeof value !== "boolean")
217
+ throw new TypeError("runtime.enablePlanTool must be a boolean");
218
+ return;
219
+ case "allowedToolIds":
220
+ case "stableCoreToolIds":
221
+ validateIdList(key, value, /* allowEmpty */ false);
222
+ return;
223
+ case "skillFilter":
224
+ validateIdList(key, value, /* allowEmpty */ true);
225
+ return;
226
+ case "knowledgeBudgetRatio":
227
+ if (typeof value !== "number" || !(value > 0 && value <= 1)) {
228
+ throw new TypeError("runtime.knowledgeBudgetRatio must be a number in (0, 1]");
229
+ }
230
+ return;
231
+ case "repeatFuse": {
232
+ if (value === false)
233
+ return;
234
+ if (typeof value !== "object" || value === null) {
235
+ throw new TypeError("runtime.repeatFuse must be false or { denyAfter?, terminateAfter? }");
236
+ }
237
+ const fuse = value;
238
+ for (const k of Object.keys(fuse)) {
239
+ if (k !== "denyAfter" && k !== "terminateAfter") {
240
+ throw new RangeError(`runtime.repeatFuse has unknown key: ${k}`);
241
+ }
242
+ if (fuse[k] !== undefined && !positiveInt(fuse[k])) {
243
+ throw new TypeError(`runtime.repeatFuse.${k} must be a positive integer`);
244
+ }
245
+ }
246
+ return;
247
+ }
248
+ case "entropyWatch": {
249
+ if (typeof value !== "object" || value === null) {
250
+ throw new TypeError("runtime.entropyWatch must be an object");
251
+ }
252
+ const watch = value;
253
+ for (const k of Object.keys(watch)) {
254
+ const v = watch[k];
255
+ if (v === undefined)
256
+ continue;
257
+ if (k === "enabled" || k === "notifyModel") {
258
+ if (typeof v !== "boolean")
259
+ throw new TypeError(`runtime.entropyWatch.${k} must be a boolean`);
260
+ }
261
+ else if (k === "threshold" || k === "hysteresis") {
262
+ if (typeof v !== "number" || !(v >= 0 && v <= 1)) {
263
+ throw new TypeError(`runtime.entropyWatch.${k} must be a number in [0, 1]`);
264
+ }
265
+ }
266
+ else if (k === "cooldownTurns") {
267
+ if (!positiveInt(v))
268
+ throw new TypeError("runtime.entropyWatch.cooldownTurns must be a positive integer");
269
+ }
270
+ else {
271
+ throw new RangeError(`runtime.entropyWatch has unknown key: ${k}`);
272
+ }
273
+ }
274
+ return;
275
+ }
276
+ default:
277
+ throw new RangeError(`runtime patch key not in the editable whitelist: ${key}`);
278
+ }
279
+ }
280
+ /** Structural load check — throws on anything a manifest is forbidden to carry. */
281
+ export function validateManifest(manifest) {
282
+ if (typeof manifest !== "object" || manifest === null)
283
+ throw new TypeError("manifest must be an object");
284
+ if (manifest.manifestVersion !== 1)
285
+ throw new TypeError("manifest.manifestVersion must be 1");
286
+ if (!(manifest.parent === null || typeof manifest.parent === "string")) {
287
+ throw new TypeError("manifest.parent must be a digest string or null");
288
+ }
289
+ if (!Array.isArray(manifest.editableSurfaces) || manifest.editableSurfaces.some(s => typeof s !== "string")) {
290
+ throw new TypeError("manifest.editableSurfaces must be a string[]");
291
+ }
292
+ if (manifest.scope !== undefined) {
293
+ if (typeof manifest.scope !== "string" || !SCOPE_PATTERN.test(manifest.scope)) {
294
+ throw new TypeError("manifest.scope must be a non-empty path-safe token matching /^[A-Za-z0-9._-]{1,64}$/");
295
+ }
296
+ }
297
+ if (manifest.instructions !== undefined)
298
+ validateInstructionProfile(manifest.instructions);
299
+ if (manifest.nudges !== undefined)
300
+ validateNudgeRules(manifest.nudges);
301
+ if (manifest.runtime !== undefined)
302
+ validateRuntimePatch(manifest.runtime);
303
+ }
304
+ // ── Apply ────────────────────────────────────────────────────────────────────
305
+ /**
306
+ * Fold a validated manifest onto `base` runtime options. Instructions ride through as DATA — the
307
+ * runner composes the system prompt once at option normalization so `run_started` and the kernel's
308
+ * AddSystemMessage stay byte-identical. Runtime keys outside the whitelist throw.
309
+ */
310
+ export function applyManifest(manifest, base) {
311
+ validateManifest(manifest);
312
+ const out = { ...base };
313
+ if (manifest.instructions !== undefined)
314
+ out.instructions = manifest.instructions;
315
+ if (manifest.nudges !== undefined)
316
+ out.nudges = manifest.nudges;
317
+ if (manifest.runtime !== undefined) {
318
+ for (const [key, value] of Object.entries(manifest.runtime)) {
319
+ if (value === undefined)
320
+ continue;
321
+ if (MEMORY_POLICY_PATCH_KEYS.includes(key)) {
322
+ out.memoryPolicy = { ...out.memoryPolicy, [key]: value };
323
+ }
324
+ else if (INTERSECTION_PATCH_KEYS.includes(key)) {
325
+ out[key] = foldIntersection(key, value, out[key]);
326
+ }
327
+ else {
328
+ // enablePlanTool + numeric/boolean knobs: plain assignment.
329
+ out[key] = value;
330
+ }
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+ /**
336
+ * Fold one intersection surface (capability ceiling): effective = manifest ∩ host-baseline, so a
337
+ * manifest can only NARROW. The empty-baseline meaning is surface-specific and load-bearing:
338
+ *
339
+ * - allowedToolIds / stableCoreToolIds — the runner reads an empty OR absent baseline as
340
+ * "no gating = all registered tools" (the universe), so a non-array/empty baseline yields the
341
+ * manifest list verbatim; only a NON-EMPTY baseline is a real ceiling to intersect against. An
342
+ * empty intersection THROWS: a zero-tool run reprises the v0.2.46 pathology AND the runner would
343
+ * silently reinterpret the empty result as "no gating" (full exposure) — so we turn the candidate
344
+ * into a discardable error instead.
345
+ * - skillFilter — the runner's no-gating sentinel is ONLY `undefined`; an empty-array baseline is a
346
+ * genuine, maximally-tight ceiling (no skills). So ANY present array (even `[]`) is intersected,
347
+ * and an empty result is FINE (= no skills). This mirrors the validation asymmetry exactly.
348
+ */
349
+ function foldIntersection(key, manifestList, baseList) {
350
+ const skillLike = key === "skillFilter";
351
+ // Is the host baseline a real constraining set? Tool ids: non-empty array only (empty == universe).
352
+ // skillFilter: any array (empty == the empty set).
353
+ const constrained = Array.isArray(baseList) && (skillLike || baseList.length > 0);
354
+ const effective = constrained
355
+ ? manifestList.filter(id => baseList.includes(id)) // manifest order → deterministic
356
+ : manifestList;
357
+ if (!skillLike && effective.length === 0) {
358
+ throw new RangeError(`applyManifest: runtime.${key} intersection is empty — manifest [${manifestList.join(", ")}] ∩ ` +
359
+ `host [${(baseList ?? []).join(", ")}] names no shared tool. A zero-tool run is rejected (it ` +
360
+ `reprises the v0.2.46 pathology and the runner would read empty as "no gating" = full exposure).`);
361
+ }
362
+ return effective;
363
+ }
364
+ function validatePatchShape(patch) {
365
+ if (typeof patch !== "object" || patch === null)
366
+ throw new TypeError("patch must be an object");
367
+ if (typeof patch.targetSurface !== "string" || patch.targetSurface.length === 0) {
368
+ throw new TypeError("patch.targetSurface must be a non-empty string");
369
+ }
370
+ if (patch.op !== "set" && patch.op !== "append" && patch.op !== "remove") {
371
+ throw new TypeError(`patch.op must be set|append|remove, got ${String(patch.op)}`);
372
+ }
373
+ for (const field of ["rationale", "targetCluster", "expectedEffect"]) {
374
+ if (typeof patch[field] !== "string" || patch[field].length === 0) {
375
+ throw new TypeError(`patch.${field} must be a non-empty string`);
376
+ }
377
+ }
378
+ }
379
+ function editInstructionSlot(manifest, slot, patch) {
380
+ if (slot === undefined || !INSTRUCTION_SLOTS.includes(slot)) {
381
+ throw new RangeError(`unknown instruction slot: ${patch.targetSurface}`);
382
+ }
383
+ if (patch.op === "append")
384
+ throw new RangeError("append applies only to nudges");
385
+ const key = slot;
386
+ if (patch.op === "remove") {
387
+ if (manifest.instructions)
388
+ delete manifest.instructions[key];
389
+ return;
390
+ }
391
+ if (typeof patch.value !== "string")
392
+ throw new TypeError(`instructions.${key} set requires a string value`);
393
+ if (patch.value.length > MAX_INSTRUCTION_CHARS) {
394
+ throw new RangeError(`instructions.${key} exceeds ${MAX_INSTRUCTION_CHARS} chars`);
395
+ }
396
+ manifest.instructions = { ...(manifest.instructions ?? {}), [key]: patch.value };
397
+ }
398
+ function editNudges(manifest, patch) {
399
+ const current = manifest.nudges ?? [];
400
+ if (patch.op === "set") {
401
+ const rules = patch.value;
402
+ validateNudgeRules(rules);
403
+ manifest.nudges = rules;
404
+ return;
405
+ }
406
+ if (patch.op === "append") {
407
+ const additions = Array.isArray(patch.value) ? patch.value : [patch.value];
408
+ const merged = [...current, ...additions];
409
+ validateNudgeRules(merged);
410
+ manifest.nudges = merged;
411
+ return;
412
+ }
413
+ // remove — by id
414
+ if (typeof patch.value !== "string")
415
+ throw new TypeError("nudges remove requires a rule id string");
416
+ manifest.nudges = current.filter(rule => rule.id !== patch.value);
417
+ }
418
+ function editRuntime(manifest, key, patch) {
419
+ if (key === undefined || !RUNTIME_PATCH_KEYS.includes(key)) {
420
+ throw new RangeError(`runtime patch key not in the editable whitelist: ${patch.targetSurface}`);
421
+ }
422
+ if (patch.op === "append")
423
+ throw new RangeError("append applies only to nudges");
424
+ const runtime = { ...(manifest.runtime ?? {}) };
425
+ if (patch.op === "remove")
426
+ delete runtime[key];
427
+ else {
428
+ validateRuntimeValue(key, patch.value);
429
+ runtime[key] = patch.value;
430
+ }
431
+ manifest.runtime = runtime;
432
+ }
433
+ function applySurfaceEdit(manifest, patch) {
434
+ const [head, sub] = patch.targetSurface.split(".");
435
+ if (head === "instructions")
436
+ return editInstructionSlot(manifest, sub, patch);
437
+ if (head === "nudges") {
438
+ if (sub !== undefined)
439
+ throw new RangeError(`nudges surface takes no sub-path: ${patch.targetSurface}`);
440
+ return editNudges(manifest, patch);
441
+ }
442
+ if (head === "runtime")
443
+ return editRuntime(manifest, sub, patch);
444
+ throw new RangeError(`unknown surface path: ${patch.targetSurface}`);
445
+ }
446
+ /**
447
+ * Apply one structural edit, returning a NEW manifest whose `parent` is the source's digest. Throws
448
+ * when the surface is off-whitelist, the patch is malformed, or the result violates a bound
449
+ * (instruction ≤4000 chars, nudge load rules). The source manifest is never mutated.
450
+ */
451
+ export function applyPatch(manifest, patch) {
452
+ validatePatchShape(patch);
453
+ if (!manifest.editableSurfaces.includes(patch.targetSurface)) {
454
+ throw new RangeError(`surface not in the editable whitelist: ${patch.targetSurface}`);
455
+ }
456
+ const next = structuredClone(manifest);
457
+ applySurfaceEdit(next, patch);
458
+ next.parent = manifestDigest(manifest);
459
+ validateManifest(next);
460
+ return next;
461
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Self-Harness H1.2 — declarative event→note rules (the runtime control-policy surface).
3
+ *
4
+ * A `NudgeRule` says "when this session event fires, push this note to the model". It generalizes the
5
+ * two hard-coded precedents (EntropyWatch.notify_model, the RepeatFuse STOP text) into data the
6
+ * self-harness loop can rewrite. `NudgeEngine` is a pure, clock-free class: `observe(event)` folds one
7
+ * session event into its per-rule state and returns the notes that fired, which the runner lowers to
8
+ * the `injectNote` signal channel. No I/O, no randomness — so the trigger matrix is exhaustively
9
+ * unit-testable and two engines never share state.
10
+ */
11
+ import type { RuntimeSignalUrgency } from "../signals/types.js";
12
+ import type { SessionEvent } from "../runtime/session-log.js";
13
+ export type NudgeTrigger = {
14
+ kind: "tool_error";
15
+ errorKind?: string;
16
+ toolName?: string;
17
+ } | {
18
+ kind: "tool_denied";
19
+ reasonIncludes?: string;
20
+ } | {
21
+ kind: "tool_calls_at_least";
22
+ count: number;
23
+ } | {
24
+ kind: "turns_at_least";
25
+ count: number;
26
+ } | {
27
+ kind: "entropy_alert";
28
+ };
29
+ export interface NudgeRule {
30
+ id: string;
31
+ on: NudgeTrigger;
32
+ /** Template — supports {{tool_name}} {{error_kind}} {{turn}} only. */
33
+ note: string;
34
+ /** Default "normal" (the injectNote default). */
35
+ urgency?: RuntimeSignalUrgency;
36
+ /** Turns that must pass after a fire before the rule may fire again. Default 3. */
37
+ cooldownTurns?: number;
38
+ /** Total fires allowed for the whole run. Default 2. */
39
+ maxFires?: number;
40
+ }
41
+ /** Structural load check — throws on a malformed / oversized / duplicate-id rule set. */
42
+ export declare function validateNudgeRules(rules: NudgeRule[]): void;
43
+ export interface NudgeOutput {
44
+ note: string;
45
+ urgency: RuntimeSignalUrgency;
46
+ }
47
+ export declare class NudgeEngine {
48
+ private readonly rules;
49
+ private readonly state;
50
+ /** Cumulative tool_requested calls, for `tool_calls_at_least` edge detection. */
51
+ private toolCallsSeen;
52
+ /** Highest turn observed, for cooldown + `turns_at_least` edge detection. */
53
+ private currentTurn;
54
+ /** call_id → tool name, so a tool_completed error can name its tool (results carry no name). */
55
+ private readonly callNames;
56
+ constructor(rules: NudgeRule[]);
57
+ /** Fold one session event into rule state; return the notes that fired this event, in rule order. */
58
+ observe(event: SessionEvent): NudgeOutput[];
59
+ /** Returns the template context when the trigger matches this event, else null. */
60
+ private match;
61
+ }
@@ -0,0 +1,176 @@
1
+ const MAX_RULES = 16;
2
+ const MAX_NOTE_CHARS = 2000;
3
+ const DEFAULT_COOLDOWN_TURNS = 3;
4
+ const DEFAULT_MAX_FIRES = 2;
5
+ const ALLOWED_TEMPLATE_VARS = ["tool_name", "error_kind", "turn"];
6
+ const URGENCIES = ["low", "normal", "high", "critical"];
7
+ // ── Load validation ──────────────────────────────────────────────────────────
8
+ function validateTrigger(on, id) {
9
+ if (typeof on !== "object" || on === null)
10
+ throw new TypeError(`nudge rule ${id}: trigger must be an object`);
11
+ switch (on.kind) {
12
+ case "tool_error":
13
+ if (on.errorKind !== undefined && typeof on.errorKind !== "string") {
14
+ throw new TypeError(`nudge rule ${id}: tool_error.errorKind must be a string`);
15
+ }
16
+ if (on.toolName !== undefined && typeof on.toolName !== "string") {
17
+ throw new TypeError(`nudge rule ${id}: tool_error.toolName must be a string`);
18
+ }
19
+ return;
20
+ case "tool_denied":
21
+ if (on.reasonIncludes !== undefined && typeof on.reasonIncludes !== "string") {
22
+ throw new TypeError(`nudge rule ${id}: tool_denied.reasonIncludes must be a string`);
23
+ }
24
+ return;
25
+ case "tool_calls_at_least":
26
+ case "turns_at_least":
27
+ if (!Number.isInteger(on.count) || on.count < 1) {
28
+ throw new RangeError(`nudge rule ${id}: ${on.kind}.count must be a positive integer`);
29
+ }
30
+ return;
31
+ case "entropy_alert":
32
+ return;
33
+ default:
34
+ throw new RangeError(`nudge rule ${id}: unknown trigger kind: ${on.kind}`);
35
+ }
36
+ }
37
+ function validateTemplateVars(note, id) {
38
+ const re = /\{\{([^{}]*)\}\}/g;
39
+ let match;
40
+ while ((match = re.exec(note)) !== null) {
41
+ if (!ALLOWED_TEMPLATE_VARS.includes(match[1])) {
42
+ throw new RangeError(`nudge rule ${id}: unsupported template variable {{${match[1]}}}`);
43
+ }
44
+ }
45
+ }
46
+ /** Structural load check — throws on a malformed / oversized / duplicate-id rule set. */
47
+ export function validateNudgeRules(rules) {
48
+ if (!Array.isArray(rules))
49
+ throw new TypeError("nudges must be an array");
50
+ if (rules.length > MAX_RULES)
51
+ throw new RangeError(`at most ${MAX_RULES} nudge rules (got ${rules.length})`);
52
+ const ids = new Set();
53
+ for (const rule of rules) {
54
+ if (typeof rule !== "object" || rule === null)
55
+ throw new TypeError("each nudge rule must be an object");
56
+ if (typeof rule.id !== "string" || rule.id.length === 0) {
57
+ throw new TypeError("nudge rule id must be a non-empty string");
58
+ }
59
+ if (ids.has(rule.id))
60
+ throw new RangeError(`duplicate nudge rule id: ${rule.id}`);
61
+ ids.add(rule.id);
62
+ validateTrigger(rule.on, rule.id);
63
+ if (typeof rule.note !== "string" || rule.note.length === 0) {
64
+ throw new TypeError(`nudge rule ${rule.id}: note must be a non-empty string`);
65
+ }
66
+ if (rule.note.length > MAX_NOTE_CHARS) {
67
+ throw new RangeError(`nudge rule ${rule.id}: note exceeds ${MAX_NOTE_CHARS} chars`);
68
+ }
69
+ validateTemplateVars(rule.note, rule.id);
70
+ if (rule.cooldownTurns !== undefined && (!Number.isInteger(rule.cooldownTurns) || rule.cooldownTurns < 0)) {
71
+ throw new RangeError(`nudge rule ${rule.id}: cooldownTurns must be a non-negative integer`);
72
+ }
73
+ if (rule.maxFires !== undefined && (!Number.isInteger(rule.maxFires) || rule.maxFires < 1)) {
74
+ throw new RangeError(`nudge rule ${rule.id}: maxFires must be a positive integer`);
75
+ }
76
+ if (rule.urgency !== undefined && !URGENCIES.includes(rule.urgency)) {
77
+ throw new RangeError(`nudge rule ${rule.id}: invalid urgency ${rule.urgency}`);
78
+ }
79
+ }
80
+ }
81
+ function renderNote(template, ctx) {
82
+ return template
83
+ .replace(/\{\{tool_name\}\}/g, ctx.tool_name ?? "")
84
+ .replace(/\{\{error_kind\}\}/g, ctx.error_kind ?? "")
85
+ .replace(/\{\{turn\}\}/g, String(ctx.turn));
86
+ }
87
+ export class NudgeEngine {
88
+ rules;
89
+ state;
90
+ /** Cumulative tool_requested calls, for `tool_calls_at_least` edge detection. */
91
+ toolCallsSeen = 0;
92
+ /** Highest turn observed, for cooldown + `turns_at_least` edge detection. */
93
+ currentTurn = 0;
94
+ /** call_id → tool name, so a tool_completed error can name its tool (results carry no name). */
95
+ callNames = new Map();
96
+ constructor(rules) {
97
+ validateNudgeRules(rules);
98
+ // Defensive shallow copy: the caller can mutate its array afterwards without touching the engine.
99
+ this.rules = rules.map(rule => ({ ...rule }));
100
+ this.state = this.rules.map(() => ({ fires: 0, lastFireTurn: 0 }));
101
+ }
102
+ /** Fold one session event into rule state; return the notes that fired this event, in rule order. */
103
+ observe(event) {
104
+ const prevToolCalls = this.toolCallsSeen;
105
+ const prevTurn = this.currentTurn;
106
+ if (event.kind === "tool_requested") {
107
+ for (const call of event.calls)
108
+ this.callNames.set(call.id, call.name);
109
+ this.toolCallsSeen += event.calls.length;
110
+ }
111
+ if ("turn" in event && typeof event.turn === "number") {
112
+ this.currentTurn = Math.max(this.currentTurn, event.turn);
113
+ }
114
+ const out = [];
115
+ for (let i = 0; i < this.rules.length; i++) {
116
+ const rule = this.rules[i];
117
+ const matched = this.match(rule.on, event, prevToolCalls, prevTurn);
118
+ if (!matched)
119
+ continue;
120
+ const st = this.state[i];
121
+ if (st.fires >= (rule.maxFires ?? DEFAULT_MAX_FIRES))
122
+ continue;
123
+ if (st.fires > 0 && this.currentTurn - st.lastFireTurn < (rule.cooldownTurns ?? DEFAULT_COOLDOWN_TURNS))
124
+ continue;
125
+ st.fires += 1;
126
+ st.lastFireTurn = this.currentTurn;
127
+ out.push({ note: renderNote(rule.note, matched), urgency: rule.urgency ?? "normal" });
128
+ }
129
+ return out;
130
+ }
131
+ /** Returns the template context when the trigger matches this event, else null. */
132
+ match(on, event, prevToolCalls, prevTurn) {
133
+ switch (on.kind) {
134
+ case "tool_error": {
135
+ if (event.kind !== "tool_completed")
136
+ return null;
137
+ for (const result of event.results) {
138
+ if (!result.is_error)
139
+ continue;
140
+ if (on.errorKind !== undefined && result.error_kind !== on.errorKind)
141
+ continue;
142
+ const name = this.callNames.get(result.call_id);
143
+ if (on.toolName !== undefined && name !== on.toolName)
144
+ continue;
145
+ return { tool_name: name ?? "", error_kind: result.error_kind ?? "", turn: event.turn };
146
+ }
147
+ return null;
148
+ }
149
+ case "tool_denied": {
150
+ if (event.kind !== "tool_denied")
151
+ return null;
152
+ if (on.reasonIncludes !== undefined && !event.reason.includes(on.reasonIncludes))
153
+ return null;
154
+ return { tool_name: event.tool_name, error_kind: "", turn: event.turn };
155
+ }
156
+ case "tool_calls_at_least": {
157
+ // Edge: the cumulative count crosses the threshold on THIS event (monotonic ⇒ fires once).
158
+ if (prevToolCalls < on.count && this.toolCallsSeen >= on.count) {
159
+ return { turn: this.currentTurn };
160
+ }
161
+ return null;
162
+ }
163
+ case "turns_at_least": {
164
+ if (prevTurn < on.count && this.currentTurn >= on.count) {
165
+ return { turn: this.currentTurn };
166
+ }
167
+ return null;
168
+ }
169
+ case "entropy_alert": {
170
+ if (event.kind !== "entropy_alert")
171
+ return null;
172
+ return { turn: event.turn };
173
+ }
174
+ }
175
+ }
176
+ }
@@ -4,3 +4,7 @@ export { VerdictFnJudge, LlmEvalJudge, HybridJudge } from "./judge.js";
4
4
  export type { AttemptJudge, JudgeContext, JudgeResult, SkillCandidate } from "./judge.js";
5
5
  export { judge } from "../runtime/eval.js";
6
6
  export type { VerdictDetail, JudgeArgs } from "../runtime/eval.js";
7
+ export { composeSystemPrompt, manifestDigest, applyManifest, applyPatch, validateManifest, surfaceTier, } from "./manifest.js";
8
+ export type { InstructionProfile, HarnessManifest, HarnessRuntimePatch, HarnessPatch, SurfaceTier, } from "./manifest.js";
9
+ export { NudgeEngine, validateNudgeRules } from "./nudge.js";
10
+ export type { NudgeTrigger, NudgeRule, NudgeOutput } from "./nudge.js";
@@ -2,3 +2,7 @@
2
2
  export { AttemptLoop, RuntimeAttemptBody, continueSession, freshWithFeedback, freshWithDigest, } from "./harness.js";
3
3
  export { VerdictFnJudge, LlmEvalJudge, HybridJudge } from "./judge.js";
4
4
  export { judge } from "../runtime/eval.js";
5
+ // Self-Harness H1: the harness face as data (manifest lineage + declarative event→note rules). The
6
+ // lab layer loads these through the compiled dist, so they live on this public barrel.
7
+ export { composeSystemPrompt, manifestDigest, applyManifest, applyPatch, validateManifest, surfaceTier, } from "./manifest.js";
8
+ export { NudgeEngine, validateNudgeRules } from "./nudge.js";
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export type { LoopSpec, LoopOutcome } from "./runtime/loop-driver.js";
4
4
  export type { RunAgentOptions, RunFanoutOptions } from "./runtime/facade.js";
5
5
  export { RuntimeRunner, collectText } from "./runtime/runner.js";
6
6
  export type { RuntimeOptions, KernelReliabilityOptions, OperationCancellationReason, PromptBudget, SchedulerPolicy } from "./runtime/runner.js";
7
+ export type { InstructionProfile, NudgeRule, NudgeTrigger } from "./harness/public.js";
7
8
  export type { SignalPolicy } from "./runtime/os-profile.js";
8
9
  export { readKernelDiagnostics, restoreKernelRuntime, snapshotKernelRuntime } from "./runtime/kernel-step.js";
9
10
  export type { KernelDiagnostics, KernelSnapshot } from "./runtime/kernel-step.js";
@@ -16,6 +16,8 @@ import { type NativeOsProfile, type OsProfileId, type SignalPolicy } from "./os-
16
16
  import { LargeResultSpool } from "./large-result-spool.js";
17
17
  import type { BackgroundTaskErrorHandler } from "./reliability.js";
18
18
  import { type ContextPolicyOverridesV1 } from "./context-policy.js";
19
+ import { type InstructionProfile } from "../harness/manifest.js";
20
+ import { type NudgeRule } from "../harness/nudge.js";
19
21
  export interface SchedulerPolicy {
20
22
  version: 1;
21
23
  criticalPathWeight: number;
@@ -142,8 +144,24 @@ export interface RuntimeOptions {
142
144
  phase?: "initial" | "renewal";
143
145
  }) => Promise<MemoryQuery[] | undefined> | MemoryQuery[] | undefined;
144
146
  systemPrompt?: string;
147
+ /** Self-Harness H1.1: the four instruction slots (bootstrap/execution/verification/failureRecovery)
148
+ * composed onto `systemPrompt` in fixed order at option normalization. The kernel still sees ONE
149
+ * system prompt; this is the editable instruction surface the self-harness loop rewrites. Absent ⇒
150
+ * `systemPrompt` is used verbatim (zero behavior difference). */
151
+ instructions?: InstructionProfile;
152
+ /** Self-Harness H1.2: declarative event→note rules. On each matching session event a rendered note
153
+ * is pushed through the `injectNote` signal channel (same path as `onToolResult`'s `{note}`).
154
+ * ≤16 rules, validated at construction. Absent/empty ⇒ no engine, no append wrapping, zero
155
+ * behavior difference. */
156
+ nudges?: NudgeRule[];
145
157
  initialMemory?: string[];
146
158
  skillDir?: string;
159
+ /** Host-layer allowlist over the `skillDir` catalog by skill NAME. When set, only scanned skills
160
+ * whose name is listed are fed to the kernel via `set_available_skills` (the manifest layer
161
+ * intersects onto this host baseline in `applyManifest`). Absent ⇒ zero behavior difference (all
162
+ * scanned skills fed); empty array ⇒ no skills (a legitimate narrowing — unlike an empty
163
+ * `allowedToolIds`, which the runner reads as "no gating"). Only takes effect when `skillDir` is set. */
164
+ skillFilter?: string[];
147
165
  dreamStore?: DreamStore;
148
166
  /** M4: advisory callback when a recalled record crosses the promotion threshold. The host/model
149
167
  * decides whether to pin the record or promote its content into knowledge. */
@@ -372,6 +390,11 @@ export declare class RuntimeRunner {
372
390
  private dashboard;
373
391
  /** Most recent kernel entropy sample of the active/last run (see `latestEntropy`). */
374
392
  private lastEntropySample;
393
+ /** H1.1: `systemPrompt` with the instruction slots composed in, computed ONCE so `run_started` and
394
+ * the kernel AddSystemMessage take the identical string. */
395
+ private readonly composedSystemPrompt;
396
+ /** H1.2: present only when `opts.nudges` is non-empty; else null and the append funnel is untouched. */
397
+ private readonly nudgeEngine;
375
398
  constructor(opts: RuntimeOptions);
376
399
  /** Host configuration (for coordinator / sub-agent spawn). */
377
400
  get hostOptions(): RuntimeOptions;
@@ -19,6 +19,8 @@ import { LargeResultSpool } from "./large-result-spool.js";
19
19
  import { formatToolError } from "../tools/errors.js";
20
20
  import { ManagedTaskScope } from "./reliability.js";
21
21
  import { contextPolicyV1, normalizeContextPolicyV1, } from "./context-policy.js";
22
+ import { composeSystemPrompt } from "../harness/manifest.js";
23
+ import { NudgeEngine } from "../harness/nudge.js";
22
24
  export function schedulerPolicyToKernel(policy) {
23
25
  const allowed = new Set([
24
26
  "version", "criticalPathWeight", "fanoutWeight", "ageWeight", "tokenCostWeight",
@@ -97,12 +99,18 @@ export class RuntimeRunner {
97
99
  dashboard = null;
98
100
  /** Most recent kernel entropy sample of the active/last run (see `latestEntropy`). */
99
101
  lastEntropySample = null;
102
+ /** H1.1: `systemPrompt` with the instruction slots composed in, computed ONCE so `run_started` and
103
+ * the kernel AddSystemMessage take the identical string. */
104
+ composedSystemPrompt;
105
+ /** H1.2: present only when `opts.nudges` is non-empty; else null and the append funnel is untouched. */
106
+ nudgeEngine;
100
107
  constructor(opts) {
101
108
  this.opts = opts;
102
109
  const schemaAttempts = opts.workflowSchemaValidationAttempts ?? 2;
103
110
  if (!Number.isInteger(schemaAttempts) || schemaAttempts < 1 || schemaAttempts > 16) {
104
111
  throw new RangeError("workflowSchemaValidationAttempts must be an integer between 1 and 16");
105
112
  }
113
+ this.composedSystemPrompt = composeSystemPrompt(opts.systemPrompt, opts.instructions);
106
114
  if (opts.enableDiagnosticsDashboard) {
107
115
  const originalAppend = opts.sessionLog.append.bind(opts.sessionLog);
108
116
  opts.sessionLog.append = async (sessionId, event) => {
@@ -114,6 +122,21 @@ export class RuntimeRunner {
114
122
  return seq;
115
123
  };
116
124
  }
125
+ // H1.2: feed the nudge engine at the append funnel, stacking OVER any prior wrapping so the event
126
+ // is durably logged before a nudge observes it. Notes drain through the same `injectNote` channel
127
+ // as host `onToolResult` notes, surfacing in the NEXT provider request. Skipped entirely when no
128
+ // rules are configured, keeping the default event stream byte-identical.
129
+ this.nudgeEngine = opts.nudges?.length ? new NudgeEngine(opts.nudges) : null;
130
+ if (this.nudgeEngine) {
131
+ const priorAppend = opts.sessionLog.append.bind(opts.sessionLog);
132
+ opts.sessionLog.append = async (sessionId, event) => {
133
+ const seq = await priorAppend(sessionId, event);
134
+ for (const { note, urgency } of this.nudgeEngine.observe(event)) {
135
+ this.injectNote(note, urgency);
136
+ }
137
+ return seq;
138
+ };
139
+ }
117
140
  }
118
141
  /** Host configuration (for coordinator / sub-agent spawn). */
119
142
  get hostOptions() {
@@ -1222,7 +1245,7 @@ export class RuntimeRunner {
1222
1245
  goal: req.goal,
1223
1246
  criteria: req.criteria ?? [],
1224
1247
  agent_id: this.opts.agentId,
1225
- system_prompt: this.opts.systemPrompt,
1248
+ system_prompt: this.composedSystemPrompt,
1226
1249
  ...(attachments ? { attachments } : {}),
1227
1250
  });
1228
1251
  }
@@ -1424,11 +1447,11 @@ export class RuntimeRunner {
1424
1447
  kind: "set_tools",
1425
1448
  tools: this.opts.executionPlane.schemas().map(toolSchemaToKernel),
1426
1449
  });
1427
- if (this.opts.systemPrompt) {
1450
+ if (this.composedSystemPrompt) {
1428
1451
  await this.commitKernelApply(runtime, this.pendingObservations, {
1429
1452
  kind: "add_system_message",
1430
- content: this.opts.systemPrompt,
1431
- tokens: Math.max(1, Math.ceil(this.opts.systemPrompt.length / 4)),
1453
+ content: this.composedSystemPrompt,
1454
+ tokens: Math.max(1, Math.ceil(this.composedSystemPrompt.length / 4)),
1432
1455
  });
1433
1456
  }
1434
1457
  if (this.opts.initialMemory) {
@@ -1443,11 +1466,17 @@ export class RuntimeRunner {
1443
1466
  if (this.opts.skillDir) {
1444
1467
  const { scanSkillDir } = await import("../skills/loader.js");
1445
1468
  const metas = await scanSkillDir(this.opts.skillDir);
1469
+ // S2 host-layer skill allowlist: keep only scanned skills named in `skillFilter` before feeding
1470
+ // the catalog. Absent ⇒ feed all (identical to the pre-feature message); empty ⇒ feed none. The
1471
+ // `set_available_skills` message is ALWAYS sent when a skillDir exists (shape preserved) — only
1472
+ // the list narrows; the no-skillDir path stays untouched.
1473
+ const filter = this.opts.skillFilter;
1474
+ const selected = filter === undefined ? metas : metas.filter(m => filter.includes(m.name));
1446
1475
  // P1-B: pass the full SkillMetadata (incl. `allowedTools`) straight through — re-mapping it
1447
1476
  // field-by-field previously dropped `allowedTools`.
1448
1477
  await this.commitKernelApply(runtime, this.pendingObservations, {
1449
1478
  kind: "set_available_skills",
1450
- skills: metas.map(m => skillMetadataToKernel(m)),
1479
+ skills: selected.map(m => skillMetadataToKernel(m)),
1451
1480
  });
1452
1481
  }
1453
1482
  // P1-B/D: configure the stable-core tool ids (always exposed under skill gating). Empty/absent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepstrike/sdk",
3
- "version": "0.2.46",
3
+ "version": "0.2.48",
4
4
  "description": "DeepStrike Node.js SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@anthropic-ai/sdk": "^0.99.0",
75
- "@deepstrike/core": "0.2.46",
75
+ "@deepstrike/core": "0.2.48",
76
76
  "@google/generative-ai": "^0.24.1",
77
77
  "openai": "^5.23.2"
78
78
  },