@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,11 +1,17 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
-
4
- // FSM object artifact compiled from decide.gears.md.
5
- // This module defines only the machine, actor contracts, and typed inputs.
6
- // The linked runtime supplies the player and nested-playbook actors.
3
+ //
4
+ // FSM object artifact compiled from ./decide.gears.md by gears2fsm.
5
+ // It declares only the machine, its actor contracts, and typed inputs; the
6
+ // linked runtime supplies the `player` and `playbook` actor implementations.
7
7
 
8
8
  import { assign, fromPromise, setup } from 'xstate';
9
+ import { validatePlaybookCallResult } from '@sublang/playbook/xstate-runtime';
10
+ import type { PlaybookCallResult } from '@sublang/playbook/runtime';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Shared JSON and error shapes (exact readonly variance of the shared boundary)
14
+ // ---------------------------------------------------------------------------
9
15
 
10
16
  export type JsonValue =
11
17
  | null
@@ -15,148 +21,116 @@ export type JsonValue =
15
21
  | readonly JsonValue[]
16
22
  | { readonly [key: string]: JsonValue };
17
23
 
18
- type Role = 'coder' | 'reviewer';
19
-
20
- export const concurrentRoleSets = [['coder', 'reviewer']] as const;
21
-
22
- type JumpableStateId = 'independentProposals';
23
-
24
- type ResumableStateId =
25
- | 'askCoderProposal'
26
- | 'askReviewerProposal'
27
- | 'commitCoderProposal';
28
-
29
- export interface PendingBossQuestion {
30
- questionId: ResumableStateId;
31
- resumeStateId: ResumableStateId;
32
- sourceItem: string;
33
- asker: { kind: 'role'; roleId: Role };
34
- question: string;
24
+ export interface CompactError {
25
+ readonly name: string;
26
+ readonly message: string;
35
27
  }
36
28
 
37
- type PendingBossQuestions = Partial<
38
- Record<ResumableStateId, PendingBossQuestion>
39
- >;
40
-
41
- type BossReplies = Partial<Record<ResumableStateId, string>>;
42
-
43
- type PendingBossQuestionParams = Omit<PendingBossQuestion, 'questionId'>;
44
-
45
- export interface ReviewSuccessOutput {
46
- evaluatedRevision: string;
47
- noUnsettledFindings: true;
29
+ export interface ErrorRecord {
30
+ readonly name: string;
31
+ readonly message: string;
32
+ readonly stack?: string;
48
33
  }
49
34
 
50
- export interface DecideSuccessOutput {
51
- decideCommit: string;
52
- evaluatedRevision: string;
53
- noUnsettledFindings: true;
54
- }
35
+ // ---------------------------------------------------------------------------
36
+ // Identities
37
+ // ---------------------------------------------------------------------------
55
38
 
56
- export interface DecideFailureOutput {
57
- lastDecideCommit: string;
58
- noUnsettledFindings: false;
59
- reviewStatus: 'aborted' | 'error';
60
- error?: {
61
- name: string;
62
- message: string;
63
- };
64
- }
39
+ /** Canonical lowercase local ids of the source roles Coder and Reviewer. */
40
+ export type DecideRoleId = 'coder' | 'reviewer';
65
41
 
66
- export type DecideOutput = DecideSuccessOutput | DecideFailureOutput;
42
+ export type DecideSourceItem = 'DECIDE-1' | 'DECIDE-2' | 'DECIDE-3' | 'DECIDE-4';
67
43
 
68
- interface ChildFailure {
69
- status: 'aborted' | 'error';
70
- playbookId: 'review';
71
- error?: {
72
- name: string;
73
- message: string;
74
- };
75
- }
44
+ /** Delegated-player working leaves; each is its own Boss-reply resume target. */
45
+ export type ResumableStateId =
46
+ | 'askCoderProposal'
47
+ | 'askReviewerProposal'
48
+ | 'synthesizeCommit';
76
49
 
77
- export interface DecideContext {
78
- callerTopic?: string;
79
- coderProposal?: string;
80
- reviewerProposal?: string;
81
- latestCommit?: string;
82
- coderOutput?: string;
83
- reviewResult?: ReviewSuccessOutput;
84
- reviewFailure?: ChildFailure;
85
- lastResult?: PlayerOutput;
86
- lastError?: unknown;
87
- pendingBossQuestions?: PendingBossQuestions;
88
- bossReplies?: BossReplies;
89
- stagedCoderResult?: PlayerOutput;
90
- stagedReviewerResult?: PlayerOutput;
91
- }
50
+ /**
51
+ * Root `BOSS_INTERRUPT` targets: the parallel proposal pair as one jumpable
52
+ * unit (DECIDE-1, DECIDE-2), and the synthesis leaf (DECIDE-3). Branch working
53
+ * leaves and waits are never interrupt targets.
54
+ */
55
+ export type JumpableStateId = 'independentProposals' | 'synthesizeCommit';
92
56
 
93
- export type DecideEvent =
94
- | { type: 'START_DECIDE'; callerTopic: string }
95
- | {
96
- type: 'BOSS_INTERRUPT';
97
- targetId: JumpableStateId;
98
- bossIntent: string;
99
- }
100
- | {
101
- type: 'BOSS_REPLY';
102
- questionId?: ResumableStateId;
103
- answer: string;
104
- };
57
+ /**
58
+ * One role-id array per parallel group, in first-item source order; the inner
59
+ * array follows the `independent-proposals` item order (DECIDE-1, DECIDE-2).
60
+ */
61
+ export const concurrentRoleSets: readonly (readonly DecideRoleId[])[] = [
62
+ ['coder', 'reviewer'],
63
+ ] as const;
105
64
 
106
- export type DecideInput = Readonly<Record<string, never>>;
65
+ /** The literal nested-call target of DECIDE-4. */
66
+ const REVIEW_PLAYBOOK_ID = 'review' as const;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Boss-reply suspension (keyed form: the machine has parallel player tasks)
70
+ // ---------------------------------------------------------------------------
107
71
 
108
- export interface PlayerInput {
109
- stateId: ResumableStateId;
110
- sourceItem: string;
111
- prompt: string;
112
- result: Record<string, string>;
113
- role: Role;
114
- callerTopic?: string;
115
- reviewerProposal?: string;
116
- pendingBossQuestion?: PendingBossQuestion;
117
- bossReply?: string;
72
+ export interface PendingBossQuestion {
73
+ readonly questionId: ResumableStateId;
74
+ readonly resumeStateId: ResumableStateId;
75
+ readonly sourceItem: 'DECIDE-1' | 'DECIDE-2' | 'DECIDE-3';
76
+ readonly asker: { readonly kind: 'role'; readonly roleId: DecideRoleId };
77
+ readonly question: string;
118
78
  }
119
79
 
120
- type AdditionalPlayerFields = {
121
- coderProposal?: string;
122
- reviewerProposal?: string;
123
- coderOutput?: string;
124
- latestCommit?: string;
125
- question?: string;
126
- readonly [key: string]: unknown;
127
- };
80
+ export type PendingBossQuestions = Partial<
81
+ Record<ResumableStateId, PendingBossQuestion>
82
+ >;
128
83
 
129
- export type PlayerOutput =
130
- | ({ guard: 'proposed'; coderProposal: string } & AdditionalPlayerFields)
131
- | ({ guard: 'proposed'; reviewerProposal: string } & AdditionalPlayerFields)
132
- | ({ guard: 'committed'; coderOutput: string; latestCommit: string } &
133
- AdditionalPlayerFields)
134
- | ({ guard: 'needsBossReply'; question: string } & AdditionalPlayerFields);
84
+ export type BossReplies = Partial<Record<ResumableStateId, string>>;
135
85
 
136
- export interface PlaybookInput {
137
- stateId: 'reviewCommit';
138
- sourceItem: 'DECIDE-4';
139
- playbookId: 'review';
140
- text: string;
141
- }
86
+ const RESUMABLE_STATES = {
87
+ askCoderProposal: { sourceItem: 'DECIDE-1', roleId: 'coder' },
88
+ askReviewerProposal: { sourceItem: 'DECIDE-2', roleId: 'reviewer' },
89
+ synthesizeCommit: { sourceItem: 'DECIDE-3', roleId: 'coder' },
90
+ } as const satisfies Record<
91
+ ResumableStateId,
92
+ {
93
+ readonly sourceItem: PendingBossQuestion['sourceItem'];
94
+ readonly roleId: DecideRoleId;
95
+ }
96
+ >;
142
97
 
143
- type PlayerDoneEvent = { type: string; output: PlayerOutput };
144
- type ActorErrorEvent = { type: string; error: unknown };
145
- type PlaybookDoneEvent = { type: string; output: JsonValue | undefined };
98
+ // ---------------------------------------------------------------------------
99
+ // Prompts (each item's blockquote, verbatim) and local result contracts
100
+ // ---------------------------------------------------------------------------
146
101
 
147
102
  const NEEDS_BOSS_REPLY_DESCRIPTION =
148
103
  "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>`.";
149
104
 
150
- const INDEPENDENT_PROPOSAL_PROMPT = [
151
- '> Original topic: <caller-topic>',
105
+ /** DECIDE-1 blockquote; `<caller-topic>` is carried as `callerTopic`. */
106
+ const CODER_PROPOSAL_PROMPT = [
107
+ 'Assess whether the topic is better expressed as a few spec items under @specs/packages/ or requires one or more DRs under @specs/decisions/.',
108
+ 'Propose your design.',
109
+ 'Keep your proposal coherent, focused, and concise.',
110
+ 'Consult @specs/map.md for relevant context and @specs/meta.md for spec requirements, if needed.',
111
+ 'Do not change any files.',
152
112
  '',
113
+ '> Original topic: <caller-topic>',
114
+ ].join('\n');
115
+
116
+ /** DECIDE-2 blockquote; `<caller-topic>` is carried as `callerTopic`. */
117
+ const REVIEWER_PROPOSAL_PROMPT = [
153
118
  'Assess whether the topic is better expressed as a few spec items under @specs/packages/ or requires one or more DRs under @specs/decisions/.',
154
119
  'Propose your design.',
155
120
  'Keep your proposal coherent, focused, and concise.',
156
121
  'Consult @specs/map.md for relevant context and @specs/meta.md for spec requirements, if needed.',
157
122
  'Do not change any files.',
123
+ '',
124
+ '> Original topic: <caller-topic>',
158
125
  ].join('\n');
159
126
 
127
+ /**
128
+ * DECIDE-3 blockquote. `<caller-topic>` is carried as `callerTopic` and
129
+ * `<reviewer-proposal>` as `reviewerProposal`. `<coder-llm>` and
130
+ * `<reviewer-llm>` are local-role prompt identities of Coder and Reviewer: the
131
+ * linker resolves them through the invocation-scoped `promptIdentity(roleId)`
132
+ * lookup, so they stay literal here and have no machine or actor-input field.
133
+ */
160
134
  const SYNTHESIZE_COMMIT_PROMPT = [
161
135
  "Synthesize your independent proposal with Reviewer's proposal below.",
162
136
  'Keep to the original topic below and follow what it asks.',
@@ -174,362 +148,773 @@ const SYNTHESIZE_COMMIT_PROMPT = [
174
148
  "> Reviewer's independent proposal: <reviewer-proposal>",
175
149
  ].join('\n');
176
150
 
177
- const REVIEW_INPUT_TEMPLATE = [
151
+ const CODER_PROPOSAL_RESULT = {
152
+ proposed:
153
+ 'Coder affirmatively provided a complete design proposal; a progress report, status update, or promise of a later proposal supports no proposal outcome. Output shall include `coderProposal: <verbatim final text>`.',
154
+ needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
155
+ } as const;
156
+
157
+ const REVIEWER_PROPOSAL_RESULT = {
158
+ proposed:
159
+ 'Reviewer affirmatively provided a complete design proposal; a progress report, status update, or promise of a later proposal supports no proposal outcome. Output shall include `reviewerProposal: <verbatim final text>`.',
160
+ needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
161
+ } as const;
162
+
163
+ const SYNTHESIZE_COMMIT_RESULT = {
164
+ committed:
165
+ 'Coder synthesized the proposals into the necessary DRs and/or spec items and committed the result as one new commit. Output shall include `coderOutput: <verbatim final text>` and `latestCommit: <commit identity>`.',
166
+ needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
167
+ } as const;
168
+
169
+ /** DECIDE-4 blockquote: the `review` call-text template, verbatim. */
170
+ const REVIEW_COMMIT_TEMPLATE = [
178
171
  '> Original intent: <caller-topic>',
179
172
  '> Review scope: the `decide`-owned commit <decide-commit> and its resulting repository state.',
173
+ "> Coder's independent proposal: <coder-proposal>",
180
174
  '> Coder output: <coder-output>',
181
175
  ].join('\n');
182
176
 
183
- function composeReviewInput(
184
- callerTopic: string | undefined,
185
- decideCommit: string | undefined,
186
- coderOutput: string | undefined,
187
- ): string {
188
- const fields: Readonly<Record<string, string>> = {
189
- '<caller-topic>': callerTopic ?? '',
190
- '<decide-commit>': decideCommit ?? '',
191
- '<coder-output>': coderOutput ?? '',
192
- };
193
- return REVIEW_INPUT_TEMPLATE.replace(
194
- /<caller-topic>|<decide-commit>|<coder-output>/g,
195
- (placeholder, offset: number, source: string) => {
196
- const value = fields[placeholder];
197
- const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
198
- return source.slice(lineStart, offset).startsWith('> ')
199
- ? value.replaceAll('\n', '\n> ')
200
- : value;
201
- },
202
- );
177
+ // ---------------------------------------------------------------------------
178
+ // Delegated player actor contract
179
+ // ---------------------------------------------------------------------------
180
+
181
+ interface PlayerInputBase {
182
+ readonly pendingBossQuestion?: PendingBossQuestion;
183
+ readonly bossReply?: string;
203
184
  }
204
185
 
205
- const outputOf = (event: unknown): PlayerOutput =>
206
- (event as PlayerDoneEvent).output;
186
+ /** DECIDE-1: relays `<caller-topic>` as `callerTopic`. */
187
+ export interface CoderProposalInput extends PlayerInputBase {
188
+ readonly stateId: 'askCoderProposal';
189
+ readonly role: 'coder';
190
+ readonly sourceItem: 'DECIDE-1';
191
+ readonly prompt: string;
192
+ readonly result: typeof CODER_PROPOSAL_RESULT;
193
+ /** `<caller-topic>`: the caller's complete topic. */
194
+ readonly callerTopic: string;
195
+ }
207
196
 
208
- const playbookOutputOf = (event: unknown): JsonValue | undefined =>
209
- (event as PlaybookDoneEvent).output;
197
+ /** DECIDE-2: relays `<caller-topic>` as `callerTopic`. */
198
+ export interface ReviewerProposalInput extends PlayerInputBase {
199
+ readonly stateId: 'askReviewerProposal';
200
+ readonly role: 'reviewer';
201
+ readonly sourceItem: 'DECIDE-2';
202
+ readonly prompt: string;
203
+ readonly result: typeof REVIEWER_PROPOSAL_RESULT;
204
+ /** `<caller-topic>`: the caller's complete topic. */
205
+ readonly callerTopic: string;
206
+ }
210
207
 
211
- const isNonEmptyString = (value: unknown): value is string =>
212
- typeof value === 'string' && value.trim().length > 0;
208
+ /**
209
+ * DECIDE-3: relays `<caller-topic>` as `callerTopic` and
210
+ * `<reviewer-proposal>` as `reviewerProposal`.
211
+ */
212
+ export interface SynthesizeCommitInput extends PlayerInputBase {
213
+ readonly stateId: 'synthesizeCommit';
214
+ readonly role: 'coder';
215
+ readonly sourceItem: 'DECIDE-3';
216
+ readonly prompt: string;
217
+ readonly result: typeof SYNTHESIZE_COMMIT_RESULT;
218
+ /** `<caller-topic>`: the caller's complete topic. */
219
+ readonly callerTopic: string;
220
+ /** `<reviewer-proposal>`: Reviewer's complete independent proposal. */
221
+ readonly reviewerProposal: string;
222
+ }
213
223
 
214
- const coderProposed = ({ event }: { event: unknown }) => {
215
- const output = outputOf(event);
216
- return output.guard === 'proposed' && isNonEmptyString(output.coderProposal);
217
- };
224
+ export type PlayerInput =
225
+ | CoderProposalInput
226
+ | ReviewerProposalInput
227
+ | SynthesizeCommitInput;
218
228
 
219
- const reviewerProposed = ({ event }: { event: unknown }) => {
220
- const output = outputOf(event);
221
- return (
222
- output.guard === 'proposed' && isNonEmptyString(output.reviewerProposal)
223
- );
224
- };
229
+ export interface NeedsBossReplyOutput {
230
+ readonly guard: 'needsBossReply';
231
+ readonly question: string;
232
+ }
225
233
 
226
- function commitOutput(
227
- event: unknown,
228
- ): { readonly coderOutput: string; readonly latestCommit: string } | undefined {
229
- const output = outputOf(event);
230
- if (
231
- output.guard !== 'committed' ||
232
- !isNonEmptyString(output.coderOutput) ||
233
- !isNonEmptyString(output.latestCommit)
234
- ) {
235
- return undefined;
236
- }
234
+ export type CoderProposalOutput =
235
+ | { readonly guard: 'proposed'; readonly coderProposal: string }
236
+ | NeedsBossReplyOutput;
237
+
238
+ export type ReviewerProposalOutput =
239
+ | { readonly guard: 'proposed'; readonly reviewerProposal: string }
240
+ | NeedsBossReplyOutput;
241
+
242
+ export type SynthesizeCommitOutput =
243
+ | {
244
+ readonly guard: 'committed';
245
+ readonly coderOutput: string;
246
+ /** Effect-owned: the runtime fills it from the repository receipt. */
247
+ readonly latestCommit: string;
248
+ }
249
+ | NeedsBossReplyOutput;
250
+
251
+ export type PlayerOutput =
252
+ | CoderProposalOutput
253
+ | ReviewerProposalOutput
254
+ | SynthesizeCommitOutput;
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Nested playbook actor contract
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** DECIDE-4: literal call of the builtin `review` playbook. */
261
+ export interface PlaybookInput {
262
+ readonly stateId: 'reviewCommit';
263
+ readonly sourceItem?: 'DECIDE-4';
264
+ readonly playbookId: typeof REVIEW_PLAYBOOK_ID;
265
+ readonly text: string;
266
+ }
267
+
268
+ /** A successful call yields the child's own JSON-safe machine output. */
269
+ export type PlaybookOutput = JsonValue | undefined;
270
+
271
+ /** Public output interface of the packaged builtin `review`. */
272
+ export interface ReviewOutput {
273
+ readonly noUnsettledFindings: true;
274
+ readonly evaluatedRevision: string;
275
+ }
276
+
277
+ /** Sanitized completed-result evidence of the `review` call that ended DECIDE. */
278
+ export type CompletedReviewResult =
279
+ | {
280
+ readonly playbookId: typeof REVIEW_PLAYBOOK_ID;
281
+ readonly status: 'ok';
282
+ readonly output?: JsonValue;
283
+ }
284
+ | {
285
+ readonly playbookId: typeof REVIEW_PLAYBOOK_ID;
286
+ readonly status: 'aborted' | 'error';
287
+ readonly error: CompactError;
288
+ };
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Machine input, context, events, and output
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /** The machine reads no input: the caller's topic arrives on `START_DECIDE`. */
295
+ export type DecideInput = Readonly<Record<string, never>>;
296
+
297
+ export type ReviewStatus = 'aborted' | 'error';
298
+
299
+ /**
300
+ * Typed run context. A text field holds `''` until its producer runs; every
301
+ * transition that enters a consumer guards the fields it reads as non-empty.
302
+ */
303
+ export interface DecideContext {
304
+ /** `<caller-topic>`: set by `START_DECIDE` or a restarting interrupt. */
305
+ readonly callerTopic: string;
306
+ /** Branch-staged proposals; the parallel join promotes them atomically. */
307
+ readonly stagedCoderProposal: string;
308
+ readonly stagedReviewerProposal: string;
309
+ /** `<coder-proposal>`: Coder's promoted independent proposal. */
310
+ readonly coderProposal: string;
311
+ /** `<reviewer-proposal>`: Reviewer's promoted independent proposal. */
312
+ readonly reviewerProposal: string;
313
+ /** `<coder-output>`: Coder's verbatim final synthesis text. */
314
+ readonly coderOutput: string;
315
+ /** `<decide-commit>`: DECIDE-3's accepted, receipt-owned `latestCommit`. */
316
+ readonly decideCommit: string;
317
+ /** Revision evaluated by the passing `review`. */
318
+ readonly evaluatedRevision: string;
319
+ /** Reported status when `review` did not pass the commit. */
320
+ readonly reviewStatus: ReviewStatus | null;
321
+ /** Compact failure DECIDE reports when `review` did not pass the commit. */
322
+ readonly reviewError: CompactError | null;
323
+ /** Sanitized evidence of the `review` result that ended DECIDE. */
324
+ readonly reviewEvidence: CompletedReviewResult | null;
325
+ readonly lastError: ErrorRecord | null;
326
+ readonly pendingBossQuestions: PendingBossQuestions;
327
+ readonly bossReplies: BossReplies;
328
+ }
329
+
330
+ export type DecideEvent =
331
+ | { readonly type: 'START_DECIDE'; readonly callerTopic: string }
332
+ | {
333
+ readonly type: 'BOSS_INTERRUPT';
334
+ readonly targetId: 'independentProposals';
335
+ /** The new topic both proposal players receive. */
336
+ readonly callerTopic: string;
337
+ }
338
+ | {
339
+ readonly type: 'BOSS_INTERRUPT';
340
+ readonly targetId: 'synthesizeCommit';
341
+ }
342
+ | {
343
+ readonly type: 'BOSS_REPLY';
344
+ readonly questionId: string;
345
+ readonly answer: string;
346
+ };
347
+
348
+ /** Exactly the catalog's public `decide` output interface. */
349
+ export type DecideOutput =
350
+ | {
351
+ readonly decideCommit: string;
352
+ readonly evaluatedRevision: string;
353
+ readonly noUnsettledFindings: true;
354
+ }
355
+ | {
356
+ readonly lastDecideCommit: string;
357
+ readonly noUnsettledFindings: false;
358
+ readonly reviewStatus: ReviewStatus;
359
+ readonly error?: CompactError;
360
+ };
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // State descriptions and public metadata
364
+ // ---------------------------------------------------------------------------
365
+
366
+ const STATE_DESCRIPTIONS = {
367
+ ready: 'Waiting for the caller to give a topic to decide.',
368
+ independentProposals:
369
+ 'Coder and Reviewer independently propose designs for the topic in parallel.',
370
+ coderProposalRegion: "Coder's independent proposal branch.",
371
+ askCoderProposal: 'Coder is independently proposing a design for the topic.',
372
+ awaitCoderProposalReply:
373
+ "Waiting for Boss to answer Coder's question about its independent proposal.",
374
+ coderProposalStaged:
375
+ "Coder's independent proposal is complete and staged for the join.",
376
+ reviewerProposalRegion: "Reviewer's independent proposal branch.",
377
+ askReviewerProposal:
378
+ 'Reviewer is independently proposing a design for the topic.',
379
+ awaitReviewerProposalReply:
380
+ "Waiting for Boss to answer Reviewer's question about its independent proposal.",
381
+ reviewerProposalStaged:
382
+ "Reviewer's independent proposal is complete and staged for the join.",
383
+ synthesizeCommit:
384
+ 'Coder synthesizes both proposals into the necessary DRs and/or spec items and commits the result as one new commit.',
385
+ awaitBossReply: "Waiting for Boss to answer the acting agent's question.",
386
+ reviewCommit: 'The review playbook examines the decide-owned commit.',
387
+ failed:
388
+ 'DECIDE parked after a control-plane failure and waits for Boss to restart or resume it.',
389
+ reportedReviewFailure:
390
+ "DECIDE reported review's abort, failure, or unestablished result to its caller with the last decide-owned commit.",
391
+ done: 'DECIDE completed: review established no unsettled findings for the decide-owned commit at the reported evaluated revision.',
392
+ } as const;
393
+
394
+ type DescribedStateId = keyof typeof STATE_DESCRIPTIONS;
395
+
396
+ function playbookMeta(stateId: DescribedStateId, role?: DecideRoleId) {
237
397
  return {
238
- coderOutput: output.coderOutput,
239
- latestCommit: output.latestCommit,
398
+ playbook: {
399
+ stateId,
400
+ description: STATE_DESCRIPTIONS[stateId],
401
+ ...(role === undefined ? {} : { role }),
402
+ },
240
403
  };
241
404
  }
242
405
 
243
- const committed = ({ event }: { event: unknown }) =>
244
- commitOutput(event) !== undefined;
406
+ function terminalMeta(
407
+ stateId: 'reportedReviewFailure' | 'done',
408
+ terminal: 'success' | 'failure',
409
+ ) {
410
+ return {
411
+ playbook: {
412
+ stateId,
413
+ description: STATE_DESCRIPTIONS[stateId],
414
+ terminal,
415
+ },
416
+ };
417
+ }
245
418
 
246
- const needsBossReplyWithQuestion = ({ event }: { event: unknown }) => {
247
- const output = outputOf(event);
248
- return output.guard === 'needsBossReply' && isNonEmptyString(output.question);
249
- };
419
+ // ---------------------------------------------------------------------------
420
+ // Structural narrowing helpers
421
+ // ---------------------------------------------------------------------------
422
+
423
+ function isObject(value: unknown): value is Readonly<Record<string, unknown>> {
424
+ return typeof value === 'object' && value !== null;
425
+ }
250
426
 
251
- const needsBossReplyWithoutQuestion = ({ event }: { event: unknown }) => {
252
- const output = outputOf(event);
427
+ function isPlainRecord(
428
+ value: unknown,
429
+ ): value is Readonly<Record<string, unknown>> {
430
+ if (!isObject(value) || Array.isArray(value)) return false;
431
+ const prototype = Object.getPrototypeOf(value);
432
+ return prototype === Object.prototype || prototype === null;
433
+ }
434
+
435
+ function isNonEmptyString(value: unknown): value is string {
436
+ return typeof value === 'string' && value.trim().length > 0;
437
+ }
438
+
439
+ function isResumableStateId(value: unknown): value is ResumableStateId {
253
440
  return (
254
- output.guard === 'needsBossReply' && !isNonEmptyString(output.question)
441
+ value === 'askCoderProposal' ||
442
+ value === 'askReviewerProposal' ||
443
+ value === 'synthesizeCommit'
255
444
  );
256
- };
445
+ }
257
446
 
258
- const pendingQuestionIds = (context: DecideContext): ResumableStateId[] =>
259
- Object.keys(context.pendingBossQuestions ?? {}) as ResumableStateId[];
447
+ /** The actor's `output`, narrowed structurally from an unknown done event. */
448
+ function actorOutput(
449
+ event: unknown,
450
+ ): Readonly<Record<string, unknown>> | undefined {
451
+ if (!isObject(event)) return undefined;
452
+ const output = event.output;
453
+ return isPlainRecord(output) ? output : undefined;
454
+ }
260
455
 
261
- const resolvedQuestionId = (
262
- context: DecideContext,
263
- event: DecideEvent,
264
- ): ResumableStateId | undefined => {
265
- if (event.type !== 'BOSS_REPLY') return undefined;
266
- if (event.questionId) return event.questionId;
267
- const ids = pendingQuestionIds(context);
268
- return ids.length === 1 ? ids[0] : undefined;
269
- };
456
+ function outputString(event: unknown, field: string): string | undefined {
457
+ const value = actorOutput(event)?.[field];
458
+ return isNonEmptyString(value) ? value : undefined;
459
+ }
270
460
 
271
- const bossReplyTargets =
272
- (stateId: ResumableStateId, requireAnswer: boolean) =>
273
- ({ context, event }: { context: DecideContext; event: DecideEvent }) =>
274
- event.type === 'BOSS_REPLY' &&
275
- resolvedQuestionId(context, event) === stateId &&
276
- (requireAnswer
277
- ? event.answer.trim().length > 0
278
- : event.answer.trim().length === 0);
461
+ function outputGuardIs(event: unknown, guard: PlayerOutput['guard']): boolean {
462
+ return actorOutput(event)?.guard === guard;
463
+ }
279
464
 
280
- const bossReplyFields = (
281
- context: DecideContext,
282
- stateId: ResumableStateId,
283
- ) => ({
284
- ...(context.pendingBossQuestions?.[stateId]
285
- ? { pendingBossQuestion: context.pendingBossQuestions[stateId] }
286
- : {}),
287
- ...(context.bossReplies?.[stateId]
288
- ? { bossReply: context.bossReplies[stateId] }
289
- : {}),
290
- });
465
+ /** The actor's `error`, narrowed structurally from an unknown error event. */
466
+ function actorError(event: unknown): unknown {
467
+ return isObject(event) ? event.error : undefined;
468
+ }
469
+
470
+ /** Normalizes any thrown or malformed value to a JSON-safe error record. */
471
+ function errorRecord(value: unknown): ErrorRecord {
472
+ if (value instanceof Error) {
473
+ return {
474
+ name: isNonEmptyString(value.name) ? value.name : 'Error',
475
+ message: typeof value.message === 'string' ? value.message : '',
476
+ ...(typeof value.stack === 'string' ? { stack: value.stack } : {}),
477
+ };
478
+ }
479
+ if (isObject(value) && typeof value.message === 'string') {
480
+ return {
481
+ name: isNonEmptyString(value.name) ? value.name : 'Error',
482
+ message: value.message,
483
+ };
484
+ }
485
+ return {
486
+ name: 'Error',
487
+ message:
488
+ typeof value === 'string'
489
+ ? value
490
+ : 'The actor failed with a non-Error value.',
491
+ };
492
+ }
291
493
 
292
- const withoutKey = <T>(
293
- record: Partial<Record<ResumableStateId, T>> | undefined,
494
+ function withoutKey<Value>(
495
+ record: Partial<Record<ResumableStateId, Value>>,
294
496
  stateId: ResumableStateId,
295
- ): Partial<Record<ResumableStateId, T>> | undefined => {
296
- if (!record || record[stateId] === undefined) return record;
297
- const next = { ...record };
298
- delete next[stateId];
299
- return Object.keys(next).length > 0 ? next : undefined;
300
- };
497
+ ): Partial<Record<ResumableStateId, Value>> {
498
+ const next: Partial<Record<ResumableStateId, Value>> = {};
499
+ for (const key of Object.keys(record)) {
500
+ if (!isResumableStateId(key) || key === stateId) continue;
501
+ const value = record[key];
502
+ if (value !== undefined) next[key] = value;
503
+ }
504
+ return next;
505
+ }
301
506
 
302
- const withNeedsBossReply = <T extends Record<string, string>>(result: T) => ({
303
- ...result,
304
- needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
305
- });
507
+ /**
508
+ * Strict JSON validation: null, booleans, finite numbers, strings, exact
509
+ * arrays, and plain records of enumerable own data properties. Cycle
510
+ * detection tracks only the active recursion path.
511
+ */
512
+ function isJsonValue(
513
+ value: unknown,
514
+ path: Set<object> = new Set(),
515
+ ): value is JsonValue {
516
+ if (value === null) return true;
517
+ switch (typeof value) {
518
+ case 'boolean':
519
+ case 'string':
520
+ return true;
521
+ case 'number':
522
+ return Number.isFinite(value);
523
+ case 'object':
524
+ break;
525
+ default:
526
+ return false;
527
+ }
528
+ const container = value as object;
529
+ if (path.has(container)) return false;
530
+ path.add(container);
531
+ try {
532
+ if (Array.isArray(container)) {
533
+ if (Object.getPrototypeOf(container) !== Array.prototype) return false;
534
+ const keys = Reflect.ownKeys(container);
535
+ if (keys.length !== container.length + 1) return false;
536
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(
537
+ container,
538
+ 'length',
539
+ );
540
+ if (
541
+ lengthDescriptor === undefined ||
542
+ lengthDescriptor.configurable !== false ||
543
+ lengthDescriptor.enumerable !== false ||
544
+ lengthDescriptor.value !== container.length
545
+ ) {
546
+ return false;
547
+ }
548
+ for (let index = 0; index < container.length; index += 1) {
549
+ const descriptor = Object.getOwnPropertyDescriptor(
550
+ container,
551
+ String(index),
552
+ );
553
+ if (
554
+ descriptor === undefined ||
555
+ !descriptor.enumerable ||
556
+ !('value' in descriptor) ||
557
+ !isJsonValue(descriptor.value, path)
558
+ ) {
559
+ return false;
560
+ }
561
+ }
562
+ return true;
563
+ }
564
+ const prototype = Object.getPrototypeOf(container);
565
+ if (prototype !== Object.prototype && prototype !== null) return false;
566
+ for (const key of Reflect.ownKeys(container)) {
567
+ if (typeof key !== 'string') return false;
568
+ const descriptor = Object.getOwnPropertyDescriptor(container, key);
569
+ if (
570
+ descriptor === undefined ||
571
+ !descriptor.enumerable ||
572
+ !('value' in descriptor) ||
573
+ !isJsonValue(descriptor.value, path)
574
+ ) {
575
+ return false;
576
+ }
577
+ }
578
+ return true;
579
+ } finally {
580
+ path.delete(container);
581
+ }
582
+ }
583
+
584
+ // ---------------------------------------------------------------------------
585
+ // Actor-output narrowing (declared per-state result contracts)
586
+ // ---------------------------------------------------------------------------
306
587
 
307
- const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
308
- typeof value === 'object' &&
309
- value !== null &&
310
- !Array.isArray(value) &&
311
- (Object.getPrototypeOf(value) === Object.prototype ||
312
- Object.getPrototypeOf(value) === null);
313
-
314
- const isExactKeys = (
315
- value: Record<string, unknown>,
316
- keys: readonly string[],
317
- ): boolean => {
318
- const ownKeys = Reflect.ownKeys(value);
588
+ function isCoderProposed(event: unknown): boolean {
319
589
  return (
320
- ownKeys.length === keys.length &&
321
- ownKeys.every((key) => typeof key === 'string' && keys.includes(key))
590
+ outputGuardIs(event, 'proposed') &&
591
+ outputString(event, 'coderProposal') !== undefined
322
592
  );
323
- };
593
+ }
324
594
 
325
- function isStateValue(value: unknown, ancestors = new Set<object>()): boolean {
326
- if (typeof value === 'string') return true;
327
- if (!isPlainRecord(value) || ancestors.has(value)) return false;
328
- const next = new Set(ancestors).add(value);
329
- return Reflect.ownKeys(value).every(
330
- (key) =>
331
- typeof key === 'string' &&
332
- Object.prototype.propertyIsEnumerable.call(value, key) &&
333
- isStateValue(value[key], next),
595
+ function isReviewerProposed(event: unknown): boolean {
596
+ return (
597
+ outputGuardIs(event, 'proposed') &&
598
+ outputString(event, 'reviewerProposal') !== undefined
334
599
  );
335
600
  }
336
601
 
337
- function isStringArray(value: unknown): value is readonly string[] {
602
+ function isCommitted(event: unknown): boolean {
338
603
  return (
339
- Array.isArray(value) &&
340
- Reflect.ownKeys(value).length === value.length + 1 &&
341
- Reflect.ownKeys(value).every((key) => {
342
- if (key === 'length') return true;
343
- return (
344
- typeof key === 'string' &&
345
- /^(0|[1-9][0-9]*)$/.test(key) &&
346
- Number(key) < value.length
347
- );
348
- }) &&
349
- value.every((entry) => isNonEmptyString(entry)) &&
350
- new Set(value).size === value.length
604
+ outputGuardIs(event, 'committed') &&
605
+ outputString(event, 'coderOutput') !== undefined &&
606
+ outputString(event, 'latestCommit') !== undefined
351
607
  );
352
608
  }
353
609
 
354
- function isPlaybookState(value: unknown): boolean {
355
- if (!isPlainRecord(value)) return false;
356
- const allowed = new Set([
357
- 'value',
358
- 'activeStateIds',
359
- 'tags',
360
- 'status',
361
- 'quiescent',
362
- 'stateId',
363
- ]);
364
- if (
365
- Reflect.ownKeys(value).some(
366
- (key) => typeof key !== 'string' || !allowed.has(key),
367
- ) ||
368
- !Object.prototype.hasOwnProperty.call(value, 'value') ||
369
- !Object.prototype.hasOwnProperty.call(value, 'activeStateIds') ||
370
- !Object.prototype.hasOwnProperty.call(value, 'tags') ||
371
- !Object.prototype.hasOwnProperty.call(value, 'status') ||
372
- !Object.prototype.hasOwnProperty.call(value, 'quiescent')
373
- ) {
374
- return false;
375
- }
610
+ function isNeedsBossReply(event: unknown): boolean {
376
611
  return (
377
- isStateValue(value.value) &&
378
- isStringArray(value.activeStateIds) &&
379
- isStringArray(value.tags) &&
380
- (value.status === 'active' ||
381
- value.status === 'done' ||
382
- value.status === 'error' ||
383
- value.status === 'stopped') &&
384
- typeof value.quiescent === 'boolean' &&
385
- (!Object.prototype.hasOwnProperty.call(value, 'stateId') ||
386
- (isNonEmptyString(value.stateId) &&
387
- value.activeStateIds.length === 1 &&
388
- value.activeStateIds[0] === value.stateId))
612
+ outputGuardIs(event, 'needsBossReply') &&
613
+ outputString(event, 'question') !== undefined
389
614
  );
390
615
  }
391
616
 
392
- // DECIDE supplied `review` a scope rooted at the `decide`-owned commit, so a
393
- // result applies to that scope only when it is the exact declared terminal
394
- // contract: the evaluated repository revision plus the affirmative
395
- // no-unsettled-findings fact. Any other shape does not establish those facts.
396
- function reviewSuccessFrom(event: unknown): ReviewSuccessOutput | undefined {
397
- const output = playbookOutputOf(event);
398
- if (!isPlainRecord(output)) return undefined;
399
- if (!isExactKeys(output, ['evaluatedRevision', 'noUnsettledFindings'])) {
617
+ // ---------------------------------------------------------------------------
618
+ // Nested `review` results
619
+ // ---------------------------------------------------------------------------
620
+
621
+ /**
622
+ * Recognizes an authored rejected child result: a validated public result
623
+ * whose status is `aborted` or `error`, or `ok` at the child's own authored
624
+ * failure terminal. Anything else is a control-plane error.
625
+ */
626
+ export function authoredChildResult(
627
+ error: unknown,
628
+ expectedPlaybookId: string,
629
+ ): PlaybookCallResult | undefined {
630
+ if (!(error instanceof Error)) return undefined;
631
+ try {
632
+ const result = validatePlaybookCallResult(
633
+ (error as Error & { result?: unknown }).result,
634
+ expectedPlaybookId,
635
+ );
636
+ return result.status !== 'ok' || result.terminal?.kind === 'failure'
637
+ ? result
638
+ : undefined;
639
+ } catch {
400
640
  return undefined;
401
641
  }
642
+ }
643
+
644
+ /**
645
+ * DECIDE-4's acceptance predicate on the builtin `review` public interface:
646
+ * the result gives the exact evaluated repository revision and affirmatively
647
+ * establishes that no unsettled findings remain. The shared bridge correlates
648
+ * the result with the supplied review scope.
649
+ */
650
+ function reviewApprovalFrom(event: unknown): ReviewOutput | undefined {
651
+ const output = actorOutput(event);
652
+ if (output === undefined) return undefined;
402
653
  if (
403
- !isNonEmptyString(output.evaluatedRevision) ||
404
- output.noUnsettledFindings !== true
654
+ output.noUnsettledFindings !== true ||
655
+ !isNonEmptyString(output.evaluatedRevision)
405
656
  ) {
406
657
  return undefined;
407
658
  }
408
659
  return {
409
- evaluatedRevision: output.evaluatedRevision,
410
660
  noUnsettledFindings: true,
661
+ evaluatedRevision: output.evaluatedRevision,
411
662
  };
412
663
  }
413
664
 
414
- const validReviewSuccess = ({ event }: { event: unknown }) =>
415
- reviewSuccessFrom(event) !== undefined;
416
-
417
- function normalizedChildFailure(error: unknown): ChildFailure | undefined {
418
- if (!(error instanceof Error)) return undefined;
419
- const result = (error as Error & { result?: unknown }).result;
420
- if (!isPlainRecord(result)) return undefined;
421
- const allowed = new Set([
422
- 'status',
423
- 'playbookId',
424
- 'childSessionId',
425
- 'state',
426
- 'error',
427
- ]);
428
- if (
429
- Reflect.ownKeys(result).some(
430
- (key) => typeof key !== 'string' || !allowed.has(key),
431
- ) ||
432
- (result.status !== 'aborted' && result.status !== 'error') ||
433
- result.playbookId !== 'review' ||
434
- Object.prototype.hasOwnProperty.call(result, 'output')
435
- ) {
436
- return undefined;
437
- }
438
- if (
439
- Object.prototype.hasOwnProperty.call(result, 'childSessionId') &&
440
- !isNonEmptyString(result.childSessionId)
441
- ) {
442
- return undefined;
443
- }
444
- if (
445
- Object.prototype.hasOwnProperty.call(result, 'state') &&
446
- !isPlaybookState(result.state)
447
- ) {
448
- return undefined;
449
- }
450
- let normalizedError: ChildFailure['error'];
451
- if (Object.prototype.hasOwnProperty.call(result, 'error')) {
452
- if (!isPlainRecord(result.error)) return undefined;
453
- const errorKeys = Reflect.ownKeys(result.error);
454
- if (
455
- errorKeys.some(
456
- (key) =>
457
- typeof key !== 'string' ||
458
- (key !== 'name' && key !== 'message' && key !== 'stack'),
459
- ) ||
460
- !Object.prototype.hasOwnProperty.call(result.error, 'name') ||
461
- !Object.prototype.hasOwnProperty.call(result.error, 'message')
462
- ) {
463
- return undefined;
464
- }
465
- if (
466
- !isNonEmptyString(result.error.name) ||
467
- typeof result.error.message !== 'string' ||
468
- (Object.prototype.hasOwnProperty.call(result.error, 'stack') &&
469
- typeof result.error.stack !== 'string')
470
- ) {
471
- return undefined;
665
+ /** Reported status, compact error, and evidence of an authored review failure. */
666
+ function authoredReviewFailureOf(event: unknown):
667
+ | {
668
+ readonly status: ReviewStatus;
669
+ readonly error: CompactError;
670
+ readonly evidence: CompletedReviewResult;
472
671
  }
473
- normalizedError = {
474
- name: result.error.name,
475
- message: result.error.message,
672
+ | undefined {
673
+ const result = authoredChildResult(actorError(event), REVIEW_PLAYBOOK_ID);
674
+ if (result === undefined) return undefined;
675
+ if (result.status === 'ok') {
676
+ // `ok` at review's own authored failure terminal: a review failure whose
677
+ // own output is relayed as evidence.
678
+ const description = result.terminal?.description;
679
+ return {
680
+ status: 'error',
681
+ error: {
682
+ name: 'ReviewFailed',
683
+ message: isNonEmptyString(description)
684
+ ? `review ended at its authored failure terminal: ${description}`
685
+ : 'review ended at its authored failure terminal.',
686
+ },
687
+ evidence: {
688
+ playbookId: REVIEW_PLAYBOOK_ID,
689
+ status: 'ok',
690
+ ...(result.output === undefined ? {} : { output: result.output }),
691
+ },
476
692
  };
477
- } else if (result.status === 'error') {
478
- return undefined;
479
693
  }
694
+ const error: CompactError =
695
+ result.error !== undefined
696
+ ? { name: result.error.name, message: result.error.message }
697
+ : { name: 'AbortError', message: 'The review call was aborted.' };
480
698
  return {
481
699
  status: result.status,
482
- playbookId: 'review',
483
- ...(normalizedError ? { error: normalizedError } : {}),
700
+ error,
701
+ evidence: { playbookId: REVIEW_PLAYBOOK_ID, status: result.status, error },
484
702
  };
485
703
  }
486
704
 
487
- const authoredReviewFailure = ({ event }: { event: unknown }) =>
488
- normalizedChildFailure((event as ActorErrorEvent).error) !== undefined;
489
-
490
- function bossInterrupts(ids: readonly JumpableStateId[]): any[] {
491
- return ids.map((id) => ({
492
- guard: ({ event }: { event: DecideEvent }) =>
493
- event.type === 'BOSS_INTERRUPT' &&
494
- event.targetId === id &&
495
- typeof event.bossIntent === 'string' &&
496
- event.bossIntent.trim().length > 0,
497
- target: `#${id}`,
498
- reenter: true,
499
- actions: 'copyInterruptedTopic',
500
- }));
705
+ const REVIEW_NOT_ESTABLISHED: CompactError = {
706
+ name: 'ReviewNotEstablished',
707
+ message:
708
+ 'review returned a terminal result that does not establish the exact evaluated repository revision with no unsettled findings.',
709
+ };
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // Nested call text composition (DECIDE-4 quoted relays)
713
+ // ---------------------------------------------------------------------------
714
+
715
+ const REVIEW_PLACEHOLDER =
716
+ /<caller-topic>|<decide-commit>|<coder-proposal>|<coder-output>/g;
717
+
718
+ /**
719
+ * Composes the `review` call text from the verbatim template, one line at a
720
+ * time: a quoted relay line whose value is empty contributes no line, and a
721
+ * multi-line value is quoted line by line. The composer adds no empty quoted
722
+ * line of its own.
723
+ */
724
+ function reviewCallText(context: DecideContext): string {
725
+ const values: Readonly<Record<string, string>> = {
726
+ '<caller-topic>': context.callerTopic,
727
+ '<decide-commit>': context.decideCommit,
728
+ '<coder-proposal>': context.coderProposal,
729
+ '<coder-output>': context.coderOutput,
730
+ };
731
+ const lines: string[] = [];
732
+ for (const line of REVIEW_COMMIT_TEMPLATE.split('\n')) {
733
+ const tokens = line.match(REVIEW_PLACEHOLDER) ?? [];
734
+ if (tokens.some((token) => values[token] === '')) continue;
735
+ lines.push(
736
+ line.replace(REVIEW_PLACEHOLDER, (token: string) =>
737
+ (values[token] ?? token).split('\n').join('\n> '),
738
+ ),
739
+ );
740
+ }
741
+ return lines.join('\n');
501
742
  }
502
743
 
503
- function resumableStates(ids: readonly ResumableStateId[]): any[] {
504
- return ids.map((id) => ({
505
- guard: ({ context, event }: { context: DecideContext; event: DecideEvent }) =>
506
- event.type === 'BOSS_REPLY' &&
507
- event.answer.trim().length > 0 &&
508
- resolvedQuestionId(context, event) === id,
509
- target: `#${id}`,
510
- reenter: true,
511
- actions: 'rememberBossReply',
512
- }));
744
+ // ---------------------------------------------------------------------------
745
+ // Player input mapping (pure reads of typed, entry-validated context)
746
+ // ---------------------------------------------------------------------------
747
+
748
+ function bossReplyFields(
749
+ context: DecideContext,
750
+ stateId: ResumableStateId,
751
+ ): PlayerInputBase {
752
+ const pendingBossQuestion = context.pendingBossQuestions[stateId];
753
+ const bossReply = context.bossReplies[stateId];
754
+ return {
755
+ ...(pendingBossQuestion === undefined ? {} : { pendingBossQuestion }),
756
+ ...(bossReply === undefined ? {} : { bossReply }),
757
+ };
513
758
  }
514
759
 
515
- const stateMetadata: Record<
516
- ResumableStateId,
517
- { sourceItem: string; asker: { kind: 'role'; roleId: Role } }
518
- > = {
519
- askCoderProposal: {
520
- sourceItem: 'DECIDE-1',
521
- asker: { kind: 'role', roleId: 'coder' },
522
- },
523
- askReviewerProposal: {
524
- sourceItem: 'DECIDE-2',
525
- asker: { kind: 'role', roleId: 'reviewer' },
526
- },
527
- commitCoderProposal: {
528
- sourceItem: 'DECIDE-3',
529
- asker: { kind: 'role', roleId: 'coder' },
530
- },
760
+ // ---------------------------------------------------------------------------
761
+ // Context snapshots
762
+ // ---------------------------------------------------------------------------
763
+
764
+ const INITIAL_CONTEXT: DecideContext = {
765
+ callerTopic: '',
766
+ stagedCoderProposal: '',
767
+ stagedReviewerProposal: '',
768
+ coderProposal: '',
769
+ reviewerProposal: '',
770
+ coderOutput: '',
771
+ decideCommit: '',
772
+ evaluatedRevision: '',
773
+ reviewStatus: null,
774
+ reviewError: null,
775
+ reviewEvidence: null,
776
+ lastError: null,
777
+ pendingBossQuestions: {},
778
+ bossReplies: {},
779
+ };
780
+
781
+ /**
782
+ * A fresh run for a topic: clears every staged and promoted proposal, the
783
+ * commit, review evidence, errors, and every pending Boss question and reply.
784
+ */
785
+ function freshRun(callerTopic: string): DecideContext {
786
+ return { ...INITIAL_CONTEXT, callerTopic };
787
+ }
788
+
789
+ /** Context reset shared by every exit into `failed`. */
790
+ const FAILURE_RESET = {
791
+ pendingBossQuestions: {},
792
+ bossReplies: {},
793
+ stagedCoderProposal: '',
794
+ stagedReviewerProposal: '',
795
+ } as const;
796
+
797
+ /**
798
+ * A synthesis restart keeps the topic and the promoted proposals, and clears
799
+ * the prior commit, review evidence, error, and every pending question.
800
+ */
801
+ const SYNTHESIS_RESTART = {
802
+ coderOutput: '',
803
+ decideCommit: '',
804
+ evaluatedRevision: '',
805
+ reviewStatus: null,
806
+ reviewError: null,
807
+ reviewEvidence: null,
808
+ lastError: null,
809
+ pendingBossQuestions: {},
810
+ bossReplies: {},
811
+ } as const;
812
+
813
+ // ---------------------------------------------------------------------------
814
+ // Transition helpers
815
+ // ---------------------------------------------------------------------------
816
+
817
+ /**
818
+ * Accepted-outcome marker (artifact schema 3). `target` names the public
819
+ * state the next snapshot shows after the transition's macrostep.
820
+ */
821
+ function acceptedOutcome(
822
+ source: ResumableStateId,
823
+ target: DescribedStateId,
824
+ outcome: 'proposed' | 'committed' | 'needsBossReply',
825
+ ) {
826
+ return {
827
+ type: 'playbook.acceptedOutcome',
828
+ params: { source, target, acceptedOutcome: outcome },
829
+ } as const;
830
+ }
831
+
832
+ /** Root `BOSS_INTERRUPT` arms, one per jumpable state. */
833
+ function bossInterrupts<const Ids extends readonly JumpableStateId[]>(
834
+ ids: Ids,
835
+ ) {
836
+ return ids.map(
837
+ (targetId) =>
838
+ ({
839
+ guard: { type: 'interruptTargets', params: { targetId } },
840
+ target: `#${targetId}`,
841
+ reenter: true,
842
+ actions: 'restartFromInterrupt',
843
+ }) as const,
844
+ );
845
+ }
846
+
847
+ /** Keyed `BOSS_REPLY` arms of the wait that resumes the given working leaves. */
848
+ function resumableStates<const Ids extends readonly ResumableStateId[]>(
849
+ ids: Ids,
850
+ ) {
851
+ return ids.flatMap((stateId) => [
852
+ {
853
+ guard: { type: 'bossReplyIsEmpty', params: { stateId } },
854
+ target: '#failed',
855
+ actions: 'rememberEmptyBossReply',
856
+ } as const,
857
+ {
858
+ guard: { type: 'bossReplyResumes', params: { stateId } },
859
+ target: `#${stateId}`,
860
+ reenter: true,
861
+ actions: { type: 'rememberBossReply', params: { stateId } },
862
+ } as const,
863
+ ]);
864
+ }
865
+
866
+ /**
867
+ * At the root synthesis wait, where no other task is active, a keyed
868
+ * `BOSS_REPLY` that names no pending question fails the run.
869
+ */
870
+ const unknownBossReplyArm = {
871
+ guard: 'bossReplyNamesNoPendingQuestion',
872
+ target: '#failed',
873
+ actions: 'rememberUnknownBossReply',
874
+ } as const;
875
+
876
+ const startDecideArm = {
877
+ guard: 'validStartTopic',
878
+ target: '#independentProposals',
879
+ actions: 'startDecide',
880
+ } as const;
881
+
882
+ function suspendArm(
883
+ stateId: ResumableStateId,
884
+ wait: 'awaitCoderProposalReply' | 'awaitReviewerProposalReply' | 'awaitBossReply',
885
+ ) {
886
+ return {
887
+ guard: 'needsBossReply',
888
+ target: `#${wait}`,
889
+ actions: [
890
+ acceptedOutcome(stateId, wait, 'needsBossReply'),
891
+ { type: 'setPendingBossQuestion', params: { stateId } },
892
+ ],
893
+ } as const;
894
+ }
895
+
896
+ function malformedPlayerOutputArm(sourceItem: DecideSourceItem) {
897
+ return {
898
+ target: '#failed',
899
+ actions: { type: 'rememberMalformedPlayerOutput', params: { sourceItem } },
900
+ } as const;
901
+ }
902
+
903
+ const playerOnError = {
904
+ target: '#failed',
905
+ actions: 'rememberActorError',
906
+ } as const;
907
+
908
+ type EventArgs = { readonly event: unknown };
909
+ type ContextEventArgs = {
910
+ readonly context: DecideContext;
911
+ readonly event: unknown;
531
912
  };
532
913
 
914
+ // ---------------------------------------------------------------------------
915
+ // Machine
916
+ // ---------------------------------------------------------------------------
917
+
533
918
  export const decideMachine = setup({
534
919
  types: {
535
920
  context: {} as DecideContext,
@@ -541,10 +926,75 @@ export const decideMachine = setup({
541
926
  player: fromPromise<PlayerOutput, PlayerInput>(async () => {
542
927
  throw new Error('player actor must be provided by the runner');
543
928
  }),
544
- playbook: fromPromise<JsonValue | undefined, PlaybookInput>(async () => {
929
+ playbook: fromPromise<PlaybookOutput, PlaybookInput>(async () => {
545
930
  throw new Error('playbook actor must be provided by the runner');
546
931
  }),
547
932
  },
933
+ guards: {
934
+ validStartTopic: ({ event }) =>
935
+ event.type === 'START_DECIDE' && isNonEmptyString(event.callerTopic),
936
+ // Each arm guards its target id and every typed precondition for
937
+ // entering that target safely.
938
+ interruptTargets: (
939
+ { context, event },
940
+ params: { readonly targetId: JumpableStateId },
941
+ ) => {
942
+ if (event.type !== 'BOSS_INTERRUPT') return false;
943
+ if (event.targetId !== params.targetId) return false;
944
+ if (event.targetId === 'independentProposals') {
945
+ return isNonEmptyString(event.callerTopic);
946
+ }
947
+ return (
948
+ isNonEmptyString(context.callerTopic) &&
949
+ isNonEmptyString(context.coderProposal) &&
950
+ isNonEmptyString(context.reviewerProposal)
951
+ );
952
+ },
953
+ // A branch completing after its sibling has staged its proposal also
954
+ // completes the join, so its accepted outcome lands on `synthesizeCommit`.
955
+ coderProposedJoining: ({ context, event }: ContextEventArgs) =>
956
+ isCoderProposed(event) &&
957
+ isNonEmptyString(context.stagedReviewerProposal) &&
958
+ isNonEmptyString(context.callerTopic),
959
+ coderProposed: ({ event }: EventArgs) => isCoderProposed(event),
960
+ reviewerProposedJoining: ({ context, event }: ContextEventArgs) =>
961
+ isReviewerProposed(event) &&
962
+ isNonEmptyString(context.stagedCoderProposal) &&
963
+ isNonEmptyString(context.callerTopic),
964
+ reviewerProposed: ({ event }: EventArgs) => isReviewerProposed(event),
965
+ committed: ({ context, event }: ContextEventArgs) =>
966
+ isCommitted(event) &&
967
+ isNonEmptyString(context.callerTopic) &&
968
+ isNonEmptyString(context.coderProposal),
969
+ needsBossReply: ({ event }: EventArgs) => isNeedsBossReply(event),
970
+ reviewApproved: ({ context, event }: ContextEventArgs) =>
971
+ isNonEmptyString(context.decideCommit) &&
972
+ reviewApprovalFrom(event) !== undefined,
973
+ authoredReviewFailure: ({ context, event }: ContextEventArgs) =>
974
+ isNonEmptyString(context.decideCommit) &&
975
+ authoredReviewFailureOf(event) !== undefined,
976
+ bossReplyNamesNoPendingQuestion: ({ context, event }) =>
977
+ event.type === 'BOSS_REPLY' &&
978
+ (!isResumableStateId(event.questionId) ||
979
+ context.pendingBossQuestions[event.questionId] === undefined),
980
+ bossReplyIsEmpty: (
981
+ { context, event },
982
+ params: { readonly stateId: ResumableStateId },
983
+ ) =>
984
+ event.type === 'BOSS_REPLY' &&
985
+ event.questionId === params.stateId &&
986
+ context.pendingBossQuestions[params.stateId] !== undefined &&
987
+ !isNonEmptyString(event.answer),
988
+ bossReplyResumes: (
989
+ { context, event },
990
+ params: { readonly stateId: ResumableStateId },
991
+ ) =>
992
+ event.type === 'BOSS_REPLY' &&
993
+ event.questionId === params.stateId &&
994
+ context.pendingBossQuestions[params.stateId] !== undefined &&
995
+ isNonEmptyString(event.answer) &&
996
+ isNonEmptyString(context.callerTopic),
997
+ },
548
998
  actions: {
549
999
  'playbook.acceptedOutcome': (
550
1000
  _args,
@@ -554,702 +1004,474 @@ export const decideMachine = setup({
554
1004
  readonly acceptedOutcome: string;
555
1005
  },
556
1006
  ) => undefined,
557
- copyStartTopic: assign({
558
- callerTopic: ({ event }) =>
559
- event.type === 'START_DECIDE' ? event.callerTopic : undefined,
560
- coderProposal: undefined,
561
- reviewerProposal: undefined,
562
- latestCommit: undefined,
563
- coderOutput: undefined,
564
- reviewResult: undefined,
565
- reviewFailure: undefined,
566
- lastResult: undefined,
567
- lastError: undefined,
568
- pendingBossQuestions: undefined,
569
- bossReplies: undefined,
570
- stagedCoderResult: undefined,
571
- stagedReviewerResult: undefined,
572
- }),
573
- copyInterruptedTopic: assign({
574
- callerTopic: ({ event }) =>
575
- event.type === 'BOSS_INTERRUPT' ? event.bossIntent : undefined,
576
- coderProposal: undefined,
577
- reviewerProposal: undefined,
578
- latestCommit: undefined,
579
- coderOutput: undefined,
580
- reviewResult: undefined,
581
- reviewFailure: undefined,
582
- lastResult: undefined,
583
- lastError: undefined,
584
- pendingBossQuestions: undefined,
585
- bossReplies: undefined,
586
- stagedCoderResult: undefined,
587
- stagedReviewerResult: undefined,
588
- }),
589
- stageCoderProposal: assign({
590
- stagedCoderResult: ({ event }) => outputOf(event),
591
- lastResult: ({ event }) => outputOf(event),
592
- lastError: undefined,
593
- }),
594
- stageReviewerProposal: assign({
595
- stagedReviewerResult: ({ event }) => outputOf(event),
596
- lastResult: ({ event }) => outputOf(event),
597
- lastError: undefined,
598
- }),
599
- promoteProposals: assign({
600
- coderProposal: ({ context }) =>
601
- context.stagedCoderResult?.coderProposal,
602
- reviewerProposal: ({ context }) =>
603
- context.stagedReviewerResult?.reviewerProposal,
604
- stagedCoderResult: undefined,
605
- stagedReviewerResult: undefined,
606
- lastResult: undefined,
607
- lastError: undefined,
608
- }),
609
- rememberCommit: assign({
610
- latestCommit: ({ event }) => outputOf(event).latestCommit,
611
- coderOutput: ({ event }) => outputOf(event).coderOutput,
612
- lastResult: ({ event }) => outputOf(event),
613
- lastError: undefined,
1007
+ startDecide: assign(({ event }) =>
1008
+ event.type === 'START_DECIDE' ? freshRun(event.callerTopic) : {},
1009
+ ),
1010
+ // A new topic restarts the whole proposal pair from a fresh run; a
1011
+ // synthesis restart keeps the topic and the promoted proposals.
1012
+ restartFromInterrupt: assign(({ event }) => {
1013
+ if (event.type !== 'BOSS_INTERRUPT') return {};
1014
+ return event.targetId === 'independentProposals'
1015
+ ? freshRun(event.callerTopic)
1016
+ : SYNTHESIS_RESTART;
614
1017
  }),
615
- rememberReviewSuccess: assign({
616
- reviewResult: ({ event }) => reviewSuccessFrom(event),
617
- reviewFailure: undefined,
618
- lastError: undefined,
619
- }),
620
- rememberReviewFailure: assign({
621
- reviewFailure: ({ event }) =>
622
- normalizedChildFailure((event as unknown as ActorErrorEvent).error),
623
- lastError: undefined,
624
- }),
625
- rememberReviewProtocolFailure: assign({
626
- reviewFailure: {
627
- status: 'error',
628
- playbookId: 'review',
629
- error: {
630
- name: 'ReviewProtocolError',
631
- message:
632
- 'REVIEW returned without the evaluated repository revision and a no-unsettled-findings result for the supplied scope.',
633
- },
1018
+ // Each branch assigns only its own staged result.
1019
+ stageCoderProposal: assign(({ context, event }) => ({
1020
+ stagedCoderProposal: outputString(event, 'coderProposal') ?? '',
1021
+ pendingBossQuestions: withoutKey(
1022
+ context.pendingBossQuestions,
1023
+ 'askCoderProposal',
1024
+ ),
1025
+ bossReplies: withoutKey(context.bossReplies, 'askCoderProposal'),
1026
+ })),
1027
+ stageReviewerProposal: assign(({ context, event }) => ({
1028
+ stagedReviewerProposal: outputString(event, 'reviewerProposal') ?? '',
1029
+ pendingBossQuestions: withoutKey(
1030
+ context.pendingBossQuestions,
1031
+ 'askReviewerProposal',
1032
+ ),
1033
+ bossReplies: withoutKey(context.bossReplies, 'askReviewerProposal'),
1034
+ })),
1035
+ // The join promotes both staged proposals atomically.
1036
+ promoteProposals: assign(({ context }) => ({
1037
+ coderProposal: context.stagedCoderProposal,
1038
+ reviewerProposal: context.stagedReviewerProposal,
1039
+ stagedCoderProposal: '',
1040
+ stagedReviewerProposal: '',
1041
+ pendingBossQuestions: {},
1042
+ bossReplies: {},
1043
+ })),
1044
+ rememberCommit: assign(({ context, event }) => ({
1045
+ coderOutput: outputString(event, 'coderOutput') ?? '',
1046
+ decideCommit: outputString(event, 'latestCommit') ?? '',
1047
+ lastError: null,
1048
+ pendingBossQuestions: withoutKey(
1049
+ context.pendingBossQuestions,
1050
+ 'synthesizeCommit',
1051
+ ),
1052
+ bossReplies: withoutKey(context.bossReplies, 'synthesizeCommit'),
1053
+ })),
1054
+ setPendingBossQuestion: assign(
1055
+ (
1056
+ { context, event },
1057
+ params: { readonly stateId: ResumableStateId },
1058
+ ) => {
1059
+ const question = outputString(event, 'question');
1060
+ if (question === undefined) return {};
1061
+ const { sourceItem, roleId } = RESUMABLE_STATES[params.stateId];
1062
+ const pending: PendingBossQuestion = {
1063
+ questionId: params.stateId,
1064
+ resumeStateId: params.stateId,
1065
+ sourceItem,
1066
+ asker: { kind: 'role', roleId },
1067
+ question,
1068
+ };
1069
+ return {
1070
+ pendingBossQuestions: {
1071
+ ...context.pendingBossQuestions,
1072
+ [params.stateId]: pending,
1073
+ },
1074
+ bossReplies: withoutKey(context.bossReplies, params.stateId),
1075
+ };
634
1076
  },
635
- lastError: undefined,
636
- }),
637
- rememberActorError: assign({
638
- lastError: ({ event }) =>
639
- (event as unknown as ActorErrorEvent).error,
640
- }),
641
- rememberMalformedActorOutput: assign({
642
- lastError: ({ event }) =>
643
- (event as unknown as PlayerDoneEvent | PlaybookDoneEvent).output,
644
- }),
645
- rememberMalformedBossReply: assign({
646
- lastError: ({ event }) => event,
647
- }),
648
- setPendingBossQuestion: assign({
649
- pendingBossQuestions: (
1077
+ ),
1078
+ rememberBossReply: assign(
1079
+ (
650
1080
  { context, event },
651
- params: PendingBossQuestionParams,
652
- ) => ({
653
- ...context.pendingBossQuestions,
654
- [params.resumeStateId]: {
655
- ...params,
656
- questionId: params.resumeStateId,
657
- question: outputOf(event).question ?? '',
658
- },
659
- }),
660
- bossReplies: ({ context }, params: PendingBossQuestionParams) =>
661
- withoutKey(context.bossReplies, params.resumeStateId),
662
- lastResult: ({ event }) => outputOf(event),
663
- lastError: undefined,
1081
+ params: { readonly stateId: ResumableStateId },
1082
+ ) =>
1083
+ event.type === 'BOSS_REPLY'
1084
+ ? {
1085
+ bossReplies: {
1086
+ ...context.bossReplies,
1087
+ [params.stateId]: event.answer,
1088
+ },
1089
+ }
1090
+ : {},
1091
+ ),
1092
+ rememberEmptyBossReply: assign({
1093
+ lastError: {
1094
+ name: 'MalformedBossReply',
1095
+ message: 'BOSS_REPLY carried an empty answer.',
1096
+ },
1097
+ ...FAILURE_RESET,
664
1098
  }),
665
- rememberBossReply: assign({
666
- bossReplies: ({ context, event }) => {
667
- const questionId = resolvedQuestionId(context, event);
668
- return event.type === 'BOSS_REPLY' && questionId
669
- ? { ...context.bossReplies, [questionId]: event.answer }
670
- : context.bossReplies;
1099
+ rememberUnknownBossReply: assign({
1100
+ lastError: {
1101
+ name: 'MalformedBossReply',
1102
+ message: 'BOSS_REPLY names no pending question.',
671
1103
  },
672
- lastError: undefined,
1104
+ ...FAILURE_RESET,
673
1105
  }),
674
- clearBranchBossReplyContext: assign({
675
- pendingBossQuestions: (
676
- { context },
677
- params: { stateId: ResumableStateId },
678
- ) => withoutKey(context.pendingBossQuestions, params.stateId),
679
- bossReplies: ({ context }, params: { stateId: ResumableStateId }) =>
680
- withoutKey(context.bossReplies, params.stateId),
1106
+ rememberMalformedPlayerOutput: assign(
1107
+ ({ event }, params: { readonly sourceItem: DecideSourceItem }) => {
1108
+ const output = actorOutput(event);
1109
+ return {
1110
+ lastError: {
1111
+ name: 'MalformedPlayerOutput',
1112
+ message:
1113
+ output?.guard === 'needsBossReply'
1114
+ ? `Player output for ${params.sourceItem} declared needsBossReply without a question.`
1115
+ : `Player output for ${params.sourceItem} did not match an outcome declared for its state.`,
1116
+ },
1117
+ ...FAILURE_RESET,
1118
+ };
1119
+ },
1120
+ ),
1121
+ rememberActorError: assign(({ event }) => ({
1122
+ lastError: errorRecord(actorError(event)),
1123
+ ...FAILURE_RESET,
1124
+ })),
1125
+ rememberReviewApproval: assign(({ event }) => {
1126
+ const approval = reviewApprovalFrom(event);
1127
+ if (approval === undefined) return {};
1128
+ const output = actorOutput(event);
1129
+ const reviewEvidence: CompletedReviewResult = {
1130
+ playbookId: REVIEW_PLAYBOOK_ID,
1131
+ status: 'ok',
1132
+ ...(isJsonValue(output) ? { output } : {}),
1133
+ };
1134
+ return {
1135
+ evaluatedRevision: approval.evaluatedRevision,
1136
+ reviewStatus: null,
1137
+ reviewError: null,
1138
+ reviewEvidence,
1139
+ lastError: null,
1140
+ };
681
1141
  }),
682
- clearProposalRoundContext: assign({
683
- pendingBossQuestions: undefined,
684
- bossReplies: undefined,
685
- stagedCoderResult: undefined,
686
- stagedReviewerResult: undefined,
1142
+ rememberUnestablishedReview: assign(({ event }) => {
1143
+ const output = actorOutput(event);
1144
+ const reviewEvidence: CompletedReviewResult = {
1145
+ playbookId: REVIEW_PLAYBOOK_ID,
1146
+ status: 'ok',
1147
+ ...(isJsonValue(output) ? { output } : {}),
1148
+ };
1149
+ return {
1150
+ evaluatedRevision: '',
1151
+ reviewStatus: 'error' as const,
1152
+ reviewError: REVIEW_NOT_ESTABLISHED,
1153
+ reviewEvidence,
1154
+ lastError: null,
1155
+ };
687
1156
  }),
688
- clearBossReplyContext: assign({
689
- pendingBossQuestions: undefined,
690
- bossReplies: undefined,
1157
+ rememberAuthoredReviewFailure: assign(({ event }) => {
1158
+ const failure = authoredReviewFailureOf(event);
1159
+ return failure === undefined
1160
+ ? {}
1161
+ : {
1162
+ evaluatedRevision: '',
1163
+ reviewStatus: failure.status,
1164
+ reviewError: failure.error,
1165
+ reviewEvidence: failure.evidence,
1166
+ lastError: null,
1167
+ };
691
1168
  }),
692
1169
  },
693
- guards: {
694
- coderProposed,
695
- reviewerProposed,
696
- committed,
697
- needsBossReplyWithQuestion,
698
- needsBossReplyWithoutQuestion,
699
- validReviewSuccess,
700
- authoredReviewFailure,
701
- },
702
1170
  }).createMachine({
703
1171
  id: 'decide',
1172
+ description:
1173
+ 'DECIDE: independent Coder and Reviewer proposals, a synthesized decide-owned commit, and a nested review.',
704
1174
  initial: 'ready',
705
- context: (): DecideContext => ({}),
1175
+ context: (): DecideContext => INITIAL_CONTEXT,
706
1176
  output: ({ context }): DecideOutput => {
707
- if (context.reviewResult) {
708
- if (!context.latestCommit) {
709
- throw new Error('DECIDE completed without its decide-owned commit');
710
- }
1177
+ const commit = context.decideCommit;
1178
+ if (!isNonEmptyString(commit)) {
1179
+ throw new Error(
1180
+ 'DECIDE reached a final state without its decide-owned commit',
1181
+ );
1182
+ }
1183
+ if (context.reviewStatus !== null) {
711
1184
  return {
712
- decideCommit: context.latestCommit,
713
- evaluatedRevision: context.reviewResult.evaluatedRevision,
714
- noUnsettledFindings: true,
1185
+ lastDecideCommit: commit,
1186
+ noUnsettledFindings: false,
1187
+ reviewStatus: context.reviewStatus,
1188
+ ...(context.reviewError === null ? {} : { error: context.reviewError }),
715
1189
  };
716
1190
  }
717
- if (!context.latestCommit || !context.reviewFailure) {
718
- throw new Error('DECIDE reached a final state without a result');
1191
+ const evaluatedRevision = context.evaluatedRevision;
1192
+ if (!isNonEmptyString(evaluatedRevision)) {
1193
+ throw new Error(
1194
+ 'DECIDE completed without the evaluated repository revision',
1195
+ );
719
1196
  }
720
1197
  return {
721
- lastDecideCommit: context.latestCommit,
722
- noUnsettledFindings: false,
723
- reviewStatus: context.reviewFailure.status,
724
- ...(context.reviewFailure.error
725
- ? { error: context.reviewFailure.error }
726
- : {}),
1198
+ decideCommit: commit,
1199
+ evaluatedRevision,
1200
+ noUnsettledFindings: true,
727
1201
  };
728
1202
  },
729
1203
  on: {
730
- BOSS_INTERRUPT: bossInterrupts(['independentProposals']),
1204
+ BOSS_INTERRUPT: bossInterrupts([
1205
+ 'independentProposals',
1206
+ 'synthesizeCommit',
1207
+ ] as const),
731
1208
  },
732
1209
  states: {
733
1210
  ready: {
734
1211
  id: 'ready',
735
- tags: 'playbook.parked',
736
- description: 'Waiting for a topic to decide.',
737
- meta: {
738
- playbook: {
739
- stateId: 'ready',
740
- description: 'Waiting for a topic to decide.',
741
- },
742
- },
1212
+ description: STATE_DESCRIPTIONS.ready,
1213
+ meta: playbookMeta('ready'),
1214
+ tags: ['playbook.parked'],
743
1215
  on: {
744
- START_DECIDE: {
745
- guard: ({ event }) => event.callerTopic.trim().length > 0,
746
- target: 'independentProposals',
747
- actions: 'copyStartTopic',
748
- },
1216
+ START_DECIDE: startDecideArm,
749
1217
  },
750
1218
  },
751
1219
  independentProposals: {
752
1220
  id: 'independentProposals',
1221
+ description: STATE_DESCRIPTIONS.independentProposals,
1222
+ meta: playbookMeta('independentProposals'),
753
1223
  type: 'parallel',
754
- description: 'Coder and Reviewer prepare independent proposals.',
755
- meta: {
756
- playbook: {
757
- stateId: 'independentProposals',
758
- description: 'Coder and Reviewer prepare independent proposals.',
759
- },
760
- },
1224
+ // No group-level BOSS_REPLY handler: a reply is handled only by the
1225
+ // branch wait whose pending question it names, and a reply naming no
1226
+ // pending branch question moves no branch.
761
1227
  states: {
762
- coder: {
763
- initial: 'working',
1228
+ coderProposalRegion: {
1229
+ id: 'coderProposalRegion',
1230
+ description: STATE_DESCRIPTIONS.coderProposalRegion,
1231
+ meta: playbookMeta('coderProposalRegion'),
1232
+ initial: 'askCoderProposal',
764
1233
  states: {
765
- working: {
1234
+ askCoderProposal: {
766
1235
  id: 'askCoderProposal',
767
- tags: 'playbook.busy',
768
- description: 'Coder independently proposes a spec design.',
769
- meta: {
770
- playbook: {
771
- stateId: 'askCoderProposal',
772
- description: 'Coder independently proposes a spec design.',
773
- role: 'coder',
774
- },
775
- },
1236
+ description: STATE_DESCRIPTIONS.askCoderProposal,
1237
+ meta: playbookMeta('askCoderProposal', 'coder'),
1238
+ tags: ['playbook.busy'],
776
1239
  invoke: {
777
1240
  src: 'player',
778
- input: ({ context }): PlayerInput => ({
1241
+ input: ({ context }): CoderProposalInput => ({
779
1242
  stateId: 'askCoderProposal',
780
- sourceItem: 'DECIDE-1',
781
1243
  role: 'coder',
782
- prompt: INDEPENDENT_PROPOSAL_PROMPT,
783
- result: withNeedsBossReply({
784
- proposed:
785
- 'Coder affirmatively provided a complete design proposal; a progress report, status update, or promise of a later proposal supports no proposal outcome. Output shall include `coderProposal: <verbatim final text>`.',
786
- }),
1244
+ sourceItem: 'DECIDE-1',
1245
+ prompt: CODER_PROPOSAL_PROMPT,
1246
+ result: CODER_PROPOSAL_RESULT,
787
1247
  callerTopic: context.callerTopic,
788
1248
  ...bossReplyFields(context, 'askCoderProposal'),
789
1249
  }),
790
1250
  onDone: [
1251
+ suspendArm('askCoderProposal', 'awaitCoderProposalReply'),
791
1252
  {
792
- guard: 'needsBossReplyWithQuestion',
793
- target: 'waiting',
794
- actions: [
795
- {
796
- type: 'playbook.acceptedOutcome',
797
- params: {
798
- source: 'askCoderProposal',
799
- target: 'waitCoderProposalReply',
800
- acceptedOutcome: 'needsBossReply',
801
- },
802
- },
803
- {
804
- type: 'setPendingBossQuestion',
805
- params: {
806
- ...stateMetadata.askCoderProposal,
807
- resumeStateId: 'askCoderProposal',
808
- question: '',
809
- },
810
- },
811
- ],
812
- },
813
- {
814
- guard: 'needsBossReplyWithoutQuestion',
815
- target: '#failed',
1253
+ guard: 'coderProposedJoining',
1254
+ target: 'coderProposalStaged',
816
1255
  actions: [
817
- 'rememberMalformedActorOutput',
818
- 'clearProposalRoundContext',
1256
+ acceptedOutcome(
1257
+ 'askCoderProposal',
1258
+ 'synthesizeCommit',
1259
+ 'proposed',
1260
+ ),
1261
+ 'stageCoderProposal',
819
1262
  ],
820
1263
  },
821
1264
  {
822
1265
  guard: 'coderProposed',
823
- target: 'complete',
1266
+ target: 'coderProposalStaged',
824
1267
  actions: [
825
- {
826
- type: 'playbook.acceptedOutcome',
827
- params: ({ context }) => ({
828
- source: 'askCoderProposal',
829
- target:
830
- context.stagedReviewerResult === undefined
831
- ? 'coderProposalComplete'
832
- : 'commitCoderProposal',
833
- acceptedOutcome: 'proposed',
834
- }),
835
- },
1268
+ acceptedOutcome(
1269
+ 'askCoderProposal',
1270
+ 'coderProposalStaged',
1271
+ 'proposed',
1272
+ ),
836
1273
  'stageCoderProposal',
837
- {
838
- type: 'clearBranchBossReplyContext',
839
- params: { stateId: 'askCoderProposal' },
840
- },
841
- ],
842
- },
843
- {
844
- target: '#failed',
845
- actions: [
846
- 'rememberMalformedActorOutput',
847
- 'clearProposalRoundContext',
848
1274
  ],
849
1275
  },
1276
+ malformedPlayerOutputArm('DECIDE-1'),
850
1277
  ],
851
- onError: {
852
- target: '#failed',
853
- actions: [
854
- 'rememberActorError',
855
- 'clearProposalRoundContext',
856
- ],
857
- },
1278
+ onError: playerOnError,
858
1279
  },
859
1280
  },
860
- waiting: {
861
- id: 'waitCoderProposalReply',
862
- tags: 'playbook.parked',
863
- description: 'Coder waits for Boss to answer a question.',
864
- meta: {
865
- playbook: {
866
- stateId: 'waitCoderProposalReply',
867
- description: 'Coder waits for Boss to answer a question.',
868
- },
869
- },
1281
+ awaitCoderProposalReply: {
1282
+ id: 'awaitCoderProposalReply',
1283
+ description: STATE_DESCRIPTIONS.awaitCoderProposalReply,
1284
+ meta: playbookMeta('awaitCoderProposalReply'),
1285
+ tags: ['playbook.parked'],
870
1286
  on: {
871
- BOSS_REPLY: [
872
- {
873
- guard: bossReplyTargets('askCoderProposal', false),
874
- target: '#failed',
875
- actions: [
876
- 'rememberMalformedBossReply',
877
- 'clearProposalRoundContext',
878
- ],
879
- },
880
- {
881
- guard: bossReplyTargets('askCoderProposal', true),
882
- target: 'working',
883
- actions: 'rememberBossReply',
884
- },
885
- ],
1287
+ BOSS_REPLY: resumableStates(['askCoderProposal'] as const),
886
1288
  },
887
1289
  },
888
- complete: {
1290
+ coderProposalStaged: {
1291
+ id: 'coderProposalStaged',
1292
+ description: STATE_DESCRIPTIONS.coderProposalStaged,
1293
+ meta: playbookMeta('coderProposalStaged'),
889
1294
  type: 'final',
890
- description: 'Coder proposal is staged for the parallel join.',
891
- meta: {
892
- playbook: {
893
- stateId: 'coderProposalComplete',
894
- description:
895
- 'Coder proposal is staged for the parallel join.',
896
- },
897
- },
898
1295
  },
899
1296
  },
900
1297
  },
901
- reviewer: {
902
- initial: 'working',
1298
+ reviewerProposalRegion: {
1299
+ id: 'reviewerProposalRegion',
1300
+ description: STATE_DESCRIPTIONS.reviewerProposalRegion,
1301
+ meta: playbookMeta('reviewerProposalRegion'),
1302
+ initial: 'askReviewerProposal',
903
1303
  states: {
904
- working: {
1304
+ askReviewerProposal: {
905
1305
  id: 'askReviewerProposal',
906
- tags: 'playbook.busy',
907
- description: 'Reviewer independently proposes a spec design.',
908
- meta: {
909
- playbook: {
910
- stateId: 'askReviewerProposal',
911
- description:
912
- 'Reviewer independently proposes a spec design.',
913
- role: 'reviewer',
914
- },
915
- },
1306
+ description: STATE_DESCRIPTIONS.askReviewerProposal,
1307
+ meta: playbookMeta('askReviewerProposal', 'reviewer'),
1308
+ tags: ['playbook.busy'],
916
1309
  invoke: {
917
1310
  src: 'player',
918
- input: ({ context }): PlayerInput => ({
1311
+ input: ({ context }): ReviewerProposalInput => ({
919
1312
  stateId: 'askReviewerProposal',
920
- sourceItem: 'DECIDE-2',
921
1313
  role: 'reviewer',
922
- prompt: INDEPENDENT_PROPOSAL_PROMPT,
923
- result: withNeedsBossReply({
924
- proposed:
925
- 'Reviewer affirmatively provided a complete design proposal; a progress report, status update, or promise of a later proposal supports no proposal outcome. Output shall include `reviewerProposal: <verbatim final text>`.',
926
- }),
1314
+ sourceItem: 'DECIDE-2',
1315
+ prompt: REVIEWER_PROPOSAL_PROMPT,
1316
+ result: REVIEWER_PROPOSAL_RESULT,
927
1317
  callerTopic: context.callerTopic,
928
1318
  ...bossReplyFields(context, 'askReviewerProposal'),
929
1319
  }),
930
1320
  onDone: [
1321
+ suspendArm('askReviewerProposal', 'awaitReviewerProposalReply'),
931
1322
  {
932
- guard: 'needsBossReplyWithQuestion',
933
- target: 'waiting',
1323
+ guard: 'reviewerProposedJoining',
1324
+ target: 'reviewerProposalStaged',
934
1325
  actions: [
935
- {
936
- type: 'playbook.acceptedOutcome',
937
- params: {
938
- source: 'askReviewerProposal',
939
- target: 'waitReviewerProposalReply',
940
- acceptedOutcome: 'needsBossReply',
941
- },
942
- },
943
- {
944
- type: 'setPendingBossQuestion',
945
- params: {
946
- ...stateMetadata.askReviewerProposal,
947
- resumeStateId: 'askReviewerProposal',
948
- question: '',
949
- },
950
- },
951
- ],
952
- },
953
- {
954
- guard: 'needsBossReplyWithoutQuestion',
955
- target: '#failed',
956
- actions: [
957
- 'rememberMalformedActorOutput',
958
- 'clearProposalRoundContext',
1326
+ acceptedOutcome(
1327
+ 'askReviewerProposal',
1328
+ 'synthesizeCommit',
1329
+ 'proposed',
1330
+ ),
1331
+ 'stageReviewerProposal',
959
1332
  ],
960
1333
  },
961
1334
  {
962
1335
  guard: 'reviewerProposed',
963
- target: 'complete',
1336
+ target: 'reviewerProposalStaged',
964
1337
  actions: [
965
- {
966
- type: 'playbook.acceptedOutcome',
967
- params: ({ context }) => ({
968
- source: 'askReviewerProposal',
969
- target:
970
- context.stagedCoderResult === undefined
971
- ? 'reviewerProposalComplete'
972
- : 'commitCoderProposal',
973
- acceptedOutcome: 'proposed',
974
- }),
975
- },
1338
+ acceptedOutcome(
1339
+ 'askReviewerProposal',
1340
+ 'reviewerProposalStaged',
1341
+ 'proposed',
1342
+ ),
976
1343
  'stageReviewerProposal',
977
- {
978
- type: 'clearBranchBossReplyContext',
979
- params: { stateId: 'askReviewerProposal' },
980
- },
981
- ],
982
- },
983
- {
984
- target: '#failed',
985
- actions: [
986
- 'rememberMalformedActorOutput',
987
- 'clearProposalRoundContext',
988
1344
  ],
989
1345
  },
1346
+ malformedPlayerOutputArm('DECIDE-2'),
990
1347
  ],
991
- onError: {
992
- target: '#failed',
993
- actions: [
994
- 'rememberActorError',
995
- 'clearProposalRoundContext',
996
- ],
997
- },
1348
+ onError: playerOnError,
998
1349
  },
999
1350
  },
1000
- waiting: {
1001
- id: 'waitReviewerProposalReply',
1002
- tags: 'playbook.parked',
1003
- description: 'Reviewer waits for Boss to answer a question.',
1004
- meta: {
1005
- playbook: {
1006
- stateId: 'waitReviewerProposalReply',
1007
- description:
1008
- 'Reviewer waits for Boss to answer a question.',
1009
- },
1010
- },
1351
+ awaitReviewerProposalReply: {
1352
+ id: 'awaitReviewerProposalReply',
1353
+ description: STATE_DESCRIPTIONS.awaitReviewerProposalReply,
1354
+ meta: playbookMeta('awaitReviewerProposalReply'),
1355
+ tags: ['playbook.parked'],
1011
1356
  on: {
1012
- BOSS_REPLY: [
1013
- {
1014
- guard: bossReplyTargets('askReviewerProposal', false),
1015
- target: '#failed',
1016
- actions: [
1017
- 'rememberMalformedBossReply',
1018
- 'clearProposalRoundContext',
1019
- ],
1020
- },
1021
- {
1022
- guard: bossReplyTargets('askReviewerProposal', true),
1023
- target: 'working',
1024
- actions: 'rememberBossReply',
1025
- },
1026
- ],
1357
+ BOSS_REPLY: resumableStates(['askReviewerProposal'] as const),
1027
1358
  },
1028
1359
  },
1029
- complete: {
1360
+ reviewerProposalStaged: {
1361
+ id: 'reviewerProposalStaged',
1362
+ description: STATE_DESCRIPTIONS.reviewerProposalStaged,
1363
+ meta: playbookMeta('reviewerProposalStaged'),
1030
1364
  type: 'final',
1031
- description:
1032
- 'Reviewer proposal is staged for the parallel join.',
1033
- meta: {
1034
- playbook: {
1035
- stateId: 'reviewerProposalComplete',
1036
- description:
1037
- 'Reviewer proposal is staged for the parallel join.',
1038
- },
1039
- },
1040
1365
  },
1041
1366
  },
1042
1367
  },
1043
1368
  },
1369
+ // The join: taken only after both regions reach final; it promotes both
1370
+ // staged proposals atomically before DECIDE-3 begins.
1044
1371
  onDone: {
1045
- target: 'commitCoderProposal',
1372
+ target: 'synthesizeCommit',
1046
1373
  actions: 'promoteProposals',
1047
1374
  },
1048
1375
  },
1049
- commitCoderProposal: {
1050
- id: 'commitCoderProposal',
1051
- tags: 'playbook.busy',
1052
- description: 'Coder synthesizes both proposals and commits the design.',
1053
- meta: {
1054
- playbook: {
1055
- stateId: 'commitCoderProposal',
1056
- description:
1057
- 'Coder synthesizes both proposals and commits the design.',
1058
- role: 'coder',
1059
- },
1060
- },
1376
+ synthesizeCommit: {
1377
+ id: 'synthesizeCommit',
1378
+ description: STATE_DESCRIPTIONS.synthesizeCommit,
1379
+ meta: playbookMeta('synthesizeCommit', 'coder'),
1380
+ tags: ['playbook.busy'],
1061
1381
  invoke: {
1062
1382
  src: 'player',
1063
- input: ({ context }): PlayerInput => ({
1064
- stateId: 'commitCoderProposal',
1065
- sourceItem: 'DECIDE-3',
1383
+ input: ({ context }): SynthesizeCommitInput => ({
1384
+ stateId: 'synthesizeCommit',
1066
1385
  role: 'coder',
1386
+ sourceItem: 'DECIDE-3',
1067
1387
  prompt: SYNTHESIZE_COMMIT_PROMPT,
1068
- result: withNeedsBossReply({
1069
- committed:
1070
- 'Coder synthesized both proposals and committed the resulting design as one new commit. Output shall include `coderOutput: <verbatim final text>` and `latestCommit: <commit identity>`.',
1071
- }),
1388
+ result: SYNTHESIZE_COMMIT_RESULT,
1072
1389
  callerTopic: context.callerTopic,
1073
1390
  reviewerProposal: context.reviewerProposal,
1074
- ...bossReplyFields(context, 'commitCoderProposal'),
1391
+ ...bossReplyFields(context, 'synthesizeCommit'),
1075
1392
  }),
1076
1393
  onDone: [
1077
- {
1078
- guard: 'needsBossReplyWithQuestion',
1079
- target: 'awaitBossReply',
1080
- actions: [
1081
- {
1082
- type: 'playbook.acceptedOutcome',
1083
- params: {
1084
- source: 'commitCoderProposal',
1085
- target: 'awaitBossReply',
1086
- acceptedOutcome: 'needsBossReply',
1087
- },
1088
- },
1089
- {
1090
- type: 'setPendingBossQuestion',
1091
- params: {
1092
- ...stateMetadata.commitCoderProposal,
1093
- resumeStateId: 'commitCoderProposal',
1094
- question: '',
1095
- },
1096
- },
1097
- ],
1098
- },
1099
- {
1100
- guard: 'needsBossReplyWithoutQuestion',
1101
- target: 'failed',
1102
- actions: [
1103
- 'rememberMalformedActorOutput',
1104
- 'clearBossReplyContext',
1105
- ],
1106
- },
1394
+ suspendArm('synthesizeCommit', 'awaitBossReply'),
1107
1395
  {
1108
1396
  guard: 'committed',
1109
1397
  target: 'reviewCommit',
1110
1398
  actions: [
1111
- {
1112
- type: 'playbook.acceptedOutcome',
1113
- params: {
1114
- source: 'commitCoderProposal',
1115
- target: 'reviewCommit',
1116
- acceptedOutcome: 'committed',
1117
- },
1118
- },
1399
+ acceptedOutcome('synthesizeCommit', 'reviewCommit', 'committed'),
1119
1400
  'rememberCommit',
1120
- {
1121
- type: 'clearBranchBossReplyContext',
1122
- params: { stateId: 'commitCoderProposal' },
1123
- },
1124
- ],
1125
- },
1126
- {
1127
- target: 'failed',
1128
- actions: [
1129
- 'rememberMalformedActorOutput',
1130
- 'clearBossReplyContext',
1131
1401
  ],
1132
1402
  },
1403
+ malformedPlayerOutputArm('DECIDE-3'),
1133
1404
  ],
1134
- onError: {
1135
- target: 'failed',
1136
- actions: ['rememberActorError', 'clearBossReplyContext'],
1137
- },
1405
+ onError: playerOnError,
1138
1406
  },
1139
1407
  },
1140
1408
  awaitBossReply: {
1141
1409
  id: 'awaitBossReply',
1142
- tags: 'playbook.parked',
1143
- description: 'Waiting for Boss to answer Coder’s question.',
1144
- meta: {
1145
- playbook: {
1146
- stateId: 'awaitBossReply',
1147
- description: 'Waiting for Boss to answer Coder’s question.',
1148
- },
1149
- },
1410
+ description: STATE_DESCRIPTIONS.awaitBossReply,
1411
+ meta: playbookMeta('awaitBossReply'),
1412
+ tags: ['playbook.parked'],
1150
1413
  on: {
1151
1414
  BOSS_REPLY: [
1152
- {
1153
- guard: bossReplyTargets('commitCoderProposal', false),
1154
- target: 'failed',
1155
- actions: [
1156
- 'rememberMalformedBossReply',
1157
- 'clearBossReplyContext',
1158
- ],
1159
- },
1160
- ...resumableStates(['commitCoderProposal']),
1415
+ unknownBossReplyArm,
1416
+ ...resumableStates(['synthesizeCommit'] as const),
1161
1417
  ],
1418
+ START_DECIDE: startDecideArm,
1162
1419
  },
1163
1420
  },
1164
1421
  reviewCommit: {
1165
1422
  id: 'reviewCommit',
1166
- tags: 'playbook.suspended',
1167
- description: 'REVIEW examines the committed proposal.',
1168
- meta: {
1169
- playbook: {
1170
- stateId: 'reviewCommit',
1171
- description: 'REVIEW examines the committed proposal.',
1172
- },
1173
- },
1423
+ description: STATE_DESCRIPTIONS.reviewCommit,
1424
+ meta: playbookMeta('reviewCommit'),
1425
+ tags: ['playbook.suspended'],
1174
1426
  invoke: {
1175
1427
  src: 'playbook',
1176
1428
  input: ({ context }): PlaybookInput => ({
1177
1429
  stateId: 'reviewCommit',
1178
1430
  sourceItem: 'DECIDE-4',
1179
- playbookId: 'review',
1180
- text: composeReviewInput(
1181
- context.callerTopic,
1182
- context.latestCommit,
1183
- context.coderOutput,
1184
- ),
1431
+ playbookId: REVIEW_PLAYBOOK_ID,
1432
+ text: reviewCallText(context),
1185
1433
  }),
1186
1434
  onDone: [
1187
1435
  {
1188
- guard: 'validReviewSuccess',
1436
+ guard: 'reviewApproved',
1189
1437
  target: 'done',
1190
- actions: 'rememberReviewSuccess',
1438
+ actions: 'rememberReviewApproval',
1191
1439
  },
1192
1440
  {
1193
1441
  target: 'reportedReviewFailure',
1194
- actions: 'rememberReviewProtocolFailure',
1442
+ actions: 'rememberUnestablishedReview',
1195
1443
  },
1196
1444
  ],
1197
1445
  onError: [
1198
1446
  {
1199
1447
  guard: 'authoredReviewFailure',
1200
1448
  target: 'reportedReviewFailure',
1201
- actions: 'rememberReviewFailure',
1202
- },
1203
- {
1204
- target: 'failed',
1205
- actions: 'rememberActorError',
1449
+ actions: 'rememberAuthoredReviewFailure',
1206
1450
  },
1451
+ { target: 'failed', actions: 'rememberActorError' },
1207
1452
  ],
1208
1453
  },
1209
1454
  },
1210
1455
  failed: {
1211
1456
  id: 'failed',
1212
- tags: 'playbook.parked',
1213
- description: 'DECIDE failed and is waiting for a new topic.',
1214
- meta: {
1215
- playbook: {
1216
- stateId: 'failed',
1217
- description: 'DECIDE failed and is waiting for a new topic.',
1218
- },
1219
- },
1457
+ description: STATE_DESCRIPTIONS.failed,
1458
+ meta: playbookMeta('failed'),
1459
+ tags: ['playbook.parked'],
1220
1460
  on: {
1221
- START_DECIDE: {
1222
- guard: ({ event }) => event.callerTopic.trim().length > 0,
1223
- target: 'independentProposals',
1224
- actions: 'copyStartTopic',
1225
- },
1461
+ START_DECIDE: startDecideArm,
1226
1462
  },
1227
1463
  },
1228
1464
  reportedReviewFailure: {
1229
1465
  id: 'reportedReviewFailure',
1466
+ description: STATE_DESCRIPTIONS.reportedReviewFailure,
1467
+ meta: terminalMeta('reportedReviewFailure', 'failure'),
1230
1468
  type: 'final',
1231
- description: 'DECIDE reports REVIEW’s failure and its last commit.',
1232
- meta: {
1233
- playbook: {
1234
- stateId: 'reportedReviewFailure',
1235
- description: 'DECIDE reports REVIEW’s failure and its last commit.',
1236
- // DR-048: this final state means the workflow failed, so a caller
1237
- // learns that from the machine rather than from DECIDE's output.
1238
- terminal: 'failure',
1239
- },
1240
- },
1241
1469
  },
1242
1470
  done: {
1243
1471
  id: 'done',
1472
+ description: STATE_DESCRIPTIONS.done,
1473
+ meta: terminalMeta('done', 'success'),
1244
1474
  type: 'final',
1245
- description: 'DECIDE completed with an approved commit.',
1246
- meta: {
1247
- playbook: {
1248
- stateId: 'done',
1249
- description: 'DECIDE completed with an approved commit.',
1250
- terminal: 'success',
1251
- },
1252
- },
1253
1475
  },
1254
1476
  },
1255
1477
  });