agentxchain 2.45.0 → 2.46.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.45.0",
3
+ "version": "2.46.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,36 @@ export async function acceptTurnCommand(opts = {}) {
23
23
  resolutionMode: opts.resolution || 'standard',
24
24
  });
25
25
  if (!result.ok) {
26
+ if (result.error_code === 'policy_escalation' || result.error_code === 'policy_violation') {
27
+ const recovery = result.state ? deriveRecoveryDescriptor(result.state, config) : null;
28
+ const retainedTurnId = result.state?.blocked_reason?.turn_id || opts.turn || '(unknown)';
29
+ const policyTitle = result.error_code === 'policy_escalation'
30
+ ? 'Turn Acceptance Escalated By Policy'
31
+ : 'Turn Acceptance Blocked By Policy';
32
+
33
+ console.log('');
34
+ console.log(chalk.yellow(` ${policyTitle}`));
35
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
36
+ console.log('');
37
+ console.log(` ${chalk.dim('Turn:')} ${retainedTurnId}`);
38
+ console.log(` ${chalk.dim('Error:')} ${result.error}`);
39
+ const violations = Array.isArray(result.policy_violations) ? result.policy_violations : [];
40
+ for (const violation of violations) {
41
+ console.log(` ${chalk.dim('Policy:')} ${violation.policy_id} (${violation.rule})`);
42
+ console.log(` ${chalk.dim('Detail:')} ${violation.message}`);
43
+ }
44
+ if (recovery) {
45
+ console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
46
+ console.log(` ${chalk.dim('Owner:')} ${recovery.owner}`);
47
+ console.log(` ${chalk.dim('Action:')} ${recovery.recovery_action}`);
48
+ console.log(` ${chalk.dim('Turn:')} ${recovery.turn_retained ? 'retained' : 'cleared'}`);
49
+ } else {
50
+ console.log(` ${chalk.dim('Action:')} Fix the policy condition, then rerun agentxchain accept-turn`);
51
+ }
52
+ console.log('');
53
+ process.exit(1);
54
+ }
55
+
26
56
  if (result.error_code?.startsWith('hook_') || result.error_code === 'hook_blocked') {
27
57
  const recovery = deriveRecoveryDescriptor(result.state);
28
58
  const activeTurn = result.state?.current_turn;
@@ -547,11 +547,14 @@ function buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitC
547
547
  Object.keys(roles).map((roleId) => [roleId, `.agentxchain/prompts/${roleId}.md`])
548
548
  );
549
549
 
550
+ const policies = cloneJsonCompatible(blueprint?.policies || []);
551
+
550
552
  return {
551
553
  roles,
552
554
  runtimes,
553
555
  routing,
554
556
  gates,
557
+ policies,
555
558
  prompts,
556
559
  workflowKitConfig: effectiveWorkflowKitConfig,
557
560
  };
@@ -627,7 +630,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
627
630
  const template = loadGovernedTemplate(templateId);
628
631
  const { runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(runtimeOptions);
629
632
  const scaffoldConfig = buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitConfig);
630
- const { roles, runtimes, routing, gates, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
633
+ const { roles, runtimes, routing, gates, policies, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
631
634
  const scaffoldWorkflowKitConfig = effectiveWorkflowKitConfig
632
635
  ? normalizeWorkflowKit(effectiveWorkflowKitConfig, Object.keys(routing))
633
636
  : null;
@@ -667,6 +670,9 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
667
670
  max_deadlock_cycles: 2
668
671
  }
669
672
  };
673
+ if (policies && policies.length > 0) {
674
+ config.policies = policies;
675
+ }
670
676
  if (effectiveWorkflowKitConfig) {
671
677
  config.workflow_kit = effectiveWorkflowKitConfig;
672
678
  }
@@ -4,6 +4,8 @@ import {
4
4
  deriveEscalationRecoveryAction,
5
5
  deriveHookTamperRecoveryAction,
6
6
  deriveNeedsHumanRecoveryAction,
7
+ derivePolicyEscalationDetail,
8
+ derivePolicyEscalationRecoveryAction,
7
9
  getActiveTurnCount,
8
10
  } from './governed-state.js';
9
11
 
@@ -53,6 +55,13 @@ function maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetain
53
55
  });
54
56
  }
55
57
 
58
+ if (typedReason === 'policy_escalation') {
59
+ return derivePolicyEscalationRecoveryAction(state, config, {
60
+ turnRetained,
61
+ turnId,
62
+ });
63
+ }
64
+
56
65
  if (typedReason === 'conflict_loop' && isLegacyConflictLoopRecoveryAction(currentAction)) {
57
66
  return deriveConflictLoopRecoveryAction(turnId);
58
67
  }
@@ -158,6 +167,21 @@ export function deriveRecoveryDescriptor(state, config = null) {
158
167
  };
159
168
  }
160
169
 
170
+ if (state.blocked_on.startsWith('policy:')) {
171
+ const policyId = state.blocked_on.slice('policy:'.length).trim() || null;
172
+ return {
173
+ typed_reason: 'policy_escalation',
174
+ owner: 'human',
175
+ recovery_action: derivePolicyEscalationRecoveryAction(state, config, {
176
+ turnRetained,
177
+ turnId: state.blocked_reason?.turn_id ?? null,
178
+ policyId,
179
+ }),
180
+ turn_retained: turnRetained,
181
+ detail: derivePolicyEscalationDetail(state, { policyId }),
182
+ };
183
+ }
184
+
161
185
  return {
162
186
  typed_reason: 'unknown_block',
163
187
  owner: 'human',
@@ -21,6 +21,7 @@ import { randomBytes, createHash } from 'crypto';
21
21
  import { safeWriteJson } from './safe-write.js';
22
22
  import { validateStagedTurnResult } from './turn-result-validator.js';
23
23
  import { evaluatePhaseExit, evaluateRunCompletion } from './gate-evaluator.js';
24
+ import { evaluatePolicies } from './policy-evaluator.js';
24
25
  import {
25
26
  captureBaseline,
26
27
  observeChanges,
@@ -350,6 +351,58 @@ export function deriveDispatchRecoveryAction(state, config, options = {}) {
350
351
  return `Resolve the dispatch issue, then run ${command}`;
351
352
  }
352
353
 
354
+ function normalizePolicyId(policyId) {
355
+ if (typeof policyId !== 'string') {
356
+ return null;
357
+ }
358
+ const trimmed = policyId.trim();
359
+ return trimmed || null;
360
+ }
361
+
362
+ function getPolicyIdFromBlockedState(state) {
363
+ if (typeof state?.blocked_on !== 'string' || !state.blocked_on.startsWith('policy:')) {
364
+ return null;
365
+ }
366
+ return normalizePolicyId(state.blocked_on.slice('policy:'.length));
367
+ }
368
+
369
+ export function derivePolicyEscalationDetail(state, options = {}) {
370
+ if (typeof options.detail === 'string' && options.detail.trim()) {
371
+ return options.detail.trim();
372
+ }
373
+
374
+ if (typeof state?.blocked_reason === 'string' && state.blocked_reason.trim()) {
375
+ return state.blocked_reason.trim();
376
+ }
377
+
378
+ const policyId = normalizePolicyId(options.policyId) || getPolicyIdFromBlockedState(state);
379
+ return policyId ? `Policy "${policyId}" triggered` : (state?.blocked_on || 'Policy escalation');
380
+ }
381
+
382
+ export function derivePolicyEscalationRecoveryAction(state, config, options = {}) {
383
+ const command = deriveBlockedRecoveryCommand(state, config, {
384
+ turnRetained: options.turnRetained,
385
+ turnId: options.turnId,
386
+ });
387
+ const policyId = normalizePolicyId(options.policyId) || getPolicyIdFromBlockedState(state);
388
+ return policyId
389
+ ? `Resolve policy "${policyId}" condition, then run ${command}`
390
+ : `Resolve the policy condition, then run ${command}`;
391
+ }
392
+
393
+ export function readTurnCostUsd(turnResult) {
394
+ if (!turnResult || typeof turnResult !== 'object') {
395
+ return null;
396
+ }
397
+ if (typeof turnResult.cost?.usd === 'number') {
398
+ return turnResult.cost.usd;
399
+ }
400
+ if (typeof turnResult.cost?.total_usd === 'number') {
401
+ return turnResult.cost.total_usd;
402
+ }
403
+ return null;
404
+ }
405
+
353
406
  export function deriveHookTamperRecoveryAction(state, config, options = {}) {
354
407
  const command = deriveBlockedRecoveryCommand(state, config, {
355
408
  turnRetained: options.turnRetained,
@@ -1149,6 +1202,13 @@ export function reconcileRecoveryActionsWithConfig(state, config) {
1149
1202
  turnId,
1150
1203
  });
1151
1204
  }
1205
+ } else if (typedReason === 'policy_escalation') {
1206
+ nextAction = derivePolicyEscalationRecoveryAction(state, config, {
1207
+ turnRetained,
1208
+ turnId,
1209
+ policyId: getPolicyIdFromBlockedState(state),
1210
+ });
1211
+ shouldRefresh = currentAction !== nextAction;
1152
1212
  } else if (typedReason === 'conflict_loop') {
1153
1213
  shouldRefresh = isLegacyConflictLoopRecoveryAction(currentAction);
1154
1214
  if (shouldRefresh) {
@@ -1262,6 +1322,25 @@ function inferBlockedReasonFromState(state) {
1262
1322
  });
1263
1323
  }
1264
1324
 
1325
+ if (state.blocked_on.startsWith('policy:')) {
1326
+ const policyId = getPolicyIdFromBlockedState(state);
1327
+ return buildBlockedReason({
1328
+ category: 'policy_escalation',
1329
+ recovery: {
1330
+ typed_reason: 'policy_escalation',
1331
+ owner: 'human',
1332
+ recovery_action: derivePolicyEscalationRecoveryAction(state, null, {
1333
+ turnRetained,
1334
+ turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
1335
+ policyId,
1336
+ }),
1337
+ turn_retained: turnRetained,
1338
+ detail: derivePolicyEscalationDetail(state, { policyId }),
1339
+ },
1340
+ turnId: activeTurn?.turn_id ?? state.blocked_reason?.turn_id ?? null,
1341
+ });
1342
+ }
1343
+
1265
1344
  return null;
1266
1345
  }
1267
1346
 
@@ -2047,6 +2126,83 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2047
2126
  const artifactType = turnResult.artifact?.type || 'review';
2048
2127
  const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
2049
2128
  const historyEntries = readJsonlEntries(root, HISTORY_PATH);
2129
+
2130
+ // Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
2131
+ const policyResult = evaluatePolicies(config.policies || [], {
2132
+ currentPhase: state.phase,
2133
+ turnRole: turnResult.role,
2134
+ turnStatus: turnResult.status,
2135
+ turnCostUsd: readTurnCostUsd(turnResult),
2136
+ history: historyEntries,
2137
+ });
2138
+
2139
+ if (policyResult.blocks.length > 0) {
2140
+ const blockMessages = policyResult.blocks.map((v) => v.message);
2141
+ return {
2142
+ ok: false,
2143
+ error: `Policy violation: ${blockMessages.join('; ')}`,
2144
+ error_code: 'policy_violation',
2145
+ policy_violations: policyResult.violations,
2146
+ };
2147
+ }
2148
+
2149
+ if (policyResult.escalations.length > 0) {
2150
+ const escalationMessages = policyResult.escalations.map((v) => v.message);
2151
+ const policyId = policyResult.escalations[0].policy_id;
2152
+ const turnRetained = getActiveTurnCount(state) > 0;
2153
+ const recovery = {
2154
+ typed_reason: 'policy_escalation',
2155
+ owner: 'human',
2156
+ recovery_action: derivePolicyEscalationRecoveryAction(state, config, {
2157
+ turnRetained,
2158
+ turnId: currentTurn.turn_id,
2159
+ policyId,
2160
+ }),
2161
+ turn_retained: turnRetained,
2162
+ detail: derivePolicyEscalationDetail(state, {
2163
+ policyId,
2164
+ detail: escalationMessages.join('; '),
2165
+ }),
2166
+ };
2167
+ const blockedState = {
2168
+ ...state,
2169
+ status: 'blocked',
2170
+ blocked_on: `policy:${policyId}`,
2171
+ blocked_reason: buildBlockedReason({
2172
+ category: 'policy_escalation',
2173
+ recovery,
2174
+ turnId: currentTurn.turn_id,
2175
+ blockedAt: now,
2176
+ }),
2177
+ };
2178
+ writeState(root, blockedState);
2179
+ recordRunHistory(root, blockedState, config, 'blocked');
2180
+ emitBlockedNotification(root, config, blockedState, {
2181
+ category: 'policy_escalation',
2182
+ blockedOn: blockedState.blocked_on,
2183
+ recovery,
2184
+ }, currentTurn);
2185
+ appendJsonl(root, LEDGER_PATH, {
2186
+ timestamp: now,
2187
+ decision: 'policy_escalation',
2188
+ turn_id: currentTurn.turn_id,
2189
+ role: turnResult.role,
2190
+ phase: state.phase,
2191
+ violations: policyResult.escalations.map((v) => ({
2192
+ policy_id: v.policy_id,
2193
+ rule: v.rule,
2194
+ message: v.message,
2195
+ })),
2196
+ });
2197
+ return {
2198
+ ok: false,
2199
+ error: `Policy escalation: ${escalationMessages.join('; ')}`,
2200
+ error_code: 'policy_escalation',
2201
+ state: attachLegacyCurrentTurnAlias(blockedState),
2202
+ policy_violations: policyResult.violations,
2203
+ };
2204
+ }
2205
+
2050
2206
  const conflict = detectAcceptanceConflict(currentTurn, observedArtifact, historyEntries);
2051
2207
 
2052
2208
  if (conflict) {
@@ -2527,6 +2683,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
2527
2683
  completionResult,
2528
2684
  hookResults,
2529
2685
  ...(budgetWarning ? { budget_warning: budgetWarning } : {}),
2686
+ ...(policyResult.warnings.length > 0 ? { policy_warnings: policyResult.warnings } : {}),
2530
2687
  };
2531
2688
  }
2532
2689
 
@@ -80,6 +80,7 @@ const VALID_SCAFFOLD_BLUEPRINT_KEYS = new Set([
80
80
  'runtimes',
81
81
  'routing',
82
82
  'gates',
83
+ 'policies',
83
84
  'workflow_kit',
84
85
  ]);
85
86
 
@@ -106,6 +107,7 @@ function validateScaffoldBlueprint(scaffoldBlueprint, errors) {
106
107
  runtimes: scaffoldBlueprint.runtimes,
107
108
  routing: scaffoldBlueprint.routing,
108
109
  gates: scaffoldBlueprint.gates,
110
+ policies: scaffoldBlueprint.policies,
109
111
  workflow_kit: scaffoldBlueprint.workflow_kit,
110
112
  });
111
113
 
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { validateHooksConfig } from './hook-runner.js';
16
16
  import { validateNotificationsConfig } from './notification-runner.js';
17
+ import { validatePolicies, normalizePolicies } from './policy-evaluator.js';
17
18
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
18
19
  import {
19
20
  buildDefaultWorkflowKitArtifactsForPhase,
@@ -512,6 +513,12 @@ export function validateV4Config(data, projectRoot) {
512
513
  errors.push(...wkValidation.errors);
513
514
  }
514
515
 
516
+ // Policies (optional but validated if present)
517
+ if (data.policies !== undefined) {
518
+ const policyValidation = validatePolicies(data.policies);
519
+ errors.push(...policyValidation.errors);
520
+ }
521
+
515
522
  return { ok: errors.length === 0, errors };
516
523
  }
517
524
 
@@ -725,6 +732,7 @@ export function normalizeV3(raw) {
725
732
  hooks: {},
726
733
  notifications: {},
727
734
  budget: null,
735
+ policies: [],
728
736
  workflow_kit: normalizeWorkflowKit(undefined, DEFAULT_PHASES),
729
737
  retention: {
730
738
  talk_strategy: 'append_only',
@@ -789,6 +797,7 @@ export function normalizeV4(raw) {
789
797
  hooks: raw.hooks || {},
790
798
  notifications: raw.notifications || {},
791
799
  budget: raw.budget || null,
800
+ policies: normalizePolicies(raw.policies),
792
801
  workflow_kit: normalizeWorkflowKit(raw.workflow_kit, routingPhases),
793
802
  retention: raw.retention || {
794
803
  talk_strategy: 'append_only',
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Policy evaluator — declarative governance rules for turn acceptance.
3
+ *
4
+ * Policies are config-driven rules that evaluate on every turn acceptance.
5
+ * Gates evaluate at phase boundaries. Hooks run external commands.
6
+ * Policies evaluate built-in governance rules on every accepted turn.
7
+ *
8
+ * Pure functions only — no I/O, no side effects.
9
+ */
10
+
11
+ /**
12
+ * Registry of built-in rule evaluators.
13
+ * Each evaluator: (params, context) → { triggered: boolean, message: string }
14
+ */
15
+ const RULE_EVALUATORS = {
16
+ max_turns_per_phase: (params, ctx) => {
17
+ const count = ctx.history.filter(
18
+ (entry) => entry.phase === ctx.currentPhase,
19
+ ).length;
20
+ if (count >= params.limit) {
21
+ return {
22
+ triggered: true,
23
+ message: `phase "${ctx.currentPhase}" has reached ${count}/${params.limit} accepted turns`,
24
+ };
25
+ }
26
+ return { triggered: false, message: '' };
27
+ },
28
+
29
+ max_total_turns: (params, ctx) => {
30
+ const count = ctx.history.length;
31
+ if (count >= params.limit) {
32
+ return {
33
+ triggered: true,
34
+ message: `run has reached ${count}/${params.limit} total accepted turns`,
35
+ };
36
+ }
37
+ return { triggered: false, message: '' };
38
+ },
39
+
40
+ max_consecutive_same_role: (params, ctx) => {
41
+ const role = ctx.turnRole;
42
+ let consecutive = 0;
43
+ for (let i = ctx.history.length - 1; i >= 0; i--) {
44
+ if (ctx.history[i].role === role) {
45
+ consecutive++;
46
+ } else {
47
+ break;
48
+ }
49
+ }
50
+ // The current turn (not yet in history) adds one more
51
+ consecutive += 1;
52
+ if (consecutive > params.limit) {
53
+ return {
54
+ triggered: true,
55
+ message: `role "${role}" has ${consecutive} consecutive turns (limit: ${params.limit})`,
56
+ };
57
+ }
58
+ return { triggered: false, message: '' };
59
+ },
60
+
61
+ max_cost_per_turn: (params, ctx) => {
62
+ const cost = ctx.turnCostUsd;
63
+ if (cost != null && cost > params.limit_usd) {
64
+ return {
65
+ triggered: true,
66
+ message: `turn cost $${cost.toFixed(2)} exceeds limit $${params.limit_usd.toFixed(2)}`,
67
+ };
68
+ }
69
+ return { triggered: false, message: '' };
70
+ },
71
+
72
+ require_status: (params, ctx) => {
73
+ if (!params.allowed.includes(ctx.turnStatus)) {
74
+ return {
75
+ triggered: true,
76
+ message: `status "${ctx.turnStatus}" is not in allowed set [${params.allowed.join(', ')}]`,
77
+ };
78
+ }
79
+ return { triggered: false, message: '' };
80
+ },
81
+ };
82
+
83
+ export const VALID_POLICY_RULES = Object.keys(RULE_EVALUATORS);
84
+ export const VALID_POLICY_ACTIONS = ['block', 'warn', 'escalate'];
85
+ export const VALID_POLICY_TURN_STATUSES = [
86
+ 'completed',
87
+ 'blocked',
88
+ 'needs_human',
89
+ 'failed',
90
+ ];
91
+ const VALID_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
92
+
93
+ /**
94
+ * Validate a single policy definition at config load time.
95
+ * Returns an array of error strings (empty if valid).
96
+ */
97
+ export function validatePolicy(policy, index) {
98
+ const errors = [];
99
+ const prefix = `policies[${index}]`;
100
+
101
+ if (!policy || typeof policy !== 'object') {
102
+ return [`${prefix}: must be an object`];
103
+ }
104
+
105
+ if (typeof policy.id !== 'string' || !VALID_ID_PATTERN.test(policy.id)) {
106
+ errors.push(`${prefix}: id must be a lowercase kebab-case string`);
107
+ }
108
+
109
+ if (!VALID_POLICY_RULES.includes(policy.rule)) {
110
+ errors.push(
111
+ `${prefix}: unknown rule "${policy.rule}"; valid rules: ${VALID_POLICY_RULES.join(', ')}`,
112
+ );
113
+ }
114
+
115
+ if (!VALID_POLICY_ACTIONS.includes(policy.action)) {
116
+ errors.push(
117
+ `${prefix}: action must be one of ${VALID_POLICY_ACTIONS.join(', ')}`,
118
+ );
119
+ }
120
+
121
+ // Rule-specific param validation
122
+ if (VALID_POLICY_RULES.includes(policy.rule)) {
123
+ const paramErrors = validatePolicyParams(policy.rule, policy.params, prefix);
124
+ errors.push(...paramErrors);
125
+ }
126
+
127
+ // Scope validation (optional)
128
+ if (policy.scope != null) {
129
+ if (typeof policy.scope !== 'object') {
130
+ errors.push(`${prefix}: scope must be an object`);
131
+ } else {
132
+ if (policy.scope.phases != null && !Array.isArray(policy.scope.phases)) {
133
+ errors.push(`${prefix}: scope.phases must be an array`);
134
+ }
135
+ if (policy.scope.roles != null && !Array.isArray(policy.scope.roles)) {
136
+ errors.push(`${prefix}: scope.roles must be an array`);
137
+ }
138
+ }
139
+ }
140
+
141
+ return errors;
142
+ }
143
+
144
+ function validatePolicyParams(rule, params, prefix) {
145
+ const errors = [];
146
+
147
+ switch (rule) {
148
+ case 'max_turns_per_phase':
149
+ case 'max_total_turns':
150
+ if (!params || typeof params.limit !== 'number' || params.limit < 1) {
151
+ errors.push(`${prefix}: params.limit must be a number >= 1`);
152
+ }
153
+ break;
154
+
155
+ case 'max_consecutive_same_role':
156
+ if (!params || typeof params.limit !== 'number' || params.limit < 1) {
157
+ errors.push(`${prefix}: params.limit must be a number >= 1`);
158
+ }
159
+ break;
160
+
161
+ case 'max_cost_per_turn':
162
+ if (
163
+ !params ||
164
+ typeof params.limit_usd !== 'number' ||
165
+ params.limit_usd <= 0
166
+ ) {
167
+ errors.push(`${prefix}: params.limit_usd must be a number > 0`);
168
+ }
169
+ break;
170
+
171
+ case 'require_status':
172
+ if (
173
+ !params ||
174
+ !Array.isArray(params.allowed) ||
175
+ params.allowed.length === 0
176
+ ) {
177
+ errors.push(`${prefix}: params.allowed must be a non-empty array`);
178
+ } else {
179
+ for (const status of params.allowed) {
180
+ if (!VALID_POLICY_TURN_STATUSES.includes(status)) {
181
+ errors.push(
182
+ `${prefix}: params.allowed contains invalid status "${status}"; valid statuses: ${VALID_POLICY_TURN_STATUSES.join(', ')}`,
183
+ );
184
+ }
185
+ }
186
+ }
187
+ break;
188
+ }
189
+
190
+ return errors;
191
+ }
192
+
193
+ /**
194
+ * Validate the full policies array at config load time.
195
+ * Returns { ok: boolean, errors: string[] }.
196
+ */
197
+ export function validatePolicies(policies) {
198
+ if (policies == null) {
199
+ return { ok: true, errors: [] };
200
+ }
201
+
202
+ if (!Array.isArray(policies)) {
203
+ return { ok: false, errors: ['policies must be an array'] };
204
+ }
205
+
206
+ const errors = [];
207
+ const ids = new Set();
208
+
209
+ for (let i = 0; i < policies.length; i++) {
210
+ const policyErrors = validatePolicy(policies[i], i);
211
+ errors.push(...policyErrors);
212
+
213
+ if (policies[i]?.id) {
214
+ if (ids.has(policies[i].id)) {
215
+ errors.push(`policies[${i}]: duplicate id "${policies[i].id}"`);
216
+ }
217
+ ids.add(policies[i].id);
218
+ }
219
+ }
220
+
221
+ return { ok: errors.length === 0, errors };
222
+ }
223
+
224
+ /**
225
+ * Evaluate all policies against the current turn context.
226
+ *
227
+ * @param {Array} policies - normalized policies from config
228
+ * @param {object} context
229
+ * @param {string} context.currentPhase
230
+ * @param {string} context.turnRole - role of the turn being accepted
231
+ * @param {string} context.turnStatus - status from turn result
232
+ * @param {number|null} context.turnCostUsd - cost from turn result
233
+ * @param {Array} context.history - accepted history entries
234
+ * @returns {PolicyEvaluationResult}
235
+ *
236
+ * @typedef {object} PolicyEvaluationResult
237
+ * @property {boolean} ok - true if no block/escalate violations
238
+ * @property {PolicyViolation[]} violations - all triggered policies
239
+ * @property {PolicyViolation[]} blocks - violations with action "block"
240
+ * @property {PolicyViolation[]} escalations - violations with action "escalate"
241
+ * @property {PolicyViolation[]} warnings - violations with action "warn"
242
+ *
243
+ * @typedef {object} PolicyViolation
244
+ * @property {string} policy_id
245
+ * @property {string} rule
246
+ * @property {string} action
247
+ * @property {string} message
248
+ */
249
+ export function evaluatePolicies(policies, context) {
250
+ const result = {
251
+ ok: true,
252
+ violations: [],
253
+ blocks: [],
254
+ escalations: [],
255
+ warnings: [],
256
+ };
257
+
258
+ if (!Array.isArray(policies) || policies.length === 0) {
259
+ return result;
260
+ }
261
+
262
+ for (const policy of policies) {
263
+ // Scope check: skip if out of scope
264
+ if (policy.scope) {
265
+ if (
266
+ Array.isArray(policy.scope.phases) &&
267
+ policy.scope.phases.length > 0 &&
268
+ !policy.scope.phases.includes(context.currentPhase)
269
+ ) {
270
+ continue;
271
+ }
272
+ if (
273
+ Array.isArray(policy.scope.roles) &&
274
+ policy.scope.roles.length > 0 &&
275
+ !policy.scope.roles.includes(context.turnRole)
276
+ ) {
277
+ continue;
278
+ }
279
+ }
280
+
281
+ const evaluator = RULE_EVALUATORS[policy.rule];
282
+ if (!evaluator) {
283
+ continue; // Unknown rules caught at config validation
284
+ }
285
+
286
+ const evaluation = evaluator(policy.params || {}, context);
287
+ if (!evaluation.triggered) {
288
+ continue;
289
+ }
290
+
291
+ const violation = {
292
+ policy_id: policy.id,
293
+ rule: policy.rule,
294
+ action: policy.action,
295
+ message:
296
+ policy.message ||
297
+ `Policy "${policy.id}": ${evaluation.message}`,
298
+ };
299
+
300
+ result.violations.push(violation);
301
+
302
+ switch (policy.action) {
303
+ case 'block':
304
+ result.blocks.push(violation);
305
+ break;
306
+ case 'escalate':
307
+ result.escalations.push(violation);
308
+ break;
309
+ case 'warn':
310
+ result.warnings.push(violation);
311
+ break;
312
+ }
313
+ }
314
+
315
+ result.ok = result.blocks.length === 0 && result.escalations.length === 0;
316
+ return result;
317
+ }
318
+
319
+ /**
320
+ * Normalize policies config: null/undefined → [], validate, return.
321
+ */
322
+ export function normalizePolicies(raw) {
323
+ if (raw == null) {
324
+ return [];
325
+ }
326
+ if (!Array.isArray(raw)) {
327
+ return [];
328
+ }
329
+ return raw;
330
+ }
@@ -142,6 +142,26 @@
142
142
  "requires_human_approval": true
143
143
  }
144
144
  },
145
+ "policies": [
146
+ {
147
+ "id": "phase-turn-cap",
148
+ "rule": "max_turns_per_phase",
149
+ "params": { "limit": 15 },
150
+ "action": "escalate"
151
+ },
152
+ {
153
+ "id": "total-turn-cap",
154
+ "rule": "max_total_turns",
155
+ "params": { "limit": 60 },
156
+ "action": "escalate"
157
+ },
158
+ {
159
+ "id": "no-role-monopoly",
160
+ "rule": "max_consecutive_same_role",
161
+ "params": { "limit": 4 },
162
+ "action": "block"
163
+ }
164
+ ],
145
165
  "workflow_kit": {
146
166
  "phases": {
147
167
  "planning": {