@osolmaz/pi-workflows 0.10.0 → 0.11.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 (135) hide show
  1. package/README.md +32 -14
  2. package/dist/builtins/autodoc.workflow.d.ts +58 -0
  3. package/dist/builtins/autodoc.workflow.js +266 -0
  4. package/dist/builtins/autodoc.workflow.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +212 -11
  6. package/dist/builtins/autoimplement.workflow.js +431 -22
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  8. package/dist/builtins/{autodevise.workflow.d.ts → autoplan.workflow.d.ts} +9 -9
  9. package/dist/builtins/{autodevise.workflow.js → autoplan.workflow.js} +20 -20
  10. package/dist/builtins/autoplan.workflow.js.map +1 -0
  11. package/dist/builtins/catalog.js +8 -4
  12. package/dist/builtins/catalog.js.map +1 -1
  13. package/dist/builtins/index.d.ts +6 -3
  14. package/dist/builtins/index.js +5 -2
  15. package/dist/builtins/index.js.map +1 -1
  16. package/dist/builtins/monitor.workflow.d.ts +4 -0
  17. package/dist/builtins/monitor.workflow.js +134 -9
  18. package/dist/builtins/monitor.workflow.js.map +1 -1
  19. package/dist/builtins/plan-approval.workflow.d.ts +83 -0
  20. package/dist/builtins/plan-approval.workflow.js +148 -0
  21. package/dist/builtins/plan-approval.workflow.js.map +1 -0
  22. package/dist/builtins/plan-presentation.d.ts +7 -0
  23. package/dist/builtins/plan-presentation.js +44 -0
  24. package/dist/builtins/plan-presentation.js.map +1 -0
  25. package/dist/extension/decision-channels.d.ts +132 -0
  26. package/dist/extension/decision-channels.js +1082 -0
  27. package/dist/extension/decision-channels.js.map +1 -0
  28. package/dist/extension/index.d.ts +1 -0
  29. package/dist/extension/index.js +426 -11
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/widget.js +26 -2
  32. package/dist/extension/widget.js.map +1 -1
  33. package/dist/extension/workflow-tool.d.ts +1 -37
  34. package/dist/extension/workflow-tool.js +1 -42
  35. package/dist/extension/workflow-tool.js.map +1 -1
  36. package/dist/herdr/setup.d.ts +13 -1
  37. package/dist/herdr/setup.js +349 -36
  38. package/dist/herdr/setup.js.map +1 -1
  39. package/dist/host/rpc-bridge.js +4 -21
  40. package/dist/host/rpc-bridge.js.map +1 -1
  41. package/dist/render/graph-render.js +33 -8
  42. package/dist/render/graph-render.js.map +1 -1
  43. package/dist/viewer/cli.d.ts +1 -0
  44. package/dist/viewer/cli.js +21 -10
  45. package/dist/viewer/cli.js.map +1 -1
  46. package/dist/viewer/render.js +36 -2
  47. package/dist/viewer/render.js.map +1 -1
  48. package/dist/workflows/composition.js +15 -1
  49. package/dist/workflows/composition.js.map +1 -1
  50. package/dist/workflows/decision-presentation.d.ts +18 -0
  51. package/dist/workflows/decision-presentation.js +417 -0
  52. package/dist/workflows/decision-presentation.js.map +1 -0
  53. package/dist/workflows/engine.d.ts +2 -1
  54. package/dist/workflows/engine.js +86 -4
  55. package/dist/workflows/engine.js.map +1 -1
  56. package/dist/workflows/human-decision.d.ts +87 -0
  57. package/dist/workflows/human-decision.js +583 -0
  58. package/dist/workflows/human-decision.js.map +1 -0
  59. package/dist/workflows/index.d.ts +3 -1
  60. package/dist/workflows/index.js +2 -0
  61. package/dist/workflows/index.js.map +1 -1
  62. package/dist/workflows/schema.js +15 -0
  63. package/dist/workflows/schema.js.map +1 -1
  64. package/dist/workflows/store.js +7 -0
  65. package/dist/workflows/store.js.map +1 -1
  66. package/dist/workflows/tool-input.d.ts +72 -0
  67. package/dist/workflows/tool-input.js +141 -0
  68. package/dist/workflows/tool-input.js.map +1 -0
  69. package/dist/workflows/types.d.ts +223 -0
  70. package/docs/HUMAN_DECISIONS.md +371 -0
  71. package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
  72. package/docs/MONITOR.md +17 -6
  73. package/docs/WORKFLOW_COMPOSITION.md +13 -6
  74. package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
  75. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
  76. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
  77. package/docs/plans/2026-08-19-workflow-composition-plan.md +25 -17
  78. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +138 -0
  79. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +104 -0
  80. package/docs/run-bundles.md +23 -2
  81. package/docs/workflows.md +47 -5
  82. package/examples/workflows/approved-plan.workflow.ts +58 -0
  83. package/examples/workflows/autoplan.workflow.ts +1 -0
  84. package/examples/workflows/human-decision.workflow.ts +62 -0
  85. package/herdr-plugin.toml +1 -1
  86. package/package.json +2 -1
  87. package/schemas/decision-presentation-v1.schema.json +83 -0
  88. package/schemas/human-decision-accepted-v1.schema.json +44 -0
  89. package/schemas/human-decision-accepted-v2.schema.json +50 -0
  90. package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
  91. package/schemas/human-decision-cancellation-v1.schema.json +15 -0
  92. package/schemas/human-decision-continuation-v1.schema.json +16 -0
  93. package/schemas/human-decision-delivery-v1.schema.json +28 -0
  94. package/schemas/human-decision-delivery-v2.schema.json +36 -0
  95. package/schemas/human-decision-receipt-v1.schema.json +33 -0
  96. package/schemas/human-decision-receipt-v2.schema.json +39 -0
  97. package/schemas/human-decision-request-v1.schema.json +59 -0
  98. package/schemas/human-decision-request-v2.schema.json +69 -0
  99. package/schemas/human-decision-resolution-v1.schema.json +27 -0
  100. package/schemas/human-decision-resolution-v2.schema.json +27 -0
  101. package/schemas/human-decision-settlement-v1.schema.json +28 -0
  102. package/skills/autodoc/SKILL.md +43 -0
  103. package/skills/autoimplement/SKILL.md +58 -0
  104. package/skills/autoimplement/agents/openai.yaml +4 -0
  105. package/skills/autoplan/SKILL.md +25 -0
  106. package/skills/monitor/SKILL.md +3 -1
  107. package/skills/pi-workflows/SKILL.md +3 -3
  108. package/src/builtins/autodoc.workflow.ts +325 -0
  109. package/src/builtins/autoimplement.workflow.ts +493 -23
  110. package/src/builtins/{autodevise.workflow.ts → autoplan.workflow.ts} +32 -32
  111. package/src/builtins/catalog.ts +8 -4
  112. package/src/builtins/index.ts +25 -6
  113. package/src/builtins/monitor.workflow.ts +162 -8
  114. package/src/builtins/plan-approval.workflow.ts +190 -0
  115. package/src/builtins/plan-presentation.ts +57 -0
  116. package/src/extension/decision-channels.ts +1533 -0
  117. package/src/extension/index.ts +499 -9
  118. package/src/extension/widget.ts +39 -2
  119. package/src/extension/workflow-tool.ts +5 -81
  120. package/src/herdr/setup.ts +429 -39
  121. package/src/host/rpc-bridge.ts +7 -30
  122. package/src/render/graph-render.ts +40 -3
  123. package/src/viewer/cli.ts +22 -10
  124. package/src/viewer/render.ts +56 -3
  125. package/src/workflows/composition.ts +16 -1
  126. package/src/workflows/decision-presentation.ts +501 -0
  127. package/src/workflows/engine.ts +108 -4
  128. package/src/workflows/human-decision.ts +819 -0
  129. package/src/workflows/index.ts +69 -0
  130. package/src/workflows/schema.ts +17 -0
  131. package/src/workflows/store.ts +8 -0
  132. package/src/workflows/tool-input.ts +202 -0
  133. package/src/workflows/types.ts +265 -0
  134. package/dist/builtins/autodevise.workflow.js.map +0 -1
  135. package/examples/workflows/autodevise.workflow.ts +0 -1
@@ -0,0 +1,501 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ DecisionPresentation,
4
+ DecisionPresentationBlock,
5
+ HumanDecisionChannelRequest,
6
+ HumanDecisionRequest,
7
+ } from "./types.js";
8
+
9
+ export const MAX_PRESENTATION_CODE_UNITS = 64_000;
10
+ export const MAX_PRESENTATION_BLOCKS = 256;
11
+ export const MAX_PRESENTATION_ITEMS = 256;
12
+ export const MAX_PRESENTATION_STRING_CODE_UNITS = 16_000;
13
+ export const MAX_PRESENTATION_TRANSPORT_PARTS = 20;
14
+
15
+ export type DecisionDocumentSegment = {
16
+ kind: "title" | "summary" | DecisionPresentationBlock["kind"] | "choices";
17
+ text: string;
18
+ };
19
+
20
+ export function normalizeDecisionPresentation(value: unknown): DecisionPresentation {
21
+ const presentation = requireRecord(value, "Decision presentation");
22
+ requireExactKeys(presentation, ["schema", "summary", "blocks"], "Decision presentation");
23
+ if (presentation.schema !== "pi-workflows.decision-presentation.v1") {
24
+ throw new Error("Decision presentation schema must be pi-workflows.decision-presentation.v1");
25
+ }
26
+ const summary = normalizeDisplayString(presentation.summary, "Decision presentation summary");
27
+ if (!Array.isArray(presentation.blocks)) {
28
+ throw new Error("Decision presentation blocks must be an array");
29
+ }
30
+ if (presentation.blocks.length > MAX_PRESENTATION_BLOCKS) {
31
+ throw new Error(
32
+ `Decision presentation has ${presentation.blocks.length} blocks; limit is ${MAX_PRESENTATION_BLOCKS}`,
33
+ );
34
+ }
35
+ const blocks = presentation.blocks.map((block, index) =>
36
+ normalizeBlock(block, `Decision presentation block ${index + 1}`),
37
+ );
38
+ const normalized: DecisionPresentation = {
39
+ schema: "pi-workflows.decision-presentation.v1",
40
+ summary,
41
+ blocks,
42
+ };
43
+ const codeUnits = presentationCodeUnits(normalized);
44
+ if (codeUnits > MAX_PRESENTATION_CODE_UNITS) {
45
+ throw new Error(
46
+ `Decision presentation has ${codeUnits} UTF-16 code units; limit is ${MAX_PRESENTATION_CODE_UNITS}`,
47
+ );
48
+ }
49
+ return normalized;
50
+ }
51
+
52
+ export function decisionPresentationDigest(presentation: DecisionPresentation): string {
53
+ return digestCanonical(normalizeDecisionPresentation(presentation));
54
+ }
55
+
56
+ export function validateHumanDecisionRequestIntegrity(
57
+ request: HumanDecisionRequest,
58
+ ): HumanDecisionRequest {
59
+ if (request.schema === "pi-workflows.human-decision-request.v1") return request;
60
+ const presentation = normalizeDecisionPresentation(request.presentation);
61
+ const subjectDigest = digestCanonical(request.subject);
62
+ const presentationDigest = digestCanonical(presentation);
63
+ const requestDigest = digestCanonical({
64
+ schema: request.schema,
65
+ runId: request.runId,
66
+ workflowName: request.workflowName,
67
+ nodeId: request.nodeId,
68
+ attemptId: request.attemptId,
69
+ audience: request.audience,
70
+ title: request.title,
71
+ choices: request.choices,
72
+ ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
73
+ subject: request.subject,
74
+ presentation,
75
+ revision: request.revision,
76
+ });
77
+ if (subjectDigest !== request.subjectDigest) {
78
+ throw new Error("Human decision subject digest does not match its durable request");
79
+ }
80
+ if (presentationDigest !== request.presentationDigest) {
81
+ throw new Error("Human decision presentation digest does not match its durable request");
82
+ }
83
+ if (requestDigest !== request.requestDigest) {
84
+ throw new Error("Human decision request digest does not match its durable request");
85
+ }
86
+ return request;
87
+ }
88
+
89
+ export function humanDecisionChannelRequest(
90
+ request: HumanDecisionRequest,
91
+ ): HumanDecisionChannelRequest {
92
+ validateHumanDecisionRequestIntegrity(request);
93
+ const presentation =
94
+ request.schema === "pi-workflows.human-decision-request.v2"
95
+ ? normalizeDecisionPresentation(request.presentation)
96
+ : legacyDecisionPresentation(request.body);
97
+ const presentationDigest = decisionPresentationDigest(presentation);
98
+ return {
99
+ schema: "pi-workflows.human-decision-channel-request.v1",
100
+ sourceSchema: request.schema,
101
+ decisionId: request.decisionId,
102
+ requestDigest: request.requestDigest,
103
+ runId: request.runId,
104
+ workflowName: request.workflowName,
105
+ nodeId: request.nodeId,
106
+ attemptId: request.attemptId,
107
+ audience: request.audience,
108
+ title: request.title,
109
+ presentation,
110
+ presentationDigest,
111
+ revision: request.schema === "pi-workflows.human-decision-request.v2" ? request.revision : 1,
112
+ choices: request.choices,
113
+ createdAt: request.createdAt,
114
+ ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
115
+ };
116
+ }
117
+
118
+ export function legacyDecisionPresentation(body: unknown): DecisionPresentation {
119
+ const blocks: DecisionPresentationBlock[] = [];
120
+ if (typeof body === "string") {
121
+ appendLegacyText(blocks, body, "Decision details");
122
+ } else {
123
+ appendLegacyValue(blocks, body, undefined);
124
+ }
125
+ const summary =
126
+ typeof body === "string"
127
+ ? "Review the decision message below."
128
+ : "Review the decision details below.";
129
+ return normalizeDecisionPresentation(boundLegacyPresentation(summary, blocks, body));
130
+ }
131
+
132
+ export function decisionDocumentSegments(
133
+ request: HumanDecisionChannelRequest,
134
+ ): DecisionDocumentSegment[] {
135
+ const segments: DecisionDocumentSegment[] = [
136
+ { kind: "title", text: request.title },
137
+ { kind: "summary", text: request.presentation.summary },
138
+ ];
139
+ for (const block of request.presentation.blocks) {
140
+ if (block.kind === "paragraph") segments.push({ kind: block.kind, text: block.text });
141
+ else if (block.kind === "section") segments.push({ kind: block.kind, text: block.title });
142
+ else if (block.kind === "bullets") {
143
+ segments.push({ kind: block.kind, text: block.items.map((item) => `• ${item}`).join("\n") });
144
+ } else if (block.kind === "fields") {
145
+ segments.push({
146
+ kind: block.kind,
147
+ text: block.items.map(({ label, value }) => `${label}: ${value}`).join("\n"),
148
+ });
149
+ } else {
150
+ segments.push({ kind: block.kind, text: block.text });
151
+ }
152
+ }
153
+ const choices = Object.values(request.choices).map((choice) =>
154
+ choice.input === undefined
155
+ ? `• ${choice.label}`
156
+ : `• ${choice.label}\n ${choice.input.prompt}`,
157
+ );
158
+ segments.push({ kind: "choices", text: `Choices\n${choices.join("\n")}` });
159
+ return segments;
160
+ }
161
+
162
+ export function decisionPresentationFingerprint(request: HumanDecisionChannelRequest): string {
163
+ return request.presentationDigest.slice("sha256:".length, "sha256:".length + 12);
164
+ }
165
+
166
+ export function digestCanonical(value: unknown): string {
167
+ return `sha256:${createHash("sha256")
168
+ .update(`${canonicalJson(value)}\n`)
169
+ .digest("hex")}`;
170
+ }
171
+
172
+ function normalizeBlock(value: unknown, label: string): DecisionPresentationBlock {
173
+ const block = requireRecord(value, label);
174
+ if (block.kind === "paragraph") {
175
+ requireExactKeys(block, ["kind", "text"], label);
176
+ return { kind: "paragraph", text: normalizeMultilineString(block.text, `${label} text`) };
177
+ }
178
+ if (block.kind === "section") {
179
+ requireExactKeys(block, ["kind", "title"], label);
180
+ return { kind: "section", title: normalizeDisplayString(block.title, `${label} title`) };
181
+ }
182
+ if (block.kind === "bullets") {
183
+ requireExactKeys(block, ["kind", "items"], label);
184
+ return {
185
+ kind: "bullets",
186
+ items: normalizeItems(block.items, `${label} items`, (item, itemLabel) =>
187
+ normalizeDisplayString(item, itemLabel),
188
+ ),
189
+ };
190
+ }
191
+ if (block.kind === "fields") {
192
+ requireExactKeys(block, ["kind", "items"], label);
193
+ return {
194
+ kind: "fields",
195
+ items: normalizeItems(block.items, `${label} items`, (item, itemLabel) => {
196
+ const field = requireRecord(item, itemLabel);
197
+ requireExactKeys(field, ["label", "value"], itemLabel);
198
+ return {
199
+ label: normalizeDisplayString(field.label, `${itemLabel} label`),
200
+ value: normalizeDisplayString(field.value, `${itemLabel} value`),
201
+ };
202
+ }),
203
+ };
204
+ }
205
+ if (block.kind === "preformatted") {
206
+ requireExactKeys(block, ["kind", "text"], label);
207
+ return { kind: "preformatted", text: normalizeMultilineString(block.text, `${label} text`) };
208
+ }
209
+ throw new Error(`${label} kind is not supported`);
210
+ }
211
+
212
+ function normalizeItems<T>(
213
+ value: unknown,
214
+ label: string,
215
+ normalize: (item: unknown, label: string) => T,
216
+ ): T[] {
217
+ if (!Array.isArray(value) || value.length === 0) {
218
+ throw new Error(`${label} must be a non-empty array`);
219
+ }
220
+ if (value.length > MAX_PRESENTATION_ITEMS) {
221
+ throw new Error(`${label} has ${value.length} items; limit is ${MAX_PRESENTATION_ITEMS}`);
222
+ }
223
+ return value.map((item, index) => normalize(item, `${label} item ${index + 1}`));
224
+ }
225
+
226
+ function normalizeDisplayString(value: unknown, label: string): string {
227
+ const text = normalizeString(value, label);
228
+ if (hasControlCharacter(text, false)) {
229
+ throw new Error(`${label} contains a control character`);
230
+ }
231
+ return text;
232
+ }
233
+
234
+ function normalizeMultilineString(value: unknown, label: string): string {
235
+ const text = normalizeString(value, label);
236
+ if (hasControlCharacter(text, true)) {
237
+ throw new Error(`${label} contains a terminal control character`);
238
+ }
239
+ return text;
240
+ }
241
+
242
+ function normalizeString(value: unknown, label: string): string {
243
+ if (typeof value !== "string") throw new Error(`${label} must be a string`);
244
+ const text = value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").normalize("NFC");
245
+ if (text.trim().length === 0) throw new Error(`${label} must not be empty`);
246
+ if (text.length > MAX_PRESENTATION_STRING_CODE_UNITS) {
247
+ throw new Error(
248
+ `${label} has ${text.length} UTF-16 code units; limit is ${MAX_PRESENTATION_STRING_CODE_UNITS}`,
249
+ );
250
+ }
251
+ return text;
252
+ }
253
+
254
+ function boundLegacyPresentation(
255
+ summary: string,
256
+ blocks: DecisionPresentationBlock[],
257
+ body: unknown,
258
+ ): DecisionPresentation {
259
+ const candidate: DecisionPresentation = {
260
+ schema: "pi-workflows.decision-presentation.v1",
261
+ summary,
262
+ blocks,
263
+ };
264
+ if (
265
+ blocks.length <= MAX_PRESENTATION_BLOCKS &&
266
+ blocks.every(legacyBlockFitsSchema) &&
267
+ presentationCodeUnits(candidate) <= MAX_PRESENTATION_CODE_UNITS
268
+ ) {
269
+ return candidate;
270
+ }
271
+ const originalCodeUnits = canonicalJson(body).length;
272
+ const omission = `Some legacy decision content is not shown because it exceeds the readable presentation limit. Inspect the durable v1 request before answering. Body digest: ${digestCanonical(body)}. Original body: ${originalCodeUnits} UTF-16 code units. Generated blocks: ${blocks.length}.`;
273
+ const budget = MAX_PRESENTATION_CODE_UNITS - summary.length - omission.length;
274
+ const kept: DecisionPresentationBlock[] = [];
275
+ let used = 0;
276
+ let omitted = false;
277
+ for (const block of blocks) {
278
+ const units = presentationBlockCodeUnits(block);
279
+ if (
280
+ kept.length >= MAX_PRESENTATION_BLOCKS ||
281
+ !legacyBlockFitsSchema(block) ||
282
+ used + units > budget
283
+ ) {
284
+ omitted = true;
285
+ continue;
286
+ }
287
+ kept.push(block);
288
+ used += units;
289
+ }
290
+ if (omitted) {
291
+ if (kept.length >= MAX_PRESENTATION_BLOCKS) kept.pop();
292
+ kept.push({ kind: "paragraph", text: omission });
293
+ }
294
+ return {
295
+ schema: "pi-workflows.decision-presentation.v1",
296
+ summary,
297
+ blocks: kept,
298
+ };
299
+ }
300
+
301
+ function legacyBlockFitsSchema(block: DecisionPresentationBlock): boolean {
302
+ if (block.kind === "paragraph" || block.kind === "preformatted") {
303
+ return block.text.length <= MAX_PRESENTATION_STRING_CODE_UNITS;
304
+ }
305
+ if (block.kind === "section") {
306
+ return block.title.length <= MAX_PRESENTATION_STRING_CODE_UNITS;
307
+ }
308
+ if (block.kind === "bullets") {
309
+ return block.items.every((item) => item.length <= MAX_PRESENTATION_STRING_CODE_UNITS);
310
+ }
311
+ return block.items.every(
312
+ (item) =>
313
+ item.label.length <= MAX_PRESENTATION_STRING_CODE_UNITS &&
314
+ item.value.length <= MAX_PRESENTATION_STRING_CODE_UNITS,
315
+ );
316
+ }
317
+
318
+ function presentationBlockCodeUnits(block: DecisionPresentationBlock): number {
319
+ if (block.kind === "paragraph" || block.kind === "preformatted") return block.text.length;
320
+ if (block.kind === "section") return block.title.length;
321
+ if (block.kind === "bullets") {
322
+ return block.items.reduce((sum, item) => sum + item.length, 0);
323
+ }
324
+ return block.items.reduce((sum, item) => sum + item.label.length + item.value.length, 0);
325
+ }
326
+
327
+ function presentationCodeUnits(presentation: DecisionPresentation): number {
328
+ let total = presentation.summary.length;
329
+ for (const block of presentation.blocks) {
330
+ total += presentationBlockCodeUnits(block);
331
+ }
332
+ return total;
333
+ }
334
+
335
+ function appendLegacyValue(
336
+ blocks: DecisionPresentationBlock[],
337
+ value: unknown,
338
+ label: string | undefined,
339
+ ): void {
340
+ if (isScalar(value)) {
341
+ const scalar = legacyScalar(value);
342
+ if (label === undefined || scalar.length > MAX_PRESENTATION_STRING_CODE_UNITS) {
343
+ appendLegacyText(
344
+ blocks,
345
+ scalar,
346
+ label === undefined ? "Decision details" : legacyLabel(label),
347
+ );
348
+ } else {
349
+ blocks.push({ kind: "fields", items: [{ label: legacyLabel(label), value: scalar }] });
350
+ }
351
+ return;
352
+ }
353
+ if (Array.isArray(value)) {
354
+ if (label !== undefined) blocks.push({ kind: "section", title: legacyLabel(label) });
355
+ if (value.length === 0) {
356
+ blocks.push({ kind: "paragraph", text: "None" });
357
+ return;
358
+ }
359
+ if (
360
+ value.every(isScalar) &&
361
+ value.every((item) => legacyScalar(item).length <= MAX_PRESENTATION_STRING_CODE_UNITS)
362
+ ) {
363
+ const items = value.map(legacyScalar);
364
+ for (let index = 0; index < items.length; index += MAX_PRESENTATION_ITEMS) {
365
+ blocks.push({ kind: "bullets", items: items.slice(index, index + MAX_PRESENTATION_ITEMS) });
366
+ }
367
+ return;
368
+ }
369
+ value.forEach((item, index) => appendLegacyValue(blocks, item, `Item ${index + 1}`));
370
+ return;
371
+ }
372
+ const entries = Object.entries(value as Record<string, unknown>).sort(([left], [right]) =>
373
+ compareKeys(left, right),
374
+ );
375
+ if (label !== undefined) blocks.push({ kind: "section", title: legacyLabel(label) });
376
+ if (entries.length === 0) {
377
+ blocks.push({ kind: "paragraph", text: "None" });
378
+ return;
379
+ }
380
+ const fields = entries
381
+ .filter(
382
+ ([, child]) =>
383
+ isScalar(child) && legacyScalar(child).length <= MAX_PRESENTATION_STRING_CODE_UNITS,
384
+ )
385
+ .map(([key, child]) => ({ label: legacyLabel(key), value: legacyScalar(child) }));
386
+ for (let index = 0; index < fields.length; index += MAX_PRESENTATION_ITEMS) {
387
+ blocks.push({ kind: "fields", items: fields.slice(index, index + MAX_PRESENTATION_ITEMS) });
388
+ }
389
+ for (const [key, child] of entries.filter(
390
+ ([, child]) =>
391
+ !isScalar(child) || legacyScalar(child).length > MAX_PRESENTATION_STRING_CODE_UNITS,
392
+ )) {
393
+ appendLegacyValue(blocks, child, key);
394
+ }
395
+ }
396
+
397
+ function appendLegacyText(
398
+ blocks: DecisionPresentationBlock[],
399
+ value: string,
400
+ section: string,
401
+ ): void {
402
+ const text = sanitizeLegacyText(value);
403
+ blocks.push({ kind: "section", title: section });
404
+ for (const part of splitByCodeUnits(text, MAX_PRESENTATION_STRING_CODE_UNITS)) {
405
+ blocks.push({ kind: "paragraph", text: part.trim().length === 0 ? "None" : part });
406
+ }
407
+ }
408
+
409
+ function legacyLabel(value: string): string {
410
+ const sanitized = sanitizeLegacyText(value)
411
+ .replaceAll(/[_-]+/gu, " ")
412
+ .replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2")
413
+ .replaceAll(/\s+/gu, " ")
414
+ .trim();
415
+ if (sanitized.length === 0) return "Value";
416
+ return `${sanitized[0]!.toUpperCase()}${sanitized.slice(1)}`;
417
+ }
418
+
419
+ function legacyScalar(value: unknown): string {
420
+ if (value === null) return "None";
421
+ if (typeof value === "boolean") return value ? "Yes" : "No";
422
+ return sanitizeLegacyText(String(value));
423
+ }
424
+
425
+ function sanitizeLegacyText(value: string): string {
426
+ return value
427
+ .replaceAll("\r\n", "\n")
428
+ .replaceAll("\r", "\n")
429
+ .normalize("NFC")
430
+ .split("")
431
+ .map((character) => (isControlCode(character.charCodeAt(0), true) ? "�" : character))
432
+ .join("");
433
+ }
434
+
435
+ function hasControlCharacter(value: string, allowLineAndTab: boolean): boolean {
436
+ return [...value].some((character) =>
437
+ isControlCode(character.codePointAt(0) ?? 0, allowLineAndTab),
438
+ );
439
+ }
440
+
441
+ function isControlCode(code: number, allowLineAndTab: boolean): boolean {
442
+ if (allowLineAndTab && (code === 9 || code === 10)) return false;
443
+ return code <= 31 || code === 127;
444
+ }
445
+
446
+ function splitByCodeUnits(value: string, limit: number): string[] {
447
+ if (value.length <= limit) return [value];
448
+ const parts: string[] = [];
449
+ let remaining = value;
450
+ while (remaining.length > 0) {
451
+ let end = Math.min(limit, remaining.length);
452
+ const last = remaining.charCodeAt(end - 1);
453
+ if (last >= 0xd800 && last <= 0xdbff && end < remaining.length) end -= 1;
454
+ parts.push(remaining.slice(0, end));
455
+ remaining = remaining.slice(end);
456
+ }
457
+ return parts;
458
+ }
459
+
460
+ function isScalar(value: unknown): value is null | string | number | boolean {
461
+ return value === null || ["string", "number", "boolean"].includes(typeof value);
462
+ }
463
+
464
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
465
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
466
+ throw new Error(`${label} must be an object`);
467
+ }
468
+ return value as Record<string, unknown>;
469
+ }
470
+
471
+ function requireExactKeys(
472
+ value: Record<string, unknown>,
473
+ allowed: readonly string[],
474
+ label: string,
475
+ ): void {
476
+ const keys = Object.keys(value);
477
+ const extras = keys.filter((key) => !allowed.includes(key));
478
+ const missing = allowed.filter((key) => !Object.hasOwn(value, key));
479
+ if (missing.length > 0) throw new Error(`${label} is missing ${missing.join(", ")}`);
480
+ if (extras.length > 0) throw new Error(`${label} has unknown field ${extras.join(", ")}`);
481
+ }
482
+
483
+ function canonicalJson(value: unknown): string {
484
+ return JSON.stringify(sortJson(value));
485
+ }
486
+
487
+ function compareKeys(left: string, right: string): number {
488
+ return left < right ? -1 : left > right ? 1 : 0;
489
+ }
490
+
491
+ function sortJson(value: unknown): unknown {
492
+ if (Array.isArray(value)) return value.map(sortJson);
493
+ if (value !== null && typeof value === "object") {
494
+ return Object.fromEntries(
495
+ Object.entries(value as Record<string, unknown>)
496
+ .sort(([left], [right]) => compareKeys(left, right))
497
+ .map(([key, child]) => [key, sortJson(child)]),
498
+ );
499
+ }
500
+ return value;
501
+ }
@@ -17,6 +17,11 @@ import {
17
17
  WorkflowSourceChangedError,
18
18
  } from "./errors.js";
19
19
  import { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
20
+ import {
21
+ HumanDecisionStore,
22
+ createHumanDecisionRequest,
23
+ validateHumanDecisionResponse,
24
+ } from "./human-decision.js";
20
25
  import { extractJsonValue } from "./json.js";
21
26
  import { runShellAction, shellResultFromError } from "./shell.js";
22
27
  import {
@@ -27,6 +32,7 @@ import {
27
32
  readRunBundle,
28
33
  } from "./store.js";
29
34
  import type {
35
+ AcceptedHumanDecision,
30
36
  AgentNodeDefinition,
31
37
  AgentStepExecutor,
32
38
  ActionNodeDefinition,
@@ -43,6 +49,7 @@ import type {
43
49
  WorkflowNodeOutcome,
44
50
  WorkflowNodeResult,
45
51
  WorkflowNotificationSink,
52
+ HumanDecisionRequest,
46
53
  WorkflowRunResult,
47
54
  WorkflowRunState,
48
55
  WorkflowSource,
@@ -366,7 +373,12 @@ export class WorkflowEngine {
366
373
  workflow: WorkflowDefinition,
367
374
  parentRunId: string,
368
375
  input: unknown,
369
- options: { workflowSource?: WorkflowSource; runId?: string; force?: boolean } = {},
376
+ options: {
377
+ workflowSource?: WorkflowSource;
378
+ runId?: string;
379
+ force?: boolean;
380
+ humanDecision?: AcceptedHumanDecision;
381
+ } = {},
370
382
  ): Promise<WorkflowRunResult> {
371
383
  workflow = isCompiledWorkflow(workflow) ? workflow : compileWorkflowDefinition(workflow);
372
384
  validateWorkflowDefinition(workflow);
@@ -387,8 +399,39 @@ export class WorkflowEngine {
387
399
  throw new WorkflowSourceChangedError(parentRunId);
388
400
  }
389
401
 
390
- const suppliedInput = input === undefined ? null : input;
391
- const normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
402
+ const waitingNodeId = parent.state.waitingOn;
403
+ const waitingNode = workflow.nodes[waitingNodeId];
404
+ const humanContract =
405
+ waitingNode?.nodeType === "checkpoint" ? waitingNode.humanDecision : undefined;
406
+ let acceptedResponse: unknown;
407
+ let acceptedNodeId: string | undefined;
408
+ let normalizedInput: unknown;
409
+ if (humanContract !== undefined) {
410
+ if (options.humanDecision === undefined) {
411
+ throw new Error(`Checkpoint ${waitingNodeId} requires an accepted verified human decision`);
412
+ }
413
+ const request = parent.state.finalOutput as HumanDecisionRequest;
414
+ if (
415
+ (request?.schema !== "pi-workflows.human-decision-request.v1" &&
416
+ request?.schema !== "pi-workflows.human-decision-request.v2") ||
417
+ request.decisionId !== options.humanDecision.decisionId ||
418
+ request.requestDigest !== options.humanDecision.requestDigest
419
+ ) {
420
+ throw new Error("Accepted human decision does not match the waiting request");
421
+ }
422
+ const durableDecision = await new HumanDecisionStore(this.store.outputRoot).readAccepted(
423
+ request.decisionId,
424
+ );
425
+ if (durableDecision === null || !isDeepStrictEqual(durableDecision, options.humanDecision)) {
426
+ throw new Error("Accepted human decision does not match the durable decision record");
427
+ }
428
+ acceptedResponse = validateHumanDecisionResponse(request, durableDecision.response);
429
+ acceptedNodeId = request.nodeId;
430
+ normalizedInput = await resolveArtifacts(parent.state.input, parent.runDir);
431
+ } else {
432
+ const suppliedInput = input === undefined ? null : input;
433
+ normalizedInput = workflow.input ? await workflow.input(suppliedInput) : suppliedInput;
434
+ }
392
435
  assertJsonSerializable(normalizedInput, "Workflow run input");
393
436
  if (options.runId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(options.runId)) {
394
437
  throw new Error(`Invalid workflow run id: ${JSON.stringify(options.runId)}`);
@@ -415,6 +458,39 @@ export class WorkflowEngine {
415
458
  parent.state.steps,
416
459
  parent.runDir,
417
460
  )) as WorkflowRunState["steps"];
461
+ if (humanContract !== undefined && options.humanDecision !== undefined) {
462
+ const receipt = {
463
+ decisionId: options.humanDecision.decisionId,
464
+ requestDigest: options.humanDecision.requestDigest,
465
+ nodeId: acceptedNodeId ?? waitingNodeId,
466
+ response: options.humanDecision.response,
467
+ acceptedAt: options.humanDecision.acceptedAt,
468
+ answerDigest: options.humanDecision.answerDigest,
469
+ };
470
+ state.humanDecision =
471
+ options.humanDecision.schema === "pi-workflows.human-decision-accepted.v2"
472
+ ? {
473
+ schema: "pi-workflows.human-decision-receipt.v2",
474
+ ...receipt,
475
+ subjectDigest: options.humanDecision.subjectDigest,
476
+ presentationDigest: options.humanDecision.presentationDigest,
477
+ revision: options.humanDecision.revision,
478
+ }
479
+ : { schema: "pi-workflows.human-decision-receipt.v1", ...receipt };
480
+ state.outputs[waitingNodeId] = acceptedResponse;
481
+ const priorResult = state.results[waitingNodeId];
482
+ if (priorResult === undefined) {
483
+ throw new Error(`Waiting human decision result is missing for ${waitingNodeId}`);
484
+ }
485
+ state.results[waitingNodeId] = { ...priorResult, output: acceptedResponse };
486
+ const stepIndex = state.steps.findLastIndex((step) => step.nodeId === waitingNodeId);
487
+ if (stepIndex < 0)
488
+ throw new Error(`Waiting human decision step is missing for ${waitingNodeId}`);
489
+ const priorStep = state.steps[stepIndex];
490
+ if (priorStep === undefined)
491
+ throw new Error("Waiting human decision step became unavailable");
492
+ state.steps[stepIndex] = { ...priorStep, output: acceptedResponse };
493
+ }
418
494
  state.carriedStepCount = state.steps.length;
419
495
 
420
496
  const runDir = await this.store.initializeRunBundle(workflow, state);
@@ -1043,7 +1119,12 @@ export class WorkflowEngine {
1043
1119
  case "action":
1044
1120
  return await this.runActionNode(node, context, nodeId, attemptId, signal, meta);
1045
1121
  case "checkpoint":
1046
- return await runCheckpointNode(node, context);
1122
+ return await runCheckpointNode(node, context, {
1123
+ store: this.store,
1124
+ workflowName: workflow.name,
1125
+ nodeId,
1126
+ attemptId,
1127
+ });
1047
1128
  }
1048
1129
  }
1049
1130
 
@@ -1220,7 +1301,30 @@ export class WorkflowEngine {
1220
1301
  async function runCheckpointNode(
1221
1302
  node: CheckpointNodeDefinition,
1222
1303
  context: WorkflowNodeContext,
1304
+ execution: {
1305
+ store: WorkflowRunStore;
1306
+ workflowName: string;
1307
+ nodeId: string;
1308
+ attemptId: string;
1309
+ },
1223
1310
  ): Promise<NodeExecution> {
1311
+ if (node.humanDecision !== undefined) {
1312
+ const prompt = await node.humanDecision.request(context);
1313
+ const audience =
1314
+ typeof node.humanDecision.audience === "function"
1315
+ ? await node.humanDecision.audience(context)
1316
+ : node.humanDecision.audience;
1317
+ const request = createHumanDecisionRequest({
1318
+ runId: context.state.runId,
1319
+ workflowName: execution.workflowName,
1320
+ nodeId: execution.nodeId,
1321
+ attemptId: execution.attemptId,
1322
+ contract: { audience, choices: node.humanDecision.choices },
1323
+ prompt,
1324
+ });
1325
+ await new HumanDecisionStore(execution.store.outputRoot).createRequest(request);
1326
+ return { output: request, promptText: null };
1327
+ }
1224
1328
  const output = node.run ? await node.run(context) : { summary: node.summary ?? "checkpoint" };
1225
1329
  return { output, promptText: null };
1226
1330
  }