agentxchain 2.141.1 → 2.143.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.141.1",
3
+ "version": "2.143.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,12 +42,10 @@ function updateSidebarPosition(content, nextPosition) {
42
42
  const body = content.slice(frontmatterEnd + 5);
43
43
  const expectedLine = `sidebar_position: ${nextPosition}`;
44
44
 
45
- let updatedFrontmatter;
46
- if (/^sidebar_position:\s*\d+\s*$/m.test(frontmatter)) {
47
- updatedFrontmatter = frontmatter.replace(/^sidebar_position:\s*\d+\s*$/m, expectedLine);
48
- } else {
49
- updatedFrontmatter = frontmatter.replace(/^---\n/, `---\n${expectedLine}\n`);
50
- }
45
+ // Strip ALL existing sidebar_position lines (handles duplicates from manual edits)
46
+ let strippedFrontmatter = frontmatter.replace(/^sidebar_position:\s*-?\d+\s*\n/gm, '');
47
+ // Insert the canonical position after the opening delimiter
48
+ let updatedFrontmatter = strippedFrontmatter.replace(/^---\n/, `---\n${expectedLine}\n`);
51
49
 
52
50
  const updated = updatedFrontmatter + body;
53
51
  return {
package/src/lib/export.js CHANGED
@@ -6,6 +6,10 @@ import { join, relative, resolve } from 'node:path';
6
6
  import { loadProjectContext, loadProjectState } from './config.js';
7
7
  import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
8
8
  import { loadCoordinatorState } from './coordinator-state.js';
9
+ import {
10
+ RUN_CONTINUITY_DIRECTORY_ROOTS,
11
+ RUN_CONTINUITY_STATE_FILES,
12
+ } from './repo-observer.js';
9
13
  import { normalizeRunProvenance } from './run-provenance.js';
10
14
  import { getDashboardPid, getDashboardSession } from '../commands/dashboard.js';
11
15
  import { readRepoDecisions, summarizeRepoDecisions } from './repo-decisions.js';
@@ -23,62 +27,24 @@ const COORDINATOR_INCLUDED_ROOTS = [
23
27
  '.agentxchain/multirepo/RECOVERY_REPORT.md',
24
28
  ];
25
29
 
26
- export const RUN_EXPORT_INCLUDED_ROOTS = [
30
+ const RUN_EXPORT_ONLY_ROOTS = [
27
31
  'agentxchain.json',
28
- 'TALK.md',
29
32
  '.agentxchain-dashboard.pid',
30
33
  '.agentxchain-dashboard.json',
31
- '.agentxchain/state.json',
32
- '.agentxchain/session.json',
33
- '.agentxchain/history.jsonl',
34
- '.agentxchain/decision-ledger.jsonl',
35
- '.agentxchain/repo-decisions.jsonl',
36
- '.agentxchain/hook-audit.jsonl',
37
- '.agentxchain/hook-annotations.jsonl',
38
- '.agentxchain/notification-audit.jsonl',
39
- '.agentxchain/run-history.jsonl',
40
- '.agentxchain/events.jsonl',
41
- '.agentxchain/schedule-state.json',
42
- '.agentxchain/schedule-daemon.json',
43
- '.agentxchain/continuous-session.json',
44
- '.agentxchain/human-escalations.jsonl',
45
- '.agentxchain/sla-reminders.json',
46
- '.agentxchain/dispatch',
47
- '.agentxchain/staging',
48
- '.agentxchain/transactions/accept',
49
- '.agentxchain/intake',
50
- '.agentxchain/multirepo',
51
- '.agentxchain/reviews',
52
- '.agentxchain/proposed',
53
- '.agentxchain/reports',
54
34
  '.planning',
55
35
  ];
56
36
 
37
+ export const RUN_EXPORT_INCLUDED_ROOTS = [
38
+ 'agentxchain.json',
39
+ ...RUN_CONTINUITY_STATE_FILES,
40
+ ...RUN_CONTINUITY_DIRECTORY_ROOTS,
41
+ ...RUN_EXPORT_ONLY_ROOTS.filter((root) => root !== 'agentxchain.json'),
42
+ ];
43
+
57
44
  export const RUN_RESTORE_ROOTS = [
58
45
  'agentxchain.json',
59
- 'TALK.md',
60
- '.agentxchain/state.json',
61
- '.agentxchain/session.json',
62
- '.agentxchain/history.jsonl',
63
- '.agentxchain/decision-ledger.jsonl',
64
- '.agentxchain/hook-audit.jsonl',
65
- '.agentxchain/hook-annotations.jsonl',
66
- '.agentxchain/notification-audit.jsonl',
67
- '.agentxchain/run-history.jsonl',
68
- '.agentxchain/events.jsonl',
69
- '.agentxchain/schedule-state.json',
70
- '.agentxchain/schedule-daemon.json',
71
- '.agentxchain/continuous-session.json',
72
- '.agentxchain/human-escalations.jsonl',
73
- '.agentxchain/sla-reminders.json',
74
- '.agentxchain/dispatch',
75
- '.agentxchain/staging',
76
- '.agentxchain/transactions/accept',
77
- '.agentxchain/intake',
78
- '.agentxchain/multirepo',
79
- '.agentxchain/reviews',
80
- '.agentxchain/proposed',
81
- '.agentxchain/reports',
46
+ ...RUN_CONTINUITY_STATE_FILES,
47
+ ...RUN_CONTINUITY_DIRECTORY_ROOTS,
82
48
  '.planning',
83
49
  ];
84
50
 
@@ -36,8 +36,10 @@ import {
36
36
  compareDeclaredVsObserved,
37
37
  deriveAcceptedRef,
38
38
  checkCleanBaseline,
39
+ detectDirtyFilesOutsideAllowed,
39
40
  isOperationalPath,
40
41
  isBaselineExemptPath,
42
+ normalizeCheckpointableFiles,
41
43
  } from './repo-observer.js';
42
44
  import { getMaxConcurrentTurns } from './normalized-config.js';
43
45
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
@@ -65,6 +67,8 @@ import {
65
67
  } from './repo-decisions.js';
66
68
  import {
67
69
  replayVerificationMachineEvidence,
70
+ normalizeVerificationProducedFiles,
71
+ cleanupIgnoredVerificationFiles,
68
72
  summarizeVerificationReplay,
69
73
  } from './verification-replay.js';
70
74
  import { executeGateActions } from './gate-actions.js';
@@ -3205,7 +3209,16 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3205
3209
  };
3206
3210
  }
3207
3211
 
3208
- 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
+ };
3209
3222
 
3210
3223
  // Validate cross-run decision overrides against repo-decisions.jsonl
3211
3224
  if (turnResult.decisions && turnResult.decisions.length > 0) {
@@ -3231,6 +3244,35 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3231
3244
  const stagingFile = join(root, resolvedStagingPath);
3232
3245
  const now = new Date().toISOString();
3233
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
+ }
3234
3276
  const rawObservation = observeChanges(root, baseline);
3235
3277
  const historyEntries = readJsonlEntries(root, HISTORY_PATH);
3236
3278
  const pendingConcurrentSiblingDeclarations = collectPendingConcurrentSiblingDeclarations(
@@ -3268,9 +3310,76 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3268
3310
  const concurrentIds = new Set(
3269
3311
  Array.isArray(currentTurn.concurrent_with) ? currentTurn.concurrent_with : [],
3270
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
+ }
3271
3333
  const acceptedTurnIds = new Set(historyEntries.map(h => h.turn_id));
3272
3334
  const hasUnacceptedConcurrentSiblings = [...concurrentIds].some(id => !acceptedTurnIds.has(id));
3273
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
+
3274
3383
  const diffComparison = compareDeclaredVsObserved(
3275
3384
  turnResult.files_changed || [],
3276
3385
  observation.files_changed,
@@ -3433,6 +3542,42 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3433
3542
  const observedArtifact = buildObservedArtifact(observation, baseline);
3434
3543
  const normalizedVerification = normalizeVerification(turnResult.verification, runtimeType);
3435
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
+ // proposed turns cannot use workspace artifacts (validator rejects earlier).
3551
+ if (artifactType === 'workspace'
3552
+ && writeAuthority === 'authoritative'
3553
+ && turnResult.status === 'completed'
3554
+ && (turnResult.files_changed || []).length === 0
3555
+ && (observation.files_changed || []).length === 0) {
3556
+ const emptyWsError = 'Turn declared artifact.type: "workspace" but files_changed is empty. '
3557
+ + 'Either declare the files modified, or set artifact.type: "review" if no repo mutations were intended.';
3558
+ transitionToFailedAcceptance(root, state, currentTurn, emptyWsError, {
3559
+ error_code: 'empty_workspace_artifact',
3560
+ stage: 'artifact_validation',
3561
+ extra: {
3562
+ artifact_type: artifactType,
3563
+ write_authority: writeAuthority,
3564
+ turn_status: turnResult.status,
3565
+ },
3566
+ });
3567
+ return {
3568
+ ok: false,
3569
+ error: emptyWsError,
3570
+ validation: {
3571
+ ...validation,
3572
+ ok: false,
3573
+ stage: 'artifact_validation',
3574
+ error_class: 'artifact_error',
3575
+ errors: [emptyWsError],
3576
+ warnings: validation.warnings,
3577
+ },
3578
+ };
3579
+ }
3580
+
3436
3581
  const derivedRef = deriveAcceptedRef(observation, artifactType, state.accepted_integration_ref);
3437
3582
  const verificationReplay = (config.policies || []).some((policy) => policy?.rule === 'require_reproducible_verification')
3438
3583
  ? replayVerificationMachineEvidence({ root, verification: turnResult.verification })
@@ -3740,7 +3885,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3740
3885
  summary: turnResult.summary,
3741
3886
  decisions: turnResult.decisions || [],
3742
3887
  objections: turnResult.objections || [],
3743
- files_changed: turnResult.files_changed || [],
3888
+ files_changed: normalizeCheckpointableFiles(turnResult.files_changed),
3744
3889
  artifacts_created: turnResult.artifacts_created || [],
3745
3890
  verification: turnResult.verification || {},
3746
3891
  normalized_verification: normalizedVerification,
@@ -22,20 +22,21 @@ import { join } from 'path';
22
22
  // They must never be attributed to agents in observation or baseline checks.
23
23
  // Frozen per Session #19 decision.
24
24
 
25
- const OPERATIONAL_PATH_PREFIXES = [
25
+ export const OPERATIONAL_PATH_PREFIXES = Object.freeze([
26
26
  '.agentxchain/dispatch/',
27
- '.agentxchain/dispatch-progress-',
27
+ '.agentxchain/dispatch-progress',
28
28
  '.agentxchain/staging/',
29
29
  '.agentxchain/intake/',
30
30
  '.agentxchain/locks/',
31
31
  '.agentxchain/transactions/',
32
32
  '.agentxchain/missions/',
33
33
  '.agentxchain/multirepo/',
34
- ];
34
+ '.agentxchain/prompts/',
35
+ ]);
35
36
 
36
37
  // Orchestrator-owned state files that agents must never be blamed for modifying.
37
38
  // These are written exclusively by the orchestrator (§4.1 State Ownership Rule).
38
- const ORCHESTRATOR_STATE_FILES = [
39
+ export const ORCHESTRATOR_STATE_FILES = Object.freeze([
39
40
  '.agentxchain/state.json',
40
41
  '.agentxchain/session.json',
41
42
  '.agentxchain/history.jsonl',
@@ -52,18 +53,36 @@ const ORCHESTRATOR_STATE_FILES = [
52
53
  '.agentxchain/continuous-session.json',
53
54
  '.agentxchain/human-escalations.jsonl',
54
55
  '.agentxchain/sla-reminders.json',
56
+ '.agentxchain/SESSION_RECOVERY.md',
57
+ '.agentxchain/migration-report.md',
55
58
  'TALK.md',
56
59
  'HUMAN_TASKS.md',
57
- ];
60
+ ]);
58
61
 
59
62
  // Evidence paths may legitimately remain dirty across turns without blocking the
60
63
  // next code-writing assignment. They still remain actor-observable so review
61
64
  // accountability is preserved during acceptance.
62
- const BASELINE_EXEMPT_PATH_PREFIXES = [
65
+ export const BASELINE_EXEMPT_PATH_PREFIXES = Object.freeze([
63
66
  '.agentxchain/reviews/',
64
67
  '.agentxchain/reports/',
65
68
  '.agentxchain/proposed/',
66
- ];
69
+ ]);
70
+
71
+ // Continuity export/restore must stay aligned with orchestrator ownership,
72
+ // but only for the subset that represents governed run state.
73
+ export const RUN_CONTINUITY_STATE_FILES = Object.freeze([
74
+ ...ORCHESTRATOR_STATE_FILES.filter((filePath) => filePath !== 'HUMAN_TASKS.md'),
75
+ ]);
76
+
77
+ export const RUN_CONTINUITY_DIRECTORY_ROOTS = Object.freeze([
78
+ '.agentxchain/dispatch',
79
+ '.agentxchain/staging',
80
+ '.agentxchain/transactions/accept',
81
+ '.agentxchain/intake',
82
+ '.agentxchain/missions',
83
+ '.agentxchain/multirepo',
84
+ ...BASELINE_EXEMPT_PATH_PREFIXES.map((prefix) => prefix.replace(/\/$/, '')),
85
+ ]);
67
86
 
68
87
  /**
69
88
  * Check whether a file path belongs to orchestrator-owned operational state.
@@ -79,6 +98,16 @@ export function isBaselineExemptPath(filePath) {
79
98
  || BASELINE_EXEMPT_PATH_PREFIXES.some(prefix => filePath.startsWith(prefix));
80
99
  }
81
100
 
101
+ export function normalizeCheckpointableFiles(filesChanged) {
102
+ return [...new Set(
103
+ (Array.isArray(filesChanged) ? filesChanged : [])
104
+ .filter((value) => typeof value === 'string')
105
+ .map((value) => value.trim())
106
+ .filter(Boolean)
107
+ .filter((value) => !isOperationalPath(value)),
108
+ )];
109
+ }
110
+
82
111
  // ── Baseline Capture ────────────────────────────────────────────────────────
83
112
 
84
113
  /**
@@ -631,6 +660,43 @@ export function checkCleanBaseline(root, writeAuthority) {
631
660
  };
632
661
  }
633
662
 
663
+ export function detectDirtyFilesOutsideAllowed(root, writeAuthority, allowedFiles = []) {
664
+ const cleanCheck = checkCleanBaseline(root, writeAuthority);
665
+ if (cleanCheck.clean) {
666
+ return {
667
+ clean: true,
668
+ dirty_files: [],
669
+ unexpected_dirty_files: [],
670
+ reason: null,
671
+ };
672
+ }
673
+
674
+ const allowedSet = new Set(
675
+ (Array.isArray(allowedFiles) ? allowedFiles : [])
676
+ .filter((value) => typeof value === 'string')
677
+ .map((value) => value.trim())
678
+ .filter(Boolean),
679
+ );
680
+ const dirtyFiles = Array.isArray(cleanCheck.dirty_files) ? cleanCheck.dirty_files : [];
681
+ const unexpectedDirtyFiles = dirtyFiles.filter((filePath) => !allowedSet.has(filePath));
682
+
683
+ if (unexpectedDirtyFiles.length === 0) {
684
+ return {
685
+ clean: true,
686
+ dirty_files: dirtyFiles,
687
+ unexpected_dirty_files: [],
688
+ reason: null,
689
+ };
690
+ }
691
+
692
+ return {
693
+ clean: false,
694
+ dirty_files: dirtyFiles,
695
+ unexpected_dirty_files: unexpectedDirtyFiles,
696
+ 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.`,
697
+ };
698
+ }
699
+
634
700
  // ── Git Primitives ──────────────────────────────────────────────────────────
635
701
 
636
702
  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
 
@@ -81,6 +81,93 @@ export function summarizeVerificationReplay(payload) {
81
81
  };
82
82
  }
83
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
+
84
171
  function createReplayWorkspaceGuard(root) {
85
172
  if (!isGitRepo(root)) {
86
173
  return {