@danypops/papyrus 0.41.0 → 0.42.1

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.
Files changed (78) hide show
  1. package/README.md +8 -11
  2. package/package.json +2 -2
  3. package/src/adapters/sqlite-artifact-scope-store.ts +18 -9
  4. package/src/adapters/sqlite-artifact-store.ts +7 -5
  5. package/src/adapters/sqlite-discussion-round-store.ts +26 -17
  6. package/src/adapters/sqlite-gate-runner.ts +1 -1
  7. package/src/adapters/sqlite-graph-projection-store.ts +14 -10
  8. package/src/adapters/sqlite-log-store.ts +36 -17
  9. package/src/adapters/sqlite-note-event-store.ts +20 -16
  10. package/src/adapters/sqlite-session-identity-store.ts +13 -7
  11. package/src/adapters/sqlite-task-event-store.ts +29 -21
  12. package/src/adapters/sqlite-task-focus-store.ts +36 -10
  13. package/src/adapters/sqlite-task-lease-store.ts +12 -6
  14. package/src/adapters/sqlite-task-scope-store.ts +25 -14
  15. package/src/artifact-relationship-view.ts +4 -4
  16. package/src/artifact-subtree.ts +4 -2
  17. package/src/authority-registry.ts +2 -1
  18. package/src/cli.ts +794 -354
  19. package/src/client.ts +6 -3
  20. package/src/constants.ts +34 -56
  21. package/src/daemon-state.ts +4 -12
  22. package/src/daemon.ts +31 -9
  23. package/src/db.ts +153 -105
  24. package/src/discussion-service.ts +109 -44
  25. package/src/domain/artifact-event.ts +18 -5
  26. package/src/domain/artifact.ts +3 -1
  27. package/src/domain/blueprint-definition.ts +268 -0
  28. package/src/domain/checklist.ts +20 -17
  29. package/src/domain/discussion.ts +37 -18
  30. package/src/domain/gate.ts +7 -7
  31. package/src/domain/log-entry.ts +1 -1
  32. package/src/domain/note-event.ts +20 -7
  33. package/src/domain/task-event.ts +17 -7
  34. package/src/domain-services.ts +362 -288
  35. package/src/graph-projection-service.ts +34 -8
  36. package/src/id-migration.ts +17 -4
  37. package/src/index.ts +16 -11
  38. package/src/log-service.ts +6 -5
  39. package/src/log.ts +19 -0
  40. package/src/modules/discuss.ts +63 -28
  41. package/src/modules/docs.ts +74 -17
  42. package/src/modules/graph-projection.ts +20 -9
  43. package/src/modules/logs.ts +34 -22
  44. package/src/modules/notes.ts +66 -28
  45. package/src/modules/playbooks.ts +93 -32
  46. package/src/modules/rules.ts +57 -15
  47. package/src/modules/session-identity.ts +6 -2
  48. package/src/modules/tasks.ts +142 -67
  49. package/src/note-service.ts +11 -7
  50. package/src/ops.ts +134 -69
  51. package/src/playbook-definition.ts +124 -39
  52. package/src/playbook-execution.ts +18 -25
  53. package/src/ports/artifact-scope-store.ts +1 -1
  54. package/src/ports/note-event-store.ts +6 -4
  55. package/src/ports/task-event-store.ts +9 -6
  56. package/src/ports/task-focus-store.ts +17 -4
  57. package/src/ports/task-lease-store.ts +9 -4
  58. package/src/ports/task-scope-store.ts +3 -1
  59. package/src/service.ts +148 -110
  60. package/src/session-identity-service.ts +10 -2
  61. package/src/task-context.ts +28 -16
  62. package/src/task-execution.ts +4 -12
  63. package/src/task-graph-view.ts +12 -12
  64. package/src/task-relationship-view.ts +1 -3
  65. package/src/task-service.ts +168 -73
  66. package/src/vehicle/artifact-trash-vehicle.ts +26 -14
  67. package/src/vehicle/artifact-vehicle-shared.ts +32 -13
  68. package/src/vehicle/docs-vehicle.ts +50 -18
  69. package/src/vehicle/notes-vehicle.ts +26 -8
  70. package/src/vehicle/papyrus-vehicle.ts +16 -8
  71. package/src/vehicle/playbooks-vehicle.ts +88 -19
  72. package/src/vehicle/rules-vehicle.ts +58 -21
  73. package/src/vehicle/tasks-vehicle.ts +366 -54
  74. package/src/version.ts +1 -1
  75. package/src/workflow-execution.ts +198 -109
  76. package/src/domain/skill-definition.ts +0 -270
  77. package/src/modules/skills.ts +0 -158
  78. package/src/vehicle/skills-vehicle.ts +0 -194
package/src/ops.ts CHANGED
@@ -3,36 +3,40 @@
3
3
  * Enforces the schema protocol (kinds, statuses, relations) via FK + app validation.
4
4
  */
5
5
  import { createRequire } from "node:module";
6
+ import { ARTIFACT_TRASH_RETENTION_MS, DEFAULT_STATUS_BY_KIND } from "./constants.ts";
6
7
  import type { Db } from "./db.ts";
7
8
  import { inTransaction } from "./db.ts";
8
- import { ARTIFACT_TRASH_RETENTION_MS, DEFAULT_STATUS_BY_KIND } from "./constants.ts";
9
9
  import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
10
10
  import type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
11
+
11
12
  export type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
12
- import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
13
+
13
14
  import {
14
- normalizeArtifactEventQuery,
15
- resolveArtifactEvent,
16
15
  type AppendArtifactEvent,
17
16
  type ArtifactEvent,
18
17
  type ArtifactEventContext,
19
18
  type ArtifactEventPage,
20
19
  type ArtifactEventQuery,
21
20
  type ArtifactEventType,
21
+ normalizeArtifactEventQuery,
22
+ resolveArtifactEvent,
22
23
  } from "./domain/artifact-event.ts";
24
+ import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
25
+
23
26
  export type { Artifact } from "./domain/artifact.ts";
24
27
  export type { Gate, GateResult } from "./domain/gate.ts";
25
28
  export type CreateInput = CreateArtifactInput;
29
+
26
30
  import {
27
31
  DEFAULT_GRAPH_DEPTH,
28
32
  DEFAULT_GRAPH_MAX_NODES,
29
- MAX_GRAPH_DEPTH,
30
- MAX_GRAPH_NODES,
31
33
  GATE_COMMAND_TIMEOUT_MS,
32
- GATE_TEST_TIMEOUT_MS,
33
- GATE_OUTPUT_LIMIT,
34
- GATE_MAX_BUFFER_BYTES,
35
34
  GATE_FILE_MAX_BYTES,
35
+ GATE_MAX_BUFFER_BYTES,
36
+ GATE_OUTPUT_LIMIT,
37
+ GATE_TEST_TIMEOUT_MS,
38
+ MAX_GRAPH_DEPTH,
39
+ MAX_GRAPH_NODES,
36
40
  } from "./constants.ts";
37
41
 
38
42
  const require_ = createRequire(import.meta.url);
@@ -58,8 +62,7 @@ function deepMerge(base: unknown, override: unknown): unknown {
58
62
  }
59
63
 
60
64
  function valueAtPath(value: unknown, path: string): unknown {
61
- return path.split(".").reduce<unknown>((current, segment) =>
62
- isRecord(current) ? current[segment] : undefined, value);
65
+ return path.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), value);
63
66
  }
64
67
 
65
68
  function isPresent(value: unknown): boolean {
@@ -75,11 +78,13 @@ function resolveCreateInput(db: Db, input: CreateInput): ResolvedCreateInput {
75
78
 
76
79
  const template = getArtifact(db, input.templateId);
77
80
  if (!template) throw new Error(`template "${input.templateId}" not found`);
78
- if (template.kind !== "skill" || template.subtype !== "artifact-template") {
81
+ // A template is any artifact carrying subtype=artifact-template metadata -- its own kind is
82
+ // irrelevant to its function as a defaults/required carrier for the *target* kind.
83
+ if (template.subtype !== "artifact-template") {
79
84
  throw new Error(`artifact "${input.templateId}" is not an artifact template`);
80
85
  }
81
86
 
82
- const targetKind = template.extra["targetKind"];
87
+ const targetKind = template.extra.targetKind;
83
88
  if (typeof targetKind !== "string" || targetKind.length === 0) {
84
89
  throw new Error(`template "${input.templateId}" has no targetKind`);
85
90
  }
@@ -87,13 +92,13 @@ function resolveCreateInput(db: Db, input: CreateInput): ResolvedCreateInput {
87
92
  throw new Error(`template "${input.templateId}" targets kind "${targetKind}", not "${input.kind}"`);
88
93
  }
89
94
 
90
- const defaults = isRecord(template.extra["defaults"]) ? template.extra["defaults"] : {};
95
+ const defaults = isRecord(template.extra.defaults) ? template.extra.defaults : {};
91
96
  const { templateId: _templateId, ...overrides } = input;
92
97
  const merged = deepMerge(defaults, overrides) as CreateInput;
93
98
  merged.kind = targetKind;
94
99
 
95
- const required = Array.isArray(template.extra["required"])
96
- ? template.extra["required"].filter((field): field is string => typeof field === "string")
100
+ const required = Array.isArray(template.extra.required)
101
+ ? template.extra.required.filter((field): field is string => typeof field === "string")
97
102
  : ["title"];
98
103
  for (const field of required) {
99
104
  if (!isPresent(valueAtPath(merged, field))) {
@@ -116,16 +121,16 @@ function defaultStatusFor(db: Db, kind: string): string {
116
121
 
117
122
  function rowToArtifact(row: Record<string, unknown>): Artifact {
118
123
  return {
119
- id: row["id"] as string,
120
- kind: row["kind"] as string,
121
- title: row["title"] as string,
122
- status: row["status"] as string,
123
- subtype: (row["subtype"] as string) ?? "",
124
- body: (row["body"] as string) ?? "",
125
- labels: JSON.parse((row["labels"] as string) ?? "[]"),
126
- extra: JSON.parse((row["extra"] as string) ?? "{}"),
127
- created_at: row["created_at"] as string,
128
- updated_at: row["updated_at"] as string,
124
+ id: row.id as string,
125
+ kind: row.kind as string,
126
+ title: row.title as string,
127
+ status: row.status as string,
128
+ subtype: (row.subtype as string) ?? "",
129
+ body: (row.body as string) ?? "",
130
+ labels: JSON.parse((row.labels as string) ?? "[]"),
131
+ extra: JSON.parse((row.extra as string) ?? "{}"),
132
+ created_at: row.created_at as string,
133
+ updated_at: row.updated_at as string,
129
134
  };
130
135
  }
131
136
 
@@ -141,23 +146,25 @@ export function appendArtifactEvent(db: Db, input: AppendArtifactEvent): Artifac
141
146
  const now = new Date().toISOString();
142
147
  let id: number | bigint = 0;
143
148
  inTransaction(db, () => {
144
- const result = db.prepare(`
149
+ const result = db
150
+ .prepare(`
145
151
  INSERT INTO artifact_events (
146
152
  artifact_id, occurred_at, event_type, actor, source, session_id,
147
153
  from_status, to_status, relation, related_id, event_schema_version
148
154
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
149
- `).run(
150
- event.artifactId,
151
- now,
152
- event.type,
153
- event.actor,
154
- event.source,
155
- event.sessionId ?? null,
156
- event.fromStatus ?? null,
157
- event.toStatus ?? null,
158
- event.relation ?? null,
159
- event.relatedId ?? null,
160
- );
155
+ `)
156
+ .run(
157
+ event.artifactId,
158
+ now,
159
+ event.type,
160
+ event.actor,
161
+ event.source,
162
+ event.sessionId ?? null,
163
+ event.fromStatus ?? null,
164
+ event.toStatus ?? null,
165
+ event.relation ?? null,
166
+ event.relatedId ?? null,
167
+ );
161
168
  id = result.lastInsertRowid;
162
169
  });
163
170
  return {
@@ -213,19 +220,36 @@ export function queryArtifactEvents(db: Db, query: ArtifactEventQuery): Artifact
213
220
  const { artifactId, actor, sessionId, since, limit, direction, cursor } = normalizeArtifactEventQuery(query);
214
221
  const conditions: string[] = [];
215
222
  const params: unknown[] = [];
216
- if (artifactId) { conditions.push("(artifact_id = ? OR related_id = ?)"); params.push(artifactId, artifactId); }
217
- if (actor) { conditions.push("actor = ?"); params.push(actor); }
218
- if (sessionId) { conditions.push("session_id = ?"); params.push(sessionId); }
219
- if (since) { conditions.push("occurred_at >= ?"); params.push(since); }
223
+ if (artifactId) {
224
+ conditions.push("(artifact_id = ? OR related_id = ?)");
225
+ params.push(artifactId, artifactId);
226
+ }
227
+ if (actor) {
228
+ conditions.push("actor = ?");
229
+ params.push(actor);
230
+ }
231
+ if (sessionId) {
232
+ conditions.push("session_id = ?");
233
+ params.push(sessionId);
234
+ }
235
+ if (since) {
236
+ conditions.push("occurred_at >= ?");
237
+ params.push(since);
238
+ }
220
239
  const comparator = direction === "desc" ? "<" : ">";
221
- if (cursor !== undefined) { conditions.push(`id ${comparator} ?`); params.push(cursor); }
240
+ if (cursor !== undefined) {
241
+ conditions.push(`id ${comparator} ?`);
242
+ params.push(cursor);
243
+ }
222
244
  const order = direction === "desc" ? "DESC" : "ASC";
223
- const rows = db.prepare(`
245
+ const rows = db
246
+ .prepare(`
224
247
  SELECT * FROM artifact_events
225
248
  WHERE ${conditions.join(" AND ")}
226
249
  ORDER BY occurred_at ${order}, id ${order}
227
250
  LIMIT ?
228
- `).all(...params, limit + 1) as ArtifactEventRow[];
251
+ `)
252
+ .all(...params, limit + 1) as ArtifactEventRow[];
229
253
  const hasMore = rows.length > limit;
230
254
  const events = rows.slice(0, limit).map(mapArtifactEventRow);
231
255
  return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
@@ -260,7 +284,11 @@ export function getArtifact(db: Db, id: string, opts?: { tree?: boolean; depth?:
260
284
  const depthLimit = Math.min(MAX_GRAPH_DEPTH, Math.max(0, Math.floor(opts.depth ?? DEFAULT_GRAPH_DEPTH)));
261
285
  const nodeLimit = Math.min(MAX_GRAPH_NODES, Math.max(1, Math.floor(opts.maxNodes ?? DEFAULT_GRAPH_MAX_NODES)));
262
286
  const queue: Array<{ id: string; depth: number }> = [{ id, depth: 0 }];
263
- const allEdges = db.prepare('SELECT from_id AS "from", relation, to_id AS "to" FROM edges').all() as { from: string; relation: string; to: string }[];
287
+ const allEdges = db.prepare('SELECT from_id AS "from", relation, to_id AS "to" FROM edges').all() as {
288
+ from: string;
289
+ relation: string;
290
+ to: string;
291
+ }[];
264
292
  const reachable = new Set<string>([id]);
265
293
  const adj = new Map<string, { from: string; relation: string; to: string }[]>();
266
294
  for (const edge of allEdges) {
@@ -295,16 +323,31 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
295
323
  conditions.push(`id IN (${filter.ids.map(() => "?").join(", ")})`);
296
324
  params.push(...filter.ids);
297
325
  }
298
- if (filter.kind) { conditions.push("kind = ?"); params.push(filter.kind); }
299
- if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
326
+ if (filter.kind) {
327
+ conditions.push("kind = ?");
328
+ params.push(filter.kind);
329
+ }
330
+ if (filter.status) {
331
+ conditions.push("status = ?");
332
+ params.push(filter.status);
333
+ }
300
334
  if (filter.statuses) {
301
335
  if (filter.statuses.length === 0) return [];
302
336
  conditions.push(`status IN (${filter.statuses.map(() => "?").join(", ")})`);
303
337
  params.push(...filter.statuses);
304
338
  }
305
- if (filter.subtype) { conditions.push("subtype = ?"); params.push(filter.subtype); }
306
- if (filter.excludeSubtype) { conditions.push("subtype != ?"); params.push(filter.excludeSubtype); }
307
- if (filter.text) { conditions.push("(title LIKE ? OR body LIKE ?)"); params.push(`%${filter.text}%`, `%${filter.text}%`); }
339
+ if (filter.subtype) {
340
+ conditions.push("subtype = ?");
341
+ params.push(filter.subtype);
342
+ }
343
+ if (filter.excludeSubtype) {
344
+ conditions.push("subtype != ?");
345
+ params.push(filter.excludeSubtype);
346
+ }
347
+ if (filter.text) {
348
+ conditions.push("(title LIKE ? OR body LIKE ?)");
349
+ params.push(`%${filter.text}%`, `%${filter.text}%`);
350
+ }
308
351
  for (const label of filter.labels ?? []) {
309
352
  conditions.push("EXISTS (SELECT 1 FROM json_each(artifacts.labels) WHERE value = ?)");
310
353
  params.push(label);
@@ -314,7 +357,7 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
314
357
  conditions.push("json_extract(extra, ?) = ?");
315
358
  params.push(`$.${key}`, value);
316
359
  }
317
- if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
360
+ if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
318
361
  sql += " ORDER BY updated_at DESC";
319
362
  if (filter.limit !== undefined) {
320
363
  if (!Number.isInteger(filter.limit) || filter.limit < 1) throw new Error("artifact query limit must be a positive integer");
@@ -327,20 +370,25 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
327
370
 
328
371
  function rowToTrashRecord(row: Record<string, unknown>): ArtifactTrashRecord {
329
372
  return {
330
- artifactId: row["artifact_id"] as string,
331
- trashedAt: row["trashed_at"] as string,
332
- purgeAfter: row["purge_after"] as string,
333
- ...(row["reason"] == null ? {} : { reason: row["reason"] as string }),
373
+ artifactId: row.artifact_id as string,
374
+ trashedAt: row.trashed_at as string,
375
+ purgeAfter: row.purge_after as string,
376
+ ...(row.reason == null ? {} : { reason: row.reason as string }),
334
377
  };
335
378
  }
336
379
 
337
380
  export function getArtifactTrash(db: Db, id: string): ArtifactTrashRecord | null {
338
- const row = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash WHERE artifact_id = ?").get(id) as Record<string, unknown> | null;
381
+ const row = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash WHERE artifact_id = ?").get(id) as Record<
382
+ string,
383
+ unknown
384
+ > | null;
339
385
  return row ? rowToTrashRecord(row) : null;
340
386
  }
341
387
 
342
388
  export function listArtifactTrash(db: Db): ArtifactTrashRecord[] {
343
- const rows = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash ORDER BY purge_after ASC").all() as Record<string, unknown>[];
389
+ const rows = db
390
+ .prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash ORDER BY purge_after ASC")
391
+ .all() as Record<string, unknown>[];
344
392
  return rows.map(rowToTrashRecord);
345
393
  }
346
394
 
@@ -357,11 +405,16 @@ export function listArtifactTrash(db: Db): ArtifactTrashRecord[] {
357
405
  * caller is not necessarily looking at right now. No other kind has an analogous "currently
358
406
  * in use" signal to check.
359
407
  */
360
- export function trashArtifact(db: Db, id: string, options?: { reason?: string; now?: () => string; context?: ArtifactEventContext }): ArtifactTrashRecord {
408
+ export function trashArtifact(
409
+ db: Db,
410
+ id: string,
411
+ options?: { reason?: string; now?: () => string; context?: ArtifactEventContext },
412
+ ): ArtifactTrashRecord {
361
413
  const artifact = getArtifact(db, id);
362
414
  if (!artifact) throw new Error(`artifact "${id}" not found`);
363
415
  const focusedScope = db.prepare("SELECT scope FROM task_focus WHERE task_id = ? LIMIT 1").get(id) as { scope: string } | null;
364
- if (focusedScope) throw new Error(`artifact "${id}" is the active Task Focus in scope "${focusedScope.scope}"; clear focus before removing it`);
416
+ if (focusedScope)
417
+ throw new Error(`artifact "${id}" is the active Task Focus in scope "${focusedScope.scope}"; clear focus before removing it`);
365
418
  const now = options?.now ?? (() => new Date().toISOString());
366
419
  const trashedAt = now();
367
420
  const purgeAfter = new Date(new Date(trashedAt).getTime() + ARTIFACT_TRASH_RETENTION_MS).toISOString();
@@ -407,7 +460,9 @@ export function restoreArtifact(db: Db, id: string, context?: ArtifactEventConte
407
460
  */
408
461
  export function purgeDueArtifacts(db: Db, now: () => string = () => new Date().toISOString()): number {
409
462
  const nowIso = now();
410
- const due = (db.prepare("SELECT artifact_id FROM artifact_trash WHERE purge_after <= ?").all(nowIso) as Array<{ artifact_id: string }>).map((row) => row.artifact_id);
463
+ const due = (
464
+ db.prepare("SELECT artifact_id FROM artifact_trash WHERE purge_after <= ?").all(nowIso) as Array<{ artifact_id: string }>
465
+ ).map((row) => row.artifact_id);
411
466
  let purged = 0;
412
467
  for (const id of due) {
413
468
  inTransaction(db, () => {
@@ -500,7 +555,10 @@ export function updateExtra(db: Db, id: string, extra: Record<string, unknown>,
500
555
 
501
556
  /** Active rules with inject metadata — for before_agent_start system prompt injection. */
502
557
  export function injectableRules(db: Db): Array<{ id: string; title: string; body: string; extra: Record<string, unknown> }> {
503
- const rows = db.prepare("SELECT * FROM artifacts WHERE kind = 'rule' AND status = 'active' ORDER BY updated_at DESC").all() as Record<string, unknown>[];
558
+ const rows = db.prepare("SELECT * FROM artifacts WHERE kind = 'rule' AND status = 'active' ORDER BY updated_at DESC").all() as Record<
559
+ string,
560
+ unknown
561
+ >[];
504
562
  return rows.map((row) => {
505
563
  const art = rowToArtifact(row);
506
564
  return { id: art.id, title: art.title, body: art.body, extra: art.extra };
@@ -536,15 +594,19 @@ function runProcessGateSync(gate: Gate, cwd?: string): GateResult {
536
594
  if (result.error) return { gate, passed: false, output: result.error.message.slice(0, GATE_OUTPUT_LIMIT) };
537
595
  const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
538
596
  const passed = result.status === 0 && (gate.expect ? combined.includes(gate.expect) : true);
539
- return { gate, passed, output: combined.slice(0, GATE_OUTPUT_LIMIT) || (result.status === 0 ? "ok" : `command exited with code ${result.status}`) };
597
+ return {
598
+ gate,
599
+ passed,
600
+ output: combined.slice(0, GATE_OUTPUT_LIMIT) || (result.status === 0 ? "ok" : `command exited with code ${result.status}`),
601
+ };
540
602
  }
541
603
 
542
604
  export function runGates(db: Db, artifactId: string, options: GateRunOptions = {}): GateResult[] {
543
605
  const art = getArtifact(db, artifactId);
544
606
  if (!art) throw new Error("artifact not found");
545
- const gates = (art.extra["gates"] as Gate[]) ?? [];
607
+ const gates = (art.extra.gates as Gate[]) ?? [];
546
608
  const cwd = options.cwd;
547
- return gates.map((gate) => (gate.type === "command" || gate.type === "test") ? runProcessGateSync(gate, cwd) : runNonProcessGate(gate));
609
+ return gates.map((gate) => (gate.type === "command" || gate.type === "test" ? runProcessGateSync(gate, cwd) : runNonProcessGate(gate)));
548
610
  }
549
611
 
550
612
  /**
@@ -558,7 +620,11 @@ export function runGates(db: Db, artifactId: string, options: GateRunOptions = {
558
620
  * indefinitely after Papyrus considers the gate "timed out". Spawning detached (its own
559
621
  * process group) and killing the negated pid on our own timer reaches the whole tree.
560
622
  */
561
- function executeGateCommand(command: string, timeout: number, cwd?: string): Promise<{ passed: boolean; output: string; matchable: string }> {
623
+ function executeGateCommand(
624
+ command: string,
625
+ timeout: number,
626
+ cwd?: string,
627
+ ): Promise<{ passed: boolean; output: string; matchable: string }> {
562
628
  // `spawn(..., { shell: true, detached: true })` instead of the `exec()` convenience wrapper:
563
629
  // `detached` (needed to make the shell the leader of its own process group, so the negated pid
564
630
  // below reaches every descendant, not just the shell) is not part of Node's `exec()`/
@@ -634,7 +700,7 @@ function runNonProcessGate(gate: Gate): GateResult {
634
700
  export async function runGatesAsync(db: Db, artifactId: string, options: GateRunOptions = {}): Promise<GateResult[]> {
635
701
  const art = getArtifact(db, artifactId);
636
702
  if (!art) throw new Error("artifact not found");
637
- const gates = (art.extra["gates"] as Gate[]) ?? [];
703
+ const gates = (art.extra.gates as Gate[]) ?? [];
638
704
  const results: GateResult[] = [];
639
705
  for (const gate of gates) {
640
706
  const remainingMs = options.deadlineMs === undefined ? undefined : options.deadlineMs - Date.now();
@@ -657,4 +723,3 @@ export async function runGatesAsync(db: Db, artifactId: string, options: GateRun
657
723
  }
658
724
  return results;
659
725
  }
660
-
@@ -1,19 +1,22 @@
1
1
  /**
2
2
  * playbook-definition.ts — compiles a Playbook's own steps/trigger/tools/arguments, plus its
3
- * `contains` (nested) and `depends_on` (prerequisite) composition tree, into an in-memory
4
- * SkillDefinition. This is the "recycle Papyrus Skills -> Playbooks" half of the redesign:
5
- * rather than a second graph-materialization engine, a Playbook becomes prose that compiles
6
- * down to the exact blueprint shape workflow Skills already use, then hands off to
7
- * workflow-execution.ts's shared materializeWorkflowDefinition for the actual Task creation.
3
+ * `contains` (nested) and `depends_on` (prerequisite) whole-artifact composition tree, into
4
+ * an in-memory BlueprintDefinition. Rather than a second graph-materialization engine, a
5
+ * Playbook becomes a definition that compiles down to the exact Blueprint shape a
6
+ * workflow-definition target already uses, then hands off to workflow-execution.ts's shared
7
+ * materializeWorkflowDefinition for the actual artifact creation.
8
8
  *
9
9
  * One task blueprint per playbook-node root (a container, never itself gated) plus one per
10
10
  * step (chained by sequential dependsOn); `contains`-linked playbooks nest their own root
11
11
  * under the parent root and continue the parent's own step chain ("run as part of this one",
12
12
  * after the parent's own steps); `depends_on`-linked playbooks compile as independent
13
- * subtrees whose tails gate this node's first step ("complete this FIRST"). No SkillCallBlueprint
14
- * indirection is used -- everything is inlined into one flat definition, since a Playbook's
15
- * composition tree is fully known and owned at compile time, unlike a workflow Skill's nested
16
- * pipeline step (which references another SKILL by id, resolved and executed independently).
13
+ * subtrees whose tails gate this node's first step ("complete this FIRST"). That whole-artifact
14
+ * composition tree is fully known and owned at compile time and is always inlined directly
15
+ * (no CallBlueprint indirection). A step-level `call` (one step within a single playbook
16
+ * node, not the composition tree above) is the one place this compiler DOES emit a
17
+ * CallBlueprint -- a finer-grained, in-blueprint nested-run reference resolved and executed
18
+ * independently by workflow-execution.ts, exactly like a workflow-definition target's own
19
+ * nested pipeline step, since its target's own definition is not knowable at this compile time.
17
20
  */
18
21
  import {
19
22
  PLAYBOOK_INVOCATION_MAX_CALL_DEPTH,
@@ -21,9 +24,16 @@ import {
21
24
  PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
22
25
  } from "./constants.ts";
23
26
  import type { Artifact } from "./domain/artifact.ts";
24
- import type { SkillDefinition, SkillInputDefinition, SkillTaskBlueprint } from "./domain/skill-definition.ts";
25
- import { validateSkillDefinition } from "./domain/skill-definition.ts";
26
- import type { PlaybookArgument } from "./domain-services.ts";
27
+ import type {
28
+ BlueprintDefinition,
29
+ BlueprintInputDefinition,
30
+ CallBlueprint,
31
+ DocBlueprint,
32
+ RuleBlueprint,
33
+ TaskBlueprint,
34
+ } from "./domain/blueprint-definition.ts";
35
+ import { validateBlueprintDefinition } from "./domain/blueprint-definition.ts";
36
+ import type { PlaybookArgument, PlaybookStep } from "./domain-services.ts";
27
37
  import type { ArtifactStore } from "./ports/artifact-store.ts";
28
38
 
29
39
  /** A non-composing edge touching a playbook node, to be mirrored onto that node's generated root task once real task ids exist -- e.g. a Rule `gates` this playbook, or this playbook `references`/`documents` a Doc. Direction is preserved exactly: `from`/`to` name whichever side is NOT the playbook, and `ownerIsFrom` says which side the playbook (now the generated root task) occupies. */
@@ -36,7 +46,7 @@ export interface PlaybookExternalLink {
36
46
  }
37
47
 
38
48
  export interface CompiledPlaybook {
39
- definition: SkillDefinition;
49
+ definition: BlueprintDefinition;
40
50
  /** The very first real leaf task in the whole tree's reading order -- what a caller should focus once materialized. */
41
51
  entryRef: string;
42
52
  externalLinks: PlaybookExternalLink[];
@@ -56,21 +66,21 @@ function requirePlaybook(artifacts: ArtifactStore, id: string): Artifact {
56
66
  return playbook;
57
67
  }
58
68
 
59
- function stepsOf(playbook: Artifact): string[] {
60
- return Array.isArray(playbook.extra["steps"]) ? playbook.extra["steps"].filter((step): step is string => typeof step === "string") : [];
69
+ function stepsOf(playbook: Artifact): PlaybookStep[] {
70
+ return Array.isArray(playbook.extra.steps) ? (playbook.extra.steps as PlaybookStep[]) : [];
61
71
  }
62
72
 
63
73
  function toolsOf(playbook: Artifact): string[] {
64
- return Array.isArray(playbook.extra["tools"]) ? playbook.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
74
+ return Array.isArray(playbook.extra.tools) ? playbook.extra.tools.filter((tool): tool is string => typeof tool === "string") : [];
65
75
  }
66
76
 
67
77
  function argumentsOf(playbook: Artifact): PlaybookArgument[] {
68
- return Array.isArray(playbook.extra["arguments"]) ? (playbook.extra["arguments"] as PlaybookArgument[]) : [];
78
+ return Array.isArray(playbook.extra.arguments) ? (playbook.extra.arguments as PlaybookArgument[]) : [];
69
79
  }
70
80
 
71
81
  /** The generated container task's own body -- purpose and context only. Steps are separate child tasks, so they are not re-listed here (that was the old text-dump shape). */
72
82
  function rootTaskBody(playbook: Artifact): string {
73
- const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
83
+ const trigger = typeof playbook.extra.trigger === "string" ? playbook.extra.trigger : "manual invocation";
74
84
  const tools = toolsOf(playbook);
75
85
  return [
76
86
  `Playbook "${playbook.title}".`,
@@ -87,8 +97,11 @@ function stepTitle(step: string): string {
87
97
  }
88
98
 
89
99
  interface CompileContext {
90
- tasks: SkillTaskBlueprint[];
91
- inputs: Record<string, SkillInputDefinition>;
100
+ docs: DocBlueprint[];
101
+ rules: RuleBlueprint[];
102
+ tasks: TaskBlueprint[];
103
+ skills: CallBlueprint[];
104
+ inputs: Record<string, BlueprintInputDefinition>;
92
105
  externalLinks: PlaybookExternalLink[];
93
106
  refCounter: { n: number };
94
107
  }
@@ -101,9 +114,20 @@ interface CompileNodeResult {
101
114
  tailRef: string;
102
115
  }
103
116
 
104
- function mergeArgument(inputs: Record<string, SkillInputDefinition>, argument: PlaybookArgument): void {
117
+ /** A composition tree can declare the same argument name from more than one node (e.g. two prerequisite playbooks both need a `target`); required OR-accumulates the same way it always did, but the type must agree everywhere -- silently picking one node's type over another's would compile a definition whose placeholder substitution disagrees with what one of the two authors actually declared. */
118
+ function mergeArgument(inputs: Record<string, BlueprintInputDefinition>, argument: PlaybookArgument): void {
105
119
  const existing = inputs[argument.name];
106
- inputs[argument.name] = { type: "string", required: (existing?.required ?? false) || argument.required };
120
+ if (existing && existing.type !== argument.type) {
121
+ throw new Error(
122
+ `playbook composition declares conflicting types for argument "${argument.name}" (${existing.type} vs ${argument.type})`,
123
+ );
124
+ }
125
+ inputs[argument.name] = {
126
+ type: argument.type,
127
+ required: (existing?.required ?? false) || argument.required,
128
+ ...(argument.enum ? { enum: argument.enum } : {}),
129
+ ...(argument.default !== undefined ? { default: argument.default } : {}),
130
+ };
107
131
  }
108
132
 
109
133
  function compileNode(
@@ -116,28 +140,44 @@ function compileNode(
116
140
  incomingPrecedingRefs: string[],
117
141
  ): CompileNodeResult {
118
142
  if (ancestorIds.has(playbookId)) throw new Error(`playbook composition cycle includes "${playbookId}"`);
119
- if (depth > PLAYBOOK_INVOCATION_MAX_CALL_DEPTH) throw new Error(`playbook composition exceeds ${PLAYBOOK_INVOCATION_MAX_CALL_DEPTH} levels`);
143
+ if (depth > PLAYBOOK_INVOCATION_MAX_CALL_DEPTH)
144
+ throw new Error(`playbook composition exceeds ${PLAYBOOK_INVOCATION_MAX_CALL_DEPTH} levels`);
120
145
  const nextAncestors = new Set([...ancestorIds, playbookId]);
121
146
 
122
147
  const playbook = requirePlaybook(artifacts, playbookId);
123
148
  for (const argument of argumentsOf(playbook)) mergeArgument(ctx.inputs, argument);
124
149
 
125
150
  const rootRef = `pb${ctx.refCounter.n++}`;
126
- const rootBlueprint: SkillTaskBlueprint = { ref: rootRef, title: playbook.title, body: rootTaskBody(playbook), ...(parentRef ? { parent: parentRef } : {}) };
151
+ const rootBlueprint: TaskBlueprint = {
152
+ ref: rootRef,
153
+ title: playbook.title,
154
+ body: rootTaskBody(playbook),
155
+ ...(parentRef ? { parent: parentRef } : {}),
156
+ };
127
157
  ctx.tasks.push(rootBlueprint);
128
- if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS) throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
158
+ if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS)
159
+ throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
129
160
 
130
161
  const edges = artifacts.relationships({ artifactIds: [playbookId] }).slice(0, PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS);
131
- const composablePlaybookIds = nonTrashedPlaybookIds(artifacts, edges.filter((edge) => edge.from === playbookId).map((edge) => edge.to));
132
- const prerequisiteIds = edges.filter((edge) => edge.from === playbookId && edge.relation === "depends_on").map((edge) => edge.to)
162
+ const composablePlaybookIds = nonTrashedPlaybookIds(
163
+ artifacts,
164
+ edges.filter((edge) => edge.from === playbookId).map((edge) => edge.to),
165
+ );
166
+ const prerequisiteIds = edges
167
+ .filter((edge) => edge.from === playbookId && edge.relation === "depends_on")
168
+ .map((edge) => edge.to)
133
169
  .filter((id) => composablePlaybookIds.has(id));
134
- const nestedIds = edges.filter((edge) => edge.from === playbookId && edge.relation === "contains").map((edge) => edge.to)
170
+ const nestedIds = edges
171
+ .filter((edge) => edge.from === playbookId && edge.relation === "contains")
172
+ .map((edge) => edge.to)
135
173
  .filter((id) => composablePlaybookIds.has(id));
136
174
  for (const edge of edges) {
137
- const isComposingFrom = edge.from === playbookId && (edge.relation === "contains" || edge.relation === "depends_on") && composablePlaybookIds.has(edge.to);
175
+ const isComposingFrom =
176
+ edge.from === playbookId && (edge.relation === "contains" || edge.relation === "depends_on") && composablePlaybookIds.has(edge.to);
138
177
  if (isComposingFrom) continue;
139
178
  if (edge.from === playbookId) ctx.externalLinks.push({ rootRef, relation: edge.relation, otherArtifactId: edge.to, ownerIsFrom: true });
140
- else if (edge.to === playbookId) ctx.externalLinks.push({ rootRef, relation: edge.relation, otherArtifactId: edge.from, ownerIsFrom: false });
179
+ else if (edge.to === playbookId)
180
+ ctx.externalLinks.push({ rootRef, relation: edge.relation, otherArtifactId: edge.from, ownerIsFrom: false });
141
181
  }
142
182
 
143
183
  const prerequisiteTailRefs: string[] = [];
@@ -148,15 +188,60 @@ function compileNode(
148
188
  if (headRef === undefined) headRef = result.headRef;
149
189
  }
150
190
 
191
+ // Doc/rule steps are not gated tasks (DocBlueprint/RuleBlueprint have no dependsOn/parent of
192
+ // their own -- workflow-execution.ts always creates them unconditionally alongside the run)
193
+ // -- they do not touch cursorPrecedingRefs/headRef/tailRef at all. A task or call step DOES
194
+ // occupy a position in the sequential chain, exactly as a plain-string step always did.
151
195
  let cursorPrecedingRefs = [...incomingPrecedingRefs, ...prerequisiteTailRefs];
152
196
  let tailRef = rootRef;
153
197
  for (const [index, step] of stepsOf(playbook).entries()) {
154
- const stepRef = `${rootRef}-s${index}`;
155
- ctx.tasks.push({ ref: stepRef, title: stepTitle(step), body: step, parent: rootRef, dependsOn: cursorPrecedingRefs });
156
- if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS) throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
157
- if (headRef === undefined) headRef = stepRef;
158
- cursorPrecedingRefs = [stepRef];
159
- tailRef = stepRef;
198
+ if (typeof step === "string" || step.kind === "task") {
199
+ const body = typeof step === "string" ? step : step.body;
200
+ const title = typeof step === "string" ? stepTitle(step) : (step.title ?? stepTitle(body));
201
+ const stepRef = `${rootRef}-s${index}`;
202
+ ctx.tasks.push({ ref: stepRef, title, body, parent: rootRef, dependsOn: cursorPrecedingRefs });
203
+ if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS)
204
+ throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
205
+ if (headRef === undefined) headRef = stepRef;
206
+ cursorPrecedingRefs = [stepRef];
207
+ tailRef = stepRef;
208
+ } else if (step.kind === "doc") {
209
+ const stepRef = `${rootRef}-d${index}`;
210
+ ctx.docs.push({
211
+ ref: stepRef,
212
+ title: step.title,
213
+ ...(step.body ? { body: step.body } : {}),
214
+ ...(step.subtype ? { subtype: step.subtype } : {}),
215
+ ...(step.labels ? { labels: step.labels } : {}),
216
+ });
217
+ } else if (step.kind === "rule") {
218
+ const stepRef = `${rootRef}-r${index}`;
219
+ ctx.rules.push({
220
+ ref: stepRef,
221
+ title: step.title,
222
+ ...(step.body ? { body: step.body } : {}),
223
+ ...(step.condition ? { condition: step.condition } : {}),
224
+ ...(step.action ? { action: step.action } : {}),
225
+ ...(step.severity ? { severity: step.severity } : {}),
226
+ ...(step.labels ? { labels: step.labels } : {}),
227
+ });
228
+ } else {
229
+ // kind === "call": nests another Playbook's (or a workflow-definition target's) run as
230
+ // a pipeline step -- shares the same dependsOn chain as a task step, resolved
231
+ // polymorphically at execution time by workflow-execution.ts based on the target's kind.
232
+ const stepRef = `${rootRef}-c${index}`;
233
+ ctx.skills.push({
234
+ ref: stepRef,
235
+ title: step.title,
236
+ targetId: step.playbookId,
237
+ ...(step.arguments ? { arguments: step.arguments } : {}),
238
+ parent: rootRef,
239
+ dependsOn: cursorPrecedingRefs,
240
+ });
241
+ if (headRef === undefined) headRef = stepRef;
242
+ cursorPrecedingRefs = [stepRef];
243
+ tailRef = stepRef;
244
+ }
160
245
  }
161
246
 
162
247
  for (const nestedId of nestedIds) {
@@ -172,12 +257,12 @@ function compileNode(
172
257
 
173
258
  /** Pure and read-only: creates no artifacts. Cycle/depth-bounded exactly like playbookInvocation's own traversal, but a composition cycle here is a hard error (real Tasks would be created, unlike a text render degrading to a marker). */
174
259
  export function compilePlaybookDefinition(artifacts: ArtifactStore, playbookId: string): CompiledPlaybook {
175
- const ctx: CompileContext = { tasks: [], inputs: {}, externalLinks: [], refCounter: { n: 0 } };
260
+ const ctx: CompileContext = { docs: [], rules: [], tasks: [], skills: [], inputs: {}, externalLinks: [], refCounter: { n: 0 } };
176
261
  const { headRef } = compileNode(artifacts, playbookId, ctx, new Set(), 0, undefined, []);
177
- const definition = validateSkillDefinition({
262
+ const definition = validateBlueprintDefinition({
178
263
  version: 1,
179
264
  inputs: ctx.inputs,
180
- blueprints: { docs: [], rules: [], tasks: ctx.tasks, skills: [] },
265
+ blueprints: { docs: ctx.docs, rules: ctx.rules, tasks: ctx.tasks, skills: ctx.skills },
181
266
  links: [],
182
267
  });
183
268
  return { definition, entryRef: headRef, externalLinks: ctx.externalLinks };