@danypops/papyrus 0.35.2 → 0.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.35.2",
3
+ "version": "0.36.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"],
package/src/cli.ts CHANGED
@@ -113,13 +113,17 @@ const USAGE = `Usage:
113
113
  papyrus skills assign-project <id> [project-root] [--json]
114
114
  papyrus skills update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
115
115
  papyrus playbooks create --title <title> [--body <body>] [--trigger <text>] [--steps-json <json>] [--tools-json <json>] [--labels-json <json>] [--extra-json <json>] [--arguments-json <json array>] [--project-root <path>] [--json]
116
- papyrus playbooks invoke <id> [--arguments-json <json object>] [--json]
116
+ papyrus playbooks invoke <id> [--arguments-json <json object>] [--project-root <path>] [--json] # materializes real tasks (contains/depends_on-wired) and focuses the entry task
117
+ papyrus playbooks preview <id> [--arguments-json <json object>] [--json] # renders text only, creates nothing
117
118
  papyrus playbooks list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
118
119
  papyrus playbooks show <id> [--json]
119
- papyrus playbooks invoke <id> [--json]
120
120
  papyrus playbooks enable|disable <id> [--json]
121
121
  papyrus playbooks assign-project <id> [project-root] [--json]
122
122
  papyrus playbooks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
123
+ papyrus playbooks contain <parent-id> <child-id> [--json]
124
+ papyrus playbooks uncontain <parent-id> <child-id> [--json]
125
+ papyrus playbooks depend <id> <dependency-id> [--json]
126
+ papyrus playbooks undepend <id> <dependency-id> [--json]
123
127
  papyrus notes capture <request> [--title <title>] [--json]
124
128
  papyrus notes list [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--json]
125
129
  papyrus notes show <id> [--json]
@@ -859,11 +863,20 @@ export async function runPlaybooksCli(args: string[], client: TaskCliClient): Pr
859
863
  human = `${artifactLabel(artifact)}\n\n${artifact.body ?? ""}`;
860
864
  break;
861
865
  }
866
+ case "preview": {
867
+ if (!id || second) throw new Error("playbooks preview requires exactly one playbook id");
868
+ const rendered = await client.call<Record<string, unknown>, string>("playbooks.preview", { id, arguments: playbookArguments });
869
+ result = rendered;
870
+ human = rendered;
871
+ break;
872
+ }
862
873
  case "invoke": {
863
874
  if (!id || second) throw new Error("playbooks invoke requires exactly one playbook id");
864
- const invocation = await client.call<Record<string, unknown>, string>("playbooks.invoke", { id, arguments: playbookArguments });
875
+ const invocation = await client.call<Record<string, unknown>, { entryTaskId: string; missingArguments?: string[] }>("playbooks.invoke", { id, arguments: playbookArguments, project_root: playbookProjectRoot });
865
876
  result = invocation;
866
- human = invocation;
877
+ human = invocation.missingArguments
878
+ ? `Missing required argument(s): ${invocation.missingArguments.join(", ")}.`
879
+ : `Invoked: entry task ${invocation.entryTaskId} focused. Drive it forward with \`tasks start/submit/complete\` like any other task.`;
867
880
  break;
868
881
  }
869
882
  case "enable":
@@ -889,8 +902,36 @@ export async function runPlaybooksCli(args: string[], client: TaskCliClient): Pr
889
902
  human = `${artifactLabel(artifact)}`;
890
903
  break;
891
904
  }
905
+ case "contain": {
906
+ if (!id || !second || positional.length !== 3) throw new Error("playbooks contain requires a parent id and child id");
907
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.contain", { parent_id: id, child_id: second });
908
+ result = artifact;
909
+ human = `Nested: ${second} → ${artifactLabel(artifact)}`;
910
+ break;
911
+ }
912
+ case "uncontain": {
913
+ if (!id || !second || positional.length !== 3) throw new Error("playbooks uncontain requires a parent id and child id");
914
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.uncontain", { parent_id: id, child_id: second });
915
+ result = artifact;
916
+ human = `Removed ${second} from ${artifactLabel(artifact)}`;
917
+ break;
918
+ }
919
+ case "depend": {
920
+ if (!id || !second || positional.length !== 3) throw new Error("playbooks depend requires a playbook id and prerequisite id");
921
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.depend", { id, dependency_id: second });
922
+ result = artifact;
923
+ human = `Dependency added: ${artifactLabel(artifact)} waits for ${second}`;
924
+ break;
925
+ }
926
+ case "undepend": {
927
+ if (!id || !second || positional.length !== 3) throw new Error("playbooks undepend requires a playbook id and prerequisite id");
928
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.undepend", { id, dependency_id: second });
929
+ result = artifact;
930
+ human = `Dependency removed: ${artifactLabel(artifact)} no longer waits for ${second}`;
931
+ break;
932
+ }
892
933
  default:
893
- throw new Error("playbooks action must be create, list, show, invoke, enable, disable, assign-project, or update");
934
+ throw new Error("playbooks action must be create, list, show, invoke, preview, enable, disable, assign-project, update, contain, uncontain, depend, or undepend");
894
935
  }
895
936
  return json ? JSON.stringify(result) : human;
896
937
  }
package/src/constants.ts CHANGED
@@ -108,9 +108,18 @@ export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
108
108
  export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
109
109
  export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
110
110
  export const PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
111
+ /** Mirrors SKILL_INVOCATION_MAX_CALL_DEPTH: a playbook-calls-playbook edge chain is bounded the same way a skill-calls-skill chain is. */
112
+ export const PLAYBOOK_INVOCATION_MAX_CALL_DEPTH = 4;
111
113
  export const PLAYBOOK_ARGUMENT_MAX_COUNT = 20;
112
114
  export const PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH = 64;
113
115
  export const PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH = 500;
116
+ /**
117
+ * playbooks.invoke materializes a real Task per step (plus one container Task per playbook
118
+ * node in the contains/depends_on composition tree) instead of rendering text -- this bounds
119
+ * the total number of Tasks one invoke call can create, the same blast-radius concern
120
+ * SKILL_MAX_BLUEPRINTS already covers for workflow Skills.
121
+ */
122
+ export const PLAYBOOK_INVOCATION_MAX_CREATED_TASKS = 200;
114
123
 
115
124
  /**
116
125
  * At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
@@ -49,7 +49,7 @@ export interface SkillTaskBlueprint {
49
49
  /**
50
50
  * A pipeline step that nests another workflow Skill's run inside this one -- the Jenkins
51
51
  * "trigger downstream job and wait" / Ansible "include_tasks" primitive. `skillId` is late-
52
- * bound: existence and workflow-subtype are checked at execution time (skill-execution.ts),
52
+ * bound: existence and workflow-subtype are checked at execution time (workflow-execution.ts),
53
53
  * not here, since this validator has no store access. `dependsOn`/`parent` place this step in
54
54
  * the SAME dependency graph as ordinary task blueprints -- a task can depend on a skill-call
55
55
  * ref (meaning: depend on every task the nested run creates), and a skill-call's own `parent`
@@ -7,6 +7,7 @@ import {
7
7
  PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH,
8
8
  PLAYBOOK_ARGUMENT_MAX_COUNT,
9
9
  PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH,
10
+ PLAYBOOK_INVOCATION_MAX_CALL_DEPTH,
10
11
  PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
11
12
  RULE_TEXT_HARD_LIMIT_CHARACTERS,
12
13
  SKILL_INVOCATION_MAX_CALL_DEPTH,
@@ -523,10 +524,22 @@ export function transitionSkill(artifacts: ArtifactStore, id: string, action: Sk
523
524
  }
524
525
 
525
526
  /**
526
- * Playbooks: a trigger and an ordered list of steps an agent reads and follows -- a completely
527
- * different beast from Skills, not a subtype of one. A Skill (artifact-template or workflow) is
528
- * mechanically instantiated into other artifacts; a Playbook is never instantiated, it's read
529
- * and followed, and it never composes other Playbooks the way a Skill can call another Skill.
527
+ * Playbooks: a trigger and an ordered list of steps -- authored as prose, a completely
528
+ * different beast from Skills at that level. But playbooks.invoke (playbook-execution.ts)
529
+ * recycles the exact same materialization engine workflow Skills use: it compiles a Playbook
530
+ * into a SkillDefinition and mechanically instantiates real Tasks from it, same as a Skill's
531
+ * own artifact-template/workflow blueprint. `playbookInvocation` below is the OTHER, older
532
+ * path -- rendered text with no side effects, now exposed as the `preview` action for a human
533
+ * who wants to just read a playbook before invoking it, not the primary way of running one.
534
+ * Like Tasks, a Playbook can be nested or chained with another Playbook: `contains`/`part_of`
535
+ * (containPlaybook/uncontainPlaybook) nests a sub-playbook inside a parent -- both preview and
536
+ * invoke run the nested one's own steps as part of the parent, invoke as real dependsOn-chained
537
+ * Tasks, preview as embedded text. `depends_on` (dependPlaybook/undependPlaybook) chains one
538
+ * playbook before another -- the prerequisite's steps run first, either as real Tasks the
539
+ * dependent's first step depends_on (invoke) or as embedded text rendered first (preview).
540
+ * Composition is bounded in both paths; preview degrades a cycle to a text marker at render
541
+ * time, while invoke's compiler (playbook-definition.ts) treats a cycle as a hard error --
542
+ * real Tasks would otherwise be created in an infinite loop, unlike text rendering.
530
543
  */
531
544
  export interface PlaybookArgument {
532
545
  name: string;
@@ -629,15 +642,44 @@ export function transitionPlaybook(artifacts: ArtifactStore, id: string, action:
629
642
  return artifacts.setStatus(id, target, context)!;
630
643
  }
631
644
 
632
- /**
633
- * Renders trigger/steps/tools/arguments into readable guidance, plus any real linked artifacts.
634
- * No nested playbook-calls-playbook composition -- a Playbook is a flat procedure, not a
635
- * composable bundle. `provided` is the caller's already-known argument values (e.g. from the
636
- * conversation so far); any declared *required* argument missing from it is called out
637
- * explicitly, directing the agent to discuss (live:true) rather than guess or silently proceed.
638
- */
639
- export function playbookInvocation(artifacts: ArtifactStore, id: string, provided: Record<string, string> = {}): string {
640
- const playbook = requireKind(artifacts, id, "playbook");
645
+ /** Idempotent (INSERT OR IGNORE at the storage layer): containing an already-nested child is a no-op, not an error. Both contains/part_of edges are written atomically -- matches tasks.contain's own shape. */
646
+ export function containPlaybook(artifacts: ArtifactStore, parentId: string, childId: string, context?: ArtifactEventContext): Artifact {
647
+ requireLocallyOwnedContent(requireKind(artifacts, parentId, "playbook"));
648
+ requireLocallyOwnedContent(requireKind(artifacts, childId, "playbook"));
649
+ if (parentId === childId) throw new Error(`playbook "${parentId}" cannot contain itself`);
650
+ artifacts.link({ from: parentId, relation: "contains", to: childId }, context);
651
+ artifacts.link({ from: childId, relation: "part_of", to: parentId }, context);
652
+ return showPlaybook(artifacts, parentId);
653
+ }
654
+
655
+ /** Idempotent: uncontaining an already-absent nesting is a no-op. Both contains/part_of edges are removed atomically. */
656
+ export function uncontainPlaybook(artifacts: ArtifactStore, parentId: string, childId: string, context?: ArtifactEventContext): Artifact {
657
+ requireLocallyOwnedContent(requireKind(artifacts, parentId, "playbook"));
658
+ requireLocallyOwnedContent(requireKind(artifacts, childId, "playbook"));
659
+ artifacts.unlink({ from: parentId, relation: "contains", to: childId }, context);
660
+ artifacts.unlink({ from: childId, relation: "part_of", to: parentId }, context);
661
+ return showPlaybook(artifacts, parentId);
662
+ }
663
+
664
+ /** Idempotent: depending on an already-declared prerequisite is a no-op, not an error. Unlike tasks.depend, this never rejects a cycle at write time -- playbookInvocation degrades a composition cycle to a marker at render time instead, the same posture already established for Skill-calls-Skill and Playbook-calls-Playbook via `contains`. */
665
+ export function dependPlaybook(artifacts: ArtifactStore, id: string, dependencyId: string, context?: ArtifactEventContext): Artifact {
666
+ requireLocallyOwnedContent(requireKind(artifacts, id, "playbook"));
667
+ requireLocallyOwnedContent(requireKind(artifacts, dependencyId, "playbook"));
668
+ if (id === dependencyId) throw new Error(`playbook "${id}" cannot depend on itself`);
669
+ artifacts.link({ from: id, relation: "depends_on", to: dependencyId }, context);
670
+ return showPlaybook(artifacts, id);
671
+ }
672
+
673
+ /** Idempotent: undepending an already-absent prerequisite is a no-op. */
674
+ export function undependPlaybook(artifacts: ArtifactStore, id: string, dependencyId: string, context?: ArtifactEventContext): Artifact {
675
+ requireLocallyOwnedContent(requireKind(artifacts, id, "playbook"));
676
+ requireLocallyOwnedContent(requireKind(artifacts, dependencyId, "playbook"));
677
+ artifacts.unlink({ from: id, relation: "depends_on", to: dependencyId }, context);
678
+ return showPlaybook(artifacts, id);
679
+ }
680
+
681
+ /** Renders trigger/body/arguments/steps/tools into readable guidance -- the flat, non-recursive part of a Playbook's own invocation, shared by the top-level render and by a nested composed call. */
682
+ function playbookInvocationBody(playbook: Artifact, provided: Record<string, string>): string {
641
683
  const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
642
684
  const steps = Array.isArray(playbook.extra["steps"]) ? playbook.extra["steps"].filter((step): step is string => typeof step === "string") : [];
643
685
  const tools = Array.isArray(playbook.extra["tools"]) ? playbook.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
@@ -649,7 +691,7 @@ export function playbookInvocation(artifacts: ArtifactStore, id: string, provide
649
691
  return `- ${argument.name} (${qualifier}${argument.description ? `: ${argument.description}` : ""}) -- not yet provided`;
650
692
  });
651
693
  const missingRequired = declaredArguments.filter((argument) => argument.required && provided[argument.name] === undefined);
652
- const sections = [[
694
+ return [
653
695
  `Apply Papyrus playbook "${playbook.title}".`,
654
696
  `Trigger: ${trigger}`,
655
697
  ...(playbook.body ? [`Context: ${playbook.body}`] : []),
@@ -659,11 +701,56 @@ export function playbookInvocation(artifacts: ArtifactStore, id: string, provide
659
701
  : []),
660
702
  ...(steps.length ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
661
703
  ...(tools.length ? [`Tools: ${tools.join(", ")}`] : []),
662
- ].join("\n")];
704
+ ].join("\n");
705
+ }
706
+
707
+ /**
708
+ * Renders trigger/steps/tools/arguments into readable guidance, plus any real linked artifacts.
709
+ * Two relations compose recursively, each with distinct wording matching Tasks' own semantics:
710
+ * `contains` nests a child playbook -- its full steps render AFTER this playbook's own, as
711
+ * "run as part of this one". `depends_on` chains a prerequisite -- its full steps render BEFORE
712
+ * this playbook's own, as "complete this first". Every other relation (references, relates_to,
713
+ * etc.) still gets the flat one-line "Linked context" pointer, unchanged. Bounded and
714
+ * cycle-safe -- a composition cycle degrades to a marker instead of infinite-looping, matching
715
+ * skillInvocation's own cycle-safety discipline.
716
+ * `provided` is the caller's already-known argument values (e.g. from the conversation so far);
717
+ * any declared *required* argument missing from it is called out explicitly, directing the agent
718
+ * to discuss (live:true) rather than guess or silently proceed. `visited` and `depth` are
719
+ * recursion-internal; callers should not pass them.
720
+ */
721
+ export function playbookInvocation(artifacts: ArtifactStore, id: string, provided: Record<string, string> = {}, visited: Set<string> = new Set(), depth = 0): string {
722
+ const playbook = requireKind(artifacts, id, "playbook");
723
+ visited.add(id);
724
+
663
725
  const edges = artifacts.relationships({ artifactIds: [id] }).filter((edge) => edge.from === id).slice(0, PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS);
664
- const linkedLines = edges
665
- .map((edge) => { const target = artifacts.get(edge.to); return target ? `- ${edge.relation} ${target.kind} "${target.title}"` : undefined; })
666
- .filter((line): line is string => line !== undefined);
667
- if (linkedLines.length > 0) sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedLines].join("\n"));
726
+ const linkedArtifactLines: string[] = [];
727
+ const nestedSections: string[] = []; // contains -- rendered after this playbook's own body
728
+ const prerequisiteSections: string[] = []; // depends_on -- rendered before this playbook's own body
729
+ for (const edge of edges) {
730
+ const target = artifacts.get(edge.to);
731
+ if (!target) continue; // dangling edge -- defensive, should not happen
732
+ const isComposing = target.kind === "playbook" && (edge.relation === "contains" || edge.relation === "depends_on");
733
+ if (!isComposing) {
734
+ linkedArtifactLines.push(`- ${edge.relation} ${target.kind} "${target.title}"`);
735
+ continue;
736
+ }
737
+ const bucket = edge.relation === "contains" ? nestedSections : prerequisiteSections;
738
+ const role = edge.relation === "contains" ? "nested" : "prerequisite";
739
+ if (visited.has(target.id)) {
740
+ bucket.push(`Also linked via ${edge.relation} to ${role} playbook "${target.title}" -- already invoked above in this chain, not repeated.`);
741
+ } else if (depth + 1 > PLAYBOOK_INVOCATION_MAX_CALL_DEPTH) {
742
+ bucket.push(`Also linked via ${edge.relation} to ${role} playbook "${target.title}" -- call depth limit reached, invoke it separately.`);
743
+ } else {
744
+ const nested = playbookInvocation(artifacts, target.id, provided, visited, depth + 1);
745
+ bucket.push(edge.relation === "contains"
746
+ ? `Nested playbook (contains) "${target.title}" -- run as part of this one:\n${nested}`
747
+ : `Prerequisite playbook (depends_on) "${target.title}" -- complete this FIRST, before the steps below:\n${nested}`);
748
+ }
749
+ }
750
+
751
+ const sections = [...prerequisiteSections, playbookInvocationBody(playbook, provided), ...nestedSections];
752
+ if (linkedArtifactLines.length > 0) {
753
+ sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedArtifactLines].join("\n"));
754
+ }
668
755
  return sections.join("\n\n");
669
756
  }
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ export { connectPapyrusClient, resolvePushChannelTarget, type PapyrusClient, typ
23
23
  export type { DiscussionAndRounds } from "./discussion-service.ts";
24
24
  export { NOTE_DISPOSITIONS } from "./note-service.ts";
25
25
  export type { OperationName, SchemaState } from "./service.ts";
26
- export type { SkillWorkflowRunResult } from "./skill-execution.ts";
26
+ export type { WorkflowRunResult } from "./workflow-execution.ts";
27
27
  export { projectArtifactRelationships } from "./artifact-relationship-view.ts";
28
28
  export { taskContext } from "./task-context.ts";
29
29
  export { projectTaskExecution, type TaskExecutionPlan, type TaskExecutionState } from "./task-execution.ts";
@@ -1,15 +1,34 @@
1
1
  /**
2
2
  * modules/playbooks.ts — Playbooks as a Papyrus-native registered module.
3
3
  *
4
- * A Playbook (trigger + ordered steps an agent reads and follows) is a completely different
5
- * beast from a Skill (a mechanically instantiated artifact-template or workflow blueprint) --
6
- * its own kind, not a subtype squeezed into "skill". See domain-services.ts's Playbook section
7
- * for the full rationale.
4
+ * A Playbook (trigger + ordered steps, contains/depends_on composition, predefined
5
+ * rules/docs) is a completely different beast from a Skill (a mechanically instantiated
6
+ * artifact-template or workflow blueprint) at the AUTHORING level -- but playbooks.invoke
7
+ * recycles the exact same materialization engine workflow Skills use (playbook-execution.ts
8
+ * compiles a Playbook's composition tree into a SkillDefinition, then hands off to
9
+ * workflow-execution.ts's shared core). See domain-services.ts's Playbook section and
10
+ * playbook-definition.ts for the full rationale.
8
11
  */
9
- import { assignPlaybookProject, createPlaybook, listPlaybooks, playbookInvocation, showPlaybook, transitionPlaybook, updatePlaybook } from "../domain-services.ts";
12
+ import {
13
+ assignPlaybookProject,
14
+ containPlaybook,
15
+ createPlaybook,
16
+ dependPlaybook,
17
+ listPlaybooks,
18
+ playbookInvocation,
19
+ showPlaybook,
20
+ transitionPlaybook,
21
+ uncontainPlaybook,
22
+ undependPlaybook,
23
+ updatePlaybook,
24
+ } from "../domain-services.ts";
10
25
  import type { OperationDefinition } from "../module-registry.ts";
11
26
  import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
12
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 { invokePlaybook } from "../playbook-execution.ts";
31
+ import type { Tasks } from "../task-service.ts";
13
32
 
14
33
  const MODULE_ID = "playbooks";
15
34
 
@@ -41,6 +60,11 @@ const eventContext = (input: OperationInput) => ({
41
60
  sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
42
61
  });
43
62
 
63
+ const eventContextFor = (input: OperationInput, source: string) => {
64
+ const context = eventContext(input);
65
+ return { ...context, source: context.source ?? source };
66
+ };
67
+
44
68
  const artifactFilter = (input: OperationInput) => ({
45
69
  status: optionalString(input, "status"),
46
70
  text: optionalString(input, "text"),
@@ -50,29 +74,53 @@ const artifactFilter = (input: OperationInput) => ({
50
74
 
51
75
  /** 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. */
52
76
  export const PLAYBOOKS_OPERATION_NAMES = [
53
- "playbooks.create", "playbooks.list", "playbooks.show", "playbooks.invoke", "playbooks.enable", "playbooks.disable", "playbooks.assign_project", "playbooks.update",
77
+ "playbooks.create", "playbooks.list", "playbooks.show", "playbooks.invoke", "playbooks.preview", "playbooks.enable", "playbooks.disable", "playbooks.assign_project", "playbooks.update",
78
+ "playbooks.contain", "playbooks.uncontain", "playbooks.depend", "playbooks.undepend",
54
79
  ] as const;
55
80
 
56
- export function playbooksOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore): OperationDefinition[] {
81
+ export interface PlaybooksModuleDeps {
82
+ artifacts: ArtifactStore;
83
+ events: TaskEventStore;
84
+ scopes: TaskScopeStore;
85
+ /** Docs/Rules/Skills/Playbooks project scoping (distinct from `scopes`, which is Task-run project scoping for playbooks.invoke's materialized tasks). */
86
+ artifactScopes: ArtifactScopeStore;
87
+ /** Used for exactly one thing: focusing the entry task after a successful invoke -- the one safety-checked Tasks operation this module needs, not bulk graph construction (that goes straight through artifacts/events/scopes in playbook-execution.ts, mirroring workflow-execution.ts). */
88
+ tasks: Tasks;
89
+ }
90
+
91
+ export function playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks }: PlaybooksModuleDeps): OperationDefinition[] {
57
92
  const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
58
93
  name, moduleId: MODULE_ID, execute,
59
94
  });
60
95
  return [
61
- define("playbooks.create", (input: OperationInput) => createPlaybook(artifacts, scopes, {
96
+ define("playbooks.create", (input: OperationInput) => createPlaybook(artifacts, artifactScopes, {
62
97
  title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
63
98
  steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
64
99
  arguments: input["arguments"],
65
100
  labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
66
101
  projectRoot: optionalString(input, "project_root"),
67
102
  }, eventContext(input))),
68
- define("playbooks.list", (input: OperationInput) => listPlaybooks(artifacts, scopes, artifactFilter(input))),
103
+ define("playbooks.list", (input: OperationInput) => listPlaybooks(artifacts, artifactScopes, artifactFilter(input))),
69
104
  define("playbooks.show", (input: OperationInput) => showPlaybook(artifacts, string(input, "id"))),
70
- define("playbooks.invoke", (input: OperationInput) => playbookInvocation(artifacts, string(input, "id"), input["arguments"] as Record<string, string> | undefined)),
105
+ define("playbooks.preview", (input: OperationInput) => playbookInvocation(artifacts, string(input, "id"), input["arguments"] as Record<string, string> | undefined)),
106
+ define("playbooks.invoke", (input: OperationInput) => {
107
+ const result = invokePlaybook(artifacts, string(input, "id"), {
108
+ runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
109
+ arguments: input["arguments"] as Record<string, unknown> | undefined,
110
+ }, { events, scopes, projectRoot: optionalString(input, "project_root"), context: eventContextFor(input, "playbook-run") });
111
+ if ("missingArguments" in result) return result;
112
+ tasks.focus(result.entryTaskId, eventContextFor(input, "playbook-run"));
113
+ return result;
114
+ }),
71
115
  define("playbooks.enable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "enable", eventContext(input))),
72
116
  define("playbooks.disable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "disable", eventContext(input))),
73
- define("playbooks.assign_project", (input: OperationInput) => assignPlaybookProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
117
+ define("playbooks.assign_project", (input: OperationInput) => assignPlaybookProject(artifacts, artifactScopes, string(input, "id"), optionalString(input, "project_root"))),
74
118
  define("playbooks.update", (input: OperationInput) => updatePlaybook(artifacts, string(input, "id"), {
75
119
  title: optionalString(input, "title"), body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
76
120
  }, eventContext(input))),
121
+ define("playbooks.contain", (input: OperationInput) => containPlaybook(artifacts, string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
122
+ define("playbooks.uncontain", (input: OperationInput) => uncontainPlaybook(artifacts, string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
123
+ define("playbooks.depend", (input: OperationInput) => dependPlaybook(artifacts, string(input, "id"), string(input, "dependency_id"), eventContext(input))),
124
+ define("playbooks.undepend", (input: OperationInput) => undependPlaybook(artifacts, string(input, "id"), string(input, "dependency_id"), eventContext(input))),
77
125
  ];
78
126
  }
@@ -12,7 +12,7 @@
12
12
  * skills.run depends on the Task-domain ports (TaskEventStore, TaskScopeStore) as
13
13
  * constructor parameters. These are shared port contracts every module may depend on,
14
14
  * the same way every module already depends on ArtifactStore — not "another module's
15
- * infrastructure" in the sense of a concrete class. skill-execution.ts already has this
15
+ * infrastructure" in the sense of a concrete class. workflow-execution.ts already has this
16
16
  * port dependency pre-existing; untangling it is a separate, larger concern than this
17
17
  * extraction.
18
18
  */
@@ -23,7 +23,7 @@ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
23
23
  import type { ArtifactStore } from "../ports/artifact-store.ts";
24
24
  import type { TaskEventStore } from "../ports/task-event-store.ts";
25
25
  import type { TaskScopeStore } from "../ports/task-scope-store.ts";
26
- import { instantiateSkillWorkflow } from "../skill-execution.ts";
26
+ import { instantiateSkillWorkflow } from "../workflow-execution.ts";
27
27
 
28
28
  const MODULE_ID = "skills";
29
29
 
@@ -0,0 +1,176 @@
1
+ /**
2
+ * playbook-definition.ts — compiles a Playbook's own steps/trigger/tools/arguments, plus its
3
+ * `contains` (nested) and `depends_on` (prerequisite) composition tree, into an in-memory
4
+ * SkillDefinition. This is the "recycle Papyrus Skills -> Playbooks" half of the redesign:
5
+ * rather than a second graph-materialization engine, a Playbook becomes prose that compiles
6
+ * down to the exact blueprint shape workflow Skills already use, then hands off to
7
+ * workflow-execution.ts's shared materializeWorkflowDefinition for the actual Task creation.
8
+ *
9
+ * One task blueprint per playbook-node root (a container, never itself gated) plus one per
10
+ * step (chained by sequential dependsOn); `contains`-linked playbooks nest their own root
11
+ * under the parent root and continue the parent's own step chain ("run as part of this one",
12
+ * after the parent's own steps); `depends_on`-linked playbooks compile as independent
13
+ * subtrees whose tails gate this node's first step ("complete this FIRST"). No SkillCallBlueprint
14
+ * indirection is used -- everything is inlined into one flat definition, since a Playbook's
15
+ * composition tree is fully known and owned at compile time, unlike a workflow Skill's nested
16
+ * pipeline step (which references another SKILL by id, resolved and executed independently).
17
+ */
18
+ import {
19
+ PLAYBOOK_INVOCATION_MAX_CALL_DEPTH,
20
+ PLAYBOOK_INVOCATION_MAX_CREATED_TASKS,
21
+ PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
22
+ } from "./constants.ts";
23
+ import type { Artifact } from "./domain/artifact.ts";
24
+ import type { SkillDefinition, SkillInputDefinition, SkillTaskBlueprint } from "./domain/skill-definition.ts";
25
+ import { validateSkillDefinition } from "./domain/skill-definition.ts";
26
+ import type { PlaybookArgument } from "./domain-services.ts";
27
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
28
+
29
+ /** A non-composing edge touching a playbook node, to be mirrored onto that node's generated root task once real task ids exist -- e.g. a Rule `gates` this playbook, or this playbook `references`/`documents` a Doc. Direction is preserved exactly: `from`/`to` name whichever side is NOT the playbook, and `ownerIsFrom` says which side the playbook (now the generated root task) occupies. */
30
+ export interface PlaybookExternalLink {
31
+ rootRef: string;
32
+ relation: string;
33
+ otherArtifactId: string;
34
+ /** true: playbook (root task) is the edge's `from`; false: playbook (root task) is the edge's `to`. */
35
+ ownerIsFrom: boolean;
36
+ }
37
+
38
+ export interface CompiledPlaybook {
39
+ definition: SkillDefinition;
40
+ /** The very first real leaf task in the whole tree's reading order -- what a caller should focus once materialized. */
41
+ entryRef: string;
42
+ externalLinks: PlaybookExternalLink[];
43
+ }
44
+
45
+ function requirePlaybook(artifacts: ArtifactStore, id: string): Artifact {
46
+ const playbook = artifacts.get(id);
47
+ if (!playbook) throw new Error(`playbook artifact "${id}" not found`);
48
+ if (playbook.kind !== "playbook") throw new Error(`artifact "${id}" is not a playbook`);
49
+ return playbook;
50
+ }
51
+
52
+ function stepsOf(playbook: Artifact): string[] {
53
+ return Array.isArray(playbook.extra["steps"]) ? playbook.extra["steps"].filter((step): step is string => typeof step === "string") : [];
54
+ }
55
+
56
+ function toolsOf(playbook: Artifact): string[] {
57
+ return Array.isArray(playbook.extra["tools"]) ? playbook.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
58
+ }
59
+
60
+ function argumentsOf(playbook: Artifact): PlaybookArgument[] {
61
+ return Array.isArray(playbook.extra["arguments"]) ? (playbook.extra["arguments"] as PlaybookArgument[]) : [];
62
+ }
63
+
64
+ /** The generated container task's own body -- purpose and context only. Steps are separate child tasks, so they are not re-listed here (that was the old text-dump shape). */
65
+ function rootTaskBody(playbook: Artifact): string {
66
+ const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
67
+ const tools = toolsOf(playbook);
68
+ return [
69
+ `Playbook "${playbook.title}".`,
70
+ `Trigger: ${trigger}`,
71
+ ...(playbook.body ? [`Context: ${playbook.body}`] : []),
72
+ ...(tools.length > 0 ? [`Tools: ${tools.join(", ")}`] : []),
73
+ "This task contains its steps as child tasks -- work through them in order as each becomes focused.",
74
+ ].join("\n");
75
+ }
76
+
77
+ function stepTitle(step: string): string {
78
+ const firstLine = step.split("\n")[0]!.trim();
79
+ return firstLine.length > 120 ? `${firstLine.slice(0, 117)}...` : firstLine;
80
+ }
81
+
82
+ interface CompileContext {
83
+ tasks: SkillTaskBlueprint[];
84
+ inputs: Record<string, SkillInputDefinition>;
85
+ externalLinks: PlaybookExternalLink[];
86
+ refCounter: { n: number };
87
+ }
88
+
89
+ interface CompileNodeResult {
90
+ rootRef: string;
91
+ /** First real leaf in this subtree's own reading order -- rootRef itself when the node has neither steps nor nested children. */
92
+ headRef: string;
93
+ /** Last real leaf in this subtree's own reading order -- what an enclosing "nested-after" continuation or successor prerequisite gate should depend on. */
94
+ tailRef: string;
95
+ }
96
+
97
+ function mergeArgument(inputs: Record<string, SkillInputDefinition>, argument: PlaybookArgument): void {
98
+ const existing = inputs[argument.name];
99
+ inputs[argument.name] = { type: "string", required: (existing?.required ?? false) || argument.required };
100
+ }
101
+
102
+ function compileNode(
103
+ artifacts: ArtifactStore,
104
+ playbookId: string,
105
+ ctx: CompileContext,
106
+ ancestorIds: ReadonlySet<string>,
107
+ depth: number,
108
+ parentRef: string | undefined,
109
+ incomingPrecedingRefs: string[],
110
+ ): CompileNodeResult {
111
+ if (ancestorIds.has(playbookId)) throw new Error(`playbook composition cycle includes "${playbookId}"`);
112
+ if (depth > PLAYBOOK_INVOCATION_MAX_CALL_DEPTH) throw new Error(`playbook composition exceeds ${PLAYBOOK_INVOCATION_MAX_CALL_DEPTH} levels`);
113
+ const nextAncestors = new Set([...ancestorIds, playbookId]);
114
+
115
+ const playbook = requirePlaybook(artifacts, playbookId);
116
+ for (const argument of argumentsOf(playbook)) mergeArgument(ctx.inputs, argument);
117
+
118
+ const rootRef = `pb${ctx.refCounter.n++}`;
119
+ const rootBlueprint: SkillTaskBlueprint = { ref: rootRef, title: playbook.title, body: rootTaskBody(playbook), ...(parentRef ? { parent: parentRef } : {}) };
120
+ ctx.tasks.push(rootBlueprint);
121
+ if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS) throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
122
+
123
+ const edges = artifacts.relationships({ artifactIds: [playbookId] }).slice(0, PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS);
124
+ const prerequisiteIds = edges.filter((edge) => edge.from === playbookId && edge.relation === "depends_on").map((edge) => edge.to)
125
+ .filter((id) => artifacts.get(id)?.kind === "playbook");
126
+ const nestedIds = edges.filter((edge) => edge.from === playbookId && edge.relation === "contains").map((edge) => edge.to)
127
+ .filter((id) => artifacts.get(id)?.kind === "playbook");
128
+ for (const edge of edges) {
129
+ const isComposingFrom = edge.from === playbookId && (edge.relation === "contains" || edge.relation === "depends_on") && artifacts.get(edge.to)?.kind === "playbook";
130
+ if (isComposingFrom) continue;
131
+ if (edge.from === playbookId) ctx.externalLinks.push({ rootRef, relation: edge.relation, otherArtifactId: edge.to, ownerIsFrom: true });
132
+ else if (edge.to === playbookId) ctx.externalLinks.push({ rootRef, relation: edge.relation, otherArtifactId: edge.from, ownerIsFrom: false });
133
+ }
134
+
135
+ const prerequisiteTailRefs: string[] = [];
136
+ let headRef: string | undefined;
137
+ for (const prerequisiteId of prerequisiteIds) {
138
+ const result = compileNode(artifacts, prerequisiteId, ctx, nextAncestors, depth + 1, undefined, []);
139
+ prerequisiteTailRefs.push(result.tailRef);
140
+ if (headRef === undefined) headRef = result.headRef;
141
+ }
142
+
143
+ let cursorPrecedingRefs = [...incomingPrecedingRefs, ...prerequisiteTailRefs];
144
+ let tailRef = rootRef;
145
+ for (const [index, step] of stepsOf(playbook).entries()) {
146
+ const stepRef = `${rootRef}-s${index}`;
147
+ ctx.tasks.push({ ref: stepRef, title: stepTitle(step), body: step, parent: rootRef, dependsOn: cursorPrecedingRefs });
148
+ if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS) throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
149
+ if (headRef === undefined) headRef = stepRef;
150
+ cursorPrecedingRefs = [stepRef];
151
+ tailRef = stepRef;
152
+ }
153
+
154
+ for (const nestedId of nestedIds) {
155
+ const result = compileNode(artifacts, nestedId, ctx, nextAncestors, depth + 1, rootRef, cursorPrecedingRefs);
156
+ if (headRef === undefined) headRef = result.headRef;
157
+ cursorPrecedingRefs = [result.tailRef];
158
+ tailRef = result.tailRef;
159
+ }
160
+
161
+ if (tailRef === rootRef && cursorPrecedingRefs.length > 0) rootBlueprint.dependsOn = cursorPrecedingRefs;
162
+ return { rootRef, headRef: headRef ?? rootRef, tailRef };
163
+ }
164
+
165
+ /** Pure and read-only: creates no artifacts. Cycle/depth-bounded exactly like playbookInvocation's own traversal, but a composition cycle here is a hard error (real Tasks would be created, unlike a text render degrading to a marker). */
166
+ export function compilePlaybookDefinition(artifacts: ArtifactStore, playbookId: string): CompiledPlaybook {
167
+ const ctx: CompileContext = { tasks: [], inputs: {}, externalLinks: [], refCounter: { n: 0 } };
168
+ const { headRef } = compileNode(artifacts, playbookId, ctx, new Set(), 0, undefined, []);
169
+ const definition = validateSkillDefinition({
170
+ version: 1,
171
+ inputs: ctx.inputs,
172
+ blueprints: { docs: [], rules: [], tasks: ctx.tasks, skills: [] },
173
+ links: [],
174
+ });
175
+ return { definition, entryRef: headRef, externalLinks: ctx.externalLinks };
176
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * playbook-execution.ts — playbooks.invoke's real implementation: compile the Playbook's
3
+ * composition tree (playbook-definition.ts) into a SkillDefinition, materialize it through
4
+ * workflow-execution.ts's shared engine, mirror any pre-existing Rule/Doc links onto the
5
+ * generated root task, and report which task to focus. No text is rendered here -- every
6
+ * step is its own Task, and only the currently-focused one is ever surfaced to an agent
7
+ * (via the existing Task Focus system-prompt pointer), which is what actually avoids the
8
+ * old text-dump problem: one page at a time, not the whole book at once.
9
+ */
10
+ import type { SkillArgumentValue } from "./domain/skill-definition.ts";
11
+ import { compilePlaybookDefinition } from "./playbook-definition.ts";
12
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
13
+ import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
14
+ import { materializeWorkflowDefinition, type WorkflowRunHistory } from "./workflow-execution.ts";
15
+ import type { TaskExecutionPlan } from "./task-execution.ts";
16
+
17
+ export interface InvokePlaybookInput {
18
+ runId?: string;
19
+ arguments?: Record<string, unknown>;
20
+ }
21
+
22
+ export interface PlaybookInvocationResult {
23
+ playbookId: string;
24
+ runId: string;
25
+ arguments: Record<string, SkillArgumentValue>;
26
+ created: { docs: string[]; rules: string[]; tasks: string[] };
27
+ rootTaskIds: string[];
28
+ /** The one task to focus -- the first real leaf in the whole composition tree's reading order (deepest prerequisite's own first step, or this playbook's own first step, or its first nested child's, or the container task itself when there is nothing else). */
29
+ entryTaskId: string;
30
+ execution: TaskExecutionPlan;
31
+ }
32
+
33
+ export interface PlaybookMissingArguments {
34
+ playbookId: string;
35
+ /** Nothing was created: ask the human for these (discuss tool, live:true) before invoking again with them supplied. */
36
+ missingArguments: string[];
37
+ }
38
+
39
+ function missingRequiredInputs(inputs: Record<string, { required?: boolean; default?: SkillArgumentValue }>, provided: Record<string, unknown>): string[] {
40
+ return Object.entries(inputs)
41
+ .filter(([name, input]) => input.required && provided[name] === undefined && input.default === undefined)
42
+ .map(([name]) => name);
43
+ }
44
+
45
+ /** Reads each newly-created task's own playbookRun.ref tag back off the store -- avoids threading an extra ref-to-id map out of materializeWorkflowDefinition's existing, already-stable return shape. */
46
+ function resolveRefToTaskId(artifacts: ArtifactStore, taskIds: string[]): Map<string, string> {
47
+ const map = new Map<string, string>();
48
+ for (const taskId of taskIds) {
49
+ const lineage = artifacts.get(taskId)?.extra["playbookRun"];
50
+ if (typeof lineage !== "object" || lineage === null || Array.isArray(lineage)) continue;
51
+ const ref = (lineage as Record<string, unknown>)["ref"];
52
+ if (typeof ref === "string") map.set(ref, taskId);
53
+ }
54
+ return map;
55
+ }
56
+
57
+ export function invokePlaybook(
58
+ artifacts: ArtifactStore,
59
+ playbookId: string,
60
+ input: InvokePlaybookInput,
61
+ history?: WorkflowRunHistory,
62
+ ): PlaybookInvocationResult | PlaybookMissingArguments {
63
+ const compiled = compilePlaybookDefinition(artifacts, playbookId);
64
+ const missingArguments = missingRequiredInputs(compiled.definition.inputs, input.arguments ?? {});
65
+ if (missingArguments.length > 0) return { playbookId, missingArguments };
66
+
67
+ const atomic = history ? history.events.atomic.bind(history.events) : requireAtomicArtifactStore(artifacts).atomic.bind(requireAtomicArtifactStore(artifacts));
68
+ return atomic(() => {
69
+ const result = materializeWorkflowDefinition(
70
+ artifacts,
71
+ { ownerId: playbookId, extraKey: "playbookRun", labelPrefix: "playbook-run" },
72
+ compiled.definition,
73
+ { runId: input.runId, arguments: input.arguments, focusRef: compiled.entryRef },
74
+ history,
75
+ new Set(),
76
+ 0,
77
+ );
78
+ const refToTaskId = resolveRefToTaskId(artifacts, result.created.tasks);
79
+ for (const link of compiled.externalLinks) {
80
+ const taskId = refToTaskId.get(link.rootRef);
81
+ if (!taskId) continue; // defensive -- every rootRef this compiler emits is always materialized
82
+ if (link.ownerIsFrom) artifacts.link({ from: taskId, relation: link.relation, to: link.otherArtifactId });
83
+ else artifacts.link({ from: link.otherArtifactId, relation: link.relation, to: taskId });
84
+ }
85
+ return {
86
+ playbookId,
87
+ runId: result.runId,
88
+ arguments: result.arguments,
89
+ created: { docs: result.created.docs, rules: result.created.rules, tasks: result.created.tasks },
90
+ rootTaskIds: result.rootTaskIds,
91
+ entryTaskId: result.entryTaskId!,
92
+ execution: result.execution,
93
+ };
94
+ });
95
+ }
package/src/service.ts CHANGED
@@ -409,10 +409,15 @@ function handlers(
409
409
  "playbooks.list": forwardToModule("playbooks.list"),
410
410
  "playbooks.show": forwardToModule("playbooks.show"),
411
411
  "playbooks.invoke": forwardToModule("playbooks.invoke"),
412
+ "playbooks.preview": forwardToModule("playbooks.preview"),
412
413
  "playbooks.enable": forwardToModule("playbooks.enable"),
413
414
  "playbooks.disable": forwardToModule("playbooks.disable"),
414
415
  "playbooks.assign_project": forwardToModule("playbooks.assign_project"),
415
416
  "playbooks.update": forwardToModule("playbooks.update"),
417
+ "playbooks.contain": forwardToModule("playbooks.contain"),
418
+ "playbooks.uncontain": forwardToModule("playbooks.uncontain"),
419
+ "playbooks.depend": forwardToModule("playbooks.depend"),
420
+ "playbooks.undepend": forwardToModule("playbooks.undepend"),
416
421
  "skills.instantiate": (input) => {
417
422
  const templateId = string(input, "template_id");
418
423
  const template = artifacts.get(templateId);
@@ -476,7 +481,7 @@ export function createPapyrusService(path: string): PapyrusService {
476
481
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
477
482
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
478
483
  moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
479
- moduleRegistry.registerAll(playbooksOperations(artifacts, artifactScopes));
484
+ moduleRegistry.registerAll(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks }));
480
485
  moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
481
486
  const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db), moduleRegistry, authority);
482
487
  const state = (): SchemaState => {
@@ -26,9 +26,25 @@ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
26
26
  export interface InstantiateSkillWorkflowInput {
27
27
  runId?: string;
28
28
  arguments?: Record<string, unknown>;
29
+ /** When set, names one blueprint task ref whose resolved real task id is returned as `entryTaskId` -- e.g. the first-in-reading-order step of a compiled Playbook, so the caller can focus it without recomputing the ref-to-id mapping externally. */
30
+ focusRef?: string;
29
31
  }
30
32
 
31
- export interface SkillWorkflowRunResult {
33
+ /**
34
+ * Identifies who owns a materialized run for tagging purposes: which artifact gets the
35
+ * `triggers` edges to its root tasks, which extra-bag key records run lineage on each created
36
+ * artifact, and which label prefix scopes them. Defaults used by instantiateSkillWorkflow
37
+ * (ownerId: the skill's own id, extraKey: "skillRun", labelPrefix: "skill-run") are unchanged
38
+ * from before this was made pluggable -- a Playbook-compiled run supplies its own (playbook id,
39
+ * "playbookRun", "playbook-run") instead, the only thing that actually differs between the two.
40
+ */
41
+ export interface WorkflowLineage {
42
+ ownerId: string;
43
+ extraKey: string;
44
+ labelPrefix: string;
45
+ }
46
+
47
+ export interface WorkflowRunResult {
32
48
  skillId: string;
33
49
  runId: string;
34
50
  arguments: Record<string, SkillArgumentValue>;
@@ -41,6 +57,8 @@ export interface SkillWorkflowRunResult {
41
57
  };
42
58
  /** Real starting points: for a nested skill-call root step, that nested run's own root tasks (recursively), not just "all its tasks". */
43
59
  rootTaskIds: string[];
60
+ /** Resolved from input.focusRef when supplied -- the one real task id a caller (e.g. Playbook invocation) should focus, undefined when focusRef was not requested or names an unknown ref. */
61
+ entryTaskId?: string;
44
62
  /** Scoped to this definition's own directly-created tasks only -- nested runs' tasks are real, graph-linked, and visible via /tasks graph, but not folded into this projection. */
45
63
  execution: TaskExecutionPlan;
46
64
  }
@@ -98,15 +116,15 @@ function renderDefinition(definition: SkillDefinition, arguments_: Record<string
98
116
  return validateSkillDefinition(rendered);
99
117
  }
100
118
 
101
- function withRunLabel(labels: string[] | undefined, runId: string): string[] {
102
- return [...new Set([...(labels ?? []), `skill-run:${runId}`])];
119
+ function withRunLabel(labels: string[] | undefined, labelPrefix: string, runId: string): string[] {
120
+ return [...new Set([...(labels ?? []), `${labelPrefix}:${runId}`])];
103
121
  }
104
122
 
105
- function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map<string, string>): TaskGraph {
123
+ function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map<string, string>, extraKey: string): TaskGraph {
106
124
  const byRef = new Map(definition.blueprints.tasks.map((task) => [task.ref, task]));
107
125
  const nodes: TaskNode[] = tasks.map((task) => {
108
- const ref = task.extra["skillRun"] && typeof task.extra["skillRun"] === "object"
109
- ? (task.extra["skillRun"] as Record<string, unknown>)["ref"] as string
126
+ const ref = task.extra[extraKey] && typeof task.extra[extraKey] === "object"
127
+ ? (task.extra[extraKey] as Record<string, unknown>)["ref"] as string
110
128
  : "";
111
129
  const blueprint = byRef.get(ref)!;
112
130
  return {
@@ -120,7 +138,8 @@ function executionGraph(tasks: Artifact[], definition: SkillDefinition, ids: Map
120
138
  return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
121
139
  }
122
140
 
123
- type SkillWorkflowHistory = { events: TaskEventStore; scopes: TaskScopeStore; projectRoot: string; context?: TaskEventContext };
141
+ /** projectRoot is optional -- skills.run always supplies one (workflow Skill runs are always project-scoped today), while a Playbook invocation may legitimately be ad hoc/cross-project (e.g. a lab-deploy playbook not tied to any one repo), landing its tasks in the same "unscoped" bucket Tasks.create already supports for a caller that omits projectRoot entirely. */
142
+ export type WorkflowRunHistory = { events: TaskEventStore; scopes: TaskScopeStore; projectRoot?: string; context?: TaskEventContext };
124
143
 
125
144
  /**
126
145
  * Public entry point: wraps one complete pipeline run (including every nested sub-pipeline
@@ -133,8 +152,8 @@ export function instantiateSkillWorkflow(
133
152
  artifacts: ArtifactStore,
134
153
  skillId: string,
135
154
  input: InstantiateSkillWorkflowInput = {},
136
- history?: SkillWorkflowHistory,
137
- ): SkillWorkflowRunResult {
155
+ history?: WorkflowRunHistory,
156
+ ): WorkflowRunResult {
138
157
  const run = () => runWorkflowSteps(artifacts, skillId, input, history, new Set(), 0);
139
158
  if (history) return history.events.atomic(run);
140
159
  return requireAtomicArtifactStore(artifacts).atomic(run);
@@ -154,19 +173,53 @@ function runWorkflowSteps(
154
173
  artifacts: ArtifactStore,
155
174
  skillId: string,
156
175
  input: InstantiateSkillWorkflowInput,
157
- history: SkillWorkflowHistory | undefined,
176
+ history: WorkflowRunHistory | undefined,
158
177
  ancestorSkillIds: ReadonlySet<string>,
159
178
  depth: number,
160
- ): SkillWorkflowRunResult {
179
+ ): WorkflowRunResult {
161
180
  if (ancestorSkillIds.has(skillId)) throw new Error(`skill workflow nesting cycle includes "${skillId}"`);
162
181
  if (depth > SKILL_WORKFLOW_MAX_NESTING_DEPTH) throw new Error(`skill workflow nesting exceeds ${SKILL_WORKFLOW_MAX_NESTING_DEPTH} levels`);
163
182
  const nextAncestors = new Set([...ancestorSkillIds, skillId]);
164
-
165
183
  const { definition } = requireWorkflowSkill(artifacts, skillId);
166
- const projectRoot = history ? normalizeProjectRoot(history.projectRoot) : undefined;
184
+ return materializeWorkflowDefinition(
185
+ artifacts,
186
+ { ownerId: skillId, extraKey: "skillRun", labelPrefix: "skill-run" },
187
+ definition,
188
+ input,
189
+ history,
190
+ nextAncestors,
191
+ depth,
192
+ );
193
+ }
194
+
195
+ /**
196
+ * The definition-materialization core, shared by workflow Skills (instantiateSkillWorkflow,
197
+ * via runWorkflowSteps above) and Playbook invocation (playbook-execution.ts): given an
198
+ * ALREADY-RESOLVED SkillDefinition -- fetched from a persisted Skill artifact for the Skill
199
+ * path, compiled in-memory from a Playbook's steps/trigger/arguments and its contains/
200
+ * depends_on composition tree for the Playbook path -- creates every blueprint artifact,
201
+ * wires dependsOn/parent/links, recurses into nested skill-call pipeline steps (a no-op for a
202
+ * Playbook-compiled definition, which never populates blueprints.skills), and tags every
203
+ * created artifact and the run's containing labels via `lineage` rather than a hardcoded
204
+ * "skillRun"/"skill-run" shape -- the only thing that differs between the two callers.
205
+ * `ancestorSkillIds`/`depth` are the same cycle/nesting-depth tracking runWorkflowSteps already
206
+ * enforced before this was extracted; a Playbook caller with no nested skill-calls to recurse
207
+ * into passes an empty set and depth 0 and never revisits this function itself.
208
+ */
209
+ export function materializeWorkflowDefinition(
210
+ artifacts: ArtifactStore,
211
+ lineage: WorkflowLineage,
212
+ definition: SkillDefinition,
213
+ input: InstantiateSkillWorkflowInput,
214
+ history: WorkflowRunHistory | undefined,
215
+ ancestorSkillIds: ReadonlySet<string>,
216
+ depth: number,
217
+ ): WorkflowRunResult {
218
+ const { ownerId, extraKey, labelPrefix } = lineage;
219
+ const projectRoot = history?.projectRoot !== undefined ? normalizeProjectRoot(history.projectRoot) : undefined;
167
220
  const arguments_ = resolveSkillArguments(definition, input.arguments);
168
221
  const rendered = renderDefinition(definition, arguments_);
169
- const runId = normalizeRunId(skillId, input.runId);
222
+ const runId = normalizeRunId(ownerId, input.runId);
170
223
  const refs = [
171
224
  ...rendered.blueprints.docs.map(({ ref }) => ref),
172
225
  ...rendered.blueprints.rules.map(({ ref }) => ref),
@@ -194,22 +247,22 @@ function runWorkflowSteps(
194
247
  title: blueprint.title,
195
248
  body: blueprint.body,
196
249
  subtype: blueprint.subtype,
197
- labels: withRunLabel(blueprint.labels, runId),
198
- extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
250
+ labels: withRunLabel(blueprint.labels, labelPrefix, runId),
251
+ extra: { ...(blueprint.extra ?? {}), [extraKey]: { id: runId, ownerId, ref: blueprint.ref } },
199
252
  }));
200
253
  const rules = rendered.blueprints.rules.map((blueprint) => artifacts.create({
201
254
  id: ids.get(blueprint.ref),
202
255
  kind: "rule",
203
256
  title: blueprint.title,
204
257
  body: blueprint.body,
205
- labels: withRunLabel(blueprint.labels, runId),
258
+ labels: withRunLabel(blueprint.labels, labelPrefix, runId),
206
259
  extra: {
207
260
  ...(blueprint.extra ?? {}),
208
261
  ...(blueprint.condition ? { condition: blueprint.condition } : {}),
209
262
  ...(blueprint.action ? { action: blueprint.action } : {}),
210
263
  ...(blueprint.severity ? { severity: blueprint.severity } : {}),
211
- skillRun: { id: runId, skillId, ref: blueprint.ref },
212
- scope: { type: "skill-run", runId, taskIds },
264
+ [extraKey]: { id: runId, ownerId, ref: blueprint.ref },
265
+ scope: { type: labelPrefix, runId, taskIds },
213
266
  },
214
267
  }));
215
268
  const tasks = rendered.blueprints.tasks.map((blueprint) => {
@@ -218,16 +271,16 @@ function runWorkflowSteps(
218
271
  kind: "task",
219
272
  title: blueprint.title,
220
273
  body: blueprint.body,
221
- labels: withRunLabel(blueprint.labels, runId),
222
- extra: { ...(blueprint.extra ?? {}), skillRun: { id: runId, skillId, ref: blueprint.ref } },
274
+ labels: withRunLabel(blueprint.labels, labelPrefix, runId),
275
+ extra: { ...(blueprint.extra ?? {}), [extraKey]: { id: runId, ownerId, ref: blueprint.ref } },
223
276
  });
224
277
  if (history) {
225
- history.scopes.assign(task.id, projectRoot, "cwd");
278
+ history.scopes.assign(task.id, projectRoot, projectRoot ? "cwd" : "unscoped");
226
279
  history.events.append({
227
280
  taskId: task.id,
228
281
  type: "created",
229
282
  actor: history.context?.actor ?? "system",
230
- source: history.context?.source ?? "skill-run",
283
+ source: history.context?.source ?? labelPrefix,
231
284
  toStatus: task.status as TaskStatus,
232
285
  ...(history.context?.sessionId === undefined ? {} : { sessionId: history.context.sessionId }),
233
286
  ...(history.context?.reason === undefined ? {} : { reason: history.context.reason }),
@@ -240,7 +293,7 @@ function runWorkflowSteps(
240
293
  // nested run actually produced. stepTaskIds/stepRootTaskIds map EVERY step ref (task or
241
294
  // skill-call) to the task id(s) it resolves to, so dependsOn/parent wiring below treats
242
295
  // both kinds of step uniformly.
243
- const nestedRuns: SkillWorkflowRunResult[] = [];
296
+ const nestedRuns: WorkflowRunResult[] = [];
244
297
  const stepTaskIds = new Map<string, string[]>(tasks.map((task, index) => [rendered.blueprints.tasks[index]!.ref, [task.id]]));
245
298
  const stepRootTaskIds = new Map<string, string[]>(
246
299
  tasks.map((task, index) => [rendered.blueprints.tasks[index]!.ref, (rendered.blueprints.tasks[index]!.dependsOn?.length ?? 0) === 0 ? [task.id] : []]),
@@ -251,7 +304,7 @@ function runWorkflowSteps(
251
304
  call.skillId,
252
305
  { runId: `${runId}-${call.ref}`, arguments: call.arguments },
253
306
  history,
254
- nextAncestors,
307
+ ancestorSkillIds,
255
308
  depth + 1,
256
309
  );
257
310
  nestedRuns.push(nested);
@@ -298,14 +351,14 @@ function runWorkflowSteps(
298
351
  .flatMap((call) => stepRootTaskIds.get(call.ref) ?? []),
299
352
  ];
300
353
  for (const task of rendered.blueprints.tasks) {
301
- if ((task.dependsOn?.length ?? 0) === 0) artifacts.link({ from: skillId, relation: "triggers", to: ids.get(task.ref)! });
354
+ if ((task.dependsOn?.length ?? 0) === 0) artifacts.link({ from: ownerId, relation: "triggers", to: ids.get(task.ref)! });
302
355
  }
303
356
  for (const call of rendered.blueprints.skills as SkillCallBlueprint[]) {
304
- if ((call.dependsOn?.length ?? 0) === 0) artifacts.link({ from: skillId, relation: "triggers", to: call.skillId });
357
+ if ((call.dependsOn?.length ?? 0) === 0) artifacts.link({ from: ownerId, relation: "triggers", to: call.skillId });
305
358
  }
306
359
 
307
360
  return {
308
- skillId,
361
+ skillId: ownerId,
309
362
  runId,
310
363
  arguments: arguments_,
311
364
  created: {
@@ -315,6 +368,7 @@ function runWorkflowSteps(
315
368
  skillRuns: [...nestedRuns.map((run) => run.runId), ...nestedRuns.flatMap((run) => run.created.skillRuns)],
316
369
  },
317
370
  rootTaskIds,
318
- execution: projectTaskExecution(executionGraph(tasks, rendered, ids)),
371
+ ...(input.focusRef !== undefined && ids.has(input.focusRef) ? { entryTaskId: ids.get(input.focusRef)! } : {}),
372
+ execution: projectTaskExecution(executionGraph(tasks, rendered, ids, extraKey)),
319
373
  };
320
374
  }