agentxchain 2.140.0 → 2.142.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.140.0",
3
+ "version": "2.142.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -153,7 +153,7 @@ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
153
153
  test/release-notes-gate.test.js
154
154
  test/release-identity-hardening.test.js
155
155
  test/normalized-config.test.js
156
- test/conformance.test.js
156
+ test/protocol-conformance.test.js
157
157
  test/beta-scenario-emission-guard.test.js
158
158
  test/claim-reality-preflight.test.js
159
159
  test/beta-tester-scenarios/*.test.js
@@ -36,7 +36,10 @@ import {
36
36
  compareDeclaredVsObserved,
37
37
  deriveAcceptedRef,
38
38
  checkCleanBaseline,
39
+ detectDirtyFilesOutsideAllowed,
39
40
  isOperationalPath,
41
+ isBaselineExemptPath,
42
+ normalizeCheckpointableFiles,
40
43
  } from './repo-observer.js';
41
44
  import { getMaxConcurrentTurns } from './normalized-config.js';
42
45
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
@@ -64,6 +67,8 @@ import {
64
67
  } from './repo-decisions.js';
65
68
  import {
66
69
  replayVerificationMachineEvidence,
70
+ normalizeVerificationProducedFiles,
71
+ cleanupIgnoredVerificationFiles,
67
72
  summarizeVerificationReplay,
68
73
  } from './verification-replay.js';
69
74
  import { executeGateActions } from './gate-actions.js';
@@ -106,6 +111,34 @@ function buildInitialPhaseGateStatus(config) {
106
111
  );
107
112
  }
108
113
 
114
+ function buildObservationDrift(beforeObservation, afterObservation) {
115
+ const beforeFiles = (Array.isArray(beforeObservation?.files_changed) ? beforeObservation.files_changed : [])
116
+ .filter((filePath) => !isBaselineExemptPath(filePath));
117
+ const afterFiles = (Array.isArray(afterObservation?.files_changed) ? afterObservation.files_changed : [])
118
+ .filter((filePath) => !isBaselineExemptPath(filePath));
119
+ const beforeSet = new Set(beforeFiles);
120
+ const afterSet = new Set(afterFiles);
121
+ const beforeMarkers = beforeObservation?.file_markers && typeof beforeObservation.file_markers === 'object'
122
+ ? beforeObservation.file_markers
123
+ : {};
124
+ const afterMarkers = afterObservation?.file_markers && typeof afterObservation.file_markers === 'object'
125
+ ? afterObservation.file_markers
126
+ : {};
127
+
128
+ const added = afterFiles.filter((filePath) => !beforeSet.has(filePath));
129
+ const removed = beforeFiles.filter((filePath) => !afterSet.has(filePath));
130
+ const markerChanged = beforeFiles.filter((filePath) => (
131
+ afterSet.has(filePath) && beforeMarkers[filePath] !== afterMarkers[filePath]
132
+ ));
133
+
134
+ return {
135
+ added,
136
+ removed,
137
+ marker_changed: markerChanged,
138
+ drifted_files: [...new Set([...added, ...removed, ...markerChanged])].sort(),
139
+ };
140
+ }
141
+
109
142
  function listIntakeIntentFiles(root) {
110
143
  const intentsDir = join(root, INTAKE_INTENTS_DIR);
111
144
  if (!existsSync(intentsDir)) return [];
@@ -3176,7 +3209,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3176
3209
  };
3177
3210
  }
3178
3211
 
3179
- const turnResult = validation.turnResult;
3212
+ const rawTurnResult = validation.turnResult;
3213
+ const verificationProducedFiles = normalizeVerificationProducedFiles(rawTurnResult.verification);
3214
+ const effectiveFilesChanged = normalizeCheckpointableFiles([
3215
+ ...(rawTurnResult.files_changed || []),
3216
+ ...verificationProducedFiles.artifact_files,
3217
+ ]);
3218
+ const turnResult = {
3219
+ ...rawTurnResult,
3220
+ files_changed: effectiveFilesChanged,
3221
+ };
3180
3222
 
3181
3223
  // Validate cross-run decision overrides against repo-decisions.jsonl
3182
3224
  if (turnResult.decisions && turnResult.decisions.length > 0) {
@@ -3202,6 +3244,35 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3202
3244
  const stagingFile = join(root, resolvedStagingPath);
3203
3245
  const now = new Date().toISOString();
3204
3246
  const baseline = currentTurn.baseline || null;
3247
+ const ignoredVerificationCleanup = cleanupIgnoredVerificationFiles({
3248
+ root,
3249
+ baseline,
3250
+ ignoredFiles: verificationProducedFiles.ignored_files,
3251
+ });
3252
+ if (ignoredVerificationCleanup.ok === false) {
3253
+ const cleanupError = ignoredVerificationCleanup.cleanup_error
3254
+ || 'Ignored verification-produced files could not be restored safely.';
3255
+ transitionToFailedAcceptance(root, state, currentTurn, cleanupError, {
3256
+ error_code: 'verification_produced_files_cleanup',
3257
+ stage: 'artifact_observation',
3258
+ extra: {
3259
+ ignored_files: verificationProducedFiles.ignored_files,
3260
+ restored_files: ignoredVerificationCleanup.restored_files || [],
3261
+ },
3262
+ });
3263
+ return {
3264
+ ok: false,
3265
+ error: cleanupError,
3266
+ validation: {
3267
+ ...validation,
3268
+ ok: false,
3269
+ stage: 'artifact_observation',
3270
+ error_class: 'artifact_error',
3271
+ errors: [cleanupError],
3272
+ warnings: validation.warnings,
3273
+ },
3274
+ };
3275
+ }
3205
3276
  const rawObservation = observeChanges(root, baseline);
3206
3277
  const historyEntries = readJsonlEntries(root, HISTORY_PATH);
3207
3278
  const pendingConcurrentSiblingDeclarations = collectPendingConcurrentSiblingDeclarations(
@@ -3210,11 +3281,17 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3210
3281
  currentTurn,
3211
3282
  historyEntries,
3212
3283
  );
3213
- const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries, {
3284
+ const observationAttributionOptions = {
3214
3285
  currentDeclaredFiles: turnResult.files_changed || [],
3215
3286
  concurrentSiblingIds: pendingConcurrentSiblingDeclarations.map((entry) => entry.turn_id),
3216
3287
  pendingConcurrentSiblingDeclarations,
3217
- });
3288
+ };
3289
+ const observation = attributeObservedChangesToTurn(
3290
+ rawObservation,
3291
+ currentTurn,
3292
+ historyEntries,
3293
+ observationAttributionOptions,
3294
+ );
3218
3295
  const role = config.roles?.[turnResult.role];
3219
3296
  const runtimeId = turnResult.runtime_id;
3220
3297
  const runtime = config.runtimes?.[runtimeId];
@@ -3233,9 +3310,76 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3233
3310
  const concurrentIds = new Set(
3234
3311
  Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
3235
3312
  );
3313
+ const concurrentAllowedDirtyFiles = [];
3314
+ for (const declaration of pendingConcurrentSiblingDeclarations) {
3315
+ concurrentIds.add(declaration?.turn_id);
3316
+ for (const filePath of Array.isArray(declaration?.files_changed) ? declaration.files_changed : []) {
3317
+ concurrentAllowedDirtyFiles.push(filePath);
3318
+ }
3319
+ }
3320
+ for (const historyEntry of historyEntries) {
3321
+ const isConcurrentSibling = concurrentIds.has(historyEntry?.turn_id)
3322
+ || (Array.isArray(historyEntry?.concurrent_with) && historyEntry.concurrent_with.includes(currentTurn.turn_id));
3323
+ if (!isConcurrentSibling) {
3324
+ continue;
3325
+ }
3326
+ const siblingFiles = Array.isArray(historyEntry?.observed_artifact?.files_changed)
3327
+ ? historyEntry.observed_artifact.files_changed
3328
+ : historyEntry?.files_changed;
3329
+ for (const filePath of Array.isArray(siblingFiles) ? siblingFiles : []) {
3330
+ concurrentAllowedDirtyFiles.push(filePath);
3331
+ }
3332
+ }
3236
3333
  const acceptedTurnIds = new Set(historyEntries.map(h => h.turn_id));
3237
3334
  const hasUnacceptedConcurrentSiblings = [...concurrentIds].some(id => !acceptedTurnIds.has(id));
3238
3335
 
3336
+ // Files from accepted-but-uncheckpointed prior turns are expected to be
3337
+ // dirty in the working tree until `checkpoint-turn` commits them. These
3338
+ // must not block the current turn's acceptance — they are known accepted
3339
+ // mutations, not undeclared agent writes.
3340
+ const uncheckpointedPriorFiles = [];
3341
+ for (const historyEntry of historyEntries) {
3342
+ if (historyEntry.checkpoint_sha) continue; // already committed
3343
+ const priorFiles = Array.isArray(historyEntry?.files_changed)
3344
+ ? historyEntry.files_changed
3345
+ : [];
3346
+ for (const filePath of priorFiles) {
3347
+ uncheckpointedPriorFiles.push(filePath);
3348
+ }
3349
+ }
3350
+
3351
+ const dirtyParity = detectDirtyFilesOutsideAllowed(
3352
+ root,
3353
+ writeAuthority,
3354
+ [
3355
+ ...(turnResult.files_changed || []),
3356
+ ...concurrentAllowedDirtyFiles,
3357
+ ...uncheckpointedPriorFiles,
3358
+ ],
3359
+ );
3360
+ if (!dirtyParity.clean) {
3361
+ transitionToFailedAcceptance(root, state, currentTurn, dirtyParity.reason, {
3362
+ error_code: 'artifact_dirty_tree_mismatch',
3363
+ stage: 'artifact_observation',
3364
+ extra: {
3365
+ unexpected_dirty_files: dirtyParity.unexpected_dirty_files,
3366
+ dirty_files: dirtyParity.dirty_files,
3367
+ },
3368
+ });
3369
+ return {
3370
+ ok: false,
3371
+ error: dirtyParity.reason,
3372
+ validation: {
3373
+ ...validation,
3374
+ ok: false,
3375
+ stage: 'artifact_observation',
3376
+ error_class: 'artifact_error',
3377
+ errors: [dirtyParity.reason],
3378
+ warnings: validation.warnings,
3379
+ },
3380
+ };
3381
+ }
3382
+
3239
3383
  const diffComparison = compareDeclaredVsObserved(
3240
3384
  turnResult.files_changed || [],
3241
3385
  observation.files_changed,
@@ -3398,11 +3542,70 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3398
3542
  const observedArtifact = buildObservedArtifact(observation, baseline);
3399
3543
  const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
3400
3544
  const artifactType = turnResult.artifact?.type || 'review';
3545
+
3546
+ // BUG-46 fix requirement #6: workspace artifact with empty files_changed is a
3547
+ // protocol violation for authoritative completed turns. A workspace artifact
3548
+ // declares "I mutated repo files" — if files_changed is empty, the declaration
3549
+ // is incoherent and must be rejected before replay or history persistence.
3550
+ if (artifactType === 'workspace'
3551
+ && writeAuthority === 'authoritative'
3552
+ && turnResult.status === 'completed'
3553
+ && (turnResult.files_changed || []).length === 0
3554
+ && (observation.files_changed || []).length === 0) {
3555
+ const emptyWsError = 'Turn declared artifact.type: "workspace" but files_changed is empty. '
3556
+ + 'Either declare the files modified, or set artifact.type: "review" if no repo mutations were intended.';
3557
+ transitionToFailedAcceptance(root, state, currentTurn, emptyWsError, {
3558
+ error_code: 'empty_workspace_artifact',
3559
+ stage: 'artifact_validation',
3560
+ extra: {
3561
+ artifact_type: artifactType,
3562
+ write_authority: writeAuthority,
3563
+ turn_status: turnResult.status,
3564
+ },
3565
+ });
3566
+ return {
3567
+ ok: false,
3568
+ error: emptyWsError,
3569
+ validation: {
3570
+ ...validation,
3571
+ ok: false,
3572
+ stage: 'artifact_validation',
3573
+ error_class: 'artifact_error',
3574
+ errors: [emptyWsError],
3575
+ warnings: validation.warnings,
3576
+ },
3577
+ };
3578
+ }
3579
+
3401
3580
  const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
3402
3581
  const verificationReplay = (config.policies || []).some((policy) => policy?.rule === 'require_reproducible_verification')
3403
3582
  ? replayVerificationMachineEvidence({ root, verification: turnResult.verification })
3404
3583
  : null;
3405
3584
 
3585
+ if (verificationReplay?.workspace_guard?.ok === false) {
3586
+ const replayError = verificationReplay.workspace_guard.cleanup_error
3587
+ || 'Verification replay mutated actor-owned workspace files and cleanup failed.';
3588
+ transitionToFailedAcceptance(root, state, currentTurn, replayError, {
3589
+ error_code: 'verification_replay_drift',
3590
+ stage: 'verification_replay',
3591
+ extra: {
3592
+ replay_side_effect_files: verificationReplay.workspace_guard.restored_files || [],
3593
+ },
3594
+ });
3595
+ return {
3596
+ ok: false,
3597
+ error: replayError,
3598
+ validation: {
3599
+ ...validation,
3600
+ ok: false,
3601
+ stage: 'verification_replay',
3602
+ error_class: 'verification_replay_error',
3603
+ errors: [replayError],
3604
+ warnings: validation.warnings,
3605
+ },
3606
+ };
3607
+ }
3608
+
3406
3609
  // Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
3407
3610
  const policyResult = evaluatePolicies(config.policies || [], {
3408
3611
  currentPhase: state.phase,
@@ -3638,6 +3841,38 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3638
3841
  }
3639
3842
  }
3640
3843
 
3844
+ const finalObservation = attributeObservedChangesToTurn(
3845
+ observeChanges(root, baseline),
3846
+ currentTurn,
3847
+ historyEntries,
3848
+ observationAttributionOptions,
3849
+ );
3850
+ const observationDrift = buildObservationDrift(observation, finalObservation);
3851
+ if (observationDrift.drifted_files.length > 0) {
3852
+ const driftError = `Acceptance mutated actor-owned workspace after artifact observation: ${observationDrift.drifted_files.join(', ')}`;
3853
+ transitionToFailedAcceptance(root, state, currentTurn, driftError, {
3854
+ error_code: 'acceptance_drift',
3855
+ stage: 'artifact_observation',
3856
+ extra: {
3857
+ added_files: observationDrift.added,
3858
+ removed_files: observationDrift.removed,
3859
+ marker_changed_files: observationDrift.marker_changed,
3860
+ },
3861
+ });
3862
+ return {
3863
+ ok: false,
3864
+ error: driftError,
3865
+ validation: {
3866
+ ...validation,
3867
+ ok: false,
3868
+ stage: 'artifact_observation',
3869
+ error_class: 'artifact_error',
3870
+ errors: [driftError],
3871
+ warnings: validation.warnings,
3872
+ },
3873
+ };
3874
+ }
3875
+
3641
3876
  const acceptedSequence = (state.turn_sequence || 0) + 1;
3642
3877
  const historyEntry = {
3643
3878
  turn_id: turnResult.turn_id,
@@ -3649,7 +3884,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3649
3884
  summary: turnResult.summary,
3650
3885
  decisions: turnResult.decisions || [],
3651
3886
  objections: turnResult.objections || [],
3652
- files_changed: turnResult.files_changed || [],
3887
+ files_changed: normalizeCheckpointableFiles(turnResult.files_changed),
3653
3888
  artifacts_created: turnResult.artifacts_created || [],
3654
3889
  verification: turnResult.verification || {},
3655
3890
  normalized_verification: normalizedVerification,
@@ -29,6 +29,8 @@ const OPERATIONAL_PATH_PREFIXES = [
29
29
  '.agentxchain/intake/',
30
30
  '.agentxchain/locks/',
31
31
  '.agentxchain/transactions/',
32
+ '.agentxchain/missions/',
33
+ '.agentxchain/multirepo/',
32
34
  ];
33
35
 
34
36
  // Orchestrator-owned state files that agents must never be blamed for modifying.
@@ -38,6 +40,7 @@ const ORCHESTRATOR_STATE_FILES = [
38
40
  '.agentxchain/session.json',
39
41
  '.agentxchain/history.jsonl',
40
42
  '.agentxchain/decision-ledger.jsonl',
43
+ '.agentxchain/repo-decisions.jsonl',
41
44
  '.agentxchain/lock.json',
42
45
  '.agentxchain/hook-audit.jsonl',
43
46
  '.agentxchain/hook-annotations.jsonl',
@@ -71,11 +74,21 @@ export function isOperationalPath(filePath) {
71
74
  || ORCHESTRATOR_STATE_FILES.includes(filePath);
72
75
  }
73
76
 
74
- function isBaselineExemptPath(filePath) {
77
+ export function isBaselineExemptPath(filePath) {
75
78
  return isOperationalPath(filePath)
76
79
  || BASELINE_EXEMPT_PATH_PREFIXES.some(prefix => filePath.startsWith(prefix));
77
80
  }
78
81
 
82
+ export function normalizeCheckpointableFiles(filesChanged) {
83
+ return [...new Set(
84
+ (Array.isArray(filesChanged) ? filesChanged : [])
85
+ .filter((value) => typeof value === 'string')
86
+ .map((value) => value.trim())
87
+ .filter(Boolean)
88
+ .filter((value) => !isOperationalPath(value)),
89
+ )];
90
+ }
91
+
79
92
  // ── Baseline Capture ────────────────────────────────────────────────────────
80
93
 
81
94
  /**
@@ -628,6 +641,43 @@ export function checkCleanBaseline(root, writeAuthority) {
628
641
  };
629
642
  }
630
643
 
644
+ export function detectDirtyFilesOutsideAllowed(root, writeAuthority, allowedFiles = []) {
645
+ const cleanCheck = checkCleanBaseline(root, writeAuthority);
646
+ if (cleanCheck.clean) {
647
+ return {
648
+ clean: true,
649
+ dirty_files: [],
650
+ unexpected_dirty_files: [],
651
+ reason: null,
652
+ };
653
+ }
654
+
655
+ const allowedSet = new Set(
656
+ (Array.isArray(allowedFiles) ? allowedFiles : [])
657
+ .filter((value) => typeof value === 'string')
658
+ .map((value) => value.trim())
659
+ .filter(Boolean),
660
+ );
661
+ const dirtyFiles = Array.isArray(cleanCheck.dirty_files) ? cleanCheck.dirty_files : [];
662
+ const unexpectedDirtyFiles = dirtyFiles.filter((filePath) => !allowedSet.has(filePath));
663
+
664
+ if (unexpectedDirtyFiles.length === 0) {
665
+ return {
666
+ clean: true,
667
+ dirty_files: dirtyFiles,
668
+ unexpected_dirty_files: [],
669
+ reason: null,
670
+ };
671
+ }
672
+
673
+ return {
674
+ clean: false,
675
+ dirty_files: dirtyFiles,
676
+ unexpected_dirty_files: unexpectedDirtyFiles,
677
+ reason: `Working tree has uncommitted changes in actor-owned files outside the accepted turn contract: ${unexpectedDirtyFiles.slice(0, 5).join(', ')}${unexpectedDirtyFiles.length > 5 ? '...' : ''}. Resume would block on the same files. Declare them in files_changed, classify verification outputs under verification.produced_files, or clean them before acceptance.`,
678
+ };
679
+ }
680
+
631
681
  // ── Git Primitives ──────────────────────────────────────────────────────────
632
682
 
633
683
  function isGitRepo(root) {
@@ -165,6 +165,20 @@
165
165
  "exit_code": { "type": "integer" }
166
166
  }
167
167
  }
168
+ },
169
+ "produced_files": {
170
+ "type": "array",
171
+ "items": {
172
+ "type": "object",
173
+ "required": ["path"],
174
+ "additionalProperties": false,
175
+ "properties": {
176
+ "path": { "type": "string" },
177
+ "disposition": {
178
+ "enum": ["artifact", "ignore"]
179
+ }
180
+ }
181
+ }
168
182
  }
169
183
  }
170
184
  },
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { resolveAcceptedTurnHistoryReference } from './accepted-turn-history.js';
5
5
  import { emitRunEvent } from './run-events.js';
6
6
  import { safeWriteJson } from './safe-write.js';
7
+ import { normalizeCheckpointableFiles } from './repo-observer.js';
7
8
 
8
9
  const STATE_PATH = '.agentxchain/state.json';
9
10
  const HISTORY_PATH = '.agentxchain/history.jsonl';
@@ -52,25 +53,8 @@ function isGitRepo(root) {
52
53
  }
53
54
  }
54
55
 
55
- // BUG-43: Staging and dispatch dirs are ephemeral — cleaned up after acceptance.
56
- // They must never appear in checkpoint git-add paths.
57
- const EPHEMERAL_PATH_PREFIXES = [
58
- '.agentxchain/staging/',
59
- '.agentxchain/dispatch/',
60
- ];
61
-
62
- function isEphemeralPath(filePath) {
63
- return EPHEMERAL_PATH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
64
- }
65
-
66
56
  function normalizeFilesChanged(filesChanged) {
67
- return [...new Set(
68
- (Array.isArray(filesChanged) ? filesChanged : [])
69
- .filter((value) => typeof value === 'string')
70
- .map((value) => value.trim())
71
- .filter(Boolean)
72
- .filter((value) => !isEphemeralPath(value)),
73
- )];
57
+ return normalizeCheckpointableFiles(filesChanged);
74
58
  }
75
59
 
76
60
  function extractGitError(err) {
@@ -624,6 +624,40 @@ function validateVerification(tr) {
624
624
  }
625
625
  }
626
626
 
627
+ if (Array.isArray(v.produced_files)) {
628
+ const seenProducedPaths = new Map();
629
+ const declaredFiles = new Set(Array.isArray(tr.files_changed) ? tr.files_changed : []);
630
+ for (let i = 0; i < v.produced_files.length; i++) {
631
+ const entry = v.produced_files[i];
632
+ if (typeof entry !== 'object' || entry === null) {
633
+ errors.push(`verification.produced_files[${i}] must be an object.`);
634
+ continue;
635
+ }
636
+
637
+ const rawPath = typeof entry.path === 'string' ? entry.path : '';
638
+ const path = rawPath.trim();
639
+ if (!path) {
640
+ errors.push(`verification.produced_files[${i}].path must be a non-empty string.`);
641
+ }
642
+
643
+ const disposition = entry.disposition == null ? 'artifact' : entry.disposition;
644
+ if (!['artifact', 'ignore'].includes(disposition)) {
645
+ errors.push(`verification.produced_files[${i}].disposition must be "artifact" or "ignore".`);
646
+ }
647
+
648
+ if (path) {
649
+ if (seenProducedPaths.has(path)) {
650
+ errors.push(`verification.produced_files[${i}].path duplicates "${path}" from verification.produced_files[${seenProducedPaths.get(path)}].`);
651
+ } else {
652
+ seenProducedPaths.set(path, i);
653
+ }
654
+ if (disposition === 'ignore' && declaredFiles.has(path)) {
655
+ errors.push(`verification.produced_files[${i}] marks "${path}" as ignore, but it is also listed in files_changed.`);
656
+ }
657
+ }
658
+ }
659
+ }
660
+
627
661
  return { errors, warnings };
628
662
  }
629
663
 
@@ -1,4 +1,10 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { isOperationalPath } from './repo-observer.js';
2
8
 
3
9
  export const DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS = 30_000;
4
10
 
@@ -22,10 +28,15 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
22
28
  return payload;
23
29
  }
24
30
 
25
- payload.commands = machineEvidence.map((entry, index) => replayEvidenceCommand(root, entry, index, timeoutMs));
26
- payload.replayed_commands = payload.commands.length;
27
- payload.matched_commands = payload.commands.filter((entry) => entry.matched).length;
28
- payload.overall = payload.commands.every((entry) => entry.matched) ? 'match' : 'mismatch';
31
+ const workspaceGuard = createReplayWorkspaceGuard(root);
32
+ try {
33
+ payload.commands = machineEvidence.map((entry, index) => replayEvidenceCommand(root, entry, index, timeoutMs));
34
+ payload.replayed_commands = payload.commands.length;
35
+ payload.matched_commands = payload.commands.filter((entry) => entry.matched).length;
36
+ payload.overall = payload.commands.every((entry) => entry.matched) ? 'match' : 'mismatch';
37
+ } finally {
38
+ payload.workspace_guard = workspaceGuard.cleanup();
39
+ }
29
40
 
30
41
  return payload;
31
42
  }
@@ -69,3 +80,273 @@ export function summarizeVerificationReplay(payload) {
69
80
  ...(payload.reason ? { reason: payload.reason } : {}),
70
81
  };
71
82
  }
83
+
84
+ export function normalizeVerificationProducedFiles(verification) {
85
+ const artifactFiles = new Set();
86
+ const ignoredFiles = new Set();
87
+ const producedFiles = Array.isArray(verification?.produced_files)
88
+ ? verification.produced_files
89
+ : [];
90
+
91
+ for (const entry of producedFiles) {
92
+ if (!entry || typeof entry !== 'object') {
93
+ continue;
94
+ }
95
+ const path = typeof entry.path === 'string' ? entry.path.trim() : '';
96
+ if (!path || isOperationalPath(path)) {
97
+ continue;
98
+ }
99
+ if (entry.disposition === 'ignore') {
100
+ ignoredFiles.add(path);
101
+ } else {
102
+ artifactFiles.add(path);
103
+ }
104
+ }
105
+
106
+ return {
107
+ artifact_files: [...artifactFiles].sort(),
108
+ ignored_files: [...ignoredFiles].sort(),
109
+ };
110
+ }
111
+
112
+ export function cleanupIgnoredVerificationFiles({ root, baseline, ignoredFiles }) {
113
+ const targetFiles = [...new Set(Array.isArray(ignoredFiles) ? ignoredFiles : [])]
114
+ .filter((filePath) => typeof filePath === 'string' && filePath && !isOperationalPath(filePath))
115
+ .sort();
116
+
117
+ if (targetFiles.length === 0 || !isGitRepo(root)) {
118
+ return { ok: true, restored_files: [], cleanup_error: null };
119
+ }
120
+
121
+ const restoredFiles = [];
122
+ const baselineSnapshot = baseline?.dirty_snapshot && typeof baseline.dirty_snapshot === 'object' && !Array.isArray(baseline.dirty_snapshot)
123
+ ? baseline.dirty_snapshot
124
+ : {};
125
+
126
+ try {
127
+ for (const filePath of targetFiles) {
128
+ const baselineMarker = baselineSnapshot[filePath];
129
+ const currentMarker = getWorkspaceFileMarker(root, filePath);
130
+
131
+ if (baselineMarker && currentMarker !== baselineMarker) {
132
+ return {
133
+ ok: false,
134
+ restored_files: restoredFiles.sort(),
135
+ cleanup_error: `verification.produced_files declared "${filePath}" as ignore, but that path was already dirty at dispatch and cannot be restored safely.`,
136
+ };
137
+ }
138
+
139
+ if (baselineMarker) {
140
+ continue;
141
+ }
142
+
143
+ restoreCleanPath(root, filePath);
144
+ restoredFiles.push(filePath);
145
+ }
146
+
147
+ const remainingDirty = new Set(collectDirtyActorFiles(root));
148
+ const stranded = targetFiles.filter((filePath) => remainingDirty.has(filePath));
149
+ if (stranded.length > 0) {
150
+ return {
151
+ ok: false,
152
+ restored_files: restoredFiles.sort(),
153
+ cleanup_error: `Ignored verification-produced files remain dirty after cleanup: ${stranded.join(', ')}`,
154
+ };
155
+ }
156
+
157
+ return {
158
+ ok: true,
159
+ restored_files: restoredFiles.sort(),
160
+ cleanup_error: null,
161
+ };
162
+ } catch (err) {
163
+ return {
164
+ ok: false,
165
+ restored_files: restoredFiles.sort(),
166
+ cleanup_error: err?.message || String(err),
167
+ };
168
+ }
169
+ }
170
+
171
+ function createReplayWorkspaceGuard(root) {
172
+ if (!isGitRepo(root)) {
173
+ return {
174
+ cleanup() {
175
+ return { ok: true, restored_files: [], cleanup_error: null };
176
+ },
177
+ };
178
+ }
179
+
180
+ const backupDir = mkdtempSync(join(tmpdir(), 'axc-verification-replay-'));
181
+ const beforeState = captureDirtyWorkspaceState(root, backupDir);
182
+
183
+ return {
184
+ cleanup() {
185
+ const restoredFiles = new Set();
186
+
187
+ try {
188
+ const afterFiles = collectDirtyActorFiles(root);
189
+ const candidateFiles = new Set([...beforeState.files.keys(), ...afterFiles]);
190
+
191
+ for (const filePath of candidateFiles) {
192
+ const beforeInfo = beforeState.files.get(filePath);
193
+ const currentMarker = getWorkspaceFileMarker(root, filePath);
194
+
195
+ if (beforeInfo) {
196
+ if (currentMarker !== beforeInfo.marker) {
197
+ restorePreReplayPath(root, filePath, beforeInfo);
198
+ restoredFiles.add(filePath);
199
+ }
200
+ continue;
201
+ }
202
+
203
+ restoreCleanPath(root, filePath);
204
+ restoredFiles.add(filePath);
205
+ }
206
+
207
+ const remainingDrift = detectWorkspaceDrift(root, beforeState.files);
208
+ if (remainingDrift.length > 0) {
209
+ return {
210
+ ok: false,
211
+ restored_files: [...restoredFiles].sort(),
212
+ cleanup_error: `Verification replay left actor-owned workspace drift after cleanup: ${remainingDrift.join(', ')}`,
213
+ };
214
+ }
215
+
216
+ return {
217
+ ok: true,
218
+ restored_files: [...restoredFiles].sort(),
219
+ cleanup_error: null,
220
+ };
221
+ } catch (err) {
222
+ return {
223
+ ok: false,
224
+ restored_files: [...restoredFiles].sort(),
225
+ cleanup_error: err?.message || String(err),
226
+ };
227
+ } finally {
228
+ rmSync(backupDir, { recursive: true, force: true });
229
+ }
230
+ },
231
+ };
232
+ }
233
+
234
+ function captureDirtyWorkspaceState(root, backupDir) {
235
+ const files = new Map();
236
+ for (const filePath of collectDirtyActorFiles(root)) {
237
+ const absPath = join(root, filePath);
238
+ const backupPath = join(backupDir, filePath);
239
+ const existed = existsSync(absPath);
240
+ const marker = getWorkspaceFileMarker(root, filePath);
241
+
242
+ if (existed) {
243
+ mkdirSync(dirname(backupPath), { recursive: true });
244
+ cpSync(absPath, backupPath, { force: true, recursive: true });
245
+ }
246
+
247
+ files.set(filePath, { existed, marker, backupPath });
248
+ }
249
+ return { files };
250
+ }
251
+
252
+ function detectWorkspaceDrift(root, beforeFiles) {
253
+ const afterFiles = collectDirtyActorFiles(root);
254
+ const candidates = new Set([...beforeFiles.keys(), ...afterFiles]);
255
+ const drift = [];
256
+
257
+ for (const filePath of candidates) {
258
+ const beforeInfo = beforeFiles.get(filePath);
259
+ const currentMarker = getWorkspaceFileMarker(root, filePath);
260
+ if (beforeInfo) {
261
+ if (currentMarker !== beforeInfo.marker) {
262
+ drift.push(filePath);
263
+ }
264
+ continue;
265
+ }
266
+ drift.push(filePath);
267
+ }
268
+
269
+ return drift.sort();
270
+ }
271
+
272
+ function restorePreReplayPath(root, filePath, beforeInfo) {
273
+ const absPath = join(root, filePath);
274
+ if (!beforeInfo.existed) {
275
+ rmSync(absPath, { recursive: true, force: true });
276
+ return;
277
+ }
278
+
279
+ rmSync(absPath, { recursive: true, force: true });
280
+ mkdirSync(dirname(absPath), { recursive: true });
281
+ cpSync(beforeInfo.backupPath, absPath, { force: true, recursive: true });
282
+ }
283
+
284
+ function restoreCleanPath(root, filePath) {
285
+ const absPath = join(root, filePath);
286
+ if (isTrackedPath(root, filePath)) {
287
+ execFileSync('git', ['restore', '--source=HEAD', '--staged', '--worktree', '--', filePath], {
288
+ cwd: root,
289
+ stdio: ['ignore', 'pipe', 'pipe'],
290
+ });
291
+ return;
292
+ }
293
+
294
+ rmSync(absPath, { recursive: true, force: true });
295
+ }
296
+
297
+ function collectDirtyActorFiles(root) {
298
+ return [...new Set([
299
+ ...readGitLines(root, ['diff', '--name-only', 'HEAD']),
300
+ ...readGitLines(root, ['diff', '--name-only', '--cached']),
301
+ ...readGitLines(root, ['ls-files', '--others', '--exclude-standard']),
302
+ ])]
303
+ .filter((filePath) => filePath && !isOperationalPath(filePath))
304
+ .sort();
305
+ }
306
+
307
+ function readGitLines(root, args) {
308
+ try {
309
+ const output = execFileSync('git', args, {
310
+ cwd: root,
311
+ encoding: 'utf8',
312
+ stdio: ['ignore', 'pipe', 'pipe'],
313
+ }).trim();
314
+ return output ? output.split('\n').filter(Boolean) : [];
315
+ } catch {
316
+ return [];
317
+ }
318
+ }
319
+
320
+ function isTrackedPath(root, filePath) {
321
+ try {
322
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', filePath], {
323
+ cwd: root,
324
+ stdio: ['ignore', 'pipe', 'pipe'],
325
+ });
326
+ return true;
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ function isGitRepo(root) {
333
+ try {
334
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
335
+ cwd: root,
336
+ stdio: ['ignore', 'pipe', 'pipe'],
337
+ });
338
+ return true;
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ function getWorkspaceFileMarker(root, filePath) {
345
+ const absPath = join(root, filePath);
346
+ if (!existsSync(absPath)) {
347
+ return '__deleted__';
348
+ }
349
+
350
+ const content = readFileSync(absPath);
351
+ return createHash('sha1').update(content).digest('hex');
352
+ }