@danypops/papyrus 0.51.0 → 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.51.0",
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",
@@ -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
 
@@ -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
 
@@ -62,6 +63,7 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
62
63
  projectRegistry: deps.projectRegistry,
63
64
  });
64
65
  registerTasksVehicleOperations(registry, { tasks: deps.tasks, artifacts: deps.artifacts, sessionIdentity: deps.sessionIdentity });
66
+ registerProjectsVehicleOperations(registry, deps.projectRegistry);
65
67
  registerDiscussVehicleOperations(registry, deps.discussions, deps.artifacts);
66
68
  registerArtifactTrashOperations(registry, deps.artifacts);
67
69
  return registry;
@@ -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
+ }
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,
@@ -451,6 +453,9 @@ function handlers(
451
453
  "playbooks.uncontain": forwardToModule("playbooks.uncontain"),
452
454
  "playbooks.depend": forwardToModule("playbooks.depend"),
453
455
  "playbooks.undepend": forwardToModule("playbooks.undepend"),
456
+ "projects.list": forwardToModule("projects.list"),
457
+ "projects.resolve": forwardToModule("projects.resolve"),
458
+ "projects.register": forwardToModule("projects.register"),
454
459
  "graph_projection.apply": forwardToModule("graph_projection.apply"),
455
460
  "graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
456
461
  "logs.append": forwardToModule("logs.append"),
@@ -513,6 +518,7 @@ export function createPapyrusService(path: string): PapyrusService {
513
518
  moduleRegistry.registerAll(
514
519
  playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, registry: projectRegistry }),
515
520
  );
521
+ moduleRegistry.registerAll(projectsOperations(projectRegistry));
516
522
  moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
517
523
  const registry = handlers(artifacts, gates, tasks, notes, events, scopes, artifactScopes, () => migrateDb(db), moduleRegistry, authority);
518
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
  }