@danypops/papyrus 0.35.2 → 0.35.3

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.35.3",
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
@@ -120,6 +120,10 @@ const USAGE = `Usage:
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]
@@ -889,8 +893,36 @@ export async function runPlaybooksCli(args: string[], client: TaskCliClient): Pr
889
893
  human = `${artifactLabel(artifact)}`;
890
894
  break;
891
895
  }
896
+ case "contain": {
897
+ if (!id || !second || positional.length !== 3) throw new Error("playbooks contain requires a parent id and child id");
898
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.contain", { parent_id: id, child_id: second });
899
+ result = artifact;
900
+ human = `Nested: ${second} → ${artifactLabel(artifact)}`;
901
+ break;
902
+ }
903
+ case "uncontain": {
904
+ if (!id || !second || positional.length !== 3) throw new Error("playbooks uncontain requires a parent id and child id");
905
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.uncontain", { parent_id: id, child_id: second });
906
+ result = artifact;
907
+ human = `Removed ${second} from ${artifactLabel(artifact)}`;
908
+ break;
909
+ }
910
+ case "depend": {
911
+ if (!id || !second || positional.length !== 3) throw new Error("playbooks depend requires a playbook id and prerequisite id");
912
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.depend", { id, dependency_id: second });
913
+ result = artifact;
914
+ human = `Dependency added: ${artifactLabel(artifact)} waits for ${second}`;
915
+ break;
916
+ }
917
+ case "undepend": {
918
+ if (!id || !second || positional.length !== 3) throw new Error("playbooks undepend requires a playbook id and prerequisite id");
919
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.undepend", { id, dependency_id: second });
920
+ result = artifact;
921
+ human = `Dependency removed: ${artifactLabel(artifact)} no longer waits for ${second}`;
922
+ break;
923
+ }
892
924
  default:
893
- throw new Error("playbooks action must be create, list, show, invoke, enable, disable, assign-project, or update");
925
+ throw new Error("playbooks action must be create, list, show, invoke, enable, disable, assign-project, update, contain, uncontain, depend, or undepend");
894
926
  }
895
927
  return json ? JSON.stringify(result) : human;
896
928
  }
package/src/constants.ts CHANGED
@@ -108,6 +108,8 @@ 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;
@@ -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,
@@ -526,7 +527,15 @@ export function transitionSkill(artifacts: ArtifactStore, id: string, action: Sk
526
527
  * Playbooks: a trigger and an ordered list of steps an agent reads and follows -- a completely
527
528
  * different beast from Skills, not a subtype of one. A Skill (artifact-template or workflow) is
528
529
  * 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.
530
+ * and followed. Like Tasks, a Playbook can be nested or chained with another Playbook:
531
+ * `contains`/`part_of` (containPlaybook/uncontainPlaybook) nests a sub-playbook inside a parent
532
+ * -- invoking the parent recursively embeds the nested one's own steps, run as part of it.
533
+ * `depends_on` (dependPlaybook/undependPlaybook) chains one playbook before another -- invoking
534
+ * the dependent recursively renders the prerequisite's steps FIRST, to be completed before the
535
+ * dependent's own. Both are bounded and cycle-safe: a composition cycle degrades to a marker at
536
+ * render time (playbookInvocation) rather than being rejected at link time -- unlike Tasks' own
537
+ * depends_on, which does reject a real dependency cycle up front because Task dependencies gate
538
+ * actual lifecycle execution, not just text rendering.
530
539
  */
531
540
  export interface PlaybookArgument {
532
541
  name: string;
@@ -629,15 +638,44 @@ export function transitionPlaybook(artifacts: ArtifactStore, id: string, action:
629
638
  return artifacts.setStatus(id, target, context)!;
630
639
  }
631
640
 
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");
641
+ /** 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. */
642
+ export function containPlaybook(artifacts: ArtifactStore, parentId: string, childId: string, context?: ArtifactEventContext): Artifact {
643
+ requireLocallyOwnedContent(requireKind(artifacts, parentId, "playbook"));
644
+ requireLocallyOwnedContent(requireKind(artifacts, childId, "playbook"));
645
+ if (parentId === childId) throw new Error(`playbook "${parentId}" cannot contain itself`);
646
+ artifacts.link({ from: parentId, relation: "contains", to: childId }, context);
647
+ artifacts.link({ from: childId, relation: "part_of", to: parentId }, context);
648
+ return showPlaybook(artifacts, parentId);
649
+ }
650
+
651
+ /** Idempotent: uncontaining an already-absent nesting is a no-op. Both contains/part_of edges are removed atomically. */
652
+ export function uncontainPlaybook(artifacts: ArtifactStore, parentId: string, childId: string, context?: ArtifactEventContext): Artifact {
653
+ requireLocallyOwnedContent(requireKind(artifacts, parentId, "playbook"));
654
+ requireLocallyOwnedContent(requireKind(artifacts, childId, "playbook"));
655
+ artifacts.unlink({ from: parentId, relation: "contains", to: childId }, context);
656
+ artifacts.unlink({ from: childId, relation: "part_of", to: parentId }, context);
657
+ return showPlaybook(artifacts, parentId);
658
+ }
659
+
660
+ /** 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`. */
661
+ export function dependPlaybook(artifacts: ArtifactStore, id: string, dependencyId: string, context?: ArtifactEventContext): Artifact {
662
+ requireLocallyOwnedContent(requireKind(artifacts, id, "playbook"));
663
+ requireLocallyOwnedContent(requireKind(artifacts, dependencyId, "playbook"));
664
+ if (id === dependencyId) throw new Error(`playbook "${id}" cannot depend on itself`);
665
+ artifacts.link({ from: id, relation: "depends_on", to: dependencyId }, context);
666
+ return showPlaybook(artifacts, id);
667
+ }
668
+
669
+ /** Idempotent: undepending an already-absent prerequisite is a no-op. */
670
+ export function undependPlaybook(artifacts: ArtifactStore, id: string, dependencyId: string, context?: ArtifactEventContext): Artifact {
671
+ requireLocallyOwnedContent(requireKind(artifacts, id, "playbook"));
672
+ requireLocallyOwnedContent(requireKind(artifacts, dependencyId, "playbook"));
673
+ artifacts.unlink({ from: id, relation: "depends_on", to: dependencyId }, context);
674
+ return showPlaybook(artifacts, id);
675
+ }
676
+
677
+ /** 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. */
678
+ function playbookInvocationBody(playbook: Artifact, provided: Record<string, string>): string {
641
679
  const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
642
680
  const steps = Array.isArray(playbook.extra["steps"]) ? playbook.extra["steps"].filter((step): step is string => typeof step === "string") : [];
643
681
  const tools = Array.isArray(playbook.extra["tools"]) ? playbook.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
@@ -649,7 +687,7 @@ export function playbookInvocation(artifacts: ArtifactStore, id: string, provide
649
687
  return `- ${argument.name} (${qualifier}${argument.description ? `: ${argument.description}` : ""}) -- not yet provided`;
650
688
  });
651
689
  const missingRequired = declaredArguments.filter((argument) => argument.required && provided[argument.name] === undefined);
652
- const sections = [[
690
+ return [
653
691
  `Apply Papyrus playbook "${playbook.title}".`,
654
692
  `Trigger: ${trigger}`,
655
693
  ...(playbook.body ? [`Context: ${playbook.body}`] : []),
@@ -659,11 +697,56 @@ export function playbookInvocation(artifacts: ArtifactStore, id: string, provide
659
697
  : []),
660
698
  ...(steps.length ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
661
699
  ...(tools.length ? [`Tools: ${tools.join(", ")}`] : []),
662
- ].join("\n")];
700
+ ].join("\n");
701
+ }
702
+
703
+ /**
704
+ * Renders trigger/steps/tools/arguments into readable guidance, plus any real linked artifacts.
705
+ * Two relations compose recursively, each with distinct wording matching Tasks' own semantics:
706
+ * `contains` nests a child playbook -- its full steps render AFTER this playbook's own, as
707
+ * "run as part of this one". `depends_on` chains a prerequisite -- its full steps render BEFORE
708
+ * this playbook's own, as "complete this first". Every other relation (references, relates_to,
709
+ * etc.) still gets the flat one-line "Linked context" pointer, unchanged. Bounded and
710
+ * cycle-safe -- a composition cycle degrades to a marker instead of infinite-looping, matching
711
+ * skillInvocation's own cycle-safety discipline.
712
+ * `provided` is the caller's already-known argument values (e.g. from the conversation so far);
713
+ * any declared *required* argument missing from it is called out explicitly, directing the agent
714
+ * to discuss (live:true) rather than guess or silently proceed. `visited` and `depth` are
715
+ * recursion-internal; callers should not pass them.
716
+ */
717
+ export function playbookInvocation(artifacts: ArtifactStore, id: string, provided: Record<string, string> = {}, visited: Set<string> = new Set(), depth = 0): string {
718
+ const playbook = requireKind(artifacts, id, "playbook");
719
+ visited.add(id);
720
+
663
721
  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"));
722
+ const linkedArtifactLines: string[] = [];
723
+ const nestedSections: string[] = []; // contains -- rendered after this playbook's own body
724
+ const prerequisiteSections: string[] = []; // depends_on -- rendered before this playbook's own body
725
+ for (const edge of edges) {
726
+ const target = artifacts.get(edge.to);
727
+ if (!target) continue; // dangling edge -- defensive, should not happen
728
+ const isComposing = target.kind === "playbook" && (edge.relation === "contains" || edge.relation === "depends_on");
729
+ if (!isComposing) {
730
+ linkedArtifactLines.push(`- ${edge.relation} ${target.kind} "${target.title}"`);
731
+ continue;
732
+ }
733
+ const bucket = edge.relation === "contains" ? nestedSections : prerequisiteSections;
734
+ const role = edge.relation === "contains" ? "nested" : "prerequisite";
735
+ if (visited.has(target.id)) {
736
+ bucket.push(`Also linked via ${edge.relation} to ${role} playbook "${target.title}" -- already invoked above in this chain, not repeated.`);
737
+ } else if (depth + 1 > PLAYBOOK_INVOCATION_MAX_CALL_DEPTH) {
738
+ bucket.push(`Also linked via ${edge.relation} to ${role} playbook "${target.title}" -- call depth limit reached, invoke it separately.`);
739
+ } else {
740
+ const nested = playbookInvocation(artifacts, target.id, provided, visited, depth + 1);
741
+ bucket.push(edge.relation === "contains"
742
+ ? `Nested playbook (contains) "${target.title}" -- run as part of this one:\n${nested}`
743
+ : `Prerequisite playbook (depends_on) "${target.title}" -- complete this FIRST, before the steps below:\n${nested}`);
744
+ }
745
+ }
746
+
747
+ const sections = [...prerequisiteSections, playbookInvocationBody(playbook, provided), ...nestedSections];
748
+ if (linkedArtifactLines.length > 0) {
749
+ sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedArtifactLines].join("\n"));
750
+ }
668
751
  return sections.join("\n\n");
669
752
  }
@@ -6,7 +6,7 @@
6
6
  * its own kind, not a subtype squeezed into "skill". See domain-services.ts's Playbook section
7
7
  * for the full rationale.
8
8
  */
9
- import { assignPlaybookProject, createPlaybook, listPlaybooks, playbookInvocation, showPlaybook, transitionPlaybook, updatePlaybook } from "../domain-services.ts";
9
+ import { assignPlaybookProject, containPlaybook, createPlaybook, dependPlaybook, listPlaybooks, playbookInvocation, showPlaybook, transitionPlaybook, uncontainPlaybook, undependPlaybook, updatePlaybook } from "../domain-services.ts";
10
10
  import type { OperationDefinition } from "../module-registry.ts";
11
11
  import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
12
12
  import type { ArtifactStore } from "../ports/artifact-store.ts";
@@ -51,6 +51,7 @@ const artifactFilter = (input: OperationInput) => ({
51
51
  /** 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
52
  export const PLAYBOOKS_OPERATION_NAMES = [
53
53
  "playbooks.create", "playbooks.list", "playbooks.show", "playbooks.invoke", "playbooks.enable", "playbooks.disable", "playbooks.assign_project", "playbooks.update",
54
+ "playbooks.contain", "playbooks.uncontain", "playbooks.depend", "playbooks.undepend",
54
55
  ] as const;
55
56
 
56
57
  export function playbooksOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore): OperationDefinition[] {
@@ -74,5 +75,9 @@ export function playbooksOperations(artifacts: ArtifactStore, scopes: ArtifactSc
74
75
  define("playbooks.update", (input: OperationInput) => updatePlaybook(artifacts, string(input, "id"), {
75
76
  title: optionalString(input, "title"), body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
76
77
  }, eventContext(input))),
78
+ define("playbooks.contain", (input: OperationInput) => containPlaybook(artifacts, string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
79
+ define("playbooks.uncontain", (input: OperationInput) => uncontainPlaybook(artifacts, string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
80
+ define("playbooks.depend", (input: OperationInput) => dependPlaybook(artifacts, string(input, "id"), string(input, "dependency_id"), eventContext(input))),
81
+ define("playbooks.undepend", (input: OperationInput) => undependPlaybook(artifacts, string(input, "id"), string(input, "dependency_id"), eventContext(input))),
77
82
  ];
78
83
  }
package/src/service.ts CHANGED
@@ -413,6 +413,10 @@ function handlers(
413
413
  "playbooks.disable": forwardToModule("playbooks.disable"),
414
414
  "playbooks.assign_project": forwardToModule("playbooks.assign_project"),
415
415
  "playbooks.update": forwardToModule("playbooks.update"),
416
+ "playbooks.contain": forwardToModule("playbooks.contain"),
417
+ "playbooks.uncontain": forwardToModule("playbooks.uncontain"),
418
+ "playbooks.depend": forwardToModule("playbooks.depend"),
419
+ "playbooks.undepend": forwardToModule("playbooks.undepend"),
416
420
  "skills.instantiate": (input) => {
417
421
  const templateId = string(input, "template_id");
418
422
  const template = artifacts.get(templateId);