@danypops/papyrus 0.39.0 → 0.41.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,11 +80,11 @@ 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
 
87
- Artifacts are never hard-deleted on request: every artifact gets a permanent, immutable `created` row in the mutation event log the moment it exists, so removal is a real, time-gated trash rather than a status flip. `remove` (any of the `tasks`/`docs`/`rules`/`skills` domain tools, or `papyrus artifact remove <id> [--reason <text>]`) moves an artifact to the trash: it is immediately excluded from every list/query, still directly reachable by id, and fully recoverable via `restore` until its purge deadline (30 days later) passes. `remove` refuses a Task that is the live Task Focus in any scope.
87
+ Artifacts are never hard-deleted on request: every artifact gets a permanent, immutable `created` row in the mutation event log the moment it exists, so removal is a real, time-gated trash rather than a status flip. `remove` (the shared `artifact.remove`/`artifact.remove_subtree` operations every agent-facing domain routes through, or `papyrus artifact remove <id> [--reason <text>]`) moves an artifact to the trash: it is immediately excluded from every list/query, still directly reachable by id, and fully recoverable via `restore` until its purge deadline (30 days later) passes. `remove` refuses a Task that is the live Task Focus in any scope.
88
88
 
89
89
  Once the deadline passes, the daemon's periodic sweep performs a real, cascading, irreversible deletion — the one deliberate, narrow exception to Papyrus's otherwise-absolute append-only history, enforced by the database itself (not merely application code) via a trigger condition checked at delete time.
90
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.39.0",
3
+ "version": "0.41.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 still registers via pi-papyrus's own pi.registerTool() in domain-tools.ts,
9
+ * 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,27 @@ 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";
27
+ import { registerTasksVehicleOperations } from "./tasks-vehicle.ts";
20
28
 
21
29
  export interface PapyrusVehicleDeps {
22
30
  artifacts: ArtifactStore & ArtifactTrashStore;
23
31
  scopes: ArtifactScopeStore;
24
32
  authority: AuthorityRegistry;
25
33
  notes: Notes;
34
+ events: TaskEventStore;
35
+ taskScopes: TaskScopeStore;
36
+ tasks: Tasks;
37
+ sessionIdentity: SessionIdentity;
26
38
  }
27
39
 
28
40
  export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleRegistry {
@@ -30,6 +42,9 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
30
42
  registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
31
43
  registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes);
32
44
  registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority);
45
+ registerSkillsVehicleOperations(registry, { artifacts: deps.artifacts, events: deps.events, scopes: deps.taskScopes, artifactScopes: deps.scopes, authority: deps.authority, tasks: deps.tasks });
46
+ registerPlaybooksVehicleOperations(registry, { artifacts: deps.artifacts, events: deps.events, scopes: deps.taskScopes, artifactScopes: deps.scopes, tasks: deps.tasks, sessionIdentity: deps.sessionIdentity });
47
+ registerTasksVehicleOperations(registry, { tasks: deps.tasks, artifacts: deps.artifacts, sessionIdentity: deps.sessionIdentity });
33
48
  registerArtifactTrashOperations(registry, deps.artifacts);
34
49
  return registry;
35
50
  }
@@ -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
+ }
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Tasks projected as a real VehicleRegistry: one VehicleOperation per real action.
3
+ * Wraps modules/tasks.ts's operation definitions -- the largest domain (37 actions),
4
+ * already fully extracted server-side.
5
+ *
6
+ * tasks.focus/pause/unpause/clear_focus keep two things the raw RPC tool used to
7
+ * handle client-side, since neither is expressible inside a stateless Vehicle
8
+ * operation's own input/output contract:
9
+ *
10
+ * - session_secret authorizes which session's Task Focus row gets mutated
11
+ * (modules/tasks.ts's own guardFocusMutation). It must never be a model-visible
12
+ * input field -- it travels through VehicleInvocationOptions.principal.claims,
13
+ * the same mechanism playbooks.invoke uses (see vehicle-notes-client.ts).
14
+ * - papyrus.task-focus.v1 is a same-process Pi extension event bus broadcast (a
15
+ * token-cost router or similar can correlate its own telemetry with the
16
+ * currently focused task) with no Vehicle-transport equivalent -- fired from
17
+ * pi-papyrus's own onInvoked hook (see vehicle-client-pi's registerVehicleTools),
18
+ * not from this module, since a remote HTTP Vehicle consumer has no such bus.
19
+ *
20
+ * remove/remove_subtree/restore are not duplicated here -- see ./artifact-trash-vehicle.ts.
21
+ */
22
+ import { bindVehicleOperation, defineVehicleOperation, type VehicleOperationContext } from "@danypops/vehicle-core";
23
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
24
+ import type { TaskExecutionPlan } from "../task-execution.ts";
25
+ import type { TaskCompletion, Tasks } from "../task-service.ts";
26
+ import type { TaskViewMode } from "../domain/task-scope.ts";
27
+ import { tasksOperations } from "../modules/tasks.ts";
28
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
29
+ import type { SessionIdentity } from "../session-identity-service.ts";
30
+ import { labelsById, looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
31
+
32
+ const OWNER = "tasks";
33
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
34
+
35
+ const objectProp = { type: "object" } as unknown as { type: string };
36
+ const arrayProp = { type: "array" } as unknown as { type: string };
37
+ const boolProp = { type: "boolean" } as unknown as { type: string };
38
+
39
+ export interface TasksVehicleDeps {
40
+ tasks: Tasks;
41
+ artifacts: ArtifactStore;
42
+ sessionIdentity: SessionIdentity;
43
+ }
44
+
45
+ /**
46
+ * Resolves an id from either an explicit id or a title lookup scoped to the exact
47
+ * same view (project_root/scope/root_task_id) a plain tasks.list call under those
48
+ * same filters would use -- name resolution must never search a wider or narrower
49
+ * scope than the caller's own view. tasks.list itself requires project_root (see
50
+ * modules/tasks.ts's taskFilter), so resolving by name does too: there is no
51
+ * ambient cwd server-side to default to, unlike the removed client-side tool.
52
+ *
53
+ * A two-task action (depend/contain) routinely names tasks that live in two
54
+ * different projects. When the caller didn't already pin an explicit `scope`, a
55
+ * miss under the narrow filter retries once against `scope: "all"` before giving
56
+ * up -- the same widen-once behavior the removed tool's own resolveArtifactIdByName
57
+ * carried, hard-won from real cross-project depend/contain friction.
58
+ */
59
+ function resolveTaskId(
60
+ tasks: Tasks,
61
+ filter: { projectRoot?: string; scope?: TaskViewMode; rootTaskId?: string },
62
+ id: unknown,
63
+ name: unknown,
64
+ ): string {
65
+ if (typeof id === "string" && id.length > 0) return id;
66
+ if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
67
+ if (!filter.projectRoot) throw new Error("project_root is required when resolving a task by name");
68
+ return resolveArtifactIdWidened(
69
+ name,
70
+ () => tasks.list({ ...filter, text: name }),
71
+ filter.scope === undefined ? () => tasks.list({ ...filter, scope: "all", text: name }) : undefined,
72
+ );
73
+ }
74
+
75
+ /** Resolves root_task_name first and scoped to "project" only, matching the removed tool's own resolution order -- every other name lookup below must see the caller's FINAL scope/root selection, which root_task_id itself feeds into. */
76
+ function resolveRootTaskId(tasks: Tasks, projectRoot: string | undefined, rootTaskId: unknown, rootTaskName: unknown): string | undefined {
77
+ if (typeof rootTaskId === "string" && rootTaskId.length > 0) return rootTaskId;
78
+ if (typeof rootTaskName !== "string" || rootTaskName.length === 0) return undefined;
79
+ return resolveTaskId(tasks, { projectRoot, scope: "project" }, undefined, rootTaskName);
80
+ }
81
+
82
+ function resolveArrayField(tasks: Tasks, filter: { projectRoot?: string; scope?: TaskViewMode; rootTaskId?: string }, ids: unknown, names: unknown): string[] | undefined {
83
+ if (Array.isArray(ids)) return ids as string[];
84
+ if (!Array.isArray(names) || names.length === 0) return undefined;
85
+ return names.map((entry) => resolveTaskId(tasks, filter, undefined, String(entry)));
86
+ }
87
+
88
+ const readSchemaProps = { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp, scope: { type: "string", enum: ["project", "graph", "all"] }, root_task_id: stringProp, root_task_name: stringProp, session_id: stringProp, labels: arrayProp };
89
+
90
+ /** Same gate/checklist narrative lines the removed tool built client-side. */
91
+ function completionContentText(labels: Map<string, string>, result: TaskCompletion): string {
92
+ const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
93
+ const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
94
+ const focused = result.focused ? `\nActive: ${result.focused.title} (${result.focused.id})` : "";
95
+ const blocked = result.blocked.length > 0
96
+ ? `\nBlocked: ${result.blocked.map((entry) => `${entry.artifact.title} (${entry.artifact.id}) waits for ${entry.dependencyIds.map((id) => labels.get(id) ?? "unknown task").join(", ")}`).join("; ")}`
97
+ : "";
98
+ return `${result.completed ? "Completed" : "Rejected"}: ${result.artifact.title} (${result.artifact.id})${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
99
+ }
100
+
101
+ function planContentText(plan: TaskExecutionPlan): string {
102
+ const byId = new Map(plan.nodes.map((node) => [node.id, node]));
103
+ const titleCounts = new Map<string, number>();
104
+ for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
105
+ const nodeLabel = (id: string): string => {
106
+ const node = byId.get(id);
107
+ if (!node) return "unknown task";
108
+ return (titleCounts.get(node.title) ?? 0) > 1 ? `${node.title} (${node.id})` : node.title;
109
+ };
110
+ const lines = plan.layers.flatMap((layer, index) => [
111
+ `Layer ${index + 1}`,
112
+ ...layer.map((id) => ` [${byId.get(id)?.state ?? "unknown"}] ${nodeLabel(id)}`),
113
+ ]);
114
+ if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.map(nodeLabel).join(", ")}`);
115
+ return lines.join("\n") || "No tasks in execution plan.";
116
+ }
117
+
118
+ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps: TasksVehicleDeps): void {
119
+ const { tasks, artifacts, sessionIdentity } = deps;
120
+ const moduleOperations = new Map(tasksOperations(tasks, artifacts, sessionIdentity).map((op) => [op.name, op]));
121
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
122
+
123
+ const define = (
124
+ action: string,
125
+ description: string,
126
+ effect: "read" | "local-write",
127
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
128
+ required: readonly string[],
129
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
130
+ execute?: (input: Record<string, unknown>, context: VehicleOperationContext<Record<string, unknown>>) => unknown,
131
+ ): void => {
132
+ const operation = defineVehicleOperation({
133
+ name: `tasks.${action}`,
134
+ version: 1,
135
+ description,
136
+ input: looseObjectSchema(properties, required),
137
+ output: passthroughOutput,
138
+ permissions: ["tasks:read", "tasks:write"],
139
+ effect,
140
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
141
+ limits: LIMITS,
142
+ });
143
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => (execute ?? ((input: Record<string, unknown>) => call(`tasks.${action}`, input)))(resolve(context.input), context)));
144
+ };
145
+
146
+ /** Shared by every action taking a single id/name: resolves root_task_name first, then name -> id against the final scope. */
147
+ const resolveIdAndScope = (input: Record<string, unknown>): Record<string, unknown> => {
148
+ const projectRoot = input.project_root as string | undefined;
149
+ const rootTaskId = resolveRootTaskId(tasks, projectRoot, input.root_task_id, input.root_task_name);
150
+ const scope = input.scope as TaskViewMode | undefined;
151
+ const filter = { projectRoot, scope, rootTaskId };
152
+ return {
153
+ ...input,
154
+ ...(rootTaskId ? { root_task_id: rootTaskId } : {}),
155
+ id: resolveTaskId(tasks, filter, input.id, input.name),
156
+ };
157
+ };
158
+
159
+ define(
160
+ "create",
161
+ "Creates a Task -- work: desired outcomes, gates, checklists, and dependencies. project_root is required (no ambient cwd server-side). Prefer parent_name/depends_on_names over parent_id/depends_on -- resolved server-side.",
162
+ "local-write",
163
+ { title: stringProp, body: stringProp, status: stringProp, labels: arrayProp, extra: objectProp, gates: arrayProp, checklist: objectProp, template_id: stringProp, parent_id: stringProp, parent_name: stringProp, depends_on: arrayProp, depends_on_names: arrayProp, project_root: stringProp, session_id: stringProp },
164
+ ["title", "project_root"],
165
+ (input) => {
166
+ const projectRoot = input.project_root as string;
167
+ const filter = { projectRoot };
168
+ const parentId = typeof input.parent_id === "string" && input.parent_id.length > 0 ? input.parent_id : (typeof input.parent_name === "string" && input.parent_name.length > 0 ? resolveTaskId(tasks, filter, undefined, input.parent_name) : undefined);
169
+ const dependsOn = resolveArrayField(tasks, filter, input.depends_on, input.depends_on_names);
170
+ return { ...input, ...(parentId ? { parent_id: parentId } : {}), ...(dependsOn ? { depends_on: dependsOn } : {}) };
171
+ },
172
+ );
173
+
174
+ define(
175
+ "update",
176
+ "Recovers an accidentally-terminal task via status=todo + reason, or changes title/body/labels, without rewriting real history. Never touches gates -- use set_gates.",
177
+ "local-write",
178
+ { id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: arrayProp, status: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp },
179
+ [],
180
+ resolveIdAndScope,
181
+ );
182
+
183
+ define("list", "Lists Tasks matching an optional status/text/labels filter, scoped to project_root. project_root is required (no ambient cwd server-side).", "read", readSchemaProps, ["project_root"], (input) => {
184
+ const rootTaskId = resolveRootTaskId(tasks, input.project_root as string, input.root_task_id, input.root_task_name);
185
+ return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
186
+ });
187
+
188
+ define(
189
+ "graph",
190
+ "Returns the full task graph (nodes with parent/child/dependency ids) for the requested scope. project_root is required.",
191
+ "read",
192
+ readSchemaProps,
193
+ ["project_root"],
194
+ (input) => {
195
+ const rootTaskId = resolveRootTaskId(tasks, input.project_root as string, input.root_task_id, input.root_task_name);
196
+ return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
197
+ },
198
+ );
199
+
200
+ define(
201
+ "plan",
202
+ "Projects the task graph into layered execution order (ready/blocked/invalid states, cycle detection). project_root is required.",
203
+ "read",
204
+ readSchemaProps,
205
+ ["project_root"],
206
+ (input) => {
207
+ const rootTaskId = resolveRootTaskId(tasks, input.project_root as string, input.root_task_id, input.root_task_name);
208
+ return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
209
+ },
210
+ (input) => {
211
+ const plan = call("tasks.plan", input) as TaskExecutionPlan;
212
+ return { ...plan, content: [{ type: "text" as const, text: planContentText(plan) }] };
213
+ },
214
+ );
215
+
216
+ define("show", "Shows one Task by id or title.", "read", { id: stringProp, name: stringProp, project_root: stringProp, scope: { type: "string", enum: ["project", "graph", "all"] }, root_task_id: stringProp, root_task_name: stringProp }, [], resolveIdAndScope);
217
+
218
+ define(
219
+ "history",
220
+ "Task's append-only lifecycle event history, cursor-paginated.",
221
+ "read",
222
+ { id: stringProp, name: stringProp, limit: numberProp, cursor: numberProp, direction: { type: "string", enum: ["asc", "desc"] }, project_root: stringProp, scope: { type: "string", enum: ["project", "graph", "all"] }, root_task_id: stringProp, root_task_name: stringProp },
223
+ [],
224
+ resolveIdAndScope,
225
+ );
226
+
227
+ define("scope", "Describes the current task-view scope selection for project_root.", "read", { project_root: stringProp }, ["project_root"], (input) => input);
228
+
229
+ define(
230
+ "set_scope",
231
+ "Sets the task-view scope (project/graph/all) for project_root, optionally pinned to root_task_id.",
232
+ "local-write",
233
+ { project_root: stringProp, scope: { type: "string", enum: ["project", "graph", "all"] }, root_task_id: stringProp, root_task_name: stringProp },
234
+ ["project_root", "scope"],
235
+ (input) => {
236
+ const rootTaskId = resolveRootTaskId(tasks, input.project_root as string, input.root_task_id, input.root_task_name);
237
+ return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
238
+ },
239
+ );
240
+
241
+ define(
242
+ "assign_project",
243
+ "Reassigns a Task's project_root.",
244
+ "local-write",
245
+ { id: stringProp, name: stringProp, project_root: stringProp, session_id: stringProp },
246
+ ["project_root"],
247
+ (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }),
248
+ );
249
+
250
+ define("active", "The current active Task (the one being worked on) for this scope. project_root is required.", "read", readSchemaProps, ["project_root"], (input) => input);
251
+ define("focused", "The current focused Task and its focus status (focused/paused) for this session's scope. project_root is required.", "read", readSchemaProps, ["project_root"], (input) => input);
252
+
253
+ const focusOperation = (
254
+ action: "focus" | "pause" | "unpause" | "clear_focus",
255
+ description: string,
256
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
257
+ required: readonly string[],
258
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
259
+ ): void => {
260
+ const operation = defineVehicleOperation({
261
+ name: `tasks.${action}`,
262
+ version: 1,
263
+ description,
264
+ input: looseObjectSchema(properties, required),
265
+ output: passthroughOutput,
266
+ permissions: ["tasks:read", "tasks:write"],
267
+ effect: "local-write",
268
+ idempotency: { mode: "unsafe" },
269
+ limits: LIMITS,
270
+ });
271
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => {
272
+ const claims = context.principal?.claims as { sessionId?: string; sessionSecret?: string } | undefined;
273
+ return call(`tasks.${action}`, { ...resolve(context.input), session_id: claims?.sessionId, session_secret: claims?.sessionSecret });
274
+ }));
275
+ };
276
+
277
+ focusOperation("focus", "Sets the active Task Focus (singular per scope) to this Task. Multiple sessions can focus the same task while only one holds its lease.", { id: stringProp, name: stringProp, project_root: stringProp }, [], (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }));
278
+ focusOperation("pause", "Pauses the active Task Focus without clearing it.", { reason: stringProp }, [], (input) => input);
279
+ focusOperation("unpause", "Resumes a paused Task Focus.", {}, [], (input) => input);
280
+ focusOperation("clear_focus", "Clears the active Task Focus.", {}, [], (input) => input);
281
+
282
+ define("start", "Lifecycle transition: todo -> in-progress.", "local-write", { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp }, [], resolveIdAndScope);
283
+ define("submit", "Lifecycle transition: in-progress -> review.", "local-write", { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp }, [], resolveIdAndScope);
284
+ define("reject", "Lifecycle transition: review -> rejected.", "local-write", { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp }, [], resolveIdAndScope);
285
+ define("retry", "Lifecycle transition: rejected -> in-progress.", "local-write", { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp }, [], resolveIdAndScope);
286
+ define("cancel", "Lifecycle transition to canceled (terminal) from todo/in-progress/review/rejected.", "local-write", { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp }, [], resolveIdAndScope);
287
+
288
+ define(
289
+ "complete",
290
+ "Runs gates + checklist-proof review, then focuses one deterministic ready successor without claiming effort. Rejects (not completes) on gate/checklist failure.",
291
+ "local-write",
292
+ { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp, scope: { type: "string", enum: ["project", "graph", "all"] }, root_task_id: stringProp, root_task_name: stringProp },
293
+ [],
294
+ resolveIdAndScope,
295
+ async (input) => {
296
+ const result = (await call("tasks.complete", input)) as TaskCompletion;
297
+ const dependencyIds = result.blocked.flatMap((entry) => entry.dependencyIds);
298
+ const labels = labelsById(artifacts, dependencyIds);
299
+ return { ...result, content: [{ type: "text" as const, text: completionContentText(labels, result) }] };
300
+ },
301
+ );
302
+
303
+ define(
304
+ "run_gates",
305
+ "Runs a Task's configured gates without transitioning its status -- for checking readiness before submit/complete.",
306
+ "read",
307
+ { id: stringProp, name: stringProp, session_id: stringProp, project_root: stringProp, scope: { type: "string", enum: ["project", "graph", "all"] }, root_task_id: stringProp, root_task_name: stringProp },
308
+ [],
309
+ resolveIdAndScope,
310
+ async (input) => {
311
+ const gates = (await call("tasks.run_gates", input)) as Array<{ gate: { type: string; target: string }; passed: boolean; output: string }>;
312
+ const text = gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.";
313
+ return { gates, content: [{ type: "text" as const, text }] };
314
+ },
315
+ );
316
+
317
+ define("set_checklist", "Replaces a Task's evidence-bearing checklist (proof requirements) in full.", "local-write", { id: stringProp, name: stringProp, checklist: objectProp, project_root: stringProp }, ["checklist"], resolveIdAndScope);
318
+ define("set_gates", "Replaces a Task's gate commands in full.", "local-write", { id: stringProp, name: stringProp, gates: arrayProp, project_root: stringProp }, ["gates"], resolveIdAndScope);
319
+
320
+ define(
321
+ "context",
322
+ "The full plan-reconciliation context (the system prompt itself only carries a one-line pointer) -- call explicitly after a compaction or before reconciling. project_root is required.",
323
+ "read",
324
+ readSchemaProps,
325
+ ["project_root"],
326
+ (input) => ({ ...input, verbosity: "full" }),
327
+ (input) => {
328
+ const summary = call("tasks.context", input) as string | null;
329
+ const text = summary ?? "No open tasks.";
330
+ return { context: summary, content: [{ type: "text" as const, text }] };
331
+ },
332
+ );
333
+
334
+ define(
335
+ "cancel_subtree",
336
+ "Cancels a Task and its whole containment subtree in one call, skipping tasks already done/canceled.",
337
+ "local-write",
338
+ { id: stringProp, name: stringProp, reason: stringProp, session_id: stringProp, project_root: stringProp, scope: { type: "string", enum: ["project", "graph", "all"] }, root_task_id: stringProp, root_task_name: stringProp },
339
+ [],
340
+ resolveIdAndScope,
341
+ (input) => {
342
+ const outcome = call("tasks.cancel_subtree", input) as { canceled: string[]; skipped: string[] };
343
+ const text = `Canceled ${outcome.canceled.length} task(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-terminal` : ""}.`;
344
+ return { ...outcome, content: [{ type: "text" as const, text }] };
345
+ },
346
+ );
347
+
348
+ const scopeProp = { type: "string", enum: ["project", "graph", "all"] } as const;
349
+
350
+ define(
351
+ "depend",
352
+ "Adds a dependency edge (this task waits for dependency_id/dependency_name). Dependency edges form an executable DAG -- self-dependencies and cycles are rejected. A name resolved outside project_root's own scope is retried once against every project before failing, unless scope is pinned explicitly.",
353
+ "local-write",
354
+ { id: stringProp, name: stringProp, dependency_id: stringProp, dependency_name: stringProp, project_root: stringProp, scope: scopeProp, session_id: stringProp },
355
+ [],
356
+ (input) => {
357
+ const filter = { projectRoot: input.project_root as string | undefined, scope: input.scope as TaskViewMode | undefined };
358
+ return { ...input, id: resolveTaskId(tasks, filter, input.id, input.name), dependency_id: resolveTaskId(tasks, filter, input.dependency_id, input.dependency_name) };
359
+ },
360
+ );
361
+
362
+ define(
363
+ "undepend",
364
+ "Removes a dependency edge. Idempotent -- a no-op if the edge is already absent.",
365
+ "local-write",
366
+ { id: stringProp, name: stringProp, dependency_id: stringProp, dependency_name: stringProp, project_root: stringProp, scope: scopeProp, session_id: stringProp },
367
+ [],
368
+ (input) => {
369
+ const filter = { projectRoot: input.project_root as string | undefined, scope: input.scope as TaskViewMode | undefined };
370
+ return { ...input, id: resolveTaskId(tasks, filter, input.id, input.name), dependency_id: resolveTaskId(tasks, filter, input.dependency_id, input.dependency_name) };
371
+ },
372
+ );
373
+
374
+ define(
375
+ "contain",
376
+ "Nests a child Task inside a parent (parent_id/parent_name contains child_id/child_name) -- explicit hierarchy, distinct from depends_on execution ordering. A name resolved outside project_root's own scope is retried once against every project before failing, unless scope is pinned explicitly.",
377
+ "local-write",
378
+ { parent_id: stringProp, parent_name: stringProp, child_id: stringProp, child_name: stringProp, project_root: stringProp, scope: scopeProp, session_id: stringProp },
379
+ [],
380
+ (input) => {
381
+ const filter = { projectRoot: input.project_root as string | undefined, scope: input.scope as TaskViewMode | undefined };
382
+ return { ...input, parent_id: resolveTaskId(tasks, filter, input.parent_id, input.parent_name), child_id: resolveTaskId(tasks, filter, input.child_id, input.child_name) };
383
+ },
384
+ );
385
+
386
+ define(
387
+ "uncontain",
388
+ "Removes a parent/child nesting. Idempotent -- a no-op if the edge is already absent.",
389
+ "local-write",
390
+ { parent_id: stringProp, parent_name: stringProp, child_id: stringProp, child_name: stringProp, project_root: stringProp, scope: scopeProp, session_id: stringProp },
391
+ [],
392
+ (input) => {
393
+ const filter = { projectRoot: input.project_root as string | undefined, scope: input.scope as TaskViewMode | undefined };
394
+ return { ...input, parent_id: resolveTaskId(tasks, filter, input.parent_id, input.parent_name), child_id: resolveTaskId(tasks, filter, input.child_id, input.child_name) };
395
+ },
396
+ );
397
+
398
+ define("claim", "Claims this Task's lease under owner (defaults to session_id). Throws if a different owner already holds one.", "local-write", { id: stringProp, name: stringProp, owner: stringProp, ttl_ms: numberProp, note: stringProp, project_root: stringProp, session_id: stringProp }, [], (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name), owner: input.owner ?? input.session_id }));
399
+ define("heartbeat_lease", "Extends this Task's lease -- needs the exact owner/token claim() returned.", "local-write", { id: stringProp, name: stringProp, owner: stringProp, token: stringProp, ttl_ms: numberProp, project_root: stringProp, session_id: stringProp }, ["owner", "token"], (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }));
400
+ define("release_lease", "Releases this Task's lease -- needs the exact owner/token claim() returned.", "local-write", { id: stringProp, name: stringProp, owner: stringProp, token: stringProp, project_root: stringProp, session_id: stringProp }, ["owner", "token"], (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }));
401
+ define("lease", "Shows this Task's current lease, if any.", "read", { id: stringProp, name: stringProp, project_root: stringProp }, [], (input) => ({ ...input, id: resolveTaskId(tasks, { projectRoot: input.project_root as string | undefined }, input.id, input.name) }));
402
+
403
+ define("event_feed", "Cursor-paginated feed of raw Task lifecycle events across every task, optionally filtered by event_types.", "read", { cursor: numberProp, limit: numberProp, event_types: arrayProp }, [], (input) => input);
404
+ }
405
+
406
+ /** Kept out of the registry deliberately, matching the removed tool's own ACTIONS list -- system maintenance, not an agent-facing action. Exposed via reapStale* CLI/cron paths, not a Vehicle operation. */
407
+ export const TASKS_MAINTENANCE_OPERATIONS = ["tasks.reap_stale_focus", "tasks.reap_stale_leases"] as const;