@danypops/papyrus 0.38.4 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.38.4",
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,11 +24,10 @@ 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";
31
- import { createNotesVehicleRegistry } from "./vehicle/notes-vehicle.ts";
30
+ import { createPapyrusVehicleRegistry } from "./vehicle/papyrus-vehicle.ts";
32
31
  import type { VehicleRegistry } from "@danypops/vehicle-server";
33
32
  import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
34
33
  import { Logs } from "./log-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";
@@ -208,12 +207,13 @@ export interface PapyrusService {
208
207
  schemaState(): SchemaState;
209
208
  execute(operation: string, input?: OperationInput): Promise<unknown>;
210
209
  /**
211
- * Notes projected as a real VehicleRegistry (see ./vehicle/notes-vehicle.ts) --
212
- * one honest VehicleOperation per real action, replacing the Pi extension's old
213
- * `notes(action=X)` mega-tool. The first domain migrated this way; not every
214
- * domain has one yet.
210
+ * Every domain migrated onto Vehicle, merged into one registry/one HTTP mount
211
+ * (see ./vehicle/papyrus-vehicle.ts) -- one honest VehicleOperation per real
212
+ * action, replacing the Pi extension's old `<domain>(action=X)` mega-tools.
213
+ * Not every domain is migrated yet -- see papyrus-vehicle.ts's own doc comment
214
+ * for what still isn't and why.
215
215
  */
216
- readonly notesVehicle: VehicleRegistry;
216
+ readonly vehicle: VehicleRegistry;
217
217
  checkpoint(): void;
218
218
  optimize(): void;
219
219
  /** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
@@ -431,24 +431,7 @@ function handlers(
431
431
  "playbooks.uncontain": forwardToModule("playbooks.uncontain"),
432
432
  "playbooks.depend": forwardToModule("playbooks.depend"),
433
433
  "playbooks.undepend": forwardToModule("playbooks.undepend"),
434
- "skills.instantiate": (input) => {
435
- const templateId = string(input, "template_id");
436
- const template = artifacts.get(templateId);
437
- // Note ownership for a non-task template target is enforced inside instantiateTemplate's
438
- // own rejectsNoteTemplate for the non-task branch below -- nothing else currently claims
439
- // an unresolved (pre-template-resolution) kind, so there is no check to perform here.
440
- if (template?.extra["targetKind"] !== "task") return instantiateTemplate(artifacts, templateId, normalizeCreateInput(input), authority, eventContext(input));
441
- return tasks.create({
442
- title: optionalString(input, "title") as string,
443
- body: optionalString(input, "body"),
444
- status: optionalString(input, "status") as TaskStatus | undefined,
445
- labels: input["labels"] as string[] | undefined,
446
- extra: input["extra"] as Record<string, unknown> | undefined,
447
- templateId,
448
- projectRoot: string(input, "project_root"),
449
- projectSource: "cwd",
450
- }, eventContextFor(input, "template-instantiation"));
451
- },
434
+ "skills.instantiate": (input) => instantiateSkillOrTemplate({ artifacts, tasks, authority }, input, eventContextFor(input, "template-instantiation")),
452
435
  "graph_projection.apply": forwardToModule("graph_projection.apply"),
453
436
  "graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
454
437
  "logs.append": forwardToModule("logs.append"),
@@ -479,13 +462,13 @@ export function createPapyrusService(path: string): PapyrusService {
479
462
  const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
480
463
  const noteEvents = new SQLiteNoteEventStore(db);
481
464
  const notes = new Notes(artifacts, noteEvents);
482
- const notesVehicle = createNotesVehicleRegistry(notes, artifacts);
483
465
  const projections = new SQLiteGraphProjectionStore(db);
484
466
  const artifactScopes = new SQLiteArtifactScopeStore(db);
485
467
  const logs = new Logs(new SQLiteLogStore(db));
486
468
  const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
487
469
  const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
488
470
  const authority = createAuthorityRegistry();
471
+ const vehicle = createPapyrusVehicleRegistry({ artifacts, scopes: artifactScopes, authority, notes, events, taskScopes: scopes, tasks, sessionIdentity });
489
472
  const moduleRegistry = new OperationRegistry();
490
473
  moduleRegistry.registerAll(notesOperations(notes));
491
474
  moduleRegistry.registerAll(logsOperations(logs));
@@ -505,7 +488,7 @@ export function createPapyrusService(path: string): PapyrusService {
505
488
  return {
506
489
  operationNames: () => [...EXPECTED_OPERATION_NAMES],
507
490
  schemaState: state,
508
- notesVehicle,
491
+ vehicle,
509
492
  async execute(operation, input = {}) {
510
493
  const handler = registry[operation as OperationName];
511
494
  if (!handler) throw new UnknownOperationError(`unknown operation "${operation}"`);
@@ -565,10 +548,8 @@ export function createApp(deps: {
565
548
  */
566
549
  onOperationExecuted?: (operation: string, input: OperationInput) => void;
567
550
  }): { fetch(request: Request): Promise<Response> } {
568
- // Same Bearer token as the rest of this API -- a Vehicle-projected domain (see
569
- // ./vehicle/notes-vehicle.ts) rides the same daemon, same auth, same port; it is
570
- // not a second service to stand up or authenticate against separately.
571
- const vehicleApp = createVehicleHttpApp({ registry: deps.service.notesVehicle, token: deps.token });
551
+ // Same Bearer token, daemon, and port as the rest of this API -- see ./vehicle/papyrus-vehicle.ts.
552
+ const vehicleApp = createVehicleHttpApp({ registry: deps.service.vehicle, token: deps.token });
572
553
  return {
573
554
  async fetch(request: Request): Promise<Response> {
574
555
  if (request.headers.get("authorization") !== `Bearer ${deps.token}`) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * artifact.* -- show/remove/remove_subtree/restore, identical regardless of an
3
+ * artifact's kind. Registered once here, shared by every domain, instead of
4
+ * duplicated as rules.remove/docs.remove/etc.
5
+ */
6
+ import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
7
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
8
+ import { removeArtifactSubtree } from "../artifact-subtree.ts";
9
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
10
+ import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
11
+ import { looseObjectSchema, numberProp, passthroughOutput, stringProp } from "./artifact-vehicle-shared.ts";
12
+
13
+ const OWNER = "artifact";
14
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
15
+
16
+ function eventContext(input: Record<string, unknown>): { actor?: string; source?: string; sessionId?: string } {
17
+ const actor = input["actor"];
18
+ const source = input["source"];
19
+ const sessionId = input["session_id"] ?? input["sessionId"];
20
+ return {
21
+ actor: typeof actor === "string" ? actor : undefined,
22
+ source: typeof source === "string" ? source : undefined,
23
+ sessionId: typeof sessionId === "string" ? sessionId : undefined,
24
+ };
25
+ }
26
+
27
+ function requireId(input: Record<string, unknown>): string {
28
+ const id = input["id"];
29
+ if (typeof id !== "string" || id.length === 0) throw new Error("id is required");
30
+ return id;
31
+ }
32
+
33
+ export function registerArtifactTrashOperations(registry: VehicleRegistry, artifacts: ArtifactStore & ArtifactTrashStore): void {
34
+ const define = (
35
+ action: string,
36
+ description: string,
37
+ effect: "read" | "local-write" | "destructive",
38
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
39
+ required: readonly string[],
40
+ execute: (input: Record<string, unknown>) => unknown,
41
+ ): void => {
42
+ const operation = defineVehicleOperation({
43
+ name: `artifact.${action}`,
44
+ version: 1,
45
+ description,
46
+ input: looseObjectSchema(properties, required),
47
+ output: passthroughOutput,
48
+ permissions: ["artifact:read", "artifact:write"],
49
+ effect,
50
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
51
+ limits: LIMITS,
52
+ });
53
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => execute(context.input)));
54
+ };
55
+
56
+ define(
57
+ "show",
58
+ "Shows any artifact (doc, task, rule, skill, playbook) by id, regardless of kind.",
59
+ "read",
60
+ { id: stringProp, tree: { type: "boolean" } as unknown as { type: string }, depth: numberProp, max_nodes: numberProp },
61
+ ["id"],
62
+ (input) => artifacts.get(requireId(input), {
63
+ tree: input["tree"] === true,
64
+ depth: typeof input["depth"] === "number" ? input["depth"] : undefined,
65
+ maxNodes: typeof input["max_nodes"] === "number" ? input["max_nodes"] : undefined,
66
+ }),
67
+ );
68
+
69
+ define(
70
+ "remove",
71
+ "Moves any artifact to a time-gated trash, excluded from list/query but still directly showable, restorable via artifact.restore until the purge deadline.",
72
+ "local-write",
73
+ { id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
74
+ ["id"],
75
+ (input) => artifacts.trash(requireId(input), { reason: typeof input["reason"] === "string" ? input["reason"] : undefined, context: eventContext(input) }),
76
+ );
77
+
78
+ define(
79
+ "remove_subtree",
80
+ "Trashes an artifact and its whole `contains` subtree in one call, skipping already-trashed nodes.",
81
+ "local-write",
82
+ { id: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
83
+ ["id"],
84
+ (input) => removeArtifactSubtree(artifacts, requireId(input), { reason: typeof input["reason"] === "string" ? input["reason"] : undefined, context: eventContext(input) }),
85
+ );
86
+
87
+ define(
88
+ "restore",
89
+ "Restores a trashed artifact. Idempotent: restoring one that isn't trashed is a real no-op, not an error.",
90
+ "local-write",
91
+ { id: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
92
+ ["id"],
93
+ (input) => artifacts.restore(requireId(input), eventContext(input)),
94
+ );
95
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Shared schema helpers and name->id resolution for every per-domain
3
+ * VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
4
+ * artifact-trash-vehicle.ts).
5
+ */
6
+ import { defineVehicleSchema, type VehicleSchemaCodec, type VehicleContentBlock } from "@danypops/vehicle-core";
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";
10
+
11
+ /**
12
+ * VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
13
+ * descriptive metadata surfaced to a client/Pi projection, never itself
14
+ * enforced at runtime -- so a declared `enum` has to be checked here for
15
+ * real, or it's a documentation gesture, not an honest contract.
16
+ */
17
+ export function looseObjectSchema(properties: Record<string, { type: string; enum?: readonly string[] }>, required: readonly string[] = []): VehicleSchemaCodec<Record<string, unknown>> {
18
+ return defineVehicleSchema<Record<string, unknown>>({
19
+ jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
20
+ safeParse(value) {
21
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
22
+ return { success: false, issues: [{ path: [], message: "input must be an object" }] };
23
+ }
24
+ const input = value as Record<string, unknown>;
25
+ for (const key of required) {
26
+ if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
27
+ }
28
+ for (const [key, schema] of Object.entries(properties)) {
29
+ if (!schema.enum || !(key in input)) continue;
30
+ if (!schema.enum.includes(input[key] as string)) {
31
+ return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
32
+ }
33
+ }
34
+ return { success: true, value: input };
35
+ },
36
+ });
37
+ }
38
+
39
+ export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchema<unknown>({
40
+ jsonSchema: { type: "object" },
41
+ safeParse: (value) => ({ success: true, value }),
42
+ });
43
+
44
+ export const stringProp = { type: "string" } as const;
45
+ export const numberProp = { type: "number" } as const;
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
+
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. */
59
+ export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
60
+ const needle = name.trim().toLowerCase();
61
+ const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
62
+ if (matches.length === 0) throw new Error(`no artifact named "${name}" found in this scope`);
63
+ if (matches.length > 1) {
64
+ throw new Error(`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`);
65
+ }
66
+ return matches[0]!.id;
67
+ }
68
+
69
+ /**
70
+ * Resolves a name to an id, retrying against `fetchWidened` (an unscoped/cross-project
71
+ * search) only when `fetchCandidates` finds nothing. Owns the match-or-widen control
72
+ * flow only -- the caller supplies its own scoped/widened list calls, since scoping
73
+ * differs per domain. Omit `fetchWidened` when there is no wider scope to retry.
74
+ */
75
+ export function resolveArtifactIdWidened(name: string, fetchCandidates: () => readonly Artifact[], fetchWidened?: () => readonly Artifact[]): string {
76
+ try {
77
+ return matchArtifactByName(fetchCandidates(), name);
78
+ } catch (error) {
79
+ if (!(error instanceof Error) || !error.message.startsWith("no artifact named") || !fetchWidened) throw error;
80
+ return matchArtifactByName(fetchWidened(), name);
81
+ }
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
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Docs projected as a real VehicleRegistry: one VehicleOperation per real action.
3
+ * Wraps modules/docs.ts's operation definitions. remove/restore/remove_subtree
4
+ * are not duplicated here -- see ./artifact-trash-vehicle.ts.
5
+ */
6
+ import { defineVehicleOperation, bindVehicleOperation } from "@danypops/vehicle-core";
7
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
8
+ import type { AuthorityRegistry } from "../authority-registry.ts";
9
+ import { listDocuments } from "../domain-services.ts";
10
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
11
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
12
+ import { docsOperations } from "../modules/docs.ts";
13
+ import { looseObjectSchema, numberProp, passthroughOutput, resolveArtifactIdWidened, stringProp } from "./artifact-vehicle-shared.ts";
14
+
15
+ const OWNER = "docs";
16
+ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
17
+
18
+ function resolveDocId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, projectRoot: string | undefined, id: unknown, name: unknown): string {
19
+ if (typeof id === "string" && id.length > 0) return id;
20
+ if (typeof name !== "string" || name.length === 0) throw new Error("id or name is required");
21
+ return resolveArtifactIdWidened(
22
+ name,
23
+ () => listDocuments(artifacts, scopes, { text: name, projectRoot }),
24
+ projectRoot === undefined ? undefined : () => listDocuments(artifacts, scopes, { text: name }),
25
+ );
26
+ }
27
+
28
+ /** Cross-kind resolution for a link target -- can be a doc, task, rule, or skill. Unscoped, matching the exact behavior of the artifact.query-backed resolution it replaces. */
29
+ function resolveTargetId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
30
+ if (typeof id === "string" && id.length > 0) return id;
31
+ if (typeof name !== "string" || name.length === 0) throw new Error("target_id or target_name is required");
32
+ return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
33
+ }
34
+
35
+ export function registerDocsVehicleOperations(registry: VehicleRegistry, artifacts: ArtifactStore, scopes: ArtifactScopeStore, authority: AuthorityRegistry): void {
36
+ const moduleOperations = new Map(docsOperations(artifacts, scopes, authority).map((op) => [op.name, op]));
37
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
38
+
39
+ const define = (
40
+ action: string,
41
+ description: string,
42
+ effect: "read" | "local-write",
43
+ properties: Record<string, { type: string; enum?: readonly string[] }>,
44
+ required: readonly string[],
45
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
46
+ ): void => {
47
+ const operation = defineVehicleOperation({
48
+ name: `docs.${action}`,
49
+ version: 1,
50
+ description,
51
+ input: looseObjectSchema(properties, required),
52
+ output: passthroughOutput,
53
+ permissions: ["docs:read", "docs:write"],
54
+ effect,
55
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
56
+ limits: LIMITS,
57
+ });
58
+ registry.register(OWNER, bindVehicleOperation(operation, () => async (context) => call(`docs.${action}`, resolve(context.input))));
59
+ };
60
+
61
+ define(
62
+ "create",
63
+ "Creates a Doc -- descriptive knowledge, not actionable work. project_root is optional (omitted = unscoped).",
64
+ "local-write",
65
+ { title: stringProp, body: stringProp, subtype: stringProp, labels: { type: "array" } as unknown as { type: string }, extra: { type: "object" } as unknown as { type: string }, template_id: stringProp, project_root: stringProp },
66
+ ["title"],
67
+ (input) => input,
68
+ );
69
+
70
+ define(
71
+ "list",
72
+ "Lists Docs matching an optional status/text filter, scoped to project_root when given.",
73
+ "read",
74
+ { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp },
75
+ [],
76
+ (input) => input,
77
+ );
78
+
79
+ define(
80
+ "show",
81
+ "Shows one Doc by id or title.",
82
+ "read",
83
+ { id: stringProp, name: stringProp, project_root: stringProp },
84
+ [],
85
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
86
+ );
87
+
88
+ define(
89
+ "activate",
90
+ "Activates a draft Doc.",
91
+ "local-write",
92
+ { id: stringProp, name: stringProp, project_root: stringProp },
93
+ [],
94
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
95
+ );
96
+
97
+ define(
98
+ "archive",
99
+ "Archives an active Doc.",
100
+ "local-write",
101
+ { id: stringProp, name: stringProp, project_root: stringProp },
102
+ [],
103
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
104
+ );
105
+
106
+ define(
107
+ "reopen",
108
+ "Reopens an archived Doc back to active.",
109
+ "local-write",
110
+ { id: stringProp, name: stringProp, project_root: stringProp },
111
+ [],
112
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
113
+ );
114
+
115
+ define(
116
+ "link",
117
+ "Links a Doc to another artifact via a typed relation. Prefer target_name over target_id -- resolved server-side, searching every kind since a link target can be a doc, task, rule, or skill.",
118
+ "local-write",
119
+ { id: stringProp, name: stringProp, relation: { type: "string", enum: ["references", "documents", "supersedes", "relates_to", "contains", "part_of"] }, target_id: stringProp, target_name: stringProp, project_root: stringProp },
120
+ ["relation"],
121
+ (input) => ({
122
+ ...input,
123
+ id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name),
124
+ target_id: resolveTargetId(artifacts, input.target_id, input.target_name),
125
+ }),
126
+ );
127
+
128
+ define(
129
+ "assign_project",
130
+ "Reassigns a Doc's project_root, or unscopes it when project_root is omitted.",
131
+ "local-write",
132
+ { id: stringProp, name: stringProp, project_root: stringProp },
133
+ [],
134
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, undefined, input.id, input.name) }),
135
+ );
136
+
137
+ define(
138
+ "update",
139
+ "Changes a Doc's title/body/labels (at least one required). Refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead.",
140
+ "local-write",
141
+ { id: stringProp, name: stringProp, title: stringProp, body: stringProp, labels: { type: "array" } as unknown as { type: string }, project_root: stringProp },
142
+ [],
143
+ (input) => ({ ...input, id: resolveDocId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name) }),
144
+ );
145
+ }