@osolmaz/pi-workflows 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +64 -19
  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 +353 -0
  6. package/dist/builtins/autoimplement.workflow.js +961 -0
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -0
  8. package/dist/builtins/autoplan.workflow.d.ts +58 -0
  9. package/dist/builtins/autoplan.workflow.js +190 -0
  10. package/dist/builtins/autoplan.workflow.js.map +1 -0
  11. package/dist/builtins/catalog.js +9 -1
  12. package/dist/builtins/catalog.js.map +1 -1
  13. package/dist/builtins/index.d.ts +6 -0
  14. package/dist/builtins/index.js +7 -0
  15. package/dist/builtins/index.js.map +1 -0
  16. package/dist/builtins/monitor.workflow.d.ts +29 -3
  17. package/dist/builtins/monitor.workflow.js +325 -13
  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/host/rpc-bridge.js +4 -21
  37. package/dist/host/rpc-bridge.js.map +1 -1
  38. package/dist/render/graph-render.js +46 -10
  39. package/dist/render/graph-render.js.map +1 -1
  40. package/dist/viewer/render.js +36 -2
  41. package/dist/viewer/render.js.map +1 -1
  42. package/dist/workflows/catalog.d.ts +1 -0
  43. package/dist/workflows/catalog.js +6 -0
  44. package/dist/workflows/catalog.js.map +1 -1
  45. package/dist/workflows/composition.d.ts +45 -0
  46. package/dist/workflows/composition.js +485 -0
  47. package/dist/workflows/composition.js.map +1 -0
  48. package/dist/workflows/decision-presentation.d.ts +18 -0
  49. package/dist/workflows/decision-presentation.js +417 -0
  50. package/dist/workflows/decision-presentation.js.map +1 -0
  51. package/dist/workflows/decision.d.ts +11 -5
  52. package/dist/workflows/decision.js.map +1 -1
  53. package/dist/workflows/definition.d.ts +22 -3
  54. package/dist/workflows/definition.js +46 -3
  55. package/dist/workflows/definition.js.map +1 -1
  56. package/dist/workflows/engine.d.ts +2 -1
  57. package/dist/workflows/engine.js +199 -18
  58. package/dist/workflows/engine.js.map +1 -1
  59. package/dist/workflows/graph.js +8 -6
  60. package/dist/workflows/graph.js.map +1 -1
  61. package/dist/workflows/human-decision.d.ts +87 -0
  62. package/dist/workflows/human-decision.js +583 -0
  63. package/dist/workflows/human-decision.js.map +1 -0
  64. package/dist/workflows/index.d.ts +5 -2
  65. package/dist/workflows/index.js +4 -1
  66. package/dist/workflows/index.js.map +1 -1
  67. package/dist/workflows/loader.d.ts +5 -4
  68. package/dist/workflows/loader.js +118 -18
  69. package/dist/workflows/loader.js.map +1 -1
  70. package/dist/workflows/schema.d.ts +3 -1
  71. package/dist/workflows/schema.js +64 -2
  72. package/dist/workflows/schema.js.map +1 -1
  73. package/dist/workflows/store.js +39 -2
  74. package/dist/workflows/store.js.map +1 -1
  75. package/dist/workflows/tool-input.d.ts +72 -0
  76. package/dist/workflows/tool-input.js +141 -0
  77. package/dist/workflows/tool-input.js.map +1 -0
  78. package/dist/workflows/types.d.ts +300 -2
  79. package/docs/CONTROLLERS.md +1 -1
  80. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  81. package/docs/HUMAN_DECISIONS.md +371 -0
  82. package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
  83. package/docs/MONITOR.md +46 -18
  84. package/docs/WORKFLOW_COMPOSITION.md +333 -0
  85. package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
  86. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
  87. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
  88. package/docs/plans/2026-08-19-workflow-composition-plan.md +308 -0
  89. package/docs/run-bundles.md +45 -10
  90. package/docs/workflows.md +106 -15
  91. package/examples/workflows/approved-plan.workflow.ts +58 -0
  92. package/examples/workflows/autoimplement.workflow.ts +1 -92
  93. package/examples/workflows/autoplan.workflow.ts +1 -0
  94. package/examples/workflows/human-decision.workflow.ts +62 -0
  95. package/herdr-plugin.toml +1 -1
  96. package/package.json +6 -1
  97. package/schemas/decision-presentation-v1.schema.json +83 -0
  98. package/schemas/human-decision-accepted-v1.schema.json +44 -0
  99. package/schemas/human-decision-accepted-v2.schema.json +50 -0
  100. package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
  101. package/schemas/human-decision-cancellation-v1.schema.json +15 -0
  102. package/schemas/human-decision-continuation-v1.schema.json +16 -0
  103. package/schemas/human-decision-delivery-v1.schema.json +28 -0
  104. package/schemas/human-decision-delivery-v2.schema.json +36 -0
  105. package/schemas/human-decision-receipt-v1.schema.json +33 -0
  106. package/schemas/human-decision-receipt-v2.schema.json +39 -0
  107. package/schemas/human-decision-request-v1.schema.json +59 -0
  108. package/schemas/human-decision-request-v2.schema.json +69 -0
  109. package/schemas/human-decision-resolution-v1.schema.json +27 -0
  110. package/schemas/human-decision-resolution-v2.schema.json +27 -0
  111. package/schemas/human-decision-settlement-v1.schema.json +28 -0
  112. package/skills/autodoc/SKILL.md +43 -0
  113. package/skills/autoimplement/SKILL.md +58 -0
  114. package/skills/autoimplement/agents/openai.yaml +4 -0
  115. package/skills/autoplan/SKILL.md +25 -0
  116. package/skills/monitor/SKILL.md +8 -1
  117. package/skills/pi-workflows/SKILL.md +5 -3
  118. package/src/builtins/autodoc.workflow.ts +325 -0
  119. package/src/builtins/autoimplement.workflow.ts +1123 -0
  120. package/src/builtins/autoplan.workflow.ts +231 -0
  121. package/src/builtins/catalog.ts +9 -1
  122. package/src/builtins/index.ts +32 -0
  123. package/src/builtins/monitor.workflow.ts +396 -15
  124. package/src/builtins/plan-approval.workflow.ts +190 -0
  125. package/src/builtins/plan-presentation.ts +57 -0
  126. package/src/extension/decision-channels.ts +1533 -0
  127. package/src/extension/index.ts +499 -9
  128. package/src/extension/widget.ts +39 -2
  129. package/src/extension/workflow-tool.ts +5 -81
  130. package/src/host/rpc-bridge.ts +7 -30
  131. package/src/render/graph-render.ts +54 -5
  132. package/src/viewer/render.ts +56 -3
  133. package/src/workflows/catalog.ts +7 -0
  134. package/src/workflows/composition.ts +642 -0
  135. package/src/workflows/decision-presentation.ts +501 -0
  136. package/src/workflows/decision.ts +12 -5
  137. package/src/workflows/definition.ts +118 -8
  138. package/src/workflows/engine.ts +257 -20
  139. package/src/workflows/graph.ts +8 -6
  140. package/src/workflows/human-decision.ts +819 -0
  141. package/src/workflows/index.ts +89 -0
  142. package/src/workflows/loader.ts +186 -18
  143. package/src/workflows/schema.ts +79 -2
  144. package/src/workflows/store.ts +45 -2
  145. package/src/workflows/tool-input.ts +202 -0
  146. package/src/workflows/types.ts +374 -2
  147. package/examples/workflows/elegant-solution.workflow.ts +0 -95
@@ -0,0 +1,371 @@
1
+ # Human decisions
2
+
3
+ Pi Workflows needs a reusable way to stop at a proposal and wait for a person. The same decision must appear in Pi and Telegram, and either channel must be able to continue the run. Workflows must be able to offer plain choices, choices that collect text, and choices that route back to planning.
4
+
5
+ This document defines that behavior. The implementation is tracked in the [human decision gates plan](plans/2026-08-19-human-decision-gates-plan.md).
6
+
7
+ The [human decision presentation contract](HUMAN_DECISION_PRESENTATIONS.md)
8
+ separates canonical subject data from the complete readable message shown in Pi
9
+ and Telegram. Its [implementation plan](plans/2026-08-19-human-decision-presentations-plan.md)
10
+ preserves v1 records without rewriting them.
11
+
12
+ ## Goals
13
+
14
+ A workflow author can:
15
+
16
+ - add a typed human decision without writing channel code;
17
+ - route every choice exhaustively;
18
+ - collect exact text with a choice such as `replan`;
19
+ - address a logical audience instead of a Telegram chat or Pi session;
20
+ - reuse a standard plan approval workflow; and
21
+ - rely on one accepted answer and one continuation after a crash or concurrent reply.
22
+
23
+ The feature uses the existing checkpoint execution primitive. It does not add another engine node type.
24
+
25
+ ## Authoring API
26
+
27
+ `humanDecision()` is the human counterpart to the existing model-driven `decision()` helper. It returns a checkpoint definition with a typed request and response contract.
28
+
29
+ ```typescript
30
+ import {
31
+ choice,
32
+ defineHumanChoices,
33
+ humanDecision,
34
+ humanDecisionEdge,
35
+ textInput,
36
+ } from "@osolmaz/pi-workflows";
37
+
38
+ const planChoices = defineHumanChoices({
39
+ continue: choice({ label: "Yes, continue" }),
40
+ stop: choice({ label: "No, stop" }),
41
+ replan: choice({
42
+ label: "Replan",
43
+ input: textInput({
44
+ name: "instructions",
45
+ prompt: "What should change?",
46
+ minLength: 1,
47
+ maxLength: 4_000,
48
+ }),
49
+ }),
50
+ });
51
+
52
+ export default defineWorkflow({
53
+ name: "approve-example",
54
+ startAt: "propose",
55
+ nodes: {
56
+ propose: agent({
57
+ prompt: "Prepare a proposal.",
58
+ expectedOutput: '{ "summary": "text", "plan": {} }',
59
+ }),
60
+ approve: humanDecision({
61
+ audience: "operator",
62
+ choices: planChoices,
63
+ request: ({ outputs }) => ({
64
+ title: "Approve the implementation plan",
65
+ subject: outputs.propose,
66
+ presentation: {
67
+ schema: "pi-workflows.decision-presentation.v1",
68
+ summary: "Review the implementation plan.",
69
+ blocks: [
70
+ { kind: "section", title: "Changes" },
71
+ { kind: "paragraph", text: "Implement the proposed changes." },
72
+ ],
73
+ },
74
+ }),
75
+ }),
76
+ implement: agent({ prompt: "Implement the approved proposal." }),
77
+ revise: agent({
78
+ prompt: ({ outputs }) => `Revise the plan: ${JSON.stringify(outputs.approve)}`,
79
+ }),
80
+ stopped: compute({ run: () => ({ status: "stopped" }) }),
81
+ },
82
+ edges: [
83
+ { from: "propose", to: "approve" },
84
+ humanDecisionEdge({
85
+ from: "approve",
86
+ choices: planChoices,
87
+ cases: {
88
+ continue: "implement",
89
+ stop: "stopped",
90
+ replan: "revise",
91
+ },
92
+ }),
93
+ { from: "revise", to: "approve" },
94
+ ],
95
+ });
96
+ ```
97
+
98
+ The accepted result is a discriminated union:
99
+
100
+ ```typescript
101
+ type PlanDecision =
102
+ | { choice: "continue" }
103
+ | { choice: "stop" }
104
+ | { choice: "replan"; input: { instructions: string } };
105
+ ```
106
+
107
+ `humanDecisionEdge()` requires one destination for every choice. A missing or extra case is a TypeScript error. Runtime validation applies the same choice and input contract before an answer can win.
108
+
109
+ ## Checkpoint behavior
110
+
111
+ A human decision still has `nodeType: "checkpoint"`. The helper adds a human decision contract to that checkpoint.
112
+
113
+ When the engine reaches the node, it:
114
+
115
+ 1. evaluates and validates the decision request;
116
+ 2. computes a canonical request digest from the subject, choices, audience, run, node, and attempt;
117
+ 3. writes an immutable decision request linked to the waiting run;
118
+ 4. asks the configured channels to deliver it; and
119
+ 5. parks the run in `waiting` state.
120
+
121
+ When a valid answer is accepted, the continuation keeps the workflow's original input. The human answer becomes the checkpoint node's output in the continuation. Existing checkpoints keep their current behavior, where `/workflow answer` supplies the continuation input.
122
+
123
+ This distinction preserves old workflow definitions and run bundles.
124
+
125
+ ## Request and response contracts
126
+
127
+ A new decision request contains:
128
+
129
+ - a decision ID;
130
+ - the waiting run, node, and attempt IDs;
131
+ - a short title;
132
+ - a canonical structured subject;
133
+ - a normalized human-readable presentation;
134
+ - separate subject and presentation digests;
135
+ - a positive decision revision;
136
+ - the logical audience;
137
+ - the complete choice contract;
138
+ - the canonical request digest;
139
+ - an optional expiry rule; and
140
+ - the creation time.
141
+
142
+ The presentation is an explicit display allowlist. A channel does not receive the subject and cannot infer operator text from it. The request digest binds the subject, visible presentation, title, revision, choices, and input prompts. Each choice has a stable ID and may have no input or one validated text input contract.
143
+
144
+ The former `body` form remains available for existing workflow definitions. It creates a v1 request and uses a deterministic readable compatibility formatter. Oversized historical bodies receive a bounded readable prefix and an explicit omission notice with the full body digest and size. V1 request bytes and digests do not change.
145
+
146
+ A submitted response contains:
147
+
148
+ - the decision ID and request digest;
149
+ - the selected choice;
150
+ - the validated input, when required;
151
+ - the channel and verified actor;
152
+ - a channel event ID;
153
+ - an idempotency key; and
154
+ - the receipt time.
155
+
156
+ A stale request digest, unknown choice, malformed input, unapproved actor, or model-originated answer is rejected before acceptance.
157
+
158
+ ## Human answer boundary
159
+
160
+ The model-facing `workflow` tool cannot satisfy a human decision. Its `answer` action remains available for ordinary checkpoints, but returns an error when the waiting node requires a verified human channel.
161
+
162
+ The accepted human sources are:
163
+
164
+ - the Pi interactive decision view; and
165
+ - a configured external decision channel such as Telegram.
166
+
167
+ The host assigns the source. A workflow or model cannot claim that an answer came from a person. Pi non-interactive modes can wait for Telegram, but they cannot manufacture a Pi UI answer.
168
+
169
+ ## Audiences and channel profiles
170
+
171
+ Workflows address a logical audience:
172
+
173
+ ```typescript
174
+ humanDecision({ audience: "operator", ... });
175
+ ```
176
+
177
+ Private configuration maps the audience to channels:
178
+
179
+ ```json
180
+ {
181
+ "schema": "pi-workflows.channels.v1",
182
+ "audiences": {
183
+ "operator": {
184
+ "channels": ["pi", "telegram:default"],
185
+ "accept": "first-valid-answer"
186
+ }
187
+ },
188
+ "telegramProfiles": {
189
+ "default": {
190
+ "credential": "telegram-default",
191
+ "allowedUserIds": ["<telegram-user-id>"],
192
+ "allowedChatIds": ["<telegram-chat-id>"]
193
+ }
194
+ }
195
+ }
196
+ ```
197
+
198
+ The workflow never receives a bot token, user ID, chat ID, Telegram message ID, or Pi session detail. Channel profiles are private host configuration and are excluded from run presentation.
199
+
200
+ Pi Workflows keeps credential references in a separate private file. A Telegram credential points to an existing absolute mode-`0600` token file:
201
+
202
+ ```json
203
+ {
204
+ "schema": "pi-workflows.credentials.v1",
205
+ "telegram": {
206
+ "telegram-default": {
207
+ "tokenFile": "<absolute-mode-0600-token-file>"
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ Run `/workflow-channel setup` in Pi TUI to verify and install a profile, `/workflow-channel status` to inspect whether profiles are active, and `/workflow-channel reload` after a private configuration change. Setup asks for the token file path, not the token. It updates mode-`0600` private files and does not copy the token. Token values never enter source files, workflow inputs, run bundles, logs, child environments, or model-visible tool results.
214
+
215
+ The same Unix account can read a local credential file. This design prevents accidental propagation, not a hostile same-account process. A separately owned connector can implement the same channel interface later if stronger isolation becomes necessary.
216
+
217
+ ## Channel interface
218
+
219
+ All decision channels implement one interface:
220
+
221
+ ```typescript
222
+ interface HumanDecisionChannel {
223
+ readonly id: string;
224
+ start(): Promise<void>;
225
+ deliver(request: HumanDecisionRequest): Promise<DecisionDeliveryResult>;
226
+ settle(decision: AcceptedHumanDecision | HumanDecisionCancellation): Promise<void>;
227
+ stop(): Promise<void>;
228
+ }
229
+ ```
230
+
231
+ The context exposes a narrow answer submission function. It does not expose the workflow engine, arbitrary run mutation, or another channel's credentials.
232
+
233
+ Channel delivery is independent from workflow routing. A failed Telegram delivery leaves the decision available in Pi. Audience policy decides whether one successful channel is enough or whether all configured channels are required before the request is considered delivered.
234
+
235
+ ### Pi channel
236
+
237
+ The Pi channel uses documented extension UI APIs. It shows the request, lists the choices, and opens a text editor when the selected choice requires input. The host records the interactive session as the answer source.
238
+
239
+ The waiting workflow remains durable before the UI opens. Closing Pi or cancelling the view cannot lose the checkpoint. A later Pi session can reopen pending decisions.
240
+
241
+ ### Telegram channel
242
+
243
+ The Telegram channel uses the Bot API and private profile configuration. It sends one decision message with inline buttons.
244
+
245
+ A choice without input submits from its button. A text choice such as `replan` works as follows:
246
+
247
+ 1. the operator presses **Replan**;
248
+ 2. the bot sends a `ForceReply` prompt tied to that decision and choice;
249
+ 3. the operator replies to that exact prompt;
250
+ 4. the channel verifies the numeric user ID, chat ID, reply message ID, decision ID, and request digest; and
251
+ 5. the exact received text becomes `input.instructions`.
252
+
253
+ The adapter does not infer a choice from ordinary chat text. Callback payloads contain short opaque IDs because Telegram limits callback data. The private channel store maps each opaque ID to the full decision request.
254
+
255
+ Telegram permits one long-polling consumer for a bot profile. Active Pi processes use a shared lease so one process owns polling and the others use the same private channel state. The lease owner can accept a verified reply, but only the Pi session that owns the waiting run creates its continuation. Active sessions inspect the durable accepted-answer fence and recover their own continuation. If no Pi process is running, Telegram delivery and reply collection resume when Pi starts again. Running an always-on service is outside this design.
256
+
257
+ The Bot API does not provide an idempotency key for `sendMessage`. Pi Workflows therefore writes a delivery intent before sending and never blindly retries an ambiguous send. A timed-out send is recorded as `unknown`; Pi remains available and an operator can request another delivery. This avoids automatic duplicate messages while keeping decision acceptance exactly once.
258
+
259
+ ## Durable decision records
260
+
261
+ Decision records live next to workflow run bundles under the Pi Workflows state root. They are additive and linked by run ID. A decision directory contains immutable records for:
262
+
263
+ - the request;
264
+ - channel delivery intents and results;
265
+ - answer attempts;
266
+ - the atomic accepted-or-cancelled resolution;
267
+ - the accepted answer or cancellation detail;
268
+ - channel settlement results; and
269
+ - the continuation request and result.
270
+
271
+ The resolution record uses a no-replace create. The first valid acceptance, cancellation, or expiry writer wins. Acceptance then materializes the matching accepted-answer detail. Cancellation or expiry materializes cancellation detail. A retry with the same response and idempotency key adopts the existing answer. A conflicting answer receives an `already decided` result.
272
+
273
+ The continuation run ID is derived from the accepted decision ID. Recovery adopts an existing matching continuation or creates it once. A crash after answer acceptance cannot run the next workflow step twice. The continuation run carries a redacted receipt with the decision ID, request digest, gate node ID, choice, acceptance time, and answer digest. Actor, channel, event, and idempotency provenance stays only in the private decision records and never enters the run bundle or model context.
274
+
275
+ SQLite may index pending decisions and channel leases, but immutable decision files remain the source of truth. The index is disposable and rebuildable.
276
+
277
+ ## Planning workflow composition
278
+
279
+ Pi Workflows keeps solution choice, documentation, and implementation in separate built-ins:
280
+
281
+ - `autoplan` chooses a solution or revises one after new evidence.
282
+ - `autodoc` records an already selected solution in the canonical specification and implementation plan. It does not choose a solution or implement it.
283
+ - `autoimplement` executes a clear existing plan.
284
+
285
+ `autodoc` runs by itself and as an included workflow. It returns a documented-plan record with the plan, plan digest, document paths, document digests, and check results. If the canonical documents already describe the selected plan, it adopts them without rewriting files.
286
+
287
+ `autoimplement` first finds the clear existing plan in its input, the conversation, or referenced canonical documents. It blocks when no clear plan exists. A caller can bypass discovery and autodoc only by supplying both the explicit plan and a `documentation` receipt whose plan digest matches it. A plan without that current-document evidence enters autodoc so the canonical documents are inspected and adopted or updated. The absence of a structured `plan` input never authorizes `autoplan`.
288
+
289
+ If later implementation, verification, review, comments, or CI evidence invalidates the plan, autoimplement runs `autoplan`, sends the revised plan through `autodoc`, and then resumes implementation. An optional approval policy inserts `plan-approval` after the revised documentation.
290
+
291
+ ## Reusable plan approval workflow
292
+
293
+ Pi Workflows ships a typed `plan-approval` workflow built on `humanDecision()`. Its input contains the documented plan, plan digest, audience, and display summary. It has three named exits:
294
+
295
+ - `continue`, with the approval receipt;
296
+ - `stop`, with the stop receipt; and
297
+ - `replan`, with the exact instructions and receipt.
298
+
299
+ A parent can include it without copying prompts or channel handling:
300
+
301
+ ```typescript
302
+ includes: {
303
+ approval: includeWorkflow(planApproval, {
304
+ input: ({ outputs }) => ({
305
+ plan: outputs.devise.plan,
306
+ planDigest: outputs.devise.planDigest,
307
+ audience: "operator",
308
+ }),
309
+ }),
310
+ },
311
+ ```
312
+
313
+ A `replan` exit returns to `autoplan` with the previous plan and the exact human instructions as new evidence. The revised plan passes through `autodoc`, receives a new digest, and enters a new approval decision. Step limits bound repeated replanning.
314
+
315
+ Monitor repair composes `autoplan`, `autodoc`, optional `plan-approval`, `autoimplement`, and a fresh target check. Autoimplement can mount the same approval workflow after evidence-driven redesign. Existing behavior stays unchanged when no approval policy is present.
316
+
317
+ ## Recovery and cancellation
318
+
319
+ Recovery follows these rules:
320
+
321
+ - missing channel deliveries can be attempted;
322
+ - confirmed deliveries are adopted;
323
+ - Telegram records intent and confirmed evidence for every multipart message;
324
+ - Telegram resumes only when the next part is provably unsent;
325
+ - ambiguous Telegram sends are not retried automatically;
326
+ - duplicate channel updates are harmless;
327
+ - stale responses are rejected;
328
+ - one accepted response creates one continuation;
329
+ - the winning channel dismisses any pending Pi dialog;
330
+ - confirmed channel settlement is adopted without another remote call;
331
+ - failed channel settlement has a bounded retry count and cannot create an unbounded record loop; and
332
+ - cancellation resolves the owned waiting decision from durable state, even after restart, closes pending views, and prevents a later answer from continuing the run.
333
+
334
+ A decision with no available channel remains waiting and reports the configuration problem. It does not silently continue or choose a default.
335
+
336
+ ## Compatibility
337
+
338
+ Ordinary checkpoints, their continuation input behavior, and existing run bundles remain unchanged. V2 requests, accepted records, receipts, resolutions, and multipart delivery records are additive. Older viewers ignore them. Updated viewers label a human decision as a checkpoint, show the readable presentation and its fingerprint, and keep the canonical subject separate. Private channel configuration and transport identifiers remain hidden.
339
+
340
+ The engine remains independent from Pi and Telegram. Core code owns decision contracts, validation, durable acceptance, and continuation. The Pi extension owns UI and channel lifecycle. The Telegram adapter owns Bot API translation. Workflow definitions own only the question, choices, audience, and routes.
341
+
342
+ ## Contract impact
343
+
344
+ - **Session state:** Pi records normal workflow messages and interactive decision results.
345
+ - **Other persistent data:** additive decision records, a rebuildable private channel index, and private channel configuration.
346
+ - **Pi internals:** none.
347
+ - **Public Pi API:** documented extension lifecycle and UI methods only.
348
+ - **Public Pi Workflows API:** typed human choices, `humanDecision()`, `humanDecisionEdge()`, the channel interface, and the `plan-approval` workflow.
349
+
350
+ ## Verification requirements
351
+
352
+ The implementation must test:
353
+
354
+ - compile-time exhaustive choice routing;
355
+ - choices with no input and choices with exact text input;
356
+ - runtime choice and input validation;
357
+ - legacy checkpoint continuation;
358
+ - model-tool answer rejection;
359
+ - Pi interactive answers;
360
+ - Telegram callbacks and reply binding with a fake Bot API;
361
+ - unauthorized users and chats;
362
+ - stale request digests;
363
+ - concurrent Pi and Telegram answers;
364
+ - identical and conflicting retries;
365
+ - crashes before and after answer acceptance and continuation creation;
366
+ - ambiguous Telegram sends;
367
+ - long-poll lease handoff between Pi processes;
368
+ - decision cancellation and expiry;
369
+ - included `plan-approval` routes and bounded replan loops;
370
+ - viewer redaction; and
371
+ - real Pi execution without real Telegram credentials or network calls.
@@ -0,0 +1,323 @@
1
+ # Human decision presentations
2
+
3
+ This contract is implemented. New human decision requests separate their canonical
4
+ subject from the complete readable message shown to an operator. Historical requests
5
+ that use `body` remain compatible through a deterministic readable formatter.
6
+ The implementation plan is in
7
+ [the human decision presentations plan](plans/2026-08-19-human-decision-presentations-plan.md).
8
+
9
+ A human decision contains machine data and a separate message for the operator.
10
+ Pi Workflows stores and validates the machine data. Pi and Telegram render only
11
+ the operator message, and future channels follow the same rule.
12
+
13
+ ## Minimal example
14
+
15
+ ```typescript
16
+ approve: humanDecision({
17
+ audience: "operator",
18
+ choices,
19
+ request: () => ({
20
+ title: "Approve the implementation plan",
21
+ subject: {
22
+ task: "Add readable human decisions",
23
+ plan: selectedPlan,
24
+ planDigest,
25
+ revision: 1,
26
+ },
27
+ presentation: {
28
+ schema: "pi-workflows.decision-presentation.v1",
29
+ summary: "Replace raw JSON decisions with readable messages.",
30
+ blocks: [
31
+ { kind: "section", title: "Changes" },
32
+ {
33
+ kind: "bullets",
34
+ items: [
35
+ "Keep structured data as the durable source.",
36
+ "Show the same readable message in Pi and Telegram.",
37
+ ],
38
+ },
39
+ {
40
+ kind: "fields",
41
+ items: [{ label: "Plan revision", value: "1" }],
42
+ },
43
+ ],
44
+ },
45
+ }),
46
+ });
47
+ ```
48
+
49
+ The operator sees the summary and sections along with lists and fields. The operator does
50
+ not see `subject` unless the workflow explicitly copies selected text into
51
+ `presentation`.
52
+
53
+ ## Request contract
54
+
55
+ A new request uses `pi-workflows.human-decision-request.v2`. It contains:
56
+
57
+ | Field | Required | Meaning |
58
+ | -------------------- | -------- | ----------------------------------------------- |
59
+ | `title` | Yes | Short decision title shown by every channel. |
60
+ | `subject` | Yes | Canonical JSON data used by the workflow. |
61
+ | `presentation` | Yes | Human-readable content defined below. |
62
+ | `audience` | Yes | Named audience resolved by the host. |
63
+ | `choices` | Yes | Typed choices and optional input contracts. |
64
+ | `revision` | Yes | Positive decision revision. |
65
+ | `subjectDigest` | Yes | SHA-256 digest of the canonical subject. |
66
+ | `presentationDigest` | Yes | SHA-256 digest of the normalized presentation. |
67
+ | `requestDigest` | Yes | SHA-256 digest that binds the complete request. |
68
+
69
+ The runtime adds the usual run binding and lifecycle fields as it does for
70
+ current decisions.
71
+
72
+ A request that supplies `subject` without `presentation` is invalid. A channel
73
+ must not derive a presentation from `subject`.
74
+
75
+ ## Presentation contract
76
+
77
+ `pi-workflows.decision-presentation.v1` is a small ordered document:
78
+
79
+ ```typescript
80
+ type DecisionPresentation = {
81
+ schema: "pi-workflows.decision-presentation.v1";
82
+ summary: string;
83
+ blocks: DecisionPresentationBlock[];
84
+ };
85
+ ```
86
+
87
+ `summary` is required and non-empty. Every channel shows it first. `blocks` can be empty. Block
88
+ order is meaningful and every channel preserves it.
89
+
90
+ ### Blocks
91
+
92
+ The first version supports five flat block types.
93
+
94
+ #### Paragraph
95
+
96
+ ```json
97
+ { "kind": "paragraph", "text": "This change affects active workflows." }
98
+ ```
99
+
100
+ #### Section
101
+
102
+ ```json
103
+ { "kind": "section", "title": "Verification" }
104
+ ```
105
+
106
+ A section labels the blocks that follow it until the next section. Sections do
107
+ not contain nested blocks.
108
+
109
+ #### Bullets
110
+
111
+ ```json
112
+ {
113
+ "kind": "bullets",
114
+ "items": ["Run the unit tests.", "Run the real-Pi test."]
115
+ }
116
+ ```
117
+
118
+ #### Fields
119
+
120
+ ```json
121
+ {
122
+ "kind": "fields",
123
+ "items": [
124
+ { "label": "Repository", "value": "pi-workflows" },
125
+ { "label": "Action", "value": "Update the decision contract" }
126
+ ]
127
+ }
128
+ ```
129
+
130
+ #### Preformatted text
131
+
132
+ ```json
133
+ {
134
+ "kind": "preformatted",
135
+ "text": "npm run check\nnpm run test:e2e"
136
+ }
137
+ ```
138
+
139
+ Preformatted text preserves spacing and line breaks. It does not permit HTML,
140
+ Markdown, terminal escape sequences, or executable channel instructions.
141
+
142
+ ## Validation and normalization
143
+
144
+ The authoring API validates the presentation before it creates a pending
145
+ request.
146
+
147
+ - Unknown fields and unknown block kinds are invalid.
148
+ - Display values are strings. Arbitrary nested objects are invalid.
149
+ - The summary, section titles, labels, bullet items, and field values must not
150
+ be empty after whitespace checks.
151
+ - The normalizer converts CRLF and CR line endings to LF.
152
+ - The normalizer preserves block order, item order, Unicode text, and other
153
+ spacing.
154
+ - Terminal control characters are invalid. Paragraph and preformatted text can
155
+ contain LF and tab. Titles and labels plus bullet items cannot contain control
156
+ characters.
157
+ - The complete normalized presentation is limited to 64,000 UTF-16 code units.
158
+ - A presentation can contain at most 256 blocks.
159
+ - One bullets or fields block can contain at most 256 items.
160
+ - One string can contain at most 16,000 UTF-16 code units.
161
+ - A renderer can produce at most 20 transport parts for one recipient.
162
+
163
+ If a presentation exceeds a limit, request creation fails with the exact limit
164
+ and observed value. The runtime does not create a pending request and does not
165
+ send a partial message.
166
+
167
+ ## Digests
168
+
169
+ The runtime uses canonical JSON with a trailing newline, as defined by the run
170
+ bundle contract.
171
+
172
+ ```text
173
+ subjectDigest = sha256(canonicalJson(subject))
174
+ presentationDigest = sha256(canonicalJson(normalizedPresentation))
175
+ requestDigest = sha256(canonicalJson(requestBasis))
176
+ ```
177
+
178
+ `requestBasis` contains the request schema and run binding. It also contains the
179
+ audience, title, subject, normalized presentation, choices, typed input
180
+ contracts, revision and optional expiry. It does not contain the three digest
181
+ fields or the derived decision ID.
182
+
183
+ An answer must match the exact request revision and request digest. A change to
184
+ only the subject, visible text, choice label, or input prompt makes an older
185
+ answer stale.
186
+
187
+ Channels can show a short prefix of `presentationDigest` so an operator can
188
+ compare the decision in Pi and Telegram. The viewer shows the same fingerprint.
189
+ The complete digest remains in the durable request and accepted answer evidence.
190
+
191
+ ## Channel rendering
192
+
193
+ The workflow creates one presentation. It does not contain channel names,
194
+ Telegram markup, terminal colors, message limits, buttons, or callback data.
195
+
196
+ Each channel can change spacing and wrapping as well as styling, navigation, or
197
+ controls. It must preserve the title and summary together with block order and
198
+ contents. It must also preserve every choice and input prompt. A channel must
199
+ not omit or reinterpret approval-relevant content.
200
+
201
+ ### Telegram
202
+
203
+ Telegram uses plain text without a parse mode. Text that resembles HTML,
204
+ Markdown, a command, or a mention remains inert text.
205
+
206
+ The renderer:
207
+
208
+ 1. Renders the complete normalized presentation.
209
+ 2. Splits at block and paragraph boundaries before splitting a line.
210
+ 3. Splits a long line at Unicode-safe boundaries only when required.
211
+ 4. Reserves space for `Part n/N` and a short presentation fingerprint.
212
+ 5. Keeps every Telegram message below the Telegram limit.
213
+ 6. Adds choice buttons only to the final part.
214
+ 7. Stores durable part indexes and content digests.
215
+ 8. Keeps chat and message IDs only in the private channel projection.
216
+
217
+ The renderer never adds an ellipsis in place of omitted decision content.
218
+
219
+ The channel records intent before sending. It records each part after an
220
+ unambiguous response. If a send result is ambiguous, it marks delivery unknown
221
+ and does not retry that part or later parts automatically. Another configured
222
+ channel can still answer the decision.
223
+
224
+ ### Pi
225
+
226
+ Pi uses the documented extension TUI API. A custom decision component shows the
227
+ complete presentation and fingerprint together with the choices and optional
228
+ text prompt. It wraps and scrolls while responding to resize, theme, cancellation,
229
+ and `AbortSignal` events.
230
+
231
+ When Telegram or another channel accepts the decision, the signal closes the Pi
232
+ dialog. Pi Workflows does not modify Pi core or use undocumented TUI state.
233
+
234
+ ### Other channels
235
+
236
+ A new channel consumes the normalized presentation and typed interaction
237
+ contract. It must pass the same conformance tests. It cannot access the subject
238
+ for display.
239
+
240
+ ## Plan presentation
241
+
242
+ Pi Workflows provides a reusable plan presenter for built-in workflows. It
243
+ derives a presentation from the same typed plan stored as the subject.
244
+
245
+ The presenter uses these sections when data exists:
246
+
247
+ 1. Goal
248
+ 2. Proposed changes
249
+ 3. Verification
250
+ 4. Boundaries
251
+ 5. Risks and mitigations
252
+ 6. Evidence
253
+
254
+ It omits absent sections. It never serializes the plan object. `plan-approval`,
255
+ `monitor`, `autoplan`, `autodoc`, and `autoimplement` reuse this presenter where
256
+ they ask a person to approve a plan.
257
+
258
+ ## Compatibility
259
+
260
+ Current v1 requests remain immutable.
261
+
262
+ - A v1 string body becomes one readable paragraph at delivery time.
263
+ - A v1 object body uses a deterministic compatibility formatter. It converts
264
+ stable key order into readable labels and fields with sections and lists.
265
+ - An oversized v1 body remains answerable. The formatter keeps a bounded readable
266
+ prefix and adds an explicit omission notice with the full body digest and size.
267
+ It never hides omitted content behind an ellipsis.
268
+ - The compatibility formatter reads only the historical `body`, which was
269
+ already the display field. It never reads a new structured subject.
270
+ - V1 request bytes and digests do not change.
271
+ - Pending and accepted v1 decisions continue to use v1 validation and digest
272
+ rules.
273
+ - New preferred authoring emits v2. The existing body form remains a deprecated
274
+ compatibility overload until a separate removal is approved.
275
+
276
+ No migration rewrites run bundles or decision records.
277
+
278
+ ## Privacy and security
279
+
280
+ The presentation is an explicit display allowlist. Data does not become visible
281
+ because it exists in the subject.
282
+
283
+ Channel adapters do not receive the subject. Durable public records do not
284
+ contain Telegram chat IDs, message IDs, bot tokens, credential profile values,
285
+ or private audience configuration. The private channel projection retains only
286
+ the transport identifiers required for recovery and settlement.
287
+
288
+ Workflow authors remain responsible for not putting a secret in the explicit
289
+ presentation. Renderers treat all text as untrusted and inert.
290
+
291
+ ## Failure behavior
292
+
293
+ - Invalid or excessive content fails before a request becomes pending.
294
+ - A renderer that cannot represent every block fails closed.
295
+ - Confirmed multipart delivery requires a receipt for every part.
296
+ - Ambiguous Telegram delivery remains unknown and is not retried blindly.
297
+ - A channel failure does not choose a default answer.
298
+ - Rules for the first valid answer and stale answers remain unchanged, as do
299
+ cancellation and settlement rules together with exactly-once continuation.
300
+
301
+ ## Verification
302
+
303
+ Implementation must pass:
304
+
305
+ ```bash
306
+ npm run check
307
+ npm run test:e2e
308
+ cargo test --manifest-path tui/Cargo.toml
309
+ npx slophammer-ts@latest dry .
310
+ npx slophammer-ts@latest check . --only ts.dependency-boundaries-required
311
+ npx -y @simpledoc/simpledoc check
312
+ ```
313
+
314
+ Tests must cover schema validation, canonical digests, readable legacy bodies,
315
+ plan rendering, Pi and Telegram content parity, unsafe text, Unicode, multipart
316
+ delivery, ambiguous sends, recovery, stale answers, viewer output, and proof
317
+ that no channel serializes a subject as JSON.
318
+
319
+ ## Boundaries
320
+
321
+ This contract does not add a new workflow node, template language, rich text
322
+ framework, translation service, delivery relay, Telegram resource, credential,
323
+ or Pi core change.