@sublang/playbook 15.0.0 → 16.0.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 (81) hide show
  1. package/README.md +1 -1
  2. package/docs/embedding.md +9 -7
  3. package/package.json +3 -1
  4. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +63 -33
  5. package/reference/sdlc/branch.playbook/branch.fsm.js +190 -108
  6. package/reference/sdlc/branch.playbook/branch.fsm.ts +269 -147
  7. package/reference/sdlc/branch.playbook/branch.gears.md +7 -17
  8. package/reference/sdlc/branch.playbook/branch.playbook.d.ts +12 -16
  9. package/reference/sdlc/branch.playbook/branch.playbook.js +112 -101
  10. package/reference/sdlc/branch.playbook/branch.playbook.ts +128 -171
  11. package/reference/sdlc/branch.playbook/branch.registry.d.ts +3 -2
  12. package/reference/sdlc/branch.playbook/branch.registry.ts +6 -2
  13. package/reference/sdlc/code.playbook/bin/repository-effects.js +54 -13
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +143 -90
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +8 -8
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +13 -13
  17. package/reference/sdlc/code.playbook/code.fsm.js +609 -649
  18. package/reference/sdlc/code.playbook/code.fsm.ts +890 -821
  19. package/reference/sdlc/code.playbook/code.gears.md +39 -29
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +13 -16
  21. package/reference/sdlc/code.playbook/code.playbook.js +166 -147
  22. package/reference/sdlc/code.playbook/code.playbook.ts +180 -229
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +3 -2
  24. package/reference/sdlc/code.playbook/code.registry.ts +6 -2
  25. package/reference/sdlc/code.playbook/playbook-captain.d.ts +42 -0
  26. package/reference/sdlc/code.playbook/playbook-captain.js +829 -194
  27. package/reference/sdlc/code.playbook/playbook-captain.ts +1227 -220
  28. package/reference/sdlc/decide.md +1 -0
  29. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +270 -154
  30. package/reference/sdlc/decide.playbook/decide.fsm.js +765 -732
  31. package/reference/sdlc/decide.playbook/decide.fsm.ts +1167 -945
  32. package/reference/sdlc/decide.playbook/decide.gears.md +28 -21
  33. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -67
  34. package/reference/sdlc/decide.playbook/decide.playbook.js +155 -3570
  35. package/reference/sdlc/decide.playbook/decide.playbook.ts +186 -5061
  36. package/reference/sdlc/decide.playbook/decide.registry.d.ts +8 -4
  37. package/reference/sdlc/decide.playbook/decide.registry.js +2 -2
  38. package/reference/sdlc/decide.playbook/decide.registry.ts +13 -6
  39. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +113 -131
  40. package/reference/sdlc/dev.playbook/dev.fsm.js +517 -575
  41. package/reference/sdlc/dev.playbook/dev.fsm.ts +667 -787
  42. package/reference/sdlc/dev.playbook/dev.gears.md +52 -54
  43. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +13 -10
  44. package/reference/sdlc/dev.playbook/dev.playbook.js +128 -120
  45. package/reference/sdlc/dev.playbook/dev.playbook.ts +143 -193
  46. package/reference/sdlc/dev.playbook/dev.registry.d.ts +3 -2
  47. package/reference/sdlc/dev.playbook/dev.registry.ts +6 -2
  48. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +92 -85
  49. package/reference/sdlc/pr.playbook/pr.fsm.js +461 -518
  50. package/reference/sdlc/pr.playbook/pr.fsm.ts +533 -663
  51. package/reference/sdlc/pr.playbook/pr.gears.md +47 -63
  52. package/reference/sdlc/pr.playbook/pr.playbook.d.ts +13 -23
  53. package/reference/sdlc/pr.playbook/pr.playbook.js +118 -112
  54. package/reference/sdlc/pr.playbook/pr.playbook.ts +136 -190
  55. package/reference/sdlc/pr.playbook/pr.registry.d.ts +3 -2
  56. package/reference/sdlc/pr.playbook/pr.registry.ts +4 -2
  57. package/reference/sdlc/review.md +3 -3
  58. package/reference/sdlc/review.playbook/review.fsm.d.ts +150 -116
  59. package/reference/sdlc/review.playbook/review.fsm.js +479 -550
  60. package/reference/sdlc/review.playbook/review.fsm.ts +717 -662
  61. package/reference/sdlc/review.playbook/review.gears.md +32 -29
  62. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -12
  63. package/reference/sdlc/review.playbook/review.playbook.js +180 -155
  64. package/reference/sdlc/review.playbook/review.playbook.ts +190 -230
  65. package/reference/sdlc/review.playbook/review.registry.d.ts +6 -5
  66. package/reference/sdlc/review.playbook/review.registry.js +3 -3
  67. package/reference/sdlc/review.playbook/review.registry.ts +9 -5
  68. package/slc/gears2fsm.md +72 -3
  69. package/slc/link.md +50 -27
  70. package/slc/materialize-link.mjs +51 -13
  71. package/slc/optimize.md +4 -1
  72. package/slc/prefix-prompts.mjs +191 -0
  73. package/slc/prefix.md +203 -0
  74. package/slc/slc.pin-inputs.json +11 -1
  75. package/slc/text2gears.md +71 -11
  76. package/src/xstate-playbook-runtime.d.ts +82 -9
  77. package/src/xstate-playbook-runtime.js +1224 -283
  78. package/src/xstate-playbook-runtime.ts +1671 -338
  79. package/src/xstate-runtime.d.ts +3 -1
  80. package/src/xstate-runtime.js +9 -2
  81. package/src/xstate-runtime.ts +12 -2
@@ -1,7 +1,5 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
- //
4
- // Generated by slc/gears2fsm.md from ./branch.gears.md.
5
3
 
6
4
  import { assign, fromPromise, setup } from 'xstate';
7
5
 
@@ -9,83 +7,105 @@ export type BranchStateId = 'createBranch';
9
7
 
10
8
  export type BranchSourceItem = 'BRANCH-1';
11
9
 
10
+ /**
11
+ * Stable working-leaf ids that may suspend on a Boss question and be resumed
12
+ * by `BOSS_REPLY`. The machine has at most one active player task, so the
13
+ * scalar Boss-reply form applies.
14
+ */
15
+ const RESUMABLE_STATE_IDS = ['createBranch'] as const;
16
+ export type ResumableStateId = (typeof RESUMABLE_STATE_IDS)[number];
17
+
18
+ /** JSON-safe normalization of a rejected invocation, retained for inspection. */
19
+ export type NormalizedError = {
20
+ readonly name: string;
21
+ readonly message: string;
22
+ readonly stack?: string;
23
+ };
24
+
25
+ /** Question record raised by the suspended Coder working leaf. */
12
26
  export type PendingBossQuestion = {
13
- readonly questionId: 'createBranch';
14
- readonly resumeStateId: 'createBranch';
15
- readonly sourceItem: 'BRANCH-1';
27
+ readonly questionId: ResumableStateId;
28
+ readonly resumeStateId: ResumableStateId;
29
+ readonly sourceItem: BranchSourceItem;
16
30
  readonly asker: { readonly kind: 'role'; readonly roleId: 'coder' };
17
31
  readonly question: string;
18
32
  };
19
33
 
34
+ /** Typed input for the delegated `player` actor. */
20
35
  export type PlayerInput = {
21
36
  readonly stateId: 'createBranch';
22
37
  readonly role: 'coder';
23
38
  readonly sourceItem: 'BRANCH-1';
24
39
  readonly prompt: string;
25
40
  readonly result: Readonly<Record<string, string>>;
41
+ /** Backs the prompt's `<caller-input>` placeholder with the exact caller input. */
26
42
  readonly callerInput: string;
27
43
  readonly pendingBossQuestion?: PendingBossQuestion;
28
44
  readonly bossReply?: string;
29
45
  };
30
46
 
47
+ /** Discriminated result contract of the delegated `player` actor. */
31
48
  export type PlayerOutput =
32
49
  | {
33
50
  readonly guard: 'branched';
34
51
  readonly branch: string;
35
- /**
36
- * Receipt-owned effect evidence (DR-045): the branching call's
37
- * `unchanged` receipt observes the commit the branch was created from
38
- * as HEAD, so the linked runtime injects it and never takes it from
39
- * Coder's prose.
40
- */
52
+ /** Exact base revision from repository authority, not from Coder's prose. */
41
53
  readonly baseRevision: string;
42
54
  readonly issueSummary: string;
43
55
  }
44
56
  | { readonly guard: 'refused'; readonly coderOutput: string }
45
57
  | { readonly guard: 'needsBossReply'; readonly question: string };
46
58
 
59
+ /**
60
+ * Terminal result: exactly the packaged `branch` workflow's public output
61
+ * interface (workflow-contracts.json), derived from typed context.
62
+ */
47
63
  export type BranchPlaybookOutput =
48
64
  | {
49
65
  readonly status: 'branched';
50
- /** Exact name of the new branch now checked out. */
66
+ /** Exact new branch name. */
51
67
  readonly branch: string;
52
- /** Exact receipt-observed revision the branch was created from. */
68
+ /** Exact receipt-observed repository revision the branch was created from. */
53
69
  readonly baseRevision: string;
54
- /** Coder's concise summary of the issue and its comments, or of the request. */
70
+ /** Concise issue-and-comments or request summary. */
55
71
  readonly issueSummary: string;
56
72
  }
57
73
  | {
58
74
  readonly status: 'refused';
59
- /** Coder's complete report carrying the reason no branch was created. */
75
+ /** Complete refusal report. */
60
76
  readonly coderOutput: string;
61
77
  };
62
78
 
63
- export type BranchInput = Readonly<Record<string, never>>;
79
+ /** The machine reads no input: the caller input arrives on `START_BRANCH`. */
80
+ export type BranchInput = Readonly<Record<never, never>>;
81
+
82
+ export type BranchCompletion = 'branched' | 'refused';
64
83
 
65
84
  export type BranchContext = {
66
85
  readonly callerInput?: string;
67
86
  readonly branch?: string;
68
- /** Observed HEAD of the branching call's unchanged receipt (DR-045). */
69
87
  readonly baseRevision?: string;
70
88
  readonly issueSummary?: string;
71
89
  readonly coderOutput?: string;
72
- readonly completion?: 'branched' | 'refused';
73
- readonly lastError?: unknown;
90
+ readonly completion?: BranchCompletion;
91
+ readonly lastError?: NormalizedError;
74
92
  readonly pendingBossQuestion?: PendingBossQuestion;
75
93
  readonly bossReply?: string;
76
94
  };
77
95
 
96
+ /** Typed Boss surfaces: the caller's entry event and the Boss reply. */
78
97
  export type BranchEvent =
79
98
  | { readonly type: 'START_BRANCH'; readonly callerInput: string }
80
99
  | {
81
100
  readonly type: 'BOSS_REPLY';
82
101
  readonly answer: string;
83
- readonly questionId?: 'createBranch';
102
+ readonly questionId?: ResumableStateId;
84
103
  };
85
104
 
105
+ /** No parallel group in this playbook. */
106
+ export const concurrentRoleSets: readonly (readonly string[])[] = [];
107
+
86
108
  const CREATE_BRANCH_PROMPT = [
87
- '> Original request: <caller-input>',
88
- '',
89
109
  'Prepare a new branch for this work without changing any file or making any commit.',
90
110
  'Identify the GitHub issue the request names, if any, and read it with its comments (`gh issue view --comments` with the issue number).',
91
111
  'If the request could refer to more than one issue, or does not say which work to branch for, ask Boss before creating anything.',
@@ -94,34 +114,37 @@ const CREATE_BRANCH_PROMPT = [
94
114
  'Create the branch from the current commit and check it out; do not pull, reset, stash, or move HEAD to another commit.',
95
115
  'Report the exact branch name, the commit it was created from, and a concise summary of the issue and its comments, or of the request when no issue is named.',
96
116
  'If the working tree is not clean, `gh` is not authenticated, the named issue does not exist or cannot be read, or a branch with that name already exists locally or on the remote, create nothing and report the failure with its reason.',
117
+ '',
118
+ '> Original request: <caller-input>',
97
119
  ].join('\n');
98
120
 
121
+ const NEEDS_BOSS_REPLY_RESULT =
122
+ "The acting agent's prose surfaces a clarifying question for Boss that the agent cannot answer alone. Output shall include `question: <verbatim question text from the acting agent's prose>`.";
123
+
99
124
  const CREATE_BRANCH_RESULTS = {
100
125
  branched:
101
- 'Coder created the new branch from the current commit and checked it out, reporting its exact name and a concise summary of the issue and its comments, or of the request when no issue is named; the absence of a reported obstacle is not support. Output shall include `branch: <exact branch name>`, `baseRevision: <repository revision>`, and `issueSummary: <concise summary>`.',
126
+ "Coder affirmatively reported that it created the new branch from the current commit and checked it out, with the exact branch name and a concise summary of the issue and its comments, or of the request when no issue is named; the absence of a reported obstacle is not support. The branch workflow is then complete and returns the exact branch name, the exact base revision taken from repository authority, and the issue summary to its caller. Output shall include `branch: <exact branch name>`, `baseRevision: <exact base revision from repository authority, not from Coder's prose>`, and `issueSummary: <concise summary of the issue and its comments, or of the request when no issue is named>`.",
102
127
  refused:
103
- 'Coder created nothing and reported the failure with its reason: the working tree is not clean, `gh` is not authenticated, the named issue does not exist or cannot be read, or a branch with that name already exists locally or on the remote. Output shall include `coderOutput: <verbatim final text>`.',
104
- needsBossReply:
105
- "The acting agent's prose surfaces a clarifying question for Boss that the agent cannot answer alone. Output shall include `question: <verbatim question text from the acting agent's prose>`.",
128
+ "Coder affirmatively reported that it created no branch and gave the failure with its reason, such as a working tree that is not clean, gh not being authenticated, a named issue that does not exist or cannot be read, or a branch with that name already existing locally or on the remote. The branch workflow then fails and reports Coder's complete result with its reason to its caller; no branch was created. Output shall include `coderOutput: <verbatim final text>`.",
129
+ needsBossReply: NEEDS_BOSS_REPLY_RESULT,
106
130
  } as const;
107
131
 
108
132
  const STATE_DESCRIPTIONS = {
109
- ready: 'Waiting for a development request to branch for.',
133
+ ready: 'Waiting for the caller to give a development request to branch for.',
110
134
  createBranch:
111
- 'Coder is reading the named issue and creating and checking out a new branch at the current commit.',
112
- awaitBossReply: 'Waiting for Boss to answer Coder.',
135
+ 'Coder is identifying the request\'s issue and creating and checking out a new branch at the current commit.',
136
+ awaitBossReply: "Waiting for Boss to answer the acting agent's question.",
113
137
  failed:
114
- 'The branch workflow failed and is waiting for a new development request.',
138
+ 'The branch workflow failed and is waiting for the caller to give the request again.',
115
139
  branched:
116
- "A new branch for the request is checked out at the caller's current commit; no file changed and no commit was made.",
140
+ 'A new branch for the request was created at the current commit and checked out; the workflow returns its exact name, the exact base revision taken from repository authority, and the issue summary.',
117
141
  refused:
118
- 'No branch was created: Coder reported the obstacle with its reason — a dirty working tree, an unauthenticated `gh`, an unreadable issue, or a branch-name collision.',
142
+ "No branch was created: Coder reported the failure with its reason, and the workflow fails and reports Coder's complete result to its caller.",
119
143
  } as const;
120
144
 
121
- function playbookMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
122
- stateId: StateId,
123
- role?: 'coder',
124
- ) {
145
+ type DescribedStateId = keyof typeof STATE_DESCRIPTIONS;
146
+
147
+ function playbookMeta(stateId: DescribedStateId, role?: 'coder') {
125
148
  return {
126
149
  playbook: {
127
150
  stateId,
@@ -131,11 +154,9 @@ function playbookMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
131
154
  };
132
155
  }
133
156
 
134
- // DR-048: a final state additionally declares whether its outcome means the
135
- // workflow succeeded or failed, so BRANCH's own caller learns that from the
136
- // machine rather than from BRANCH's output fields.
137
- function terminalMeta<StateId extends keyof typeof STATE_DESCRIPTIONS>(
138
- stateId: StateId,
157
+ /** A final state also publishes whether its outcome is success or failure. */
158
+ function terminalMeta(
159
+ stateId: DescribedStateId,
139
160
  terminal: 'success' | 'failure',
140
161
  ) {
141
162
  return {
@@ -159,43 +180,87 @@ function isNonEmptyString(value: unknown): value is string {
159
180
  return typeof value === 'string' && value.trim().length > 0;
160
181
  }
161
182
 
162
- function playerOutput(event: unknown): Record<string, unknown> | undefined {
163
- if (!isRecord(event)) return undefined;
164
- return isRecord(event.output) ? event.output : undefined;
165
- }
166
-
167
- function outputGuard(event: unknown, guard: PlayerOutput['guard']): boolean {
168
- return playerOutput(event)?.guard === guard;
183
+ /**
184
+ * Narrows an unknown event's `output` structurally to the declared `player`
185
+ * contract. Output missing a field its guard requires narrows to `undefined`.
186
+ */
187
+ function playerOutputOf(event: unknown): PlayerOutput | undefined {
188
+ if (!isRecord(event) || !isRecord(event.output)) return undefined;
189
+ const output = event.output;
190
+ if (
191
+ output.guard === 'branched' &&
192
+ isNonEmptyString(output.branch) &&
193
+ isNonEmptyString(output.baseRevision) &&
194
+ isNonEmptyString(output.issueSummary)
195
+ ) {
196
+ return {
197
+ guard: 'branched',
198
+ branch: output.branch,
199
+ baseRevision: output.baseRevision,
200
+ issueSummary: output.issueSummary,
201
+ };
202
+ }
203
+ if (output.guard === 'refused' && isNonEmptyString(output.coderOutput)) {
204
+ return { guard: 'refused', coderOutput: output.coderOutput };
205
+ }
206
+ if (output.guard === 'needsBossReply' && isNonEmptyString(output.question)) {
207
+ return { guard: 'needsBossReply', question: output.question };
208
+ }
209
+ return undefined;
169
210
  }
170
211
 
171
- function outputString(event: unknown, field: string): string | undefined {
172
- const value = playerOutput(event)?.[field];
173
- return isNonEmptyString(value) ? value : undefined;
212
+ /** Narrows an unknown event's `error` structurally to a JSON-safe record. */
213
+ function normalizedErrorOf(event: unknown): NormalizedError {
214
+ const error = isRecord(event) ? event.error : undefined;
215
+ if (error instanceof Error) {
216
+ const name = error.name || 'Error';
217
+ return typeof error.stack === 'string'
218
+ ? { name, message: error.message, stack: error.stack }
219
+ : { name, message: error.message };
220
+ }
221
+ if (isRecord(error)) {
222
+ const name = typeof error.name === 'string' ? error.name : 'Error';
223
+ const message =
224
+ typeof error.message === 'string'
225
+ ? error.message
226
+ : 'player actor rejected without a normalized error';
227
+ return typeof error.stack === 'string'
228
+ ? { name, message, stack: error.stack }
229
+ : { name, message };
230
+ }
231
+ return {
232
+ name: 'Error',
233
+ message:
234
+ typeof error === 'string'
235
+ ? error
236
+ : 'player actor rejected without a normalized error',
237
+ };
174
238
  }
175
239
 
176
- // DR-045: the branched outcome is accepted only with the receipt-observed
177
- // base revision the linked runtime injects; Coder's prose never supplies it.
178
- function isBranched({ event }: { event: unknown }): boolean {
179
- return (
180
- outputGuard(event, 'branched') &&
181
- outputString(event, 'branch') !== undefined &&
182
- outputString(event, 'baseRevision') !== undefined &&
183
- outputString(event, 'issueSummary') !== undefined
184
- );
240
+ function isResumableStateId(value: string): value is ResumableStateId {
241
+ return (RESUMABLE_STATE_IDS as readonly string[]).includes(value);
185
242
  }
186
243
 
187
- function isRefused({ event }: { event: unknown }): boolean {
188
- return (
189
- outputGuard(event, 'refused') &&
190
- outputString(event, 'coderOutput') !== undefined
191
- );
192
- }
244
+ type ResumeTransition = {
245
+ readonly guard: {
246
+ readonly type: 'canResume';
247
+ readonly params: { readonly stateId: ResumableStateId };
248
+ };
249
+ readonly target: `#${ResumableStateId}`;
250
+ readonly reenter: true;
251
+ readonly actions: 'acceptBossReply';
252
+ };
193
253
 
194
- function needsBossReply({ event }: { event: unknown }): boolean {
195
- return (
196
- outputGuard(event, 'needsBossReply') &&
197
- outputString(event, 'question') !== undefined
198
- );
254
+ /** Builds one guarded `BOSS_REPLY` resume arm per registered working-leaf id. */
255
+ function resumableStates(
256
+ ids: readonly ResumableStateId[],
257
+ ): ResumeTransition[] {
258
+ return ids.map((id) => ({
259
+ guard: { type: 'canResume', params: { stateId: id } },
260
+ target: `#${id}`,
261
+ reenter: true,
262
+ actions: 'acceptBossReply',
263
+ }));
199
264
  }
200
265
 
201
266
  const machineSetup = setup({
@@ -211,16 +276,34 @@ const machineSetup = setup({
211
276
  }),
212
277
  },
213
278
  guards: {
214
- isBranched,
215
- isRefused,
216
- needsBossReply,
217
- emptyBossReply: ({ event }) =>
218
- event.type === 'BOSS_REPLY' && event.answer.trim().length === 0,
219
- resumesCreateBranch: ({ context, event }) =>
220
- event.type === 'BOSS_REPLY' &&
221
- event.answer.trim().length > 0 &&
222
- context.pendingBossQuestion?.resumeStateId === 'createBranch' &&
223
- (event.questionId === undefined || event.questionId === 'createBranch'),
279
+ // The entry guard validates the text the current event supplies, before
280
+ // the entry action copies it into context.
281
+ givesRequest: ({ event }) =>
282
+ event.type === 'START_BRANCH' && isNonEmptyString(event.callerInput),
283
+ isBranched: ({ event }) => playerOutputOf(event)?.guard === 'branched',
284
+ isRefused: ({ event }) => playerOutputOf(event)?.guard === 'refused',
285
+ needsBossReply: (
286
+ { event },
287
+ params: { readonly stateId: ResumableStateId },
288
+ ) =>
289
+ playerOutputOf(event)?.guard === 'needsBossReply' &&
290
+ isResumableStateId(params.stateId),
291
+ canResume: (
292
+ { context, event },
293
+ params: { readonly stateId: ResumableStateId },
294
+ ) => {
295
+ if (event.type !== 'BOSS_REPLY' || !isNonEmptyString(event.answer)) {
296
+ return false;
297
+ }
298
+ const pending = context.pendingBossQuestion;
299
+ if (pending === undefined || pending.resumeStateId !== params.stateId) {
300
+ return false;
301
+ }
302
+ return (
303
+ event.questionId === undefined ||
304
+ event.questionId === pending.questionId
305
+ );
306
+ },
224
307
  },
225
308
  actions: {
226
309
  'playbook.acceptedOutcome': (
@@ -231,73 +314,98 @@ const machineSetup = setup({
231
314
  readonly acceptedOutcome: string;
232
315
  },
233
316
  ) => undefined,
234
- startBranch: assign(({ event }) => {
317
+ startBranch: assign(({ context, event }): Partial<BranchContext> => {
235
318
  if (event.type !== 'START_BRANCH') return {};
236
319
  return {
237
320
  callerInput: event.callerInput,
238
- branch: undefined,
239
- baseRevision: undefined,
240
- issueSummary: undefined,
241
- coderOutput: undefined,
242
- completion: undefined,
243
- lastError: undefined,
244
- pendingBossQuestion: undefined,
245
- bossReply: undefined,
321
+ ...(context.lastError === undefined ? {} : { lastError: undefined }),
322
+ ...(context.pendingBossQuestion === undefined
323
+ ? {}
324
+ : { pendingBossQuestion: undefined }),
325
+ ...(context.bossReply === undefined ? {} : { bossReply: undefined }),
246
326
  };
247
327
  }),
248
- rememberBranched: assign(({ event }) => ({
249
- branch: outputString(event, 'branch'),
250
- // DR-045: the receipt-observed HEAD the branch was created from.
251
- baseRevision: outputString(event, 'baseRevision'),
252
- issueSummary: outputString(event, 'issueSummary'),
253
- completion: 'branched' as const,
254
- pendingBossQuestion: undefined,
255
- bossReply: undefined,
256
- })),
257
- rememberRefused: assign(({ event }) => ({
258
- coderOutput: outputString(event, 'coderOutput'),
259
- completion: 'refused' as const,
260
- pendingBossQuestion: undefined,
261
- bossReply: undefined,
262
- })),
263
- rememberPendingQuestion: assign(({ event }) => {
264
- const question = outputString(event, 'question');
265
- if (question === undefined || !outputGuard(event, 'needsBossReply')) {
266
- return { lastError: new Error('Coder question output was malformed') };
267
- }
328
+ rememberBranched: assign(({ context, event }): Partial<BranchContext> => {
329
+ const output = playerOutputOf(event);
330
+ if (output?.guard !== 'branched') return {};
268
331
  return {
269
- pendingBossQuestion: {
270
- questionId: 'createBranch' as const,
271
- resumeStateId: 'createBranch' as const,
272
- sourceItem: 'BRANCH-1' as const,
273
- asker: { kind: 'role' as const, roleId: 'coder' as const },
274
- question,
275
- },
276
- bossReply: undefined,
332
+ branch: output.branch,
333
+ baseRevision: output.baseRevision,
334
+ issueSummary: output.issueSummary,
335
+ completion: 'branched',
336
+ ...(context.pendingBossQuestion === undefined
337
+ ? {}
338
+ : { pendingBossQuestion: undefined }),
339
+ ...(context.bossReply === undefined ? {} : { bossReply: undefined }),
340
+ };
341
+ }),
342
+ rememberRefused: assign(({ context, event }): Partial<BranchContext> => {
343
+ const output = playerOutputOf(event);
344
+ if (output?.guard !== 'refused') return {};
345
+ return {
346
+ coderOutput: output.coderOutput,
347
+ completion: 'refused',
348
+ ...(context.pendingBossQuestion === undefined
349
+ ? {}
350
+ : { pendingBossQuestion: undefined }),
351
+ ...(context.bossReply === undefined ? {} : { bossReply: undefined }),
277
352
  };
278
353
  }),
279
- rememberBossReply: assign(({ event }) =>
354
+ setPendingBossQuestion: assign(
355
+ (
356
+ { context, event },
357
+ params: {
358
+ readonly stateId: ResumableStateId;
359
+ readonly sourceItem: BranchSourceItem;
360
+ },
361
+ ): Partial<BranchContext> => {
362
+ const output = playerOutputOf(event);
363
+ if (output?.guard !== 'needsBossReply') return {};
364
+ return {
365
+ pendingBossQuestion: {
366
+ questionId: params.stateId,
367
+ resumeStateId: params.stateId,
368
+ sourceItem: params.sourceItem,
369
+ asker: { kind: 'role', roleId: 'coder' },
370
+ question: output.question,
371
+ },
372
+ ...(context.bossReply === undefined ? {} : { bossReply: undefined }),
373
+ };
374
+ },
375
+ ),
376
+ acceptBossReply: assign(({ event }): Partial<BranchContext> =>
280
377
  event.type === 'BOSS_REPLY' ? { bossReply: event.answer } : {},
281
378
  ),
282
- rememberEmptyBossReplyError: assign({
283
- lastError: () => new Error('BOSS_REPLY received an empty answer'),
284
- pendingBossQuestion: undefined,
285
- bossReply: undefined,
286
- }),
287
- rememberActorError: assign(({ event }) => ({
288
- lastError: (event as unknown as { readonly error?: unknown }).error,
379
+ clearBossReplyContext: assign(({ context }): Partial<BranchContext> =>
380
+ context.pendingBossQuestion === undefined &&
381
+ context.bossReply === undefined
382
+ ? {}
383
+ : { pendingBossQuestion: undefined, bossReply: undefined },
384
+ ),
385
+ rememberActorError: assign(({ event }): Partial<BranchContext> => ({
386
+ lastError: normalizedErrorOf(event),
387
+ })),
388
+ rememberMalformedPlayerOutput: assign((): Partial<BranchContext> => ({
389
+ lastError: {
390
+ name: 'MalformedPlayerOutput',
391
+ message:
392
+ 'Coder result for BRANCH-1 did not satisfy a declared outcome of its result contract.',
393
+ },
394
+ })),
395
+ rememberMalformedBossReply: assign((): Partial<BranchContext> => ({
396
+ lastError: {
397
+ name: 'MalformedBossReply',
398
+ message:
399
+ 'BOSS_REPLY carried an empty answer or named no pending question.',
400
+ },
289
401
  })),
290
- rememberMalformedPlayerOutput: assign({
291
- lastError: () =>
292
- new Error('Coder result for BRANCH-1 did not match an available outcome.'),
293
- }),
294
402
  },
295
403
  });
296
404
 
297
405
  export const branchMachine = machineSetup.createMachine({
298
406
  id: 'branch',
299
407
  initial: 'ready',
300
- context: {},
408
+ context: () => ({}),
301
409
  output: ({ context }): BranchPlaybookOutput => {
302
410
  if (context.completion === 'branched') {
303
411
  if (
@@ -306,7 +414,7 @@ export const branchMachine = machineSetup.createMachine({
306
414
  !isNonEmptyString(context.issueSummary)
307
415
  ) {
308
416
  throw new Error(
309
- 'BRANCH reached branched without the exact branch name, a receipt-observed base revision, and an issue summary',
417
+ 'BRANCH reached branched without the exact branch name, base revision, and issue summary',
310
418
  );
311
419
  }
312
420
  return {
@@ -318,7 +426,7 @@ export const branchMachine = machineSetup.createMachine({
318
426
  }
319
427
  if (context.completion === 'refused') {
320
428
  if (!isNonEmptyString(context.coderOutput)) {
321
- throw new Error("BRANCH reached refused without Coder's report");
429
+ throw new Error("BRANCH reached refused without Coder's complete result");
322
430
  }
323
431
  return { status: 'refused', coderOutput: context.coderOutput };
324
432
  }
@@ -331,7 +439,11 @@ export const branchMachine = machineSetup.createMachine({
331
439
  meta: playbookMeta('ready'),
332
440
  tags: ['playbook.parked'],
333
441
  on: {
334
- START_BRANCH: { target: 'createBranch', actions: 'startBranch' },
442
+ START_BRANCH: {
443
+ guard: 'givesRequest',
444
+ target: 'createBranch',
445
+ actions: 'startBranch',
446
+ },
335
447
  },
336
448
  },
337
449
  createBranch: {
@@ -347,6 +459,8 @@ export const branchMachine = machineSetup.createMachine({
347
459
  sourceItem: 'BRANCH-1',
348
460
  prompt: CREATE_BRANCH_PROMPT,
349
461
  result: CREATE_BRANCH_RESULTS,
462
+ // Entry is guarded on a non-empty caller input, so this state is
463
+ // never entered without it.
350
464
  callerInput: context.callerInput ?? '',
351
465
  ...(context.pendingBossQuestion === undefined
352
466
  ? {}
@@ -387,7 +501,10 @@ export const branchMachine = machineSetup.createMachine({
387
501
  ],
388
502
  },
389
503
  {
390
- guard: 'needsBossReply',
504
+ guard: {
505
+ type: 'needsBossReply',
506
+ params: { stateId: 'createBranch' },
507
+ },
391
508
  target: 'awaitBossReply',
392
509
  actions: [
393
510
  {
@@ -398,7 +515,10 @@ export const branchMachine = machineSetup.createMachine({
398
515
  acceptedOutcome: 'needsBossReply',
399
516
  },
400
517
  },
401
- 'rememberPendingQuestion',
518
+ {
519
+ type: 'setPendingBossQuestion',
520
+ params: { stateId: 'createBranch', sourceItem: 'BRANCH-1' },
521
+ },
402
522
  ],
403
523
  },
404
524
  {
@@ -416,19 +536,17 @@ export const branchMachine = machineSetup.createMachine({
416
536
  tags: ['playbook.parked'],
417
537
  on: {
418
538
  BOSS_REPLY: [
539
+ ...resumableStates(RESUMABLE_STATE_IDS),
419
540
  {
420
- guard: 'emptyBossReply',
421
- target: '#failed',
422
- actions: 'rememberEmptyBossReplyError',
423
- },
424
- {
425
- guard: 'resumesCreateBranch',
426
- target: '#createBranch',
427
- reenter: true,
428
- actions: 'rememberBossReply',
541
+ target: 'failed',
542
+ actions: ['rememberMalformedBossReply', 'clearBossReplyContext'],
429
543
  },
430
544
  ],
431
- START_BRANCH: { target: 'createBranch', actions: 'startBranch' },
545
+ START_BRANCH: {
546
+ guard: 'givesRequest',
547
+ target: 'createBranch',
548
+ actions: 'startBranch',
549
+ },
432
550
  },
433
551
  },
434
552
  failed: {
@@ -437,7 +555,11 @@ export const branchMachine = machineSetup.createMachine({
437
555
  meta: playbookMeta('failed'),
438
556
  tags: ['playbook.parked'],
439
557
  on: {
440
- START_BRANCH: { target: 'createBranch', actions: 'startBranch' },
558
+ START_BRANCH: {
559
+ guard: 'givesRequest',
560
+ target: 'createBranch',
561
+ actions: 'startBranch',
562
+ },
441
563
  },
442
564
  },
443
565
  branched: {