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.
- package/README.md +12 -1
- package/bin/agentxchain.js +8 -0
- package/package.json +1 -1
- package/scripts/release-downstream-truth.sh +28 -2
- package/src/commands/demo.js +632 -0
- package/src/commands/init.js +14 -7
- package/src/commands/start.js +2 -1
- package/src/lib/context-section-parser.js +43 -5
- package/src/lib/dispatch-bundle.js +303 -5
- package/src/lib/governed-state.js +81 -1
- package/src/lib/repo-observer.js +47 -9
- package/src/lib/turn-paths.js +6 -0
- package/src/lib/turn-result-validator.js +149 -1
|
@@ -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 {
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -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
|
|
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:
|
|
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 {
|
|
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
|
|
411
|
-
// If only
|
|
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 => !
|
|
451
|
+
const actorDirtyFiles = dirtyFiles.filter(f => !isBaselineExemptPath(f));
|
|
414
452
|
|
|
415
453
|
if (actorDirtyFiles.length === 0) return { clean: true };
|
|
416
454
|
|
package/src/lib/turn-paths.js
CHANGED
|
@@ -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 = []) {
|