agentxchain 2.10.0 → 2.12.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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Run Loop — reusable governed-execution engine.
3
+ *
4
+ * Drives repeated governed turns to a terminal state, yielding control at
5
+ * well-defined pause points. Any runner (CLI, CI, hosted, custom) composes
6
+ * this to implement continuous governed delivery.
7
+ *
8
+ * Design rules:
9
+ * - Never calls process dot exit
10
+ * - No stdout/stderr
11
+ * - No adapter dispatch (caller provides dispatch callback)
12
+ * - Imports only from runner-interface.js
13
+ */
14
+
15
+ import {
16
+ loadState,
17
+ initRun,
18
+ assignTurn,
19
+ acceptTurn,
20
+ rejectTurn,
21
+ writeDispatchBundle,
22
+ getTurnStagingResultPath,
23
+ approvePhaseGate,
24
+ approveCompletionGate,
25
+ getActiveTurn,
26
+ getActiveTurnCount,
27
+ RUNNER_INTERFACE_VERSION,
28
+ } from './runner-interface.js';
29
+
30
+ import { mkdirSync, writeFileSync } from 'fs';
31
+ import { join, dirname } from 'path';
32
+
33
+ const DEFAULT_MAX_TURNS = 50;
34
+
35
+ /**
36
+ * Drive governed turns to a terminal state.
37
+ *
38
+ * @param {string} root - project root directory
39
+ * @param {object} config - normalized governed config
40
+ * @param {object} callbacks - { selectRole, dispatch, approveGate, onEvent? }
41
+ * @param {object} [options] - { maxTurns?: number }
42
+ * @returns {Promise<RunLoopResult>}
43
+ */
44
+ export async function runLoop(root, config, callbacks, options = {}) {
45
+ const maxTurns = options.maxTurns ?? config.run_loop?.max_turns ?? DEFAULT_MAX_TURNS;
46
+ let turnsExecuted = 0;
47
+ const turnHistory = [];
48
+ let gatesApproved = 0;
49
+ const errors = [];
50
+ const emit = (event) => {
51
+ if (!callbacks.onEvent) return;
52
+ try {
53
+ callbacks.onEvent(event);
54
+ } catch (err) {
55
+ errors.push(`onEvent threw for ${event?.type || 'unknown'}: ${err.message}`);
56
+ }
57
+ };
58
+
59
+ // ── Initialize if idle ──────────────────────────────────────────────────
60
+ let state = loadState(root, config);
61
+ if (!state || state.status === 'idle') {
62
+ const initResult = initRun(root, config);
63
+ if (!initResult.ok) {
64
+ return makeResult(false, 'init_failed', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, [initResult.error]);
65
+ }
66
+ state = initResult.state;
67
+ }
68
+
69
+ // ── Main loop ───────────────────────────────────────────────────────────
70
+ while (true) {
71
+ state = loadState(root, config);
72
+ if (!state) {
73
+ errors.push('loadState returned null — state file missing or invalid');
74
+ return makeResult(false, 'blocked', null, turnsExecuted, turnHistory, gatesApproved, errors);
75
+ }
76
+
77
+ // Terminal: completed
78
+ if (state.status === 'completed') {
79
+ emit({ type: 'completed', state });
80
+ return makeResult(true, 'completed', state, turnsExecuted, turnHistory, gatesApproved, errors);
81
+ }
82
+
83
+ // Terminal: blocked
84
+ if (state.status === 'blocked') {
85
+ emit({ type: 'blocked', state });
86
+ return makeResult(false, 'blocked', state, turnsExecuted, turnHistory, gatesApproved, errors);
87
+ }
88
+
89
+ // Paused: gate handling
90
+ if (state.status === 'paused') {
91
+ const gateOutcome = await handleGatePause(root, config, state, callbacks, emit);
92
+ if (gateOutcome.continue) {
93
+ gatesApproved += gateOutcome.approved ? 1 : 0;
94
+ continue;
95
+ }
96
+ return makeResult(false, gateOutcome.stop_reason, loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
97
+ }
98
+
99
+ // Safety limit
100
+ if (turnsExecuted >= maxTurns) {
101
+ return makeResult(false, 'max_turns_reached', state, turnsExecuted, turnHistory, gatesApproved, errors);
102
+ }
103
+
104
+ // ── Check for active turn (retry after rejection) ──────────────────��
105
+ let turn;
106
+ let assignState;
107
+ const activeTurn = getActiveTurn(state);
108
+
109
+ if (activeTurn && (activeTurn.status === 'running' || activeTurn.status === 'retrying')) {
110
+ // Re-dispatch an existing active turn (retry after rejection)
111
+ turn = activeTurn;
112
+ assignState = state;
113
+ } else {
114
+ // ── Role selection ────────────────────────────────────────────────
115
+ let roleId;
116
+ try {
117
+ roleId = callbacks.selectRole(state, config);
118
+ } catch (err) {
119
+ errors.push(`selectRole threw: ${err.message}`);
120
+ return makeResult(false, 'dispatch_error', state, turnsExecuted, turnHistory, gatesApproved, errors);
121
+ }
122
+
123
+ if (roleId === null || roleId === undefined) {
124
+ emit({ type: 'caller_stopped', state });
125
+ return makeResult(false, 'caller_stopped', state, turnsExecuted, turnHistory, gatesApproved, errors);
126
+ }
127
+
128
+ // ── Turn assignment ───────────────────────────────────────────────
129
+ const assignResult = assignTurn(root, config, roleId);
130
+ if (!assignResult.ok) {
131
+ errors.push(`assignTurn(${roleId}): ${assignResult.error}`);
132
+ return makeResult(false, 'blocked', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
133
+ }
134
+ turn = assignResult.turn;
135
+ assignState = assignResult.state;
136
+ emit({ type: 'turn_assigned', turn, role: roleId, state: assignState });
137
+ }
138
+
139
+ const roleId = turn.assigned_role;
140
+
141
+ // ── Dispatch bundle ─────────────────────────────────────────────────
142
+ const bundleResult = writeDispatchBundle(root, assignState, config);
143
+ if (!bundleResult.ok) {
144
+ errors.push(`writeDispatchBundle(${roleId}): ${bundleResult.error}`);
145
+ return makeResult(false, 'blocked', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
146
+ }
147
+
148
+ const stagingPath = getTurnStagingResultPath(turn.turn_id);
149
+ const context = {
150
+ turn,
151
+ state: assignState,
152
+ bundlePath: bundleResult.bundlePath,
153
+ stagingPath,
154
+ config,
155
+ root,
156
+ };
157
+
158
+ // ── Dispatch ────────────────────────────────────────────────────────
159
+ let dispatchResult;
160
+ try {
161
+ dispatchResult = await callbacks.dispatch(context);
162
+ } catch (err) {
163
+ errors.push(`dispatch threw for ${roleId}: ${err.message}`);
164
+ return makeResult(false, 'dispatch_error', loadState(root, config), turnsExecuted, turnHistory, gatesApproved, errors);
165
+ }
166
+
167
+ if (dispatchResult.accept) {
168
+ // Stage the turn result
169
+ const absStaging = join(root, stagingPath);
170
+ mkdirSync(dirname(absStaging), { recursive: true });
171
+ writeFileSync(absStaging, JSON.stringify(dispatchResult.turnResult, null, 2));
172
+
173
+ // Accept
174
+ const acceptResult = acceptTurn(root, config);
175
+ if (!acceptResult.ok) {
176
+ errors.push(`acceptTurn(${roleId}): ${acceptResult.error}`);
177
+ const postState = loadState(root, config);
178
+ return makeResult(false, 'blocked', postState, turnsExecuted, turnHistory, gatesApproved, errors);
179
+ }
180
+
181
+ turnsExecuted++;
182
+ turnHistory.push({ role: roleId, turn_id: turn.turn_id, accepted: true });
183
+ emit({ type: 'turn_accepted', turn, role: roleId, state: acceptResult.state });
184
+
185
+ } else {
186
+ // Rejection
187
+ const validationResult = {
188
+ stage: 'dispatch',
189
+ errors: [dispatchResult.reason || 'Dispatch callback rejected the turn'],
190
+ };
191
+ rejectTurn(root, config, validationResult, dispatchResult.reason || 'Dispatch rejection');
192
+ turnHistory.push({ role: roleId, turn_id: turn.turn_id, accepted: false });
193
+ emit({ type: 'turn_rejected', turn, role: roleId, reason: dispatchResult.reason });
194
+
195
+ // Check if retries exhausted → run blocked
196
+ const postRejectState = loadState(root, config);
197
+ if (postRejectState?.status === 'blocked') {
198
+ errors.push(`Turn rejected for ${roleId}, retries exhausted`);
199
+ emit({ type: 'blocked', state: postRejectState });
200
+ return makeResult(false, 'reject_exhausted', postRejectState, turnsExecuted, turnHistory, gatesApproved, errors);
201
+ }
202
+ // Otherwise continue — loop will detect the active turn and re-dispatch
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Handle a paused state by checking for pending gates and calling approveGate.
209
+ */
210
+ async function handleGatePause(root, config, state, callbacks, emit) {
211
+ if (state.pending_phase_transition) {
212
+ emit({ type: 'gate_paused', gateType: 'phase_transition', state });
213
+ let approved;
214
+ try {
215
+ approved = await callbacks.approveGate('phase_transition', state);
216
+ } catch (err) {
217
+ return { continue: false, stop_reason: 'blocked', approved: false };
218
+ }
219
+ if (approved) {
220
+ const gateResult = approvePhaseGate(root, config);
221
+ if (!gateResult.ok) {
222
+ return { continue: false, stop_reason: 'blocked', approved: false };
223
+ }
224
+ emit({ type: 'gate_approved', gateType: 'phase_transition', state: loadState(root, config) });
225
+ return { continue: true, approved: true };
226
+ }
227
+ emit({ type: 'gate_held', gateType: 'phase_transition', state });
228
+ return { continue: false, stop_reason: 'gate_held', approved: false };
229
+ }
230
+
231
+ if (state.pending_run_completion) {
232
+ emit({ type: 'gate_paused', gateType: 'run_completion', state });
233
+ let approved;
234
+ try {
235
+ approved = await callbacks.approveGate('run_completion', state);
236
+ } catch (err) {
237
+ return { continue: false, stop_reason: 'blocked', approved: false };
238
+ }
239
+ if (approved) {
240
+ const gateResult = approveCompletionGate(root, config);
241
+ if (!gateResult.ok) {
242
+ return { continue: false, stop_reason: 'blocked', approved: false };
243
+ }
244
+ emit({ type: 'gate_approved', gateType: 'run_completion', state: loadState(root, config) });
245
+ return { continue: true, approved: true };
246
+ }
247
+ emit({ type: 'gate_held', gateType: 'run_completion', state });
248
+ return { continue: false, stop_reason: 'gate_held', approved: false };
249
+ }
250
+
251
+ // Paused without a known gate — treat as blocked
252
+ return { continue: false, stop_reason: 'blocked', approved: false };
253
+ }
254
+
255
+ function makeResult(ok, stop_reason, state, turns_executed, turn_history, gates_approved, errors) {
256
+ return {
257
+ ok,
258
+ stop_reason,
259
+ state: state || null,
260
+ turns_executed,
261
+ turn_history,
262
+ gates_approved,
263
+ errors,
264
+ };
265
+ }
266
+
267
+ function noop() {}
268
+
269
+ export { DEFAULT_MAX_TURNS };
@@ -7,6 +7,7 @@ import {
7
7
  validateGovernedTemplateRegistry,
8
8
  validateProjectPlanningArtifacts,
9
9
  validateAcceptanceHintCompletion,
10
+ validateGovernedWorkflowKit,
10
11
  } from './governed-templates.js';
11
12
 
12
13
  const DEFAULT_REQUIRED_FILES = [
@@ -110,6 +111,10 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
110
111
  const acceptanceHints = validateAcceptanceHintCompletion(root, rawConfig?.template);
111
112
  warnings.push(...acceptanceHints.warnings);
112
113
 
114
+ const workflowKit = validateGovernedWorkflowKit(root, config);
115
+ errors.push(...workflowKit.errors);
116
+ warnings.push(...workflowKit.warnings);
117
+
113
118
  const mustExist = [
114
119
  config.files?.state || '.agentxchain/state.json',
115
120
  config.files?.history || '.agentxchain/history.jsonl',
@@ -132,14 +137,6 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
132
137
  }
133
138
  }
134
139
 
135
- for (const [gateId, gate] of Object.entries(config.gates || {})) {
136
- for (const rel of gate.requires_files || []) {
137
- if (!existsSync(join(root, rel))) {
138
- errors.push(`Gate "${gateId}" requires missing file: ${rel}`);
139
- }
140
- }
141
- }
142
-
143
140
  const statePath = join(root, config.files?.state || '.agentxchain/state.json');
144
141
  const state = readJson(statePath);
145
142
  if (!state) {
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const PM_SIGNOFF_PATH = '.planning/PM_SIGNOFF.md';
5
+ export const SHIP_VERDICT_PATH = '.planning/ship-verdict.md';
6
+
7
+ const AFFIRMATIVE_SHIP_VERDICTS = new Set(['YES', 'SHIP', 'SHIP IT']);
8
+
9
+ function normalizeToken(value) {
10
+ return value.trim().replace(/\s+/g, ' ').toUpperCase();
11
+ }
12
+
13
+ function readFile(root, relPath) {
14
+ const absPath = join(root, relPath);
15
+ if (!existsSync(absPath)) {
16
+ return null;
17
+ }
18
+ return readFileSync(absPath, 'utf8');
19
+ }
20
+
21
+ function parseLineValue(content, pattern) {
22
+ const match = content.match(pattern);
23
+ return match ? match[1].trim() : null;
24
+ }
25
+
26
+ function evaluatePmSignoff(content) {
27
+ const approved = parseLineValue(content, /^Approved\s*:\s*(.+)$/im);
28
+ if (!approved) {
29
+ return {
30
+ ok: false,
31
+ reason: 'PM signoff must declare `Approved: YES` in .planning/PM_SIGNOFF.md.',
32
+ };
33
+ }
34
+
35
+ if (normalizeToken(approved) !== 'YES') {
36
+ return {
37
+ ok: false,
38
+ reason: `PM signoff is not approved. Found "Approved: ${approved}" in .planning/PM_SIGNOFF.md; set it to "Approved: YES".`,
39
+ };
40
+ }
41
+
42
+ return { ok: true };
43
+ }
44
+
45
+ function evaluateShipVerdict(content) {
46
+ const verdict = parseLineValue(content, /^##\s+Verdict\s*:\s*(.+)$/im);
47
+ if (!verdict) {
48
+ return {
49
+ ok: false,
50
+ reason: 'Ship verdict must declare an affirmative `## Verdict:` line in .planning/ship-verdict.md.',
51
+ };
52
+ }
53
+
54
+ if (!AFFIRMATIVE_SHIP_VERDICTS.has(normalizeToken(verdict))) {
55
+ return {
56
+ ok: false,
57
+ reason: `Ship verdict is not affirmative. Found "## Verdict: ${verdict}" in .planning/ship-verdict.md; use "## Verdict: YES".`,
58
+ };
59
+ }
60
+
61
+ return { ok: true };
62
+ }
63
+
64
+ export function evaluateWorkflowGateSemantics(root, relPath) {
65
+ const content = readFile(root, relPath);
66
+ if (content === null) {
67
+ return null;
68
+ }
69
+
70
+ if (relPath === PM_SIGNOFF_PATH) {
71
+ return evaluatePmSignoff(content);
72
+ }
73
+
74
+ if (relPath === SHIP_VERDICT_PATH) {
75
+ return evaluateShipVerdict(content);
76
+ }
77
+
78
+ return null;
79
+ }