@danypops/papyrus 0.21.6 → 0.23.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/src/cli.ts CHANGED
@@ -92,6 +92,7 @@ const USAGE = `Usage:
92
92
  papyrus docs activate|archive|reopen <id> [--json]
93
93
  papyrus docs link <id> <relation> <target-id> [--json]
94
94
  papyrus docs assign-project <id> [project-root] [--json]
95
+ papyrus docs update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
95
96
  papyrus rules create --title <title> [--body <body>] [--condition <text>] [--rule-action <text>] [--severity block|warn|info] [--labels-json <json>] [--extra-json <json>] [--project-root <path>] [--json]
96
97
  papyrus rules list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
97
98
  papyrus rules show <id> [--json]
@@ -100,6 +101,7 @@ const USAGE = `Usage:
100
101
  papyrus rules gate <rule-id> <task-id> [--json]
101
102
  papyrus rules injectable [--json]
102
103
  papyrus rules assign-project <id> [project-root] [--json]
104
+ papyrus rules update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
103
105
  papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
104
106
  papyrus skills create --title <title> [--body <body>] [--trigger <text>] [--steps-json <json>] [--tools-json <json>] [--definition-json <json>] [--labels-json <json>] [--extra-json <json>] [--project-root <path>] [--json]
105
107
  papyrus skills create-template --title <title> --target-kind <kind> [--defaults-json <json>] [--required-json <json>] [--body <body>] [--labels-json <json>] [--project-root <path>] [--json]
@@ -109,6 +111,14 @@ const USAGE = `Usage:
109
111
  papyrus skills enable|disable <id> [--json]
110
112
  papyrus skills instantiate <template-id> [--title <title>] [--body <body>] [--status <status>] [--labels-json <json>] [--extra-json <json>] [--json]
111
113
  papyrus skills assign-project <id> [project-root] [--json]
114
+ papyrus skills update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
115
+ papyrus playbooks create --title <title> [--body <body>] [--trigger <text>] [--steps-json <json>] [--tools-json <json>] [--labels-json <json>] [--extra-json <json>] [--project-root <path>] [--json]
116
+ papyrus playbooks list [--status <status>] [--text <query>] [--limit <count>] [--project-root <path>] [--json]
117
+ papyrus playbooks show <id> [--json]
118
+ papyrus playbooks invoke <id> [--json]
119
+ papyrus playbooks enable|disable <id> [--json]
120
+ papyrus playbooks assign-project <id> [project-root] [--json]
121
+ papyrus playbooks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--json]
112
122
  papyrus notes capture <request> [--title <title>] [--json]
113
123
  papyrus notes list [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--json]
114
124
  papyrus notes show <id> [--json]
@@ -466,8 +476,16 @@ export async function runSkillCli(args: string[], client: TaskCliClient, project
466
476
  human = `Created: ${artifactLabel(artifact)}`;
467
477
  break;
468
478
  }
479
+ case "update": {
480
+ if (!id || second) throw new Error("skills update requires exactly one skill id");
481
+ if (title === undefined && body === undefined && labels === undefined) throw new Error("skills update requires --title, --body, or --labels-json");
482
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("skills.update", { id, title, body, labels });
483
+ result = artifact;
484
+ human = `${artifactLabel(artifact)}`;
485
+ break;
486
+ }
469
487
  default:
470
- throw new Error("skills action must be run, create, create-template, list, show, invoke, enable, disable, instantiate, or assign-project");
488
+ throw new Error("skills action must be run, create, create-template, list, show, invoke, enable, disable, instantiate, assign-project, or update");
471
489
  }
472
490
  return json ? JSON.stringify(result) : human;
473
491
  }
@@ -632,8 +650,16 @@ export async function runDocsCli(args: string[], client: TaskCliClient): Promise
632
650
  human = `Linked ${id} --${second}--> ${third}`;
633
651
  break;
634
652
  }
653
+ case "update": {
654
+ if (!id || second) throw new Error("docs update requires exactly one document id");
655
+ if (title === undefined && body === undefined && labels === undefined) throw new Error("docs update requires --title, --body, or --labels-json");
656
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("docs.update", { id, title, body, labels });
657
+ result = artifact;
658
+ human = `${artifactLabel(artifact)}`;
659
+ break;
660
+ }
635
661
  default:
636
- throw new Error("docs action must be create, list, show, activate, archive, reopen, link, or assign-project");
662
+ throw new Error("docs action must be create, list, show, activate, archive, reopen, link, assign-project, or update");
637
663
  }
638
664
  return json ? JSON.stringify(result) : human;
639
665
  }
@@ -736,8 +762,114 @@ export async function runRulesCli(args: string[], client: TaskCliClient, project
736
762
  human = rows.length === 0 ? "No injectable rules." : rows.map((row) => row.title).join("\n");
737
763
  break;
738
764
  }
765
+ case "update": {
766
+ if (!id || second) throw new Error("rules update requires exactly one rule id");
767
+ if (title === undefined && body === undefined && labels === undefined) throw new Error("rules update requires --title, --body, or --labels-json");
768
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("rules.update", { id, title, body, labels });
769
+ result = artifact;
770
+ human = `${artifactLabel(artifact)}`;
771
+ break;
772
+ }
739
773
  default:
740
- throw new Error("rules action must be create, list, show, preview, enable, disable, gate, injectable, or assign-project");
774
+ throw new Error("rules action must be create, list, show, preview, enable, disable, gate, injectable, assign-project, or update");
775
+ }
776
+ return json ? JSON.stringify(result) : human;
777
+ }
778
+
779
+ export async function runPlaybooksCli(args: string[], client: TaskCliClient): Promise<string> {
780
+ const json = args.includes("--json");
781
+ const positional: string[] = [];
782
+ let title: string | undefined;
783
+ let body: string | undefined;
784
+ let trigger: string | undefined;
785
+ let steps: string[] | undefined;
786
+ let tools: string[] | undefined;
787
+ let labels: string[] | undefined;
788
+ let extra: Record<string, unknown> | undefined;
789
+ let status: string | undefined;
790
+ let text: string | undefined;
791
+ let limit: number | undefined;
792
+ let playbookProjectRoot: string | undefined;
793
+ for (let index = 0; index < args.length; index++) {
794
+ const argument = args[index]!;
795
+ if (argument === "--json") continue;
796
+ if (argument === "--title") { title = args[++index]; if (title === undefined) throw new Error("--title requires a value"); continue; }
797
+ if (argument === "--body") { body = args[++index]; if (body === undefined) throw new Error("--body requires a value"); continue; }
798
+ if (argument === "--trigger") { trigger = args[++index]; if (trigger === undefined) throw new Error("--trigger requires a value"); continue; }
799
+ if (argument === "--steps-json") { steps = parseJsonStringArrayFlag(args[++index], "--steps-json"); continue; }
800
+ if (argument === "--tools-json") { tools = parseJsonStringArrayFlag(args[++index], "--tools-json"); continue; }
801
+ if (argument === "--labels-json") { labels = parseJsonStringArrayFlag(args[++index], "--labels-json"); continue; }
802
+ if (argument === "--extra-json") { extra = parseJsonObjectFlag(args[++index], "--extra-json"); continue; }
803
+ if (argument === "--status") { status = args[++index]; if (!status) throw new Error("--status requires a value"); continue; }
804
+ if (argument === "--text") { text = args[++index]; if (text === undefined) throw new Error("--text requires a value"); continue; }
805
+ if (argument === "--project-root") { playbookProjectRoot = args[++index]; if (!playbookProjectRoot) throw new Error("--project-root requires a value"); continue; }
806
+ if (argument === "--limit") {
807
+ const value = args[++index];
808
+ if (!value || Number.isNaN(Number(value))) throw new Error("--limit requires a numeric value");
809
+ limit = Number(value);
810
+ continue;
811
+ }
812
+ if (argument.startsWith("--")) throw new Error(`unknown playbooks option ${argument}`);
813
+ positional.push(argument);
814
+ }
815
+ const [action, id, second] = positional;
816
+ let result: unknown;
817
+ let human: string;
818
+ switch (action) {
819
+ case "create": {
820
+ if (id) throw new Error("playbooks create accepts no positional arguments");
821
+ if (!title) throw new Error("playbooks create requires --title");
822
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.create", { title, body, trigger, steps, tools, labels, extra, project_root: playbookProjectRoot });
823
+ result = artifact;
824
+ human = `Created playbook: ${artifactLabel(artifact)}`;
825
+ break;
826
+ }
827
+ case "list": {
828
+ if (id) throw new Error("playbooks list accepts no positional arguments");
829
+ const rows = await client.call<Record<string, unknown>, CliArtifact[]>("playbooks.list", { status, text, limit, project_root: playbookProjectRoot });
830
+ result = rows;
831
+ human = rows.length === 0 ? "No playbooks found." : rows.map((row) => artifactLabel(row)).join("\n");
832
+ break;
833
+ }
834
+ case "show": {
835
+ if (!id || second) throw new Error("playbooks show requires exactly one playbook id");
836
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.show", { id });
837
+ result = artifact;
838
+ human = `${artifactLabel(artifact)}\n\n${artifact.body ?? ""}`;
839
+ break;
840
+ }
841
+ case "invoke": {
842
+ if (!id || second) throw new Error("playbooks invoke requires exactly one playbook id");
843
+ const invocation = await client.call<Record<string, unknown>, string>("playbooks.invoke", { id });
844
+ result = invocation;
845
+ human = invocation;
846
+ break;
847
+ }
848
+ case "enable":
849
+ case "disable": {
850
+ if (!id || second) throw new Error(`playbooks ${action} requires exactly one playbook id`);
851
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>(`playbooks.${action}`, { id });
852
+ result = artifact;
853
+ human = `${artifactLabel(artifact)}`;
854
+ break;
855
+ }
856
+ case "assign-project": {
857
+ if (!id || second === undefined && positional.length > 2) throw new Error("playbooks assign-project requires <id> [project-root]");
858
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.assign_project", { id, project_root: second });
859
+ result = artifact;
860
+ human = second ? `Assigned ${id} to ${second}` : `Unscoped ${id}`;
861
+ break;
862
+ }
863
+ case "update": {
864
+ if (!id || second) throw new Error("playbooks update requires exactly one playbook id");
865
+ if (title === undefined && body === undefined && labels === undefined) throw new Error("playbooks update requires --title, --body, or --labels-json");
866
+ const artifact = await client.call<Record<string, unknown>, CliArtifact>("playbooks.update", { id, title, body, labels });
867
+ result = artifact;
868
+ human = `${artifactLabel(artifact)}`;
869
+ break;
870
+ }
871
+ default:
872
+ throw new Error("playbooks action must be create, list, show, invoke, enable, disable, assign-project, or update");
741
873
  }
742
874
  return json ? JSON.stringify(result) : human;
743
875
  }
@@ -1505,6 +1637,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
1505
1637
  console.log(await runSkillCli(args.slice(1), client));
1506
1638
  return;
1507
1639
  }
1640
+ if (command === "playbooks") {
1641
+ const client = await connectPapyrusClient();
1642
+ console.log(await runPlaybooksCli(args.slice(1), client));
1643
+ return;
1644
+ }
1508
1645
  if (command === "notes") {
1509
1646
  const client = await connectPapyrusClient();
1510
1647
  console.log(await runNoteCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -7,7 +7,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
7
7
  export const DAEMON_UNIT_NAME = "papyrus.service";
8
8
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
9
9
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
10
- export const SQLITE_SCHEMA_VERSION = 17;
10
+ export const SQLITE_SCHEMA_VERSION = 18;
11
11
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
12
12
 
13
13
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -99,6 +99,7 @@ export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
99
99
  */
100
100
  export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
101
101
  export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
102
+ export const PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
102
103
 
103
104
  /**
104
105
  * At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
@@ -119,6 +120,11 @@ export const TASK_TITLE_MAX_LENGTH = 500;
119
120
  export const TASK_BODY_MAX_LENGTH = 100_000;
120
121
  export const TASK_LABEL_MAX_COUNT = 64;
121
122
  export const TASK_LABEL_MAX_LENGTH = 128;
123
+ /** Mutable Doc/Rule/Skill content bounds -- same numbers as Task's, since these are the same kind of freeform content at the same scale. Rules also enforce their own stricter RULE_TEXT_HARD_LIMIT_CHARACTERS on top of this. */
124
+ export const ARTIFACT_TITLE_MAX_LENGTH = 500;
125
+ export const ARTIFACT_BODY_MAX_LENGTH = 100_000;
126
+ export const ARTIFACT_LABEL_MAX_COUNT = 64;
127
+ export const ARTIFACT_LABEL_MAX_LENGTH = 128;
122
128
  /** Append-only Task chronology query and evidence bounds. */
123
129
  export const TASK_HISTORY_DEFAULT_LIMIT = 25;
124
130
  export const TASK_HISTORY_MAX_LIMIT = 100;
package/src/db.ts CHANGED
@@ -253,6 +253,7 @@ INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, de
253
253
  INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (objectives, steps, checklists)');
254
254
  INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
255
255
  INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — inputs and templates load tasks, rules, and docs');
256
+ INSERT OR IGNORE INTO kinds VALUES ('playbook','Reusable procedure — a trigger and an ordered list of steps an agent reads and follows, not a mechanically instantiated blueprint');
256
257
  INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
257
258
  INSERT OR IGNORE INTO statuses VALUES ('active','doc');
258
259
  INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
@@ -266,6 +267,8 @@ INSERT OR IGNORE INTO statuses VALUES ('active','rule');
266
267
  INSERT OR IGNORE INTO statuses VALUES ('deprecated','rule');
267
268
  INSERT OR IGNORE INTO statuses VALUES ('active','skill');
268
269
  INSERT OR IGNORE INTO statuses VALUES ('deprecated','skill');
270
+ INSERT OR IGNORE INTO statuses VALUES ('active','playbook');
271
+ INSERT OR IGNORE INTO statuses VALUES ('deprecated','playbook');
269
272
  INSERT OR IGNORE INTO relation_names VALUES ('references','Source material (doc→doc, doc→task, doc→rule)');
270
273
  INSERT OR IGNORE INTO relation_names VALUES ('implements','This work satisfies that (task→doc, task→rule)');
271
274
  INSERT OR IGNORE INTO relation_names VALUES ('follows','This work obeys that (task→rule, task→skill)');
@@ -504,6 +507,23 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
504
507
  `);
505
508
  },
506
509
  },
510
+ {
511
+ version: 18,
512
+ name: "playbook-kind",
513
+ // Playbooks (trigger/steps/tools guidance an agent reads and follows) were a subtype-less
514
+ // shape squeezed into the "skill" kind alongside artifact-templates and workflow blueprints
515
+ // -- fundamentally different mechanisms (mechanical multi-artifact instantiation) from a flat
516
+ // step list. Split into its own kind; only rows with no subtype move -- artifact-template and
517
+ // workflow rows stay exactly where they are.
518
+ up: (db) => {
519
+ db.exec(`
520
+ INSERT OR IGNORE INTO kinds VALUES ('playbook','Reusable procedure — a trigger and an ordered list of steps an agent reads and follows, not a mechanically instantiated blueprint');
521
+ INSERT OR IGNORE INTO statuses VALUES ('active','playbook');
522
+ INSERT OR IGNORE INTO statuses VALUES ('deprecated','playbook');
523
+ UPDATE artifacts SET kind = 'playbook' WHERE kind = 'skill' AND (subtype IS NULL OR subtype = '');
524
+ `);
525
+ },
526
+ },
507
527
  ];
508
528
 
509
529
  /**
@@ -67,3 +67,27 @@ export interface RelationshipQuery {
67
67
  artifactIds?: string[];
68
68
  limit?: number;
69
69
  }
70
+
71
+ /**
72
+ * A label of the form "source:<system>" marks an artifact as ingested/projected from an
73
+ * external, non-Papyrus system (e.g. web-spider's own "source:web-spider" convention on the
74
+ * Docs it creates) -- content Papyrus does not own and cannot safely rewrite without silently
75
+ * diverging from the true source. Editing one directly would look like a correction but really
76
+ * just be a local fork nobody re-syncs.
77
+ */
78
+ export const EXTERNAL_SOURCE_LABEL_PREFIX = "source:";
79
+
80
+ /** The external system name from a "source:<system>" label, or undefined if this artifact has no such label (i.e. it's Papyrus-native content). */
81
+ export function externalSourceOf(artifact: Pick<Artifact, "labels">): string | undefined {
82
+ const label = artifact.labels.find((entry) => entry.startsWith(EXTERNAL_SOURCE_LABEL_PREFIX));
83
+ return label === undefined ? undefined : label.slice(EXTERNAL_SOURCE_LABEL_PREFIX.length) || undefined;
84
+ }
85
+
86
+ /** Throws if the artifact is a read-only external projection; a caller must never silently rewrite content it doesn't own the source of. */
87
+ export function requireLocallyOwnedContent(artifact: Artifact): Artifact {
88
+ const system = externalSourceOf(artifact);
89
+ if (system !== undefined) {
90
+ throw new Error(`"${artifact.title}" is a read-only projection from ${system}; edit it there, or capture a correction as a new linked Doc, until a write-back capability is integrated`);
91
+ }
92
+ return artifact;
93
+ }
@@ -1,17 +1,52 @@
1
1
  import {
2
+ ARTIFACT_BODY_MAX_LENGTH,
3
+ ARTIFACT_LABEL_MAX_COUNT,
4
+ ARTIFACT_LABEL_MAX_LENGTH,
2
5
  ARTIFACT_SCOPE_MAX_ARTIFACTS,
6
+ ARTIFACT_TITLE_MAX_LENGTH,
7
+ PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
3
8
  RULE_TEXT_HARD_LIMIT_CHARACTERS,
4
9
  SKILL_INVOCATION_MAX_CALL_DEPTH,
5
10
  SKILL_INVOCATION_MAX_LINKED_ARTIFACTS,
6
11
  } from "./constants.ts";
7
- import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
12
+ import { requireLocallyOwnedContent, type Artifact, type CreateArtifactInput } from "./domain/artifact.ts";
8
13
  import type { ArtifactEventContext } from "./domain/artifact-event.ts";
9
14
  import { normalizeProjectRoot } from "./domain/task-scope.ts";
10
15
  import { validateSkillDefinition } from "./domain/skill-definition.ts";
11
16
  import type { ArtifactStore } from "./ports/artifact-store.ts";
12
17
  import type { ArtifactScopeStore } from "./ports/artifact-scope-store.ts";
13
18
  import { NOTE_SUBTYPE } from "./note-service.ts";
14
- import type { AuthorityRegistry } from "./authority-registry.ts";
19
+ import { type ArtifactAction, type AuthorityRegistry } from "./authority-registry.ts";
20
+
21
+ export interface UpdateContentInput {
22
+ title?: string;
23
+ body?: string;
24
+ labels?: string[];
25
+ }
26
+
27
+ function requireContentUpdateFields(input: UpdateContentInput): void {
28
+ if (input.title === undefined && input.body === undefined && input.labels === undefined) {
29
+ throw new Error("update requires title, body, or labels");
30
+ }
31
+ }
32
+
33
+ function assertTitleBounds(title: string | undefined): void {
34
+ if (title !== undefined && (title.trim().length === 0 || title.length > ARTIFACT_TITLE_MAX_LENGTH)) {
35
+ throw new Error(`title must be between 1 and ${ARTIFACT_TITLE_MAX_LENGTH} characters`);
36
+ }
37
+ }
38
+
39
+ function assertBodyBounds(body: string | undefined): void {
40
+ if (body !== undefined && body.length > ARTIFACT_BODY_MAX_LENGTH) throw new Error(`body cannot exceed ${ARTIFACT_BODY_MAX_LENGTH} characters`);
41
+ }
42
+
43
+ function assertLabelsBounds(labels: string[] | undefined): void {
44
+ if (labels === undefined) return;
45
+ if (labels.length > ARTIFACT_LABEL_MAX_COUNT) throw new Error(`labels cannot exceed ${ARTIFACT_LABEL_MAX_COUNT} entries`);
46
+ if (labels.some((label) => label.length === 0 || label.length > ARTIFACT_LABEL_MAX_LENGTH)) {
47
+ throw new Error(`each label must be between 1 and ${ARTIFACT_LABEL_MAX_LENGTH} characters`);
48
+ }
49
+ }
15
50
 
16
51
  export interface ListFilter {
17
52
  status?: string;
@@ -83,8 +118,8 @@ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefine
83
118
  return typeof subtype === "string" ? subtype : undefined;
84
119
  }
85
120
 
86
- function requireMutableDocument(document: Artifact, authority: AuthorityRegistry): Artifact {
87
- authority.requireArtifactAllowed(document.kind, document.subtype, "status", "docs");
121
+ function requireMutableDocument(document: Artifact, authority: AuthorityRegistry, action: ArtifactAction = "status"): Artifact {
122
+ authority.requireArtifactAllowed(document.kind, document.subtype, action, "docs");
88
123
  return document;
89
124
  }
90
125
 
@@ -99,6 +134,8 @@ export interface CreateDocumentInput {
99
134
  projectRoot?: string;
100
135
  }
101
136
 
137
+ export type UpdateDocumentInput = UpdateContentInput;
138
+
102
139
  export type DocumentTransition = "activate" | "archive" | "reopen";
103
140
  export type DocumentRelation = "references" | "documents" | "supersedes" | "relates_to" | "contains" | "part_of";
104
141
 
@@ -157,6 +194,23 @@ export function transitionDocument(artifacts: ArtifactStore, id: string, action:
157
194
  return artifacts.setStatus(id, transition.to, context)!;
158
195
  }
159
196
 
197
+ /**
198
+ * Docs are immutable-by-convention only in the sense that no path existed to change them --
199
+ * this is that path. A read-only external projection (see requireLocallyOwnedContent) still
200
+ * refuses, on purpose: rewriting it here would silently fork from whatever system actually
201
+ * owns it (e.g. web-spider's ingested pages), with nothing to ever reconcile the two again.
202
+ */
203
+ export function updateDocument(artifacts: ArtifactStore, id: string, input: UpdateDocumentInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
204
+ requireContentUpdateFields(input);
205
+ assertTitleBounds(input.title);
206
+ assertBodyBounds(input.body);
207
+ assertLabelsBounds(input.labels);
208
+ const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "update"));
209
+ const updated = artifacts.updateContent(id, input, context);
210
+ if (!updated) throw new Error(`document "${id}" not found`);
211
+ return updated;
212
+ }
213
+
160
214
  export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
161
215
  requireMutableDocument(requireDocument(artifacts, id), authority);
162
216
  const target = artifacts.get(targetId);
@@ -258,6 +312,24 @@ export function transitionRule(artifacts: ArtifactStore, id: string, action: Rul
258
312
  return artifacts.setStatus(id, target, context)!;
259
313
  }
260
314
 
315
+ export type UpdateRuleInput = UpdateContentInput;
316
+
317
+ /** A Rule's body update stays under the same combined condition+action+body ceiling as creation -- a permanent per-turn injection cost doesn't get looser just because it's an edit, not a create. */
318
+ export function updateRule(artifacts: ArtifactStore, id: string, input: UpdateRuleInput, context?: ArtifactEventContext): Artifact {
319
+ requireContentUpdateFields(input);
320
+ assertTitleBounds(input.title);
321
+ assertLabelsBounds(input.labels);
322
+ const rule = requireLocallyOwnedContent(requireKind(artifacts, id, "rule"));
323
+ if (input.body !== undefined) {
324
+ const condition = typeof rule.extra["condition"] === "string" ? rule.extra["condition"] : undefined;
325
+ const action = typeof rule.extra["action"] === "string" ? rule.extra["action"] : undefined;
326
+ assertRuleTextWithinBounds(condition, action, input.body);
327
+ }
328
+ const updated = artifacts.updateContent(id, input, context);
329
+ if (!updated) throw new Error(`rule "${id}" not found`);
330
+ return updated;
331
+ }
332
+
261
333
  export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
262
334
  requireKind(artifacts, ruleId, "rule");
263
335
  requireKind(artifacts, taskId, "task");
@@ -353,6 +425,19 @@ export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
353
425
  return artifacts.get(id, { tree: true })!;
354
426
  }
355
427
 
428
+ export type UpdateSkillInput = UpdateContentInput;
429
+
430
+ export function updateSkill(artifacts: ArtifactStore, id: string, input: UpdateSkillInput, context?: ArtifactEventContext): Artifact {
431
+ requireContentUpdateFields(input);
432
+ assertTitleBounds(input.title);
433
+ assertBodyBounds(input.body);
434
+ assertLabelsBounds(input.labels);
435
+ const skill = requireLocallyOwnedContent(requireKind(artifacts, id, "skill"));
436
+ const updated = artifacts.updateContent(skill.id, input, context);
437
+ if (!updated) throw new Error(`skill "${id}" not found`);
438
+ return updated;
439
+ }
440
+
356
441
  function skillInvocationBody(skill: Artifact): string {
357
442
  if (skill.subtype === "artifact-template") {
358
443
  return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
@@ -430,3 +515,95 @@ export function transitionSkill(artifacts: ArtifactStore, id: string, action: Sk
430
515
  if (skill.status !== expected) throw new Error(`cannot ${action} skill from ${skill.status}`);
431
516
  return artifacts.setStatus(id, target, context)!;
432
517
  }
518
+
519
+ /**
520
+ * Playbooks: a trigger and an ordered list of steps an agent reads and follows -- a completely
521
+ * different beast from Skills, not a subtype of one. A Skill (artifact-template or workflow) is
522
+ * mechanically instantiated into other artifacts; a Playbook is never instantiated, it's read
523
+ * and followed, and it never composes other Playbooks the way a Skill can call another Skill.
524
+ */
525
+ export interface CreatePlaybookInput {
526
+ title: string;
527
+ body?: string;
528
+ trigger?: string;
529
+ steps?: string[];
530
+ tools?: string[];
531
+ labels?: string[];
532
+ extra?: Record<string, unknown>;
533
+ projectRoot?: string;
534
+ }
535
+
536
+ export type PlaybookTransition = "enable" | "disable";
537
+ export type UpdatePlaybookInput = UpdateContentInput;
538
+
539
+ export function createPlaybook(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreatePlaybookInput, context?: ArtifactEventContext): Artifact {
540
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
541
+ const playbook = artifacts.create({
542
+ kind: "playbook",
543
+ status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
544
+ title: input.title,
545
+ body: input.body,
546
+ labels: input.labels,
547
+ extra: {
548
+ ...(input.extra ?? {}),
549
+ ...(input.trigger ? { trigger: input.trigger } : {}),
550
+ ...(input.steps ? { steps: input.steps } : {}),
551
+ ...(input.tools ? { tools: input.tools } : {}),
552
+ },
553
+ }, context);
554
+ scopes.assign(playbook.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
555
+ return playbook;
556
+ }
557
+
558
+ export function listPlaybooks(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
559
+ return listScoped(artifacts, scopes, "playbook", filter);
560
+ }
561
+
562
+ export function assignPlaybookProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
563
+ return assignArtifactProject(artifacts, scopes, id, "playbook", projectRoot);
564
+ }
565
+
566
+ export function showPlaybook(artifacts: ArtifactStore, id: string): Artifact {
567
+ requireKind(artifacts, id, "playbook");
568
+ return artifacts.get(id, { tree: true })!;
569
+ }
570
+
571
+ export function updatePlaybook(artifacts: ArtifactStore, id: string, input: UpdatePlaybookInput, context?: ArtifactEventContext): Artifact {
572
+ requireContentUpdateFields(input);
573
+ assertTitleBounds(input.title);
574
+ assertBodyBounds(input.body);
575
+ assertLabelsBounds(input.labels);
576
+ const playbook = requireLocallyOwnedContent(requireKind(artifacts, id, "playbook"));
577
+ const updated = artifacts.updateContent(playbook.id, input, context);
578
+ if (!updated) throw new Error(`playbook "${id}" not found`);
579
+ return updated;
580
+ }
581
+
582
+ export function transitionPlaybook(artifacts: ArtifactStore, id: string, action: PlaybookTransition, context?: ArtifactEventContext): Artifact {
583
+ const playbook = requireKind(artifacts, id, "playbook");
584
+ const expected = action === "enable" ? "deprecated" : "active";
585
+ const target = action === "enable" ? "active" : "deprecated";
586
+ if (playbook.status !== expected) throw new Error(`cannot ${action} playbook from ${playbook.status}`);
587
+ return artifacts.setStatus(id, target, context)!;
588
+ }
589
+
590
+ /** Renders trigger/steps/tools into readable guidance, plus any real linked artifacts. No nested playbook-calls-playbook composition -- a Playbook is a flat procedure, not a composable bundle. */
591
+ export function playbookInvocation(artifacts: ArtifactStore, id: string): string {
592
+ const playbook = requireKind(artifacts, id, "playbook");
593
+ const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
594
+ const steps = Array.isArray(playbook.extra["steps"]) ? playbook.extra["steps"].filter((step): step is string => typeof step === "string") : [];
595
+ const tools = Array.isArray(playbook.extra["tools"]) ? playbook.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
596
+ const sections = [[
597
+ `Apply Papyrus playbook "${playbook.title}" (${playbook.id}).`,
598
+ `Trigger: ${trigger}`,
599
+ ...(playbook.body ? [`Context: ${playbook.body}`] : []),
600
+ ...(steps.length ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
601
+ ...(tools.length ? [`Tools: ${tools.join(", ")}`] : []),
602
+ ].join("\n")];
603
+ const edges = artifacts.relationships({ artifactIds: [id] }).filter((edge) => edge.from === id).slice(0, PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS);
604
+ const linkedLines = edges
605
+ .map((edge) => { const target = artifacts.get(edge.to); return target ? `- ${edge.relation} ${target.kind} "${target.title}" (${target.id})` : undefined; })
606
+ .filter((line): line is string => line !== undefined);
607
+ if (linkedLines.length > 0) sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedLines].join("\n"));
608
+ return sections.join("\n\n");
609
+ }
@@ -7,7 +7,7 @@
7
7
  * ArtifactStore-based with no other module's concrete class dependency.
8
8
  */
9
9
  import type { AuthorityRegistry } from "../authority-registry.ts";
10
- import { assignDocumentProject, createDocument, linkDocument, listDocuments, showDocument, transitionDocument, type DocumentRelation } from "../domain-services.ts";
10
+ import { assignDocumentProject, createDocument, linkDocument, listDocuments, showDocument, transitionDocument, updateDocument, type DocumentRelation } from "../domain-services.ts";
11
11
  import type { OperationDefinition } from "../module-registry.ts";
12
12
  import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
13
13
  import type { ArtifactStore } from "../ports/artifact-store.ts";
@@ -52,7 +52,7 @@ const artifactFilter = (input: OperationInput) => ({
52
52
  /** Registers every docs.* operation against the shared ArtifactStore port. Behavior is unchanged from the prior inline handlers in src/service.ts. */
53
53
  /** 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. */
54
54
  export const DOCS_OPERATION_NAMES = [
55
- "docs.create", "docs.list", "docs.show", "docs.activate", "docs.archive", "docs.reopen", "docs.link", "docs.assign_project",
55
+ "docs.create", "docs.list", "docs.show", "docs.activate", "docs.archive", "docs.reopen", "docs.link", "docs.assign_project", "docs.update",
56
56
  ] as const;
57
57
 
58
58
  export function docsOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore, authority: AuthorityRegistry): OperationDefinition[] {
@@ -73,5 +73,8 @@ export function docsOperations(artifacts: ArtifactStore, scopes: ArtifactScopeSt
73
73
  define("docs.reopen", (input: OperationInput) => transitionDocument(artifacts, string(input, "id"), "reopen", authority, eventContext(input))),
74
74
  define("docs.link", (input: OperationInput) => linkDocument(artifacts, string(input, "id"), string(input, "relation") as DocumentRelation, string(input, "target_id"), authority, eventContext(input))),
75
75
  define("docs.assign_project", (input: OperationInput) => assignDocumentProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
76
+ define("docs.update", (input: OperationInput) => updateDocument(artifacts, string(input, "id"), {
77
+ title: optionalString(input, "title"), body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
78
+ }, authority, eventContext(input))),
76
79
  ];
77
80
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * modules/playbooks.ts — Playbooks as a Papyrus-native registered module.
3
+ *
4
+ * A Playbook (trigger + ordered steps an agent reads and follows) is a completely different
5
+ * beast from a Skill (a mechanically instantiated artifact-template or workflow blueprint) --
6
+ * its own kind, not a subtype squeezed into "skill". See domain-services.ts's Playbook section
7
+ * for the full rationale.
8
+ */
9
+ import { assignPlaybookProject, createPlaybook, listPlaybooks, playbookInvocation, showPlaybook, transitionPlaybook, updatePlaybook } from "../domain-services.ts";
10
+ import type { OperationDefinition } from "../module-registry.ts";
11
+ import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
12
+ import type { ArtifactStore } from "../ports/artifact-store.ts";
13
+
14
+ const MODULE_ID = "playbooks";
15
+
16
+ type OperationInput = Record<string, unknown>;
17
+
18
+ function string(input: OperationInput, key: string): string {
19
+ const value = input[key];
20
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${key} is required`);
21
+ return value;
22
+ }
23
+
24
+ function optionalString(input: OperationInput, key: string): string | undefined {
25
+ const value = input[key];
26
+ if (value === undefined) return undefined;
27
+ if (typeof value !== "string") throw new Error(`${key} must be a string`);
28
+ return value;
29
+ }
30
+
31
+ function optionalNumber(input: OperationInput, key: string): number | undefined {
32
+ const value = input[key];
33
+ if (value === undefined) return undefined;
34
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
35
+ return value;
36
+ }
37
+
38
+ const eventContext = (input: OperationInput) => ({
39
+ actor: optionalString(input, "actor"),
40
+ source: optionalString(input, "source"),
41
+ sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
42
+ });
43
+
44
+ const artifactFilter = (input: OperationInput) => ({
45
+ status: optionalString(input, "status"),
46
+ text: optionalString(input, "text"),
47
+ limit: optionalNumber(input, "limit"),
48
+ projectRoot: optionalString(input, "project_root"),
49
+ });
50
+
51
+ /** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. */
52
+ export const PLAYBOOKS_OPERATION_NAMES = [
53
+ "playbooks.create", "playbooks.list", "playbooks.show", "playbooks.invoke", "playbooks.enable", "playbooks.disable", "playbooks.assign_project", "playbooks.update",
54
+ ] as const;
55
+
56
+ export function playbooksOperations(artifacts: ArtifactStore, scopes: ArtifactScopeStore): OperationDefinition[] {
57
+ const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
58
+ name, moduleId: MODULE_ID, execute,
59
+ });
60
+ return [
61
+ define("playbooks.create", (input: OperationInput) => createPlaybook(artifacts, scopes, {
62
+ title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
63
+ steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
64
+ labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
65
+ projectRoot: optionalString(input, "project_root"),
66
+ }, eventContext(input))),
67
+ define("playbooks.list", (input: OperationInput) => listPlaybooks(artifacts, scopes, artifactFilter(input))),
68
+ define("playbooks.show", (input: OperationInput) => showPlaybook(artifacts, string(input, "id"))),
69
+ define("playbooks.invoke", (input: OperationInput) => playbookInvocation(artifacts, string(input, "id"))),
70
+ define("playbooks.enable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "enable", eventContext(input))),
71
+ define("playbooks.disable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "disable", eventContext(input))),
72
+ define("playbooks.assign_project", (input: OperationInput) => assignPlaybookProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
73
+ define("playbooks.update", (input: OperationInput) => updatePlaybook(artifacts, string(input, "id"), {
74
+ title: optionalString(input, "title"), body: optionalString(input, "body"), labels: input["labels"] as string[] | undefined,
75
+ }, eventContext(input))),
76
+ ];
77
+ }