agentxchain 2.17.0 → 2.19.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.
@@ -32,7 +32,7 @@ import {
32
32
  checkCleanBaseline,
33
33
  } from './repo-observer.js';
34
34
  import { getMaxConcurrentTurns } from './normalized-config.js';
35
- import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir } from './turn-paths.js';
35
+ import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
36
36
  import { runHooks } from './hook-runner.js';
37
37
  import { emitNotifications } from './notification-runner.js';
38
38
 
@@ -77,6 +77,84 @@ function emitPendingLifecycleNotification(root, config, state, eventType, payloa
77
77
  emitNotifications(root, config, state, eventType, payload, turn);
78
78
  }
79
79
 
80
+ function normalizeDerivedReviewPath(turnResult) {
81
+ const requestedPath = typeof turnResult?.artifact?.ref === 'string' ? turnResult.artifact.ref.trim() : '';
82
+ if (requestedPath.startsWith('.agentxchain/reviews/')) {
83
+ return requestedPath;
84
+ }
85
+ return getReviewArtifactPath(turnResult.turn_id, turnResult.role);
86
+ }
87
+
88
+ function renderDerivedReviewArtifact(turnResult, state) {
89
+ const lines = [];
90
+ lines.push(`# Review Artifact — ${turnResult.role}`);
91
+ lines.push('');
92
+ lines.push(`- **Run:** ${turnResult.run_id}`);
93
+ lines.push(`- **Turn:** ${turnResult.turn_id}`);
94
+ lines.push(`- **Phase:** ${state.phase}`);
95
+ lines.push(`- **Status:** ${turnResult.status}`);
96
+ lines.push(`- **Proposed next role:** ${turnResult.proposed_next_role || 'human'}`);
97
+ lines.push('');
98
+ lines.push('## Summary');
99
+ lines.push('');
100
+ lines.push(turnResult.summary || 'No summary provided.');
101
+ lines.push('');
102
+ lines.push('## Decisions');
103
+ lines.push('');
104
+ if (Array.isArray(turnResult.decisions) && turnResult.decisions.length > 0) {
105
+ for (const decision of turnResult.decisions) {
106
+ lines.push(`- **${decision.id}** (${decision.category}): ${decision.statement}`);
107
+ if (decision.rationale) {
108
+ lines.push(` - Rationale: ${decision.rationale}`);
109
+ }
110
+ }
111
+ } else {
112
+ lines.push('- None.');
113
+ }
114
+ lines.push('');
115
+ lines.push('## Objections');
116
+ lines.push('');
117
+ if (Array.isArray(turnResult.objections) && turnResult.objections.length > 0) {
118
+ for (const objection of turnResult.objections) {
119
+ lines.push(`- **${objection.id}** (${objection.severity}): ${objection.statement}`);
120
+ if (objection.status) {
121
+ lines.push(` - Status: ${objection.status}`);
122
+ }
123
+ }
124
+ } else {
125
+ lines.push('- None.');
126
+ }
127
+ lines.push('');
128
+ lines.push('## Verification');
129
+ lines.push('');
130
+ lines.push(`- **Status:** ${turnResult.verification?.status || 'skipped'}`);
131
+ if (turnResult.verification?.evidence_summary) {
132
+ lines.push(`- **Summary:** ${turnResult.verification.evidence_summary}`);
133
+ }
134
+ if (turnResult.needs_human_reason) {
135
+ lines.push(`- **Needs human reason:** ${turnResult.needs_human_reason}`);
136
+ }
137
+ lines.push('');
138
+ return lines.join('\n') + '\n';
139
+ }
140
+
141
+ function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline = null) {
142
+ if (turnResult?.artifact?.type !== 'review' || runtimeType !== 'api_proxy') {
143
+ return null;
144
+ }
145
+
146
+ const reviewPath = normalizeDerivedReviewPath(turnResult);
147
+ const absReviewPath = join(root, reviewPath);
148
+ mkdirSync(dirname(absReviewPath), { recursive: true });
149
+
150
+ if (!existsSync(absReviewPath)) {
151
+ writeFileSync(absReviewPath, renderDerivedReviewArtifact(turnResult, state));
152
+ }
153
+
154
+ turnResult.artifact = { ...(turnResult.artifact || {}), ref: reviewPath };
155
+ return reviewPath;
156
+ }
157
+
80
158
  function normalizeActiveTurns(activeTurns) {
81
159
  if (!activeTurns || typeof activeTurns !== 'object' || Array.isArray(activeTurns)) {
82
160
  return {};
@@ -1503,11 +1581,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
1503
1581
  const runtimeId = turnResult.runtime_id;
1504
1582
  const runtime = config.runtimes?.[runtimeId];
1505
1583
  const runtimeType = runtime?.type || 'manual';
1584
+ materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline);
1506
1585
  const writeAuthority = role?.write_authority || 'review_only';
1507
1586
  const diffComparison = compareDeclaredVsObserved(
1508
1587
  turnResult.files_changed || [],
1509
1588
  observation.files_changed,
1510
1589
  writeAuthority,
1590
+ { observation_available: observation.observation_available },
1511
1591
  );
1512
1592
  if (diffComparison.errors.length > 0) {
1513
1593
  return {
@@ -39,6 +39,15 @@ const ORCHESTRATOR_STATE_FILES = [
39
39
  '.agentxchain/lock.json',
40
40
  '.agentxchain/hook-audit.jsonl',
41
41
  '.agentxchain/hook-annotations.jsonl',
42
+ 'TALK.md',
43
+ ];
44
+
45
+ // Evidence paths may legitimately remain dirty across turns without blocking the
46
+ // next code-writing assignment. They still remain actor-observable so review
47
+ // accountability is preserved during acceptance.
48
+ const BASELINE_EXEMPT_PATH_PREFIXES = [
49
+ '.agentxchain/reviews/',
50
+ '.agentxchain/reports/',
42
51
  ];
43
52
 
44
53
  /**
@@ -50,6 +59,11 @@ export function isOperationalPath(filePath) {
50
59
  || ORCHESTRATOR_STATE_FILES.includes(filePath);
51
60
  }
52
61
 
62
+ function isBaselineExemptPath(filePath) {
63
+ return isOperationalPath(filePath)
64
+ || BASELINE_EXEMPT_PATH_PREFIXES.some(prefix => filePath.startsWith(prefix));
65
+ }
66
+
53
67
  // ── Baseline Capture ────────────────────────────────────────────────────────
54
68
 
55
69
  /**
@@ -57,6 +71,10 @@ export function isOperationalPath(filePath) {
57
71
  * This gives acceptance a stable "before" view.
58
72
  *
59
73
  * @param {string} root — project root directory
74
+ * clean is actor-facing baseline cleanliness, not literal `git status` emptiness.
75
+ * dirty_snapshot may still contain baseline-exempt evidence paths so later
76
+ * observation can filter unchanged pre-existing dirt.
77
+ *
60
78
  * @returns {{ kind: string, head_ref: string|null, clean: boolean, captured_at: string }}
61
79
  */
62
80
  export function captureBaseline(root) {
@@ -73,14 +91,15 @@ export function captureBaseline(root) {
73
91
  }
74
92
 
75
93
  const headRef = getHeadRef(root);
76
- const clean = isWorkingTreeClean(root);
94
+ const dirtyFiles = getWorkingTreeChanges(root);
95
+ const clean = dirtyFiles.filter((filePath) => !isBaselineExemptPath(filePath)).length === 0;
77
96
 
78
97
  return {
79
98
  kind: 'git_worktree',
80
99
  head_ref: headRef,
81
100
  clean,
82
101
  captured_at: now,
83
- dirty_snapshot: clean ? {} : captureDirtyWorkspaceSnapshot(root),
102
+ dirty_snapshot: dirtyFiles.length === 0 ? {} : captureDirtyWorkspaceSnapshot(root),
84
103
  };
85
104
  }
86
105
 
@@ -92,12 +111,18 @@ export function captureBaseline(root) {
92
111
  *
93
112
  * @param {string} root — project root directory
94
113
  * @param {object} baseline — the baseline captured at assignment time
95
- * @returns {{ files_changed: string[], head_ref: string|null, diff_summary: string|null }}
114
+ * @returns {{ files_changed: string[], head_ref: string|null, diff_summary: string|null, observation_available: boolean, kind: string }}
96
115
  */
97
116
  export function observeChanges(root, baseline) {
98
117
  if (!isGitRepo(root) || (baseline && baseline.kind === 'no_git')) {
99
118
  // Non-git project — no observation possible
100
- return { files_changed: [], head_ref: null, diff_summary: null };
119
+ return {
120
+ files_changed: [],
121
+ head_ref: null,
122
+ diff_summary: null,
123
+ observation_available: false,
124
+ kind: 'no_git',
125
+ };
101
126
  }
102
127
 
103
128
  const currentHead = getHeadRef(root);
@@ -111,7 +136,6 @@ export function observeChanges(root, baseline) {
111
136
  if (baseline?.head_ref && baseline.head_ref === currentHead) {
112
137
  // Same commit — changes are in working tree / staging area
113
138
  changedFiles = getWorkingTreeChanges(root);
114
- changedFiles = filterBaselineDirtyFiles(root, changedFiles, baseline);
115
139
  diffSummary = buildObservedDiffSummary(getWorkingTreeDiffSummary(root), untrackedFiles);
116
140
  } else if (baseline?.head_ref) {
117
141
  // New commits exist — get files changed since baseline ref
@@ -128,6 +152,8 @@ export function observeChanges(root, baseline) {
128
152
  diffSummary = buildObservedDiffSummary(getWorkingTreeDiffSummary(root), untrackedFiles);
129
153
  }
130
154
 
155
+ changedFiles = filterBaselineDirtyFiles(root, changedFiles, baseline);
156
+
131
157
  // Filter out orchestrator-owned operational paths (Session #19 freeze)
132
158
  const actorFiles = changedFiles.filter(f => !isOperationalPath(f));
133
159
 
@@ -135,6 +161,8 @@ export function observeChanges(root, baseline) {
135
161
  files_changed: actorFiles.sort(),
136
162
  head_ref: currentHead,
137
163
  diff_summary: diffSummary,
164
+ observation_available: true,
165
+ kind: 'git_observed',
138
166
  };
139
167
  }
140
168
 
@@ -322,11 +350,13 @@ export function normalizeVerification(verification, runtimeType) {
322
350
  * @param {string[]} declared — files_changed from the turn result
323
351
  * @param {string[]} observed — files_changed from observeChanges()
324
352
  * @param {string} writeAuthority — 'authoritative' | 'proposed' | 'review_only'
353
+ * @param {{ observation_available?: boolean }} [options]
325
354
  * @returns {{ errors: string[], warnings: string[] }}
326
355
  */
327
- export function compareDeclaredVsObserved(declared, observed, writeAuthority) {
356
+ export function compareDeclaredVsObserved(declared, observed, writeAuthority, options = {}) {
328
357
  const errors = [];
329
358
  const warnings = [];
359
+ const observationAvailable = options.observation_available !== false;
330
360
 
331
361
  const declaredSet = new Set(declared || []);
332
362
  const observedSet = new Set(observed || []);
@@ -336,6 +366,11 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority) {
336
366
  // Files the agent declared but didn't actually change
337
367
  const phantom = [...declaredSet].filter(f => !observedSet.has(f));
338
368
 
369
+ if (!observationAvailable) {
370
+ warnings.push('Artifact observation unavailable; diff-based declared-vs-observed checks were skipped.');
371
+ return { errors, warnings };
372
+ }
373
+
339
374
  if (writeAuthority === 'authoritative') {
340
375
  if (undeclared.length > 0) {
341
376
  errors.push(`Undeclared file changes detected (observed but not in files_changed): ${undeclared.join(', ')}`);
@@ -351,6 +386,9 @@ export function compareDeclaredVsObserved(declared, observed, writeAuthority) {
351
386
  if (productFileChanges.length > 0) {
352
387
  errors.push(`review_only role modified product files (observed in actual diff): ${productFileChanges.join(', ')}`);
353
388
  }
389
+ if (phantom.length > 0) {
390
+ errors.push(`review_only role declared file changes that were not observed in the actual diff: ${phantom.join(', ')}`);
391
+ }
354
392
  }
355
393
 
356
394
  return { errors, warnings };
@@ -407,10 +445,10 @@ export function checkCleanBaseline(root, writeAuthority) {
407
445
  return { clean: true };
408
446
  }
409
447
 
410
- // Check if all dirty files are orchestrator-owned operational paths.
411
- // If only operational paths are dirty, the baseline is still clean for actor purposes.
448
+ // Check if all dirty files are baseline-exempt evidence or orchestrator-owned state.
449
+ // If only those paths are dirty, the baseline is still clean for actor purposes.
412
450
  const dirtyFiles = getWorkingTreeChanges(root);
413
- const actorDirtyFiles = dirtyFiles.filter(f => !isOperationalPath(f));
451
+ const actorDirtyFiles = dirtyFiles.filter(f => !isBaselineExemptPath(f));
414
452
 
415
453
  if (actorDirtyFiles.length === 0) return { clean: true };
416
454
 
@@ -2,6 +2,7 @@ const DISPATCH_ROOT = '.agentxchain/dispatch';
2
2
  const DISPATCH_INDEX_PATH = `${DISPATCH_ROOT}/index.json`;
3
3
  const DISPATCH_TURNS_DIR = `${DISPATCH_ROOT}/turns`;
4
4
  const STAGING_ROOT = '.agentxchain/staging';
5
+ const REVIEW_ROOT = '.agentxchain/reviews';
5
6
 
6
7
  export function getDispatchTurnDir(turnId) {
7
8
  return `${DISPATCH_TURNS_DIR}/${turnId}`;
@@ -59,9 +60,14 @@ export function getTurnRetryTracePath(turnId) {
59
60
  return `${getTurnStagingDir(turnId)}/retry-trace.json`;
60
61
  }
61
62
 
63
+ export function getReviewArtifactPath(turnId, roleId = 'review') {
64
+ return `${REVIEW_ROOT}/${turnId}-${roleId}-review.md`;
65
+ }
66
+
62
67
  export {
63
68
  DISPATCH_ROOT,
64
69
  DISPATCH_INDEX_PATH,
65
70
  DISPATCH_TURNS_DIR,
71
+ REVIEW_ROOT,
66
72
  STAGING_ROOT,
67
73
  };
@@ -69,6 +69,25 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
69
69
  return result('schema', 'schema_error', [`Invalid JSON in ${stagingRel}: ${err.message}`]);
70
70
  }
71
71
 
72
+ // ── Pre-validation normalization ───────────────────────────────────────
73
+ // Build context for role/phase-aware normalization rules
74
+ const normContext = {};
75
+ if (state) {
76
+ normContext.phase = state.phase;
77
+ // Support both active_turns (v2+) and legacy current_turn formats
78
+ const activeTurn = getActiveTurn(state) || state.current_turn;
79
+ if (activeTurn) {
80
+ const roleKey = activeTurn.assigned_role || activeTurn.role;
81
+ const roleConfig = config?.roles?.[roleKey];
82
+ if (roleConfig) {
83
+ normContext.writeAuthority = roleConfig.write_authority;
84
+ }
85
+ }
86
+ }
87
+ const { normalized, corrections } = normalizeTurnResult(turnResult, config, normContext);
88
+ turnResult = normalized;
89
+ const normWarnings = corrections.map((c) => `[normalized] ${c}`);
90
+
72
91
  // ── Stage A: Schema Validation ─────────────────────────────────────────
73
92
  const schemaErrors = validateSchema(turnResult);
74
93
  if (schemaErrors.length > 0) {
@@ -101,6 +120,7 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
101
120
 
102
121
  // ── All stages passed ──────────────────────────────────────────────────
103
122
  const allWarnings = [
123
+ ...normWarnings,
104
124
  ...artifactResult.warnings,
105
125
  ...verificationResult.warnings,
106
126
  ...protocolResult.warnings,
@@ -417,7 +437,7 @@ function validateVerification(tr) {
417
437
  const failedCommands = v.machine_evidence.filter(e => typeof e.exit_code === 'number' && e.exit_code !== 0);
418
438
  if (failedCommands.length > 0) {
419
439
  errors.push(
420
- `verification.status is "pass" but ${failedCommands.length} command(s) have non-zero exit codes.`
440
+ `verification.status is "pass" but ${failedCommands.length} command(s) have non-zero exit codes. Wrap expected-failure checks in a verifier that exits 0 only when the failure occurs as expected, or do not report "pass".`
421
441
  );
422
442
  }
423
443
  }
@@ -480,6 +500,134 @@ function validateProtocol(tr, state, config) {
480
500
  return { errors, warnings };
481
501
  }
482
502
 
503
+ // ── Normalization ───────────────────────────────────────────────────────────
504
+
505
+ /**
506
+ * Best-effort normalization of predictable model-output drift patterns.
507
+ * Returns a shallow-cloned turn result with corrections applied plus an
508
+ * array of human-readable correction strings for logging.
509
+ *
510
+ * This runs BEFORE schema validation. It does not bypass validation —
511
+ * it only fixes patterns that are unambiguously recoverable.
512
+ */
513
+ export function normalizeTurnResult(tr, config, context = {}) {
514
+ const corrections = [];
515
+ if (tr === null || typeof tr !== 'object' || Array.isArray(tr)) {
516
+ return { normalized: tr, corrections };
517
+ }
518
+
519
+ const normalized = { ...tr };
520
+
521
+ // ── Rule 0: infer missing status only when intent is unambiguous ──────
522
+ if (!('status' in normalized)) {
523
+ const hasNeedsHumanReason = typeof normalized.needs_human_reason === 'string'
524
+ && normalized.needs_human_reason.trim().length > 0;
525
+ const hasPhaseTransitionRequest = typeof normalized.phase_transition_request === 'string'
526
+ && normalized.phase_transition_request.trim().length > 0;
527
+ const hasRunCompletionRequest = normalized.run_completion_request === true;
528
+
529
+ if (hasNeedsHumanReason) {
530
+ normalized.status = 'needs_human';
531
+ corrections.push('status: inferred "needs_human" from needs_human_reason');
532
+ } else if (hasPhaseTransitionRequest) {
533
+ normalized.status = 'completed';
534
+ corrections.push(`status: inferred "completed" from phase_transition_request "${normalized.phase_transition_request}"`);
535
+ } else if (hasRunCompletionRequest) {
536
+ normalized.status = 'completed';
537
+ corrections.push('status: inferred "completed" from run_completion_request: true');
538
+ }
539
+ }
540
+
541
+ // ── Rule 1: artifacts_created object coercion ─────────────────────────
542
+ if (Array.isArray(normalized.artifacts_created)) {
543
+ const coerced = [];
544
+ for (let i = 0; i < normalized.artifacts_created.length; i++) {
545
+ const item = normalized.artifacts_created[i];
546
+ if (typeof item === 'string') {
547
+ coerced.push(item);
548
+ } else if (item !== null && typeof item === 'object') {
549
+ const str = typeof item.path === 'string' ? item.path
550
+ : typeof item.name === 'string' ? item.name
551
+ : JSON.stringify(item);
552
+ corrections.push(`artifacts_created[${i}]: coerced object to string "${str}"`);
553
+ coerced.push(str);
554
+ } else {
555
+ coerced.push(item); // let validator catch non-string/non-object
556
+ }
557
+ }
558
+ normalized.artifacts_created = coerced;
559
+ }
560
+
561
+ // ── Rule 2: exit-gate-as-phase auto-correction ────────────────────────
562
+ const routing = config?.routing;
563
+ const gates = config?.gates;
564
+ if (
565
+ typeof normalized.phase_transition_request === 'string' &&
566
+ routing && gates &&
567
+ !normalized.run_completion_request // don't touch if both are set — let mutual-exclusivity validator catch it
568
+ ) {
569
+ const requested = normalized.phase_transition_request;
570
+ const isValidPhase = requested in routing;
571
+ const isGateName = requested in gates;
572
+
573
+ if (!isValidPhase && isGateName) {
574
+ // Find which phase owns this gate
575
+ const phaseNames = Object.keys(routing);
576
+ const ownerPhaseIndex = phaseNames.findIndex(
577
+ (p) => routing[p].exit_gate === requested
578
+ );
579
+
580
+ if (ownerPhaseIndex >= 0) {
581
+ const nextPhaseIndex = ownerPhaseIndex + 1;
582
+ if (nextPhaseIndex < phaseNames.length) {
583
+ // Non-terminal phase: correct to the next phase name
584
+ const nextPhase = phaseNames[nextPhaseIndex];
585
+ corrections.push(
586
+ `phase_transition_request: corrected gate name "${requested}" to phase "${nextPhase}"`
587
+ );
588
+ normalized.phase_transition_request = nextPhase;
589
+ } else {
590
+ // Terminal phase: the agent meant run_completion_request
591
+ corrections.push(
592
+ `phase_transition_request: corrected terminal gate name "${requested}" to run_completion_request: true`
593
+ );
594
+ normalized.phase_transition_request = null;
595
+ normalized.run_completion_request = true;
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ // ── Rule 3: review_only terminal needs_human → run_completion_request ──
602
+ if (
603
+ context.writeAuthority === 'review_only' &&
604
+ context.phase &&
605
+ routing &&
606
+ normalized.status === 'needs_human' &&
607
+ normalized.run_completion_request !== false
608
+ ) {
609
+ const phaseNames = Object.keys(routing);
610
+ const isTerminal = phaseNames.indexOf(context.phase) === phaseNames.length - 1;
611
+ if (isTerminal && typeof normalized.needs_human_reason === 'string') {
612
+ const reason = normalized.needs_human_reason.toLowerCase();
613
+ const affirmativeSignals = /\b(approv|ship|release|sign.?off|no.?block|ready|pass|good|accept|green.?light)\b/i;
614
+ const blockerSignals = /\b(critical|security|fail|block|cannot|must.?fix|regression|vulnerab|reject|unsafe|broken)\b/i;
615
+ const isAffirmative = affirmativeSignals.test(reason);
616
+ const isBlocker = blockerSignals.test(reason);
617
+ if (isAffirmative && !isBlocker) {
618
+ corrections.push(
619
+ `status: corrected review_only terminal "needs_human" to run_completion_request — reason indicated ship readiness ("${normalized.needs_human_reason.slice(0, 80)}"), not a genuine blocker`
620
+ );
621
+ normalized.status = 'completed';
622
+ normalized.run_completion_request = true;
623
+ delete normalized.needs_human_reason;
624
+ }
625
+ }
626
+ }
627
+
628
+ return { normalized, corrections };
629
+ }
630
+
483
631
  // ── Helpers ──────────────────────────────────────────────────────────────────
484
632
 
485
633
  function result(stage, errorClass, errors, warnings = []) {