@danypops/papyrus 0.50.2 → 0.51.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.50.2",
3
+ "version": "0.51.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
  "main": "./src/index.ts",
@@ -53,6 +53,7 @@ const createCommand = buildCommand({
53
53
  extraJson?: Record<string, unknown>;
54
54
  argumentsJson?: unknown[] | Record<string, unknown>;
55
55
  projectRoot?: string;
56
+ projectsJson?: string[];
56
57
  },
57
58
  ) {
58
59
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("playbooks.create", {
@@ -65,6 +66,7 @@ const createCommand = buildCommand({
65
66
  extra: flags.extraJson,
66
67
  arguments: flags.argumentsJson,
67
68
  project_root: flags.projectRoot,
69
+ projects: flags.projectsJson,
68
70
  });
69
71
  render.call(this, artifact, `Created playbook: ${artifactLabel(artifact)}`);
70
72
  },
@@ -91,18 +93,29 @@ const createCommand = buildCommand({
91
93
  optional: true,
92
94
  },
93
95
  projectRoot: { brief: "Project scope", kind: "parsed", parse: String, placeholder: "path", optional: true },
96
+ projectsJson: {
97
+ brief: "JSON string array of project id/name/alias/root references, taking precedence over --project-root",
98
+ kind: "parsed",
99
+ parse: parseStringArray,
100
+ placeholder: "json",
101
+ optional: true,
102
+ },
94
103
  },
95
104
  },
96
105
  docs: { brief: "Create a Playbook" },
97
106
  });
98
107
 
99
108
  const listCommand = buildCommand({
100
- func: async function (this: PlaybooksContext, flags: { status?: string; text?: string; limit?: number; projectRoot?: string }) {
109
+ func: async function (
110
+ this: PlaybooksContext,
111
+ flags: { status?: string; text?: string; limit?: number; projectRoot?: string; applicable?: boolean },
112
+ ) {
101
113
  const rows = await this.client.call<Record<string, unknown>, CliArtifact[]>("playbooks.list", {
102
114
  status: flags.status,
103
115
  text: flags.text,
104
116
  limit: flags.limit,
105
117
  project_root: flags.projectRoot,
118
+ applicable: flags.applicable,
106
119
  });
107
120
  render.call(this, rows, rows.length === 0 ? "No playbooks found." : rows.map((row) => artifactLabel(row)).join("\n"));
108
121
  },
@@ -112,6 +125,11 @@ const listCommand = buildCommand({
112
125
  text: { brief: "Substring match against title/body", kind: "parsed", parse: String, placeholder: "text", optional: true },
113
126
  limit: { brief: "Maximum playbooks to return", kind: "parsed", parse: numberParser, optional: true },
114
127
  projectRoot: { brief: "Project scope", kind: "parsed", parse: String, placeholder: "path", optional: true },
128
+ applicable: {
129
+ brief: "With --project-root: list global Playbooks plus Playbooks applicable to it, instead of exact membership",
130
+ kind: "boolean",
131
+ optional: true,
132
+ },
115
133
  },
116
134
  },
117
135
  docs: { brief: "List Playbooks" },
@@ -213,6 +231,93 @@ const assignProjectCommand = buildCommand({
213
231
  docs: { brief: "Reassign a Playbook's project scope, or unscope it" },
214
232
  });
215
233
 
234
+ interface CliArtifactScope {
235
+ artifactId: string;
236
+ mode: string;
237
+ projectIds: string[];
238
+ source: string;
239
+ }
240
+
241
+ function renderScope(scope: CliArtifactScope): string {
242
+ return scope.mode === "global" ? "global (applies to every project)" : `projects: ${scope.projectIds.join(", ")}`;
243
+ }
244
+
245
+ const scopeCommand = buildCommand({
246
+ func: async function (this: PlaybooksContext, _flags: Record<string, never>, id: string) {
247
+ const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.scope", { id });
248
+ render.call(this, scope, renderScope(scope));
249
+ },
250
+ parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Playbook id", parse: String, placeholder: "id" }] } },
251
+ docs: { brief: "Show a Playbook's real project scope" },
252
+ });
253
+
254
+ const setGlobalCommand = buildCommand({
255
+ func: async function (this: PlaybooksContext, _flags: Record<string, never>, id: string) {
256
+ const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.set_global", { id });
257
+ render.call(this, scope, renderScope(scope));
258
+ },
259
+ parameters: { flags: {}, positional: { kind: "tuple", parameters: [{ brief: "Playbook id", parse: String, placeholder: "id" }] } },
260
+ docs: { brief: "Make a Playbook apply in every project" },
261
+ });
262
+
263
+ const addProjectCommand = buildCommand({
264
+ func: async function (this: PlaybooksContext, _flags: Record<string, never>, id: string, project: string) {
265
+ const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.add_project", { id, project });
266
+ render.call(this, scope, renderScope(scope));
267
+ },
268
+ parameters: {
269
+ flags: {},
270
+ positional: {
271
+ kind: "tuple",
272
+ parameters: [
273
+ { brief: "Playbook id", parse: String, placeholder: "id" },
274
+ { brief: "Project id/name/alias/root to add", parse: String, placeholder: "project" },
275
+ ],
276
+ },
277
+ },
278
+ docs: { brief: "Add one project to a Playbook's membership" },
279
+ });
280
+
281
+ const removeProjectCommand = buildCommand({
282
+ func: async function (this: PlaybooksContext, _flags: Record<string, never>, id: string, project: string) {
283
+ const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.remove_project", { id, project });
284
+ render.call(this, scope, renderScope(scope));
285
+ },
286
+ parameters: {
287
+ flags: {},
288
+ positional: {
289
+ kind: "tuple",
290
+ parameters: [
291
+ { brief: "Playbook id", parse: String, placeholder: "id" },
292
+ { brief: "Project id/name/alias/root to remove", parse: String, placeholder: "project" },
293
+ ],
294
+ },
295
+ },
296
+ docs: { brief: "Remove one project from a Playbook's membership" },
297
+ });
298
+
299
+ const replaceProjectsCommand = buildCommand({
300
+ func: async function (this: PlaybooksContext, flags: { projectsJson: string[] }, id: string) {
301
+ const scope = await this.client.call<Record<string, unknown>, CliArtifactScope>("playbooks.replace_projects", {
302
+ id,
303
+ projects: flags.projectsJson,
304
+ });
305
+ render.call(this, scope, renderScope(scope));
306
+ },
307
+ parameters: {
308
+ flags: {
309
+ projectsJson: {
310
+ brief: "JSON string array of project id/name/alias/root references",
311
+ kind: "parsed",
312
+ parse: parseStringArray,
313
+ placeholder: "json",
314
+ },
315
+ },
316
+ positional: { kind: "tuple", parameters: [{ brief: "Playbook id", parse: String, placeholder: "id" }] },
317
+ },
318
+ docs: { brief: "Replace a Playbook's entire project membership" },
319
+ });
320
+
216
321
  const updateCommand = buildCommand({
217
322
  func: async function (this: PlaybooksContext, flags: { title?: string; body?: string; labelsJson?: string[] }, id: string) {
218
323
  if (flags.title === undefined && flags.body === undefined && flags.labelsJson === undefined)
@@ -275,6 +380,11 @@ const app = buildApplication(
275
380
  enable: buildEnableDisableCommand("enable"),
276
381
  disable: buildEnableDisableCommand("disable"),
277
382
  "assign-project": assignProjectCommand,
383
+ scope: scopeCommand,
384
+ "set-global": setGlobalCommand,
385
+ "add-project": addProjectCommand,
386
+ "remove-project": removeProjectCommand,
387
+ "replace-projects": replaceProjectsCommand,
278
388
  update: updateCommand,
279
389
  contain: buildPairedIdCommand(
280
390
  "playbooks.contain",
@@ -24,6 +24,7 @@ import type { ArtifactStore } from "../artifact/artifact-store.ts";
24
24
  import { playbooksOperations } from "../modules/playbooks.ts";
25
25
  import type { PlaybookInvocationResult, PlaybookMissingArguments } from "../playbook/playbook-execution.ts";
26
26
  import { listPlaybooks } from "../playbook/playbook-service.ts";
27
+ import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
27
28
  import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
28
29
  import type { TaskEventStore } from "../stores/task-event-store.ts";
29
30
  import type { TaskScopeStore } from "../stores/task-scope-store.ts";
@@ -56,6 +57,7 @@ export interface PlaybooksVehicleDeps {
56
57
  artifactScopes: ArtifactScopeStore;
57
58
  tasks: Tasks;
58
59
  sessionIdentity: SessionIdentity;
60
+ projectRegistry: ProjectRegistryStore;
59
61
  }
60
62
 
61
63
  /** Unscoped resolution -- a Playbook is commonly cross-project (e.g. a lab-deploy playbook), matching the hand-rolled tool's own resolutionRequest choice. */
@@ -66,9 +68,12 @@ function resolvePlaybookId(artifacts: ArtifactStore, scopes: ArtifactScopeStore,
66
68
  }
67
69
 
68
70
  export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, deps: PlaybooksVehicleDeps): void {
69
- const { artifacts, events, scopes, artifactScopes, tasks, sessionIdentity } = deps;
71
+ const { artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, projectRegistry } = deps;
70
72
  const moduleOperations = new Map(
71
- playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }).map((op) => [op.name, op]),
73
+ playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, registry: projectRegistry }).map((op) => [
74
+ op.name,
75
+ op,
76
+ ]),
72
77
  );
73
78
  /**
74
79
  * Every playbooks.* action funnels through here. invoke's own module handler re-runs
@@ -101,6 +106,7 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
101
106
  extra: { type: "object" },
102
107
  template_id: stringProp,
103
108
  project_root: stringProp,
109
+ projects: { type: "array" },
104
110
  actor: stringProp,
105
111
  source: stringProp,
106
112
  session_id: stringProp,
@@ -114,9 +120,9 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
114
120
 
115
121
  define(
116
122
  "list",
117
- "Lists Playbooks matching an optional status/text filter, scoped to project_root when given. Returns a lean summary (no body/steps) by default -- pass full: true for the complete artifact.",
123
+ "Lists Playbooks matching an optional status/text filter. project_root alone scopes to EXACT membership in that project (audit semantics); project_root plus applicable:true instead lists every Playbook APPLICABLE to it (global Playbooks plus Playbooks whose membership includes it) -- what pi-papyrus's before_agent_start uses to inject only relevant Playbooks. Returns a lean summary (no body/steps) by default -- pass full: true for the complete artifact.",
118
124
  "read",
119
- { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp, full: booleanProp },
125
+ { status: stringProp, text: stringProp, limit: numberProp, project_root: stringProp, applicable: booleanProp, full: booleanProp },
120
126
  [],
121
127
  (input) => input,
122
128
  );
@@ -200,6 +206,63 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
200
206
  (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
201
207
  );
202
208
 
209
+ define(
210
+ "scope",
211
+ "Shows a Playbook's real project scope: global (applies everywhere) or the bounded set of registered projects it applies to.",
212
+ "read",
213
+ { id: stringProp, name: stringProp },
214
+ [],
215
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
216
+ );
217
+
218
+ define(
219
+ "set_global",
220
+ "Makes a Playbook apply in every project, clearing any project membership. The only way to widen a project-bound Playbook back to global -- removing its last membership through remove_project is rejected instead.",
221
+ "local-write",
222
+ { id: stringProp, name: stringProp },
223
+ [],
224
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
225
+ );
226
+
227
+ define(
228
+ "add_project",
229
+ "Adds one registered project (exact id, name, alias, or root) to a Playbook's membership, switching it from global to project-bound if it was global. Idempotent if the project is already a member.",
230
+ "local-write",
231
+ {
232
+ id: stringProp,
233
+ name: stringProp,
234
+ project: { ...stringProp, description: "Exact project id, name, alias, or registered root to add." },
235
+ },
236
+ ["project"],
237
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
238
+ );
239
+
240
+ define(
241
+ "remove_project",
242
+ "Removes one registered project from a Playbook's membership. Rejected while it is the Playbook's only remaining membership -- call set_global first if the Playbook should stop being project-bound entirely.",
243
+ "local-write",
244
+ {
245
+ id: stringProp,
246
+ name: stringProp,
247
+ project: { ...stringProp, description: "Exact project id, name, alias, or registered root to remove." },
248
+ },
249
+ ["project"],
250
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
251
+ );
252
+
253
+ define(
254
+ "replace_projects",
255
+ "Replaces a Playbook's entire project membership with exactly this bounded, non-empty list of registered project references (id/name/alias/root). Use set_global instead to clear scoping entirely.",
256
+ "local-write",
257
+ {
258
+ id: stringProp,
259
+ name: stringProp,
260
+ projects: { type: "array", description: "Non-empty list of exact project id/name/alias/root references." },
261
+ },
262
+ ["projects"],
263
+ (input) => ({ ...input, id: resolvePlaybookId(artifacts, artifactScopes, input.id, input.name) }),
264
+ );
265
+
203
266
  define(
204
267
  "update",
205
268
  "Changes a Playbook's title/body/labels (at least one required). Refused for a read-only external projection.",
@@ -59,6 +59,7 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
59
59
  artifactScopes: deps.scopes,
60
60
  tasks: deps.tasks,
61
61
  sessionIdentity: deps.sessionIdentity,
62
+ projectRegistry: deps.projectRegistry,
62
63
  });
63
64
  registerTasksVehicleOperations(registry, { tasks: deps.tasks, artifacts: deps.artifacts, sessionIdentity: deps.sessionIdentity });
64
65
  registerDiscussVehicleOperations(registry, deps.discussions, deps.artifacts);
@@ -16,18 +16,24 @@ import type { ArtifactStore } from "../artifact/artifact-store.ts";
16
16
  import type { OperationDefinition } from "../module-registry.ts";
17
17
  import { invokePlaybook } from "../playbook/playbook-execution.ts";
18
18
  import {
19
+ addPlaybookProject,
19
20
  assignPlaybookProject,
20
21
  containPlaybook,
21
22
  createPlaybook,
22
23
  dependPlaybook,
23
24
  listPlaybooks,
24
25
  playbookInvocation,
26
+ playbookScope,
27
+ removePlaybookProject,
28
+ replacePlaybookProjects,
29
+ setPlaybookGlobal,
25
30
  showPlaybook,
26
31
  transitionPlaybook,
27
32
  uncontainPlaybook,
28
33
  undependPlaybook,
29
34
  updatePlaybook,
30
35
  } from "../playbook/playbook-service.ts";
36
+ import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
31
37
  import type { SessionIdentity } from "../session-identity/session-identity-service.ts";
32
38
  import type { TaskEventStore } from "../stores/task-event-store.ts";
33
39
  import type { TaskScopeStore } from "../stores/task-scope-store.ts";
@@ -47,12 +53,24 @@ const eventContextFor = (input: OperationInput, source: string) => {
47
53
  return { ...context, source: context.source ?? source };
48
54
  };
49
55
 
50
- const artifactFilter = (input: OperationInput) => ({
51
- status: optionalString(input, "status"),
52
- text: optionalString(input, "text"),
53
- limit: optionalNumber(input, "limit"),
54
- projectRoot: optionalString(input, "project_root"),
55
- });
56
+ /**
57
+ * applicable=true switches project_root's meaning from exact-membership audit listing to
58
+ * applicable listing (global Playbooks plus Playbooks whose membership includes it) -- see
59
+ * ListFilter's own doc comment on projectRoot vs applicableToProjectRoot. Used by pi-papyrus's
60
+ * before_agent_start to inject only Playbooks applicable to ctx.cwd, not every active Playbook
61
+ * across every project.
62
+ */
63
+ const artifactFilter = (input: OperationInput) => {
64
+ const projectRoot = optionalString(input, "project_root");
65
+ const applicable = optionalBoolean(input, "applicable") === true;
66
+ if (applicable && projectRoot === undefined) throw new Error("applicable requires project_root");
67
+ return {
68
+ status: optionalString(input, "status"),
69
+ text: optionalString(input, "text"),
70
+ limit: optionalNumber(input, "limit"),
71
+ ...(applicable ? { applicableToProjectRoot: projectRoot } : { projectRoot }),
72
+ };
73
+ };
56
74
 
57
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. */
58
76
  export const PLAYBOOKS_OPERATION_NAMES = [
@@ -64,6 +82,11 @@ export const PLAYBOOKS_OPERATION_NAMES = [
64
82
  "playbooks.enable",
65
83
  "playbooks.disable",
66
84
  "playbooks.assign_project",
85
+ "playbooks.scope",
86
+ "playbooks.set_global",
87
+ "playbooks.add_project",
88
+ "playbooks.remove_project",
89
+ "playbooks.replace_projects",
67
90
  "playbooks.update",
68
91
  "playbooks.contain",
69
92
  "playbooks.uncontain",
@@ -81,6 +104,7 @@ export interface PlaybooksModuleDeps {
81
104
  tasks: Tasks;
82
105
  /** Guards the same session_secret check tasks.focus's own operation enforces (guardFocusMutation in modules/tasks.ts) -- invoke's internal tasks.focus() call goes straight through the Tasks class, bypassing that operation wrapper entirely, so the check must be applied here instead of silently skipped. */
83
106
  sessionIdentity: SessionIdentity;
107
+ registry: ProjectRegistryStore;
84
108
  }
85
109
 
86
110
  export function playbooksOperations({
@@ -90,6 +114,7 @@ export function playbooksOperations({
90
114
  artifactScopes,
91
115
  tasks,
92
116
  sessionIdentity,
117
+ registry,
93
118
  }: PlaybooksModuleDeps): OperationDefinition[] {
94
119
  const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
95
120
  name,
@@ -113,8 +138,10 @@ export function playbooksOperations({
113
138
  extra: input.extra as Record<string, unknown> | undefined,
114
139
  templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
115
140
  projectRoot: optionalString(input, "project_root"),
141
+ projectReferences: input.projects as string[] | undefined,
116
142
  },
117
143
  eventContext(input),
144
+ registry,
118
145
  ),
119
146
  ),
120
147
  define("playbooks.list", (input: OperationInput) => {
@@ -133,7 +160,13 @@ export function playbooksOperations({
133
160
  runId: optionalString(input, "run_id") ?? optionalString(input, "runId"),
134
161
  arguments: input.arguments as Record<string, unknown> | undefined,
135
162
  },
136
- { events, scopes, projectRoot: optionalString(input, "project_root"), context: eventContextFor(input, "playbook-run") },
163
+ {
164
+ events,
165
+ scopes,
166
+ artifactScopes,
167
+ projectRoot: optionalString(input, "project_root"),
168
+ context: eventContextFor(input, "playbook-run"),
169
+ },
137
170
  );
138
171
  if ("missingArguments" in result) return result;
139
172
  const focusContext = eventContextFor(input, "playbook-run");
@@ -150,6 +183,17 @@ export function playbooksOperations({
150
183
  define("playbooks.assign_project", (input: OperationInput) =>
151
184
  assignPlaybookProject(artifacts, artifactScopes, string(input, "id"), optionalString(input, "project_root")),
152
185
  ),
186
+ define("playbooks.scope", (input: OperationInput) => playbookScope(artifacts, artifactScopes, string(input, "id"))),
187
+ define("playbooks.set_global", (input: OperationInput) => setPlaybookGlobal(artifacts, artifactScopes, string(input, "id"))),
188
+ define("playbooks.add_project", (input: OperationInput) =>
189
+ addPlaybookProject(artifacts, artifactScopes, registry, string(input, "id"), string(input, "project")),
190
+ ),
191
+ define("playbooks.remove_project", (input: OperationInput) =>
192
+ removePlaybookProject(artifacts, artifactScopes, registry, string(input, "id"), string(input, "project")),
193
+ ),
194
+ define("playbooks.replace_projects", (input: OperationInput) =>
195
+ replacePlaybookProjects(artifacts, artifactScopes, registry, string(input, "id"), (input.projects as string[] | undefined) ?? []),
196
+ ),
153
197
  define("playbooks.update", (input: OperationInput) =>
154
198
  updatePlaybook(
155
199
  artifacts,
@@ -25,9 +25,10 @@
25
25
  import type { Artifact } from "../artifact/artifact.ts";
26
26
  import { requireLocallyOwnedContent } from "../artifact/artifact.ts";
27
27
  import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
28
- import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
28
+ import type { ArtifactScope, ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
29
29
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
30
30
  import {
31
+ ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT,
31
32
  ARTIFACT_TITLE_MAX_LENGTH,
32
33
  PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH,
33
34
  PLAYBOOK_ARGUMENT_MAX_COUNT,
@@ -43,6 +44,7 @@ import {
43
44
  type BlueprintInputType,
44
45
  validateArgumentValue,
45
46
  } from "../domain/blueprint-definition.ts";
47
+ import { resolveProjectReference } from "../domain/project-registry.ts";
46
48
  import { normalizeProjectRoot } from "../domain/task-scope.ts";
47
49
  import {
48
50
  assertBodyBounds,
@@ -57,6 +59,7 @@ import {
57
59
  type TransitionTable,
58
60
  type UpdateContentInput,
59
61
  } from "../domain-service-shared.ts";
62
+ import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
60
63
 
61
64
  export interface PlaybookArgument {
62
65
  name: string;
@@ -220,6 +223,8 @@ export interface CreatePlaybookInput {
220
223
  extra?: Record<string, unknown>;
221
224
  templateId?: string;
222
225
  projectRoot?: string;
226
+ /** Bounded exact registered project references (id/name/alias/root) -- fail-closed unlike projectRoot's auto-register-by-root legacy form. Takes precedence over projectRoot when both are given. */
227
+ projectReferences?: string[];
223
228
  }
224
229
 
225
230
  export type PlaybookTransition = "enable" | "disable";
@@ -235,7 +240,11 @@ export function createPlaybook(
235
240
  scopes: ArtifactScopeStore,
236
241
  input: CreatePlaybookInput,
237
242
  context?: ArtifactEventContext,
243
+ registry?: ProjectRegistryStore,
238
244
  ): Artifact {
245
+ if (input.projectReferences !== undefined && input.projectReferences.length > 0 && registry === undefined) {
246
+ throw new Error("projectReferences requires a project registry");
247
+ }
239
248
  const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
240
249
  const declaredArguments = validatePlaybookArguments(input.arguments);
241
250
  const declaredSteps = validatePlaybookSteps(input.steps);
@@ -258,7 +267,15 @@ export function createPlaybook(
258
267
  },
259
268
  context,
260
269
  );
261
- scopes.assign(playbook.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
270
+ if (input.projectReferences !== undefined && input.projectReferences.length > 0) {
271
+ if (input.projectReferences.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
272
+ throw new Error(`projectReferences may include at most ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} entries`);
273
+ }
274
+ const ids = input.projectReferences.map((reference) => resolveProjectReference(registry!, reference).id);
275
+ scopes.replaceProjects(playbook.id, ids, "explicit");
276
+ } else {
277
+ scopes.assign(playbook.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
278
+ }
262
279
  return playbook;
263
280
  }
264
281
 
@@ -275,6 +292,65 @@ export function assignPlaybookProject(
275
292
  return assignArtifactProject(artifacts, scopes, id, "playbook", projectRoot);
276
293
  }
277
294
 
295
+ /**
296
+ * The multi-project scope surface playbooks.assign_project cannot express -- mirrors
297
+ * docs.ts's own docScope/setDocGlobal/addDocProject/removeDocProject/replaceDocProjects (which
298
+ * itself mirrors rules.ts's original). id is resolved through requireKind so these reject the
299
+ * same way against a non-Playbook or unknown id as every other playbooks.* mutation; the
300
+ * project REFERENCE (name/alias/root) is resolved through the shared registry's
301
+ * resolveProjectReference, so an unknown or ambiguous project fails closed with bounded
302
+ * candidates rather than silently creating a new registration.
303
+ */
304
+ export function playbookScope(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
305
+ requireKind(artifacts, id, "playbook");
306
+ return scopes.scope(id);
307
+ }
308
+
309
+ export function setPlaybookGlobal(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string): ArtifactScope {
310
+ requireKind(artifacts, id, "playbook");
311
+ return scopes.setGlobal(id, "explicit");
312
+ }
313
+
314
+ export function replacePlaybookProjects(
315
+ artifacts: ArtifactStore,
316
+ scopes: ArtifactScopeStore,
317
+ registry: ProjectRegistryStore,
318
+ id: string,
319
+ projectReferences: readonly string[],
320
+ ): ArtifactScope {
321
+ requireKind(artifacts, id, "playbook");
322
+ if (projectReferences.length === 0) throw new Error("projectReferences must be non-empty; use playbooks.set_global to clear scoping");
323
+ if (projectReferences.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
324
+ throw new Error(`projectReferences may include at most ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} entries`);
325
+ }
326
+ const ids = projectReferences.map((reference) => resolveProjectReference(registry, reference).id);
327
+ return scopes.replaceProjects(id, ids, "explicit");
328
+ }
329
+
330
+ export function addPlaybookProject(
331
+ artifacts: ArtifactStore,
332
+ scopes: ArtifactScopeStore,
333
+ registry: ProjectRegistryStore,
334
+ id: string,
335
+ projectReference: string,
336
+ ): ArtifactScope {
337
+ requireKind(artifacts, id, "playbook");
338
+ const project = resolveProjectReference(registry, projectReference);
339
+ return scopes.addProject(id, project.id, "explicit");
340
+ }
341
+
342
+ export function removePlaybookProject(
343
+ artifacts: ArtifactStore,
344
+ scopes: ArtifactScopeStore,
345
+ registry: ProjectRegistryStore,
346
+ id: string,
347
+ projectReference: string,
348
+ ): ArtifactScope {
349
+ requireKind(artifacts, id, "playbook");
350
+ const project = resolveProjectReference(registry, projectReference);
351
+ return scopes.removeProject(id, project.id);
352
+ }
353
+
278
354
  export function showPlaybook(artifacts: ArtifactStore, id: string): Artifact {
279
355
  requireKind(artifacts, id, "playbook");
280
356
  return artifacts.get(id, { tree: true })!;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { Artifact } from "../artifact/artifact.ts";
3
+ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
3
4
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
4
5
  import { requireAtomicArtifactStore } from "../artifact/atomic-artifact-store.ts";
5
6
  import {
@@ -150,8 +151,28 @@ function executionGraph(tasks: Artifact[], definition: BlueprintDefinition, ids:
150
151
  return { nodes, rootIds: nodes.filter((node) => node.parentIds.length === 0).map((node) => node.task.id) };
151
152
  }
152
153
 
153
- /** projectRoot is optional -- skills.run always supplies one (workflow-definition 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. */
154
- export type WorkflowRunHistory = { events: TaskEventStore; scopes: TaskScopeStore; projectRoot?: string; context?: TaskEventContext };
154
+ /**
155
+ * projectRoot is optional -- skills.run always supplies one (workflow-definition runs are always
156
+ * project-scoped today), while a Playbook invocation may legitimately be ad hoc/cross-project
157
+ * (e.g. a lab-deploy playbook not tied to any one repo), landing its tasks in the same
158
+ * "unscoped" bucket Tasks.create already supports for a caller that omits projectRoot entirely.
159
+ *
160
+ * artifactScopes is optional for the same reason: a caller with no Doc/Rule project-scoping
161
+ * concept at all (a bare workflow-definition run with no ArtifactScopeStore wired) still works
162
+ * unchanged, generated Docs/Rules simply staying unscoped/global like every artifact created
163
+ * before project scoping existed. When supplied, a generated Doc/Rule inherits the SAME
164
+ * destination projectRoot a generated Task already does via `scopes` -- the real fix for a
165
+ * previously-confirmed gap: generated Docs/Rules bypassed ArtifactScopeStore entirely, so a
166
+ * playbook invoked with a destination project still injected its own generated Rules into every
167
+ * other project's context.
168
+ */
169
+ export type WorkflowRunHistory = {
170
+ events: TaskEventStore;
171
+ scopes: TaskScopeStore;
172
+ artifactScopes?: ArtifactScopeStore;
173
+ projectRoot?: string;
174
+ context?: TaskEventContext;
175
+ };
155
176
 
156
177
  /**
157
178
  * Public entry point: wraps one complete pipeline run (including every nested sub-pipeline
@@ -307,8 +328,8 @@ export function materializeWorkflowDefinition(
307
328
  throw new TaskExecutionBoundExceededError(`workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
308
329
  }
309
330
 
310
- const docs = rendered.blueprints.docs.map((blueprint) =>
311
- artifacts.create({
331
+ const docs = rendered.blueprints.docs.map((blueprint) => {
332
+ const doc = artifacts.create({
312
333
  id: ids.get(blueprint.ref),
313
334
  kind: "doc",
314
335
  title: blueprint.title,
@@ -316,10 +337,12 @@ export function materializeWorkflowDefinition(
316
337
  subtype: blueprint.subtype,
317
338
  labels: withRunLabel(blueprint.labels, labelPrefix, runId),
318
339
  extra: { ...(blueprint.extra ?? {}), [extraKey]: { id: runId, ownerId, ref: blueprint.ref } },
319
- }),
320
- );
321
- const rules = rendered.blueprints.rules.map((blueprint) =>
322
- artifacts.create({
340
+ });
341
+ history?.artifactScopes?.assign(doc.id, projectRoot, projectRoot ? "cwd" : "unscoped");
342
+ return doc;
343
+ });
344
+ const rules = rendered.blueprints.rules.map((blueprint) => {
345
+ const rule = artifacts.create({
323
346
  id: ids.get(blueprint.ref),
324
347
  kind: "rule",
325
348
  title: blueprint.title,
@@ -331,10 +354,15 @@ export function materializeWorkflowDefinition(
331
354
  ...(blueprint.action ? { action: blueprint.action } : {}),
332
355
  ...(blueprint.severity ? { severity: blueprint.severity } : {}),
333
356
  [extraKey]: { id: runId, ownerId, ref: blueprint.ref },
357
+ // extra.scope is run-gating only (listInjectableRules' passesRunScope) -- an
358
+ // independent AND-condition alongside project-membership (appliesToProjectRoot,
359
+ // assigned just below), never a replacement for it.
334
360
  scope: { type: labelPrefix, runId, taskIds },
335
361
  },
336
- }),
337
- );
362
+ });
363
+ history?.artifactScopes?.assign(rule.id, projectRoot, projectRoot ? "cwd" : "unscoped");
364
+ return rule;
365
+ });
338
366
  const tasks = rendered.blueprints.tasks.map((blueprint) => {
339
367
  const task = artifacts.create({
340
368
  id: ids.get(blueprint.ref),
package/src/service.ts CHANGED
@@ -441,6 +441,11 @@ function handlers(
441
441
  "playbooks.enable": forwardToModule("playbooks.enable"),
442
442
  "playbooks.disable": forwardToModule("playbooks.disable"),
443
443
  "playbooks.assign_project": forwardToModule("playbooks.assign_project"),
444
+ "playbooks.scope": forwardToModule("playbooks.scope"),
445
+ "playbooks.set_global": forwardToModule("playbooks.set_global"),
446
+ "playbooks.add_project": forwardToModule("playbooks.add_project"),
447
+ "playbooks.remove_project": forwardToModule("playbooks.remove_project"),
448
+ "playbooks.replace_projects": forwardToModule("playbooks.replace_projects"),
444
449
  "playbooks.update": forwardToModule("playbooks.update"),
445
450
  "playbooks.contain": forwardToModule("playbooks.contain"),
446
451
  "playbooks.uncontain": forwardToModule("playbooks.uncontain"),
@@ -505,7 +510,9 @@ export function createPapyrusService(path: string): PapyrusService {
505
510
  moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
506
511
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority, projectRegistry));
507
512
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry));
508
- moduleRegistry.registerAll(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }));
513
+ moduleRegistry.registerAll(
514
+ playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, registry: projectRegistry }),
515
+ );
509
516
  moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
510
517
  const registry = handlers(artifacts, gates, tasks, notes, events, scopes, artifactScopes, () => migrateDb(db), moduleRegistry, authority);
511
518
  const state = (): SchemaState => {