@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.
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/authority-registry.ts +2 -1
- package/src/cli/task-command.ts +89 -4
- package/src/constants.ts +6 -1
- package/src/db.ts +68 -1
- package/src/docs/docs-service.ts +192 -0
- package/src/domain/task-lease.ts +6 -0
- package/src/domain/task-scope.ts +16 -0
- package/src/domain-service-shared.ts +162 -0
- package/src/handlers/docs.ts +1 -1
- package/src/handlers/playbooks.ts +3 -1
- package/src/handlers/rules.ts +5 -3
- package/src/handlers/shared.ts +16 -1
- package/src/handlers/tasks.ts +129 -14
- package/src/index.ts +1 -1
- package/src/modules/docs.ts +2 -2
- package/src/modules/notes.ts +2 -2
- package/src/modules/playbooks.ts +6 -4
- package/src/modules/rules.ts +4 -2
- package/src/modules/tasks.ts +19 -0
- package/src/playbook/playbook-definition.ts +1 -1
- package/src/{domain-services.ts → playbook/playbook-service.ts} +53 -453
- package/src/rules/rules-service.ts +224 -0
- package/src/service.ts +7 -2
- package/src/stores/sqlite-task-create-request-store.ts +47 -0
- package/src/stores/sqlite-task-scope-store.ts +154 -15
- package/src/stores/task-create-request-store.ts +45 -0
- package/src/stores/task-scope-store.ts +91 -1
- package/src/task/task-service.ts +131 -13
|
@@ -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
|
-
|
|
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. */
|