@cat-factory/gatekeeper-worker 0.3.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/dist/approvals.d.ts +90 -0
  4. package/dist/approvals.d.ts.map +1 -0
  5. package/dist/approvals.js +193 -0
  6. package/dist/approvals.js.map +1 -0
  7. package/dist/capability.d.ts +61 -0
  8. package/dist/capability.d.ts.map +1 -0
  9. package/dist/capability.js +125 -0
  10. package/dist/capability.js.map +1 -0
  11. package/dist/env.d.ts +45 -0
  12. package/dist/env.d.ts.map +1 -0
  13. package/dist/env.js +90 -0
  14. package/dist/env.js.map +1 -0
  15. package/dist/errors.d.ts +22 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +30 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/gatekeeper.d.ts +78 -0
  20. package/dist/gatekeeper.d.ts.map +1 -0
  21. package/dist/gatekeeper.js +162 -0
  22. package/dist/gatekeeper.js.map +1 -0
  23. package/dist/index.d.ts +11 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +23 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/keys.d.ts +62 -0
  28. package/dist/keys.d.ts.map +1 -0
  29. package/dist/keys.js +152 -0
  30. package/dist/keys.js.map +1 -0
  31. package/dist/masking.d.ts +9 -0
  32. package/dist/masking.d.ts.map +1 -0
  33. package/dist/masking.js +43 -0
  34. package/dist/masking.js.map +1 -0
  35. package/dist/policy/compile.d.ts +88 -0
  36. package/dist/policy/compile.d.ts.map +1 -0
  37. package/dist/policy/compile.js +170 -0
  38. package/dist/policy/compile.js.map +1 -0
  39. package/dist/policy/decisions.d.ts +93 -0
  40. package/dist/policy/decisions.d.ts.map +1 -0
  41. package/dist/policy/decisions.js +659 -0
  42. package/dist/policy/decisions.js.map +1 -0
  43. package/dist/policy/index.d.ts +6 -0
  44. package/dist/policy/index.d.ts.map +1 -0
  45. package/dist/policy/index.js +17 -0
  46. package/dist/policy/index.js.map +1 -0
  47. package/dist/state.d.ts +150 -0
  48. package/dist/state.d.ts.map +1 -0
  49. package/dist/state.js +229 -0
  50. package/dist/state.js.map +1 -0
  51. package/dist/webhook/delivery.d.ts +102 -0
  52. package/dist/webhook/delivery.d.ts.map +1 -0
  53. package/dist/webhook/delivery.js +162 -0
  54. package/dist/webhook/delivery.js.map +1 -0
  55. package/dist/webhook/signature.d.ts +17 -0
  56. package/dist/webhook/signature.d.ts.map +1 -0
  57. package/dist/webhook/signature.js +73 -0
  58. package/dist/webhook/signature.js.map +1 -0
  59. package/dist/worker.d.ts +21 -0
  60. package/dist/worker.d.ts.map +1 -0
  61. package/dist/worker.js +155 -0
  62. package/dist/worker.js.map +1 -0
  63. package/package.json +62 -0
@@ -0,0 +1,659 @@
1
+ // Every park the public surface can answer, as data.
2
+ //
3
+ // A run can stop on THIRTEEN different decision kinds, not one. The first cut of this Gatekeeper
4
+ // understood only `approval-gate`, so a card raised for a requirements review, a fork, a judge
5
+ // verdict or a follow-up triage arrived in the inbox and could never be answered from it: the
6
+ // answer path looked for a pending approval gate, found none, and reported the card stale while
7
+ // the run stayed blocked. The gap was invisible because a card that reports `stale` looks like a
8
+ // card whose run moved on.
9
+ //
10
+ // So the answer path does not know any kind. It reads the run's live decision list, finds the
11
+ // entry that is actually holding the run, and looks up the ANSWERER for its `kind` here. Adding a
12
+ // park to the platform means adding an entry below; there is no `switch` anywhere else, and
13
+ // the table is a `Record` the SDK's own kind union keys, so a kind the platform gains and this
14
+ // table lacks fails the build rather than shipping as a card nobody can answer.
15
+ //
16
+ // Two rules bind every entry:
17
+ //
18
+ // - A VERB FORWARDS THROUGH A BINDING, never through a client of its own. `binding` names the
19
+ // operation, the capability's `invoke` resolves it against what policy granted, and a tier
20
+ // that was not granted it is refused there. The answer flow has no privilege.
21
+ // - A MISSING INPUT IS A REFUSAL, never a default. `request-changes` with no feedback used to
22
+ // forward an empty string, which the API rejects with a 422 the caller cannot read; a gate at
23
+ // its rework cap used to default to `proceed`, silently picking the one settlement nobody
24
+ // asked for. Every required field is declared and named back at the caller.
25
+ import { GatekeeperError } from '../errors.js';
26
+ // ---- Reading the caller's fields ------------------------------------------------------------
27
+ function refuse(field, expected) {
28
+ throw new GatekeeperError('invalid_answer', `This answer needs '${field}': ${expected}. Read the verb's own \`fields\` from ` +
29
+ 'approvals_inspect() rather than guessing; the platform refuses a blank one with a 422 ' +
30
+ 'that says less than this does.');
31
+ }
32
+ /** A required non-blank string. */
33
+ function text(input, field, expected) {
34
+ const value = input[field];
35
+ if (typeof value !== 'string' || value.trim().length === 0)
36
+ refuse(field, expected);
37
+ return value;
38
+ }
39
+ /** An optional string. Absent stays absent: an empty one is a value the API would reject. */
40
+ function optionalText(input, field) {
41
+ const value = input[field];
42
+ return typeof value === 'string' && value.length > 0 ? { [field]: value } : {};
43
+ }
44
+ /** A required member of a closed set. */
45
+ function choice(input, field, allowed) {
46
+ const value = input[field];
47
+ if (typeof value !== 'string' || !allowed.includes(value)) {
48
+ refuse(field, `one of ${allowed.map((option) => `'${option}'`).join(', ')}`);
49
+ }
50
+ return value;
51
+ }
52
+ /** A required id the caller picks out of the decision (an item, a finding, a question). */
53
+ function pick(input, field, from) {
54
+ return text(input, field, `the id of the ${from} this answer addresses`);
55
+ }
56
+ /** A string field of the live decision, or a refusal naming what the platform did not send. */
57
+ function fromDecision(decision, field) {
58
+ const value = decision[field];
59
+ if (typeof value !== 'string' || value.length === 0) {
60
+ throw new GatekeeperError('malformed_decision', `The parked '${decision.kind}' decision carries no '${field}', which every answer to it has ` +
61
+ 'to address. This is a platform-side shape this Gatekeeper does not recognise; upgrade it ' +
62
+ 'if the deployment is newer, and report it if not.');
63
+ }
64
+ return value;
65
+ }
66
+ // ---- Field declarations, shared where the platform shares a body --------------------------
67
+ const ITERATION_CAP_CHOICES = ['extra-round', 'proceed', 'stop-reset'];
68
+ const CAP_FIELDS = [
69
+ {
70
+ name: 'choice',
71
+ required: true,
72
+ choices: ITERATION_CAP_CHOICES,
73
+ detail: 'One more pass, proceed with what the last pass produced, or stop and reset the task. ' +
74
+ 'There is deliberately no default: a parked run waits indefinitely, so picking one for you ' +
75
+ 'would ship work nobody approved.',
76
+ },
77
+ ];
78
+ const FINDINGS_FIELDS = [
79
+ {
80
+ name: 'findings',
81
+ required: true,
82
+ detail: 'What is wrong. This text IS the prompt the fixer works from, so it cannot be blank.',
83
+ },
84
+ ];
85
+ function capVerb(action, binding, summary) {
86
+ return {
87
+ action,
88
+ binding,
89
+ summary,
90
+ fields: CAP_FIELDS,
91
+ call: (decision, input) => ({
92
+ binding,
93
+ args: {
94
+ ...pathOf(decision),
95
+ body: { choice: choice(input, 'choice', ITERATION_CAP_CHOICES) },
96
+ },
97
+ }),
98
+ };
99
+ }
100
+ /**
101
+ * The path arguments an entry contributes to every call against it.
102
+ *
103
+ * Only `brainstorm` has one, and it is why this exists rather than being inlined: the stage is
104
+ * part of the ROUTE for all six brainstorm verbs, and a block can hold a `requirements` and an
105
+ * `architecture` session at once, so answering without it would settle whichever the platform
106
+ * reached first.
107
+ */
108
+ function pathOf(decision) {
109
+ return decision.kind === 'brainstorm' ? { stage: fromDecision(decision, 'stage') } : {};
110
+ }
111
+ /** The shared verb set of the three iterative review loops (requirements / clarity / brainstorm). */
112
+ function reviewVerbs(bindings) {
113
+ return [
114
+ {
115
+ action: 'reply',
116
+ binding: bindings.reply,
117
+ summary: `Answer one ${bindings.item}. This is what an incorporation folds in.`,
118
+ fields: [
119
+ { name: 'itemId', required: true, detail: `The ${bindings.item}'s stable id.` },
120
+ { name: 'reply', required: true, detail: 'The answer, in prose.' },
121
+ ],
122
+ call: (decision, input) => ({
123
+ binding: bindings.reply,
124
+ args: {
125
+ ...pathOf(decision),
126
+ itemId: pick(input, 'itemId', bindings.item),
127
+ body: { reply: text(input, 'reply', 'the answer to fold in') },
128
+ },
129
+ }),
130
+ },
131
+ {
132
+ action: 'set-status',
133
+ binding: bindings.setStatus,
134
+ summary: `Dismiss a ${bindings.item} as not applicable, or reopen one dismissed by mistake.`,
135
+ fields: [
136
+ { name: 'itemId', required: true, detail: `The ${bindings.item}'s stable id.` },
137
+ {
138
+ name: 'status',
139
+ required: true,
140
+ choices: ['dismissed', 'open'],
141
+ detail: 'The new status.',
142
+ },
143
+ ],
144
+ call: (decision, input) => ({
145
+ binding: bindings.setStatus,
146
+ args: {
147
+ ...pathOf(decision),
148
+ itemId: pick(input, 'itemId', bindings.item),
149
+ body: { status: choice(input, 'status', ['dismissed', 'open']) },
150
+ },
151
+ }),
152
+ },
153
+ {
154
+ action: 'incorporate',
155
+ binding: bindings.incorporate,
156
+ summary: 'Fold the recorded answers into one standardized document and re-review it. Asynchronous: ' +
157
+ 'the run stays parked until the next pass lands.',
158
+ fields: [
159
+ { name: 'feedback', required: false, detail: 'Optional "do it differently" steer.' },
160
+ ],
161
+ call: (decision, input) => ({
162
+ binding: bindings.incorporate,
163
+ args: { ...pathOf(decision), body: optionalText(input, 'feedback') },
164
+ }),
165
+ },
166
+ {
167
+ action: 're-review',
168
+ binding: bindings.reReview,
169
+ summary: 'Run another reviewer pass over the subject as it now stands.',
170
+ fields: [],
171
+ call: (decision) => ({ binding: bindings.reReview, args: pathOf(decision) }),
172
+ },
173
+ {
174
+ action: 'proceed',
175
+ binding: bindings.proceed,
176
+ summary: 'Accept the subject as it stands and let the run advance.',
177
+ fields: [],
178
+ call: (decision) => ({ binding: bindings.proceed, args: pathOf(decision) }),
179
+ },
180
+ capVerb('resolve-exceeded', bindings.resolveExceeded, 'Settle a loop that spent its reviewer-pass budget.'),
181
+ ];
182
+ }
183
+ /** Whether an iterative review has stopped on a person. */
184
+ function reviewPending(decision) {
185
+ return decision.status === 'ready' || decision.status === 'exceeded';
186
+ }
187
+ // ---- The table ------------------------------------------------------------------------------
188
+ /**
189
+ * Every park, keyed by the kind that names it.
190
+ *
191
+ * `satisfies Record<ParkedDecisionKind, DecisionAnswerer>` is what makes this exhaustive AND
192
+ * closed: a kind the platform adds fails the build here, and a key this file invents that the
193
+ * surface does not have fails it too. Both matter, because either one produces the same symptom
194
+ * in production (a park nobody can answer) from opposite causes.
195
+ */
196
+ const ANSWERERS = {
197
+ 'approval-gate': {
198
+ summary: 'A pipeline step finished and the run is holding its output in front of a person.',
199
+ pending: (decision) => decision.status === 'pending',
200
+ verbs: [
201
+ {
202
+ action: 'approve',
203
+ binding: 'decisions_approve_step',
204
+ summary: 'Let the output through, optionally replacing it with an edited proposal.',
205
+ fields: [
206
+ {
207
+ name: 'proposal',
208
+ required: false,
209
+ detail: 'Replaces the agent’s text and is what flows downstream. Omit to accept as written.',
210
+ },
211
+ ],
212
+ call: (decision, input) => ({
213
+ binding: 'decisions_approve_step',
214
+ args: {
215
+ approvalId: fromDecision(decision, 'approvalId'),
216
+ body: optionalText(input, 'proposal'),
217
+ },
218
+ }),
219
+ },
220
+ {
221
+ action: 'request-changes',
222
+ binding: 'decisions_request_step_changes',
223
+ summary: 'Send the step back to re-run with this guidance folded in.',
224
+ fields: [
225
+ {
226
+ name: 'feedback',
227
+ required: true,
228
+ detail: 'The guidance the re-run works from. The platform refuses a blank one.',
229
+ },
230
+ ],
231
+ call: (decision, input) => ({
232
+ binding: 'decisions_request_step_changes',
233
+ args: {
234
+ approvalId: fromDecision(decision, 'approvalId'),
235
+ body: { feedback: text(input, 'feedback', 'the guidance the re-run works from') },
236
+ },
237
+ }),
238
+ },
239
+ {
240
+ action: 'reject',
241
+ binding: 'decisions_reject_step',
242
+ summary: 'Stop the run entirely. Terminal, though the board can retry it.',
243
+ fields: [{ name: 'reason', required: false, detail: 'Why the run is being stopped.' }],
244
+ call: (decision, input) => ({
245
+ binding: 'decisions_reject_step',
246
+ args: {
247
+ approvalId: fromDecision(decision, 'approvalId'),
248
+ body: optionalText(input, 'reason'),
249
+ },
250
+ }),
251
+ },
252
+ {
253
+ action: 'resolve-exceeded',
254
+ binding: 'decisions_resolve_step_exceeded',
255
+ summary: 'Settle a companion gate parked at its automatic-rework cap.',
256
+ fields: CAP_FIELDS,
257
+ call: (decision, input) => ({
258
+ binding: 'decisions_resolve_step_exceeded',
259
+ args: {
260
+ approvalId: fromDecision(decision, 'approvalId'),
261
+ body: { choice: choice(input, 'choice', ITERATION_CAP_CHOICES) },
262
+ },
263
+ }),
264
+ },
265
+ ],
266
+ },
267
+ 'agent-decision': {
268
+ summary: 'Mid-work the agent hit a fork it would not choose unilaterally and asked.',
269
+ // The projection carries no lifecycle field: an answered decision is not listed at all, so an
270
+ // entry that is present is one that is waiting.
271
+ pending: () => true,
272
+ verbs: [
273
+ {
274
+ action: 'answer',
275
+ binding: 'decisions_answer_agent_decision',
276
+ summary: 'Answer the question. The asking step re-runs with the choice folded in.',
277
+ fields: [
278
+ {
279
+ name: 'choice',
280
+ required: true,
281
+ detail: 'Taken verbatim, so it need not be one of the offered `options`, but answering ' +
282
+ 'off-list hands the agent an approach it did not propose.',
283
+ },
284
+ ],
285
+ call: (decision, input) => ({
286
+ binding: 'decisions_answer_agent_decision',
287
+ args: {
288
+ decisionId: fromDecision(decision, 'decisionId'),
289
+ body: { choice: text(input, 'choice', 'the answer the step re-runs with') },
290
+ },
291
+ }),
292
+ },
293
+ ],
294
+ },
295
+ fork: {
296
+ summary: 'Materially different implementations were proposed before any code was written.',
297
+ pending: (decision) => decision.status === 'awaiting_choice',
298
+ verbs: [
299
+ {
300
+ action: 'choose',
301
+ binding: 'decisions_choose_fork',
302
+ summary: 'Pick a proposed approach, or supply your own.',
303
+ fields: [
304
+ {
305
+ name: 'forkId',
306
+ required: false,
307
+ detail: 'One of the proposed forks. Exclusive with `custom`.',
308
+ },
309
+ {
310
+ name: 'custom',
311
+ required: false,
312
+ detail: 'Your own approach, in prose. Exclusive with `forkId`.',
313
+ },
314
+ { name: 'note', required: false, detail: 'A steering note on a picked fork.' },
315
+ ],
316
+ call: (_decision, input) => {
317
+ const forkId = optionalText(input, 'forkId');
318
+ const custom = optionalText(input, 'custom');
319
+ if ('forkId' in forkId === 'custom' in custom) {
320
+ refuse('forkId', 'exactly one of `forkId` or `custom` (the platform enforces the same xor)');
321
+ }
322
+ return {
323
+ binding: 'decisions_choose_fork',
324
+ args: { body: { ...forkId, ...custom, ...optionalText(input, 'note') } },
325
+ };
326
+ },
327
+ },
328
+ ],
329
+ },
330
+ judge: {
331
+ summary: 'A rubric scored the work below the task’s threshold and the run stopped.',
332
+ pending: (decision) => decision.status === 'awaiting_decision',
333
+ verbs: [
334
+ {
335
+ action: 'resolve',
336
+ binding: 'decisions_resolve_judge',
337
+ summary: 'Proceed anyway, bounce the work back for rework, or stop the run.',
338
+ fields: [
339
+ {
340
+ name: 'choice',
341
+ required: true,
342
+ choices: ['proceed', 'bounce', 'stop'],
343
+ detail: 'What to do with the verdict.',
344
+ },
345
+ { name: 'feedback', required: false, detail: 'Guidance a `bounce` re-runs with.' },
346
+ ],
347
+ call: (_decision, input) => ({
348
+ binding: 'decisions_resolve_judge',
349
+ args: {
350
+ body: {
351
+ choice: choice(input, 'choice', ['proceed', 'bounce', 'stop']),
352
+ ...optionalText(input, 'feedback'),
353
+ },
354
+ },
355
+ }),
356
+ },
357
+ ],
358
+ },
359
+ 'input-gate': {
360
+ summary: 'The task states nothing an agent could act on; the run stopped before its first dispatch.',
361
+ pending: (decision) => decision.status === 'blocked',
362
+ verbs: [
363
+ {
364
+ action: 'resolve',
365
+ binding: 'decisions_resolve_input_gate',
366
+ summary: 'Re-check the task as it now stands (fix it over tasks_update first), or waive the findings.',
367
+ fields: [
368
+ {
369
+ name: 'choice',
370
+ required: true,
371
+ choices: ['recheck', 'proceed'],
372
+ detail: '`recheck` re-evaluates; `proceed` waives the findings and records who did it.',
373
+ },
374
+ ],
375
+ call: (_decision, input) => ({
376
+ binding: 'decisions_resolve_input_gate',
377
+ args: { body: { choice: choice(input, 'choice', ['recheck', 'proceed']) } },
378
+ }),
379
+ },
380
+ ],
381
+ },
382
+ 'requirements-review': {
383
+ summary: 'The reviewer raised questions about the requirements and the run is waiting on them.',
384
+ pending: reviewPending,
385
+ verbs: reviewVerbs({
386
+ reply: 'decisions_reply_to_finding',
387
+ setStatus: 'decisions_set_finding_status',
388
+ incorporate: 'decisions_incorporate',
389
+ reReview: 'decisions_re_review',
390
+ proceed: 'decisions_proceed',
391
+ resolveExceeded: 'decisions_resolve_exceeded',
392
+ item: 'finding',
393
+ }),
394
+ },
395
+ 'clarity-review': {
396
+ summary: 'The reviewer asked whether the bug report is actually fixable.',
397
+ pending: reviewPending,
398
+ verbs: reviewVerbs({
399
+ reply: 'decisions_reply_to_clarity_finding',
400
+ setStatus: 'decisions_set_clarity_finding_status',
401
+ incorporate: 'decisions_incorporate_clarity',
402
+ reReview: 'decisions_re_review_clarity',
403
+ proceed: 'decisions_proceed_clarity',
404
+ resolveExceeded: 'decisions_resolve_clarity_exceeded',
405
+ item: 'finding',
406
+ }),
407
+ },
408
+ brainstorm: {
409
+ summary: 'The agent proposed directions and the run is waiting for someone to pick and steer.',
410
+ pending: reviewPending,
411
+ verbs: reviewVerbs({
412
+ reply: 'decisions_reply_to_brainstorm_option',
413
+ setStatus: 'decisions_set_brainstorm_option_status',
414
+ incorporate: 'decisions_incorporate_brainstorm',
415
+ reReview: 'decisions_re_review_brainstorm',
416
+ proceed: 'decisions_proceed_brainstorm',
417
+ resolveExceeded: 'decisions_resolve_brainstorm_exceeded',
418
+ item: 'option',
419
+ }),
420
+ },
421
+ 'pr-review': {
422
+ summary: 'A deep review sliced an open pull request and is waiting for its findings to be curated.',
423
+ pending: (decision) => decision.status === 'awaiting_selection',
424
+ verbs: [
425
+ {
426
+ action: 'resolve',
427
+ binding: 'decisions_resolve_pr_review',
428
+ summary: 'Say what to do with the curated findings: record, hand to a fixer, or post on the PR.',
429
+ fields: [
430
+ {
431
+ name: 'action',
432
+ required: false,
433
+ detail: 'Omitted reads as `finish`. See the surface’s `prReviewResolution` vocabulary.',
434
+ },
435
+ {
436
+ name: 'findingIds',
437
+ required: false,
438
+ detail: 'The findings to act on. Omitted reads as an empty selection, which only `finish` accepts.',
439
+ },
440
+ ],
441
+ call: (_decision, input) => {
442
+ const ids = input.findingIds;
443
+ return {
444
+ binding: 'decisions_resolve_pr_review',
445
+ args: {
446
+ body: {
447
+ ...optionalText(input, 'action'),
448
+ ...(Array.isArray(ids) ? { findingIds: ids } : {}),
449
+ },
450
+ },
451
+ };
452
+ },
453
+ },
454
+ {
455
+ action: 'dismiss-finding',
456
+ binding: 'decisions_dismiss_pr_review_finding',
457
+ summary: 'Drop one finding from consideration.',
458
+ fields: [{ name: 'findingId', required: true, detail: 'The finding’s stable id.' }],
459
+ call: (_decision, input) => ({
460
+ binding: 'decisions_dismiss_pr_review_finding',
461
+ args: { findingId: pick(input, 'findingId', 'finding') },
462
+ }),
463
+ },
464
+ {
465
+ action: 'challenge-finding',
466
+ binding: 'decisions_challenge_pr_review_finding',
467
+ summary: 'Send one finding back to a read-only investigator to uphold, amend or retract.',
468
+ fields: [
469
+ { name: 'findingId', required: true, detail: 'The finding’s stable id.' },
470
+ {
471
+ name: 'question',
472
+ required: false,
473
+ detail: 'What to check. Omitted uses the generic prompt.',
474
+ },
475
+ ],
476
+ call: (_decision, input) => ({
477
+ binding: 'decisions_challenge_pr_review_finding',
478
+ args: {
479
+ findingId: pick(input, 'findingId', 'finding'),
480
+ body: optionalText(input, 'question'),
481
+ },
482
+ }),
483
+ },
484
+ ],
485
+ },
486
+ 'human-test': {
487
+ summary: 'A live ephemeral environment is up and the run is waiting for someone to exercise it.',
488
+ pending: (decision) => decision.phase === 'awaiting_human',
489
+ verbs: [
490
+ {
491
+ action: 'confirm',
492
+ binding: 'decisions_confirm_human_test',
493
+ summary: 'The change works. The run advances.',
494
+ fields: [],
495
+ call: () => ({ binding: 'decisions_confirm_human_test', args: {} }),
496
+ },
497
+ {
498
+ action: 'request-fix',
499
+ binding: 'decisions_request_human_test_fix',
500
+ summary: 'It does not work. A fixer runs with these findings.',
501
+ fields: FINDINGS_FIELDS,
502
+ call: (_decision, input) => ({
503
+ binding: 'decisions_request_human_test_fix',
504
+ args: { body: { findings: text(input, 'findings', 'what the fixer has to fix') } },
505
+ }),
506
+ },
507
+ ],
508
+ },
509
+ 'visual-confirmation': {
510
+ summary: 'Screenshots are waiting to be compared against the uploaded reference designs.',
511
+ pending: (decision) => decision.phase === 'awaiting_human',
512
+ verbs: [
513
+ {
514
+ action: 'approve',
515
+ binding: 'decisions_approve_visual_confirmation',
516
+ summary: 'The screenshots match. The run advances.',
517
+ fields: [],
518
+ call: () => ({ binding: 'decisions_approve_visual_confirmation', args: {} }),
519
+ },
520
+ {
521
+ action: 'request-fix',
522
+ binding: 'decisions_request_visual_confirmation_fix',
523
+ summary: 'They do not match. A fixer runs with these findings.',
524
+ fields: FINDINGS_FIELDS,
525
+ call: (_decision, input) => ({
526
+ binding: 'decisions_request_visual_confirmation_fix',
527
+ args: { body: { findings: text(input, 'findings', 'what the fixer has to fix') } },
528
+ }),
529
+ },
530
+ ],
531
+ },
532
+ 'follow-ups': {
533
+ summary: 'The Coder surfaced forward-looking items and the run stops until each is decided.',
534
+ // This park accrues LIVE: items appear while the step still runs, so the run need not be
535
+ // blocked for them to be answerable. What holds it is a `pending` item, not the run's status.
536
+ pending: (decision) => Array.isArray(decision.items) &&
537
+ decision.items.some((item) => item.status === 'pending'),
538
+ verbs: [
539
+ {
540
+ action: 'file',
541
+ binding: 'decisions_file_follow_up',
542
+ summary: 'File a follow-up as a ticket.',
543
+ fields: [{ name: 'itemId', required: true, detail: 'The item’s stable id.' }],
544
+ call: (_decision, input) => ({
545
+ binding: 'decisions_file_follow_up',
546
+ args: { itemId: pick(input, 'itemId', 'item') },
547
+ }),
548
+ },
549
+ {
550
+ action: 'send-back',
551
+ binding: 'decisions_send_back_follow_up',
552
+ summary: 'Fold a follow-up into another Coder pass, if the send-back budget allows.',
553
+ fields: [{ name: 'itemId', required: true, detail: 'The item’s stable id.' }],
554
+ call: (_decision, input) => ({
555
+ binding: 'decisions_send_back_follow_up',
556
+ args: { itemId: pick(input, 'itemId', 'item') },
557
+ }),
558
+ },
559
+ {
560
+ action: 'answer',
561
+ binding: 'decisions_answer_follow_up',
562
+ summary: 'Answer a `question` item.',
563
+ fields: [
564
+ { name: 'itemId', required: true, detail: 'The item’s stable id.' },
565
+ { name: 'answer', required: true, detail: 'The answer the next pass works from.' },
566
+ ],
567
+ call: (_decision, input) => ({
568
+ binding: 'decisions_answer_follow_up',
569
+ args: {
570
+ itemId: pick(input, 'itemId', 'item'),
571
+ body: { answer: text(input, 'answer', 'the answer the next pass works from') },
572
+ },
573
+ }),
574
+ },
575
+ {
576
+ action: 'dismiss',
577
+ binding: 'decisions_dismiss_follow_up',
578
+ summary: 'Decide an item needs nothing.',
579
+ fields: [{ name: 'itemId', required: true, detail: 'The item’s stable id.' }],
580
+ call: (_decision, input) => ({
581
+ binding: 'decisions_dismiss_follow_up',
582
+ args: { itemId: pick(input, 'itemId', 'item') },
583
+ }),
584
+ },
585
+ ],
586
+ },
587
+ interview: {
588
+ summary: 'An inline interviewer asked clarifying questions and the run waits while they are answered.',
589
+ // An entry whose questions are all answered means the interviewer pass is IN FLIGHT, and
590
+ // `continue` / `proceed` still act on it, so presence is what makes it answerable.
591
+ pending: () => true,
592
+ verbs: [
593
+ {
594
+ action: 'answer',
595
+ binding: 'decisions_answer_interview_question',
596
+ summary: 'Record one answer. An empty string clears one recorded by mistake.',
597
+ fields: [
598
+ {
599
+ name: 'questionId',
600
+ required: true,
601
+ detail: 'The question’s id. A question whose id is null cannot be answered individually.',
602
+ },
603
+ { name: 'answer', required: false, detail: 'The answer; an empty string clears it.' },
604
+ ],
605
+ call: (_decision, input) => ({
606
+ binding: 'decisions_answer_interview_question',
607
+ args: {
608
+ body: {
609
+ questionId: pick(input, 'questionId', 'question'),
610
+ answer: typeof input.answer === 'string' ? input.answer : '',
611
+ },
612
+ },
613
+ }),
614
+ },
615
+ {
616
+ action: 'continue',
617
+ binding: 'decisions_continue_interview',
618
+ summary: 'Submit the answers and let the interviewer ask follow-ups.',
619
+ fields: [],
620
+ call: () => ({ binding: 'decisions_continue_interview', args: {} }),
621
+ },
622
+ {
623
+ action: 'proceed',
624
+ binding: 'decisions_proceed_interview',
625
+ summary: 'Force the interview to converge on what it has.',
626
+ fields: [],
627
+ call: () => ({ binding: 'decisions_proceed_interview', args: {} }),
628
+ },
629
+ ],
630
+ },
631
+ };
632
+ /** How a kind of park is answered, or `undefined` for one this package does not model. */
633
+ export function answererFor(kind) {
634
+ return ANSWERERS[kind];
635
+ }
636
+ /** Every kind this package can answer. */
637
+ export const ANSWERABLE_DECISION_KINDS = Object.keys(ANSWERERS);
638
+ /**
639
+ * The binding names every answerer forwards through, plus the read that finds the park.
640
+ *
641
+ * Published so a policy author can grant "answer parked decisions" without transcribing forty
642
+ * operation names, and so `approvals_inspect` can say which verbs a tier actually holds. It is
643
+ * DERIVED from the table rather than restated, because a hand-kept copy is exactly the drift the
644
+ * generated binding table exists to prevent one layer down.
645
+ */
646
+ export const DECISION_BINDINGS = [
647
+ 'decisions_list',
648
+ ...new Set(Object.values(ANSWERERS).flatMap((answerer) => answerer.verbs.map((verb) => verb.binding))),
649
+ ];
650
+ /**
651
+ * The key scope every decision binding needs.
652
+ *
653
+ * Stated as a constant rather than read off the table because it is a POLICY fact a tier author
654
+ * needs before compiling: a tier granting these must mint `decide` keys. `policy.test.ts` pins it
655
+ * against the live table, so a surface that ever lowered a floor would fail there rather than
656
+ * leaving this comment quietly wrong.
657
+ */
658
+ export const DECISION_KEY_SCOPE = 'decide';
659
+ //# sourceMappingURL=decisions.js.map