@sublang/playbook 15.1.0 → 17.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 (102) hide show
  1. package/README.md +2 -0
  2. package/docs/cli.md +36 -18
  3. package/docs/configuration.md +7 -5
  4. package/docs/embedding.md +28 -7
  5. package/package.json +4 -4
  6. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +63 -33
  7. package/reference/sdlc/branch.playbook/branch.fsm.js +190 -108
  8. package/reference/sdlc/branch.playbook/branch.fsm.ts +269 -147
  9. package/reference/sdlc/branch.playbook/branch.gears.md +7 -17
  10. package/reference/sdlc/branch.playbook/branch.playbook.d.ts +12 -16
  11. package/reference/sdlc/branch.playbook/branch.playbook.js +112 -101
  12. package/reference/sdlc/branch.playbook/branch.playbook.ts +128 -171
  13. package/reference/sdlc/branch.playbook/branch.registry.d.ts +3 -2
  14. package/reference/sdlc/branch.playbook/branch.registry.ts +6 -2
  15. package/reference/sdlc/captain.md +19 -10
  16. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +6 -2
  17. package/reference/sdlc/captain.playbook/captain.fsm.js +19 -7
  18. package/reference/sdlc/captain.playbook/captain.fsm.ts +25 -10
  19. package/reference/sdlc/captain.playbook/captain.gears.md +16 -8
  20. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +1 -1
  21. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -2
  22. package/reference/sdlc/captain.playbook/captain.playbook.ts +4 -3
  23. package/reference/sdlc/code.playbook/bin/interactive-session.js +14 -1
  24. package/reference/sdlc/code.playbook/bin/portable-codec.js +3 -0
  25. package/reference/sdlc/code.playbook/bin/repository-effects.js +1 -0
  26. package/reference/sdlc/code.playbook/bin/run.js +128 -85
  27. package/reference/sdlc/code.playbook/bin/session-host.js +40 -8
  28. package/reference/sdlc/code.playbook/bin/session-store.js +162 -34
  29. package/reference/sdlc/code.playbook/code.fsm.d.ts +146 -93
  30. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +8 -8
  31. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +13 -13
  32. package/reference/sdlc/code.playbook/code.fsm.js +609 -649
  33. package/reference/sdlc/code.playbook/code.fsm.ts +890 -821
  34. package/reference/sdlc/code.playbook/code.gears.md +39 -29
  35. package/reference/sdlc/code.playbook/code.playbook.d.ts +13 -16
  36. package/reference/sdlc/code.playbook/code.playbook.js +166 -147
  37. package/reference/sdlc/code.playbook/code.playbook.ts +180 -229
  38. package/reference/sdlc/code.playbook/code.registry.d.ts +3 -2
  39. package/reference/sdlc/code.playbook/code.registry.ts +6 -2
  40. package/reference/sdlc/code.playbook/host-capabilities.d.ts +3 -0
  41. package/reference/sdlc/code.playbook/playbook-captain.d.ts +74 -2
  42. package/reference/sdlc/code.playbook/playbook-captain.js +1548 -468
  43. package/reference/sdlc/code.playbook/playbook-captain.ts +1993 -573
  44. package/reference/sdlc/code.playbook/session-host.d.ts +4 -1
  45. package/reference/sdlc/code.playbook/session-store.d.ts +17 -0
  46. package/reference/sdlc/code.playbook/session-store.js +1 -0
  47. package/reference/sdlc/decide.md +1 -0
  48. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +273 -157
  49. package/reference/sdlc/decide.playbook/decide.fsm.js +765 -732
  50. package/reference/sdlc/decide.playbook/decide.fsm.ts +1167 -945
  51. package/reference/sdlc/decide.playbook/decide.gears.md +28 -21
  52. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -59
  53. package/reference/sdlc/decide.playbook/decide.playbook.js +155 -3696
  54. package/reference/sdlc/decide.playbook/decide.playbook.ts +186 -5244
  55. package/reference/sdlc/decide.playbook/decide.registry.d.ts +8 -4
  56. package/reference/sdlc/decide.playbook/decide.registry.js +2 -2
  57. package/reference/sdlc/decide.playbook/decide.registry.ts +13 -6
  58. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +116 -134
  59. package/reference/sdlc/dev.playbook/dev.fsm.js +517 -575
  60. package/reference/sdlc/dev.playbook/dev.fsm.ts +667 -787
  61. package/reference/sdlc/dev.playbook/dev.gears.md +52 -54
  62. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +13 -10
  63. package/reference/sdlc/dev.playbook/dev.playbook.js +128 -120
  64. package/reference/sdlc/dev.playbook/dev.playbook.ts +143 -193
  65. package/reference/sdlc/dev.playbook/dev.registry.d.ts +3 -2
  66. package/reference/sdlc/dev.playbook/dev.registry.ts +6 -2
  67. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +95 -88
  68. package/reference/sdlc/pr.playbook/pr.fsm.js +461 -518
  69. package/reference/sdlc/pr.playbook/pr.fsm.ts +533 -663
  70. package/reference/sdlc/pr.playbook/pr.gears.md +47 -63
  71. package/reference/sdlc/pr.playbook/pr.playbook.d.ts +13 -23
  72. package/reference/sdlc/pr.playbook/pr.playbook.js +118 -112
  73. package/reference/sdlc/pr.playbook/pr.playbook.ts +136 -190
  74. package/reference/sdlc/pr.playbook/pr.registry.d.ts +3 -2
  75. package/reference/sdlc/pr.playbook/pr.registry.ts +4 -2
  76. package/reference/sdlc/review.md +3 -3
  77. package/reference/sdlc/review.playbook/review.fsm.d.ts +150 -116
  78. package/reference/sdlc/review.playbook/review.fsm.js +479 -550
  79. package/reference/sdlc/review.playbook/review.fsm.ts +717 -662
  80. package/reference/sdlc/review.playbook/review.gears.md +32 -29
  81. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -12
  82. package/reference/sdlc/review.playbook/review.playbook.js +180 -155
  83. package/reference/sdlc/review.playbook/review.playbook.ts +190 -230
  84. package/reference/sdlc/review.playbook/review.registry.d.ts +6 -5
  85. package/reference/sdlc/review.playbook/review.registry.js +3 -3
  86. package/reference/sdlc/review.playbook/review.registry.ts +9 -5
  87. package/slc/gears2fsm.md +77 -8
  88. package/slc/link.md +127 -60
  89. package/slc/materialize-link.mjs +51 -13
  90. package/slc/optimize.md +4 -1
  91. package/slc/prefix-prompts.mjs +29 -28
  92. package/slc/prefix.md +27 -24
  93. package/slc/slc.pin-inputs.json +11 -2
  94. package/slc/text2gears.md +71 -11
  95. package/src/runtime.d.ts +46 -2
  96. package/src/runtime.ts +42 -2
  97. package/src/xstate-playbook-runtime.d.ts +63 -23
  98. package/src/xstate-playbook-runtime.js +1637 -475
  99. package/src/xstate-playbook-runtime.ts +2105 -532
  100. package/src/xstate-runtime.d.ts +3 -4
  101. package/src/xstate-runtime.js +52 -0
  102. package/src/xstate-runtime.ts +59 -4
@@ -1,20 +1,56 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
- // FSM object artifact compiled from decide.gears.md.
4
- // This module defines only the machine, actor contracts, and typed inputs.
5
- // 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.
6
7
  import { assign, fromPromise, setup } from 'xstate';
7
- export const concurrentRoleSets = [['coder', 'reviewer']];
8
+ import { validatePlaybookCallResult } from '@sublang/playbook/xstate-runtime';
9
+ /**
10
+ * One role-id array per parallel group, in first-item source order; the inner
11
+ * array follows the `independent-proposals` item order (DECIDE-1, DECIDE-2).
12
+ */
13
+ export const concurrentRoleSets = [
14
+ ['coder', 'reviewer'],
15
+ ];
16
+ /** The literal nested-call target of DECIDE-4. */
17
+ const REVIEW_PLAYBOOK_ID = 'review';
18
+ const RESUMABLE_STATES = {
19
+ askCoderProposal: { sourceItem: 'DECIDE-1', roleId: 'coder' },
20
+ askReviewerProposal: { sourceItem: 'DECIDE-2', roleId: 'reviewer' },
21
+ synthesizeCommit: { sourceItem: 'DECIDE-3', roleId: 'coder' },
22
+ };
23
+ // ---------------------------------------------------------------------------
24
+ // Prompts (each item's blockquote, verbatim) and local result contracts
25
+ // ---------------------------------------------------------------------------
8
26
  const NEEDS_BOSS_REPLY_DESCRIPTION = "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>`.";
9
- const INDEPENDENT_PROPOSAL_PROMPT = [
10
- '> Original topic: <caller-topic>',
27
+ /** DECIDE-1 blockquote; `<caller-topic>` is carried as `callerTopic`. */
28
+ const CODER_PROPOSAL_PROMPT = [
29
+ 'Assess whether the topic is better expressed as a few spec items under @specs/packages/ or requires one or more DRs under @specs/decisions/.',
30
+ 'Propose your design.',
31
+ 'Keep your proposal coherent, focused, and concise.',
32
+ 'Consult @specs/map.md for relevant context and @specs/meta.md for spec requirements, if needed.',
33
+ 'Do not change any files.',
11
34
  '',
35
+ '> Original topic: <caller-topic>',
36
+ ].join('\n');
37
+ /** DECIDE-2 blockquote; `<caller-topic>` is carried as `callerTopic`. */
38
+ const REVIEWER_PROPOSAL_PROMPT = [
12
39
  'Assess whether the topic is better expressed as a few spec items under @specs/packages/ or requires one or more DRs under @specs/decisions/.',
13
40
  'Propose your design.',
14
41
  'Keep your proposal coherent, focused, and concise.',
15
42
  'Consult @specs/map.md for relevant context and @specs/meta.md for spec requirements, if needed.',
16
43
  'Do not change any files.',
44
+ '',
45
+ '> Original topic: <caller-topic>',
17
46
  ].join('\n');
47
+ /**
48
+ * DECIDE-3 blockquote. `<caller-topic>` is carried as `callerTopic` and
49
+ * `<reviewer-proposal>` as `reviewerProposal`. `<coder-llm>` and
50
+ * `<reviewer-llm>` are local-role prompt identities of Coder and Reviewer: the
51
+ * linker resolves them through the invocation-scoped `promptIdentity(roleId)`
52
+ * lookup, so they stay literal here and have no machine or actor-input field.
53
+ */
18
54
  const SYNTHESIZE_COMMIT_PROMPT = [
19
55
  "Synthesize your independent proposal with Reviewer's proposal below.",
20
56
  'Keep to the original topic below and follow what it asks.',
@@ -31,270 +67,460 @@ const SYNTHESIZE_COMMIT_PROMPT = [
31
67
  '> Original topic: <caller-topic>',
32
68
  "> Reviewer's independent proposal: <reviewer-proposal>",
33
69
  ].join('\n');
34
- const REVIEW_INPUT_TEMPLATE = [
70
+ const CODER_PROPOSAL_RESULT = {
71
+ proposed: '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>`.',
72
+ needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
73
+ };
74
+ const REVIEWER_PROPOSAL_RESULT = {
75
+ proposed: '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>`.',
76
+ needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
77
+ };
78
+ const SYNTHESIZE_COMMIT_RESULT = {
79
+ committed: '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>`.',
80
+ needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
81
+ };
82
+ /** DECIDE-4 blockquote: the `review` call-text template, verbatim. */
83
+ const REVIEW_COMMIT_TEMPLATE = [
35
84
  '> Original intent: <caller-topic>',
36
85
  '> Review scope: the `decide`-owned commit <decide-commit> and its resulting repository state.',
86
+ "> Coder's independent proposal: <coder-proposal>",
37
87
  '> Coder output: <coder-output>',
38
88
  ].join('\n');
39
- function composeReviewInput(callerTopic, decideCommit, coderOutput) {
40
- const fields = {
41
- '<caller-topic>': callerTopic ?? '',
42
- '<decide-commit>': decideCommit ?? '',
43
- '<coder-output>': coderOutput ?? '',
89
+ // ---------------------------------------------------------------------------
90
+ // State descriptions and public metadata
91
+ // ---------------------------------------------------------------------------
92
+ const STATE_DESCRIPTIONS = {
93
+ ready: 'Waiting for the caller to give a topic to decide.',
94
+ independentProposals: 'Coder and Reviewer independently propose designs for the topic in parallel.',
95
+ coderProposalRegion: "Coder's independent proposal branch.",
96
+ askCoderProposal: 'Coder is independently proposing a design for the topic.',
97
+ awaitCoderProposalReply: "Waiting for Boss to answer Coder's question about its independent proposal.",
98
+ coderProposalStaged: "Coder's independent proposal is complete and staged for the join.",
99
+ reviewerProposalRegion: "Reviewer's independent proposal branch.",
100
+ askReviewerProposal: 'Reviewer is independently proposing a design for the topic.',
101
+ awaitReviewerProposalReply: "Waiting for Boss to answer Reviewer's question about its independent proposal.",
102
+ reviewerProposalStaged: "Reviewer's independent proposal is complete and staged for the join.",
103
+ synthesizeCommit: 'Coder synthesizes both proposals into the necessary DRs and/or spec items and commits the result as one new commit.',
104
+ awaitBossReply: "Waiting for Boss to answer the acting agent's question.",
105
+ reviewCommit: 'The review playbook examines the decide-owned commit.',
106
+ failed: 'DECIDE parked after a control-plane failure and waits for Boss to restart or resume it.',
107
+ reportedReviewFailure: "DECIDE reported review's abort, failure, or unestablished result to its caller with the last decide-owned commit.",
108
+ done: 'DECIDE completed: review established no unsettled findings for the decide-owned commit at the reported evaluated revision.',
109
+ };
110
+ function playbookMeta(stateId, role) {
111
+ return {
112
+ playbook: {
113
+ stateId,
114
+ description: STATE_DESCRIPTIONS[stateId],
115
+ ...(role === undefined ? {} : { role }),
116
+ },
44
117
  };
45
- return REVIEW_INPUT_TEMPLATE.replace(/<caller-topic>|<decide-commit>|<coder-output>/g, (placeholder, offset, source) => {
46
- const value = fields[placeholder];
47
- const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
48
- return source.slice(lineStart, offset).startsWith('> ')
49
- ? value.replaceAll('\n', '\n> ')
50
- : value;
51
- });
52
118
  }
53
- const outputOf = (event) => event.output;
54
- const playbookOutputOf = (event) => event.output;
55
- const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
56
- const coderProposed = ({ event }) => {
57
- const output = outputOf(event);
58
- return output.guard === 'proposed' && isNonEmptyString(output.coderProposal);
59
- };
60
- const reviewerProposed = ({ event }) => {
61
- const output = outputOf(event);
62
- return (output.guard === 'proposed' && isNonEmptyString(output.reviewerProposal));
63
- };
64
- function commitOutput(event) {
65
- const output = outputOf(event);
66
- if (output.guard !== 'committed' ||
67
- !isNonEmptyString(output.coderOutput) ||
68
- !isNonEmptyString(output.latestCommit)) {
69
- return undefined;
70
- }
119
+ function terminalMeta(stateId, terminal) {
71
120
  return {
72
- coderOutput: output.coderOutput,
73
- latestCommit: output.latestCommit,
121
+ playbook: {
122
+ stateId,
123
+ description: STATE_DESCRIPTIONS[stateId],
124
+ terminal,
125
+ },
74
126
  };
75
127
  }
76
- const committed = ({ event }) => commitOutput(event) !== undefined;
77
- const needsBossReplyWithQuestion = ({ event }) => {
78
- const output = outputOf(event);
79
- return output.guard === 'needsBossReply' && isNonEmptyString(output.question);
80
- };
81
- const needsBossReplyWithoutQuestion = ({ event }) => {
82
- const output = outputOf(event);
83
- return (output.guard === 'needsBossReply' && !isNonEmptyString(output.question));
84
- };
85
- const pendingQuestionIds = (context) => Object.keys(context.pendingBossQuestions ?? {});
86
- const resolvedQuestionId = (context, event) => {
87
- if (event.type !== 'BOSS_REPLY')
88
- return undefined;
89
- if (event.questionId)
90
- return event.questionId;
91
- const ids = pendingQuestionIds(context);
92
- return ids.length === 1 ? ids[0] : undefined;
93
- };
94
- const bossReplyTargets = (stateId, requireAnswer) => ({ context, event }) => event.type === 'BOSS_REPLY' &&
95
- resolvedQuestionId(context, event) === stateId &&
96
- (requireAnswer
97
- ? event.answer.trim().length > 0
98
- : event.answer.trim().length === 0);
99
- const bossReplyFields = (context, stateId) => ({
100
- ...(context.pendingBossQuestions?.[stateId]
101
- ? { pendingBossQuestion: context.pendingBossQuestions[stateId] }
102
- : {}),
103
- ...(context.bossReplies?.[stateId]
104
- ? { bossReply: context.bossReplies[stateId] }
105
- : {}),
106
- });
107
- const withoutKey = (record, stateId) => {
108
- if (!record || record[stateId] === undefined)
109
- return record;
110
- const next = { ...record };
111
- delete next[stateId];
112
- return Object.keys(next).length > 0 ? next : undefined;
113
- };
114
- const withNeedsBossReply = (result) => ({
115
- ...result,
116
- needsBossReply: NEEDS_BOSS_REPLY_DESCRIPTION,
117
- });
118
- const isPlainRecord = (value) => typeof value === 'object' &&
119
- value !== null &&
120
- !Array.isArray(value) &&
121
- (Object.getPrototypeOf(value) === Object.prototype ||
122
- Object.getPrototypeOf(value) === null);
123
- const isExactKeys = (value, keys) => {
124
- const ownKeys = Reflect.ownKeys(value);
125
- return (ownKeys.length === keys.length &&
126
- ownKeys.every((key) => typeof key === 'string' && keys.includes(key)));
127
- };
128
- function isStateValue(value, ancestors = new Set()) {
129
- if (typeof value === 'string')
130
- return true;
131
- if (!isPlainRecord(value) || ancestors.has(value))
128
+ // ---------------------------------------------------------------------------
129
+ // Structural narrowing helpers
130
+ // ---------------------------------------------------------------------------
131
+ function isObject(value) {
132
+ return typeof value === 'object' && value !== null;
133
+ }
134
+ function isPlainRecord(value) {
135
+ if (!isObject(value) || Array.isArray(value))
132
136
  return false;
133
- const next = new Set(ancestors).add(value);
134
- return Reflect.ownKeys(value).every((key) => typeof key === 'string' &&
135
- Object.prototype.propertyIsEnumerable.call(value, key) &&
136
- isStateValue(value[key], next));
137
+ const prototype = Object.getPrototypeOf(value);
138
+ return prototype === Object.prototype || prototype === null;
137
139
  }
138
- function isStringArray(value) {
139
- return (Array.isArray(value) &&
140
- Reflect.ownKeys(value).length === value.length + 1 &&
141
- Reflect.ownKeys(value).every((key) => {
142
- if (key === 'length')
143
- return true;
144
- return (typeof key === 'string' &&
145
- /^(0|[1-9][0-9]*)$/.test(key) &&
146
- Number(key) < value.length);
147
- }) &&
148
- value.every((entry) => isNonEmptyString(entry)) &&
149
- new Set(value).size === value.length);
140
+ function isNonEmptyString(value) {
141
+ return typeof value === 'string' && value.trim().length > 0;
150
142
  }
151
- function isPlaybookState(value) {
152
- if (!isPlainRecord(value))
153
- return false;
154
- const allowed = new Set([
155
- 'value',
156
- 'activeStateIds',
157
- 'tags',
158
- 'status',
159
- 'quiescent',
160
- 'stateId',
161
- ]);
162
- if (Reflect.ownKeys(value).some((key) => typeof key !== 'string' || !allowed.has(key)) ||
163
- !Object.prototype.hasOwnProperty.call(value, 'value') ||
164
- !Object.prototype.hasOwnProperty.call(value, 'activeStateIds') ||
165
- !Object.prototype.hasOwnProperty.call(value, 'tags') ||
166
- !Object.prototype.hasOwnProperty.call(value, 'status') ||
167
- !Object.prototype.hasOwnProperty.call(value, 'quiescent')) {
168
- return false;
169
- }
170
- return (isStateValue(value.value) &&
171
- isStringArray(value.activeStateIds) &&
172
- isStringArray(value.tags) &&
173
- (value.status === 'active' ||
174
- value.status === 'done' ||
175
- value.status === 'error' ||
176
- value.status === 'stopped') &&
177
- typeof value.quiescent === 'boolean' &&
178
- (!Object.prototype.hasOwnProperty.call(value, 'stateId') ||
179
- (isNonEmptyString(value.stateId) &&
180
- value.activeStateIds.length === 1 &&
181
- value.activeStateIds[0] === value.stateId)));
143
+ function isResumableStateId(value) {
144
+ return (value === 'askCoderProposal' ||
145
+ value === 'askReviewerProposal' ||
146
+ value === 'synthesizeCommit');
182
147
  }
183
- // DECIDE supplied `review` a scope rooted at the `decide`-owned commit, so a
184
- // result applies to that scope only when it is the exact declared terminal
185
- // contract: the evaluated repository revision plus the affirmative
186
- // no-unsettled-findings fact. Any other shape does not establish those facts.
187
- function reviewSuccessFrom(event) {
188
- const output = playbookOutputOf(event);
189
- if (!isPlainRecord(output))
190
- return undefined;
191
- if (!isExactKeys(output, ['evaluatedRevision', 'noUnsettledFindings'])) {
148
+ /** The actor's `output`, narrowed structurally from an unknown done event. */
149
+ function actorOutput(event) {
150
+ if (!isObject(event))
192
151
  return undefined;
152
+ const output = event.output;
153
+ return isPlainRecord(output) ? output : undefined;
154
+ }
155
+ function outputString(event, field) {
156
+ const value = actorOutput(event)?.[field];
157
+ return isNonEmptyString(value) ? value : undefined;
158
+ }
159
+ function outputGuardIs(event, guard) {
160
+ return actorOutput(event)?.guard === guard;
161
+ }
162
+ /** The actor's `error`, narrowed structurally from an unknown error event. */
163
+ function actorError(event) {
164
+ return isObject(event) ? event.error : undefined;
165
+ }
166
+ /** Normalizes any thrown or malformed value to a JSON-safe error record. */
167
+ function errorRecord(value) {
168
+ if (value instanceof Error) {
169
+ return {
170
+ name: isNonEmptyString(value.name) ? value.name : 'Error',
171
+ message: typeof value.message === 'string' ? value.message : '',
172
+ ...(typeof value.stack === 'string' ? { stack: value.stack } : {}),
173
+ };
193
174
  }
194
- if (!isNonEmptyString(output.evaluatedRevision) ||
195
- output.noUnsettledFindings !== true) {
196
- return undefined;
175
+ if (isObject(value) && typeof value.message === 'string') {
176
+ return {
177
+ name: isNonEmptyString(value.name) ? value.name : 'Error',
178
+ message: value.message,
179
+ };
197
180
  }
198
181
  return {
199
- evaluatedRevision: output.evaluatedRevision,
200
- noUnsettledFindings: true,
182
+ name: 'Error',
183
+ message: typeof value === 'string'
184
+ ? value
185
+ : 'The actor failed with a non-Error value.',
201
186
  };
202
187
  }
203
- const validReviewSuccess = ({ event }) => reviewSuccessFrom(event) !== undefined;
204
- function normalizedChildFailure(error) {
188
+ function withoutKey(record, stateId) {
189
+ const next = {};
190
+ for (const key of Object.keys(record)) {
191
+ if (!isResumableStateId(key) || key === stateId)
192
+ continue;
193
+ const value = record[key];
194
+ if (value !== undefined)
195
+ next[key] = value;
196
+ }
197
+ return next;
198
+ }
199
+ /**
200
+ * Strict JSON validation: null, booleans, finite numbers, strings, exact
201
+ * arrays, and plain records of enumerable own data properties. Cycle
202
+ * detection tracks only the active recursion path.
203
+ */
204
+ function isJsonValue(value, path = new Set()) {
205
+ if (value === null)
206
+ return true;
207
+ switch (typeof value) {
208
+ case 'boolean':
209
+ case 'string':
210
+ return true;
211
+ case 'number':
212
+ return Number.isFinite(value);
213
+ case 'object':
214
+ break;
215
+ default:
216
+ return false;
217
+ }
218
+ const container = value;
219
+ if (path.has(container))
220
+ return false;
221
+ path.add(container);
222
+ try {
223
+ if (Array.isArray(container)) {
224
+ if (Object.getPrototypeOf(container) !== Array.prototype)
225
+ return false;
226
+ const keys = Reflect.ownKeys(container);
227
+ if (keys.length !== container.length + 1)
228
+ return false;
229
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(container, 'length');
230
+ if (lengthDescriptor === undefined ||
231
+ lengthDescriptor.configurable !== false ||
232
+ lengthDescriptor.enumerable !== false ||
233
+ lengthDescriptor.value !== container.length) {
234
+ return false;
235
+ }
236
+ for (let index = 0; index < container.length; index += 1) {
237
+ const descriptor = Object.getOwnPropertyDescriptor(container, String(index));
238
+ if (descriptor === undefined ||
239
+ !descriptor.enumerable ||
240
+ !('value' in descriptor) ||
241
+ !isJsonValue(descriptor.value, path)) {
242
+ return false;
243
+ }
244
+ }
245
+ return true;
246
+ }
247
+ const prototype = Object.getPrototypeOf(container);
248
+ if (prototype !== Object.prototype && prototype !== null)
249
+ return false;
250
+ for (const key of Reflect.ownKeys(container)) {
251
+ if (typeof key !== 'string')
252
+ return false;
253
+ const descriptor = Object.getOwnPropertyDescriptor(container, key);
254
+ if (descriptor === undefined ||
255
+ !descriptor.enumerable ||
256
+ !('value' in descriptor) ||
257
+ !isJsonValue(descriptor.value, path)) {
258
+ return false;
259
+ }
260
+ }
261
+ return true;
262
+ }
263
+ finally {
264
+ path.delete(container);
265
+ }
266
+ }
267
+ // ---------------------------------------------------------------------------
268
+ // Actor-output narrowing (declared per-state result contracts)
269
+ // ---------------------------------------------------------------------------
270
+ function isCoderProposed(event) {
271
+ return (outputGuardIs(event, 'proposed') &&
272
+ outputString(event, 'coderProposal') !== undefined);
273
+ }
274
+ function isReviewerProposed(event) {
275
+ return (outputGuardIs(event, 'proposed') &&
276
+ outputString(event, 'reviewerProposal') !== undefined);
277
+ }
278
+ function isCommitted(event) {
279
+ return (outputGuardIs(event, 'committed') &&
280
+ outputString(event, 'coderOutput') !== undefined &&
281
+ outputString(event, 'latestCommit') !== undefined);
282
+ }
283
+ function isNeedsBossReply(event) {
284
+ return (outputGuardIs(event, 'needsBossReply') &&
285
+ outputString(event, 'question') !== undefined);
286
+ }
287
+ // ---------------------------------------------------------------------------
288
+ // Nested `review` results
289
+ // ---------------------------------------------------------------------------
290
+ /**
291
+ * Recognizes an authored rejected child result: a validated public result
292
+ * whose status is `aborted` or `error`, or `ok` at the child's own authored
293
+ * failure terminal. Anything else is a control-plane error.
294
+ */
295
+ export function authoredChildResult(error, expectedPlaybookId) {
205
296
  if (!(error instanceof Error))
206
297
  return undefined;
207
- const result = error.result;
208
- if (!isPlainRecord(result))
209
- return undefined;
210
- const allowed = new Set([
211
- 'status',
212
- 'playbookId',
213
- 'childSessionId',
214
- 'state',
215
- 'error',
216
- ]);
217
- if (Reflect.ownKeys(result).some((key) => typeof key !== 'string' || !allowed.has(key)) ||
218
- (result.status !== 'aborted' && result.status !== 'error') ||
219
- result.playbookId !== 'review' ||
220
- Object.prototype.hasOwnProperty.call(result, 'output')) {
221
- return undefined;
298
+ try {
299
+ const result = validatePlaybookCallResult(error.result, expectedPlaybookId);
300
+ return result.status !== 'ok' || result.terminal?.kind === 'failure'
301
+ ? result
302
+ : undefined;
222
303
  }
223
- if (Object.prototype.hasOwnProperty.call(result, 'childSessionId') &&
224
- !isNonEmptyString(result.childSessionId)) {
304
+ catch {
225
305
  return undefined;
226
306
  }
227
- if (Object.prototype.hasOwnProperty.call(result, 'state') &&
228
- !isPlaybookState(result.state)) {
307
+ }
308
+ /**
309
+ * DECIDE-4's acceptance predicate on the builtin `review` public interface:
310
+ * the result gives the exact evaluated repository revision and affirmatively
311
+ * establishes that no unsettled findings remain. The shared bridge correlates
312
+ * the result with the supplied review scope.
313
+ */
314
+ function reviewApprovalFrom(event) {
315
+ const output = actorOutput(event);
316
+ if (output === undefined)
317
+ return undefined;
318
+ if (output.noUnsettledFindings !== true ||
319
+ !isNonEmptyString(output.evaluatedRevision)) {
229
320
  return undefined;
230
321
  }
231
- let normalizedError;
232
- if (Object.prototype.hasOwnProperty.call(result, 'error')) {
233
- if (!isPlainRecord(result.error))
234
- return undefined;
235
- const errorKeys = Reflect.ownKeys(result.error);
236
- if (errorKeys.some((key) => typeof key !== 'string' ||
237
- (key !== 'name' && key !== 'message' && key !== 'stack')) ||
238
- !Object.prototype.hasOwnProperty.call(result.error, 'name') ||
239
- !Object.prototype.hasOwnProperty.call(result.error, 'message')) {
240
- return undefined;
241
- }
242
- if (!isNonEmptyString(result.error.name) ||
243
- typeof result.error.message !== 'string' ||
244
- (Object.prototype.hasOwnProperty.call(result.error, 'stack') &&
245
- typeof result.error.stack !== 'string')) {
246
- return undefined;
247
- }
248
- normalizedError = {
249
- name: result.error.name,
250
- message: result.error.message,
251
- };
252
- }
253
- else if (result.status === 'error') {
322
+ return {
323
+ noUnsettledFindings: true,
324
+ evaluatedRevision: output.evaluatedRevision,
325
+ };
326
+ }
327
+ /** Reported status, compact error, and evidence of an authored review failure. */
328
+ function authoredReviewFailureOf(event) {
329
+ const result = authoredChildResult(actorError(event), REVIEW_PLAYBOOK_ID);
330
+ if (result === undefined)
254
331
  return undefined;
332
+ if (result.status === 'ok') {
333
+ // `ok` at review's own authored failure terminal: a review failure whose
334
+ // own output is relayed as evidence.
335
+ const description = result.terminal?.description;
336
+ return {
337
+ status: 'error',
338
+ error: {
339
+ name: 'ReviewFailed',
340
+ message: isNonEmptyString(description)
341
+ ? `review ended at its authored failure terminal: ${description}`
342
+ : 'review ended at its authored failure terminal.',
343
+ },
344
+ evidence: {
345
+ playbookId: REVIEW_PLAYBOOK_ID,
346
+ status: 'ok',
347
+ ...(result.output === undefined ? {} : { output: result.output }),
348
+ },
349
+ };
255
350
  }
351
+ const error = result.error !== undefined
352
+ ? { name: result.error.name, message: result.error.message }
353
+ : { name: 'AbortError', message: 'The review call was aborted.' };
256
354
  return {
257
355
  status: result.status,
258
- playbookId: 'review',
259
- ...(normalizedError ? { error: normalizedError } : {}),
356
+ error,
357
+ evidence: { playbookId: REVIEW_PLAYBOOK_ID, status: result.status, error },
358
+ };
359
+ }
360
+ const REVIEW_NOT_ESTABLISHED = {
361
+ name: 'ReviewNotEstablished',
362
+ message: 'review returned a terminal result that does not establish the exact evaluated repository revision with no unsettled findings.',
363
+ };
364
+ // ---------------------------------------------------------------------------
365
+ // Nested call text composition (DECIDE-4 quoted relays)
366
+ // ---------------------------------------------------------------------------
367
+ const REVIEW_PLACEHOLDER = /<caller-topic>|<decide-commit>|<coder-proposal>|<coder-output>/g;
368
+ /**
369
+ * Composes the `review` call text from the verbatim template, one line at a
370
+ * time: a quoted relay line whose value is empty contributes no line, and a
371
+ * multi-line value is quoted line by line. The composer adds no empty quoted
372
+ * line of its own.
373
+ */
374
+ function reviewCallText(context) {
375
+ const values = {
376
+ '<caller-topic>': context.callerTopic,
377
+ '<decide-commit>': context.decideCommit,
378
+ '<coder-proposal>': context.coderProposal,
379
+ '<coder-output>': context.coderOutput,
380
+ };
381
+ const lines = [];
382
+ for (const line of REVIEW_COMMIT_TEMPLATE.split('\n')) {
383
+ const tokens = line.match(REVIEW_PLACEHOLDER) ?? [];
384
+ if (tokens.some((token) => values[token] === ''))
385
+ continue;
386
+ lines.push(line.replace(REVIEW_PLACEHOLDER, (token) => (values[token] ?? token).split('\n').join('\n> ')));
387
+ }
388
+ return lines.join('\n');
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+ // Player input mapping (pure reads of typed, entry-validated context)
392
+ // ---------------------------------------------------------------------------
393
+ function bossReplyFields(context, stateId) {
394
+ const pendingBossQuestion = context.pendingBossQuestions[stateId];
395
+ const bossReply = context.bossReplies[stateId];
396
+ return {
397
+ ...(pendingBossQuestion === undefined ? {} : { pendingBossQuestion }),
398
+ ...(bossReply === undefined ? {} : { bossReply }),
260
399
  };
261
400
  }
262
- const authoredReviewFailure = ({ event }) => normalizedChildFailure(event.error) !== undefined;
401
+ // ---------------------------------------------------------------------------
402
+ // Context snapshots
403
+ // ---------------------------------------------------------------------------
404
+ const INITIAL_CONTEXT = {
405
+ callerTopic: '',
406
+ stagedCoderProposal: '',
407
+ stagedReviewerProposal: '',
408
+ coderProposal: '',
409
+ reviewerProposal: '',
410
+ coderOutput: '',
411
+ decideCommit: '',
412
+ evaluatedRevision: '',
413
+ reviewStatus: null,
414
+ reviewError: null,
415
+ reviewEvidence: null,
416
+ lastError: null,
417
+ pendingBossQuestions: {},
418
+ bossReplies: {},
419
+ };
420
+ /**
421
+ * A fresh run for a topic: clears every staged and promoted proposal, the
422
+ * commit, review evidence, errors, and every pending Boss question and reply.
423
+ */
424
+ function freshRun(callerTopic) {
425
+ return { ...INITIAL_CONTEXT, callerTopic };
426
+ }
427
+ /** Context reset shared by every exit into `failed`. */
428
+ const FAILURE_RESET = {
429
+ pendingBossQuestions: {},
430
+ bossReplies: {},
431
+ stagedCoderProposal: '',
432
+ stagedReviewerProposal: '',
433
+ };
434
+ /**
435
+ * A synthesis restart keeps the topic and the promoted proposals, and clears
436
+ * the prior commit, review evidence, error, and every pending question.
437
+ */
438
+ const SYNTHESIS_RESTART = {
439
+ coderOutput: '',
440
+ decideCommit: '',
441
+ evaluatedRevision: '',
442
+ reviewStatus: null,
443
+ reviewError: null,
444
+ reviewEvidence: null,
445
+ lastError: null,
446
+ pendingBossQuestions: {},
447
+ bossReplies: {},
448
+ };
449
+ // ---------------------------------------------------------------------------
450
+ // Transition helpers
451
+ // ---------------------------------------------------------------------------
452
+ /**
453
+ * Accepted-outcome marker (artifact schema 3). `target` names the public
454
+ * state the next snapshot shows after the transition's macrostep.
455
+ */
456
+ function acceptedOutcome(source, target, outcome) {
457
+ return {
458
+ type: 'playbook.acceptedOutcome',
459
+ params: { source, target, acceptedOutcome: outcome },
460
+ };
461
+ }
462
+ /** Root `BOSS_INTERRUPT` arms, one per jumpable state. */
263
463
  function bossInterrupts(ids) {
264
- return ids.map((id) => ({
265
- guard: ({ event }) => event.type === 'BOSS_INTERRUPT' &&
266
- event.targetId === id &&
267
- typeof event.bossIntent === 'string' &&
268
- event.bossIntent.trim().length > 0,
269
- target: `#${id}`,
464
+ return ids.map((targetId) => ({
465
+ guard: { type: 'interruptTargets', params: { targetId } },
466
+ target: `#${targetId}`,
270
467
  reenter: true,
271
- actions: 'copyInterruptedTopic',
468
+ actions: 'restartFromInterrupt',
272
469
  }));
273
470
  }
471
+ /** Keyed `BOSS_REPLY` arms of the wait that resumes the given working leaves. */
274
472
  function resumableStates(ids) {
275
- return ids.map((id) => ({
276
- guard: ({ context, event }) => event.type === 'BOSS_REPLY' &&
277
- event.answer.trim().length > 0 &&
278
- resolvedQuestionId(context, event) === id,
279
- target: `#${id}`,
280
- reenter: true,
281
- actions: 'rememberBossReply',
282
- }));
473
+ return ids.flatMap((stateId) => [
474
+ {
475
+ guard: { type: 'bossReplyIsEmpty', params: { stateId } },
476
+ target: '#failed',
477
+ actions: 'rememberEmptyBossReply',
478
+ },
479
+ {
480
+ guard: { type: 'bossReplyResumes', params: { stateId } },
481
+ target: `#${stateId}`,
482
+ reenter: true,
483
+ actions: { type: 'rememberBossReply', params: { stateId } },
484
+ },
485
+ ]);
283
486
  }
284
- const stateMetadata = {
285
- askCoderProposal: {
286
- sourceItem: 'DECIDE-1',
287
- asker: { kind: 'role', roleId: 'coder' },
288
- },
289
- askReviewerProposal: {
290
- sourceItem: 'DECIDE-2',
291
- asker: { kind: 'role', roleId: 'reviewer' },
292
- },
293
- commitCoderProposal: {
294
- sourceItem: 'DECIDE-3',
295
- asker: { kind: 'role', roleId: 'coder' },
296
- },
487
+ /**
488
+ * At the root synthesis wait, where no other task is active, a keyed
489
+ * `BOSS_REPLY` that names no pending question fails the run.
490
+ */
491
+ const unknownBossReplyArm = {
492
+ guard: 'bossReplyNamesNoPendingQuestion',
493
+ target: '#failed',
494
+ actions: 'rememberUnknownBossReply',
297
495
  };
496
+ const startDecideArm = {
497
+ guard: 'validStartTopic',
498
+ target: '#independentProposals',
499
+ actions: 'startDecide',
500
+ };
501
+ function suspendArm(stateId, wait) {
502
+ return {
503
+ guard: 'needsBossReply',
504
+ target: `#${wait}`,
505
+ actions: [
506
+ acceptedOutcome(stateId, wait, 'needsBossReply'),
507
+ { type: 'setPendingBossQuestion', params: { stateId } },
508
+ ],
509
+ };
510
+ }
511
+ function malformedPlayerOutputArm(sourceItem) {
512
+ return {
513
+ target: '#failed',
514
+ actions: { type: 'rememberMalformedPlayerOutput', params: { sourceItem } },
515
+ };
516
+ }
517
+ const playerOnError = {
518
+ target: '#failed',
519
+ actions: 'rememberActorError',
520
+ };
521
+ // ---------------------------------------------------------------------------
522
+ // Machine
523
+ // ---------------------------------------------------------------------------
298
524
  export const decideMachine = setup({
299
525
  types: {
300
526
  context: {},
@@ -310,673 +536,480 @@ export const decideMachine = setup({
310
536
  throw new Error('playbook actor must be provided by the runner');
311
537
  }),
312
538
  },
539
+ guards: {
540
+ validStartTopic: ({ event }) => event.type === 'START_DECIDE' && isNonEmptyString(event.callerTopic),
541
+ // Each arm guards its target id and every typed precondition for
542
+ // entering that target safely.
543
+ interruptTargets: ({ context, event }, params) => {
544
+ if (event.type !== 'BOSS_INTERRUPT')
545
+ return false;
546
+ if (event.targetId !== params.targetId)
547
+ return false;
548
+ if (event.targetId === 'independentProposals') {
549
+ return isNonEmptyString(event.callerTopic);
550
+ }
551
+ return (isNonEmptyString(context.callerTopic) &&
552
+ isNonEmptyString(context.coderProposal) &&
553
+ isNonEmptyString(context.reviewerProposal));
554
+ },
555
+ // A branch completing after its sibling has staged its proposal also
556
+ // completes the join, so its accepted outcome lands on `synthesizeCommit`.
557
+ coderProposedJoining: ({ context, event }) => isCoderProposed(event) &&
558
+ isNonEmptyString(context.stagedReviewerProposal) &&
559
+ isNonEmptyString(context.callerTopic),
560
+ coderProposed: ({ event }) => isCoderProposed(event),
561
+ reviewerProposedJoining: ({ context, event }) => isReviewerProposed(event) &&
562
+ isNonEmptyString(context.stagedCoderProposal) &&
563
+ isNonEmptyString(context.callerTopic),
564
+ reviewerProposed: ({ event }) => isReviewerProposed(event),
565
+ committed: ({ context, event }) => isCommitted(event) &&
566
+ isNonEmptyString(context.callerTopic) &&
567
+ isNonEmptyString(context.coderProposal),
568
+ needsBossReply: ({ event }) => isNeedsBossReply(event),
569
+ reviewApproved: ({ context, event }) => isNonEmptyString(context.decideCommit) &&
570
+ reviewApprovalFrom(event) !== undefined,
571
+ authoredReviewFailure: ({ context, event }) => isNonEmptyString(context.decideCommit) &&
572
+ authoredReviewFailureOf(event) !== undefined,
573
+ bossReplyNamesNoPendingQuestion: ({ context, event }) => event.type === 'BOSS_REPLY' &&
574
+ (!isResumableStateId(event.questionId) ||
575
+ context.pendingBossQuestions[event.questionId] === undefined),
576
+ bossReplyIsEmpty: ({ context, event }, params) => event.type === 'BOSS_REPLY' &&
577
+ event.questionId === params.stateId &&
578
+ context.pendingBossQuestions[params.stateId] !== undefined &&
579
+ !isNonEmptyString(event.answer),
580
+ bossReplyResumes: ({ context, event }, params) => event.type === 'BOSS_REPLY' &&
581
+ event.questionId === params.stateId &&
582
+ context.pendingBossQuestions[params.stateId] !== undefined &&
583
+ isNonEmptyString(event.answer) &&
584
+ isNonEmptyString(context.callerTopic),
585
+ },
313
586
  actions: {
314
587
  'playbook.acceptedOutcome': (_args, _params) => undefined,
315
- copyStartTopic: assign({
316
- callerTopic: ({ event }) => event.type === 'START_DECIDE' ? event.callerTopic : undefined,
317
- coderProposal: undefined,
318
- reviewerProposal: undefined,
319
- latestCommit: undefined,
320
- coderOutput: undefined,
321
- reviewResult: undefined,
322
- reviewFailure: undefined,
323
- lastResult: undefined,
324
- lastError: undefined,
325
- pendingBossQuestions: undefined,
326
- bossReplies: undefined,
327
- stagedCoderResult: undefined,
328
- stagedReviewerResult: undefined,
329
- }),
330
- copyInterruptedTopic: assign({
331
- callerTopic: ({ event }) => event.type === 'BOSS_INTERRUPT' ? event.bossIntent : undefined,
332
- coderProposal: undefined,
333
- reviewerProposal: undefined,
334
- latestCommit: undefined,
335
- coderOutput: undefined,
336
- reviewResult: undefined,
337
- reviewFailure: undefined,
338
- lastResult: undefined,
339
- lastError: undefined,
340
- pendingBossQuestions: undefined,
341
- bossReplies: undefined,
342
- stagedCoderResult: undefined,
343
- stagedReviewerResult: undefined,
344
- }),
345
- stageCoderProposal: assign({
346
- stagedCoderResult: ({ event }) => outputOf(event),
347
- lastResult: ({ event }) => outputOf(event),
348
- lastError: undefined,
349
- }),
350
- stageReviewerProposal: assign({
351
- stagedReviewerResult: ({ event }) => outputOf(event),
352
- lastResult: ({ event }) => outputOf(event),
353
- lastError: undefined,
588
+ startDecide: assign(({ event }) => event.type === 'START_DECIDE' ? freshRun(event.callerTopic) : {}),
589
+ // A new topic restarts the whole proposal pair from a fresh run; a
590
+ // synthesis restart keeps the topic and the promoted proposals.
591
+ restartFromInterrupt: assign(({ event }) => {
592
+ if (event.type !== 'BOSS_INTERRUPT')
593
+ return {};
594
+ return event.targetId === 'independentProposals'
595
+ ? freshRun(event.callerTopic)
596
+ : SYNTHESIS_RESTART;
354
597
  }),
355
- promoteProposals: assign({
356
- coderProposal: ({ context }) => context.stagedCoderResult?.coderProposal,
357
- reviewerProposal: ({ context }) => context.stagedReviewerResult?.reviewerProposal,
358
- stagedCoderResult: undefined,
359
- stagedReviewerResult: undefined,
360
- lastResult: undefined,
361
- lastError: undefined,
362
- }),
363
- rememberCommit: assign({
364
- latestCommit: ({ event }) => outputOf(event).latestCommit,
365
- coderOutput: ({ event }) => outputOf(event).coderOutput,
366
- lastResult: ({ event }) => outputOf(event),
367
- lastError: undefined,
368
- }),
369
- rememberReviewSuccess: assign({
370
- reviewResult: ({ event }) => reviewSuccessFrom(event),
371
- reviewFailure: undefined,
372
- lastError: undefined,
373
- }),
374
- rememberReviewFailure: assign({
375
- reviewFailure: ({ event }) => normalizedChildFailure(event.error),
376
- lastError: undefined,
598
+ // Each branch assigns only its own staged result.
599
+ stageCoderProposal: assign(({ context, event }) => ({
600
+ stagedCoderProposal: outputString(event, 'coderProposal') ?? '',
601
+ pendingBossQuestions: withoutKey(context.pendingBossQuestions, 'askCoderProposal'),
602
+ bossReplies: withoutKey(context.bossReplies, 'askCoderProposal'),
603
+ })),
604
+ stageReviewerProposal: assign(({ context, event }) => ({
605
+ stagedReviewerProposal: outputString(event, 'reviewerProposal') ?? '',
606
+ pendingBossQuestions: withoutKey(context.pendingBossQuestions, 'askReviewerProposal'),
607
+ bossReplies: withoutKey(context.bossReplies, 'askReviewerProposal'),
608
+ })),
609
+ // The join promotes both staged proposals atomically.
610
+ promoteProposals: assign(({ context }) => ({
611
+ coderProposal: context.stagedCoderProposal,
612
+ reviewerProposal: context.stagedReviewerProposal,
613
+ stagedCoderProposal: '',
614
+ stagedReviewerProposal: '',
615
+ pendingBossQuestions: {},
616
+ bossReplies: {},
617
+ })),
618
+ rememberCommit: assign(({ context, event }) => ({
619
+ coderOutput: outputString(event, 'coderOutput') ?? '',
620
+ decideCommit: outputString(event, 'latestCommit') ?? '',
621
+ lastError: null,
622
+ pendingBossQuestions: withoutKey(context.pendingBossQuestions, 'synthesizeCommit'),
623
+ bossReplies: withoutKey(context.bossReplies, 'synthesizeCommit'),
624
+ })),
625
+ setPendingBossQuestion: assign(({ context, event }, params) => {
626
+ const question = outputString(event, 'question');
627
+ if (question === undefined)
628
+ return {};
629
+ const { sourceItem, roleId } = RESUMABLE_STATES[params.stateId];
630
+ const pending = {
631
+ questionId: params.stateId,
632
+ resumeStateId: params.stateId,
633
+ sourceItem,
634
+ asker: { kind: 'role', roleId },
635
+ question,
636
+ };
637
+ return {
638
+ pendingBossQuestions: {
639
+ ...context.pendingBossQuestions,
640
+ [params.stateId]: pending,
641
+ },
642
+ bossReplies: withoutKey(context.bossReplies, params.stateId),
643
+ };
377
644
  }),
378
- rememberReviewProtocolFailure: assign({
379
- reviewFailure: {
380
- status: 'error',
381
- playbookId: 'review',
382
- error: {
383
- name: 'ReviewProtocolError',
384
- message: 'REVIEW returned without the evaluated repository revision and a no-unsettled-findings result for the supplied scope.',
645
+ rememberBossReply: assign(({ context, event }, params) => event.type === 'BOSS_REPLY'
646
+ ? {
647
+ bossReplies: {
648
+ ...context.bossReplies,
649
+ [params.stateId]: event.answer,
385
650
  },
651
+ }
652
+ : {}),
653
+ rememberEmptyBossReply: assign({
654
+ lastError: {
655
+ name: 'MalformedBossReply',
656
+ message: 'BOSS_REPLY carried an empty answer.',
386
657
  },
387
- lastError: undefined,
658
+ ...FAILURE_RESET,
388
659
  }),
389
- rememberActorError: assign({
390
- lastError: ({ event }) => event.error,
391
- }),
392
- rememberMalformedActorOutput: assign({
393
- lastError: ({ event }) => event.output,
394
- }),
395
- rememberMalformedBossReply: assign({
396
- lastError: ({ event }) => event,
660
+ rememberUnknownBossReply: assign({
661
+ lastError: {
662
+ name: 'MalformedBossReply',
663
+ message: 'BOSS_REPLY names no pending question.',
664
+ },
665
+ ...FAILURE_RESET,
397
666
  }),
398
- setPendingBossQuestion: assign({
399
- pendingBossQuestions: ({ context, event }, params) => ({
400
- ...context.pendingBossQuestions,
401
- [params.resumeStateId]: {
402
- ...params,
403
- questionId: params.resumeStateId,
404
- question: outputOf(event).question ?? '',
667
+ rememberMalformedPlayerOutput: assign(({ event }, params) => {
668
+ const output = actorOutput(event);
669
+ return {
670
+ lastError: {
671
+ name: 'MalformedPlayerOutput',
672
+ message: output?.guard === 'needsBossReply'
673
+ ? `Player output for ${params.sourceItem} declared needsBossReply without a question.`
674
+ : `Player output for ${params.sourceItem} did not match an outcome declared for its state.`,
405
675
  },
406
- }),
407
- bossReplies: ({ context }, params) => withoutKey(context.bossReplies, params.resumeStateId),
408
- lastResult: ({ event }) => outputOf(event),
409
- lastError: undefined,
410
- }),
411
- rememberBossReply: assign({
412
- bossReplies: ({ context, event }) => {
413
- const questionId = resolvedQuestionId(context, event);
414
- return event.type === 'BOSS_REPLY' && questionId
415
- ? { ...context.bossReplies, [questionId]: event.answer }
416
- : context.bossReplies;
417
- },
418
- lastError: undefined,
676
+ ...FAILURE_RESET,
677
+ };
419
678
  }),
420
- clearBranchBossReplyContext: assign({
421
- pendingBossQuestions: ({ context }, params) => withoutKey(context.pendingBossQuestions, params.stateId),
422
- bossReplies: ({ context }, params) => withoutKey(context.bossReplies, params.stateId),
679
+ rememberActorError: assign(({ event }) => ({
680
+ lastError: errorRecord(actorError(event)),
681
+ ...FAILURE_RESET,
682
+ })),
683
+ rememberReviewApproval: assign(({ event }) => {
684
+ const approval = reviewApprovalFrom(event);
685
+ if (approval === undefined)
686
+ return {};
687
+ const output = actorOutput(event);
688
+ const reviewEvidence = {
689
+ playbookId: REVIEW_PLAYBOOK_ID,
690
+ status: 'ok',
691
+ ...(isJsonValue(output) ? { output } : {}),
692
+ };
693
+ return {
694
+ evaluatedRevision: approval.evaluatedRevision,
695
+ reviewStatus: null,
696
+ reviewError: null,
697
+ reviewEvidence,
698
+ lastError: null,
699
+ };
423
700
  }),
424
- clearProposalRoundContext: assign({
425
- pendingBossQuestions: undefined,
426
- bossReplies: undefined,
427
- stagedCoderResult: undefined,
428
- stagedReviewerResult: undefined,
701
+ rememberUnestablishedReview: assign(({ event }) => {
702
+ const output = actorOutput(event);
703
+ const reviewEvidence = {
704
+ playbookId: REVIEW_PLAYBOOK_ID,
705
+ status: 'ok',
706
+ ...(isJsonValue(output) ? { output } : {}),
707
+ };
708
+ return {
709
+ evaluatedRevision: '',
710
+ reviewStatus: 'error',
711
+ reviewError: REVIEW_NOT_ESTABLISHED,
712
+ reviewEvidence,
713
+ lastError: null,
714
+ };
429
715
  }),
430
- clearBossReplyContext: assign({
431
- pendingBossQuestions: undefined,
432
- bossReplies: undefined,
716
+ rememberAuthoredReviewFailure: assign(({ event }) => {
717
+ const failure = authoredReviewFailureOf(event);
718
+ return failure === undefined
719
+ ? {}
720
+ : {
721
+ evaluatedRevision: '',
722
+ reviewStatus: failure.status,
723
+ reviewError: failure.error,
724
+ reviewEvidence: failure.evidence,
725
+ lastError: null,
726
+ };
433
727
  }),
434
728
  },
435
- guards: {
436
- coderProposed,
437
- reviewerProposed,
438
- committed,
439
- needsBossReplyWithQuestion,
440
- needsBossReplyWithoutQuestion,
441
- validReviewSuccess,
442
- authoredReviewFailure,
443
- },
444
729
  }).createMachine({
445
730
  id: 'decide',
731
+ description: 'DECIDE: independent Coder and Reviewer proposals, a synthesized decide-owned commit, and a nested review.',
446
732
  initial: 'ready',
447
- context: () => ({}),
733
+ context: () => INITIAL_CONTEXT,
448
734
  output: ({ context }) => {
449
- if (context.reviewResult) {
450
- if (!context.latestCommit) {
451
- throw new Error('DECIDE completed without its decide-owned commit');
452
- }
735
+ const commit = context.decideCommit;
736
+ if (!isNonEmptyString(commit)) {
737
+ throw new Error('DECIDE reached a final state without its decide-owned commit');
738
+ }
739
+ if (context.reviewStatus !== null) {
453
740
  return {
454
- decideCommit: context.latestCommit,
455
- evaluatedRevision: context.reviewResult.evaluatedRevision,
456
- noUnsettledFindings: true,
741
+ lastDecideCommit: commit,
742
+ noUnsettledFindings: false,
743
+ reviewStatus: context.reviewStatus,
744
+ ...(context.reviewError === null ? {} : { error: context.reviewError }),
457
745
  };
458
746
  }
459
- if (!context.latestCommit || !context.reviewFailure) {
460
- throw new Error('DECIDE reached a final state without a result');
747
+ const evaluatedRevision = context.evaluatedRevision;
748
+ if (!isNonEmptyString(evaluatedRevision)) {
749
+ throw new Error('DECIDE completed without the evaluated repository revision');
461
750
  }
462
751
  return {
463
- lastDecideCommit: context.latestCommit,
464
- noUnsettledFindings: false,
465
- reviewStatus: context.reviewFailure.status,
466
- ...(context.reviewFailure.error
467
- ? { error: context.reviewFailure.error }
468
- : {}),
752
+ decideCommit: commit,
753
+ evaluatedRevision,
754
+ noUnsettledFindings: true,
469
755
  };
470
756
  },
471
757
  on: {
472
- BOSS_INTERRUPT: bossInterrupts(['independentProposals']),
758
+ BOSS_INTERRUPT: bossInterrupts([
759
+ 'independentProposals',
760
+ 'synthesizeCommit',
761
+ ]),
473
762
  },
474
763
  states: {
475
764
  ready: {
476
765
  id: 'ready',
477
- tags: 'playbook.parked',
478
- description: 'Waiting for a topic to decide.',
479
- meta: {
480
- playbook: {
481
- stateId: 'ready',
482
- description: 'Waiting for a topic to decide.',
483
- },
484
- },
766
+ description: STATE_DESCRIPTIONS.ready,
767
+ meta: playbookMeta('ready'),
768
+ tags: ['playbook.parked'],
485
769
  on: {
486
- START_DECIDE: {
487
- guard: ({ event }) => event.callerTopic.trim().length > 0,
488
- target: 'independentProposals',
489
- actions: 'copyStartTopic',
490
- },
770
+ START_DECIDE: startDecideArm,
491
771
  },
492
772
  },
493
773
  independentProposals: {
494
774
  id: 'independentProposals',
775
+ description: STATE_DESCRIPTIONS.independentProposals,
776
+ meta: playbookMeta('independentProposals'),
495
777
  type: 'parallel',
496
- description: 'Coder and Reviewer prepare independent proposals.',
497
- meta: {
498
- playbook: {
499
- stateId: 'independentProposals',
500
- description: 'Coder and Reviewer prepare independent proposals.',
501
- },
502
- },
778
+ // No group-level BOSS_REPLY handler: a reply is handled only by the
779
+ // branch wait whose pending question it names, and a reply naming no
780
+ // pending branch question moves no branch.
503
781
  states: {
504
- coder: {
505
- initial: 'working',
782
+ coderProposalRegion: {
783
+ id: 'coderProposalRegion',
784
+ description: STATE_DESCRIPTIONS.coderProposalRegion,
785
+ meta: playbookMeta('coderProposalRegion'),
786
+ initial: 'askCoderProposal',
506
787
  states: {
507
- working: {
788
+ askCoderProposal: {
508
789
  id: 'askCoderProposal',
509
- tags: 'playbook.busy',
510
- description: 'Coder independently proposes a spec design.',
511
- meta: {
512
- playbook: {
513
- stateId: 'askCoderProposal',
514
- description: 'Coder independently proposes a spec design.',
515
- role: 'coder',
516
- },
517
- },
790
+ description: STATE_DESCRIPTIONS.askCoderProposal,
791
+ meta: playbookMeta('askCoderProposal', 'coder'),
792
+ tags: ['playbook.busy'],
518
793
  invoke: {
519
794
  src: 'player',
520
795
  input: ({ context }) => ({
521
796
  stateId: 'askCoderProposal',
522
- sourceItem: 'DECIDE-1',
523
797
  role: 'coder',
524
- prompt: INDEPENDENT_PROPOSAL_PROMPT,
525
- result: withNeedsBossReply({
526
- proposed: '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>`.',
527
- }),
798
+ sourceItem: 'DECIDE-1',
799
+ prompt: CODER_PROPOSAL_PROMPT,
800
+ result: CODER_PROPOSAL_RESULT,
528
801
  callerTopic: context.callerTopic,
529
802
  ...bossReplyFields(context, 'askCoderProposal'),
530
803
  }),
531
804
  onDone: [
805
+ suspendArm('askCoderProposal', 'awaitCoderProposalReply'),
532
806
  {
533
- guard: 'needsBossReplyWithQuestion',
534
- target: 'waiting',
807
+ guard: 'coderProposedJoining',
808
+ target: 'coderProposalStaged',
535
809
  actions: [
536
- {
537
- type: 'playbook.acceptedOutcome',
538
- params: {
539
- source: 'askCoderProposal',
540
- target: 'waitCoderProposalReply',
541
- acceptedOutcome: 'needsBossReply',
542
- },
543
- },
544
- {
545
- type: 'setPendingBossQuestion',
546
- params: {
547
- ...stateMetadata.askCoderProposal,
548
- resumeStateId: 'askCoderProposal',
549
- question: '',
550
- },
551
- },
552
- ],
553
- },
554
- {
555
- guard: 'needsBossReplyWithoutQuestion',
556
- target: '#failed',
557
- actions: [
558
- 'rememberMalformedActorOutput',
559
- 'clearProposalRoundContext',
810
+ acceptedOutcome('askCoderProposal', 'synthesizeCommit', 'proposed'),
811
+ 'stageCoderProposal',
560
812
  ],
561
813
  },
562
814
  {
563
815
  guard: 'coderProposed',
564
- target: 'complete',
816
+ target: 'coderProposalStaged',
565
817
  actions: [
566
- {
567
- type: 'playbook.acceptedOutcome',
568
- params: ({ context }) => ({
569
- source: 'askCoderProposal',
570
- target: context.stagedReviewerResult === undefined
571
- ? 'coderProposalComplete'
572
- : 'commitCoderProposal',
573
- acceptedOutcome: 'proposed',
574
- }),
575
- },
818
+ acceptedOutcome('askCoderProposal', 'coderProposalStaged', 'proposed'),
576
819
  'stageCoderProposal',
577
- {
578
- type: 'clearBranchBossReplyContext',
579
- params: { stateId: 'askCoderProposal' },
580
- },
581
- ],
582
- },
583
- {
584
- target: '#failed',
585
- actions: [
586
- 'rememberMalformedActorOutput',
587
- 'clearProposalRoundContext',
588
820
  ],
589
821
  },
822
+ malformedPlayerOutputArm('DECIDE-1'),
590
823
  ],
591
- onError: {
592
- target: '#failed',
593
- actions: [
594
- 'rememberActorError',
595
- 'clearProposalRoundContext',
596
- ],
597
- },
824
+ onError: playerOnError,
598
825
  },
599
826
  },
600
- waiting: {
601
- id: 'waitCoderProposalReply',
602
- tags: 'playbook.parked',
603
- description: 'Coder waits for Boss to answer a question.',
604
- meta: {
605
- playbook: {
606
- stateId: 'waitCoderProposalReply',
607
- description: 'Coder waits for Boss to answer a question.',
608
- },
609
- },
827
+ awaitCoderProposalReply: {
828
+ id: 'awaitCoderProposalReply',
829
+ description: STATE_DESCRIPTIONS.awaitCoderProposalReply,
830
+ meta: playbookMeta('awaitCoderProposalReply'),
831
+ tags: ['playbook.parked'],
610
832
  on: {
611
- BOSS_REPLY: [
612
- {
613
- guard: bossReplyTargets('askCoderProposal', false),
614
- target: '#failed',
615
- actions: [
616
- 'rememberMalformedBossReply',
617
- 'clearProposalRoundContext',
618
- ],
619
- },
620
- {
621
- guard: bossReplyTargets('askCoderProposal', true),
622
- target: 'working',
623
- actions: 'rememberBossReply',
624
- },
625
- ],
833
+ BOSS_REPLY: resumableStates(['askCoderProposal']),
626
834
  },
627
835
  },
628
- complete: {
836
+ coderProposalStaged: {
837
+ id: 'coderProposalStaged',
838
+ description: STATE_DESCRIPTIONS.coderProposalStaged,
839
+ meta: playbookMeta('coderProposalStaged'),
629
840
  type: 'final',
630
- description: 'Coder proposal is staged for the parallel join.',
631
- meta: {
632
- playbook: {
633
- stateId: 'coderProposalComplete',
634
- description: 'Coder proposal is staged for the parallel join.',
635
- },
636
- },
637
841
  },
638
842
  },
639
843
  },
640
- reviewer: {
641
- initial: 'working',
844
+ reviewerProposalRegion: {
845
+ id: 'reviewerProposalRegion',
846
+ description: STATE_DESCRIPTIONS.reviewerProposalRegion,
847
+ meta: playbookMeta('reviewerProposalRegion'),
848
+ initial: 'askReviewerProposal',
642
849
  states: {
643
- working: {
850
+ askReviewerProposal: {
644
851
  id: 'askReviewerProposal',
645
- tags: 'playbook.busy',
646
- description: 'Reviewer independently proposes a spec design.',
647
- meta: {
648
- playbook: {
649
- stateId: 'askReviewerProposal',
650
- description: 'Reviewer independently proposes a spec design.',
651
- role: 'reviewer',
652
- },
653
- },
852
+ description: STATE_DESCRIPTIONS.askReviewerProposal,
853
+ meta: playbookMeta('askReviewerProposal', 'reviewer'),
854
+ tags: ['playbook.busy'],
654
855
  invoke: {
655
856
  src: 'player',
656
857
  input: ({ context }) => ({
657
858
  stateId: 'askReviewerProposal',
658
- sourceItem: 'DECIDE-2',
659
859
  role: 'reviewer',
660
- prompt: INDEPENDENT_PROPOSAL_PROMPT,
661
- result: withNeedsBossReply({
662
- proposed: '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>`.',
663
- }),
860
+ sourceItem: 'DECIDE-2',
861
+ prompt: REVIEWER_PROPOSAL_PROMPT,
862
+ result: REVIEWER_PROPOSAL_RESULT,
664
863
  callerTopic: context.callerTopic,
665
864
  ...bossReplyFields(context, 'askReviewerProposal'),
666
865
  }),
667
866
  onDone: [
867
+ suspendArm('askReviewerProposal', 'awaitReviewerProposalReply'),
668
868
  {
669
- guard: 'needsBossReplyWithQuestion',
670
- target: 'waiting',
671
- actions: [
672
- {
673
- type: 'playbook.acceptedOutcome',
674
- params: {
675
- source: 'askReviewerProposal',
676
- target: 'waitReviewerProposalReply',
677
- acceptedOutcome: 'needsBossReply',
678
- },
679
- },
680
- {
681
- type: 'setPendingBossQuestion',
682
- params: {
683
- ...stateMetadata.askReviewerProposal,
684
- resumeStateId: 'askReviewerProposal',
685
- question: '',
686
- },
687
- },
688
- ],
689
- },
690
- {
691
- guard: 'needsBossReplyWithoutQuestion',
692
- target: '#failed',
869
+ guard: 'reviewerProposedJoining',
870
+ target: 'reviewerProposalStaged',
693
871
  actions: [
694
- 'rememberMalformedActorOutput',
695
- 'clearProposalRoundContext',
872
+ acceptedOutcome('askReviewerProposal', 'synthesizeCommit', 'proposed'),
873
+ 'stageReviewerProposal',
696
874
  ],
697
875
  },
698
876
  {
699
877
  guard: 'reviewerProposed',
700
- target: 'complete',
878
+ target: 'reviewerProposalStaged',
701
879
  actions: [
702
- {
703
- type: 'playbook.acceptedOutcome',
704
- params: ({ context }) => ({
705
- source: 'askReviewerProposal',
706
- target: context.stagedCoderResult === undefined
707
- ? 'reviewerProposalComplete'
708
- : 'commitCoderProposal',
709
- acceptedOutcome: 'proposed',
710
- }),
711
- },
880
+ acceptedOutcome('askReviewerProposal', 'reviewerProposalStaged', 'proposed'),
712
881
  'stageReviewerProposal',
713
- {
714
- type: 'clearBranchBossReplyContext',
715
- params: { stateId: 'askReviewerProposal' },
716
- },
717
- ],
718
- },
719
- {
720
- target: '#failed',
721
- actions: [
722
- 'rememberMalformedActorOutput',
723
- 'clearProposalRoundContext',
724
882
  ],
725
883
  },
884
+ malformedPlayerOutputArm('DECIDE-2'),
726
885
  ],
727
- onError: {
728
- target: '#failed',
729
- actions: [
730
- 'rememberActorError',
731
- 'clearProposalRoundContext',
732
- ],
733
- },
886
+ onError: playerOnError,
734
887
  },
735
888
  },
736
- waiting: {
737
- id: 'waitReviewerProposalReply',
738
- tags: 'playbook.parked',
739
- description: 'Reviewer waits for Boss to answer a question.',
740
- meta: {
741
- playbook: {
742
- stateId: 'waitReviewerProposalReply',
743
- description: 'Reviewer waits for Boss to answer a question.',
744
- },
745
- },
889
+ awaitReviewerProposalReply: {
890
+ id: 'awaitReviewerProposalReply',
891
+ description: STATE_DESCRIPTIONS.awaitReviewerProposalReply,
892
+ meta: playbookMeta('awaitReviewerProposalReply'),
893
+ tags: ['playbook.parked'],
746
894
  on: {
747
- BOSS_REPLY: [
748
- {
749
- guard: bossReplyTargets('askReviewerProposal', false),
750
- target: '#failed',
751
- actions: [
752
- 'rememberMalformedBossReply',
753
- 'clearProposalRoundContext',
754
- ],
755
- },
756
- {
757
- guard: bossReplyTargets('askReviewerProposal', true),
758
- target: 'working',
759
- actions: 'rememberBossReply',
760
- },
761
- ],
895
+ BOSS_REPLY: resumableStates(['askReviewerProposal']),
762
896
  },
763
897
  },
764
- complete: {
898
+ reviewerProposalStaged: {
899
+ id: 'reviewerProposalStaged',
900
+ description: STATE_DESCRIPTIONS.reviewerProposalStaged,
901
+ meta: playbookMeta('reviewerProposalStaged'),
765
902
  type: 'final',
766
- description: 'Reviewer proposal is staged for the parallel join.',
767
- meta: {
768
- playbook: {
769
- stateId: 'reviewerProposalComplete',
770
- description: 'Reviewer proposal is staged for the parallel join.',
771
- },
772
- },
773
903
  },
774
904
  },
775
905
  },
776
906
  },
907
+ // The join: taken only after both regions reach final; it promotes both
908
+ // staged proposals atomically before DECIDE-3 begins.
777
909
  onDone: {
778
- target: 'commitCoderProposal',
910
+ target: 'synthesizeCommit',
779
911
  actions: 'promoteProposals',
780
912
  },
781
913
  },
782
- commitCoderProposal: {
783
- id: 'commitCoderProposal',
784
- tags: 'playbook.busy',
785
- description: 'Coder synthesizes both proposals and commits the design.',
786
- meta: {
787
- playbook: {
788
- stateId: 'commitCoderProposal',
789
- description: 'Coder synthesizes both proposals and commits the design.',
790
- role: 'coder',
791
- },
792
- },
914
+ synthesizeCommit: {
915
+ id: 'synthesizeCommit',
916
+ description: STATE_DESCRIPTIONS.synthesizeCommit,
917
+ meta: playbookMeta('synthesizeCommit', 'coder'),
918
+ tags: ['playbook.busy'],
793
919
  invoke: {
794
920
  src: 'player',
795
921
  input: ({ context }) => ({
796
- stateId: 'commitCoderProposal',
797
- sourceItem: 'DECIDE-3',
922
+ stateId: 'synthesizeCommit',
798
923
  role: 'coder',
924
+ sourceItem: 'DECIDE-3',
799
925
  prompt: SYNTHESIZE_COMMIT_PROMPT,
800
- result: withNeedsBossReply({
801
- committed: 'Coder synthesized both proposals and committed the resulting design as one new commit. Output shall include `coderOutput: <verbatim final text>` and `latestCommit: <commit identity>`.',
802
- }),
926
+ result: SYNTHESIZE_COMMIT_RESULT,
803
927
  callerTopic: context.callerTopic,
804
928
  reviewerProposal: context.reviewerProposal,
805
- ...bossReplyFields(context, 'commitCoderProposal'),
929
+ ...bossReplyFields(context, 'synthesizeCommit'),
806
930
  }),
807
931
  onDone: [
808
- {
809
- guard: 'needsBossReplyWithQuestion',
810
- target: 'awaitBossReply',
811
- actions: [
812
- {
813
- type: 'playbook.acceptedOutcome',
814
- params: {
815
- source: 'commitCoderProposal',
816
- target: 'awaitBossReply',
817
- acceptedOutcome: 'needsBossReply',
818
- },
819
- },
820
- {
821
- type: 'setPendingBossQuestion',
822
- params: {
823
- ...stateMetadata.commitCoderProposal,
824
- resumeStateId: 'commitCoderProposal',
825
- question: '',
826
- },
827
- },
828
- ],
829
- },
830
- {
831
- guard: 'needsBossReplyWithoutQuestion',
832
- target: 'failed',
833
- actions: [
834
- 'rememberMalformedActorOutput',
835
- 'clearBossReplyContext',
836
- ],
837
- },
932
+ suspendArm('synthesizeCommit', 'awaitBossReply'),
838
933
  {
839
934
  guard: 'committed',
840
935
  target: 'reviewCommit',
841
936
  actions: [
842
- {
843
- type: 'playbook.acceptedOutcome',
844
- params: {
845
- source: 'commitCoderProposal',
846
- target: 'reviewCommit',
847
- acceptedOutcome: 'committed',
848
- },
849
- },
937
+ acceptedOutcome('synthesizeCommit', 'reviewCommit', 'committed'),
850
938
  'rememberCommit',
851
- {
852
- type: 'clearBranchBossReplyContext',
853
- params: { stateId: 'commitCoderProposal' },
854
- },
855
- ],
856
- },
857
- {
858
- target: 'failed',
859
- actions: [
860
- 'rememberMalformedActorOutput',
861
- 'clearBossReplyContext',
862
939
  ],
863
940
  },
941
+ malformedPlayerOutputArm('DECIDE-3'),
864
942
  ],
865
- onError: {
866
- target: 'failed',
867
- actions: ['rememberActorError', 'clearBossReplyContext'],
868
- },
943
+ onError: playerOnError,
869
944
  },
870
945
  },
871
946
  awaitBossReply: {
872
947
  id: 'awaitBossReply',
873
- tags: 'playbook.parked',
874
- description: 'Waiting for Boss to answer Coder’s question.',
875
- meta: {
876
- playbook: {
877
- stateId: 'awaitBossReply',
878
- description: 'Waiting for Boss to answer Coder’s question.',
879
- },
880
- },
948
+ description: STATE_DESCRIPTIONS.awaitBossReply,
949
+ meta: playbookMeta('awaitBossReply'),
950
+ tags: ['playbook.parked'],
881
951
  on: {
882
952
  BOSS_REPLY: [
883
- {
884
- guard: bossReplyTargets('commitCoderProposal', false),
885
- target: 'failed',
886
- actions: [
887
- 'rememberMalformedBossReply',
888
- 'clearBossReplyContext',
889
- ],
890
- },
891
- ...resumableStates(['commitCoderProposal']),
953
+ unknownBossReplyArm,
954
+ ...resumableStates(['synthesizeCommit']),
892
955
  ],
956
+ START_DECIDE: startDecideArm,
893
957
  },
894
958
  },
895
959
  reviewCommit: {
896
960
  id: 'reviewCommit',
897
- tags: 'playbook.suspended',
898
- description: 'REVIEW examines the committed proposal.',
899
- meta: {
900
- playbook: {
901
- stateId: 'reviewCommit',
902
- description: 'REVIEW examines the committed proposal.',
903
- },
904
- },
961
+ description: STATE_DESCRIPTIONS.reviewCommit,
962
+ meta: playbookMeta('reviewCommit'),
963
+ tags: ['playbook.suspended'],
905
964
  invoke: {
906
965
  src: 'playbook',
907
966
  input: ({ context }) => ({
908
967
  stateId: 'reviewCommit',
909
968
  sourceItem: 'DECIDE-4',
910
- playbookId: 'review',
911
- text: composeReviewInput(context.callerTopic, context.latestCommit, context.coderOutput),
969
+ playbookId: REVIEW_PLAYBOOK_ID,
970
+ text: reviewCallText(context),
912
971
  }),
913
972
  onDone: [
914
973
  {
915
- guard: 'validReviewSuccess',
974
+ guard: 'reviewApproved',
916
975
  target: 'done',
917
- actions: 'rememberReviewSuccess',
976
+ actions: 'rememberReviewApproval',
918
977
  },
919
978
  {
920
979
  target: 'reportedReviewFailure',
921
- actions: 'rememberReviewProtocolFailure',
980
+ actions: 'rememberUnestablishedReview',
922
981
  },
923
982
  ],
924
983
  onError: [
925
984
  {
926
985
  guard: 'authoredReviewFailure',
927
986
  target: 'reportedReviewFailure',
928
- actions: 'rememberReviewFailure',
929
- },
930
- {
931
- target: 'failed',
932
- actions: 'rememberActorError',
987
+ actions: 'rememberAuthoredReviewFailure',
933
988
  },
989
+ { target: 'failed', actions: 'rememberActorError' },
934
990
  ],
935
991
  },
936
992
  },
937
993
  failed: {
938
994
  id: 'failed',
939
- tags: 'playbook.parked',
940
- description: 'DECIDE failed and is waiting for a new topic.',
941
- meta: {
942
- playbook: {
943
- stateId: 'failed',
944
- description: 'DECIDE failed and is waiting for a new topic.',
945
- },
946
- },
995
+ description: STATE_DESCRIPTIONS.failed,
996
+ meta: playbookMeta('failed'),
997
+ tags: ['playbook.parked'],
947
998
  on: {
948
- START_DECIDE: {
949
- guard: ({ event }) => event.callerTopic.trim().length > 0,
950
- target: 'independentProposals',
951
- actions: 'copyStartTopic',
952
- },
999
+ START_DECIDE: startDecideArm,
953
1000
  },
954
1001
  },
955
1002
  reportedReviewFailure: {
956
1003
  id: 'reportedReviewFailure',
1004
+ description: STATE_DESCRIPTIONS.reportedReviewFailure,
1005
+ meta: terminalMeta('reportedReviewFailure', 'failure'),
957
1006
  type: 'final',
958
- description: 'DECIDE reports REVIEW’s failure and its last commit.',
959
- meta: {
960
- playbook: {
961
- stateId: 'reportedReviewFailure',
962
- description: 'DECIDE reports REVIEW’s failure and its last commit.',
963
- // DR-048: this final state means the workflow failed, so a caller
964
- // learns that from the machine rather than from DECIDE's output.
965
- terminal: 'failure',
966
- },
967
- },
968
1007
  },
969
1008
  done: {
970
1009
  id: 'done',
1010
+ description: STATE_DESCRIPTIONS.done,
1011
+ meta: terminalMeta('done', 'success'),
971
1012
  type: 'final',
972
- description: 'DECIDE completed with an approved commit.',
973
- meta: {
974
- playbook: {
975
- stateId: 'done',
976
- description: 'DECIDE completed with an approved commit.',
977
- terminal: 'success',
978
- },
979
- },
980
1013
  },
981
1014
  },
982
1015
  });