agentxchain 2.45.0 → 2.46.2

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.
@@ -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,8 @@
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';
18
+ import { validateTimeoutsConfig } from './timeout-evaluator.js';
17
19
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
18
20
  import {
19
21
  buildDefaultWorkflowKitArtifactsForPhase,
@@ -512,6 +514,23 @@ export function validateV4Config(data, projectRoot) {
512
514
  errors.push(...wkValidation.errors);
513
515
  }
514
516
 
517
+ // Policies (optional but validated if present)
518
+ if (data.policies !== undefined) {
519
+ const policyValidation = validatePolicies(data.policies);
520
+ errors.push(...policyValidation.errors);
521
+ }
522
+
523
+ // Approval Policy (optional but validated if present)
524
+ if (data.approval_policy !== undefined) {
525
+ errors.push(...validateApprovalPolicy(data.approval_policy, data.routing));
526
+ }
527
+
528
+ // Timeouts (optional but validated if present)
529
+ if (data.timeouts !== undefined) {
530
+ const timeoutValidation = validateTimeoutsConfig(data.timeouts, data.routing);
531
+ errors.push(...timeoutValidation.errors);
532
+ }
533
+
515
534
  return { ok: errors.length === 0, errors };
516
535
  }
517
536
 
@@ -684,6 +703,113 @@ export function validateWorkflowKitConfig(wk, routing, roles) {
684
703
  return { ok: errors.length === 0, errors, warnings };
685
704
  }
686
705
 
706
+ const VALID_APPROVAL_ACTIONS = ['auto_approve', 'require_human'];
707
+
708
+ /**
709
+ * Validate the approval_policy config section.
710
+ * Returns an array of error strings.
711
+ */
712
+ export function validateApprovalPolicy(ap, routing) {
713
+ const errors = [];
714
+ if (ap === null || ap === undefined) return errors;
715
+ if (typeof ap !== 'object' || Array.isArray(ap)) {
716
+ errors.push('approval_policy must be an object');
717
+ return errors;
718
+ }
719
+
720
+ const routingPhases = routing ? Object.keys(routing) : [];
721
+
722
+ // phase_transitions
723
+ if (ap.phase_transitions !== undefined) {
724
+ const pt = ap.phase_transitions;
725
+ if (typeof pt !== 'object' || Array.isArray(pt)) {
726
+ errors.push('approval_policy.phase_transitions must be an object');
727
+ } else {
728
+ if (pt.default !== undefined && !VALID_APPROVAL_ACTIONS.includes(pt.default)) {
729
+ errors.push(`approval_policy.phase_transitions.default must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
730
+ }
731
+ if (pt.rules !== undefined) {
732
+ if (!Array.isArray(pt.rules)) {
733
+ errors.push('approval_policy.phase_transitions.rules must be an array');
734
+ } else {
735
+ for (let i = 0; i < pt.rules.length; i++) {
736
+ const rule = pt.rules[i];
737
+ const prefix = `approval_policy.phase_transitions.rules[${i}]`;
738
+ if (!rule || typeof rule !== 'object') {
739
+ errors.push(`${prefix} must be an object`);
740
+ continue;
741
+ }
742
+ if (!VALID_APPROVAL_ACTIONS.includes(rule.action)) {
743
+ errors.push(`${prefix}.action must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
744
+ }
745
+ if (rule.from_phase !== undefined) {
746
+ if (typeof rule.from_phase !== 'string') {
747
+ errors.push(`${prefix}.from_phase must be a string`);
748
+ } else if (routingPhases.length > 0 && !routingPhases.includes(rule.from_phase)) {
749
+ errors.push(`${prefix}.from_phase "${rule.from_phase}" does not exist in routing`);
750
+ }
751
+ }
752
+ if (rule.to_phase !== undefined) {
753
+ if (typeof rule.to_phase !== 'string') {
754
+ errors.push(`${prefix}.to_phase must be a string`);
755
+ } else if (routingPhases.length > 0 && !routingPhases.includes(rule.to_phase)) {
756
+ errors.push(`${prefix}.to_phase "${rule.to_phase}" does not exist in routing`);
757
+ }
758
+ }
759
+ if (rule.when !== undefined) {
760
+ errors.push(...validateApprovalWhen(rule.when, prefix));
761
+ }
762
+ }
763
+ }
764
+ }
765
+ }
766
+ }
767
+
768
+ // run_completion
769
+ if (ap.run_completion !== undefined) {
770
+ const rc = ap.run_completion;
771
+ if (typeof rc !== 'object' || Array.isArray(rc)) {
772
+ errors.push('approval_policy.run_completion must be an object');
773
+ } else {
774
+ if (rc.action !== undefined && !VALID_APPROVAL_ACTIONS.includes(rc.action)) {
775
+ errors.push(`approval_policy.run_completion.action must be one of: ${VALID_APPROVAL_ACTIONS.join(', ')}`);
776
+ }
777
+ if (rc.when !== undefined) {
778
+ errors.push(...validateApprovalWhen(rc.when, 'approval_policy.run_completion'));
779
+ }
780
+ }
781
+ }
782
+
783
+ return errors;
784
+ }
785
+
786
+ function validateApprovalWhen(when, prefix) {
787
+ const errors = [];
788
+ if (typeof when !== 'object' || Array.isArray(when) || when === null) {
789
+ errors.push(`${prefix}.when must be an object`);
790
+ return errors;
791
+ }
792
+ if (when.gate_passed !== undefined && typeof when.gate_passed !== 'boolean') {
793
+ errors.push(`${prefix}.when.gate_passed must be a boolean`);
794
+ }
795
+ if (when.roles_participated !== undefined) {
796
+ if (!Array.isArray(when.roles_participated)) {
797
+ errors.push(`${prefix}.when.roles_participated must be an array of role IDs`);
798
+ } else {
799
+ for (const r of when.roles_participated) {
800
+ if (typeof r !== 'string' || !r.trim()) {
801
+ errors.push(`${prefix}.when.roles_participated entries must be non-empty strings`);
802
+ break;
803
+ }
804
+ }
805
+ }
806
+ }
807
+ if (when.all_phases_visited !== undefined && typeof when.all_phases_visited !== 'boolean') {
808
+ errors.push(`${prefix}.when.all_phases_visited must be a boolean`);
809
+ }
810
+ return errors;
811
+ }
812
+
687
813
  /**
688
814
  * Normalize a legacy v3 config into the internal shape.
689
815
  * Does NOT modify the original file — this is a read-time transformation.
@@ -725,6 +851,9 @@ export function normalizeV3(raw) {
725
851
  hooks: {},
726
852
  notifications: {},
727
853
  budget: null,
854
+ policies: [],
855
+ approval_policy: null,
856
+ timeouts: null,
728
857
  workflow_kit: normalizeWorkflowKit(undefined, DEFAULT_PHASES),
729
858
  retention: {
730
859
  talk_strategy: 'append_only',
@@ -789,6 +918,9 @@ export function normalizeV4(raw) {
789
918
  hooks: raw.hooks || {},
790
919
  notifications: raw.notifications || {},
791
920
  budget: raw.budget || null,
921
+ policies: normalizePolicies(raw.policies),
922
+ approval_policy: raw.approval_policy || null,
923
+ timeouts: raw.timeouts || null,
792
924
  workflow_kit: normalizeWorkflowKit(raw.workflow_kit, routingPhases),
793
925
  retention: raw.retention || {
794
926
  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
+ }
@@ -139,6 +139,7 @@ function inflateState(rawState = {}, config) {
139
139
  blocked_on: rawState.blocked_on ?? null,
140
140
  blocked_reason: rawState.blocked_reason ?? null,
141
141
  escalation: rawState.escalation ?? null,
142
+ last_gate_failure: rawState.last_gate_failure ?? null,
142
143
  accepted_sequence: rawState.accepted_sequence ?? 0,
143
144
  turn_sequence: rawState.turn_sequence ?? 0,
144
145
  budget_reservations: rawState.budget_reservations ?? {},
@@ -121,6 +121,7 @@ export function observeChanges(root, baseline) {
121
121
  // Non-git project — no observation possible
122
122
  return {
123
123
  files_changed: [],
124
+ file_markers: {},
124
125
  head_ref: null,
125
126
  diff_summary: null,
126
127
  observation_available: false,
@@ -162,6 +163,7 @@ export function observeChanges(root, baseline) {
162
163
 
163
164
  return {
164
165
  files_changed: actorFiles.sort(),
166
+ file_markers: buildFileMarkers(root, actorFiles),
165
167
  head_ref: currentHead,
166
168
  diff_summary: diffSummary,
167
169
  observation_available: true,
@@ -169,6 +171,119 @@ export function observeChanges(root, baseline) {
169
171
  };
170
172
  }
171
173
 
174
+ export function attributeObservedChangesToTurn(observation, currentTurn, historyEntries = []) {
175
+ const observedFiles = Array.isArray(observation?.files_changed) ? observation.files_changed : [];
176
+ if (observedFiles.length === 0) {
177
+ return observation;
178
+ }
179
+
180
+ const concurrentIds = new Set(
181
+ Array.isArray(currentTurn?.concurrent_with) ? currentTurn.concurrent_with : [],
182
+ );
183
+ if (concurrentIds.size === 0) {
184
+ return observation;
185
+ }
186
+
187
+ const assignedSequence = Number.isInteger(currentTurn?.assigned_sequence)
188
+ ? currentTurn.assigned_sequence
189
+ : 0;
190
+ const acceptedConcurrentSiblings = (Array.isArray(historyEntries) ? historyEntries : [])
191
+ .filter((entry) => (
192
+ Number.isInteger(entry?.accepted_sequence)
193
+ && entry.accepted_sequence > assignedSequence
194
+ && concurrentIds.has(entry.turn_id)
195
+ ))
196
+ .sort((left, right) => left.accepted_sequence - right.accepted_sequence);
197
+
198
+ if (acceptedConcurrentSiblings.length === 0) {
199
+ return observation;
200
+ }
201
+
202
+ const siblingMarkersByFile = new Map();
203
+ for (const entry of acceptedConcurrentSiblings) {
204
+ const siblingFiles = Array.isArray(entry?.observed_artifact?.files_changed)
205
+ ? entry.observed_artifact.files_changed
206
+ : Array.isArray(entry?.files_changed)
207
+ ? entry.files_changed
208
+ : [];
209
+ const siblingMarkers = entry?.observed_artifact?.file_markers;
210
+ if (!siblingMarkers || typeof siblingMarkers !== 'object' || Array.isArray(siblingMarkers)) {
211
+ continue;
212
+ }
213
+ for (const filePath of siblingFiles) {
214
+ if (typeof siblingMarkers[filePath] === 'string' && siblingMarkers[filePath].length > 0) {
215
+ siblingMarkersByFile.set(filePath, siblingMarkers[filePath]);
216
+ }
217
+ }
218
+ }
219
+
220
+ if (siblingMarkersByFile.size === 0) {
221
+ return observation;
222
+ }
223
+
224
+ const nextFiles = [];
225
+ const nextMarkers = {};
226
+ const attributedToConcurrentSiblings = [];
227
+ const observationMarkers = observation?.file_markers && typeof observation.file_markers === 'object'
228
+ ? observation.file_markers
229
+ : {};
230
+
231
+ for (const filePath of observedFiles) {
232
+ const currentMarker = observationMarkers[filePath];
233
+ const siblingMarker = siblingMarkersByFile.get(filePath);
234
+ if (typeof siblingMarker === 'string' && siblingMarker === currentMarker) {
235
+ attributedToConcurrentSiblings.push(filePath);
236
+ continue;
237
+ }
238
+ nextFiles.push(filePath);
239
+ if (typeof currentMarker === 'string') {
240
+ nextMarkers[filePath] = currentMarker;
241
+ }
242
+ }
243
+
244
+ if (attributedToConcurrentSiblings.length === 0) {
245
+ return observation;
246
+ }
247
+
248
+ return {
249
+ ...observation,
250
+ files_changed: nextFiles,
251
+ file_markers: nextMarkers,
252
+ attributed_to_concurrent_siblings: attributedToConcurrentSiblings,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Build the file set used for acceptance-time conflict detection.
258
+ *
259
+ * Parallel attribution removes unchanged sibling files from the current turn's
260
+ * observed set so declared-vs-observed checks stay truthful. Conflict detection
261
+ * is stricter: if the agent declared a file that still appears in the raw
262
+ * baseline-to-now workspace union, that overlap must remain conflict-eligible
263
+ * even if the current file contents match a sibling's accepted marker.
264
+ *
265
+ * @param {object} rawObservation — unfiltered observeChanges() result
266
+ * @param {object} attributedObservation — result after attributeObservedChangesToTurn()
267
+ * @param {string[]} declaredFiles — turnResult.files_changed
268
+ * @returns {string[]}
269
+ */
270
+ export function buildConflictCandidateFiles(rawObservation, attributedObservation, declaredFiles = []) {
271
+ const conflictFiles = new Set(
272
+ Array.isArray(attributedObservation?.files_changed) ? attributedObservation.files_changed : [],
273
+ );
274
+ const rawObserved = new Set(
275
+ Array.isArray(rawObservation?.files_changed) ? rawObservation.files_changed : [],
276
+ );
277
+
278
+ for (const filePath of declaredFiles || []) {
279
+ if (rawObserved.has(filePath)) {
280
+ conflictFiles.add(filePath);
281
+ }
282
+ }
283
+
284
+ return [...conflictFiles].sort();
285
+ }
286
+
172
287
  /**
173
288
  * Classify observed file changes into added, modified, and deleted.
174
289
  *
@@ -284,13 +399,21 @@ function getFilteredChanges(root, baseRef, filter) {
284
399
  * @returns {object}
285
400
  */
286
401
  export function buildObservedArtifact(observation, baseline) {
287
- return {
402
+ const artifact = {
288
403
  derived_by: 'orchestrator',
289
404
  baseline_ref: baseline?.head_ref ? `git:${baseline.head_ref}` : null,
290
405
  accepted_ref: observation.head_ref ? `git:${observation.head_ref}` : 'workspace:dirty',
291
406
  files_changed: observation.files_changed,
407
+ file_markers: observation.file_markers || {},
292
408
  diff_summary: observation.diff_summary,
293
409
  };
410
+ // Preserve parallel attribution so operators can see which files were
411
+ // attributed to concurrent siblings and excluded from this turn.
412
+ const attributed = observation.attributed_to_concurrent_siblings;
413
+ if (Array.isArray(attributed) && attributed.length > 0) {
414
+ artifact.attributed_to_concurrent_siblings = attributed;
415
+ }
416
+ return artifact;
294
417
  }
295
418
 
296
419
  // ── Verification Normalization ──────────────────────────────────────────────
@@ -572,6 +695,14 @@ function getWorkspaceFileMarker(root, filePath) {
572
695
  }
573
696
  }
574
697
 
698
+ function buildFileMarkers(root, filePaths) {
699
+ const markers = {};
700
+ for (const filePath of filePaths || []) {
701
+ markers[filePath] = getWorkspaceFileMarker(root, filePath);
702
+ }
703
+ return markers;
704
+ }
705
+
575
706
  function getUntrackedFiles(root) {
576
707
  try {
577
708
  const result = execSync('git ls-files --others --exclude-standard', {