agentxchain 0.8.8 → 2.1.1

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 (74) hide show
  1. package/README.md +126 -142
  2. package/bin/agentxchain.js +186 -5
  3. package/dashboard/app.js +305 -0
  4. package/dashboard/components/blocked.js +145 -0
  5. package/dashboard/components/cross-repo.js +126 -0
  6. package/dashboard/components/gate.js +311 -0
  7. package/dashboard/components/hooks.js +177 -0
  8. package/dashboard/components/initiative.js +147 -0
  9. package/dashboard/components/ledger.js +165 -0
  10. package/dashboard/components/timeline.js +222 -0
  11. package/dashboard/index.html +352 -0
  12. package/package.json +14 -6
  13. package/scripts/live-api-proxy-preflight-smoke.sh +531 -0
  14. package/scripts/publish-from-tag.sh +88 -0
  15. package/scripts/release-postflight.sh +231 -0
  16. package/scripts/release-preflight.sh +167 -0
  17. package/src/commands/accept-turn.js +160 -0
  18. package/src/commands/approve-completion.js +80 -0
  19. package/src/commands/approve-transition.js +85 -0
  20. package/src/commands/dashboard.js +70 -0
  21. package/src/commands/init.js +516 -0
  22. package/src/commands/migrate.js +348 -0
  23. package/src/commands/multi.js +549 -0
  24. package/src/commands/plugin.js +157 -0
  25. package/src/commands/reject-turn.js +204 -0
  26. package/src/commands/resume.js +389 -0
  27. package/src/commands/status.js +196 -3
  28. package/src/commands/step.js +947 -0
  29. package/src/commands/template-list.js +33 -0
  30. package/src/commands/template-set.js +279 -0
  31. package/src/commands/validate.js +20 -11
  32. package/src/commands/verify.js +71 -0
  33. package/src/lib/adapters/api-proxy-adapter.js +1076 -0
  34. package/src/lib/adapters/local-cli-adapter.js +337 -0
  35. package/src/lib/adapters/manual-adapter.js +169 -0
  36. package/src/lib/blocked-state.js +94 -0
  37. package/src/lib/config.js +97 -1
  38. package/src/lib/context-compressor.js +121 -0
  39. package/src/lib/context-section-parser.js +220 -0
  40. package/src/lib/coordinator-acceptance.js +428 -0
  41. package/src/lib/coordinator-config.js +461 -0
  42. package/src/lib/coordinator-dispatch.js +276 -0
  43. package/src/lib/coordinator-gates.js +487 -0
  44. package/src/lib/coordinator-hooks.js +239 -0
  45. package/src/lib/coordinator-recovery.js +523 -0
  46. package/src/lib/coordinator-state.js +365 -0
  47. package/src/lib/cross-repo-context.js +247 -0
  48. package/src/lib/dashboard/bridge-server.js +284 -0
  49. package/src/lib/dashboard/file-watcher.js +93 -0
  50. package/src/lib/dashboard/state-reader.js +96 -0
  51. package/src/lib/dispatch-bundle.js +568 -0
  52. package/src/lib/dispatch-manifest.js +252 -0
  53. package/src/lib/gate-evaluator.js +285 -0
  54. package/src/lib/governed-state.js +2139 -0
  55. package/src/lib/governed-templates.js +145 -0
  56. package/src/lib/hook-runner.js +788 -0
  57. package/src/lib/normalized-config.js +539 -0
  58. package/src/lib/plugin-config-schema.js +192 -0
  59. package/src/lib/plugins.js +692 -0
  60. package/src/lib/protocol-conformance.js +291 -0
  61. package/src/lib/reference-conformance-adapter.js +717 -0
  62. package/src/lib/repo-observer.js +597 -0
  63. package/src/lib/repo.js +0 -31
  64. package/src/lib/schema.js +121 -0
  65. package/src/lib/schemas/turn-result.schema.json +205 -0
  66. package/src/lib/token-budget.js +206 -0
  67. package/src/lib/token-counter.js +27 -0
  68. package/src/lib/turn-paths.js +67 -0
  69. package/src/lib/turn-result-validator.js +496 -0
  70. package/src/lib/validation.js +137 -0
  71. package/src/templates/governed/api-service.json +31 -0
  72. package/src/templates/governed/cli-tool.json +30 -0
  73. package/src/templates/governed/generic.json +10 -0
  74. package/src/templates/governed/web-app.json +30 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Dispatch manifest integrity — content-addressed bundle verification.
3
+ *
4
+ * Implements V2.1-F1: each finalized dispatch bundle gets a MANIFEST.json
5
+ * containing SHA-256 digests and byte sizes for every file in the bundle.
6
+ * Adapters verify the manifest before consuming bundle files.
7
+ *
8
+ * Timing:
9
+ * writeDispatchBundle() → core files written
10
+ * after_dispatch hooks → supplements added
11
+ * finalizeDispatchManifest() → MANIFEST.json sealed
12
+ * verifyDispatchManifest() → adapter checks before execution
13
+ */
14
+
15
+ import { createHash } from 'crypto';
16
+ import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from 'fs';
17
+ import { join, relative } from 'path';
18
+ import { getDispatchTurnDir, getDispatchManifestPath } from './turn-paths.js';
19
+
20
+ const MANIFEST_VERSION = '1.0';
21
+ const MANIFEST_FILENAME = 'MANIFEST.json';
22
+
23
+ /**
24
+ * Finalize a dispatch bundle by writing MANIFEST.json with content-addressed entries.
25
+ *
26
+ * Must be called AFTER writeDispatchBundle() and after_dispatch hooks have completed.
27
+ *
28
+ * @param {string} root - project root directory
29
+ * @param {string} turnId - turn identifier
30
+ * @param {{ run_id: string, role: string }} identity - turn identity for manifest metadata
31
+ * @returns {{ ok: boolean, manifestPath?: string, fileCount?: number, error?: string }}
32
+ */
33
+ export function finalizeDispatchManifest(root, turnId, identity) {
34
+ const bundleDir = join(root, getDispatchTurnDir(turnId));
35
+
36
+ if (!existsSync(bundleDir)) {
37
+ return { ok: false, error: 'Bundle directory does not exist' };
38
+ }
39
+
40
+ const entries = scanBundleFiles(bundleDir);
41
+ if (entries.length === 0) {
42
+ return { ok: false, error: 'Bundle directory is empty — no files to manifest' };
43
+ }
44
+
45
+ const manifest = {
46
+ manifest_version: MANIFEST_VERSION,
47
+ run_id: identity.run_id,
48
+ turn_id: turnId,
49
+ role: identity.role,
50
+ finalized_at: new Date().toISOString(),
51
+ files: entries,
52
+ };
53
+
54
+ const manifestPath = join(root, getDispatchManifestPath(turnId));
55
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
56
+
57
+ return { ok: true, manifestPath, fileCount: entries.length };
58
+ }
59
+
60
+ /**
61
+ * Return whether a finalized manifest exists for the dispatch bundle.
62
+ *
63
+ * @param {string} root
64
+ * @param {string} turnId
65
+ * @returns {boolean}
66
+ */
67
+ export function hasDispatchManifest(root, turnId) {
68
+ return existsSync(join(root, getDispatchManifestPath(turnId)));
69
+ }
70
+
71
+ /**
72
+ * Verify a dispatch bundle against its MANIFEST.json.
73
+ *
74
+ * @param {string} root - project root directory
75
+ * @param {string} turnId - turn identifier
76
+ * @returns {{ ok: boolean, errors?: Array<{ type: string, path?: string, detail: string }>, manifest?: object }}
77
+ */
78
+ export function verifyDispatchManifest(root, turnId) {
79
+ const bundleDir = join(root, getDispatchTurnDir(turnId));
80
+ const manifestPath = join(root, getDispatchManifestPath(turnId));
81
+
82
+ // Check manifest exists
83
+ if (!existsSync(manifestPath)) {
84
+ return {
85
+ ok: false,
86
+ errors: [{ type: 'missing_manifest', detail: 'MANIFEST.json does not exist in bundle directory' }],
87
+ };
88
+ }
89
+
90
+ // Parse manifest
91
+ let manifest;
92
+ try {
93
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
94
+ } catch (err) {
95
+ return {
96
+ ok: false,
97
+ errors: [{ type: 'invalid_manifest', detail: `MANIFEST.json is malformed: ${err.message}` }],
98
+ };
99
+ }
100
+
101
+ // Validate schema
102
+ if (!manifest.manifest_version || !Array.isArray(manifest.files)) {
103
+ return {
104
+ ok: false,
105
+ errors: [{ type: 'invalid_manifest', detail: 'MANIFEST.json missing required fields (manifest_version, files)' }],
106
+ };
107
+ }
108
+
109
+ const errors = [];
110
+
111
+ // Check each declared file
112
+ for (const entry of manifest.files) {
113
+ const filePath = join(bundleDir, entry.path);
114
+
115
+ if (!existsSync(filePath)) {
116
+ errors.push({ type: 'missing_file', path: entry.path, detail: `Declared file "${entry.path}" is missing from bundle` });
117
+ continue;
118
+ }
119
+
120
+ const content = readFileSync(filePath);
121
+ const actualSize = content.length;
122
+ const actualDigest = createHash('sha256').update(content).digest('hex');
123
+
124
+ if (actualSize !== entry.size) {
125
+ errors.push({
126
+ type: 'size_mismatch',
127
+ path: entry.path,
128
+ detail: `"${entry.path}" size mismatch: manifest=${entry.size}, actual=${actualSize}`,
129
+ });
130
+ }
131
+
132
+ if (actualDigest !== entry.sha256) {
133
+ errors.push({
134
+ type: 'digest_mismatch',
135
+ path: entry.path,
136
+ detail: `"${entry.path}" SHA-256 mismatch: manifest=${entry.sha256}, actual=${actualDigest}`,
137
+ });
138
+ }
139
+ }
140
+
141
+ // Check for unexpected files
142
+ const declaredPaths = new Set(manifest.files.map((f) => f.path));
143
+ const actualFiles = scanFilenames(bundleDir);
144
+ for (const actual of actualFiles) {
145
+ if (!declaredPaths.has(actual)) {
146
+ errors.push({
147
+ type: 'unexpected_file',
148
+ path: actual,
149
+ detail: `Unexpected file "${actual}" not declared in manifest`,
150
+ });
151
+ }
152
+ }
153
+
154
+ if (errors.length > 0) {
155
+ return { ok: false, errors, manifest };
156
+ }
157
+
158
+ return { ok: true, manifest };
159
+ }
160
+
161
+ /**
162
+ * Verify dispatch bundle integrity according to adapter consumption policy.
163
+ *
164
+ * Policy:
165
+ * - default: verify automatically when MANIFEST.json exists
166
+ * - verifyManifest: true → manifest is required and verified
167
+ * - skipManifestVerification: true → explicit escape hatch; skip entirely
168
+ *
169
+ * @param {string} root
170
+ * @param {string} turnId
171
+ * @param {{ verifyManifest?: boolean, skipManifestVerification?: boolean }} [options]
172
+ * @returns {{ ok: boolean, skipped?: boolean, manifestPresent?: boolean, error?: string, manifest?: object, errors?: Array<{ type: string, path?: string, detail: string }> }}
173
+ */
174
+ export function verifyDispatchManifestForAdapter(root, turnId, options = {}) {
175
+ const requireManifest = options.verifyManifest === true;
176
+ const skipManifestVerification = options.skipManifestVerification === true;
177
+
178
+ if (skipManifestVerification) {
179
+ return { ok: true, skipped: true, manifestPresent: hasDispatchManifest(root, turnId) };
180
+ }
181
+
182
+ const manifestPresent = hasDispatchManifest(root, turnId);
183
+ if (!manifestPresent && !requireManifest) {
184
+ return { ok: true, skipped: true, manifestPresent: false };
185
+ }
186
+
187
+ const manifestCheck = verifyDispatchManifest(root, turnId);
188
+ if (!manifestCheck.ok) {
189
+ return {
190
+ ok: false,
191
+ manifestPresent,
192
+ errors: manifestCheck.errors,
193
+ error: formatDispatchManifestErrors(manifestCheck.errors),
194
+ };
195
+ }
196
+
197
+ return {
198
+ ok: true,
199
+ manifestPresent,
200
+ manifest: manifestCheck.manifest,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Format structured manifest errors for CLI/operator display.
206
+ *
207
+ * @param {Array<{ type: string, detail: string }>} errors
208
+ * @returns {string}
209
+ */
210
+ export function formatDispatchManifestErrors(errors = []) {
211
+ return errors.map((entry) => `${entry.type}: ${entry.detail}`).join('; ');
212
+ }
213
+
214
+ // ── Helpers ─────────────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Scan a bundle directory and return manifest file entries.
218
+ * Excludes MANIFEST.json itself.
219
+ */
220
+ function scanBundleFiles(bundleDir) {
221
+ const entries = [];
222
+ const files = readdirSync(bundleDir);
223
+
224
+ for (const filename of files) {
225
+ if (filename === MANIFEST_FILENAME) continue;
226
+
227
+ const filePath = join(bundleDir, filename);
228
+ const stat = statSync(filePath);
229
+ if (!stat.isFile()) continue;
230
+
231
+ const content = readFileSync(filePath);
232
+ const digest = createHash('sha256').update(content).digest('hex');
233
+
234
+ entries.push({
235
+ path: filename,
236
+ sha256: digest,
237
+ size: content.length,
238
+ });
239
+ }
240
+
241
+ // Sort for deterministic manifest output
242
+ entries.sort((a, b) => a.path.localeCompare(b.path));
243
+ return entries;
244
+ }
245
+
246
+ /**
247
+ * Get all filenames in a bundle directory, excluding MANIFEST.json.
248
+ */
249
+ function scanFilenames(bundleDir) {
250
+ return readdirSync(bundleDir)
251
+ .filter((f) => f !== MANIFEST_FILENAME && statSync(join(bundleDir, f)).isFile());
252
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Gate evaluator — pure library for phase exit gate evaluation.
3
+ *
4
+ * Implements the frozen spec (§40-§43):
5
+ *
6
+ * - evaluatePhaseExit() is a pure function: (state, config, acceptedTurn, root) → GateResult
7
+ * - Phase advancement happens only on accepted turns
8
+ * - phase_transition_request must be explicitly present in the turn result
9
+ * - Human-approval gates pause the run instead of auto-advancing
10
+ *
11
+ * Rules from §43:
12
+ * Rule 1: No phase_transition_request → stay in current phase
13
+ * Rule 2: Unknown target phase → gate_error
14
+ * Rule 3: Request present but gate fails → accept turn, stay in phase
15
+ * Rule 4: Request present + gate passes (no human approval) → advance immediately
16
+ * Rule 5: Request present + gate passes + requires_human_approval → pause with pending_phase_transition
17
+ * Rule 6: Acceptance never auto-assigns the next turn
18
+ */
19
+
20
+ import { existsSync } from 'fs';
21
+ import { join } from 'path';
22
+
23
+ /**
24
+ * Evaluate whether the current phase exit gate is satisfied.
25
+ *
26
+ * @param {object} params
27
+ * @param {object} params.state - current run state (post-acceptance)
28
+ * @param {object} params.config - normalized config
29
+ * @param {object} params.acceptedTurn - the accepted turn result
30
+ * @param {string} params.root - project root directory
31
+ * @returns {GateResult}
32
+ *
33
+ * @typedef {object} GateResult
34
+ * @property {string|null} gate_id - the gate that was evaluated, or null if no gate
35
+ * @property {boolean} passed - whether all predicates passed
36
+ * @property {boolean} blocked_by_human_approval - gate passed structurally but needs human sign-off
37
+ * @property {string[]} reasons - human-readable failure reasons
38
+ * @property {string[]} missing_files - files required by gate but not found
39
+ * @property {boolean} missing_verification - verification required but not passed
40
+ * @property {string|null} next_phase - the target phase if transition was requested and gate passed
41
+ * @property {string|null} transition_request - the raw phase_transition_request value
42
+ * @property {'no_request'|'unknown_phase'|'gate_failed'|'advance'|'awaiting_human_approval'|'no_gate'} action
43
+ */
44
+ export function evaluatePhaseExit({ state, config, acceptedTurn, root }) {
45
+ const currentPhase = state.phase;
46
+ const transitionRequest = acceptedTurn.phase_transition_request || null;
47
+
48
+ const baseResult = {
49
+ gate_id: null,
50
+ passed: false,
51
+ blocked_by_human_approval: false,
52
+ reasons: [],
53
+ missing_files: [],
54
+ missing_verification: false,
55
+ next_phase: null,
56
+ transition_request: transitionRequest,
57
+ action: 'no_request',
58
+ };
59
+
60
+ // Rule 1: No phase_transition_request → stay in current phase
61
+ if (!transitionRequest) {
62
+ return { ...baseResult, action: 'no_request' };
63
+ }
64
+
65
+ // Rule 2: Unknown target phase → error
66
+ const routing = config.routing || {};
67
+ if (!routing[transitionRequest]) {
68
+ return {
69
+ ...baseResult,
70
+ action: 'unknown_phase',
71
+ reasons: [`Requested phase "${transitionRequest}" does not exist in routing config`],
72
+ };
73
+ }
74
+
75
+ // Find the exit gate for the current phase
76
+ const currentRouting = routing[currentPhase];
77
+ if (!currentRouting || !currentRouting.exit_gate) {
78
+ // No gate defined for current phase → auto-advance
79
+ return {
80
+ ...baseResult,
81
+ passed: true,
82
+ next_phase: transitionRequest,
83
+ action: 'advance',
84
+ };
85
+ }
86
+
87
+ const gateId = currentRouting.exit_gate;
88
+ const gateDef = (config.gates || {})[gateId];
89
+
90
+ if (!gateDef) {
91
+ // Gate referenced but not defined → treat as no gate (advance)
92
+ return {
93
+ ...baseResult,
94
+ gate_id: gateId,
95
+ passed: true,
96
+ next_phase: transitionRequest,
97
+ action: 'advance',
98
+ reasons: [`Gate "${gateId}" referenced by routing but not defined in gates config — treated as open`],
99
+ };
100
+ }
101
+
102
+ // Evaluate gate predicates
103
+ const result = {
104
+ ...baseResult,
105
+ gate_id: gateId,
106
+ };
107
+
108
+ const failures = [];
109
+
110
+ // Predicate: requires_files
111
+ if (gateDef.requires_files && Array.isArray(gateDef.requires_files)) {
112
+ for (const filePath of gateDef.requires_files) {
113
+ if (!existsSync(join(root, filePath))) {
114
+ result.missing_files.push(filePath);
115
+ failures.push(`Required file missing: ${filePath}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ // Predicate: requires_verification_pass
121
+ if (gateDef.requires_verification_pass) {
122
+ const verificationStatus = acceptedTurn.verification?.status;
123
+ if (verificationStatus !== 'pass' && verificationStatus !== 'attested_pass') {
124
+ result.missing_verification = true;
125
+ failures.push(`Verification status is "${verificationStatus || 'missing'}", requires "pass" or "attested_pass"`);
126
+ }
127
+ }
128
+
129
+ if (failures.length > 0) {
130
+ // Rule 3: Gate fails → accept turn but stay in phase
131
+ result.passed = false;
132
+ result.reasons = failures;
133
+ result.action = 'gate_failed';
134
+ return result;
135
+ }
136
+
137
+ // All structural predicates passed
138
+ result.passed = true;
139
+ result.next_phase = transitionRequest;
140
+
141
+ // Rule 5: requires_human_approval → pause instead of advancing
142
+ if (gateDef.requires_human_approval) {
143
+ result.blocked_by_human_approval = true;
144
+ result.action = 'awaiting_human_approval';
145
+ return result;
146
+ }
147
+
148
+ // Rule 4: Gate passes, no human approval needed → advance
149
+ result.action = 'advance';
150
+ return result;
151
+ }
152
+
153
+ /**
154
+ * Evaluate whether a run should complete (terminal state).
155
+ *
156
+ * Called when a turn result contains `run_completion_request: true` in the
157
+ * final phase. Uses the same gate predicate system as phase transitions.
158
+ *
159
+ * @param {object} params
160
+ * @param {object} params.state - current run state (post-acceptance)
161
+ * @param {object} params.config - normalized config
162
+ * @param {object} params.acceptedTurn - the accepted turn result
163
+ * @param {string} params.root - project root directory
164
+ * @returns {RunCompletionResult}
165
+ *
166
+ * @typedef {object} RunCompletionResult
167
+ * @property {string|null} gate_id - the gate evaluated, or null
168
+ * @property {boolean} passed - whether all predicates passed
169
+ * @property {boolean} blocked_by_human_approval - gate passed structurally but needs human sign-off
170
+ * @property {string[]} reasons - human-readable failure reasons
171
+ * @property {string[]} missing_files - files required by gate but not found
172
+ * @property {boolean} missing_verification - verification required but not passed
173
+ * @property {'no_request'|'not_final_phase'|'gate_failed'|'complete'|'awaiting_human_approval'} action
174
+ */
175
+ export function evaluateRunCompletion({ state, config, acceptedTurn, root }) {
176
+ const baseResult = {
177
+ gate_id: null,
178
+ passed: false,
179
+ blocked_by_human_approval: false,
180
+ reasons: [],
181
+ missing_files: [],
182
+ missing_verification: false,
183
+ action: 'no_request',
184
+ };
185
+
186
+ // Must have explicit run_completion_request
187
+ if (!acceptedTurn.run_completion_request) {
188
+ return { ...baseResult, action: 'no_request' };
189
+ }
190
+
191
+ // Must be in the final phase
192
+ const phases = getPhaseOrder(config.routing || {});
193
+ const currentPhase = state.phase;
194
+ if (phases.length > 0 && phases[phases.length - 1] !== currentPhase) {
195
+ return {
196
+ ...baseResult,
197
+ action: 'not_final_phase',
198
+ reasons: [`Run completion requested but current phase "${currentPhase}" is not the final phase "${phases[phases.length - 1]}"`],
199
+ };
200
+ }
201
+
202
+ // Find the exit gate for the current (final) phase
203
+ const currentRouting = (config.routing || {})[currentPhase];
204
+ if (!currentRouting || !currentRouting.exit_gate) {
205
+ // No gate → auto-complete
206
+ return { ...baseResult, passed: true, action: 'complete' };
207
+ }
208
+
209
+ const gateId = currentRouting.exit_gate;
210
+ const gateDef = (config.gates || {})[gateId];
211
+
212
+ if (!gateDef) {
213
+ // Gate referenced but not defined → complete
214
+ return {
215
+ ...baseResult,
216
+ gate_id: gateId,
217
+ passed: true,
218
+ action: 'complete',
219
+ reasons: [`Gate "${gateId}" referenced but not defined — treated as open`],
220
+ };
221
+ }
222
+
223
+ // Evaluate gate predicates (same logic as phase exit)
224
+ const result = { ...baseResult, gate_id: gateId };
225
+ const failures = [];
226
+
227
+ if (gateDef.requires_files && Array.isArray(gateDef.requires_files)) {
228
+ for (const filePath of gateDef.requires_files) {
229
+ if (!existsSync(join(root, filePath))) {
230
+ result.missing_files.push(filePath);
231
+ failures.push(`Required file missing: ${filePath}`);
232
+ }
233
+ }
234
+ }
235
+
236
+ if (gateDef.requires_verification_pass) {
237
+ const verificationStatus = acceptedTurn.verification?.status;
238
+ if (verificationStatus !== 'pass' && verificationStatus !== 'attested_pass') {
239
+ result.missing_verification = true;
240
+ failures.push(`Verification status is "${verificationStatus || 'missing'}", requires "pass" or "attested_pass"`);
241
+ }
242
+ }
243
+
244
+ if (failures.length > 0) {
245
+ result.passed = false;
246
+ result.reasons = failures;
247
+ result.action = 'gate_failed';
248
+ return result;
249
+ }
250
+
251
+ // All structural predicates passed
252
+ result.passed = true;
253
+
254
+ if (gateDef.requires_human_approval) {
255
+ result.blocked_by_human_approval = true;
256
+ result.action = 'awaiting_human_approval';
257
+ return result;
258
+ }
259
+
260
+ result.action = 'complete';
261
+ return result;
262
+ }
263
+
264
+ /**
265
+ * Determine the phase ordering from routing config.
266
+ * Returns the list of phase names in declaration order.
267
+ *
268
+ * @param {object} routing - routing config object
269
+ * @returns {string[]}
270
+ */
271
+ export function getPhaseOrder(routing) {
272
+ return Object.keys(routing || {});
273
+ }
274
+
275
+ /**
276
+ * Check if a phase is the final phase in the routing config.
277
+ *
278
+ * @param {string} phase - the phase to check
279
+ * @param {object} routing - routing config object
280
+ * @returns {boolean}
281
+ */
282
+ export function isFinalPhase(phase, routing) {
283
+ const phases = getPhaseOrder(routing || {});
284
+ return phases.length > 0 && phases[phases.length - 1] === phase;
285
+ }