agentxchain 2.152.0 → 2.154.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.
@@ -0,0 +1,260 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { emitRunEvent } from './run-events.js';
5
+ import { captureBaselineRef, readSessionCheckpoint, SESSION_PATH } from './session-checkpoint.js';
6
+ import { safeWriteJson } from './safe-write.js';
7
+
8
+ const STATE_PATH = '.agentxchain/state.json';
9
+ const CRITICAL_DELETION_PATHS = new Set([
10
+ '.planning/acceptance-matrix.md',
11
+ ]);
12
+
13
+ function git(root, args) {
14
+ return execFileSync('git', args, {
15
+ cwd: root,
16
+ encoding: 'utf8',
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ }).trim();
19
+ }
20
+
21
+ function gitOk(root, args) {
22
+ try {
23
+ execFileSync('git', args, {
24
+ cwd: root,
25
+ encoding: 'utf8',
26
+ stdio: ['ignore', 'pipe', 'pipe'],
27
+ });
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function extractGitError(err) {
35
+ const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
36
+ const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '';
37
+ return stderr || stdout || err?.message || 'git command failed';
38
+ }
39
+
40
+ function readState(root) {
41
+ const statePath = join(root, STATE_PATH);
42
+ if (!existsSync(statePath)) return null;
43
+ try {
44
+ return JSON.parse(readFileSync(statePath, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function normalizeGitRef(ref) {
51
+ if (typeof ref !== 'string') return null;
52
+ const trimmed = ref.trim();
53
+ if (!trimmed) return null;
54
+ return trimmed.startsWith('git:') ? trimmed.slice(4).trim() || null : trimmed;
55
+ }
56
+
57
+ function resolvePreviousBaseline(state, session) {
58
+ return (
59
+ normalizeGitRef(session?.baseline_ref?.git_head)
60
+ || normalizeGitRef(state?.accepted_integration_ref)
61
+ || normalizeGitRef(state?.last_completed_turn?.checkpoint_sha)
62
+ || null
63
+ );
64
+ }
65
+
66
+ function parseNameStatus(raw) {
67
+ if (!raw.trim()) return [];
68
+ return raw.split('\n').filter(Boolean).map((line) => {
69
+ const parts = line.split('\t');
70
+ const status = parts[0] || '';
71
+ const paths = parts.slice(1).filter(Boolean);
72
+ return { status, paths };
73
+ });
74
+ }
75
+
76
+ function listCommitPaths(root, sha) {
77
+ const raw = git(root, ['diff-tree', '--no-commit-id', '--name-status', '-r', sha]);
78
+ return parseNameStatus(raw);
79
+ }
80
+
81
+ function summarizeCommit(root, sha) {
82
+ const subject = git(root, ['log', '-1', '--format=%s', sha]);
83
+ const entries = listCommitPaths(root, sha);
84
+ const paths = [...new Set(entries.flatMap((entry) => entry.paths))].sort();
85
+ return {
86
+ sha,
87
+ subject,
88
+ paths_touched: paths,
89
+ name_status: entries,
90
+ };
91
+ }
92
+
93
+ function classifyUnsafeCommit(commit) {
94
+ for (const entry of commit.name_status) {
95
+ for (const pathName of entry.paths) {
96
+ if (pathName === '.agentxchain' || pathName.startsWith('.agentxchain/')) {
97
+ return {
98
+ error_class: 'governance_state_modified',
99
+ message: `Commit ${commit.sha.slice(0, 8)} modifies governed state path ${pathName}; reconcile cannot auto-accept .agentxchain edits.`,
100
+ commit: commit.sha,
101
+ path: pathName,
102
+ };
103
+ }
104
+ if (entry.status.startsWith('D') && CRITICAL_DELETION_PATHS.has(pathName)) {
105
+ return {
106
+ error_class: 'critical_artifact_deleted',
107
+ message: `Commit ${commit.sha.slice(0, 8)} deletes critical governed evidence ${pathName}; restore the artifact or restart from an explicit recovery point.`,
108
+ commit: commit.sha,
109
+ path: pathName,
110
+ };
111
+ }
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function writeSessionBaseline(root, state, previousBaseline, acceptedHead, acceptedCommits) {
118
+ const existing = readSessionCheckpoint(root) || {};
119
+ const checkpoint = {
120
+ ...existing,
121
+ run_id: state?.run_id || existing.run_id || null,
122
+ last_checkpoint_at: new Date().toISOString(),
123
+ checkpoint_reason: 'operator_commit_reconciled',
124
+ run_status: state?.status || existing.run_status || null,
125
+ phase: state?.phase || state?.current_phase || existing.phase || null,
126
+ last_phase: state?.phase || state?.current_phase || existing.last_phase || null,
127
+ last_completed_turn_id: state?.last_completed_turn_id || existing.last_completed_turn_id || null,
128
+ baseline_ref: captureBaselineRef(root),
129
+ operator_commit_reconciliation: {
130
+ previous_baseline: previousBaseline,
131
+ accepted_head: acceptedHead,
132
+ commit_count: acceptedCommits.length,
133
+ },
134
+ };
135
+ writeFileSync(join(root, SESSION_PATH), JSON.stringify(checkpoint, null, 2) + '\n');
136
+ }
137
+
138
+ export function reconcileOperatorHead(root, opts = {}) {
139
+ let currentHead;
140
+ try {
141
+ if (git(root, ['rev-parse', '--is-inside-work-tree']) !== 'true') {
142
+ return { ok: false, error_class: 'not_git_repo', error: 'reconcile-state requires a git repository.' };
143
+ }
144
+ currentHead = git(root, ['rev-parse', 'HEAD']);
145
+ } catch (err) {
146
+ return { ok: false, error_class: 'git_unavailable', error: `Unable to inspect git HEAD: ${extractGitError(err)}` };
147
+ }
148
+
149
+ const state = readState(root);
150
+ const session = readSessionCheckpoint(root);
151
+ const previousBaseline = resolvePreviousBaseline(state, session);
152
+ if (!previousBaseline) {
153
+ return {
154
+ ok: false,
155
+ error_class: 'missing_baseline',
156
+ error: 'No prior checkpoint baseline found in session.json, state.accepted_integration_ref, or last_completed_turn.checkpoint_sha.',
157
+ };
158
+ }
159
+
160
+ if (previousBaseline === currentHead) {
161
+ return {
162
+ ok: true,
163
+ no_op: true,
164
+ previous_baseline: previousBaseline,
165
+ accepted_head: currentHead,
166
+ accepted_commits: [],
167
+ };
168
+ }
169
+
170
+ if (!gitOk(root, ['merge-base', '--is-ancestor', previousBaseline, currentHead])) {
171
+ return {
172
+ ok: false,
173
+ error_class: 'history_rewrite',
174
+ error: `Cannot reconcile operator HEAD: baseline ${previousBaseline.slice(0, 8)} is not an ancestor of current HEAD ${currentHead.slice(0, 8)}.`,
175
+ previous_baseline: previousBaseline,
176
+ current_head: currentHead,
177
+ };
178
+ }
179
+
180
+ let shas;
181
+ try {
182
+ shas = git(root, ['rev-list', '--reverse', `${previousBaseline}..${currentHead}`])
183
+ .split('\n')
184
+ .map((value) => value.trim())
185
+ .filter(Boolean);
186
+ } catch (err) {
187
+ return { ok: false, error_class: 'commit_walk_failed', error: `Failed to inspect operator commits: ${extractGitError(err)}` };
188
+ }
189
+
190
+ const commits = shas.map((sha) => summarizeCommit(root, sha));
191
+ for (const commit of commits) {
192
+ const unsafe = classifyUnsafeCommit(commit);
193
+ if (unsafe) {
194
+ return {
195
+ ok: false,
196
+ error_class: unsafe.error_class,
197
+ error: unsafe.message,
198
+ offending_commit: unsafe.commit,
199
+ offending_path: unsafe.path,
200
+ previous_baseline: previousBaseline,
201
+ current_head: currentHead,
202
+ };
203
+ }
204
+ }
205
+
206
+ const acceptedAt = new Date().toISOString();
207
+ const pathsTouched = [...new Set(commits.flatMap((commit) => commit.paths_touched))].sort();
208
+ const nextState = state
209
+ ? {
210
+ ...state,
211
+ accepted_integration_ref: `git:${currentHead}`,
212
+ operator_commit_reconciliation: {
213
+ reconciled_at: acceptedAt,
214
+ previous_baseline: previousBaseline,
215
+ accepted_head: currentHead,
216
+ commit_count: commits.length,
217
+ safety_mode: opts.safetyMode || 'manual_safe_only',
218
+ },
219
+ }
220
+ : null;
221
+
222
+ if (nextState) {
223
+ safeWriteJson(join(root, STATE_PATH), nextState);
224
+ }
225
+
226
+ emitRunEvent(root, 'state_reconciled_operator_commits', {
227
+ run_id: state?.run_id || null,
228
+ phase: state?.phase || state?.current_phase || null,
229
+ status: state?.status || null,
230
+ payload: {
231
+ previous_baseline: previousBaseline,
232
+ accepted_head: currentHead,
233
+ accepted_commits: commits.map((commit) => ({
234
+ sha: commit.sha,
235
+ subject: commit.subject,
236
+ paths_touched: commit.paths_touched,
237
+ })),
238
+ paths_touched: pathsTouched,
239
+ safety_checks: {
240
+ baseline_is_ancestor: true,
241
+ rejected_state_paths: ['.agentxchain/'],
242
+ rejected_deletions: [...CRITICAL_DELETION_PATHS],
243
+ },
244
+ },
245
+ });
246
+
247
+ writeSessionBaseline(root, nextState || state, previousBaseline, currentHead, commits);
248
+
249
+ return {
250
+ ok: true,
251
+ previous_baseline: previousBaseline,
252
+ accepted_head: currentHead,
253
+ accepted_commits: commits.map((commit) => ({
254
+ sha: commit.sha,
255
+ subject: commit.subject,
256
+ paths_touched: commit.paths_touched,
257
+ })),
258
+ paths_touched: pathsTouched,
259
+ };
260
+ }
@@ -44,6 +44,10 @@ export const VALID_RUN_EVENTS = [
44
44
  'human_escalation_resolved',
45
45
  'dispatch_progress',
46
46
  'session_continuation',
47
+ 'auto_retried_ghost',
48
+ 'ghost_retry_exhausted',
49
+ 'state_reconciled_operator_commits',
50
+ 'operator_commit_reconcile_refused',
47
51
  ];
48
52
 
49
53
  /**
@@ -104,6 +104,34 @@
104
104
  "type": "integer",
105
105
  "minimum": 1,
106
106
  "description": "Milliseconds to wait before a started turn that previously produced output is treated as stale. Default 600000 for local_cli turns and 300000 for api_proxy turns."
107
+ },
108
+ "continuous": {
109
+ "type": "object",
110
+ "description": "Continuous-run control knobs.",
111
+ "properties": {
112
+ "auto_retry_on_ghost": {
113
+ "type": "object",
114
+ "description": "Bounded ghost-turn retry policy for continuous/full-auto sessions.",
115
+ "properties": {
116
+ "enabled": {
117
+ "type": "boolean",
118
+ "description": "Enable bounded automatic reissue for startup ghost turns. Defaults false unless full-auto approval policy posture promotes it."
119
+ },
120
+ "max_retries_per_run": {
121
+ "type": "integer",
122
+ "minimum": 1,
123
+ "description": "Maximum ghost retries per run before leaving manual recovery visible. Default 3."
124
+ },
125
+ "cooldown_seconds": {
126
+ "type": "integer",
127
+ "minimum": 1,
128
+ "description": "Seconds to wait between automatic ghost retries. Default 5."
129
+ }
130
+ },
131
+ "additionalProperties": true
132
+ }
133
+ },
134
+ "additionalProperties": true
107
135
  }
108
136
  },
109
137
  "additionalProperties": true