@danypops/papyrus 0.50.2 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@ The daemon, CLI, and domain services behind Papyrus's graph artifact store: evid
9
9
  - [Schema protocol](#schema-protocol)
10
10
  - [Hierarchy and traversal](#hierarchy-and-traversal)
11
11
  - [Playbooks](#playbooks)
12
+ - [Project scope](#project-scope)
12
13
  - [Naming vs. ids](#naming-vs-ids)
13
14
  - [Mutability](#mutability)
14
15
  - [Idempotent lifecycle mutations](#idempotent-lifecycle-mutations)
@@ -75,6 +76,23 @@ papyrus playbooks invoke <playbook-id> \
75
76
  --json
76
77
  ```
77
78
 
79
+ ## Project scope
80
+
81
+ A Doc, Rule, or Playbook is either global (applies everywhere) or bound to a bounded, non-empty set of registered projects — never both, and never inferred from an accidentally empty membership. `scope` inspects an artifact's own mode and membership; `add_project`/`remove_project` mutate one membership at a time (`add_project` is idempotent for an already-present project; `remove_project` for an absent one); `replace_projects` swaps the whole set atomically; `set_global` is the only way back to global — removing an artifact's last remaining membership through `remove_project` is rejected instead of silently widening it. `assign_project` remains a documented compatibility delegate for the pre-multi-project single-root shape (replace, not add).
82
+
83
+ Listing follows the same distinction: an omitted project filter keeps the existing bounded all-artifacts search; `project_root` alone means exact membership (audit semantics — only artifacts actually bound to that project); `project_root` plus `applicable: true` means every artifact *applicable* to that project instead — global artifacts plus artifacts whose membership includes it. `pi-papyrus`'s own context injection uses `applicable` for both Rules (`rules.injectable`) and Playbooks, so a project-bound artifact never leaks into an unrelated project's prompt. Project scope governs applicability and discovery, never authorization: exact-id access works regardless of scope, the same as it always has.
84
+
85
+ A Playbook's own definition scope is a separate concern from where `playbooks.invoke` sends its generated artifacts. An unscoped or global Playbook can still be invoked with a destination `project_root`; the generated Tasks, Docs, and Rules inherit that destination, while the Playbook definition itself is untouched. A run-created Rule's injection requires both its own run to be active *and* its generated project membership to match — either alone is not enough.
86
+
87
+ The shared project catalog behind all of this (`projects.list`/`projects.resolve`/`projects.register`) is the exact one Tasks has always used, and `tasks.projects`/`tasks.resolve_project`/`tasks.register_project` remain fully working, documented compatibility delegates over it.
88
+
89
+ ```bash
90
+ papyrus docs scope <doc-id> --json
91
+ papyrus rules add-project <rule-id> Lector --json
92
+ papyrus playbooks list --project-root <root> --applicable --json
93
+ papyrus projects register /path/to/project --name Lector --json
94
+ ```
95
+
78
96
  ## Naming vs. ids
79
97
 
80
98
  Every agent-facing domain (tasks, docs, rules, playbooks, notes, discuss) addresses artifacts by `name` (the exact title) anywhere `id` would otherwise be required — `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searched across every kind), `task_name` (rules gate, discuss block/unblock), and `blocks_task_names` (discuss open). Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an ambiguous name's error lists the real ids, the one place disambiguation needs them. A returned result leads with name and status; id surfaces only when two artifacts in the same result share a title. `id` itself keeps working, in every tool.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.50.2",
3
+ "version": "0.52.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",
@@ -0,0 +1,105 @@
1
+ import type { CommandContext } from "@stricli/core";
2
+ import { buildApplication, buildCommand, buildRouteMap, numberParser } from "@stricli/core";
3
+ import type { PapyrusClient } from "../client.ts";
4
+ import { runStricliToString } from "./stricli-run.ts";
5
+
6
+ type ProjectsClient = Pick<PapyrusClient, "call">;
7
+
8
+ interface ProjectsContext extends CommandContext {
9
+ readonly client: ProjectsClient;
10
+ readonly json: boolean;
11
+ }
12
+
13
+ function parseStringArray(value: string): string[] {
14
+ const parsed = JSON.parse(value) as unknown;
15
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) throw new Error("value must be a JSON string array");
16
+ return parsed as string[];
17
+ }
18
+
19
+ interface CliProject {
20
+ id: string;
21
+ name: string;
22
+ aliases: string[];
23
+ projectRoot: string;
24
+ }
25
+
26
+ function render(this: ProjectsContext, result: unknown, human: string): void {
27
+ this.process.stdout.write(this.json ? JSON.stringify(result) : human);
28
+ }
29
+
30
+ const listCommand = buildCommand({
31
+ func: async function (this: ProjectsContext, flags: { query?: string; limit?: number }) {
32
+ const projects = await this.client.call<Record<string, unknown>, CliProject[]>("projects.list", {
33
+ query: flags.query,
34
+ limit: flags.limit,
35
+ });
36
+ render.call(
37
+ this,
38
+ projects,
39
+ projects.length === 0 ? "No registered projects." : projects.map((project) => `${project.name} — ${project.projectRoot}`).join("\n"),
40
+ );
41
+ },
42
+ parameters: {
43
+ flags: {
44
+ query: { brief: "Filter by project name, alias, or root", kind: "parsed", parse: String, placeholder: "text", optional: true },
45
+ limit: { brief: "Maximum results", kind: "parsed", parse: numberParser, placeholder: "n", optional: true },
46
+ },
47
+ },
48
+ docs: { brief: "List registered projects (shared by Tasks, Docs, Rules, and Playbooks)" },
49
+ });
50
+
51
+ const resolveCommand = buildCommand({
52
+ func: async function (this: ProjectsContext, _flags: Record<string, never>, reference: string) {
53
+ const project = await this.client.call<Record<string, unknown>, CliProject>("projects.resolve", { reference });
54
+ render.call(this, project, `${project.name} — ${project.projectRoot}`);
55
+ },
56
+ parameters: {
57
+ flags: {},
58
+ positional: { kind: "tuple", parameters: [{ brief: "Project id, name, alias, or root", parse: String, placeholder: "reference" }] },
59
+ },
60
+ docs: { brief: "Resolve a project reference to its canonical identity" },
61
+ });
62
+
63
+ const registerCommand = buildCommand({
64
+ func: async function (this: ProjectsContext, flags: { name?: string; aliasesJson?: string[]; existingId?: string }, projectRoot: string) {
65
+ const registered = await this.client.call<Record<string, unknown>, CliProject>("projects.register", {
66
+ project_root: projectRoot,
67
+ name: flags.name,
68
+ aliases: flags.aliasesJson,
69
+ existing_id: flags.existingId,
70
+ });
71
+ render.call(this, registered, `${registered.name} — ${registered.projectRoot}`);
72
+ },
73
+ parameters: {
74
+ flags: {
75
+ name: { brief: "Stable project display name", kind: "parsed", parse: String, placeholder: "name", optional: true },
76
+ aliasesJson: { brief: "JSON string array of aliases", kind: "parsed", parse: parseStringArray, placeholder: "json", optional: true },
77
+ existingId: {
78
+ brief: "Existing project id when renaming or moving",
79
+ kind: "parsed",
80
+ parse: String,
81
+ placeholder: "id",
82
+ optional: true,
83
+ },
84
+ },
85
+ positional: { kind: "tuple", parameters: [{ brief: "Project root path", parse: String, placeholder: "project-root" }] },
86
+ },
87
+ docs: { brief: "Register a new project, or rename/move an existing one" },
88
+ });
89
+
90
+ const app = buildApplication(
91
+ buildRouteMap({
92
+ routes: { list: listCommand, resolve: resolveCommand, register: registerCommand },
93
+ docs: {
94
+ brief:
95
+ "Shared project catalog operations (compatibility delegates: tasks projects/resolve-project/register-project do the identical thing)",
96
+ },
97
+ }),
98
+ { name: "projects", scanner: { caseStyle: "allow-kebab-for-camel" } },
99
+ );
100
+
101
+ export async function runProjectsCli(args: string[], client: ProjectsClient): Promise<string> {
102
+ const json = args.includes("--json");
103
+ const positional = args.filter((arg) => arg !== "--json");
104
+ return runStricliToString(app, positional, { client, json });
105
+ }
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import { runLogCli } from "./cli/log-command.ts";
14
14
  import { runMigrationCli } from "./cli/migration-command.ts";
15
15
  import { runNoteCli } from "./cli/note-command.ts";
16
16
  import { runPlaybooksCli } from "./cli/playbooks-command.ts";
17
+ import { runProjectsCli } from "./cli/projects-command.ts";
17
18
  import { runRulesCli } from "./cli/rules-command.ts";
18
19
  import { runSessionIdentityCli } from "./cli/session-identity-command.ts";
19
20
  import { runTaskCli } from "./cli/task-command.ts";
@@ -359,6 +360,7 @@ export {
359
360
  runLogCli,
360
361
  runNoteCli,
361
362
  runPlaybooksCli,
363
+ runProjectsCli,
362
364
  runRulesCli,
363
365
  runSessionIdentityCli,
364
366
  runTaskCli,
@@ -380,6 +382,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
380
382
  console.log(await runPlaybooksCli(args.slice(1), client));
381
383
  return;
382
384
  }
385
+ if (command === "projects") {
386
+ const client = await connectPapyrusClient();
387
+ console.log(await runProjectsCli(args.slice(1), client));
388
+ return;
389
+ }
383
390
  if (command === "notes") {
384
391
  const client = await connectPapyrusClient();
385
392
  console.log(await runNoteCli(args.slice(1), client));
@@ -1,3 +1,5 @@
1
+ import { TASK_PROJECT_ALIAS_MAX_COUNT, TASK_PROJECT_NAME_MAX_LENGTH } from "../constants.ts";
2
+
1
3
  /**
2
4
  * A registered project identity, shared across every artifact kind (Tasks, Docs, Rules,
3
5
  * Playbooks) rather than owned by Tasks alone -- extracted so a Doc/Rule/Playbook can resolve
@@ -20,6 +22,29 @@ export interface RegisterProjectInput {
20
22
  existingId?: string;
21
23
  }
22
24
 
25
+ /**
26
+ * The one bounded schema vocabulary for a registered project name/alias set -- shared by Tasks'
27
+ * own registerProject and the kind-neutral projects.register operation, rather than each domain
28
+ * enforcing a slightly different bound. Reuses constants.ts's existing TASK_PROJECT_* bounds
29
+ * (unchanged names -- the underlying catalog is Tasks' own historical one, just no longer
30
+ * Tasks-exclusive) rather than introducing a second, parallel set of the same numbers.
31
+ * Store-level registerProject (SQLiteProjectRegistryStore) does not itself validate
32
+ * length/count, so every caller must go through this first.
33
+ */
34
+ export function assertRegisterProjectInputBounds(name: string | undefined, aliases: string[] | undefined): void {
35
+ if (name !== undefined && (name.trim().length === 0 || name.length > TASK_PROJECT_NAME_MAX_LENGTH)) {
36
+ throw new Error(`project name must be between 1 and ${TASK_PROJECT_NAME_MAX_LENGTH} characters`);
37
+ }
38
+ if ((aliases?.length ?? 0) > TASK_PROJECT_ALIAS_MAX_COUNT) {
39
+ throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
40
+ }
41
+ for (const alias of aliases ?? []) {
42
+ if (alias.trim().length === 0 || alias.length > TASK_PROJECT_NAME_MAX_LENGTH) {
43
+ throw new Error(`each project alias must be between 1 and ${TASK_PROJECT_NAME_MAX_LENGTH} characters`);
44
+ }
45
+ }
46
+ }
47
+
23
48
  export class ProjectNotFoundError extends Error {}
24
49
  export class ProjectAmbiguousError extends Error {}
25
50
 
@@ -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.",
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The shared project catalog projected as a real VehicleRegistry: one VehicleOperation per real
3
+ * action, fronting the same ProjectRegistryStore every Docs/Rules/Playbooks scope operation
4
+ * already resolves against. tasks.projects/tasks.resolve_project/tasks.register_project remain
5
+ * unchanged, documented compatibility delegates -- see handlers/tasks.ts.
6
+ */
7
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
8
+ import { projectsOperations } from "../modules/projects.ts";
9
+ import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
10
+ import { createOperationDefiner, numberProp, stringProp } from "./shared.ts";
11
+
12
+ const OWNER = "projects";
13
+
14
+ export function registerProjectsVehicleOperations(registry: VehicleRegistry, projectRegistry: ProjectRegistryStore): void {
15
+ const moduleOperations = new Map(projectsOperations(projectRegistry).map((op) => [op.name, op]));
16
+ const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
17
+ const define = createOperationDefiner(registry, OWNER, "projects", ["projects:read", "projects:write"], call);
18
+
19
+ define(
20
+ "list",
21
+ "Lists registered projects (shared by Tasks, Docs, Rules, and Playbooks), optionally filtered by a name/alias/root substring. Compatibility delegate: tasks.projects does the identical thing.",
22
+ "read",
23
+ { query: stringProp, limit: numberProp },
24
+ [],
25
+ (input) => input,
26
+ );
27
+
28
+ define(
29
+ "resolve",
30
+ "Resolves an exact project reference (id, name, alias, or registered root) to its full identity, failing closed with bounded candidates when unknown or ambiguous. Compatibility delegate: tasks.resolve_project does the identical thing.",
31
+ "read",
32
+ { reference: stringProp },
33
+ ["reference"],
34
+ (input) => input,
35
+ );
36
+
37
+ define(
38
+ "register",
39
+ "Registers a new project, or renames/moves an existing one when project_root (or existing_id) already resolves to one. Compatibility delegate: tasks.register_project does the identical thing.",
40
+ "local-write",
41
+ { project_root: stringProp, name: stringProp, aliases: { type: "array" } as unknown as { type: string }, existing_id: stringProp },
42
+ ["project_root"],
43
+ (input) => input,
44
+ );
45
+ }
@@ -22,6 +22,7 @@ import { registerDiscussVehicleOperations } from "./discuss.ts";
22
22
  import { registerDocsVehicleOperations } from "./docs.ts";
23
23
  import { registerNotesVehicleOperations } from "./notes.ts";
24
24
  import { registerPlaybooksVehicleOperations } from "./playbooks.ts";
25
+ import { registerProjectsVehicleOperations } from "./projects.ts";
25
26
  import { registerRulesVehicleOperations } from "./rules.ts";
26
27
  import { registerTasksVehicleOperations } from "./tasks.ts";
27
28
 
@@ -59,8 +60,10 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
59
60
  artifactScopes: deps.scopes,
60
61
  tasks: deps.tasks,
61
62
  sessionIdentity: deps.sessionIdentity,
63
+ projectRegistry: deps.projectRegistry,
62
64
  });
63
65
  registerTasksVehicleOperations(registry, { tasks: deps.tasks, artifacts: deps.artifacts, sessionIdentity: deps.sessionIdentity });
66
+ registerProjectsVehicleOperations(registry, deps.projectRegistry);
64
67
  registerDiscussVehicleOperations(registry, deps.discussions, deps.artifacts);
65
68
  registerArtifactTrashOperations(registry, deps.artifacts);
66
69
  return registry;
@@ -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,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * modules/projects.ts — the shared project catalog as its own Papyrus-native registered module,
3
+ * fronting the same ProjectRegistryStore Docs/Rules/Playbooks scope operations and Tasks'
4
+ * project catalog already share underneath (see ports/project-registry-store.ts). Tasks'
5
+ * own tasks.projects/tasks.resolve_project/tasks.register_project remain fully working,
6
+ * documented compatibility delegates -- this module gives every other domain (and any caller
7
+ * with no reason to go through tasks.*) the identical operations under a kind-neutral name.
8
+ */
9
+
10
+ import { TASK_PROJECT_LIST_MAX_RESULTS } from "../constants.ts";
11
+ import { assertRegisterProjectInputBounds, resolveProjectReference } from "../domain/project-registry.ts";
12
+ import { normalizeProjectRoot } from "../domain/task-scope.ts";
13
+ import type { OperationDefinition } from "../module-registry.ts";
14
+ import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
15
+ import { type OperationInput, optionalNumber, optionalString, optionalStringArray, string } from "./operation-input.ts";
16
+
17
+ const MODULE_ID = "projects";
18
+
19
+ export const PROJECTS_OPERATION_NAMES = ["projects.list", "projects.resolve", "projects.register"] as const;
20
+
21
+ export function projectsOperations(registry: ProjectRegistryStore): OperationDefinition[] {
22
+ const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
23
+ name,
24
+ moduleId: MODULE_ID,
25
+ execute,
26
+ });
27
+ return [
28
+ define("projects.list", (input: OperationInput) => {
29
+ const limit = optionalNumber(input, "limit") ?? 20;
30
+ if (!Number.isInteger(limit) || limit < 1 || limit > TASK_PROJECT_LIST_MAX_RESULTS) {
31
+ throw new Error(`project list limit must be between 1 and ${TASK_PROJECT_LIST_MAX_RESULTS}`);
32
+ }
33
+ return registry.projects(optionalString(input, "query"), limit);
34
+ }),
35
+ define("projects.resolve", (input: OperationInput) => resolveProjectReference(registry, string(input, "reference"))),
36
+ define("projects.register", (input: OperationInput) => {
37
+ const name = optionalString(input, "name");
38
+ const aliases = optionalStringArray(input, "aliases");
39
+ assertRegisterProjectInputBounds(name, aliases);
40
+ return registry.registerProject({
41
+ projectRoot: normalizeProjectRoot(string(input, "project_root")),
42
+ name,
43
+ aliases,
44
+ existingId: optionalString(input, "existing_id"),
45
+ });
46
+ }),
47
+ ];
48
+ }
@@ -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
@@ -28,6 +28,7 @@ import { LOGS_OPERATION_NAMES, logsOperations } from "./modules/logs.ts";
28
28
  import { NOTES_OPERATION_NAMES, notesOperations } from "./modules/notes.ts";
29
29
  import { type OperationInput, optionalNumber, optionalString, string } from "./modules/operation-input.ts";
30
30
  import { PLAYBOOKS_OPERATION_NAMES, playbooksOperations } from "./modules/playbooks.ts";
31
+ import { PROJECTS_OPERATION_NAMES, projectsOperations } from "./modules/projects.ts";
31
32
  import { RULES_OPERATION_NAMES, rulesOperations } from "./modules/rules.ts";
32
33
  import { SESSION_IDENTITY_OPERATION_NAMES, sessionIdentityOperations } from "./modules/session-identity.ts";
33
34
  import { TASKS_OPERATION_NAMES, tasksOperations } from "./modules/tasks.ts";
@@ -98,6 +99,7 @@ export const EXPECTED_OPERATION_NAMES = [
98
99
  ...NOTES_OPERATION_NAMES,
99
100
  ...RULES_OPERATION_NAMES,
100
101
  ...PLAYBOOKS_OPERATION_NAMES,
102
+ ...PROJECTS_OPERATION_NAMES,
101
103
  ...GRAPH_PROJECTION_OPERATION_NAMES,
102
104
  ...LOGS_OPERATION_NAMES,
103
105
  ...SESSION_IDENTITY_OPERATION_NAMES,
@@ -441,11 +443,19 @@ function handlers(
441
443
  "playbooks.enable": forwardToModule("playbooks.enable"),
442
444
  "playbooks.disable": forwardToModule("playbooks.disable"),
443
445
  "playbooks.assign_project": forwardToModule("playbooks.assign_project"),
446
+ "playbooks.scope": forwardToModule("playbooks.scope"),
447
+ "playbooks.set_global": forwardToModule("playbooks.set_global"),
448
+ "playbooks.add_project": forwardToModule("playbooks.add_project"),
449
+ "playbooks.remove_project": forwardToModule("playbooks.remove_project"),
450
+ "playbooks.replace_projects": forwardToModule("playbooks.replace_projects"),
444
451
  "playbooks.update": forwardToModule("playbooks.update"),
445
452
  "playbooks.contain": forwardToModule("playbooks.contain"),
446
453
  "playbooks.uncontain": forwardToModule("playbooks.uncontain"),
447
454
  "playbooks.depend": forwardToModule("playbooks.depend"),
448
455
  "playbooks.undepend": forwardToModule("playbooks.undepend"),
456
+ "projects.list": forwardToModule("projects.list"),
457
+ "projects.resolve": forwardToModule("projects.resolve"),
458
+ "projects.register": forwardToModule("projects.register"),
449
459
  "graph_projection.apply": forwardToModule("graph_projection.apply"),
450
460
  "graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
451
461
  "logs.append": forwardToModule("logs.append"),
@@ -505,7 +515,10 @@ export function createPapyrusService(path: string): PapyrusService {
505
515
  moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
506
516
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority, projectRegistry));
507
517
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry));
508
- moduleRegistry.registerAll(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }));
518
+ moduleRegistry.registerAll(
519
+ playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, registry: projectRegistry }),
520
+ );
521
+ moduleRegistry.registerAll(projectsOperations(projectRegistry));
509
522
  moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
510
523
  const registry = handlers(artifacts, gates, tasks, notes, events, scopes, artifactScopes, () => migrateDb(db), moduleRegistry, authority);
511
524
  const state = (): SchemaState => {
@@ -14,15 +14,14 @@ import {
14
14
  TASK_LABEL_MAX_LENGTH,
15
15
  TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH,
16
16
  TASK_MUTATION_IDEMPOTENCY_RETENTION_MS,
17
- TASK_PROJECT_ALIAS_MAX_COUNT,
18
17
  TASK_PROJECT_LIST_MAX_RESULTS,
19
- TASK_PROJECT_NAME_MAX_LENGTH,
20
18
  TASK_SCOPE_MAX_TASKS,
21
19
  TASK_TITLE_MAX_LENGTH,
22
20
  } from "../constants.ts";
23
21
  import { type Checklist, checklistEntries, type ProofReference, validateChecklist } from "../domain/checklist.ts";
24
22
  import { DISCUSSION_SUBTYPE, isDiscussionArtifact, readDiscussionExtra } from "../domain/discussion.ts";
25
23
  import { type Gate, type GateResult, validateGates } from "../domain/gate.ts";
24
+ import { assertRegisterProjectInputBounds } from "../domain/project-registry.ts";
26
25
  import type {
27
26
  AppendTaskEvent,
28
27
  TaskEventContext,
@@ -549,17 +548,7 @@ export class Tasks {
549
548
  registerProject(input: RegisterTaskProjectInput, existingReference?: string): TaskProject {
550
549
  const projectRoot = normalizeProjectRoot(input.projectRoot);
551
550
  const name = input.name?.trim();
552
- if (name !== undefined && (name.length === 0 || name.length > TASK_PROJECT_NAME_MAX_LENGTH)) {
553
- throw new Error(`project name must be between 1 and ${TASK_PROJECT_NAME_MAX_LENGTH} characters`);
554
- }
555
- if ((input.aliases?.length ?? 0) > TASK_PROJECT_ALIAS_MAX_COUNT) {
556
- throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
557
- }
558
- for (const alias of input.aliases ?? []) {
559
- if (alias.trim().length === 0 || alias.length > TASK_PROJECT_NAME_MAX_LENGTH) {
560
- throw new Error(`each project alias must be between 1 and ${TASK_PROJECT_NAME_MAX_LENGTH} characters`);
561
- }
562
- }
551
+ assertRegisterProjectInputBounds(name, input.aliases);
563
552
  const existingId = existingReference ? this.resolveProject(existingReference).id : input.existingId;
564
553
  return this.scopes.registerProject({ projectRoot, ...(name ? { name } : {}), aliases: input.aliases, existingId });
565
554
  }