@danypops/papyrus 0.60.4 → 0.60.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.60.4",
3
+ "version": "0.60.5",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,96 @@
1
+ import { ARTIFACT_SCOPE_MAX_ARTIFACTS, CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../constants.ts";
2
+ import { passesRuleRunScope, previewRule } from "../rules/rules-service.ts";
3
+ import type { Artifact } from "./artifact.ts";
4
+ import { type ActivationContext, activationConfig, evaluateActivation, type InjectionProfile } from "./artifact-activation.ts";
5
+ import type { ArtifactScopeMode, ArtifactScopeStore } from "./artifact-scope-store.ts";
6
+ import type { ArtifactStore } from "./artifact-store.ts";
7
+
8
+ export interface ActivationAuditEntry {
9
+ id: string;
10
+ kind: "rule" | "playbook";
11
+ title: string;
12
+ status: string;
13
+ scopeMode: ArtifactScopeMode;
14
+ enabled: boolean;
15
+ reason: string;
16
+ priority: number;
17
+ injection: InjectionProfile;
18
+ estimatedTokens: number;
19
+ }
20
+
21
+ export interface ActivationAudit {
22
+ projectRoot: string;
23
+ entries: ActivationAuditEntry[];
24
+ summary: {
25
+ total: number;
26
+ enabled: number;
27
+ disabled: number;
28
+ global: number;
29
+ explicit: number;
30
+ hidden: number;
31
+ estimatedEnabledTokens: number;
32
+ };
33
+ }
34
+
35
+ function estimatedTokens(artifacts: ArtifactStore, artifact: Artifact): number {
36
+ const text =
37
+ artifact.kind === "rule"
38
+ ? previewRule(artifacts, artifact.id)
39
+ : `• ${artifact.title} (when: ${typeof artifact.extra.trigger === "string" ? artifact.extra.trigger : "manual invocation"})`;
40
+ return Math.ceil(text.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
41
+ }
42
+
43
+ export function auditArtifactActivation(
44
+ artifacts: ArtifactStore,
45
+ scopes: ArtifactScopeStore,
46
+ projectRoot: string,
47
+ activeTaskId: string | undefined,
48
+ context: ActivationContext,
49
+ ): ActivationAudit {
50
+ const rows = [
51
+ ...artifacts.query({ kind: "rule", limit: ARTIFACT_SCOPE_MAX_ARTIFACTS }),
52
+ ...artifacts.query({ kind: "playbook", limit: ARTIFACT_SCOPE_MAX_ARTIFACTS }),
53
+ ] as Artifact[];
54
+ const entries = rows
55
+ .map((artifact): ActivationAuditEntry => {
56
+ const scope = scopes.scope(artifact.id);
57
+ const config = activationConfig(artifact.extra, artifact.kind === "rule" ? "full" : "catalog");
58
+ let decision: { enabled: boolean; reason: string };
59
+ if (artifact.status !== "active") decision = { enabled: false, reason: `lifecycle status is ${artifact.status}` };
60
+ else if (!scopes.appliesToProjectRoot(artifact.id, projectRoot))
61
+ decision = { enabled: false, reason: `scope ${scope.mode} does not apply` };
62
+ else if (artifact.kind === "rule" && artifact.subtype === "artifact-template") {
63
+ decision = { enabled: false, reason: "rule is an artifact template" };
64
+ } else if (artifact.kind === "rule" && !passesRuleRunScope(artifact, activeTaskId)) {
65
+ decision = { enabled: false, reason: "run ownership does not apply" };
66
+ } else decision = evaluateActivation(config, { ...context, projectRoot });
67
+ return {
68
+ id: artifact.id,
69
+ kind: artifact.kind as "rule" | "playbook",
70
+ title: artifact.title,
71
+ status: artifact.status,
72
+ scopeMode: scope.mode,
73
+ ...decision,
74
+ priority: config.priority,
75
+ injection: config.injection,
76
+ estimatedTokens: estimatedTokens(artifacts, artifact),
77
+ };
78
+ })
79
+ .sort(
80
+ (left, right) =>
81
+ Number(right.enabled) - Number(left.enabled) || right.priority - left.priority || left.title.localeCompare(right.title),
82
+ );
83
+ return {
84
+ projectRoot,
85
+ entries,
86
+ summary: {
87
+ total: entries.length,
88
+ enabled: entries.filter((entry) => entry.enabled).length,
89
+ disabled: entries.filter((entry) => !entry.enabled).length,
90
+ global: entries.filter((entry) => entry.scopeMode === "all").length,
91
+ explicit: entries.filter((entry) => entry.scopeMode === "explicit").length,
92
+ hidden: entries.filter((entry) => entry.scopeMode === "none").length,
93
+ estimatedEnabledTokens: entries.filter((entry) => entry.enabled).reduce((sum, entry) => sum + entry.estimatedTokens, 0),
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,240 @@
1
+ export const ACTIVATION_MAX_DEPTH = 8;
2
+ export const ACTIVATION_MAX_NODES = 64;
3
+ export const ACTIVATION_MAX_VALUES = 32;
4
+ export const ACTIVATION_VALUE_MAX_LENGTH = 256;
5
+
6
+ export const ACTIVATION_FIELDS = [
7
+ "project.root",
8
+ "task.status",
9
+ "task.labels",
10
+ "languages",
11
+ "file.extensions",
12
+ "tool.name",
13
+ "operation.name",
14
+ "session.capabilities",
15
+ ] as const;
16
+ export type ActivationField = (typeof ACTIVATION_FIELDS)[number];
17
+
18
+ export const ACTIVATION_OPERATORS = ["eq", "in", "contains_any", "contains_all", "exists"] as const;
19
+ export type ActivationOperator = (typeof ACTIVATION_OPERATORS)[number];
20
+ export type InjectionProfile = "full" | "catalog" | "on-demand";
21
+
22
+ export type ActivationPredicate =
23
+ | { field: ActivationField; operator: ActivationOperator; value: string | string[] | boolean }
24
+ | { all: ActivationPredicate[] }
25
+ | { any: ActivationPredicate[] }
26
+ | { not: ActivationPredicate };
27
+
28
+ export interface ActivationConfig {
29
+ predicate?: ActivationPredicate;
30
+ priority: number;
31
+ injection: InjectionProfile;
32
+ invalid?: true;
33
+ }
34
+
35
+ export interface ActivationContext {
36
+ projectRoot?: string;
37
+ taskStatus?: string;
38
+ taskLabels?: string[];
39
+ languages?: string[];
40
+ fileExtensions?: string[];
41
+ toolName?: string;
42
+ operationName?: string;
43
+ sessionCapabilities?: string[];
44
+ }
45
+
46
+ export interface ActivationDecision {
47
+ enabled: boolean;
48
+ reason: string;
49
+ }
50
+
51
+ function optionalContextString(value: unknown, label: string): string | undefined {
52
+ return value === undefined ? undefined : boundedString(value, label);
53
+ }
54
+
55
+ function optionalContextStrings(value: unknown, label: string): string[] | undefined {
56
+ return value === undefined ? undefined : stringValues(value, label);
57
+ }
58
+
59
+ /** Parses caller-supplied activation signals. Project and active-Task fields are supplied server-side and intentionally cannot be spoofed through this object. */
60
+ export function activationContextFromInput(value: unknown): ActivationContext {
61
+ if (value === undefined) return {};
62
+ const input = record(value, "activation_context");
63
+ if (
64
+ !Object.keys(input).every((key) =>
65
+ ["languages", "file_extensions", "tool_name", "operation_name", "session_capabilities"].includes(key),
66
+ )
67
+ ) {
68
+ throw new Error("activation_context contains an unsupported field");
69
+ }
70
+ return {
71
+ languages: optionalContextStrings(input.languages, "activation_context.languages"),
72
+ fileExtensions: optionalContextStrings(input.file_extensions, "activation_context.file_extensions"),
73
+ toolName: optionalContextString(input.tool_name, "activation_context.tool_name"),
74
+ operationName: optionalContextString(input.operation_name, "activation_context.operation_name"),
75
+ sessionCapabilities: optionalContextStrings(input.session_capabilities, "activation_context.session_capabilities"),
76
+ };
77
+ }
78
+
79
+ const fieldSet = new Set<string>(ACTIVATION_FIELDS);
80
+ const operatorSet = new Set<string>(ACTIVATION_OPERATORS);
81
+ const arrayFields = new Set<ActivationField>(["task.labels", "languages", "file.extensions", "session.capabilities"]);
82
+
83
+ function record(value: unknown, label: string): Record<string, unknown> {
84
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
85
+ return value as Record<string, unknown>;
86
+ }
87
+
88
+ function boundedString(value: unknown, label: string): string {
89
+ if (typeof value !== "string" || value.length === 0 || value.length > ACTIVATION_VALUE_MAX_LENGTH) {
90
+ throw new Error(`${label} must be a string between 1 and ${ACTIVATION_VALUE_MAX_LENGTH} characters`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function stringValues(value: unknown, label: string): string[] {
96
+ if (!Array.isArray(value) || value.length === 0 || value.length > ACTIVATION_MAX_VALUES) {
97
+ throw new Error(`${label} must contain 1-${ACTIVATION_MAX_VALUES} strings`);
98
+ }
99
+ return value.map((entry, index) => boundedString(entry, `${label}[${index}]`));
100
+ }
101
+
102
+ interface ValidationState {
103
+ nodes: number;
104
+ }
105
+
106
+ function validatePredicate(value: unknown, state: ValidationState, depth: number): ActivationPredicate {
107
+ if (depth > ACTIVATION_MAX_DEPTH) throw new Error(`activation predicate depth cannot exceed ${ACTIVATION_MAX_DEPTH}`);
108
+ state.nodes++;
109
+ if (state.nodes > ACTIVATION_MAX_NODES) throw new Error(`activation predicate cannot exceed ${ACTIVATION_MAX_NODES} nodes`);
110
+ const input = record(value, "activation predicate");
111
+ const keys = Object.keys(input);
112
+ if (keys.length === 1 && keys[0] === "all") {
113
+ if (!Array.isArray(input.all) || input.all.length === 0) throw new Error("activation all must be a non-empty array");
114
+ return { all: input.all.map((entry) => validatePredicate(entry, state, depth + 1)) };
115
+ }
116
+ if (keys.length === 1 && keys[0] === "any") {
117
+ if (!Array.isArray(input.any) || input.any.length === 0) throw new Error("activation any must be a non-empty array");
118
+ return { any: input.any.map((entry) => validatePredicate(entry, state, depth + 1)) };
119
+ }
120
+ if (keys.length === 1 && keys[0] === "not") return { not: validatePredicate(input.not, state, depth + 1) };
121
+ if (!keys.every((key) => key === "field" || key === "operator" || key === "value")) {
122
+ throw new Error("activation predicate must be exactly one all/any/not group or one field/operator/value test");
123
+ }
124
+ const field = boundedString(input.field, "activation field");
125
+ if (!fieldSet.has(field)) throw new Error(`unsupported activation field "${field}"`);
126
+ const operator = boundedString(input.operator, "activation operator");
127
+ if (!operatorSet.has(operator)) throw new Error(`unsupported activation operator "${operator}"`);
128
+ const typedField = field as ActivationField;
129
+ const typedOperator = operator as ActivationOperator;
130
+ let typedValue: string | string[] | boolean;
131
+ if (typedOperator === "exists") {
132
+ if (typeof input.value !== "boolean") throw new Error("activation exists value must be a boolean");
133
+ typedValue = input.value;
134
+ } else if (typedOperator === "eq") {
135
+ typedValue = boundedString(input.value, "activation eq value");
136
+ } else {
137
+ typedValue = stringValues(input.value, `activation ${typedOperator} value`);
138
+ }
139
+ if ((typedOperator === "contains_any" || typedOperator === "contains_all") && !arrayFields.has(typedField)) {
140
+ throw new Error(`activation operator ${typedOperator} requires an array-valued field`);
141
+ }
142
+ return { field: typedField, operator: typedOperator, value: typedValue };
143
+ }
144
+
145
+ export function validateActivationConfig(value: unknown, defaultInjection: InjectionProfile = "full"): ActivationConfig {
146
+ const input = record(value, "activation");
147
+ if (!Object.keys(input).every((key) => key === "predicate" || key === "priority" || key === "injection")) {
148
+ throw new Error("activation supports only predicate, priority, and injection");
149
+ }
150
+ const priority = input.priority === undefined ? 0 : input.priority;
151
+ if (!Number.isInteger(priority) || (priority as number) < -1000 || (priority as number) > 1000) {
152
+ throw new Error("activation priority must be an integer between -1000 and 1000");
153
+ }
154
+ const injection = input.injection === undefined ? defaultInjection : input.injection;
155
+ if (injection !== "full" && injection !== "catalog" && injection !== "on-demand") {
156
+ throw new Error("activation injection must be full, catalog, or on-demand");
157
+ }
158
+ return {
159
+ ...(input.predicate === undefined ? {} : { predicate: validatePredicate(input.predicate, { nodes: 0 }, 1) }),
160
+ priority: priority as number,
161
+ injection,
162
+ };
163
+ }
164
+
165
+ export function activationConfig(extra: Record<string, unknown>, defaultInjection: InjectionProfile = "full"): ActivationConfig {
166
+ if (extra.activation === undefined) return { priority: 0, injection: defaultInjection };
167
+ try {
168
+ return validateActivationConfig(extra.activation, defaultInjection);
169
+ } catch {
170
+ return { priority: 0, injection: defaultInjection, invalid: true };
171
+ }
172
+ }
173
+
174
+ function contextValue(field: ActivationField, context: ActivationContext): string | string[] | undefined {
175
+ switch (field) {
176
+ case "project.root":
177
+ return context.projectRoot;
178
+ case "task.status":
179
+ return context.taskStatus;
180
+ case "task.labels":
181
+ return context.taskLabels;
182
+ case "languages":
183
+ return context.languages;
184
+ case "file.extensions":
185
+ return context.fileExtensions;
186
+ case "tool.name":
187
+ return context.toolName;
188
+ case "operation.name":
189
+ return context.operationName;
190
+ case "session.capabilities":
191
+ return context.sessionCapabilities;
192
+ }
193
+ }
194
+
195
+ function evaluatePredicate(predicate: ActivationPredicate, context: ActivationContext): ActivationDecision {
196
+ if ("all" in predicate) {
197
+ for (const child of predicate.all) {
198
+ const decision = evaluatePredicate(child, context);
199
+ if (!decision.enabled) return decision;
200
+ }
201
+ return { enabled: true, reason: "enabled" };
202
+ }
203
+ if ("any" in predicate) {
204
+ const decisions = predicate.any.map((child) => evaluatePredicate(child, context));
205
+ if (decisions.some((decision) => decision.enabled)) return { enabled: true, reason: "enabled" };
206
+ return { enabled: false, reason: decisions[0]?.reason ?? "activation any did not match" };
207
+ }
208
+ if ("not" in predicate) {
209
+ const decision = evaluatePredicate(predicate.not, context);
210
+ return decision.enabled ? { enabled: false, reason: "activation not matched" } : { enabled: true, reason: "enabled" };
211
+ }
212
+ const actual = contextValue(predicate.field, context);
213
+ if (predicate.operator === "exists") {
214
+ const exists = actual !== undefined && (!Array.isArray(actual) || actual.length > 0);
215
+ return exists === predicate.value
216
+ ? { enabled: true, reason: "enabled" }
217
+ : { enabled: false, reason: `activation ${predicate.field} existence did not match` };
218
+ }
219
+ if (actual === undefined) return { enabled: false, reason: `activation context field ${predicate.field} is unavailable` };
220
+ const actualValues = Array.isArray(actual) ? actual : [actual];
221
+ if (predicate.operator === "eq") {
222
+ return actualValues.includes(predicate.value as string)
223
+ ? { enabled: true, reason: "enabled" }
224
+ : { enabled: false, reason: `activation ${predicate.field} did not equal ${predicate.value as string}` };
225
+ }
226
+ const expected = predicate.value as string[];
227
+ const matches =
228
+ predicate.operator === "contains_all"
229
+ ? expected.every((entry) => actualValues.includes(entry))
230
+ : expected.some((entry) => actualValues.includes(entry));
231
+ return matches
232
+ ? { enabled: true, reason: "enabled" }
233
+ : { enabled: false, reason: `activation ${predicate.field} did not match ${predicate.operator}` };
234
+ }
235
+
236
+ export function evaluateActivation(config: ActivationConfig, context: ActivationContext): ActivationDecision {
237
+ if (config.invalid) return { enabled: false, reason: "invalid activation configuration" };
238
+ if (config.injection === "on-demand") return { enabled: false, reason: "activation injection profile is on-demand" };
239
+ return config.predicate === undefined ? { enabled: true, reason: "enabled" } : evaluatePredicate(config.predicate, context);
240
+ }
@@ -0,0 +1,81 @@
1
+ import type { CommandContext } from "@stricli/core";
2
+ import { buildApplication, buildCommand, buildRouteMap } from "@stricli/core";
3
+ import type { PapyrusClient } from "../client.ts";
4
+ import { runStricliToString } from "./stricli-run.ts";
5
+
6
+ type ActivationClient = Pick<PapyrusClient, "call">;
7
+ interface ActivationCliContext extends CommandContext {
8
+ readonly client: ActivationClient;
9
+ readonly json: boolean;
10
+ readonly callerProjectRoot: string;
11
+ }
12
+
13
+ interface ActivationAuditOutput {
14
+ summary: {
15
+ total: number;
16
+ enabled: number;
17
+ disabled: number;
18
+ global: number;
19
+ explicit: number;
20
+ hidden: number;
21
+ estimatedEnabledTokens: number;
22
+ };
23
+ entries: Array<{ enabled: boolean; kind: string; title: string; reason: string }>;
24
+ }
25
+
26
+ function parseObject(value: string): Record<string, unknown> {
27
+ const parsed = JSON.parse(value) as unknown;
28
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("value must be a JSON object");
29
+ return parsed as Record<string, unknown>;
30
+ }
31
+
32
+ const auditCommand = buildCommand({
33
+ func: async function (
34
+ this: ActivationCliContext,
35
+ flags: { projectRoot?: string; activationContextJson?: Record<string, unknown>; sessionId?: string },
36
+ ) {
37
+ const result = await this.client.call<Record<string, unknown>, ActivationAuditOutput>("activation.audit", {
38
+ project_root: flags.projectRoot ?? this.callerProjectRoot,
39
+ activation_context: flags.activationContextJson,
40
+ session_id: flags.sessionId,
41
+ });
42
+ if (this.json) this.process.stdout.write(JSON.stringify(result));
43
+ else {
44
+ const summary = result.summary;
45
+ const lines = [
46
+ `${summary.enabled}/${summary.total} enabled; ${summary.disabled} disabled; ${summary.estimatedEnabledTokens} estimated tokens`,
47
+ `scope: ${summary.global} global, ${summary.explicit} explicit, ${summary.hidden} hidden`,
48
+ ...result.entries.map((entry) => `${entry.enabled ? "enabled" : "disabled"} [${entry.kind}] ${entry.title} — ${entry.reason}`),
49
+ ];
50
+ this.process.stdout.write(lines.join("\n"));
51
+ }
52
+ },
53
+ parameters: {
54
+ flags: {
55
+ projectRoot: { brief: "Project root (defaults to caller cwd)", kind: "parsed", parse: String, placeholder: "path", optional: true },
56
+ activationContextJson: {
57
+ brief: "Trusted activation context as JSON",
58
+ kind: "parsed",
59
+ parse: parseObject,
60
+ placeholder: "json",
61
+ optional: true,
62
+ },
63
+ sessionId: { brief: "Agent session id", kind: "parsed", parse: String, placeholder: "id", optional: true },
64
+ },
65
+ },
66
+ docs: { brief: "Audit Rule and Playbook activation decisions for a project context" },
67
+ });
68
+
69
+ const app = buildApplication(buildRouteMap({ routes: { audit: auditCommand }, docs: { brief: "Conditional activation operations" } }), {
70
+ name: "activation",
71
+ scanner: { caseStyle: "allow-kebab-for-camel" },
72
+ });
73
+
74
+ export async function runActivationCli(args: string[], client: ActivationClient, projectRoot: string = process.cwd()): Promise<string> {
75
+ const json = args.includes("--json");
76
+ return runStricliToString(
77
+ app,
78
+ args.filter((arg) => arg !== "--json"),
79
+ { client, json, callerProjectRoot: projectRoot },
80
+ );
81
+ }
@@ -51,6 +51,7 @@ const createCommand = buildCommand({
51
51
  toolsJson?: string[];
52
52
  labelsJson?: string[];
53
53
  extraJson?: Record<string, unknown>;
54
+ activationJson?: Record<string, unknown>;
54
55
  argumentsJson?: unknown[] | Record<string, unknown>;
55
56
  projectRoot?: string;
56
57
  projectsJson?: string[];
@@ -64,6 +65,7 @@ const createCommand = buildCommand({
64
65
  tools: flags.toolsJson,
65
66
  labels: flags.labelsJson,
66
67
  extra: flags.extraJson,
68
+ activation: flags.activationJson,
67
69
  arguments: flags.argumentsJson,
68
70
  project_root: flags.projectRoot,
69
71
  projects: flags.projectsJson,
@@ -85,6 +87,13 @@ const createCommand = buildCommand({
85
87
  toolsJson: { brief: "JSON string array of tool names", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
86
88
  labelsJson: { brief: "JSON string array of labels", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
87
89
  extraJson: { brief: "JSON object of extra fields", kind: "parsed", parse: parseObject, placeholder: "json", optional: true },
90
+ activationJson: {
91
+ brief: "Typed activation config as JSON: {predicate?,priority?,injection?}",
92
+ kind: "parsed",
93
+ parse: parseObject,
94
+ placeholder: "json",
95
+ optional: true,
96
+ },
88
97
  argumentsJson: {
89
98
  brief: "JSON array of declared argument definitions",
90
99
  kind: "parsed",
@@ -108,7 +117,15 @@ const createCommand = buildCommand({
108
117
  const listCommand = buildCommand({
109
118
  func: async function (
110
119
  this: PlaybooksContext,
111
- flags: { status?: string; text?: string; limit?: number; projectRoot?: string; applicable?: boolean },
120
+ flags: {
121
+ status?: string;
122
+ text?: string;
123
+ limit?: number;
124
+ projectRoot?: string;
125
+ applicable?: boolean;
126
+ activated?: boolean;
127
+ activationContextJson?: Record<string, unknown>;
128
+ },
112
129
  ) {
113
130
  const rows = await this.client.call<Record<string, unknown>, CliArtifact[]>("playbooks.list", {
114
131
  status: flags.status,
@@ -116,6 +133,8 @@ const listCommand = buildCommand({
116
133
  limit: flags.limit,
117
134
  project_root: flags.projectRoot,
118
135
  applicable: flags.applicable,
136
+ activated: flags.activated,
137
+ activation_context: flags.activationContextJson,
119
138
  });
120
139
  render.call(this, rows, rows.length === 0 ? "No playbooks found." : rows.map((row) => artifactLabel(row)).join("\n"));
121
140
  },
@@ -130,6 +149,14 @@ const listCommand = buildCommand({
130
149
  kind: "boolean",
131
150
  optional: true,
132
151
  },
152
+ activated: { brief: "Evaluate typed activation predicates", kind: "boolean", optional: true },
153
+ activationContextJson: {
154
+ brief: "Trusted activation context as JSON",
155
+ kind: "parsed",
156
+ parse: parseObject,
157
+ placeholder: "json",
158
+ optional: true,
159
+ },
133
160
  },
134
161
  },
135
162
  docs: { brief: "List Playbooks" },
@@ -388,7 +415,14 @@ const replaceGroupsCommand = buildCommand({
388
415
  const updateCommand = buildCommand({
389
416
  func: async function (
390
417
  this: PlaybooksContext,
391
- flags: { title?: string; body?: string; labelsJson?: string[]; trigger?: string; stepsJson?: unknown[] | Record<string, unknown> },
418
+ flags: {
419
+ title?: string;
420
+ body?: string;
421
+ labelsJson?: string[];
422
+ trigger?: string;
423
+ stepsJson?: unknown[] | Record<string, unknown>;
424
+ activationJson?: Record<string, unknown>;
425
+ },
392
426
  id: string,
393
427
  ) {
394
428
  if (
@@ -396,9 +430,10 @@ const updateCommand = buildCommand({
396
430
  flags.body === undefined &&
397
431
  flags.labelsJson === undefined &&
398
432
  flags.trigger === undefined &&
399
- flags.stepsJson === undefined
433
+ flags.stepsJson === undefined &&
434
+ flags.activationJson === undefined
400
435
  )
401
- throw new Error("playbooks update requires --title, --body, --labels-json, --trigger, or --steps-json");
436
+ throw new Error("playbooks update requires --title, --body, --labels-json, --trigger, --steps-json, or --activation-json");
402
437
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("playbooks.update", {
403
438
  id,
404
439
  title: flags.title,
@@ -406,6 +441,7 @@ const updateCommand = buildCommand({
406
441
  labels: flags.labelsJson,
407
442
  trigger: flags.trigger,
408
443
  steps: flags.stepsJson,
444
+ activation: flags.activationJson,
409
445
  });
410
446
  render.call(this, artifact, artifactLabel(artifact));
411
447
  },
@@ -422,6 +458,13 @@ const updateCommand = buildCommand({
422
458
  placeholder: "json",
423
459
  optional: true,
424
460
  },
461
+ activationJson: {
462
+ brief: "Replacement typed activation config as JSON",
463
+ kind: "parsed",
464
+ parse: parseObject,
465
+ placeholder: "json",
466
+ optional: true,
467
+ },
425
468
  },
426
469
  positional: { kind: "tuple", parameters: [{ brief: "Playbook id", parse: String, placeholder: "id" }] },
427
470
  },
@@ -41,6 +41,7 @@ const createCommand = buildCommand({
41
41
  severity?: string;
42
42
  labelsJson?: string[];
43
43
  extraJson?: Record<string, unknown>;
44
+ activationJson?: Record<string, unknown>;
44
45
  projectRoot?: string;
45
46
  },
46
47
  ) {
@@ -52,6 +53,7 @@ const createCommand = buildCommand({
52
53
  severity: flags.severity,
53
54
  labels: flags.labelsJson,
54
55
  extra: flags.extraJson,
56
+ activation: flags.activationJson,
55
57
  project_root: flags.projectRoot,
56
58
  });
57
59
  render.call(this, artifact, `Created rule: ${artifactLabel(artifact)}`);
@@ -65,6 +67,13 @@ const createCommand = buildCommand({
65
67
  severity: { brief: "block|warn|info", kind: "parsed", parse: String, placeholder: "severity", optional: true },
66
68
  labelsJson: { brief: "JSON string array of labels", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
67
69
  extraJson: { brief: "JSON object of extra fields", kind: "parsed", parse: parseObject, placeholder: "json", optional: true },
70
+ activationJson: {
71
+ brief: "Typed activation config as JSON: {predicate?,priority?,injection?}",
72
+ kind: "parsed",
73
+ parse: parseObject,
74
+ placeholder: "json",
75
+ optional: true,
76
+ },
68
77
  projectRoot: { brief: "Project scope", kind: "parsed", parse: String, placeholder: "path", optional: true },
69
78
  },
70
79
  },
@@ -319,25 +328,41 @@ const gateCommand = buildCommand({
319
328
  });
320
329
 
321
330
  const injectableCommand = buildCommand({
322
- func: async function (this: RulesContext) {
331
+ func: async function (this: RulesContext, flags: { activationContextJson?: Record<string, unknown> }) {
323
332
  const rows = await this.client.call<Record<string, unknown>, CliArtifact[]>("rules.injectable", {
324
333
  project_root: this.callerProjectRoot,
334
+ activation_context: flags.activationContextJson,
325
335
  });
326
336
  render.call(this, rows, rows.length === 0 ? "No injectable rules." : rows.map((row) => row.title).join("\n"));
327
337
  },
328
- parameters: { flags: {} },
338
+ parameters: {
339
+ flags: {
340
+ activationContextJson: {
341
+ brief: "Trusted activation context as JSON",
342
+ kind: "parsed",
343
+ parse: parseObject,
344
+ placeholder: "json",
345
+ optional: true,
346
+ },
347
+ },
348
+ },
329
349
  docs: { brief: "List Rules currently injectable into the agent system prompt for this project" },
330
350
  });
331
351
 
332
352
  const updateCommand = buildCommand({
333
- func: async function (this: RulesContext, flags: { title?: string; body?: string; labelsJson?: string[] }, id: string) {
334
- if (flags.title === undefined && flags.body === undefined && flags.labelsJson === undefined)
335
- throw new Error("rules update requires --title, --body, or --labels-json");
353
+ func: async function (
354
+ this: RulesContext,
355
+ flags: { title?: string; body?: string; labelsJson?: string[]; activationJson?: Record<string, unknown> },
356
+ id: string,
357
+ ) {
358
+ if (flags.title === undefined && flags.body === undefined && flags.labelsJson === undefined && flags.activationJson === undefined)
359
+ throw new Error("rules update requires --title, --body, --labels-json, or --activation-json");
336
360
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("rules.update", {
337
361
  id,
338
362
  title: flags.title,
339
363
  body: flags.body,
340
364
  labels: flags.labelsJson,
365
+ activation: flags.activationJson,
341
366
  });
342
367
  render.call(this, artifact, artifactLabel(artifact));
343
368
  },
@@ -346,6 +371,13 @@ const updateCommand = buildCommand({
346
371
  title: { brief: "New title", kind: "parsed", parse: String, placeholder: "text", optional: true },
347
372
  body: { brief: "New body", kind: "parsed", parse: String, placeholder: "text", optional: true },
348
373
  labelsJson: { brief: "JSON string array of labels", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
374
+ activationJson: {
375
+ brief: "Replacement typed activation config as JSON",
376
+ kind: "parsed",
377
+ parse: parseObject,
378
+ placeholder: "json",
379
+ optional: true,
380
+ },
349
381
  },
350
382
  positional: { kind: "tuple", parameters: [{ brief: "Rule id", parse: String, placeholder: "id" }] },
351
383
  },
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
6
+ import { runActivationCli } from "./cli/activation-command.ts";
6
7
  import { runArtifactCli } from "./cli/artifact-command.ts";
7
8
  import { runBatchCli } from "./cli/batch-command.ts";
8
9
  import { runBindersCli } from "./cli/binders-command.ts";
@@ -107,6 +108,7 @@ const USAGE = `Usage:
107
108
  papyrus gates run <id> [--json]
108
109
  papyrus graph-projection apply --batch-json <json> [--json]
109
110
  papyrus graph-projection checkpoint --producer-id <id> [--json]
111
+ papyrus activation audit [--project-root <path>] [--activation-context-json <json>] [--session-id <id>] [--json]
110
112
  papyrus artifact create --kind <kind> [--title <title>] [--status <status>] [--subtype <subtype>] [--body <body>] [--labels-json <json>] [--extra-json <json>] [--template-id <id>] [--json]
111
113
  papyrus artifact query [--kind <kind>] [--status <status>] [--text <query>] [--limit <count>] [--json]
112
114
  papyrus artifact show <id> [--depth <n>] [--max-nodes <n>] [--json]
@@ -363,6 +365,7 @@ export function runIdMigrationCli(args: string[]): string {
363
365
  }
364
366
 
365
367
  export {
368
+ runActivationCli,
366
369
  runArtifactCli,
367
370
  runBatchCli,
368
371
  runBindersCli,
@@ -466,6 +469,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
466
469
  console.log(await runRulesCli(args.slice(1), client));
467
470
  return;
468
471
  }
472
+ if (command === "activation") {
473
+ const client = await connectPapyrusClient();
474
+ console.log(await runActivationCli(args.slice(1), client));
475
+ return;
476
+ }
469
477
  if (command === "artifact") {
470
478
  const client = await connectPapyrusClient();
471
479
  console.log(await runArtifactCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -72,6 +72,8 @@ export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
72
72
  * this long cannot also risk a real JavaScript call-stack overflow independent of this bound.
73
73
  */
74
74
  export const CONTEXT_TREE_MAX_NODES = 50_000;
75
+ /** Hard aggregate ceiling for Papyrus's recurring Rules + Playbooks + Task system-prompt injection. */
76
+ export const PAPYRUS_CONTEXT_INJECTION_MAX_TOKENS = 8192;
75
77
 
76
78
  /**
77
79
  * A Papyrus Rule's condition+action+body is injected into EVERY relevant turn's system
@@ -0,0 +1,39 @@
1
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
2
+ import { activationContextFromInput } from "../artifact/artifact-activation.ts";
3
+ import { auditArtifactActivation } from "../artifact/artifact-activation-audit.ts";
4
+ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
5
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
6
+ import type { Tasks } from "../task/task-service.ts";
7
+ import { createOperationDefiner, stringProp } from "./shared.ts";
8
+
9
+ export function registerActivationVehicleOperations(
10
+ registry: VehicleRegistry,
11
+ artifacts: ArtifactStore,
12
+ scopes: ArtifactScopeStore,
13
+ tasks: Tasks,
14
+ ): void {
15
+ const define = createOperationDefiner(registry, "activation", "activation", ["rules:read", "playbooks:read"], (name, input) => {
16
+ if (name !== "activation.audit") throw new Error(`unknown activation operation ${name}`);
17
+ const projectRoot = input.project_root as string;
18
+ const sessionId = input.session_id as string | undefined;
19
+ const activeTask = tasks.active({ projectRoot, sessionId });
20
+ return auditArtifactActivation(artifacts, scopes, projectRoot, activeTask?.id, {
21
+ ...activationContextFromInput(input.activation_context),
22
+ projectRoot,
23
+ taskStatus: activeTask?.status,
24
+ taskLabels: activeTask?.labels,
25
+ });
26
+ });
27
+ define(
28
+ "audit",
29
+ "Audits every Rule and Playbook against lifecycle, project/scope-group applicability, run ownership, and typed activation predicates. Returns enabled/disabled decisions, exclusion reasons, priority, injection profile, scope counts, and estimated enabled tokens.",
30
+ "read",
31
+ {
32
+ project_root: stringProp,
33
+ activation_context: { type: "object", description: "Trusted turn signals used by typed activation predicates." },
34
+ session_id: stringProp,
35
+ },
36
+ ["project_root"],
37
+ (input) => input,
38
+ );
39
+ }
@@ -105,6 +105,7 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
105
105
  subtype: stringProp,
106
106
  labels: { type: "array" },
107
107
  extra: { type: "object" },
108
+ activation: { type: "object", description: "Typed activation config: {predicate?,priority?,injection?}." },
108
109
  template_id: stringProp,
109
110
  project_root: stringProp,
110
111
  projects: { type: "array" },
@@ -123,7 +124,16 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
123
124
  "list",
124
125
  "Lists Playbooks matching an optional status/text filter. project_root alone scopes to EXACT membership in that project (audit semantics); project_root plus applicable:true instead lists every Playbook APPLICABLE to it (global Playbooks plus Playbooks whose membership includes it) -- what pi-papyrus's before_agent_start uses to inject only relevant Playbooks. Returns a lean summary (no body/steps) by default -- pass full: true for the complete artifact.",
125
126
  "read",
126
- { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp, applicable: booleanProp, full: booleanProp },
127
+ {
128
+ status: stringProp,
129
+ text: stringProp,
130
+ limit: numberProp,
131
+ project_root: stringProp,
132
+ applicable: booleanProp,
133
+ activated: booleanProp,
134
+ activation_context: { type: "object", description: "Trusted turn signals used by typed activation predicates." },
135
+ full: booleanProp,
136
+ },
127
137
  [],
128
138
  (input) => input,
129
139
  );
@@ -316,6 +326,7 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
316
326
  labels: { type: "array" },
317
327
  trigger: stringProp,
318
328
  steps: { type: "array" },
329
+ activation: { type: "object", description: "Replacement typed activation config." },
319
330
  actor: stringProp,
320
331
  source: stringProp,
321
332
  session_id: stringProp,
@@ -19,6 +19,7 @@ import type { SessionIdentity } from "../session-identity/session-identity-servi
19
19
  import type { TaskEventStore } from "../task/event/task-event-store.ts";
20
20
  import type { TaskScopeStore } from "../task/scope/task-scope-store.ts";
21
21
  import type { Tasks } from "../task/task-service.ts";
22
+ import { registerActivationVehicleOperations } from "./activation.ts";
22
23
  import { registerArtifactTrashOperations } from "./artifact-trash.ts";
23
24
  import { registerBatchVehicleOperation } from "./batch.ts";
24
25
  import { registerBindersVehicleOperations } from "./binders.ts";
@@ -57,6 +58,7 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
57
58
  // session_id, a correlation id, not a secret -- see session-identity-service.ts).
58
59
  registry.setExposeHandlerFailureDetails(true);
59
60
  registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
61
+ registerActivationVehicleOperations(registry, deps.artifacts, deps.scopes, deps.tasks);
60
62
  registerBindersVehicleOperations(registry, deps.artifacts, deps.scopes, deps.projectRegistry, deps.scopeGroups);
61
63
  registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes, deps.projectRegistry, deps.scopeGroups);
62
64
  registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority, deps.projectRegistry, deps.scopeGroups);
@@ -77,6 +77,9 @@ export function registerRulesVehicleOperations(
77
77
  subtype: stringProp,
78
78
  labels: { type: "array" } as unknown as { type: string },
79
79
  extra: { type: "object" } as unknown as { type: string },
80
+ activation: { type: "object", description: "Typed activation config: {predicate?,priority?,injection?}." } as unknown as {
81
+ type: string;
82
+ },
80
83
  template_id: stringProp,
81
84
  project_root: stringProp,
82
85
  projects: { type: "array" } as unknown as { type: string },
@@ -314,6 +317,7 @@ export function registerRulesVehicleOperations(
314
317
  title: stringProp,
315
318
  body: stringProp,
316
319
  labels: { type: "array" } as unknown as { type: string },
320
+ activation: { type: "object", description: "Replacement typed activation config." } as unknown as { type: string },
317
321
  project_root: stringProp,
318
322
  actor: stringProp,
319
323
  source: stringProp,
package/src/index.ts CHANGED
@@ -6,6 +6,13 @@
6
6
  */
7
7
 
8
8
  export type { Artifact, ArtifactEdge } from "./artifact/artifact.ts";
9
+ export {
10
+ type ActivationConfig,
11
+ type ActivationContext,
12
+ type ActivationPredicate,
13
+ activationConfig,
14
+ type InjectionProfile,
15
+ } from "./artifact/artifact-activation.ts";
9
16
  export { projectArtifactRelationships } from "./artifact/artifact-relationship-view.ts";
10
17
  export type { ArtifactStore } from "./artifact/artifact-store.ts";
11
18
  export {
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { summarizeArtifact } from "../artifact/artifact.ts";
14
+ import { activationContextFromInput } from "../artifact/artifact-activation.ts";
14
15
  import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
15
16
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
16
17
  import type { OperationDefinition } from "../module-registry.ts";
@@ -22,6 +23,7 @@ import {
22
23
  containPlaybook,
23
24
  createPlaybook,
24
25
  dependPlaybook,
26
+ listActivatedPlaybooks,
25
27
  listPlaybooks,
26
28
  playbookInvocation,
27
29
  playbookScope,
@@ -147,6 +149,7 @@ export function playbooksOperations({
147
149
  subtype: optionalString(input, "subtype"),
148
150
  labels: input.labels as string[] | undefined,
149
151
  extra: input.extra as Record<string, unknown> | undefined,
152
+ activation: input.activation,
150
153
  templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
151
154
  projectRoot: optionalString(input, "project_root"),
152
155
  projectReferences: input.projects as string[] | undefined,
@@ -156,7 +159,21 @@ export function playbooksOperations({
156
159
  ),
157
160
  ),
158
161
  define("playbooks.list", (input: OperationInput) => {
159
- const playbooks = listPlaybooks(artifacts, artifactScopes, artifactFilter(input));
162
+ const filter = artifactFilter(input);
163
+ const projectRoot = optionalString(input, "project_root");
164
+ const activeTask =
165
+ optionalBoolean(input, "activated") === true
166
+ ? tasks.active({ projectRoot, sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId") })
167
+ : undefined;
168
+ const playbooks =
169
+ optionalBoolean(input, "activated") === true
170
+ ? listActivatedPlaybooks(artifacts, artifactScopes, filter, {
171
+ ...activationContextFromInput(input.activation_context),
172
+ projectRoot,
173
+ taskStatus: activeTask?.status,
174
+ taskLabels: activeTask?.labels,
175
+ })
176
+ : listPlaybooks(artifacts, artifactScopes, filter);
160
177
  return optionalBoolean(input, "full") === true ? playbooks : playbooks.map(summarizeArtifact);
161
178
  }),
162
179
  define("playbooks.show", (input: OperationInput) => showPlaybook(artifacts, string(input, "id"))),
@@ -225,6 +242,7 @@ export function playbooksOperations({
225
242
  labels: input.labels as string[] | undefined,
226
243
  trigger: optionalString(input, "trigger"),
227
244
  steps: input.steps,
245
+ activation: input.activation,
228
246
  },
229
247
  eventContext(input),
230
248
  ),
@@ -120,6 +120,7 @@ export function rulesOperations(
120
120
  subtype: optionalString(input, "subtype"),
121
121
  labels: input.labels as string[] | undefined,
122
122
  extra: input.extra as Record<string, unknown> | undefined,
123
+ activation: input.activation,
123
124
  templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
124
125
  projectRoot: optionalString(input, "project_root"),
125
126
  projectReferences: input.projects as string[] | undefined,
@@ -182,6 +183,7 @@ export function rulesOperations(
182
183
  title: optionalString(input, "title"),
183
184
  body: optionalString(input, "body"),
184
185
  labels: input.labels as string[] | undefined,
186
+ activation: input.activation,
185
187
  },
186
188
  eventContext(input),
187
189
  ),
@@ -24,6 +24,13 @@
24
24
 
25
25
  import type { Artifact } from "../artifact/artifact.ts";
26
26
  import { requireLocallyOwnedContent } from "../artifact/artifact.ts";
27
+ import {
28
+ type ActivationContext,
29
+ activationConfig,
30
+ evaluateActivation,
31
+ type InjectionProfile,
32
+ validateActivationConfig,
33
+ } from "../artifact/artifact-activation.ts";
27
34
  import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
28
35
  import type { ArtifactScope, ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
29
36
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
@@ -226,6 +233,7 @@ export interface CreatePlaybookInput {
226
233
  subtype?: string;
227
234
  labels?: string[];
228
235
  extra?: Record<string, unknown>;
236
+ activation?: unknown;
229
237
  templateId?: string;
230
238
  projectRoot?: string;
231
239
  /** Bounded exact registered project references (id/name/alias/root) -- fail-closed unlike projectRoot's auto-register-by-root legacy form. Takes precedence over projectRoot when both are given. */
@@ -246,6 +254,7 @@ export type PlaybookTransition = "enable" | "disable";
246
254
  export interface UpdatePlaybookInput extends UpdateContentInput {
247
255
  trigger?: string;
248
256
  steps?: unknown;
257
+ activation?: unknown;
249
258
  }
250
259
 
251
260
  const PLAYBOOK_TRANSITIONS: TransitionTable<PlaybookTransition, string> = {
@@ -266,6 +275,7 @@ export function createPlaybook(
266
275
  const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
267
276
  const declaredArguments = validatePlaybookArguments(input.arguments);
268
277
  const declaredSteps = validatePlaybookSteps(input.steps);
278
+ const activation = input.activation === undefined ? undefined : validateActivationConfig(input.activation, "catalog");
269
279
  const playbook = artifacts.create(
270
280
  {
271
281
  kind: "playbook",
@@ -276,6 +286,7 @@ export function createPlaybook(
276
286
  labels: input.labels,
277
287
  extra: {
278
288
  ...(input.extra ?? {}),
289
+ ...(activation === undefined ? {} : { activation }),
279
290
  ...(input.trigger ? { trigger: input.trigger } : {}),
280
291
  ...(declaredSteps ? { steps: declaredSteps } : {}),
281
292
  ...(input.tools ? { tools: input.tools } : {}),
@@ -297,6 +308,25 @@ export function listPlaybooks(artifacts: ArtifactStore, scopes: ArtifactScopeSto
297
308
  return listScoped(artifacts, scopes, "playbook", filter);
298
309
  }
299
310
 
311
+ export function listActivatedPlaybooks(
312
+ artifacts: ArtifactStore,
313
+ scopes: ArtifactScopeStore,
314
+ filter: ListFilter,
315
+ context: ActivationContext,
316
+ ): Artifact[] {
317
+ return listPlaybooks(artifacts, scopes, filter).filter(
318
+ (playbook) => evaluateActivation(activationConfig(playbook.extra, "catalog"), context).enabled,
319
+ );
320
+ }
321
+
322
+ export function playbookActivationDecision(
323
+ playbook: Artifact,
324
+ context: ActivationContext,
325
+ ): { enabled: boolean; reason: string; priority: number; injection: InjectionProfile } {
326
+ const config = activationConfig(playbook.extra, "catalog");
327
+ return { ...evaluateActivation(config, context), priority: config.priority, injection: config.injection };
328
+ }
329
+
300
330
  export function assignPlaybookProject(
301
331
  artifacts: ArtifactStore,
302
332
  scopes: ArtifactScopeStore,
@@ -409,9 +439,10 @@ export function updatePlaybook(artifacts: ArtifactStore, id: string, input: Upda
409
439
  input.body === undefined &&
410
440
  input.labels === undefined &&
411
441
  input.trigger === undefined &&
412
- input.steps === undefined
442
+ input.steps === undefined &&
443
+ input.activation === undefined
413
444
  ) {
414
- throw new Error("update requires title, body, labels, trigger, or steps");
445
+ throw new Error("update requires title, body, labels, trigger, steps, or activation");
415
446
  }
416
447
  assertTitleBounds(input.title);
417
448
  assertBodyBounds(input.body);
@@ -423,13 +454,15 @@ export function updatePlaybook(artifacts: ArtifactStore, id: string, input: Upda
423
454
  const hasContentFields = input.title !== undefined || input.body !== undefined || input.labels !== undefined;
424
455
  const updated = hasContentFields ? artifacts.updateContent(playbook.id, input, context) : playbook;
425
456
  if (!updated) throw new Error(`playbook "${id}" not found`);
426
- if (declaredSteps === undefined && input.trigger === undefined) return updated;
457
+ if (declaredSteps === undefined && input.trigger === undefined && input.activation === undefined) return updated;
458
+ const activation = input.activation === undefined ? undefined : validateActivationConfig(input.activation, "catalog");
427
459
  const withExtra = artifacts.setExtra(
428
460
  updated.id,
429
461
  {
430
462
  ...updated.extra,
431
463
  ...(input.trigger !== undefined ? { trigger: input.trigger } : {}),
432
464
  ...(declaredSteps !== undefined ? { steps: declaredSteps } : {}),
465
+ ...(activation !== undefined ? { activation } : {}),
433
466
  },
434
467
  context,
435
468
  );
@@ -7,6 +7,13 @@
7
7
 
8
8
  import type { Artifact } from "../artifact/artifact.ts";
9
9
  import { requireLocallyOwnedContent } from "../artifact/artifact.ts";
10
+ import {
11
+ type ActivationContext,
12
+ activationConfig,
13
+ evaluateActivation,
14
+ type InjectionProfile,
15
+ validateActivationConfig,
16
+ } from "../artifact/artifact-activation.ts";
10
17
  import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
11
18
  import type { ArtifactScope, ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
12
19
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
@@ -23,7 +30,6 @@ import {
23
30
  removeArtifactScopeProject,
24
31
  replaceArtifactScopeGroups,
25
32
  replaceArtifactScopeProjects,
26
- requireContentUpdateFields,
27
33
  requireKind,
28
34
  runTransition,
29
35
  setArtifactScopeNone,
@@ -43,6 +49,7 @@ export interface CreateRuleInput {
43
49
  subtype?: string;
44
50
  labels?: string[];
45
51
  extra?: Record<string, unknown>;
52
+ activation?: unknown;
46
53
  templateId?: string;
47
54
  projectRoot?: string;
48
55
  /** Bounded exact registered project references (id/name/alias/root) -- fail-closed unlike projectRoot's auto-register-by-root legacy form. Takes precedence over projectRoot when both are given. */
@@ -135,6 +142,7 @@ export function createRule(
135
142
  registry?: ProjectRegistryStore,
136
143
  ): Artifact {
137
144
  assertRuleTextWithinBounds(input.condition, input.action, input.body);
145
+ const activation = input.activation === undefined ? undefined : validateActivationConfig(input.activation, "full");
138
146
  if (input.projectReferences !== undefined && input.projectReferences.length > 0 && registry === undefined) {
139
147
  throw new Error("projectReferences requires a project registry");
140
148
  }
@@ -149,6 +157,7 @@ export function createRule(
149
157
  labels: input.labels,
150
158
  extra: {
151
159
  ...(input.extra ?? {}),
160
+ ...(activation === undefined ? {} : { activation }),
152
161
  ...(input.condition ? { condition: input.condition } : {}),
153
162
  ...(input.action ? { action: input.action } : {}),
154
163
  severity: input.severity ?? "info",
@@ -271,7 +280,7 @@ export function removeRuleGroup(
271
280
  }
272
281
 
273
282
  /** The extra.scope run-gating check alone -- a Rule with no extra.scope always passes this; one with a skill-run/playbook-run scope passes only while its run owns activeTaskId. Both a workflow-definition target's own run scope ("skill-run", written by workflow-execution.ts's runWorkflowSteps for that target kind) and a Playbook's own run scope ("playbook-run", same call for a Playbook target) are recognized -- confirmed live that only "skill-run" was ever checked here, silently breaking Playbook-run-scoped rule injection since Playbook gained its own doc/rule structured steps. */
274
- function passesRunScope(rule: Artifact, activeTaskId: string | undefined): boolean {
283
+ export function passesRuleRunScope(rule: Artifact, activeTaskId: string | undefined): boolean {
275
284
  const scope = rule.extra.scope;
276
285
  if (scope === undefined) return true;
277
286
  if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
@@ -294,13 +303,23 @@ export function listInjectableRules(
294
303
  scopes: ArtifactScopeStore,
295
304
  projectRoot: string | undefined,
296
305
  activeTaskId?: string,
306
+ context: ActivationContext = {},
297
307
  ): Artifact[] {
298
308
  return artifacts.query({ kind: "rule", status: "active" }).filter((rule) => {
299
309
  if (rule.subtype === "artifact-template") return false;
300
- return passesRunScope(rule, activeTaskId) && scopes.appliesToProjectRoot(rule.id, projectRoot);
310
+ if (!passesRuleRunScope(rule, activeTaskId) || !scopes.appliesToProjectRoot(rule.id, projectRoot)) return false;
311
+ return evaluateActivation(activationConfig(rule.extra, "full"), { ...context, projectRoot }).enabled;
301
312
  });
302
313
  }
303
314
 
315
+ export function ruleActivationDecision(
316
+ rule: Artifact,
317
+ context: ActivationContext,
318
+ ): { enabled: boolean; reason: string; priority: number; injection: InjectionProfile } {
319
+ const config = activationConfig(rule.extra, "full");
320
+ return { ...evaluateActivation(config, context), priority: config.priority, injection: config.injection };
321
+ }
322
+
304
323
  export function showRule(artifacts: ArtifactStore, id: string): Artifact {
305
324
  requireKind(artifacts, id, "rule");
306
325
  return artifacts.get(id, { tree: true })!;
@@ -319,11 +338,15 @@ export function transitionRule(artifacts: ArtifactStore, id: string, action: Rul
319
338
  return runTransition(artifacts, rule, "rule", action, RULE_TRANSITIONS, context);
320
339
  }
321
340
 
322
- export type UpdateRuleInput = UpdateContentInput;
341
+ export interface UpdateRuleInput extends UpdateContentInput {
342
+ activation?: unknown;
343
+ }
323
344
 
324
345
  /** A Rule's body update stays under the same combined condition+action+body ceiling as creation -- a permanent per-turn injection cost doesn't get looser just because it's an edit, not a create. */
325
346
  export function updateRule(artifacts: ArtifactStore, id: string, input: UpdateRuleInput, context?: ArtifactEventContext): Artifact {
326
- requireContentUpdateFields(input);
347
+ if (input.title === undefined && input.body === undefined && input.labels === undefined && input.activation === undefined) {
348
+ throw new Error("update requires title, body, labels, or activation");
349
+ }
327
350
  assertTitleBounds(input.title);
328
351
  assertLabelsBounds(input.labels);
329
352
  const rule = requireLocallyOwnedContent(requireKind(artifacts, id, "rule"));
@@ -332,9 +355,14 @@ export function updateRule(artifacts: ArtifactStore, id: string, input: UpdateRu
332
355
  const action = typeof rule.extra.action === "string" ? rule.extra.action : undefined;
333
356
  assertRuleTextWithinBounds(condition, action, input.body);
334
357
  }
335
- const updated = artifacts.updateContent(id, input, context);
358
+ const hasContent = input.title !== undefined || input.body !== undefined || input.labels !== undefined;
359
+ const updated = hasContent ? artifacts.updateContent(id, input, context) : rule;
336
360
  if (!updated) throw new Error(`rule "${id}" not found`);
337
- return updated;
361
+ if (input.activation === undefined) return updated;
362
+ const activation = validateActivationConfig(input.activation, "full");
363
+ const withActivation = artifacts.setExtra(updated.id, { ...updated.extra, activation }, context);
364
+ if (!withActivation) throw new Error(`rule "${id}" not found`);
365
+ return withActivation;
338
366
  }
339
367
 
340
368
  export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
package/src/service.ts CHANGED
@@ -4,6 +4,8 @@ import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle"
4
4
  import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
5
5
  import type { Logger } from "@danypops/vehicle-server/logging";
6
6
  import type { CreateArtifactInput } from "./artifact/artifact.ts";
7
+ import { activationContextFromInput } from "./artifact/artifact-activation.ts";
8
+ import { auditArtifactActivation } from "./artifact/artifact-activation-audit.ts";
7
9
  import type { ArtifactEventReader } from "./artifact/artifact-event-reader.ts";
8
10
  import type { ArtifactScopeStore } from "./artifact/artifact-scope-store.ts";
9
11
  import type { ArtifactStore } from "./artifact/artifact-store.ts";
@@ -80,6 +82,7 @@ const COMPOSITION_ROOT_OPERATION_NAMES = [
80
82
  "artifact.restore",
81
83
  "artifact.trash_status",
82
84
  "artifact.trash_list",
85
+ "activation.audit",
83
86
  "graph.link",
84
87
  "graph.unlink",
85
88
  "graph.tree",
@@ -400,11 +403,26 @@ function handlers(
400
403
  const id = string(input, "id");
401
404
  return artifacts.get(id)?.kind === "task" ? tasks.runGates(id, eventContextFor(input, "gates-api")) : gates.runAsync(id);
402
405
  },
406
+ "activation.audit": (input) => {
407
+ const filter = taskFilter(input);
408
+ if (!filter.projectRoot) throw new Error("project_root is required");
409
+ const activeTask = tasks.active(filter);
410
+ return auditArtifactActivation(artifacts, artifactScopes, filter.projectRoot, activeTask?.id, {
411
+ ...activationContextFromInput(input.activation_context),
412
+ projectRoot: filter.projectRoot,
413
+ taskStatus: activeTask?.status,
414
+ taskLabels: activeTask?.labels,
415
+ });
416
+ },
403
417
  "rules.injectable": (input) => {
404
418
  const filter = taskFilter(input);
405
- return listInjectableRules(artifacts, artifactScopes, filter.projectRoot, tasks.active(filter)?.id).map(
406
- ({ id, title, body, extra }) => ({ id, title, body, extra }),
407
- );
419
+ const activeTask = tasks.active(filter);
420
+ return listInjectableRules(artifacts, artifactScopes, filter.projectRoot, activeTask?.id, {
421
+ ...activationContextFromInput(input.activation_context),
422
+ projectRoot: filter.projectRoot,
423
+ taskStatus: activeTask?.status,
424
+ taskLabels: activeTask?.labels,
425
+ }).map(({ id, title, body, extra }) => ({ id, title, body, extra }));
408
426
  },
409
427
  "tasks.create": forwardToModule("tasks.create"),
410
428
  "tasks.update": forwardToModule("tasks.update"),