@danypops/papyrus 0.35.1 → 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.1",
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/client.ts CHANGED
@@ -57,3 +57,24 @@ export async function connectPapyrusClient(dir: string = daemonStateDir()): Prom
57
57
  throw new Error("Papyrus daemon state is stale or unreachable; restart papyrus.service");
58
58
  }
59
59
  }
60
+
61
+ export interface PushChannelTarget {
62
+ /** ws:// URL for the daemon's push-invalidation channel (see push-channel.ts in daemon-kit). */
63
+ url: string;
64
+ token: string;
65
+ }
66
+
67
+ /**
68
+ * Narrow surface for a push-channel consumer -- exposes only what's needed to open
69
+ * the WebSocket (url derived from the same handle connectPapyrusClient reads, token),
70
+ * not daemon-state.ts's whole internal handle shape. Returns undefined rather than
71
+ * throwing when the daemon has never started (no token/port on disk yet); a caller
72
+ * wiring this into a UI widget already tolerates "daemon not running" for its own
73
+ * fetch-based refresh and should treat push-channel absence the same way -- fall
74
+ * back to polling rather than surfacing an error.
75
+ */
76
+ export function resolvePushChannelTarget(dir: string = daemonStateDir()): PushChannelTarget | undefined {
77
+ const handle = readDaemonHandle(dir);
78
+ if (!handle) return undefined;
79
+ return { url: `${handle.baseUrl.replace(/^http/, "ws")}/push`, token: handle.token };
80
+ }
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;
package/src/daemon.ts CHANGED
@@ -1,18 +1,47 @@
1
+ import { PushChannel } from "@danypops/daemon-kit/push-channel";
1
2
  import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, WAL_CHECKPOINT_INTERVAL_MS, dbPath } from "./constants.ts";
2
3
  import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
3
4
  import { createApp, createPapyrusService } from "./service.ts";
4
5
  import { logEvent } from "./log.ts";
5
6
 
7
+ /**
8
+ * Operations that never change what a Task-graph reader (the pi-papyrus widget's
9
+ * push subscriber) would see -- excluded from the "tasks" publish so a read call
10
+ * doesn't trigger a pointless extra refresh. Defaults to publishing for anything
11
+ * not in this set, including future operations -- a missed push (stale widget for
12
+ * up to one poll interval, the existing fallback) is a far smaller cost than a
13
+ * silently-uncovered new mutation.
14
+ */
15
+ const TASK_READ_ONLY_OPERATIONS = new Set([
16
+ "tasks.active", "tasks.context", "tasks.event_feed", "tasks.focused",
17
+ "tasks.graph", "tasks.history", "tasks.list", "tasks.plan", "tasks.scope", "tasks.show",
18
+ ]);
19
+
6
20
  /** Start the supervised, long-running Papyrus service. */
7
21
  export function serveMain(): void {
8
22
  const stateDir = daemonStateDir();
9
23
  const token = loadOrCreateToken(stateDir);
10
24
  const service = createPapyrusService(dbPath());
11
- const app = createApp({ service, token });
25
+ const pushChannel = new PushChannel({ token });
26
+ const app = createApp({
27
+ service,
28
+ token,
29
+ onOperationExecuted: (operation) => {
30
+ if (operation.startsWith("tasks.") && !TASK_READ_ONLY_OPERATIONS.has(operation)) {
31
+ pushChannel.publish("tasks", { operation });
32
+ }
33
+ },
34
+ });
12
35
  const server = Bun.serve({
13
36
  hostname: DAEMON_HOST,
14
37
  port: 0,
15
- fetch: (request) => app.fetch(request),
38
+ fetch: (request, bunServer) => {
39
+ if (new URL(request.url).pathname === "/push") return pushChannel.upgrade(request, bunServer) ?? undefined;
40
+ return app.fetch(request);
41
+ },
42
+ // A no-op fallback when pushChannel never calls server.upgrade() is safe: Bun only
43
+ // invokes these handlers for a connection that actually upgraded.
44
+ websocket: pushChannel.websocketHandlers(),
16
45
  });
17
46
  if (!server.port) {
18
47
  service.close();
@@ -192,7 +192,10 @@ export class Discussions {
192
192
 
193
193
  show(discussionId: string): DiscussionAndRounds {
194
194
  const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
195
- return { discussion, rounds: this.rounds.list({ discussionId }) };
195
+ // DISCUSSION_MAX_ROUNDS is the hard cap enforced at reply() time, so fetching exactly that
196
+ // many always returns the complete transcript -- never the round store's own smaller
197
+ // default page size, which would silently drop the tail of a long deliberation.
198
+ return { discussion, rounds: this.rounds.list({ discussionId, limit: DISCUSSION_MAX_ROUNDS }) };
196
199
  }
197
200
 
198
201
  listRounds(discussionId: string, afterRound?: number, limit?: number): DiscussionRound[] {
@@ -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
  }
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ export type { TaskViewSelection } from "./domain/task-scope.ts";
19
19
  export type { ArtifactStore } from "./ports/artifact-store.ts";
20
20
  export type { GraphRenderer } from "./ports/graph-renderer.ts";
21
21
 
22
- export { connectPapyrusClient, type PapyrusClient } from "./client.ts";
22
+ export { connectPapyrusClient, resolvePushChannelTarget, type PapyrusClient, type PushChannelTarget } from "./client.ts";
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";
@@ -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);
@@ -534,7 +538,17 @@ async function readOperationBody(request: Request): Promise<{ op?: unknown; inpu
534
538
  return JSON.parse(new TextDecoder().decode(bytes)) as { op?: unknown; input?: unknown };
535
539
  }
536
540
 
537
- export function createApp(deps: { service: PapyrusService; token: string }): { fetch(request: Request): Promise<Response> } {
541
+ export function createApp(deps: {
542
+ service: PapyrusService;
543
+ token: string;
544
+ /**
545
+ * Fired after an operation executes successfully -- decoupled from any specific
546
+ * consumer (push-invalidation, audit logging, metrics) so this HTTP layer stays
547
+ * agnostic of what a caller does with the notification. The composition root
548
+ * (daemon.ts) wires this to a PushChannel; tests and other embedders can ignore it.
549
+ */
550
+ onOperationExecuted?: (operation: string, input: OperationInput) => void;
551
+ }): { fetch(request: Request): Promise<Response> } {
538
552
  return {
539
553
  async fetch(request: Request): Promise<Response> {
540
554
  if (request.headers.get("authorization") !== `Bearer ${deps.token}`) {
@@ -555,7 +569,9 @@ export function createApp(deps: { service: PapyrusService; token: string }): { f
555
569
  if (typeof input !== "object" || input === null || Array.isArray(input)) {
556
570
  return json({ error: "input must be an object" }, { status: 400 });
557
571
  }
558
- return json({ result: await deps.service.execute(body.op, input as OperationInput) });
572
+ const result = await deps.service.execute(body.op, input as OperationInput);
573
+ deps.onOperationExecuted?.(body.op, input as OperationInput);
574
+ return json({ result });
559
575
  } catch (error) {
560
576
  const status = error instanceof PayloadTooLargeError ? 413 : error instanceof UnknownOperationError ? 404 : error instanceof InvalidSessionSecretError ? 403 : 400;
561
577
  return json({ error: error instanceof Error ? error.message : String(error) }, { status });