@danypops/papyrus 0.45.3 → 0.46.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.
@@ -1,453 +1,10 @@
1
- import { type Artifact, requireLocallyOwnedContent } from "./artifact/artifact.ts";
2
- import type { ArtifactEventContext } from "./artifact/artifact-event.ts";
3
- import type { ArtifactScopeStore } from "./artifact/artifact-scope-store.ts";
4
- import type { ArtifactStore } from "./artifact/artifact-store.ts";
5
- import type { ArtifactAction, AuthorityRegistry } from "./authority-registry.ts";
6
- import {
7
- ARTIFACT_BODY_MAX_LENGTH,
8
- ARTIFACT_LABEL_MAX_COUNT,
9
- ARTIFACT_LABEL_MAX_LENGTH,
10
- ARTIFACT_SCOPE_MAX_ARTIFACTS,
11
- ARTIFACT_TITLE_MAX_LENGTH,
12
- PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH,
13
- PLAYBOOK_ARGUMENT_MAX_COUNT,
14
- PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH,
15
- PLAYBOOK_INVOCATION_MAX_CALL_DEPTH,
16
- PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
17
- PLAYBOOK_MAX_STEPS,
18
- RULE_TEXT_HARD_LIMIT_CHARACTERS,
19
- RULE_TEXT_SOFT_TARGET_CHARACTERS,
20
- SKILL_MAX_ENUM_VALUES,
21
- } from "./constants.ts";
22
- import {
23
- BLUEPRINT_INPUT_TYPES,
24
- type BlueprintArgumentValue,
25
- type BlueprintInputType,
26
- validateArgumentValue,
27
- } from "./domain/blueprint-definition.ts";
28
- import { normalizeProjectRoot } from "./domain/task-scope.ts";
29
- import { NOTE_SUBTYPE } from "./note/note-service.ts";
30
-
31
- export interface UpdateContentInput {
32
- title?: string;
33
- body?: string;
34
- labels?: string[];
35
- }
36
-
37
- function requireContentUpdateFields(input: UpdateContentInput): void {
38
- if (input.title === undefined && input.body === undefined && input.labels === undefined) {
39
- throw new Error("update requires title, body, or labels");
40
- }
41
- }
42
-
43
- function assertTitleBounds(title: string | undefined): void {
44
- if (title !== undefined && (title.trim().length === 0 || title.length > ARTIFACT_TITLE_MAX_LENGTH)) {
45
- throw new Error(`title must be between 1 and ${ARTIFACT_TITLE_MAX_LENGTH} characters`);
46
- }
47
- }
48
-
49
- function assertBodyBounds(body: string | undefined): void {
50
- if (body !== undefined && body.length > ARTIFACT_BODY_MAX_LENGTH)
51
- throw new Error(`body cannot exceed ${ARTIFACT_BODY_MAX_LENGTH} characters`);
52
- }
53
-
54
- function assertLabelsBounds(labels: string[] | undefined): void {
55
- if (labels === undefined) return;
56
- if (labels.length > ARTIFACT_LABEL_MAX_COUNT) throw new Error(`labels cannot exceed ${ARTIFACT_LABEL_MAX_COUNT} entries`);
57
- if (labels.some((label) => label.length === 0 || label.length > ARTIFACT_LABEL_MAX_LENGTH)) {
58
- throw new Error(`each label must be between 1 and ${ARTIFACT_LABEL_MAX_LENGTH} characters`);
59
- }
60
- }
61
-
62
- export interface ListFilter {
63
- status?: string;
64
- text?: string;
65
- limit?: number;
66
- /** When supplied, results are limited to artifacts scoped to this project (or the unscoped bucket, for an empty string is not accepted -- use assignArtifactProject's own validation). */
67
- projectRoot?: string;
68
- }
69
-
70
- /**
71
- * Shared by listDocuments/listRules/listPlaybooks: when filter.projectRoot is given, resolve
72
- * via ArtifactScopeStore first and post-filter by kind/status/text (mirrors Tasks.list's
73
- * established scoped-listing shape); otherwise fall back to the existing unscoped query
74
- * path unchanged, so every caller that predates project scoping keeps working exactly as
75
- * before.
76
- */
77
- function listScoped(
78
- artifacts: ArtifactStore,
79
- scopes: ArtifactScopeStore,
80
- kind: string,
81
- filter: ListFilter,
82
- excludeSubtype?: string,
83
- ): Artifact[] {
84
- if (filter.projectRoot === undefined)
85
- return artifacts.query({ kind, excludeSubtype, status: filter.status, text: filter.text, limit: filter.limit });
86
- const limit = filter.limit ?? ARTIFACT_SCOPE_MAX_ARTIFACTS;
87
- if (!Number.isInteger(limit) || limit < 1 || limit > ARTIFACT_SCOPE_MAX_ARTIFACTS) {
88
- throw new Error(`list limit must be between 1 and ${ARTIFACT_SCOPE_MAX_ARTIFACTS}`);
89
- }
90
- const projectRoot = normalizeProjectRoot(filter.projectRoot);
91
- const ids = scopes.ids(projectRoot, ARTIFACT_SCOPE_MAX_ARTIFACTS);
92
- const text = filter.text?.toLowerCase();
93
- return ids
94
- .map((id) => artifacts.get(id))
95
- .filter((artifact): artifact is Artifact => artifact?.kind === kind && artifact.subtype !== excludeSubtype)
96
- .filter((artifact) => filter.status === undefined || artifact.status === filter.status)
97
- .filter((artifact) => text === undefined || artifact.title.toLowerCase().includes(text) || artifact.body.toLowerCase().includes(text))
98
- .sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
99
- .slice(0, limit);
100
- }
101
-
102
- /** Shared by assignDocumentProject/assignRuleProject/assignPlaybookProject. */
103
- function assignArtifactProject(
104
- artifacts: ArtifactStore,
105
- scopes: ArtifactScopeStore,
106
- id: string,
107
- kind: string,
108
- projectRoot: string | undefined,
109
- ): Artifact {
110
- requireKind(artifacts, id, kind);
111
- scopes.assign(
112
- id,
113
- projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot),
114
- projectRoot === undefined ? "unscoped" : "explicit",
115
- );
116
- return artifacts.get(id)!;
117
- }
118
-
119
- function requireKind(artifacts: ArtifactStore, id: string, kind: string): Artifact {
120
- const artifact = artifacts.get(id);
121
- if (!artifact) throw new Error(`${kind} artifact "${id}" not found`);
122
- if (artifact.kind !== kind) throw new Error(`artifact "${id}" is not a ${kind}`);
123
- return artifact;
124
- }
125
-
126
- function rejectsNoteTemplate(artifacts: ArtifactStore, templateId: string | undefined, subtype: string | undefined): boolean {
127
- if (subtype === NOTE_SUBTYPE) return true;
128
- if (!templateId) return false;
129
- const template = artifacts.get(templateId);
130
- const defaults = template?.extra.defaults;
131
- return (
132
- typeof defaults === "object" &&
133
- defaults !== null &&
134
- !Array.isArray(defaults) &&
135
- (defaults as Record<string, unknown>).subtype === NOTE_SUBTYPE
136
- );
137
- }
138
-
139
- /** caller never owns NOTE_SUBTYPE, so requireArtifactAllowed always throws — the trailing throw only satisfies TypeScript's control-flow analysis for a `never`-returning function. */
140
- function requireNotesFacade(authority: AuthorityRegistry, caller: string): never {
141
- authority.requireArtifactAllowed("doc", NOTE_SUBTYPE, "create", caller);
142
- throw new Error("note creation requires notes.capture");
143
- }
144
-
145
- function templateSubtype(artifacts: ArtifactStore, templateId: string | undefined): string | undefined {
146
- if (!templateId) return undefined;
147
- const defaults = artifacts.get(templateId)?.extra.defaults;
148
- if (typeof defaults !== "object" || defaults === null || Array.isArray(defaults)) return undefined;
149
- const subtype = (defaults as Record<string, unknown>).subtype;
150
- return typeof subtype === "string" ? subtype : undefined;
151
- }
152
-
153
- // No default action: linkDocument's own bug (both target and source checks silently defaulting to
154
- // "status" here) was exactly what let a plain reference edge to a Task trip the tasks.* lifecycle
155
- // guard, which is scoped to actual status changes only. Every call site now names its real action.
156
- function requireMutableDocument(document: Artifact, authority: AuthorityRegistry, action: ArtifactAction): Artifact {
157
- authority.requireArtifactAllowed(document.kind, document.subtype, action, "docs");
158
- return document;
159
- }
160
-
161
- export interface CreateDocumentInput {
162
- title: string;
163
- body?: string;
164
- subtype?: string;
165
- labels?: string[];
166
- extra?: Record<string, unknown>;
167
- templateId?: string;
168
- /** Optional at creation, unlike Tasks -- omitting it leaves the Doc in the unscoped bucket, matching today's default behavior for every existing caller. */
169
- projectRoot?: string;
170
- }
171
-
172
- export type UpdateDocumentInput = UpdateContentInput;
173
-
174
- export type DocumentTransition = "activate" | "archive" | "reopen";
175
- export type DocumentRelation = "references" | "documents" | "supersedes" | "relates_to" | "contains" | "part_of";
176
-
177
- const DOCUMENT_TRANSITIONS: Record<DocumentTransition, { from: string[]; to: string }> = {
178
- activate: { from: ["draft"], to: "active" },
179
- archive: { from: ["draft", "active"], to: "archived" },
180
- reopen: { from: ["archived"], to: "draft" },
181
- };
182
-
183
- export function createDocument(
184
- artifacts: ArtifactStore,
185
- scopes: ArtifactScopeStore,
186
- input: CreateDocumentInput,
187
- authority: AuthorityRegistry,
188
- context?: ArtifactEventContext,
189
- ): Artifact {
190
- if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade(authority, "docs");
191
- authority.requireArtifactAllowed("doc", input.subtype ?? templateSubtype(artifacts, input.templateId), "create", "docs");
192
- const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
193
- const document = artifacts.create(
194
- {
195
- kind: "doc",
196
- // Explicit, not defaultStatusFor's "first status row by rowid" fallback -- the same
197
- // heuristic that made Task creation non-deterministic on a migrated database. Every
198
- // creation path that has no caller-supplied initial status must set one explicitly.
199
- status: "draft",
200
- title: input.title,
201
- body: input.body,
202
- subtype: input.subtype,
203
- labels: input.labels,
204
- extra: input.extra,
205
- templateId: input.templateId,
206
- },
207
- context,
208
- );
209
- scopes.assign(document.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
210
- return document;
211
- }
212
-
213
- export function listDocuments(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
214
- return listScoped(artifacts, scopes, "doc", filter, NOTE_SUBTYPE);
215
- }
216
-
217
- export function assignDocumentProject(
218
- artifacts: ArtifactStore,
219
- scopes: ArtifactScopeStore,
220
- id: string,
221
- projectRoot: string | undefined,
222
- ): Artifact {
223
- requireDocument(artifacts, id); // rejects Notes -- project reassignment for notes goes through notes.* like everything else about them
224
- scopes.assign(
225
- id,
226
- projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot),
227
- projectRoot === undefined ? "unscoped" : "explicit",
228
- );
229
- return artifacts.get(id)!;
230
- }
231
-
232
- function requireDocument(artifacts: ArtifactStore, id: string): Artifact {
233
- const document = requireKind(artifacts, id, "doc");
234
- if (document.subtype === NOTE_SUBTYPE) throw new Error("note access requires a notes.* operation");
235
- return document;
236
- }
237
-
238
- export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
239
- requireDocument(artifacts, id);
240
- return artifacts.get(id, { tree: true })!;
241
- }
242
-
243
- export function transitionDocument(
244
- artifacts: ArtifactStore,
245
- id: string,
246
- action: DocumentTransition,
247
- authority: AuthorityRegistry,
248
- context?: ArtifactEventContext,
249
- ): Artifact {
250
- const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "status"));
251
- const transition = DOCUMENT_TRANSITIONS[action];
252
- if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
253
- return artifacts.setStatus(id, transition.to, context)!;
254
- }
255
-
256
- /**
257
- * Docs are immutable-by-convention only in the sense that no path existed to change them --
258
- * this is that path. A read-only external projection (see requireLocallyOwnedContent) still
259
- * refuses, on purpose: rewriting it here would silently fork from whatever system actually
260
- * owns it (e.g. web-spider's ingested pages), with nothing to ever reconcile the two again.
261
- */
262
- export function updateDocument(
263
- artifacts: ArtifactStore,
264
- id: string,
265
- input: UpdateDocumentInput,
266
- authority: AuthorityRegistry,
267
- context?: ArtifactEventContext,
268
- ): Artifact {
269
- requireContentUpdateFields(input);
270
- assertTitleBounds(input.title);
271
- assertBodyBounds(input.body);
272
- assertLabelsBounds(input.labels);
273
- const _document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "update"));
274
- const updated = artifacts.updateContent(id, input, context);
275
- if (!updated) throw new Error(`document "${id}" not found`);
276
- return updated;
277
- }
278
-
279
- export function linkDocument(
280
- artifacts: ArtifactStore,
281
- id: string,
282
- relation: DocumentRelation,
283
- targetId: string,
284
- authority: AuthorityRegistry,
285
- context?: ArtifactEventContext,
286
- ): Artifact {
287
- requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "link"));
288
- const target = artifacts.get(targetId);
289
- if (!target) throw new Error(`target artifact "${targetId}" not found`);
290
- requireLocallyOwnedContent(requireMutableDocument(target, authority, "link"));
291
- artifacts.link({ from: id, relation, to: targetId }, context);
292
- return showDocument(artifacts, id);
293
- }
294
-
295
- export interface CreateRuleInput {
296
- title: string;
297
- body?: string;
298
- condition?: string;
299
- action?: string;
300
- severity?: "block" | "warn" | "info";
301
- labels?: string[];
302
- extra?: Record<string, unknown>;
303
- projectRoot?: string;
304
- }
305
-
306
- export type RuleTransition = "enable" | "disable";
307
-
308
- /**
309
- * A Rule's condition+action+body is injected into every relevant turn for the rule's entire
310
- * lifetime -- a permanent tax on every future turn's context budget, not a one-time cost.
311
- * Rejects (rather than silently truncating or merely warning) once a rule is unambiguously
312
- * bloated, since a silently-truncated rule would inject different text than what its author
313
- * reviewed, and a warning nobody reads is not a bound. See RULE_TEXT_HARD_LIMIT_CHARACTERS's
314
- * own comment in constants.ts for the research this threshold is grounded in.
315
- */
316
- export function ruleCombinedLength(condition: string | undefined, action: string | undefined, body: string | undefined): number {
317
- return (condition ?? "").length + (action ?? "").length + (body ?? "").length;
318
- }
319
-
320
- /**
321
- * Non-blocking counterpart to assertRuleTextWithinBounds's hard rejection: the same combined
322
- * length, informational once it crosses the soft target, so a caller doesn't have to self-police
323
- * with a manual character count before every rules.create/update. Returns undefined at or under
324
- * the target -- the common case, not worth a field only ever seen as "undefined" on the wire.
325
- */
326
- export function ruleCombinedLengthWarning(combinedLength: number): string | undefined {
327
- if (combinedLength <= RULE_TEXT_SOFT_TARGET_CHARACTERS) return undefined;
328
- return (
329
- `condition+action+body is ${combinedLength} characters, over the ${RULE_TEXT_SOFT_TARGET_CHARACTERS}-character soft target ` +
330
- `(hard limit ${RULE_TEXT_HARD_LIMIT_CHARACTERS}) -- consider moving detail into a linked Doc.`
331
- );
332
- }
333
-
334
- function assertRuleTextWithinBounds(condition: string | undefined, action: string | undefined, body: string | undefined): void {
335
- const combined = ruleCombinedLength(condition, action, body);
336
- if (combined > RULE_TEXT_HARD_LIMIT_CHARACTERS) {
337
- throw new Error(
338
- `rule condition+action+body is ${combined} characters, exceeding the ${RULE_TEXT_HARD_LIMIT_CHARACTERS}-character bound. ` +
339
- "A Rule is injected into every relevant turn for its entire lifetime -- this is a permanent context-budget tax, not a one-time cost. " +
340
- "Split it: keep a short Rule (the condition and the invariant itself), and move the full reasoning, examples, and research into a linked Doc.",
341
- );
342
- }
343
- }
344
-
345
- export function createRule(
346
- artifacts: ArtifactStore,
347
- scopes: ArtifactScopeStore,
348
- input: CreateRuleInput,
349
- context?: ArtifactEventContext,
350
- ): Artifact {
351
- assertRuleTextWithinBounds(input.condition, input.action, input.body);
352
- const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
353
- const rule = artifacts.create(
354
- {
355
- kind: "rule",
356
- status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
357
- title: input.title,
358
- body: input.body,
359
- labels: input.labels,
360
- extra: {
361
- ...(input.extra ?? {}),
362
- ...(input.condition ? { condition: input.condition } : {}),
363
- ...(input.action ? { action: input.action } : {}),
364
- severity: input.severity ?? "info",
365
- },
366
- },
367
- context,
368
- );
369
- scopes.assign(rule.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
370
- return rule;
371
- }
372
-
373
- export function listRules(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
374
- return listScoped(artifacts, scopes, "rule", filter);
375
- }
376
-
377
- export function assignRuleProject(
378
- artifacts: ArtifactStore,
379
- scopes: ArtifactScopeStore,
380
- id: string,
381
- projectRoot: string | undefined,
382
- ): Artifact {
383
- return assignArtifactProject(artifacts, scopes, id, "rule", projectRoot);
384
- }
385
-
386
- /**
387
- * Global rules always apply; scoped workflow-run rules apply only while their run owns active
388
- * focus. Both a workflow-definition target's own run scope ("skill-run", written by
389
- * workflow-execution.ts's runWorkflowSteps for that target kind) and a Playbook's own run scope
390
- * ("playbook-run", same call for a Playbook target) are recognized -- confirmed live that only
391
- * "skill-run" was ever checked here, silently breaking Playbook-run-scoped rule injection since
392
- * Playbook gained its own doc/rule structured steps.
393
- */
394
- export function listInjectableRules(artifacts: ArtifactStore, activeTaskId?: string): Artifact[] {
395
- return artifacts.query({ kind: "rule", status: "active" }).filter((rule) => {
396
- const scope = rule.extra.scope;
397
- if (scope === undefined) return true;
398
- if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
399
- const value = scope as Record<string, unknown>;
400
- if ((value.type !== "skill-run" && value.type !== "playbook-run") || !Array.isArray(value.taskIds)) return false;
401
- return activeTaskId !== undefined && value.taskIds.some((id) => id === activeTaskId);
402
- });
403
- }
404
-
405
- export function showRule(artifacts: ArtifactStore, id: string): Artifact {
406
- requireKind(artifacts, id, "rule");
407
- return artifacts.get(id, { tree: true })!;
408
- }
409
-
410
- export function previewRule(artifacts: ArtifactStore, id: string): string {
411
- const rule = requireKind(artifacts, id, "rule");
412
- const condition = typeof rule.extra.condition === "string" ? ` (when: ${rule.extra.condition})` : "";
413
- const action = rule.body || (typeof rule.extra.action === "string" ? rule.extra.action : "");
414
- return `• ${rule.title}${condition}\n ${action}`;
415
- }
416
-
417
- export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition, context?: ArtifactEventContext): Artifact {
418
- const rule = requireLocallyOwnedContent(requireKind(artifacts, id, "rule"));
419
- const expected = action === "enable" ? "deprecated" : "active";
420
- const target = action === "enable" ? "active" : "deprecated";
421
- if (rule.status !== expected) throw new Error(`cannot ${action} rule from ${rule.status}`);
422
- return artifacts.setStatus(id, target, context)!;
423
- }
424
-
425
- export type UpdateRuleInput = UpdateContentInput;
426
-
427
- /** 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. */
428
- export function updateRule(artifacts: ArtifactStore, id: string, input: UpdateRuleInput, context?: ArtifactEventContext): Artifact {
429
- requireContentUpdateFields(input);
430
- assertTitleBounds(input.title);
431
- assertLabelsBounds(input.labels);
432
- const rule = requireLocallyOwnedContent(requireKind(artifacts, id, "rule"));
433
- if (input.body !== undefined) {
434
- const condition = typeof rule.extra.condition === "string" ? rule.extra.condition : undefined;
435
- const action = typeof rule.extra.action === "string" ? rule.extra.action : undefined;
436
- assertRuleTextWithinBounds(condition, action, input.body);
437
- }
438
- const updated = artifacts.updateContent(id, input, context);
439
- if (!updated) throw new Error(`rule "${id}" not found`);
440
- return updated;
441
- }
442
-
443
- export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
444
- requireLocallyOwnedContent(requireKind(artifacts, ruleId, "rule"));
445
- requireKind(artifacts, taskId, "task");
446
- artifacts.link({ from: ruleId, relation: "gates", to: taskId }, context);
447
- return showRule(artifacts, ruleId);
448
- }
449
-
450
1
  /**
2
+ * Playbook domain composition logic (create/list/show/update/transition/contain/depend/invoke),
3
+ * split out of the former domain-services.ts into its own per-domain file alongside
4
+ * docs/docs-service.ts and rules/rules-service.ts. Shared, kind-agnostic helpers live in
5
+ * ../domain-service-shared.ts. Distinct from playbook-definition.ts/playbook-execution.ts in
6
+ * this same directory, which compile/invoke a Playbook via the shared blueprint engine.
7
+ *
451
8
  * Playbooks: a trigger and an ordered list of steps -- authored as prose. But playbooks.invoke
452
9
  * (playbook-execution.ts) recycles the shared blueprint materialization engine: it compiles a
453
10
  * Playbook into a BlueprintDefinition and mechanically instantiates real Tasks from it.
@@ -464,6 +21,43 @@ export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskI
464
21
  * time, while invoke's compiler (playbook-definition.ts) treats a cycle as a hard error --
465
22
  * real Tasks would otherwise be created in an infinite loop, unlike text rendering.
466
23
  */
24
+
25
+ import type { Artifact } from "../artifact/artifact.ts";
26
+ import { requireLocallyOwnedContent } from "../artifact/artifact.ts";
27
+ import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
28
+ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
29
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
30
+ import {
31
+ ARTIFACT_TITLE_MAX_LENGTH,
32
+ PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH,
33
+ PLAYBOOK_ARGUMENT_MAX_COUNT,
34
+ PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH,
35
+ PLAYBOOK_INVOCATION_MAX_CALL_DEPTH,
36
+ PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
37
+ PLAYBOOK_MAX_STEPS,
38
+ SKILL_MAX_ENUM_VALUES,
39
+ } from "../constants.ts";
40
+ import {
41
+ BLUEPRINT_INPUT_TYPES,
42
+ type BlueprintArgumentValue,
43
+ type BlueprintInputType,
44
+ validateArgumentValue,
45
+ } from "../domain/blueprint-definition.ts";
46
+ import { normalizeProjectRoot } from "../domain/task-scope.ts";
47
+ import {
48
+ assertBodyBounds,
49
+ assertLabelsBounds,
50
+ assertTitleBounds,
51
+ assignArtifactProject,
52
+ type ListFilter,
53
+ listScoped,
54
+ requireContentUpdateFields,
55
+ requireKind,
56
+ runTransition,
57
+ type TransitionTable,
58
+ type UpdateContentInput,
59
+ } from "../domain-service-shared.ts";
60
+
467
61
  export interface PlaybookArgument {
468
62
  name: string;
469
63
  description?: string;
@@ -621,14 +215,21 @@ export interface CreatePlaybookInput {
621
215
  tools?: string[];
622
216
  /** Declares named arguments this Playbook needs -- see playbookInvocation for how a missing required one surfaces. */
623
217
  arguments?: unknown;
218
+ subtype?: string;
624
219
  labels?: string[];
625
220
  extra?: Record<string, unknown>;
221
+ templateId?: string;
626
222
  projectRoot?: string;
627
223
  }
628
224
 
629
225
  export type PlaybookTransition = "enable" | "disable";
630
226
  export type UpdatePlaybookInput = UpdateContentInput;
631
227
 
228
+ const PLAYBOOK_TRANSITIONS: TransitionTable<PlaybookTransition, string> = {
229
+ enable: { from: ["deprecated"], to: "active" },
230
+ disable: { from: ["active"], to: "deprecated" },
231
+ };
232
+
632
233
  export function createPlaybook(
633
234
  artifacts: ArtifactStore,
634
235
  scopes: ArtifactScopeStore,
@@ -644,6 +245,7 @@ export function createPlaybook(
644
245
  status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
645
246
  title: input.title,
646
247
  body: input.body,
248
+ subtype: input.subtype,
647
249
  labels: input.labels,
648
250
  extra: {
649
251
  ...(input.extra ?? {}),
@@ -652,6 +254,7 @@ export function createPlaybook(
652
254
  ...(input.tools ? { tools: input.tools } : {}),
653
255
  ...(declaredArguments ? { arguments: declaredArguments } : {}),
654
256
  },
257
+ templateId: input.templateId,
655
258
  },
656
259
  context,
657
260
  );
@@ -695,10 +298,7 @@ export function transitionPlaybook(
695
298
  context?: ArtifactEventContext,
696
299
  ): Artifact {
697
300
  const playbook = requireLocallyOwnedContent(requireKind(artifacts, id, "playbook"));
698
- const expected = action === "enable" ? "deprecated" : "active";
699
- const target = action === "enable" ? "active" : "deprecated";
700
- if (playbook.status !== expected) throw new Error(`cannot ${action} playbook from ${playbook.status}`);
701
- return artifacts.setStatus(id, target, context)!;
301
+ return runTransition(artifacts, playbook, "playbook", action, PLAYBOOK_TRANSITIONS, context);
702
302
  }
703
303
 
704
304
  /** Idempotent (INSERT OR IGNORE at the storage layer): containing an already-nested child is a no-op, not an error. Both contains/part_of edges are written atomically -- matches tasks.contain's own shape. */