@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 +18 -0
- package/package.json +1 -1
- package/src/cli/playbooks-command.ts +111 -1
- package/src/cli/projects-command.ts +105 -0
- package/src/cli.ts +7 -0
- package/src/domain/project-registry.ts +25 -0
- package/src/handlers/playbooks.ts +67 -4
- package/src/handlers/projects.ts +45 -0
- package/src/handlers/registry.ts +3 -0
- package/src/modules/playbooks.ts +51 -7
- package/src/modules/projects.ts +48 -0
- package/src/playbook/playbook-service.ts +78 -2
- package/src/playbook/workflow-execution.ts +38 -10
- package/src/service.ts +14 -1
- package/src/task/task-service.ts +2 -13
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
|
@@ -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 (
|
|
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) => [
|
|
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
|
|
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
|
+
}
|
package/src/handlers/registry.ts
CHANGED
|
@@ -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;
|
package/src/modules/playbooks.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
/**
|
|
154
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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(
|
|
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 => {
|
package/src/task/task-service.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|