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.
- package/README.md +12 -5
- package/bin/agentxchain.js +17 -0
- package/package.json +2 -1
- package/src/commands/init.js +163 -30
- package/src/commands/run.js +365 -0
- package/src/commands/step.js +12 -32
- package/src/commands/template-validate.js +42 -0
- package/src/commands/verify.js +41 -13
- package/src/lib/adapters/api-proxy-adapter.js +1 -1
- package/src/lib/gate-evaluator.js +13 -0
- package/src/lib/governed-templates.js +94 -0
- package/src/lib/normalized-config.js +15 -1
- package/src/lib/protocol-conformance.js +230 -15
- package/src/lib/reference-conformance-adapter.js +47 -0
- package/src/lib/role-resolution.js +103 -0
- package/src/lib/run-loop.js +269 -0
- package/src/lib/validation.js +5 -8
- package/src/lib/workflow-gate-semantics.js +79 -0
|
@@ -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 };
|
package/src/lib/validation.js
CHANGED
|
@@ -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
|
+
}
|