agentxchain 2.22.0 → 2.23.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,451 @@
1
+ /**
2
+ * Proposal operations: list, diff, apply, reject.
3
+ *
4
+ * Proposals live at .agentxchain/proposed/<turn_id>/ and are created during
5
+ * acceptGovernedTurn() for api_proxy turns with proposed write authority.
6
+ * This module provides the operator surface for acting on those proposals.
7
+ */
8
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, statSync } from 'fs';
9
+ import { join, dirname, relative } from 'path';
10
+ import { execFileSync } from 'child_process';
11
+ import { createHash } from 'crypto';
12
+ import { LEDGER_PATH } from './governed-state.js';
13
+
14
+ const PROPOSED_DIR = '.agentxchain/proposed';
15
+ const HISTORY_PATH = '.agentxchain/history.jsonl';
16
+ const SOURCE_SNAPSHOT_FILE = 'SOURCE_SNAPSHOT.json';
17
+
18
+ function appendLedgerEntry(root, entry) {
19
+ const filePath = join(root, LEDGER_PATH);
20
+ mkdirSync(dirname(filePath), { recursive: true });
21
+ writeFileSync(filePath, JSON.stringify(entry) + '\n', { flag: 'a' });
22
+ }
23
+
24
+ /**
25
+ * List all proposals with their status.
26
+ * Returns: { ok, proposals: [{ turn_id, role, file_count, status, summary }] }
27
+ */
28
+ export function listProposals(root) {
29
+ const proposedDir = join(root, PROPOSED_DIR);
30
+ if (!existsSync(proposedDir)) {
31
+ return { ok: true, proposals: [] };
32
+ }
33
+
34
+ const entries = readdirSync(proposedDir, { withFileTypes: true })
35
+ .filter((d) => d.isDirectory());
36
+
37
+ const proposals = [];
38
+ for (const entry of entries) {
39
+ const turnDir = join(proposedDir, entry.name);
40
+ const proposalMd = join(turnDir, 'PROPOSAL.md');
41
+ if (!existsSync(proposalMd)) continue;
42
+
43
+ const content = readFileSync(proposalMd, 'utf8');
44
+ const role = extractField(content, 'Role') || '(unknown)';
45
+ const fileActions = extractFileActions(content);
46
+
47
+ let status = 'pending';
48
+ if (existsSync(join(turnDir, 'APPLIED.json'))) status = 'applied';
49
+ else if (existsSync(join(turnDir, 'REJECTED.json'))) status = 'rejected';
50
+
51
+ proposals.push({
52
+ turn_id: entry.name,
53
+ role,
54
+ file_count: fileActions.length,
55
+ status,
56
+ files: fileActions,
57
+ });
58
+ }
59
+
60
+ return { ok: true, proposals };
61
+ }
62
+
63
+ /**
64
+ * Compute diff between proposed files and current workspace.
65
+ * Returns: { ok, diffs: [{ path, action, has_workspace, preview }] }
66
+ */
67
+ export function diffProposal(root, turnId, filterFile) {
68
+ const validation = validateProposalDir(root, turnId);
69
+ if (!validation.ok) return validation;
70
+
71
+ const { turnDir, fileActions } = validation;
72
+ const targets = filterFile
73
+ ? fileActions.filter((f) => f.path === filterFile)
74
+ : fileActions;
75
+
76
+ if (filterFile && targets.length === 0) {
77
+ return { ok: false, error: `File ${filterFile} is not part of proposal ${turnId}` };
78
+ }
79
+
80
+ const diffs = [];
81
+ for (const file of targets) {
82
+ const workspacePath = join(root, file.path);
83
+ const proposedPath = join(turnDir, file.path);
84
+ const hasWorkspace = existsSync(workspacePath);
85
+
86
+ let preview = '';
87
+ if (file.action === 'delete') {
88
+ preview = hasWorkspace ? `--- ${file.path}\n+++ /dev/null\n(file would be deleted)` : '(file already absent)';
89
+ } else {
90
+ const proposedContent = existsSync(proposedPath) ? readFileSync(proposedPath, 'utf8') : '';
91
+ if (!hasWorkspace) {
92
+ preview = `--- /dev/null\n+++ ${file.path}\n(new file, ${proposedContent.split('\n').length} lines)`;
93
+ } else {
94
+ const currentContent = readFileSync(workspacePath, 'utf8');
95
+ if (currentContent === proposedContent) {
96
+ preview = '(no changes — contents identical)';
97
+ } else {
98
+ preview = buildSimpleDiff(file.path, currentContent, proposedContent);
99
+ }
100
+ }
101
+ }
102
+
103
+ diffs.push({ path: file.path, action: file.action, has_workspace: hasWorkspace, preview });
104
+ }
105
+
106
+ return { ok: true, diffs };
107
+ }
108
+
109
+ /**
110
+ * Apply a proposal to the workspace.
111
+ * Returns: { ok, applied_files, skipped_files, dry_run }
112
+ */
113
+ export function applyProposal(root, turnId, opts = {}) {
114
+ const validation = validateProposalDir(root, turnId);
115
+ if (!validation.ok) return validation;
116
+
117
+ const { turnDir, fileActions } = validation;
118
+
119
+ if (existsSync(join(turnDir, 'APPLIED.json'))) {
120
+ return { ok: false, error: `Proposal ${turnId} has already been applied` };
121
+ }
122
+ if (existsSync(join(turnDir, 'REJECTED.json'))) {
123
+ return { ok: false, error: `Proposal ${turnId} has already been rejected` };
124
+ }
125
+
126
+ const targets = opts.file
127
+ ? fileActions.filter((f) => f.path === opts.file)
128
+ : fileActions;
129
+
130
+ if (opts.file && targets.length === 0) {
131
+ return { ok: false, error: `File ${opts.file} is not part of proposal ${turnId}` };
132
+ }
133
+
134
+ const conflictCheck = detectProposalApplyConflicts(root, turnId, turnDir, targets);
135
+ if (!conflictCheck.ok) {
136
+ return conflictCheck;
137
+ }
138
+ if (conflictCheck.conflicts.length > 0 && !opts.force) {
139
+ const conflictedPaths = conflictCheck.conflicts.map((conflict) => conflict.path);
140
+ return {
141
+ ok: false,
142
+ error: `Proposal ${turnId} conflicts with the current workspace for: ${conflictedPaths.join(', ')}. Re-run with --force to override.`,
143
+ error_code: 'proposal_conflict',
144
+ conflicts: conflictCheck.conflicts,
145
+ };
146
+ }
147
+
148
+ if (opts.dryRun) {
149
+ return {
150
+ ok: true,
151
+ applied_files: targets.map((f) => f.path),
152
+ skipped_files: [],
153
+ dry_run: true,
154
+ forced: Boolean(opts.force),
155
+ overridden_conflicts: conflictCheck.conflicts,
156
+ };
157
+ }
158
+
159
+ const applied = [];
160
+ const skipped = [];
161
+
162
+ for (const file of targets) {
163
+ const workspacePath = join(root, file.path);
164
+ const proposedPath = join(turnDir, file.path);
165
+
166
+ if (file.action === 'delete') {
167
+ if (existsSync(workspacePath)) {
168
+ unlinkSync(workspacePath);
169
+ applied.push(file.path);
170
+ } else {
171
+ skipped.push(file.path);
172
+ }
173
+ } else {
174
+ if (!existsSync(proposedPath)) {
175
+ skipped.push(file.path);
176
+ continue;
177
+ }
178
+ mkdirSync(dirname(workspacePath), { recursive: true });
179
+ writeFileSync(workspacePath, readFileSync(proposedPath));
180
+ applied.push(file.path);
181
+ }
182
+ }
183
+
184
+ const appliedRecord = {
185
+ applied_at: new Date().toISOString(),
186
+ files: applied,
187
+ selective: Boolean(opts.file),
188
+ forced: Boolean(opts.force),
189
+ overridden_conflicts: conflictCheck.conflicts,
190
+ };
191
+ writeFileSync(join(turnDir, 'APPLIED.json'), JSON.stringify(appliedRecord, null, 2) + '\n');
192
+
193
+ appendLedgerEntry(root, {
194
+ id: `DEC-PROP-APPLY-${turnId}`,
195
+ category: 'proposal',
196
+ action: 'applied',
197
+ turn_id: turnId,
198
+ files: applied,
199
+ selective: Boolean(opts.file),
200
+ forced: Boolean(opts.force),
201
+ overridden_conflicts: conflictCheck.conflicts,
202
+ timestamp: appliedRecord.applied_at,
203
+ });
204
+
205
+ return {
206
+ ok: true,
207
+ applied_files: applied,
208
+ skipped_files: skipped,
209
+ dry_run: false,
210
+ forced: Boolean(opts.force),
211
+ overridden_conflicts: conflictCheck.conflicts,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Reject a proposal.
217
+ * Returns: { ok }
218
+ */
219
+ export function rejectProposal(root, turnId, reason) {
220
+ const validation = validateProposalDir(root, turnId);
221
+ if (!validation.ok) return validation;
222
+
223
+ const { turnDir } = validation;
224
+
225
+ if (existsSync(join(turnDir, 'APPLIED.json'))) {
226
+ return { ok: false, error: `Proposal ${turnId} has already been applied` };
227
+ }
228
+ if (existsSync(join(turnDir, 'REJECTED.json'))) {
229
+ return { ok: false, error: `Proposal ${turnId} has already been rejected` };
230
+ }
231
+ if (!reason || !reason.trim()) {
232
+ return { ok: false, error: '--reason is required to reject a proposal' };
233
+ }
234
+
235
+ const now = new Date().toISOString();
236
+ const rejectedRecord = {
237
+ rejected_at: now,
238
+ reason: reason.trim(),
239
+ };
240
+ writeFileSync(join(turnDir, 'REJECTED.json'), JSON.stringify(rejectedRecord, null, 2) + '\n');
241
+
242
+ appendLedgerEntry(root, {
243
+ id: `DEC-PROP-REJECT-${turnId}`,
244
+ category: 'proposal',
245
+ action: 'rejected',
246
+ turn_id: turnId,
247
+ reason: reason.trim(),
248
+ timestamp: now,
249
+ });
250
+
251
+ return { ok: true };
252
+ }
253
+
254
+ // --- Internal helpers ---
255
+
256
+ function validateProposalDir(root, turnId) {
257
+ const turnDir = join(root, PROPOSED_DIR, turnId);
258
+ if (!existsSync(turnDir)) {
259
+ return { ok: false, error: `No proposal found for turn ${turnId}` };
260
+ }
261
+ const proposalMd = join(turnDir, 'PROPOSAL.md');
262
+ if (!existsSync(proposalMd)) {
263
+ return { ok: false, error: `Proposal ${turnId} is malformed (missing PROPOSAL.md)` };
264
+ }
265
+ const content = readFileSync(proposalMd, 'utf8');
266
+ const fileActions = extractFileActions(content);
267
+ return { ok: true, turnDir, fileActions, content };
268
+ }
269
+
270
+ function detectProposalApplyConflicts(root, turnId, turnDir, targets) {
271
+ const sourceSnapshots = loadProposalSourceSnapshots(root, turnId, turnDir, targets);
272
+ const conflicts = [];
273
+
274
+ for (const file of targets) {
275
+ const source = sourceSnapshots.get(file.path);
276
+ if (!source) {
277
+ conflicts.push({
278
+ path: file.path,
279
+ action: file.action,
280
+ reason: 'missing_source_snapshot',
281
+ });
282
+ continue;
283
+ }
284
+
285
+ const current = getWorkspaceFingerprint(root, file.path);
286
+ if (file.action === 'delete') {
287
+ if (!current.exists || fingerprintsMatch(current, source)) {
288
+ continue;
289
+ }
290
+ conflicts.push({
291
+ path: file.path,
292
+ action: file.action,
293
+ reason: 'workspace_diverged',
294
+ });
295
+ continue;
296
+ }
297
+
298
+ const proposed = getProposedFingerprint(turnDir, file.path);
299
+ if (fingerprintsMatch(current, proposed) || fingerprintsMatch(current, source)) {
300
+ continue;
301
+ }
302
+ conflicts.push({
303
+ path: file.path,
304
+ action: file.action,
305
+ reason: 'workspace_diverged',
306
+ });
307
+ }
308
+
309
+ return { ok: true, conflicts };
310
+ }
311
+
312
+ function loadProposalSourceSnapshots(root, turnId, turnDir, targets) {
313
+ const snapshotPath = join(turnDir, SOURCE_SNAPSHOT_FILE);
314
+ if (existsSync(snapshotPath)) {
315
+ try {
316
+ const parsed = JSON.parse(readFileSync(snapshotPath, 'utf8'));
317
+ return new Map((parsed.files || []).map((entry) => [entry.path, entry]));
318
+ } catch {
319
+ return new Map();
320
+ }
321
+ }
322
+
323
+ return deriveLegacySourceSnapshots(root, turnId, targets);
324
+ }
325
+
326
+ function deriveLegacySourceSnapshots(root, turnId, targets) {
327
+ const historyPath = join(root, HISTORY_PATH);
328
+ if (!existsSync(historyPath)) {
329
+ return new Map();
330
+ }
331
+
332
+ const historyEntry = readFileSync(historyPath, 'utf8')
333
+ .split('\n')
334
+ .filter(Boolean)
335
+ .map((line) => JSON.parse(line))
336
+ .find((entry) => entry.turn_id === turnId);
337
+ const baselineRef = historyEntry?.observed_artifact?.baseline_ref;
338
+ if (!baselineRef || !baselineRef.startsWith('git:')) {
339
+ return new Map();
340
+ }
341
+
342
+ const gitRef = baselineRef.slice(4);
343
+ return new Map(targets.map((file) => [file.path, {
344
+ path: file.path,
345
+ action: file.action,
346
+ ...getGitFingerprint(root, gitRef, file.path),
347
+ }]));
348
+ }
349
+
350
+ function getWorkspaceFingerprint(root, filePath) {
351
+ const absPath = join(root, filePath);
352
+ if (!existsSync(absPath)) {
353
+ return { existed: false, sha256: null };
354
+ }
355
+ return {
356
+ existed: true,
357
+ sha256: hashContent(readFileSync(absPath)),
358
+ };
359
+ }
360
+
361
+ function getProposedFingerprint(turnDir, filePath) {
362
+ const proposedPath = join(turnDir, filePath);
363
+ if (!existsSync(proposedPath)) {
364
+ return { existed: false, sha256: null };
365
+ }
366
+ return {
367
+ existed: true,
368
+ sha256: hashContent(readFileSync(proposedPath)),
369
+ };
370
+ }
371
+
372
+ function getGitFingerprint(root, gitRef, filePath) {
373
+ try {
374
+ const content = execFileSync('git', ['show', `${gitRef}:${filePath}`], {
375
+ cwd: root,
376
+ encoding: 'buffer',
377
+ stdio: ['ignore', 'pipe', 'ignore'],
378
+ });
379
+ return {
380
+ existed: true,
381
+ sha256: hashContent(content),
382
+ };
383
+ } catch {
384
+ return {
385
+ existed: false,
386
+ sha256: null,
387
+ };
388
+ }
389
+ }
390
+
391
+ function fingerprintsMatch(left, right) {
392
+ return Boolean(left && right)
393
+ && Boolean(left.existed) === Boolean(right.existed)
394
+ && (left.sha256 || null) === (right.sha256 || null);
395
+ }
396
+
397
+ function hashContent(content) {
398
+ return `sha256:${createHash('sha256').update(content).digest('hex')}`;
399
+ }
400
+
401
+ function extractField(content, fieldName) {
402
+ const match = content.match(new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`));
403
+ return match ? match[1].trim() : null;
404
+ }
405
+
406
+ function extractFileActions(content) {
407
+ const actions = [];
408
+ const regex = /^- `([^`]+)` — (create|modify|delete)/gm;
409
+ let match;
410
+ while ((match = regex.exec(content)) !== null) {
411
+ actions.push({ path: match[1], action: match[2] });
412
+ }
413
+ return actions;
414
+ }
415
+
416
+ function buildSimpleDiff(filePath, current, proposed) {
417
+ const currentLines = current.split('\n');
418
+ const proposedLines = proposed.split('\n');
419
+ const lines = [`--- a/${filePath}`, `+++ b/${filePath}`];
420
+
421
+ // Simple line-by-line diff (not a full Myers diff, but useful for review)
422
+ const maxLen = Math.max(currentLines.length, proposedLines.length);
423
+ let changeCount = 0;
424
+ const MAX_DIFF_LINES = 80;
425
+
426
+ for (let i = 0; i < maxLen && changeCount < MAX_DIFF_LINES; i++) {
427
+ const cur = currentLines[i];
428
+ const prop = proposedLines[i];
429
+ if (cur === prop) continue;
430
+ if (cur !== undefined && prop !== undefined) {
431
+ lines.push(`@@ line ${i + 1} @@`);
432
+ lines.push(`-${cur}`);
433
+ lines.push(`+${prop}`);
434
+ changeCount += 3;
435
+ } else if (cur === undefined) {
436
+ lines.push(`@@ line ${i + 1} (added) @@`);
437
+ lines.push(`+${prop}`);
438
+ changeCount += 2;
439
+ } else {
440
+ lines.push(`@@ line ${i + 1} (removed) @@`);
441
+ lines.push(`-${cur}`);
442
+ changeCount += 2;
443
+ }
444
+ }
445
+
446
+ if (changeCount >= MAX_DIFF_LINES) {
447
+ lines.push(`... (diff truncated, ${maxLen - currentLines.length} more lines differ)`);
448
+ }
449
+
450
+ return lines.join('\n');
451
+ }
@@ -48,6 +48,7 @@ const ORCHESTRATOR_STATE_FILES = [
48
48
  const BASELINE_EXEMPT_PATH_PREFIXES = [
49
49
  '.agentxchain/reviews/',
50
50
  '.agentxchain/reports/',
51
+ '.agentxchain/proposed/',
51
52
  ];
52
53
 
53
54
  /**
@@ -193,6 +193,34 @@
193
193
  "type": ["string", "null"],
194
194
  "description": "If status is needs_human, explain why."
195
195
  },
196
+ "proposed_changes": {
197
+ "type": "array",
198
+ "description": "Structured file proposals for proposed write authority turns via api_proxy. Orchestrator materializes these to .agentxchain/proposed/<turn_id>/.",
199
+ "items": {
200
+ "type": "object",
201
+ "required": ["path", "action"],
202
+ "additionalProperties": false,
203
+ "properties": {
204
+ "path": {
205
+ "type": "string",
206
+ "minLength": 1,
207
+ "description": "Repo-relative file path for the proposed change."
208
+ },
209
+ "action": {
210
+ "enum": ["create", "modify", "delete"],
211
+ "description": "Type of file change being proposed."
212
+ },
213
+ "content": {
214
+ "type": "string",
215
+ "description": "Full file content for create/modify actions. Required for create and modify, ignored for delete."
216
+ },
217
+ "original_snippet": {
218
+ "type": "string",
219
+ "description": "Optional snippet of the original code being replaced (for modify actions)."
220
+ }
221
+ }
222
+ }
223
+ },
196
224
  "cost": {
197
225
  "type": "object",
198
226
  "properties": {
@@ -386,6 +386,41 @@ function validateArtifact(tr, config) {
386
386
  warnings.push('Authoritative role completed with no files_changed — is this intentional?');
387
387
  }
388
388
 
389
+ // Validate proposed_changes for proposed + api_proxy turns
390
+ const runtimeType = config.runtimes?.[tr.runtime_id]?.type;
391
+ if (writeAuthority === 'proposed' && runtimeType === 'api_proxy') {
392
+ // Completion-request turns are explicitly allowed to have empty proposed_changes —
393
+ // the turn is signaling run completion, not delivering work.
394
+ const isCompletionRequest = tr.run_completion_request === true;
395
+ if (tr.status === 'completed' && (!tr.proposed_changes || tr.proposed_changes.length === 0) && !isCompletionRequest) {
396
+ errors.push('Proposed api_proxy turn completed but proposed_changes is empty or missing.');
397
+ }
398
+ }
399
+ if (tr.proposed_changes && Array.isArray(tr.proposed_changes)) {
400
+ if (writeAuthority === 'review_only') {
401
+ warnings.push('Turn result contains proposed_changes but role has review_only write authority — proposed_changes will be ignored.');
402
+ }
403
+ for (let i = 0; i < tr.proposed_changes.length; i++) {
404
+ const change = tr.proposed_changes[i];
405
+ if (!change || typeof change !== 'object') {
406
+ errors.push(`proposed_changes[${i}]: must be an object.`);
407
+ continue;
408
+ }
409
+ if (!change.path || typeof change.path !== 'string') {
410
+ errors.push(`proposed_changes[${i}]: missing or invalid "path".`);
411
+ }
412
+ if (!['create', 'modify', 'delete'].includes(change.action)) {
413
+ errors.push(`proposed_changes[${i}]: action must be "create", "modify", or "delete" (got "${change.action}").`);
414
+ }
415
+ if ((change.action === 'create' || change.action === 'modify') && (change.content == null || typeof change.content !== 'string')) {
416
+ errors.push(`proposed_changes[${i}]: content is required for "${change.action}" action.`);
417
+ }
418
+ if (change.path && RESERVED_PATHS.includes(change.path)) {
419
+ errors.push(`proposed_changes[${i}]: cannot propose changes to reserved path "${change.path}".`);
420
+ }
421
+ }
422
+ }
423
+
389
424
  return { errors, warnings };
390
425
  }
391
426