@danypops/papyrus 0.39.0 → 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.
package/README.md CHANGED
@@ -80,7 +80,7 @@ papyrus skills run <skill-id> \
80
80
  --json
81
81
  ```
82
82
 
83
- The existing `artifact-template` skill subtype remains a compatibility mechanism for one-artifact templates with metadata `{targetKind, defaults, required}`. Instantiate it through the `skills` tool's `instantiate` action with `template_id`; defaults merge recursively, explicit arrays replace defaults, required paths such as `extra.owner` are validated, and target-kind mismatches are rejected.
83
+ The existing `artifact-template` skill subtype remains a compatibility mechanism for one-artifact templates with metadata `{targetKind, defaults, required}`. Instantiate it via the `skills.instantiate` operation with `template_id`; defaults merge recursively, explicit arrays replace defaults, required paths such as `extra.owner` are validated, and target-kind mismatches are rejected.
84
84
 
85
85
  ### Removing an artifact
86
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "files": ["src", "README.md"],
36
36
  "dependencies": {
37
- "@danypops/vehicle-core": "^0.1.1",
37
+ "@danypops/vehicle-core": "^0.2.0",
38
38
  "@danypops/vehicle-client": "^0.1.1",
39
39
  "@danypops/vehicle-server": "^0.3.2"
40
40
  }
@@ -17,12 +17,15 @@
17
17
  * extraction.
18
18
  */
19
19
  import type { AuthorityRegistry } from "../authority-registry.ts";
20
- import { assignSkillProject, createArtifactTemplate, createSkill, listSkills, showSkill, skillInvocation, transitionSkill, updateSkill } from "../domain-services.ts";
20
+ import type { Artifact, CreateArtifactInput } from "../domain/artifact.ts";
21
+ import type { ArtifactEventContext } from "../domain/artifact-event.ts";
22
+ import { assignSkillProject, createArtifactTemplate, createSkill, instantiateTemplate, listSkills, showSkill, skillInvocation, transitionSkill, updateSkill } from "../domain-services.ts";
21
23
  import type { OperationDefinition } from "../module-registry.ts";
22
24
  import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
23
25
  import type { ArtifactStore } from "../ports/artifact-store.ts";
24
26
  import type { TaskEventStore } from "../ports/task-event-store.ts";
25
27
  import type { TaskScopeStore } from "../ports/task-scope-store.ts";
28
+ import type { Tasks, TaskStatus } from "../task-service.ts";
26
29
  import { instantiateSkillWorkflow } from "../workflow-execution.ts";
27
30
 
28
31
  const MODULE_ID = "skills";
@@ -76,6 +79,45 @@ export interface SkillsModuleDeps {
76
79
  authority: AuthorityRegistry;
77
80
  }
78
81
 
82
+ function normalizeCreateInput(input: OperationInput): CreateArtifactInput {
83
+ const { template_id, ...rest } = input;
84
+ return { ...rest, templateId: typeof template_id === "string" ? template_id : undefined } as CreateArtifactInput;
85
+ }
86
+
87
+ /**
88
+ * skills.instantiate's own branching logic (compatibility-template creation vs. a
89
+ * task-target template's tasks.create() call) -- shared between service.ts's raw RPC
90
+ * forwarder and skills-vehicle.ts's Vehicle operation, the two real callers, instead
91
+ * of reimplemented in each. Takes `tasks: Tasks` directly rather than through
92
+ * SkillsModuleDeps: a genuine cross-module dependency, the same category as
93
+ * rules.injectable and the module comment's own reason skills.instantiate isn't
94
+ * registered as an operation here.
95
+ */
96
+ export interface InstantiateSkillDeps {
97
+ artifacts: ArtifactStore;
98
+ tasks: Tasks;
99
+ authority: AuthorityRegistry;
100
+ }
101
+
102
+ export function instantiateSkillOrTemplate(deps: InstantiateSkillDeps, input: OperationInput, context?: ArtifactEventContext): Artifact {
103
+ const templateId = string(input, "template_id");
104
+ const template = deps.artifacts.get(templateId);
105
+ // Note ownership for a non-task template target is enforced inside instantiateTemplate's
106
+ // own rejectsNoteTemplate for the non-task branch below -- nothing else currently claims
107
+ // an unresolved (pre-template-resolution) kind, so there is no check to perform here.
108
+ if (template?.extra["targetKind"] !== "task") return instantiateTemplate(deps.artifacts, templateId, normalizeCreateInput(input), deps.authority, context);
109
+ return deps.tasks.create({
110
+ title: optionalString(input, "title") as string,
111
+ body: optionalString(input, "body"),
112
+ status: optionalString(input, "status") as TaskStatus | undefined,
113
+ labels: input["labels"] as string[] | undefined,
114
+ extra: input["extra"] as Record<string, unknown> | undefined,
115
+ templateId,
116
+ projectRoot: string(input, "project_root"),
117
+ projectSource: "cwd",
118
+ }, context);
119
+ }
120
+
79
121
  /** Registers every skills.* operation except skills.instantiate (see module comment). Behavior is unchanged from the prior inline handlers in src/service.ts. */
80
122
  /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. skills.instantiate is deliberately absent -- see the module comment above. */
81
123
  export const SKILLS_OPERATION_NAMES = [
package/src/service.ts CHANGED
@@ -24,7 +24,6 @@ import type { TaskEventStore } from "./ports/task-event-store.ts";
24
24
  import type { TaskScopeStore } from "./ports/task-scope-store.ts";
25
25
  import { Tasks, type TaskStatus } from "./task-service.ts";
26
26
  import {
27
- instantiateTemplate,
28
27
  listInjectableRules,
29
28
  } from "./domain-services.ts";
30
29
  import { Notes, NOTE_SUBTYPE } from "./note-service.ts";
@@ -40,7 +39,7 @@ import { graphProjectionOperations, GRAPH_PROJECTION_OPERATION_NAMES } from "./m
40
39
  import { logsOperations, LOGS_OPERATION_NAMES } from "./modules/logs.ts";
41
40
  import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
42
41
  import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
43
- import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
42
+ import { instantiateSkillOrTemplate, skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
44
43
  import { playbooksOperations, PLAYBOOKS_OPERATION_NAMES } from "./modules/playbooks.ts";
45
44
  import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
46
45
  import { discussOperations, DISCUSS_OPERATION_NAMES } from "./modules/discuss.ts";
@@ -432,24 +431,7 @@ function handlers(
432
431
  "playbooks.uncontain": forwardToModule("playbooks.uncontain"),
433
432
  "playbooks.depend": forwardToModule("playbooks.depend"),
434
433
  "playbooks.undepend": forwardToModule("playbooks.undepend"),
435
- "skills.instantiate": (input) => {
436
- const templateId = string(input, "template_id");
437
- const template = artifacts.get(templateId);
438
- // Note ownership for a non-task template target is enforced inside instantiateTemplate's
439
- // own rejectsNoteTemplate for the non-task branch below -- nothing else currently claims
440
- // an unresolved (pre-template-resolution) kind, so there is no check to perform here.
441
- if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input), authority, eventContext(input));
442
- return tasks.create({
443
- title: optionalString(input, "title") as string,
444
- body: optionalString(input, "body"),
445
- status: optionalString(input, "status") as TaskStatus | undefined,
446
- labels: input["labels"] as string[] | undefined,
447
- extra: input["extra"] as Record<string, unknown> | undefined,
448
- templateId,
449
- projectRoot: string(input, "project_root"),
450
- projectSource: "cwd",
451
- }, eventContextFor(input, "template-instantiation"));
452
- },
434
+ "skills.instantiate": (input) => instantiateSkillOrTemplate({ artifacts, tasks, authority }, input, eventContextFor(input, "template-instantiation")),
453
435
  "graph_projection.apply": forwardToModule("graph_projection.apply"),
454
436
  "graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
455
437
  "logs.append": forwardToModule("logs.append"),
@@ -486,7 +468,7 @@ export function createPapyrusService(path: string): PapyrusService {
486
468
  const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
487
469
  const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
488
470
  const authority = createAuthorityRegistry();
489
- const vehicle = createPapyrusVehicleRegistry({ artifacts, scopes: artifactScopes, authority, notes });
471
+ const vehicle = createPapyrusVehicleRegistry({ artifacts, scopes: artifactScopes, authority, notes, events, taskScopes: scopes, tasks, sessionIdentity });
490
472
  const moduleRegistry = new OperationRegistry();
491
473
  moduleRegistry.registerAll(notesOperations(notes));
492
474
  moduleRegistry.registerAll(logsOperations(logs));
@@ -3,8 +3,10 @@
3
3
  * VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
4
4
  * artifact-trash-vehicle.ts).
5
5
  */
6
- import { defineVehicleSchema, type VehicleSchemaCodec } from "@danypops/vehicle-core";
6
+ import { defineVehicleSchema, type VehicleSchemaCodec, type VehicleContentBlock } from "@danypops/vehicle-core";
7
7
  import type { Artifact } from "../domain/artifact.ts";
8
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
9
+ import type { TaskExecutionPlan } from "../task-execution.ts";
8
10
 
9
11
  /**
10
12
  * VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
@@ -42,6 +44,17 @@ export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchem
42
44
  export const stringProp = { type: "string" } as const;
43
45
  export const numberProp = { type: "number" } as const;
44
46
 
47
+ /** A known LLM tool-calling quirk: a nested-object field arrives JSON-stringified rather than as a real object. Mutates input[key] in place when it's a string, leaves it untouched otherwise. */
48
+ export function normalizeJsonEncodedField(input: Record<string, unknown>, key: string): void {
49
+ const value = input[key];
50
+ if (typeof value !== "string") return;
51
+ try {
52
+ input[key] = JSON.parse(value);
53
+ } catch {
54
+ throw new Error(`${key} must be valid JSON`);
55
+ }
56
+ }
57
+
45
58
  /** Exact match semantics as the Pi-extension helper this replaces (domain-tools.ts's matchArtifactByName) -- case-insensitive exact title match, refuses to guess between ambiguous matches. */
46
59
  export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
47
60
  const needle = name.trim().toLowerCase();
@@ -67,3 +80,47 @@ export function resolveArtifactIdWidened(name: string, fetchCandidates: () => re
67
80
  return matchArtifactByName(fetchWidened(), name);
68
81
  }
69
82
  }
83
+
84
+ /** Synchronous equivalent of pi-papyrus's own artifactLabelsById -- server-side, a direct ArtifactStore.get() replaces the extra RPC round-trip that helper needed client-side. Disambiguates same-titled artifacts by appending their id. */
85
+ export function labelsById(artifacts: ArtifactStore, ids: readonly string[]): Map<string, string> {
86
+ const uniqueIds = [...new Set(ids)];
87
+ const resolved = uniqueIds.map((id) => artifacts.get(id)).filter((artifact): artifact is Artifact => artifact !== null);
88
+ const titleCounts = new Map<string, number>();
89
+ for (const artifact of resolved) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
90
+ return new Map(resolved.map((artifact) => [artifact.id, (titleCounts.get(artifact.title) ?? 0) > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
91
+ }
92
+
93
+ export interface WorkflowRunNarrativeInput {
94
+ runId: string;
95
+ created: { docs: readonly string[]; rules: readonly string[]; tasks: readonly string[] };
96
+ rootTaskIds: readonly string[];
97
+ execution: TaskExecutionPlan;
98
+ }
99
+
100
+ /**
101
+ * Shared between skills.run and playbooks.invoke's Vehicle operations -- both produce the
102
+ * same shaped narrative (ready roots, context docs, scoped rules, an execution tree), only
103
+ * the headline and whether an "entry task focused" line is present differ. Builds the model-
104
+ * facing `content` text directly, so the model reads a summary instead of the raw execution
105
+ * DAG -- the same shape pi-papyrus's own hand-rolled skills/playbooks tools built client-side,
106
+ * now built once here where the run result is actually produced.
107
+ */
108
+ export function buildWorkflowRunContent(artifacts: ArtifactStore, headline: string, input: WorkflowRunNarrativeInput, extraLines: readonly string[] = []): VehicleContentBlock {
109
+ const nodeById = new Map(input.execution.nodes.map((node) => [node.id, node]));
110
+ const rootLabels = input.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
111
+ const createdLabels = labelsById(artifacts, [...input.created.docs, ...input.created.rules]);
112
+ const titleCounts = new Map<string, number>();
113
+ for (const node of input.execution.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
114
+ const executionLines = input.execution.nodes
115
+ .map((node) => ((titleCounts.get(node.title) ?? 0) > 1 ? ` [${node.state}] ${node.title} (${node.id})` : ` [${node.state}] ${node.title}`))
116
+ .join("\n");
117
+ const text = [
118
+ headline,
119
+ ...extraLines,
120
+ `Ready roots: ${rootLabels.join(", ") || "none"}.`,
121
+ `Context docs: ${input.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
122
+ `Scoped rules: ${input.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
123
+ ...(executionLines ? ["Execution:", executionLines] : []),
124
+ ].join("\n");
125
+ return { type: "text", text };
126
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Composition root for every domain projected onto Vehicle -- one VehicleRegistry,
3
3
  * one HTTP mount (see service.ts's createApp). Operation names are already globally
4
- * unique via their own dotted prefix (notes.*, rules.*, docs.*, artifact.*), so
5
- * merging costs nothing and avoids a separate registry/mount/client per domain.
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.
6
7
  *
7
- * skills, playbooks, discuss, and tasks still register via pi-papyrus's own
8
- * pi.registerTool() in domain-tools.ts, not here.
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.
9
10
  */
10
11
  import { VehicleRegistry } from "@danypops/vehicle-server";
11
12
  import type { AuthorityRegistry } from "../authority-registry.ts";
@@ -13,16 +14,26 @@ import type { Notes } from "../note-service.ts";
13
14
  import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
14
15
  import type { ArtifactStore } from "../ports/artifact-store.ts";
15
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";
16
21
  import { registerArtifactTrashOperations } from "./artifact-trash-vehicle.ts";
17
22
  import { registerDocsVehicleOperations } from "./docs-vehicle.ts";
18
23
  import { registerNotesVehicleOperations } from "./notes-vehicle.ts";
24
+ import { registerPlaybooksVehicleOperations } from "./playbooks-vehicle.ts";
19
25
  import { registerRulesVehicleOperations } from "./rules-vehicle.ts";
26
+ import { registerSkillsVehicleOperations } from "./skills-vehicle.ts";
20
27
 
21
28
  export interface PapyrusVehicleDeps {
22
29
  artifacts: ArtifactStore & ArtifactTrashStore;
23
30
  scopes: ArtifactScopeStore;
24
31
  authority: AuthorityRegistry;
25
32
  notes: Notes;
33
+ events: TaskEventStore;
34
+ taskScopes: TaskScopeStore;
35
+ tasks: Tasks;
36
+ sessionIdentity: SessionIdentity;
26
37
  }
27
38
 
28
39
  export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleRegistry {
@@ -30,6 +41,8 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
30
41
  registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
31
42
  registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes);
32
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 });
33
46
  registerArtifactTrashOperations(registry, deps.artifacts);
34
47
  return registry;
35
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,194 @@
1
+ /**
2
+ * Skills projected as a real VehicleRegistry: one VehicleOperation per real action.
3
+ * Wraps modules/skills.ts's operation definitions plus skills.instantiate (composition-
4
+ * root-only in the module -- see instantiateSkillOrTemplate's own doc comment).
5
+ * remove/restore/remove_subtree are not duplicated here -- see ./artifact-trash-vehicle.ts.
6
+ *
7
+ * skills.run's output carries its own `content` block (see @danypops/vehicle-core's
8
+ * WithVehicleContent) built from the same execution-DAG summary pi-papyrus's hand-rolled
9
+ * tool used to build client-side -- the model reads a summary, not the raw node/layer/
10
+ * cycleId structure.
11
+ */
12
+ import { bindVehicleOperation, defineVehicleOperation } from "@danypops/vehicle-core";
13
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
14
+ import type { AuthorityRegistry } from "../authority-registry.ts";
15
+ import { listSkills } from "../domain-services.ts";
16
+ import { instantiateSkillOrTemplate, skillsOperations } from "../modules/skills.ts";
17
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
18
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
19
+ import type { TaskEventStore } from "../ports/task-event-store.ts";
20
+ import type { TaskScopeStore } from "../ports/task-scope-store.ts";
21
+ import type { Tasks } from "../task-service.ts";
22
+ import type { WorkflowRunResult } from "../workflow-execution.ts";
23
+ import { buildWorkflowRunContent, looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
24
+
25
+ const OWNER = "skills";
26
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
27
+
28
+ export interface SkillsVehicleDeps {
29
+ artifacts: ArtifactStore;
30
+ events: TaskEventStore;
31
+ scopes: TaskScopeStore;
32
+ artifactScopes: ArtifactScopeStore;
33
+ authority: AuthorityRegistry;
34
+ /** Only for skills.instantiate's task-target branch -- see instantiateSkillOrTemplate. */
35
+ tasks: Tasks;
36
+ }
37
+
38
+ function resolveSkillId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined, id: unknown, name: unknown): string {
39
+ if (typeof id === "string" && id.length > 0) return id;
40
+ if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
41
+ return resolveArtifactIdWidened(
42
+ name,
43
+ () => listSkills(artifacts, scopes, { text: name, projectRoot }),
44
+ projectRoot === undefined ? undefined : () => listSkills(artifacts, scopes, { text: name }),
45
+ );
46
+ }
47
+
48
+ function resolveTemplateId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined, id: unknown, name: unknown): string | undefined {
49
+ if (typeof id === "string" && id.length > 0) return id;
50
+ if (typeof name !== "string" || name.length === 0) return undefined;
51
+ return resolveArtifactIdWidened(
52
+ name,
53
+ () => listSkills(artifacts, scopes, { text: name, projectRoot }),
54
+ projectRoot === undefined ? undefined : () => listSkills(artifacts, scopes, { text: name }),
55
+ );
56
+ }
57
+
58
+ export function registerSkillsVehicleOperations(registry: VehicleRegistry, deps: SkillsVehicleDeps): void {
59
+ const { artifacts, events, scopes, artifactScopes, authority, tasks } = deps;
60
+ const moduleOperations = new Map(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }).map((op) => [op.name, op]));
61
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
62
+
63
+ const define = (
64
+ action: string,
65
+ description: string,
66
+ effect: "read" | "local-write",
67
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
68
+ required: readonly string[],
69
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
70
+ execute?: (input: Record<string, unknown>) => unknown,
71
+ ): void => {
72
+ const operation = defineVehicleOperation({
73
+ name: `skills.${action}`,
74
+ version: 1,
75
+ description,
76
+ input: looseObjectSchema(properties, required),
77
+ output: passthroughOutput,
78
+ permissions: ["skills:read", "skills:write"],
79
+ effect,
80
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
81
+ limits: LIMITS,
82
+ });
83
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => (execute ?? ((input: Record<string, unknown>) => call(`skills.${action}`, input)))(resolve(context.input))));
84
+ };
85
+
86
+ define(
87
+ "create",
88
+ "Creates a Skill -- a parameterized Task/Rule/Doc bundle, distinct from a prompt-only skill. project_root is optional (omitted = unscoped).",
89
+ "local-write",
90
+ { title: stringProp, body: stringProp, trigger: stringProp, steps: { type: "array" }, tools: { type: "array" }, definition: { type: "object" }, labels: { type: "array" }, extra: { type: "object" }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
91
+ ["title"],
92
+ (input) => input,
93
+ );
94
+
95
+ define(
96
+ "create_template",
97
+ "Creates a compatibility artifact-template (defaults/required fields for a target kind), distinct from a workflow Skill.",
98
+ "local-write",
99
+ { title: stringProp, target_kind: stringProp, defaults: { type: "object" }, required: { type: "array" }, body: stringProp, labels: { type: "array" }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
100
+ ["title", "target_kind"],
101
+ (input) => input,
102
+ );
103
+
104
+ define(
105
+ "list",
106
+ "Lists Skills matching an optional status/text filter, scoped to project_root when given.",
107
+ "read",
108
+ { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp },
109
+ [],
110
+ (input) => input,
111
+ );
112
+
113
+ define(
114
+ "show",
115
+ "Shows one Skill by id or title.",
116
+ "read",
117
+ { id: stringProp, name: stringProp, project_root: stringProp },
118
+ [],
119
+ (input) => ({ ...input, id: resolveSkillId(artifacts, artifactScopes, input.project_root as string | undefined, input.id, input.name) }),
120
+ );
121
+
122
+ define(
123
+ "invoke",
124
+ "Renders a Skill's own preview text with no side effects.",
125
+ "read",
126
+ { id: stringProp, name: stringProp, project_root: stringProp },
127
+ [],
128
+ (input) => ({ ...input, id: resolveSkillId(artifacts, artifactScopes, input.project_root as string | undefined, input.id, input.name) }),
129
+ );
130
+
131
+ define(
132
+ "run",
133
+ "Validates arguments and atomically creates one scoped workflow run: real Tasks/Rules/Docs wired with dependsOn, one step surfacing at a time as it becomes focused -- no text dump. project_root is required here (no ambient cwd server-side); pass it explicitly.",
134
+ "local-write",
135
+ { id: stringProp, name: stringProp, run_id: stringProp, arguments: { type: "object" }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
136
+ ["project_root"],
137
+ (input) => ({ ...input, id: resolveSkillId(artifacts, artifactScopes, input.project_root as string | undefined, input.id, input.name) }),
138
+ (input) => {
139
+ const run = call("skills.run", input) as WorkflowRunResult;
140
+ const content = buildWorkflowRunContent(artifacts, `Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`, run);
141
+ return { ...run, content: [content] };
142
+ },
143
+ );
144
+
145
+ define(
146
+ "enable",
147
+ "Enables a Skill.",
148
+ "local-write",
149
+ { id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
150
+ [],
151
+ (input) => ({ ...input, id: resolveSkillId(artifacts, artifactScopes, input.project_root as string | undefined, input.id, input.name) }),
152
+ );
153
+
154
+ define(
155
+ "disable",
156
+ "Disables a Skill.",
157
+ "local-write",
158
+ { id: stringProp, name: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
159
+ [],
160
+ (input) => ({ ...input, id: resolveSkillId(artifacts, artifactScopes, input.project_root as string | undefined, input.id, input.name) }),
161
+ );
162
+
163
+ define(
164
+ "instantiate",
165
+ "Instantiates a compatibility artifact-template (template_id/template_name) -- a task-target template calls tasks.create() directly; any other target creates a plain artifact. project_root is required here (no ambient cwd server-side).",
166
+ "local-write",
167
+ { template_id: stringProp, template_name: stringProp, title: stringProp, body: stringProp, status: stringProp, labels: { type: "array" }, extra: { type: "object" }, subtype: stringProp, kind: stringProp, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
168
+ ["title", "project_root"],
169
+ (input) => {
170
+ const templateId = resolveTemplateId(artifacts, artifactScopes, input.project_root as string | undefined, input.template_id, input.template_name);
171
+ if (!templateId) throw new Error("template_id or template_name is required");
172
+ return { ...input, template_id: templateId };
173
+ },
174
+ (input) => instantiateSkillOrTemplate({ artifacts, tasks, authority }, input, { actor: input.actor as string | undefined, source: input.source as string | undefined, sessionId: (input.session_id ?? input.sessionId) as string | undefined }),
175
+ );
176
+
177
+ define(
178
+ "assign_project",
179
+ "Reassigns a Skill's project_root, or unscopes it when project_root is omitted.",
180
+ "local-write",
181
+ { id: stringProp, name: stringProp, project_root: stringProp },
182
+ [],
183
+ (input) => ({ ...input, id: resolveSkillId(artifacts, artifactScopes, undefined, input.id, input.name) }),
184
+ );
185
+
186
+ define(
187
+ "update",
188
+ "Changes a Skill's title/body/labels (at least one required). Refused for a read-only external projection.",
189
+ "local-write",
190
+ { id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: { type: "array" }, project_root: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
191
+ [],
192
+ (input) => ({ ...input, id: resolveSkillId(artifacts, artifactScopes, input.project_root as string | undefined, input.id, input.name) }),
193
+ );
194
+ }