@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
@@ -4,8 +4,13 @@
4
4
  * design rationale.
5
5
  */
6
6
  import { DISCUSSION_LIST_DEFAULT_LIMIT, DISCUSSION_LIST_MAX_LIMIT, DISCUSSION_MAX_ROUNDS } from "./constants.ts";
7
+ import type { Artifact } from "./domain/artifact.ts";
8
+ import type { ArtifactEventContext } from "./domain/artifact-event.ts";
7
9
  import {
8
10
  DISCUSSION_SUBTYPE,
11
+ type DiscussionExtra,
12
+ type DiscussionOptionsMode,
13
+ type DiscussionRound,
9
14
  isDiscussionArtifact,
10
15
  readDiscussionExtra,
11
16
  validateDeferReason,
@@ -14,12 +19,7 @@ import {
14
19
  validateDiscussionOptions,
15
20
  validateSelectedOptions,
16
21
  validateSettlement,
17
- type DiscussionExtra,
18
- type DiscussionOptionsMode,
19
- type DiscussionRound,
20
22
  } from "./domain/discussion.ts";
21
- import type { Artifact } from "./domain/artifact.ts";
22
- import type { ArtifactEventContext } from "./domain/artifact-event.ts";
23
23
  import type { AtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
24
24
  import type { DiscussionRoundStore } from "./ports/discussion-round-store.ts";
25
25
 
@@ -72,7 +72,11 @@ export class Discussions {
72
72
  }
73
73
 
74
74
  /** Validates a freshly-posed choice; undefined when neither field is given (nothing posed), since both/neither is the only valid shape. */
75
- private validatePosedOptions(options: string[] | undefined, optionsMode: DiscussionOptionsMode | undefined, optionDescriptions: string[] | undefined): { options: string[]; mode: DiscussionOptionsMode; optionDescriptions?: string[] } | undefined {
75
+ private validatePosedOptions(
76
+ options: string[] | undefined,
77
+ optionsMode: DiscussionOptionsMode | undefined,
78
+ optionDescriptions: string[] | undefined,
79
+ ): { options: string[]; mode: DiscussionOptionsMode; optionDescriptions?: string[] } | undefined {
76
80
  if (options === undefined && optionsMode === undefined) return undefined;
77
81
  return validateDiscussionOptions(options ?? [], optionsMode ?? "", optionDescriptions);
78
82
  }
@@ -82,25 +86,46 @@ export class Discussions {
82
86
  const content = validateDiscussionContent(input.content);
83
87
  const posed = this.validatePosedOptions(input.options, input.optionsMode, input.optionDescriptions);
84
88
  return this.artifacts.atomic(() => {
85
- const discussion = this.artifacts.create({
86
- kind: "task",
87
- subtype: DISCUSSION_SUBTYPE,
88
- title: input.title,
89
- body: input.body ?? "",
90
- status: "in-progress",
91
- labels: input.labels,
92
- extra: {
93
- discussion: {
94
- state: "active",
95
- roundCount: 1,
96
- ...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode, ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}) } : {}),
89
+ const discussion = this.artifacts.create(
90
+ {
91
+ kind: "task",
92
+ subtype: DISCUSSION_SUBTYPE,
93
+ title: input.title,
94
+ body: input.body ?? "",
95
+ status: "in-progress",
96
+ labels: input.labels,
97
+ extra: {
98
+ discussion: {
99
+ state: "active",
100
+ roundCount: 1,
101
+ ...(posed
102
+ ? {
103
+ pendingOptions: posed.options,
104
+ pendingOptionsMode: posed.mode,
105
+ ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}),
106
+ }
107
+ : {}),
108
+ },
97
109
  },
98
110
  },
99
- }, context);
100
- const round = this.rounds.append({
101
- discussionId: discussion.id, roundNumber: 1, actor, content,
102
- ...(posed ? { options: posed.options, optionsMode: posed.mode, ...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}) } : {}),
103
- }, new Date().toISOString());
111
+ context,
112
+ );
113
+ const round = this.rounds.append(
114
+ {
115
+ discussionId: discussion.id,
116
+ roundNumber: 1,
117
+ actor,
118
+ content,
119
+ ...(posed
120
+ ? {
121
+ options: posed.options,
122
+ optionsMode: posed.mode,
123
+ ...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
124
+ }
125
+ : {}),
126
+ },
127
+ new Date().toISOString(),
128
+ );
104
129
  for (const taskId of input.blocksTaskIds ?? []) this.block(discussion.id, taskId, context);
105
130
  return { discussion: this.artifacts.get(discussion.id)!, rounds: [round] };
106
131
  });
@@ -114,24 +139,49 @@ export class Discussions {
114
139
  const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
115
140
  const state = this.extra(discussion);
116
141
  if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; resume it before replying`);
117
- if (state.roundCount >= DISCUSSION_MAX_ROUNDS) throw new DiscussionError(`discussion "${discussionId}" has reached its ${DISCUSSION_MAX_ROUNDS}-round limit; settle or defer it`);
118
- const selected = input.selected !== undefined ? validateSelectedOptions(input.selected, state.pendingOptions, state.pendingOptionsMode) : undefined;
142
+ if (state.roundCount >= DISCUSSION_MAX_ROUNDS)
143
+ throw new DiscussionError(`discussion "${discussionId}" has reached its ${DISCUSSION_MAX_ROUNDS}-round limit; settle or defer it`);
144
+ const selected =
145
+ input.selected !== undefined ? validateSelectedOptions(input.selected, state.pendingOptions, state.pendingOptionsMode) : undefined;
119
146
  const nextRound = state.roundCount + 1;
120
- const round = this.rounds.append({
121
- discussionId, roundNumber: nextRound, actor: validActor, content: validContent,
122
- ...(posed ? { options: posed.options, optionsMode: posed.mode, ...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}) } : {}),
123
- ...(selected ? { selected } : {}),
124
- }, new Date().toISOString());
147
+ const round = this.rounds.append(
148
+ {
149
+ discussionId,
150
+ roundNumber: nextRound,
151
+ actor: validActor,
152
+ content: validContent,
153
+ ...(posed
154
+ ? {
155
+ options: posed.options,
156
+ optionsMode: posed.mode,
157
+ ...(posed.optionDescriptions ? { optionDescriptions: posed.optionDescriptions } : {}),
158
+ }
159
+ : {}),
160
+ ...(selected ? { selected } : {}),
161
+ },
162
+ new Date().toISOString(),
163
+ );
125
164
  // Whenever this round answers the pending choice OR poses a new one, the base must drop ALL
126
165
  // three pending* fields first -- otherwise a re-pose that omits descriptions this time would
127
166
  // leave a stale pendingOptionDescriptions array (sized for the OLD options) spread through
128
167
  // unchanged, no longer aligned 1:1 with the new pendingOptions. Only a plain reply that
129
168
  // neither answers nor re-poses leaves the existing pending state untouched.
130
- const { pendingOptions: _clearedOptions, pendingOptionsMode: _clearedMode, pendingOptionDescriptions: _clearedDescriptions, ...withoutPending } = state;
169
+ const {
170
+ pendingOptions: _clearedOptions,
171
+ pendingOptionsMode: _clearedMode,
172
+ pendingOptionDescriptions: _clearedDescriptions,
173
+ ...withoutPending
174
+ } = state;
131
175
  const nextState = {
132
176
  ...(selected || posed ? withoutPending : state),
133
177
  roundCount: nextRound,
134
- ...(posed ? { pendingOptions: posed.options, pendingOptionsMode: posed.mode, ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}) } : {}),
178
+ ...(posed
179
+ ? {
180
+ pendingOptions: posed.options,
181
+ pendingOptionsMode: posed.mode,
182
+ ...(posed.optionDescriptions ? { pendingOptionDescriptions: posed.optionDescriptions } : {}),
183
+ }
184
+ : {}),
135
185
  };
136
186
  const updated = this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: nextState }, context)!;
137
187
  return { discussion: updated, rounds: [round] };
@@ -143,11 +193,16 @@ export class Discussions {
143
193
  return this.artifacts.atomic(() => {
144
194
  const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
145
195
  const state = this.extra(discussion);
146
- if (state.state !== "active") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only an active Discussion can be deferred`);
147
- return this.artifacts.setExtra(discussionId, {
148
- ...discussion.extra,
149
- discussion: { ...state, state: "deferred", ...(validReason === undefined ? {} : { deferredReason: validReason }) },
150
- }, context)!;
196
+ if (state.state !== "active")
197
+ throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only an active Discussion can be deferred`);
198
+ return this.artifacts.setExtra(
199
+ discussionId,
200
+ {
201
+ ...discussion.extra,
202
+ discussion: { ...state, state: "deferred", ...(validReason === undefined ? {} : { deferredReason: validReason }) },
203
+ },
204
+ context,
205
+ )!;
151
206
  });
152
207
  }
153
208
 
@@ -155,7 +210,8 @@ export class Discussions {
155
210
  return this.artifacts.atomic(() => {
156
211
  const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
157
212
  const state = this.extra(discussion);
158
- if (state.state !== "deferred") throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only a deferred Discussion can be resumed`);
213
+ if (state.state !== "deferred")
214
+ throw new DiscussionError(`discussion "${discussionId}" is ${state.state}; only a deferred Discussion can be resumed`);
159
215
  const { deferredReason: _deferredReason, ...rest } = state;
160
216
  return this.artifacts.setExtra(discussionId, { ...discussion.extra, discussion: { ...rest, state: "active" } }, context)!;
161
217
  });
@@ -167,10 +223,14 @@ export class Discussions {
167
223
  const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
168
224
  const state = this.extra(discussion);
169
225
  if (state.state === "settled") throw new DiscussionError(`discussion "${discussionId}" is already settled`);
170
- const updated = this.artifacts.setExtra(discussionId, {
171
- ...discussion.extra,
172
- discussion: { ...state, state: "settled", settlement: validSettlement, settledAt: new Date().toISOString() },
173
- }, context)!;
226
+ const updated = this.artifacts.setExtra(
227
+ discussionId,
228
+ {
229
+ ...discussion.extra,
230
+ discussion: { ...state, state: "settled", settlement: validSettlement, settledAt: new Date().toISOString() },
231
+ },
232
+ context,
233
+ )!;
174
234
  return this.artifacts.setStatus(discussionId, "done", context) ?? updated;
175
235
  });
176
236
  }
@@ -178,7 +238,8 @@ export class Discussions {
178
238
  /** Links an existing active Discussion to a Task it blocks; refuses a non-task target or an already-settled Discussion. */
179
239
  block(discussionId: string, taskId: string, context?: ArtifactEventContext): void {
180
240
  const discussion = requireDiscussion(this.artifacts.get(discussionId), discussionId);
181
- if (this.extra(discussion).state === "settled") throw new DiscussionError(`discussion "${discussionId}" is settled; it can no longer block anything`);
241
+ if (this.extra(discussion).state === "settled")
242
+ throw new DiscussionError(`discussion "${discussionId}" is settled; it can no longer block anything`);
182
243
  const task = this.artifacts.get(taskId);
183
244
  if (!task) throw new DiscussionError(`task "${taskId}" not found`);
184
245
  if (task.kind !== "task" || isDiscussionArtifact(task)) throw new DiscussionError(`artifact "${taskId}" is not a task`);
@@ -211,7 +272,11 @@ export class Discussions {
211
272
  const rows = this.artifacts.query({ kind: "task", subtype: DISCUSSION_SUBTYPE, limit });
212
273
  if (!filter.state) return rows;
213
274
  return rows.filter((row) => {
214
- try { return this.extra(row).state === filter.state; } catch { return false; }
275
+ try {
276
+ return this.extra(row).state === filter.state;
277
+ } catch {
278
+ return false;
279
+ }
215
280
  });
216
281
  }
217
282
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Generic, kind-agnostic mutation event log — the "who did what, when" answer
3
- * shared by every artifact kind (doc, task, rule, skill), not reinvented per domain.
3
+ * shared by every artifact kind (doc, task, rule, playbook), not reinvented per domain.
4
4
  *
5
5
  * Modeled after scribe's parchment.Event/EventFilter/GetEvents shape, but avoids its
6
6
  * known gap: there, the Actor column is defined and filterable yet never populated by
@@ -10,8 +10,17 @@
10
10
  */
11
11
  import { ARTIFACT_EVENT_ACTOR_MAX_LENGTH, ARTIFACT_EVENT_HISTORY_DEFAULT_LIMIT, ARTIFACT_EVENT_HISTORY_MAX_LIMIT } from "../constants.ts";
12
12
 
13
- export const ARTIFACT_EVENT_TYPES = ["created", "updated", "status_changed", "extra_set", "linked", "unlinked", "trashed", "restored"] as const;
14
- export type ArtifactEventType = typeof ARTIFACT_EVENT_TYPES[number];
13
+ export const ARTIFACT_EVENT_TYPES = [
14
+ "created",
15
+ "updated",
16
+ "status_changed",
17
+ "extra_set",
18
+ "linked",
19
+ "unlinked",
20
+ "trashed",
21
+ "restored",
22
+ ] as const;
23
+ export type ArtifactEventType = (typeof ARTIFACT_EVENT_TYPES)[number];
15
24
  export type ArtifactEventDirection = "asc" | "desc";
16
25
 
17
26
  /** Caller-supplied identity for a mutation. All fields are advisory (self-reported), not cryptographically verified. */
@@ -73,7 +82,9 @@ function boundedString(value: string, field: string, maximum: number): string {
73
82
  }
74
83
 
75
84
  /** Fills defaults and enforces bounds. The one place every appended event is normalized. */
76
- export function resolveArtifactEvent(input: AppendArtifactEvent): Required<Pick<AppendArtifactEvent, "actor" | "source">> & AppendArtifactEvent {
85
+ export function resolveArtifactEvent(
86
+ input: AppendArtifactEvent,
87
+ ): Required<Pick<AppendArtifactEvent, "actor" | "source">> & AppendArtifactEvent {
77
88
  if (!input.artifactId) throw new Error("artifactId is required");
78
89
  const actor = boundedString(input.actor ?? ARTIFACT_EVENT_DEFAULT_ACTOR, "actor", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
79
90
  const source = boundedString(input.source ?? ARTIFACT_EVENT_DEFAULT_SOURCE, "source", ARTIFACT_EVENT_ACTOR_MAX_LENGTH);
@@ -81,7 +92,9 @@ export function resolveArtifactEvent(input: AppendArtifactEvent): Required<Pick<
81
92
  return { ...input, actor, source };
82
93
  }
83
94
 
84
- export function normalizeArtifactEventQuery(query: ArtifactEventQuery): Required<Pick<ArtifactEventQuery, "limit" | "direction">> & ArtifactEventQuery {
95
+ export function normalizeArtifactEventQuery(
96
+ query: ArtifactEventQuery,
97
+ ): Required<Pick<ArtifactEventQuery, "limit" | "direction">> & ArtifactEventQuery {
85
98
  if (!query.artifactId && !query.actor && !query.sessionId) {
86
99
  throw new Error("artifact event query requires artifactId, actor, or sessionId to stay bounded");
87
100
  }
@@ -94,7 +94,9 @@ export function externalSourceOf(artifact: Pick<Artifact, "labels">): string | u
94
94
  export function requireLocallyOwnedContent(artifact: Artifact): Artifact {
95
95
  const system = externalSourceOf(artifact);
96
96
  if (system !== undefined) {
97
- throw new Error(`"${artifact.title}" is a read-only projection from ${system}; edit it there, or capture a correction as a new linked Doc, until a write-back capability is integrated`);
97
+ throw new Error(
98
+ `"${artifact.title}" is a read-only projection from ${system}; edit it there, or capture a correction as a new linked Doc, until a write-back capability is integrated`,
99
+ );
98
100
  }
99
101
  return artifact;
100
102
  }
@@ -0,0 +1,268 @@
1
+ import { SEED_RELATIONS, SKILL_MAX_BLUEPRINTS, SKILL_MAX_ENUM_VALUES, SKILL_MAX_INPUTS, SKILL_MAX_LINKS } from "../constants.ts";
2
+
3
+ export type BlueprintArgumentValue = string | number | boolean;
4
+ export type BlueprintInputType = "string" | "number" | "boolean";
5
+
6
+ export interface BlueprintInputDefinition {
7
+ type: BlueprintInputType;
8
+ required?: boolean;
9
+ default?: BlueprintArgumentValue;
10
+ enum?: BlueprintArgumentValue[];
11
+ }
12
+
13
+ export interface DocBlueprint {
14
+ ref: string;
15
+ title: string;
16
+ body?: string;
17
+ subtype?: string;
18
+ labels?: string[];
19
+ extra?: Record<string, unknown>;
20
+ }
21
+
22
+ export interface RuleBlueprint {
23
+ ref: string;
24
+ title: string;
25
+ body?: string;
26
+ condition?: string;
27
+ action?: string;
28
+ severity?: "block" | "warn" | "info";
29
+ labels?: string[];
30
+ extra?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface TaskBlueprint {
34
+ ref: string;
35
+ title: string;
36
+ body?: string;
37
+ dependsOn?: string[];
38
+ parent?: string;
39
+ labels?: string[];
40
+ extra?: Record<string, unknown>;
41
+ }
42
+
43
+ /**
44
+ * A pipeline step that nests another run inside this one -- the Jenkins "trigger downstream
45
+ * job and wait" / Ansible "include_tasks" primitive. The target named by `targetId` can be
46
+ * either a workflow-definition Playbook or an ordinary steps/trigger-shaped Playbook
47
+ * (workflow-execution.ts resolves which, by the target artifact's own subtype); existence and
48
+ * eligibility are both checked at execution time, not here, since this validator has no store
49
+ * access. `dependsOn`/`parent` place this step in the SAME dependency graph as ordinary task
50
+ * blueprints -- a task can depend on a call ref (meaning: depend on every task the nested run
51
+ * creates), and a call's own `parent` contains the nested run's root tasks under an outer task.
52
+ */
53
+ export interface CallBlueprint {
54
+ ref: string;
55
+ title: string;
56
+ targetId: string;
57
+ arguments?: Record<string, unknown>;
58
+ dependsOn?: string[];
59
+ parent?: string;
60
+ }
61
+
62
+ export interface Blueprints {
63
+ docs: DocBlueprint[];
64
+ rules: RuleBlueprint[];
65
+ tasks: TaskBlueprint[];
66
+ skills: CallBlueprint[];
67
+ }
68
+
69
+ export interface BlueprintLink {
70
+ from: string;
71
+ relation: string;
72
+ to: string;
73
+ }
74
+
75
+ export interface BlueprintDefinition {
76
+ version: 1;
77
+ inputs: Record<string, BlueprintInputDefinition>;
78
+ blueprints: Blueprints;
79
+ links: BlueprintLink[];
80
+ }
81
+
82
+ const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
83
+ const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
84
+ /** Exported so any other caller declaring typed inputs against this same shape (e.g. Playbook arguments) validates and rejects exactly the same way, instead of re-deriving its own type-checking logic. */
85
+ export const BLUEPRINT_INPUT_TYPES = new Set<BlueprintInputType>(["string", "number", "boolean"]);
86
+ const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
87
+ const RELATIONS = new Set<string>(SEED_RELATIONS);
88
+
89
+ function record(value: unknown, label: string): Record<string, unknown> {
90
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
91
+ return value as Record<string, unknown>;
92
+ }
93
+
94
+ function array(value: unknown, label: string): unknown[] {
95
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
96
+ return value;
97
+ }
98
+
99
+ function string(value: unknown, label: string): string {
100
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
101
+ return value;
102
+ }
103
+
104
+ /** Exported for reuse by any other typed-argument declaration against this same value shape (e.g. Playbook arguments), rather than re-deriving this exact check elsewhere. */
105
+ export function validateArgumentValue(name: string, type: BlueprintInputType, value: unknown): BlueprintArgumentValue {
106
+ if (typeof value !== type || (type === "number" && !Number.isFinite(value))) {
107
+ throw new Error(`argument "${name}" must be a ${type}`);
108
+ }
109
+ return value as BlueprintArgumentValue;
110
+ }
111
+
112
+ function validateInputs(value: unknown): Record<string, BlueprintInputDefinition> {
113
+ const source = record(value ?? {}, "inputs");
114
+ const entries = Object.entries(source);
115
+ if (entries.length > SKILL_MAX_INPUTS) throw new Error(`inputs exceed ${SKILL_MAX_INPUTS}`);
116
+ const result: Record<string, BlueprintInputDefinition> = {};
117
+ for (const [name, raw] of entries) {
118
+ if (RESERVED_KEYS.has(name)) throw new Error(`reserved input name "${name}"`);
119
+ if (!NAME_PATTERN.test(name)) throw new Error(`invalid input name "${name}"`);
120
+ const input = record(raw, `input "${name}"`);
121
+ if (!BLUEPRINT_INPUT_TYPES.has(input.type as BlueprintInputType)) throw new Error(`input "${name}" has unsupported type`);
122
+ const type = input.type as BlueprintInputType;
123
+ if (input.required !== undefined && typeof input.required !== "boolean") {
124
+ throw new Error(`input "${name}" required must be boolean`);
125
+ }
126
+ const normalized: BlueprintInputDefinition = { type };
127
+ if (input.required !== undefined) normalized.required = input.required as boolean;
128
+ if (input.default !== undefined) normalized.default = validateArgumentValue(name, type, input.default);
129
+ if (input.enum !== undefined) {
130
+ const values = array(input.enum, `input "${name}" enum`);
131
+ if (values.length === 0 || values.length > SKILL_MAX_ENUM_VALUES)
132
+ throw new Error(`input "${name}" enum must contain 1-${SKILL_MAX_ENUM_VALUES} values`);
133
+ normalized.enum = values.map((entry) => validateArgumentValue(name, type, entry));
134
+ if (normalized.default !== undefined && !normalized.enum.includes(normalized.default)) {
135
+ throw new Error(`input "${name}" default must be one of its enum values`);
136
+ }
137
+ }
138
+ result[name] = normalized;
139
+ }
140
+ return result;
141
+ }
142
+
143
+ function validateBlueprint<T extends { ref: string; title: string }>(value: unknown, kind: string): T {
144
+ const source = record(value, `${kind} blueprint`);
145
+ const ref = string(source.ref, `${kind} blueprint ref`);
146
+ if (!NAME_PATTERN.test(ref)) throw new Error(`invalid blueprint ref "${ref}"`);
147
+ const title = string(source.title, `${kind} blueprint title`);
148
+ return { ...source, ref, title } as T;
149
+ }
150
+
151
+ function placeholders(value: unknown, result: Set<string> = new Set()): Set<string> {
152
+ if (typeof value === "string") {
153
+ for (const match of value.matchAll(PLACEHOLDER_PATTERN)) result.add(match[1]!);
154
+ } else if (Array.isArray(value)) {
155
+ for (const entry of value) placeholders(entry, result);
156
+ } else if (typeof value === "object" && value !== null) {
157
+ for (const entry of Object.values(value)) placeholders(entry, result);
158
+ }
159
+ return result;
160
+ }
161
+
162
+ /** Steps sharing one dependency graph: ordinary tasks and call pipeline steps alike. */
163
+ interface DependentStep {
164
+ ref: string;
165
+ dependsOn?: string[];
166
+ }
167
+
168
+ function assertAcyclic(steps: DependentStep[]): void {
169
+ const byRef = new Map(steps.map((step) => [step.ref, step]));
170
+ const visiting = new Set<string>();
171
+ const visited = new Set<string>();
172
+ const visit = (ref: string): void => {
173
+ if (visiting.has(ref)) throw new Error(`step dependency cycle includes "${ref}"`);
174
+ if (visited.has(ref)) return;
175
+ visiting.add(ref);
176
+ for (const dependency of byRef.get(ref)?.dependsOn ?? []) visit(dependency);
177
+ visiting.delete(ref);
178
+ visited.add(ref);
179
+ };
180
+ for (const step of steps) visit(step.ref);
181
+ }
182
+
183
+ function validateCallBlueprint(value: unknown): CallBlueprint {
184
+ const source = record(value, "call blueprint");
185
+ const ref = string(source.ref, "call blueprint ref");
186
+ if (!NAME_PATTERN.test(ref)) throw new Error(`invalid blueprint ref "${ref}"`);
187
+ const title = string(source.title, "call blueprint title");
188
+ const targetId = string(source.targetId ?? source.skillId, "call blueprint targetId");
189
+ return { ...source, ref, title, targetId } as CallBlueprint;
190
+ }
191
+
192
+ export function validateBlueprintDefinition(value: unknown): BlueprintDefinition {
193
+ const source = record(value, "blueprint definition");
194
+ if (source.version !== 1) throw new Error("blueprint definition version must be 1");
195
+ const inputs = validateInputs(source.inputs);
196
+ const rawBlueprints = record(source.blueprints, "blueprints");
197
+ const docs = array(rawBlueprints.docs ?? [], "doc blueprints").map((entry) => validateBlueprint<DocBlueprint>(entry, "doc"));
198
+ const rules = array(rawBlueprints.rules ?? [], "rule blueprints").map((entry) => validateBlueprint<RuleBlueprint>(entry, "rule"));
199
+ const tasks = array(rawBlueprints.tasks ?? [], "task blueprints").map((entry) => validateBlueprint<TaskBlueprint>(entry, "task"));
200
+ const calls = array(rawBlueprints.skills ?? [], "call blueprints").map(validateCallBlueprint);
201
+ const all = [...docs, ...rules, ...tasks, ...calls];
202
+ if (all.length === 0 || all.length > SKILL_MAX_BLUEPRINTS) throw new Error(`blueprints must contain 1-${SKILL_MAX_BLUEPRINTS} artifacts`);
203
+ const refs = new Set<string>();
204
+ for (const blueprint of all) {
205
+ if (refs.has(blueprint.ref)) throw new Error(`duplicate blueprint ref "${blueprint.ref}"`);
206
+ refs.add(blueprint.ref);
207
+ }
208
+ // Tasks and call pipeline steps share one dependency graph: a task may depend on a call ref
209
+ // (meaning: depend on every task that nested run creates), and vice versa.
210
+ const stepRefs = new Set<string>([...tasks.map((task) => task.ref), ...calls.map((call) => call.ref)]);
211
+ for (const task of tasks) {
212
+ if (task.dependsOn !== undefined && !Array.isArray(task.dependsOn)) throw new Error(`task "${task.ref}" dependsOn must be an array`);
213
+ for (const dependency of task.dependsOn ?? []) {
214
+ if (!stepRefs.has(dependency)) throw new Error(`unknown task dependency ref "${dependency}"`);
215
+ }
216
+ // parent stays task-only: containment under a call step's exploded task SET has no
217
+ // single natural parent, so parent must name an actual task blueprint.
218
+ if (task.parent !== undefined && !tasks.some((candidate) => candidate.ref === task.parent)) {
219
+ throw new Error(`unknown task parent ref "${task.parent}"`);
220
+ }
221
+ }
222
+ for (const call of calls) {
223
+ if (call.dependsOn !== undefined && !Array.isArray(call.dependsOn)) throw new Error(`call "${call.ref}" dependsOn must be an array`);
224
+ for (const dependency of call.dependsOn ?? []) {
225
+ if (!stepRefs.has(dependency)) throw new Error(`unknown call dependency ref "${dependency}"`);
226
+ }
227
+ if (call.parent !== undefined && !tasks.some((candidate) => candidate.ref === call.parent)) {
228
+ throw new Error(`unknown call parent ref "${call.parent}"`);
229
+ }
230
+ }
231
+ assertAcyclic([...tasks, ...calls]);
232
+ for (const name of placeholders(all)) {
233
+ if (!Object.hasOwn(inputs, name)) throw new Error(`unknown input placeholder "${name}"`);
234
+ }
235
+ const links = array(source.links ?? [], "links").map((entry) => {
236
+ const link = record(entry, "link");
237
+ const from = string(link.from, "link from");
238
+ const relation = string(link.relation, "link relation");
239
+ const to = string(link.to, "link to");
240
+ if (!refs.has(from)) throw new Error(`unknown blueprint ref "${from}"`);
241
+ if (!refs.has(to)) throw new Error(`unknown blueprint ref "${to}"`);
242
+ if (!RELATIONS.has(relation)) throw new Error(`unknown link relation "${relation}"`);
243
+ return { from, relation, to };
244
+ });
245
+ if (links.length > SKILL_MAX_LINKS) throw new Error(`links exceed ${SKILL_MAX_LINKS}`);
246
+ return { version: 1, inputs, blueprints: { docs, rules, tasks, skills: calls }, links };
247
+ }
248
+
249
+ export function resolveBlueprintArguments(definition: BlueprintDefinition, value: unknown): Record<string, BlueprintArgumentValue> {
250
+ const source = record(value ?? {}, "arguments");
251
+ for (const name of Object.keys(source)) {
252
+ if (!Object.hasOwn(definition.inputs, name)) throw new Error(`unknown argument "${name}"`);
253
+ }
254
+ const result: Record<string, BlueprintArgumentValue> = {};
255
+ for (const [name, input] of Object.entries(definition.inputs)) {
256
+ const raw = source[name] ?? input.default;
257
+ if (raw === undefined) {
258
+ if (input.required) throw new Error(`missing required argument "${name}"`);
259
+ continue;
260
+ }
261
+ const normalized = validateArgumentValue(name, input.type, raw);
262
+ if (input.enum && !input.enum.includes(normalized)) {
263
+ throw new Error(`argument "${name}" must be one of: ${input.enum.join(", ")}`);
264
+ }
265
+ result[name] = normalized;
266
+ }
267
+ return result;
268
+ }
@@ -1,6 +1,6 @@
1
1
  export const PROOF_TYPES = ["file", "symbol", "code", "test", "command", "artifact", "url"] as const;
2
2
 
3
- export type ProofType = typeof PROOF_TYPES[number];
3
+ export type ProofType = (typeof PROOF_TYPES)[number];
4
4
 
5
5
  export interface ProofReference {
6
6
  type: ProofType;
@@ -25,13 +25,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
25
25
  }
26
26
 
27
27
  function proofReference(value: unknown): ProofReference | undefined {
28
- if (!isRecord(value) || !PROOF_TYPES.includes(value["type"] as ProofType)) return undefined;
29
- if (typeof value["target"] !== "string" || value["target"].trim().length === 0) return undefined;
30
- if (value["expect"] !== undefined && typeof value["expect"] !== "string") return undefined;
28
+ if (!isRecord(value) || !PROOF_TYPES.includes(value.type as ProofType)) return undefined;
29
+ if (typeof value.target !== "string" || value.target.trim().length === 0) return undefined;
30
+ if (value.expect !== undefined && typeof value.expect !== "string") return undefined;
31
31
  return {
32
- type: value["type"] as ProofType,
33
- target: value["target"],
34
- ...(typeof value["expect"] === "string" ? { expect: value["expect"] } : {}),
32
+ type: value.type as ProofType,
33
+ target: value.target,
34
+ ...(typeof value.expect === "string" ? { expect: value.expect } : {}),
35
35
  };
36
36
  }
37
37
 
@@ -40,10 +40,10 @@ export function validateChecklist(value: unknown): Checklist {
40
40
  const checklist: Checklist = {};
41
41
  for (const [item, criterion] of Object.entries(value)) {
42
42
  if (item.trim().length === 0) throw new Error("checklist item must not be empty");
43
- if (!isRecord(criterion) || !Array.isArray(criterion["proof"]) || criterion["proof"].length === 0) {
43
+ if (!isRecord(criterion) || !Array.isArray(criterion.proof) || criterion.proof.length === 0) {
44
44
  throw new Error(`checklist item "${item}" requires at least one proof reference`);
45
45
  }
46
- const proof = criterion["proof"].map(proofReference);
46
+ const proof = criterion.proof.map(proofReference);
47
47
  if (proof.some((reference) => reference === undefined)) {
48
48
  throw new Error(`checklist item "${item}" requires a typed, non-empty proof target`);
49
49
  }
@@ -54,17 +54,20 @@ export function validateChecklist(value: unknown): Checklist {
54
54
 
55
55
  export function checklistEntries(value: unknown): ChecklistEntry[] {
56
56
  if (Array.isArray(value)) {
57
- return value.flatMap((item) => typeof item === "string"
58
- ? [{ item, proof: [], legacy: true }]
59
- : isRecord(item) && typeof item["title"] === "string"
60
- ? [{ item: item["title"], proof: [], legacy: true }]
61
- : []);
57
+ return value.flatMap((item) =>
58
+ typeof item === "string"
59
+ ? [{ item, proof: [], legacy: true }]
60
+ : isRecord(item) && typeof item.title === "string"
61
+ ? [{ item: item.title, proof: [], legacy: true }]
62
+ : [],
63
+ );
62
64
  }
63
65
  if (!isRecord(value)) return [];
64
66
  return Object.entries(value).map(([item, criterion]) => {
65
- const references = isRecord(criterion) && Array.isArray(criterion["proof"])
66
- ? criterion["proof"].map(proofReference).filter((proof): proof is ProofReference => proof !== undefined)
67
- : [];
67
+ const references =
68
+ isRecord(criterion) && Array.isArray(criterion.proof)
69
+ ? criterion.proof.map(proofReference).filter((proof): proof is ProofReference => proof !== undefined)
70
+ : [];
68
71
  return { item, proof: references, legacy: false };
69
72
  });
70
73
  }