@danypops/papyrus 0.38.4 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,97 +1,46 @@
1
1
  /**
2
- * Notes projected as a real VehicleRegistry: one VehicleOperation per real
3
- * action (capture/list/show/history/consume/promote/archive), each with its
4
- * own honest effect and narrow schema -- replacing pi-papyrus's hand-rolled
5
- * `notes(action=X)` mega-tool (unconstrained `action: Type.String()`, 14
6
- * fields unioned across all 7 branches, the "God Parameters"/"Kitchen Sink
7
- * tool" anti-pattern @danypops/vehicle's own README documents).
2
+ * Notes projected as a real VehicleRegistry: one VehicleOperation per real action
3
+ * (capture/list/show/history/consume/promote/archive), each with its own effect
4
+ * and narrow schema, instead of one `action: Type.String()` dispatch tool.
8
5
  *
9
- * Wraps modules/notes.ts's existing operation definitions rather than
10
- * reimplementing their input parsing -- this is a projection/contract layer
11
- * on top of the existing domain logic, not a second copy of it. Adds the
12
- * one thing those definitions don't do: resolving a human-readable
13
- * `name`/`target_name` to the `id`/`target_id` the domain logic actually
14
- * needs, server-side in the same call. The Pi-extension-side
15
- * `resolveNameFields` helper it replaces needed a separate round trip per
16
- * name before the real call; this does it in one.
6
+ * Wraps modules/notes.ts's operation definitions rather than reimplementing their
7
+ * input parsing. Resolves `name`/`target_name` to `id`/`target_id` server-side, in
8
+ * the same call -- avoids a separate round trip per name before the real call.
17
9
  */
18
- import { defineVehicleOperation, defineVehicleSchema, bindVehicleOperation, type VehicleSchemaCodec } from "@danypops/vehicle-core";
19
- import { VehicleRegistry } from "@danypops/vehicle-server";
10
+ import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
11
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
20
12
  import type { ArtifactStore } from "../ports/artifact-store.ts";
21
13
  import { Notes, NOTE_DISPOSITIONS } from "../note-service.ts";
22
- import type { Artifact } from "../domain/artifact.ts";
23
14
  import { notesOperations } from "../modules/notes.ts";
15
+ import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
24
16
 
25
17
  const OWNER = "notes";
26
18
 
27
19
  const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
28
20
 
29
- /**
30
- * VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
31
- * descriptive metadata surfaced to a client/Pi projection, never itself
32
- * enforced at runtime -- so a declared `enum` has to be checked here for
33
- * real, or it's a documentation gesture, not an honest contract.
34
- */
35
- function looseObjectSchema(properties: Record<string, { type: string; enum?: readonly string[] }>, required: readonly string[] = []): VehicleSchemaCodec<Record<string, unknown>> {
36
- return defineVehicleSchema<Record<string, unknown>>({
37
- jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
38
- safeParse(value) {
39
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
40
- return { success: false, issues: [{ path: [], message: "input must be an object" }] };
41
- }
42
- const input = value as Record<string, unknown>;
43
- for (const key of required) {
44
- if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
45
- }
46
- for (const [key, schema] of Object.entries(properties)) {
47
- if (!schema.enum || !(key in input)) continue;
48
- if (!schema.enum.includes(input[key] as string)) {
49
- return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
50
- }
51
- }
52
- return { success: true, value: input };
53
- },
54
- });
55
- }
56
-
57
- const passthroughOutput = defineVehicleSchema<unknown>({
58
- jsonSchema: { type: "object" },
59
- safeParse: (value) => ({ success: true, value }),
60
- });
61
-
62
- /** Exact match semantics as the Pi-extension helper it replaces (domain-tools.ts's matchArtifactByName) -- case-insensitive exact title match, refuses to guess between ambiguous matches. */
63
- function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
64
- const needle = name.trim().toLowerCase();
65
- const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
66
- if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
67
- if (matches.length > 1) {
68
- throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`);
69
- }
70
- return matches[0]!.id;
71
- }
72
-
73
21
  /** Resolves a note's id from either an explicit id or its title within projectRoot. */
74
22
  function resolveNoteId(notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
75
23
  if (typeof id === "string" && id.length > 0) return id;
76
24
  if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
77
- return matchArtifactByName(notes.list({ projectRoot, text: name }), name);
25
+ return resolveArtifactIdWidened(name, () => notes.list({ projectRoot, text: name }));
78
26
  }
79
27
 
80
28
  /** Cross-kind equivalent for a promotion target -- a target can be a task, doc, rule, or skill, not just a note. Unscoped by project, matching the exact behavior of the artifact.query-backed resolution it replaces. */
81
29
  function resolveArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
82
30
  if (typeof id === "string" && id.length > 0) return id;
83
31
  if (typeof name !== "string" || name.length === 0) throw new Error("target_id or target_name is required");
84
- return matchArtifactByName(artifacts.query({ text: name }), name);
32
+ return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
85
33
  }
86
34
 
87
35
  /**
88
- * Builds a VehicleRegistry exposing every notes.* action as its own honest
89
- * operation. `artifacts` is only needed for promote's cross-kind
90
- * target_name resolution -- every other operation only ever touches notes
91
- * themselves via `notes`.
36
+ * Registers every notes.* action as its own honest VehicleOperation onto an
37
+ * existing registry (see ./papyrus-vehicle.ts for the composition root that
38
+ * merges every domain's operations into one registry/one HTTP mount).
39
+ * `artifacts` is only needed for promote's cross-kind target_name
40
+ * resolution -- every other operation only ever touches notes themselves
41
+ * via `notes`.
92
42
  */
93
- export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStore): VehicleRegistry {
94
- const registry = new VehicleRegistry({ name: "papyrus-notes", version: "1.0.0", description: "Papyrus's deferred human-intent inbox." });
43
+ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes: Notes, artifacts: ArtifactStore): void {
95
44
  const moduleOperations = new Map(notesOperations(notes).map((op) => [op.name, op]));
96
45
  const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
97
46
 
@@ -117,9 +66,6 @@ export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStor
117
66
  registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`notes.${action}`, resolve(context.input))));
118
67
  };
119
68
 
120
- const stringProp = { type: "string" } as const;
121
- const numberProp = { type: "number" } as const;
122
-
123
69
  define(
124
70
  "capture",
125
71
  "Stores a deferred request without creating work. Returns the created note.",
@@ -205,6 +151,4 @@ export function createNotesVehicleRegistry(notes: Notes, artifacts: ArtifactStor
205
151
  ["project_root", "disposition"],
206
152
  (input) => ({ ...input, id: resolveNoteId(notes, input.project_root as string, input.id, input.name) }),
207
153
  );
208
-
209
- return registry;
210
154
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Composition root for every domain projected onto Vehicle -- one VehicleRegistry,
3
+ * one HTTP mount (see service.ts's createApp). Operation names are already globally
4
+ * unique via their own dotted prefix (notes.*, rules.*, docs.*, skills.*, playbooks.*,
5
+ * artifact.*), so merging costs nothing and avoids a separate registry/mount/client
6
+ * per domain.
7
+ *
8
+ * discuss and tasks still register via pi-papyrus's own pi.registerTool() in
9
+ * domain-tools.ts, not here -- see the papyrus Vehicle migration task for why.
10
+ */
11
+ import { VehicleRegistry } from "@danypops/vehicle-server";
12
+ import type { AuthorityRegistry } from "../authority-registry.ts";
13
+ import type { Notes } from "../note-service.ts";
14
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
15
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
16
+ import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
17
+ import type { SessionIdentity } from "../session-identity-service.ts";
18
+ import type { TaskEventStore } from "../ports/task-event-store.ts";
19
+ import type { TaskScopeStore } from "../ports/task-scope-store.ts";
20
+ import type { Tasks } from "../task-service.ts";
21
+ import { registerArtifactTrashOperations } from "./artifact-trash-vehicle.ts";
22
+ import { registerDocsVehicleOperations } from "./docs-vehicle.ts";
23
+ import { registerNotesVehicleOperations } from "./notes-vehicle.ts";
24
+ import { registerPlaybooksVehicleOperations } from "./playbooks-vehicle.ts";
25
+ import { registerRulesVehicleOperations } from "./rules-vehicle.ts";
26
+ import { registerSkillsVehicleOperations } from "./skills-vehicle.ts";
27
+
28
+ export interface PapyrusVehicleDeps {
29
+ artifacts: ArtifactStore & ArtifactTrashStore;
30
+ scopes: ArtifactScopeStore;
31
+ authority: AuthorityRegistry;
32
+ notes: Notes;
33
+ events: TaskEventStore;
34
+ taskScopes: TaskScopeStore;
35
+ tasks: Tasks;
36
+ sessionIdentity: SessionIdentity;
37
+ }
38
+
39
+ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleRegistry {
40
+ const registry = new VehicleRegistry({ name: "papyrus", version: "1.0.0", description: "Papyrus's graph-artifact domains, one honest operation per real action." });
41
+ registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
42
+ registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes);
43
+ registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority);
44
+ registerSkillsVehicleOperations(registry, { artifacts: deps.artifacts, events: deps.events, scopes: deps.taskScopes, artifactScopes: deps.scopes, authority: deps.authority, tasks: deps.tasks });
45
+ registerPlaybooksVehicleOperations(registry, { artifacts: deps.artifacts, events: deps.events, scopes: deps.taskScopes, artifactScopes: deps.scopes, tasks: deps.tasks, sessionIdentity: deps.sessionIdentity });
46
+ registerArtifactTrashOperations(registry, deps.artifacts);
47
+ return registry;
48
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Playbooks projected as a real VehicleRegistry: one VehicleOperation per real action.
3
+ * Wraps modules/playbooks.ts's operation definitions. remove/restore/remove_subtree are
4
+ * not duplicated here -- see ./artifact-trash-vehicle.ts.
5
+ *
6
+ * playbooks.invoke's own module handler calls tasks.focus() directly (bypassing the
7
+ * guarded tasks.focus operation, per modules/playbooks.ts's own doc comment) and re-runs
8
+ * that exact guard itself via sessionIdentity.assertAuthorized(session_id, session_secret).
9
+ * Those two fields never belong in this operation's model-visible inputSchema -- a model
10
+ * has no business knowing or supplying a session secret. Instead they travel through
11
+ * VehicleInvocationOptions.principal.claims, populated by pi-papyrus's own
12
+ * resolveInvocation hook (see vehicle-notes-client.ts) from its own already-cached
13
+ * session_secret, the same value the hand-rolled tool used to thread through as a raw
14
+ * input field. A caller with no cached secret for this session (unregistered, or a non-Pi
15
+ * Vehicle client) simply gets the guard's own no-op-when-unset default, unchanged.
16
+ *
17
+ * invoke's output carries its own `content` block (see @danypops/vehicle-core's
18
+ * WithVehicleContent) built from the same execution-DAG summary pi-papyrus's hand-rolled
19
+ * tool used to build client-side.
20
+ */
21
+ import { bindVehicleOperation, defineVehicleOperation, type VehicleOperationContext } from "@danypops/vehicle-core";
22
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
23
+ import { listPlaybooks } from "../domain-services.ts";
24
+ import { playbooksOperations } from "../modules/playbooks.ts";
25
+ import type { PlaybookInvocationResult, PlaybookMissingArguments } from "../playbook-execution.ts";
26
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
27
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
28
+ import type { TaskEventStore } from "../ports/task-event-store.ts";
29
+ import type { TaskScopeStore } from "../ports/task-scope-store.ts";
30
+ import type { SessionIdentity } from "../session-identity-service.ts";
31
+ import type { Tasks } from "../task-service.ts";
32
+ import { buildWorkflowRunContent, looseObjectSchema, normalizeJsonEncodedField, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
33
+
34
+ const OWNER = "playbooks";
35
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
36
+
37
+ export interface PlaybooksVehicleDeps {
38
+ artifacts: ArtifactStore;
39
+ events: TaskEventStore;
40
+ scopes: TaskScopeStore;
41
+ artifactScopes: ArtifactScopeStore;
42
+ tasks: Tasks;
43
+ sessionIdentity: SessionIdentity;
44
+ }
45
+
46
+ /** Unscoped resolution -- a Playbook is commonly cross-project (e.g. a lab-deploy playbook), matching the hand-rolled tool's own resolutionRequest choice. */
47
+ function resolvePlaybookId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: unknown, name: unknown): string {
48
+ if (typeof id === "string" && id.length > 0) return id;
49
+ if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
50
+ return resolveArtifactIdWidened(name, () => listPlaybooks(artifacts, scopes, { text: name }));
51
+ }
52
+
53
+ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, deps: PlaybooksVehicleDeps): void {
54
+ const { artifacts, events, scopes, artifactScopes, tasks, sessionIdentity } = deps;
55
+ const moduleOperations = new Map(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }).map((op) => [op.name, op]));
56
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
57
+
58
+ const define = (
59
+ action: string,
60
+ description: string,
61
+ effect: "read" | "local-write",
62
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
63
+ required: readonly string[],
64
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
65
+ execute?: (input: Record<string, unknown>, context: VehicleOperationContext<Record<string, unknown>>) => unknown,
66
+ ): void => {
67
+ const operation = defineVehicleOperation({
68
+ name: `playbooks.${action}`,
69
+ version: 1,
70
+ description,
71
+ input: looseObjectSchema(properties, required),
72
+ output: passthroughOutput,
73
+ permissions: ["playbooks:read", "playbooks:write"],
74
+ effect,
75
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
76
+ limits: LIMITS,
77
+ });
78
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => (execute ?? ((input: Record<string, unknown>) => call(`playbooks.${action}`, input)))(resolve(context.input), context)));
79
+ };
80
+
81
+ define(
82
+ "create",
83
+ "Creates a Playbook -- prose: a trigger and an ordered list of steps. `arguments` declares named inputs: [{name, description?, required?}] (required defaults true), referenced in step text as {{name}}. project_root is optional (omitted = unscoped).",
84
+ "local-write",
85
+ { title: stringProp, body: stringProp, trigger: stringProp, steps: { type: "array" }, tools: { type: "array" }, arguments: { type: "array" }, labels: { type: "array" }, extra: { type: "object" }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
86
+ ["title"],
87
+ (input) => {
88
+ normalizeJsonEncodedField(input, "arguments");
89
+ return input;
90
+ },
91
+ );
92
+
93
+ define(
94
+ "list",
95
+ "Lists Playbooks matching an optional status/text filter, scoped to project_root when given.",
96
+ "read",
97
+ { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp },
98
+ [],
99
+ (input) => input,
100
+ );
101
+
102
+ define(
103
+ "show",
104
+ "Shows one Playbook by id or title.",
105
+ "read",
106
+ { id: stringProp, name: stringProp },
107
+ [],
108
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
109
+ );
110
+
111
+ define(
112
+ "preview",
113
+ "Renders a Playbook's whole composition tree as text, with no side effects.",
114
+ "read",
115
+ { id: stringProp, name: stringProp, arguments: { type: "object" } },
116
+ [],
117
+ (input) => {
118
+ normalizeJsonEncodedField(input, "arguments");
119
+ return { ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) };
120
+ },
121
+ );
122
+
123
+ define(
124
+ "invoke",
125
+ "Compiles the Playbook's steps and composition tree into real Tasks wired with dependsOn, and focuses the first one -- one step surfaces at a time as it becomes focused, exactly like any other Task. `arguments` supplies known values as {name: value}; if a declared REQUIRED argument is still missing, nothing is created and missingArguments is returned instead -- ask the human for these (discuss tool, live:true) and invoke again, never guess. Drive the returned entryTaskId forward with the tasks tool (start/submit/complete).",
126
+ "local-write",
127
+ { id: stringProp, name: stringProp, run_id: stringProp, arguments: { type: "object" }, project_root: stringProp },
128
+ [],
129
+ (input) => {
130
+ normalizeJsonEncodedField(input, "arguments");
131
+ return { ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) };
132
+ },
133
+ (input, context) => {
134
+ const claims = context.principal?.claims as { sessionId?: string; sessionSecret?: string } | undefined;
135
+ const invocation = call("playbooks.invoke", {
136
+ ...input,
137
+ session_id: claims?.sessionId,
138
+ session_secret: claims?.sessionSecret,
139
+ }) as PlaybookInvocationResult | PlaybookMissingArguments;
140
+ if ("missingArguments" in invocation) {
141
+ const text = `Missing required argument(s): ${invocation.missingArguments.join(", ")}. Nothing was created -- ask the human for these (discuss tool, live:true), then invoke again.`;
142
+ return { ...invocation, content: [{ type: "text" as const, text }] };
143
+ }
144
+ const nodeById = new Map(invocation.execution.nodes.map((node) => [node.id, node]));
145
+ const entryLabel = nodeById.get(invocation.entryTaskId)?.title ?? invocation.entryTaskId;
146
+ const content = buildWorkflowRunContent(
147
+ artifacts,
148
+ `Invoked playbook run ${invocation.runId}: ${invocation.created.tasks.length} task(s), ${invocation.created.rules.length} rule(s), ${invocation.created.docs.length} doc(s) created.`,
149
+ invocation,
150
+ [`Entry task now focused: ${entryLabel}. Drive it forward with the tasks tool (start/submit/complete) -- contains/depends_on wiring auto-focuses each next step.`],
151
+ );
152
+ return { ...invocation, content: [content] };
153
+ },
154
+ );
155
+
156
+ define(
157
+ "enable",
158
+ "Enables a Playbook.",
159
+ "local-write",
160
+ { id: stringProp, name: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
161
+ [],
162
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
163
+ );
164
+
165
+ define(
166
+ "disable",
167
+ "Disables a Playbook.",
168
+ "local-write",
169
+ { id: stringProp, name: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
170
+ [],
171
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
172
+ );
173
+
174
+ define(
175
+ "assign_project",
176
+ "Reassigns a Playbook's project_root, or unscopes it when project_root is omitted.",
177
+ "local-write",
178
+ { id: stringProp, name: stringProp, project_root: stringProp },
179
+ [],
180
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
181
+ );
182
+
183
+ define(
184
+ "update",
185
+ "Changes a Playbook's title/body/labels (at least one required). Refused for a read-only external projection.",
186
+ "local-write",
187
+ { id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: { type: "array" }, actor: stringProp, source: stringProp, session_id: stringProp },
188
+ [],
189
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
190
+ );
191
+
192
+ define(
193
+ "contain",
194
+ "Nests a child Playbook inside a parent -- the child's steps run AFTER the parent's own. Prefer parent_name/child_name over parent_id/child_id -- resolved server-side.",
195
+ "local-write",
196
+ { parent_id: stringProp, parent_name: stringProp, child_id: stringProp, child_name: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
197
+ [],
198
+ (input) => ({
199
+ ...input,
200
+ parent_id: resolvePlaybookId(artifacts, artifactScopes, input.parent_id, input.parent_name),
201
+ child_id: resolvePlaybookId(artifacts, artifactScopes, input.child_id, input.child_name),
202
+ }),
203
+ );
204
+
205
+ define(
206
+ "uncontain",
207
+ "Removes a parent/child Playbook nesting. Idempotent -- a no-op if the edge is already absent.",
208
+ "local-write",
209
+ { parent_id: stringProp, parent_name: stringProp, child_id: stringProp, child_name: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
210
+ [],
211
+ (input) => ({
212
+ ...input,
213
+ parent_id: resolvePlaybookId(artifacts, artifactScopes, input.parent_id, input.parent_name),
214
+ child_id: resolvePlaybookId(artifacts, artifactScopes, input.child_id, input.child_name),
215
+ }),
216
+ );
217
+
218
+ define(
219
+ "depend",
220
+ "Chains a prerequisite Playbook before another -- it must fully complete FIRST. Prefer dependency_name over dependency_id.",
221
+ "local-write",
222
+ { id: stringProp, name: stringProp, dependency_id: stringProp, dependency_name: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
223
+ [],
224
+ (input) => ({
225
+ ...input,
226
+ id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name),
227
+ dependency_id: resolvePlaybookId(artifacts, artifactScopes, input.dependency_id, input.dependency_name),
228
+ }),
229
+ );
230
+
231
+ define(
232
+ "undepend",
233
+ "Removes a Playbook dependency. Idempotent -- a no-op if the edge is already absent.",
234
+ "local-write",
235
+ { id: stringProp, name: stringProp, dependency_id: stringProp, dependency_name: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
236
+ [],
237
+ (input) => ({
238
+ ...input,
239
+ id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name),
240
+ dependency_id: resolvePlaybookId(artifacts, artifactScopes, input.dependency_id, input.dependency_name),
241
+ }),
242
+ );
243
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Rules projected as a real VehicleRegistry: one VehicleOperation per real action.
3
+ * Wraps modules/rules.ts's operation definitions (rules.injectable stays a
4
+ * composition-root-only concern, absent here too). remove/restore/remove_subtree
5
+ * are not duplicated here -- see ./artifact-trash-vehicle.ts.
6
+ */
7
+ import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
8
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
9
+ import { listRules } from "../domain-services.ts";
10
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
11
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
12
+ import { rulesOperations } from "../modules/rules.ts";
13
+ import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
14
+
15
+ const OWNER = "rules";
16
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
17
+
18
+ /** Resolves a rule's id from either an explicit id or its title, widened past project_root when unscoped-vs-scoped search finds nothing. */
19
+ function resolveRuleId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined, id: unknown, name: unknown): string {
20
+ if (typeof id === "string" && id.length > 0) return id;
21
+ if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
22
+ return resolveArtifactIdWidened(
23
+ name,
24
+ () => listRules(artifacts, scopes, { text: name, projectRoot }),
25
+ projectRoot === undefined ? undefined : () => listRules(artifacts, scopes, { text: name }),
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Resolves a task's id from its title for rules.gate. No ambient cwd to default
31
+ * project_root to server-side -- pass project_root explicitly, or this searches
32
+ * unscoped.
33
+ */
34
+ function resolveTaskId(artifacts: ArtifactStore, projectRoot: string | undefined, id: unknown, name: unknown): string | undefined {
35
+ if (typeof id === "string" && id.length > 0) return id;
36
+ if (typeof name !== "string" || name.length === 0) return undefined;
37
+ return resolveArtifactIdWidened(
38
+ name,
39
+ () => artifacts.query({ kind: "task", text: name }),
40
+ );
41
+ }
42
+
43
+ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifacts: ArtifactStore, scopes: ArtifactScopeStore): void {
44
+ const moduleOperations = new Map(rulesOperations(artifacts, scopes).map((op) => [op.name, op]));
45
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
46
+
47
+ const define = (
48
+ action: string,
49
+ description: string,
50
+ effect: "read" | "local-write",
51
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
52
+ required: readonly string[],
53
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
54
+ ): void => {
55
+ const operation = defineVehicleOperation({
56
+ name: `rules.${action}`,
57
+ version: 1,
58
+ description,
59
+ input: looseObjectSchema(properties, required),
60
+ output: passthroughOutput,
61
+ permissions: ["rules:read", "rules:write"],
62
+ effect,
63
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
64
+ limits: LIMITS,
65
+ });
66
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`rules.${action}`, resolve(context.input))));
67
+ };
68
+
69
+ define(
70
+ "create",
71
+ "Creates a Rule -- a standing constraint injected into the agent system prompt while active. project_root is optional (omitted = unscoped).",
72
+ "local-write",
73
+ { title: stringProp, body: stringProp, condition: stringProp, rule_action: stringProp, severity: { type: "string", enum: ["block", "warn", "info"] }, labels: { type: "array" } as unknown as { type: string }, extra: { type: "object" } as unknown as { type: string }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
74
+ ["title"],
75
+ (input) => input,
76
+ );
77
+
78
+ define(
79
+ "list",
80
+ "Lists Rules matching an optional status/text filter, scoped to project_root when given.",
81
+ "read",
82
+ { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp },
83
+ [],
84
+ (input) => input,
85
+ );
86
+
87
+ define(
88
+ "show",
89
+ "Shows one Rule by id or title.",
90
+ "read",
91
+ { id: stringProp, name: stringProp, project_root: stringProp },
92
+ [],
93
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
94
+ );
95
+
96
+ define(
97
+ "preview",
98
+ "Renders a Rule's own condition/action/body preview text with no side effects.",
99
+ "read",
100
+ { id: stringProp, name: stringProp, project_root: stringProp },
101
+ [],
102
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
103
+ );
104
+
105
+ define(
106
+ "enable",
107
+ "Enables a Rule so it starts injecting into the agent system prompt.",
108
+ "local-write",
109
+ { id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
110
+ [],
111
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
112
+ );
113
+
114
+ define(
115
+ "disable",
116
+ "Disables a Rule; it stops injecting into the agent system prompt.",
117
+ "local-write",
118
+ { id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
119
+ [],
120
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
121
+ );
122
+
123
+ define(
124
+ "gate",
125
+ "Attaches a Rule as a gate condition on a Task. Prefer task_name over task_id -- resolved server-side (unscoped if project_root is omitted, since there is no ambient cwd to default to here).",
126
+ "local-write",
127
+ { id: stringProp, name: stringProp, task_id: stringProp, task_name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
128
+ [],
129
+ (input) => {
130
+ const taskId = resolveTaskId(artifacts, input.project_root as string | undefined, input.task_id, input.task_name);
131
+ if (!taskId) throw new Error("task_id or task_name is required");
132
+ return { ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name), task_id: taskId };
133
+ },
134
+ );
135
+
136
+ define(
137
+ "assign_project",
138
+ "Reassigns a Rule's project_root, or unscopes it when project_root is omitted.",
139
+ "local-write",
140
+ { id: stringProp, name: stringProp, project_root: stringProp },
141
+ [],
142
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, undefined, input.id, input.name) }),
143
+ );
144
+
145
+ define(
146
+ "update",
147
+ "Changes a Rule's title/body/labels (at least one required). Body updates still enforce the same combined condition+action+body context-tax bound as creation.",
148
+ "local-write",
149
+ { id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: { type: "array" } as unknown as { type: string }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
150
+ [],
151
+ (input) => ({ ...input, id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
152
+ );
153
+ }