agentxchain 2.139.0 → 2.141.1

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.
@@ -1031,6 +1031,7 @@ intakeCmd
1031
1031
  .command('resolve')
1032
1032
  .description('Resolve an executing intent by reading the governed run outcome')
1033
1033
  .option('--intent <id>', 'Intent ID to resolve')
1034
+ .option('--outcome <status>', 'Force transition to this status (e.g., "completed")')
1034
1035
  .option('-j, --json', 'Output as JSON')
1035
1036
  .action(intakeResolveCommand);
1036
1037
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.139.0",
3
+ "version": "2.141.1",
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
@@ -15,7 +15,11 @@ export async function intakeResolveCommand(opts) {
15
15
  process.exit(1);
16
16
  }
17
17
 
18
- const result = resolveIntent(root, opts.intent);
18
+ const resolveOpts = {};
19
+ if (opts.outcome) {
20
+ resolveOpts.outcome = opts.outcome;
21
+ }
22
+ const result = resolveIntent(root, opts.intent, resolveOpts);
19
23
 
20
24
  if (opts.json) {
21
25
  console.log(JSON.stringify(result, null, 2));
@@ -37,6 +37,7 @@ import {
37
37
  deriveAcceptedRef,
38
38
  checkCleanBaseline,
39
39
  isOperationalPath,
40
+ isBaselineExemptPath,
40
41
  } from './repo-observer.js';
41
42
  import { getMaxConcurrentTurns } from './normalized-config.js';
42
43
  import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
@@ -106,6 +107,34 @@ function buildInitialPhaseGateStatus(config) {
106
107
  );
107
108
  }
108
109
 
110
+ function buildObservationDrift(beforeObservation, afterObservation) {
111
+ const beforeFiles = (Array.isArray(beforeObservation?.files_changed) ? beforeObservation.files_changed : [])
112
+ .filter((filePath) => !isBaselineExemptPath(filePath));
113
+ const afterFiles = (Array.isArray(afterObservation?.files_changed) ? afterObservation.files_changed : [])
114
+ .filter((filePath) => !isBaselineExemptPath(filePath));
115
+ const beforeSet = new Set(beforeFiles);
116
+ const afterSet = new Set(afterFiles);
117
+ const beforeMarkers = beforeObservation?.file_markers && typeof beforeObservation.file_markers === 'object'
118
+ ? beforeObservation.file_markers
119
+ : {};
120
+ const afterMarkers = afterObservation?.file_markers && typeof afterObservation.file_markers === 'object'
121
+ ? afterObservation.file_markers
122
+ : {};
123
+
124
+ const added = afterFiles.filter((filePath) => !beforeSet.has(filePath));
125
+ const removed = beforeFiles.filter((filePath) => !afterSet.has(filePath));
126
+ const markerChanged = beforeFiles.filter((filePath) => (
127
+ afterSet.has(filePath) && beforeMarkers[filePath] !== afterMarkers[filePath]
128
+ ));
129
+
130
+ return {
131
+ added,
132
+ removed,
133
+ marker_changed: markerChanged,
134
+ drifted_files: [...new Set([...added, ...removed, ...markerChanged])].sort(),
135
+ };
136
+ }
137
+
109
138
  function listIntakeIntentFiles(root) {
110
139
  const intentsDir = join(root, INTAKE_INTENTS_DIR);
111
140
  if (!existsSync(intentsDir)) return [];
@@ -115,6 +144,50 @@ function listIntakeIntentFiles(root) {
115
144
  .map((name) => join(intentsDir, name));
116
145
  }
117
146
 
147
+ // BUG-45: Reconcile embedded intake_context against the live intent file.
148
+ // The embedded copy is historical state; the live intent file is authoritative.
149
+ // If the live intent cannot be read, acceptance must fail closed instead of
150
+ // silently reusing the stale embedded contract.
151
+ const INTENT_TERMINAL_STATES = ['completed', 'satisfied', 'superseded', 'suppressed', 'failed', 'rejected'];
152
+
153
+ function reconcileIntakeContext(root, intakeCtx) {
154
+ if (!intakeCtx || !intakeCtx.intent_id) {
155
+ return { ok: true, intakeCtx };
156
+ }
157
+
158
+ try {
159
+ const intentPath = join(root, INTAKE_INTENTS_DIR, `${intakeCtx.intent_id}.json`);
160
+ if (!existsSync(intentPath)) {
161
+ return {
162
+ ok: false,
163
+ error: `Intent reconciliation failed: live intent ${intakeCtx.intent_id} not found at ${INTAKE_INTENTS_DIR}/${intakeCtx.intent_id}.json`,
164
+ };
165
+ }
166
+
167
+ const liveIntent = JSON.parse(readFileSync(intentPath, 'utf8'));
168
+
169
+ // If the intent has reached a terminal state, skip coverage enforcement
170
+ if (INTENT_TERMINAL_STATES.includes(liveIntent.status)) {
171
+ return { ok: true, intakeCtx: null };
172
+ }
173
+
174
+ // Intent is still active — use the CURRENT acceptance_contract from disk
175
+ if (Array.isArray(liveIntent.acceptance_contract)) {
176
+ return {
177
+ ok: true,
178
+ intakeCtx: { ...intakeCtx, acceptance_contract: liveIntent.acceptance_contract },
179
+ };
180
+ }
181
+
182
+ return { ok: true, intakeCtx };
183
+ } catch (error) {
184
+ return {
185
+ ok: false,
186
+ error: `Intent reconciliation failed: could not read live intent ${intakeCtx.intent_id}: ${error.message}`,
187
+ };
188
+ }
189
+ }
190
+
118
191
  function retireApprovedPhaseScopedIntents(root, state, config, exitedPhase, now) {
119
192
  const retired = [];
120
193
 
@@ -3166,11 +3239,17 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3166
3239
  currentTurn,
3167
3240
  historyEntries,
3168
3241
  );
3169
- const observation = attributeObservedChangesToTurn(rawObservation, currentTurn, historyEntries, {
3242
+ const observationAttributionOptions = {
3170
3243
  currentDeclaredFiles: turnResult.files_changed || [],
3171
3244
  concurrentSiblingIds: pendingConcurrentSiblingDeclarations.map((entry) => entry.turn_id),
3172
3245
  pendingConcurrentSiblingDeclarations,
3173
- });
3246
+ };
3247
+ const observation = attributeObservedChangesToTurn(
3248
+ rawObservation,
3249
+ currentTurn,
3250
+ historyEntries,
3251
+ observationAttributionOptions,
3252
+ );
3174
3253
  const role = config.roles?.[turnResult.role];
3175
3254
  const runtimeId = turnResult.runtime_id;
3176
3255
  const runtime = config.runtimes?.[runtimeId];
@@ -3222,10 +3301,33 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3222
3301
  };
3223
3302
  }
3224
3303
 
3225
- // ── Intent coverage validation (BUG-14) ──────────────────────────────────
3226
- // When a turn is bound to an injected intent, verify the turn result
3227
- // addresses each acceptance item. Default: strict for p0, lenient for others.
3228
- const intakeCtx = currentTurn.intake_context;
3304
+ // ── BUG-45: Reconcile intake_context against live intent state ──────────
3305
+ // The embedded intake_context is a snapshot from dispatch time. The intent
3306
+ // may have since been completed, satisfied, or had its contract updated.
3307
+ // Re-read the live intent file and reconcile before evaluating coverage.
3308
+ const intakeReconciliation = reconcileIntakeContext(root, currentTurn.intake_context);
3309
+ if (!intakeReconciliation.ok) {
3310
+ transitionToFailedAcceptance(root, state, currentTurn, intakeReconciliation.error, {
3311
+ error_code: 'intent_reconciliation_failed',
3312
+ stage: 'intent_reconciliation',
3313
+ extra: {
3314
+ intent_id: currentTurn.intake_context?.intent_id || null,
3315
+ },
3316
+ });
3317
+ return {
3318
+ ok: false,
3319
+ error: intakeReconciliation.error,
3320
+ validation: {
3321
+ ...validation,
3322
+ ok: false,
3323
+ stage: 'intent_reconciliation',
3324
+ error_class: 'intent_reconciliation_error',
3325
+ errors: [intakeReconciliation.error],
3326
+ warnings: validation.warnings,
3327
+ },
3328
+ };
3329
+ }
3330
+ const intakeCtx = intakeReconciliation.intakeCtx;
3229
3331
  if (intakeCtx && Array.isArray(intakeCtx.acceptance_contract) && intakeCtx.acceptance_contract.length > 0) {
3230
3332
  const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx, {
3231
3333
  state,
@@ -3336,6 +3438,30 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3336
3438
  ? replayVerificationMachineEvidence({ root, verification: turnResult.verification })
3337
3439
  : null;
3338
3440
 
3441
+ if (verificationReplay?.workspace_guard?.ok === false) {
3442
+ const replayError = verificationReplay.workspace_guard.cleanup_error
3443
+ || 'Verification replay mutated actor-owned workspace files and cleanup failed.';
3444
+ transitionToFailedAcceptance(root, state, currentTurn, replayError, {
3445
+ error_code: 'verification_replay_drift',
3446
+ stage: 'verification_replay',
3447
+ extra: {
3448
+ replay_side_effect_files: verificationReplay.workspace_guard.restored_files || [],
3449
+ },
3450
+ });
3451
+ return {
3452
+ ok: false,
3453
+ error: replayError,
3454
+ validation: {
3455
+ ...validation,
3456
+ ok: false,
3457
+ stage: 'verification_replay',
3458
+ error_class: 'verification_replay_error',
3459
+ errors: [replayError],
3460
+ warnings: validation.warnings,
3461
+ },
3462
+ };
3463
+ }
3464
+
3339
3465
  // Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
3340
3466
  const policyResult = evaluatePolicies(config.policies || [], {
3341
3467
  currentPhase: state.phase,
@@ -3571,6 +3697,38 @@ function _acceptGovernedTurnLocked(root, config, opts) {
3571
3697
  }
3572
3698
  }
3573
3699
 
3700
+ const finalObservation = attributeObservedChangesToTurn(
3701
+ observeChanges(root, baseline),
3702
+ currentTurn,
3703
+ historyEntries,
3704
+ observationAttributionOptions,
3705
+ );
3706
+ const observationDrift = buildObservationDrift(observation, finalObservation);
3707
+ if (observationDrift.drifted_files.length > 0) {
3708
+ const driftError = `Acceptance mutated actor-owned workspace after artifact observation: ${observationDrift.drifted_files.join(', ')}`;
3709
+ transitionToFailedAcceptance(root, state, currentTurn, driftError, {
3710
+ error_code: 'acceptance_drift',
3711
+ stage: 'artifact_observation',
3712
+ extra: {
3713
+ added_files: observationDrift.added,
3714
+ removed_files: observationDrift.removed,
3715
+ marker_changed_files: observationDrift.marker_changed,
3716
+ },
3717
+ });
3718
+ return {
3719
+ ok: false,
3720
+ error: driftError,
3721
+ validation: {
3722
+ ...validation,
3723
+ ok: false,
3724
+ stage: 'artifact_observation',
3725
+ error_class: 'artifact_error',
3726
+ errors: [driftError],
3727
+ warnings: validation.warnings,
3728
+ },
3729
+ };
3730
+ }
3731
+
3574
3732
  const acceptedSequence = (state.turn_sequence || 0) + 1;
3575
3733
  const historyEntry = {
3576
3734
  turn_id: turnResult.turn_id,
package/src/lib/intake.js CHANGED
@@ -1270,7 +1270,7 @@ export function handoffIntent(root, intentId, options = {}) {
1270
1270
  // Resolve — execution exit and intent closure linkage (V3-S5)
1271
1271
  // ---------------------------------------------------------------------------
1272
1272
 
1273
- export function resolveIntent(root, intentId) {
1273
+ export function resolveIntent(root, intentId, opts = {}) {
1274
1274
  const loadedIntent = readIntent(root, intentId);
1275
1275
  if (!loadedIntent.ok) {
1276
1276
  return loadedIntent;
@@ -1298,6 +1298,38 @@ export function resolveIntent(root, intentId) {
1298
1298
  };
1299
1299
  }
1300
1300
 
1301
+ // BUG-45: operator-forced completion — transition executing → completed
1302
+ // when the operator knows the work is done but the framework hasn't retired
1303
+ // the intent automatically (e.g., retained-turn deadlock recovery).
1304
+ if (opts.outcome === 'completed' && intent.status === 'executing') {
1305
+ const now = nowISO();
1306
+ const previousStatus = intent.status;
1307
+ intent.status = 'completed';
1308
+ intent.completed_at = now;
1309
+ intent.updated_at = now;
1310
+ if (!Array.isArray(intent.history)) intent.history = [];
1311
+ intent.history.push({
1312
+ from: previousStatus,
1313
+ to: 'completed',
1314
+ at: now,
1315
+ reason: opts.reason || 'operator-resolved: intent marked completed via intake resolve --outcome completed',
1316
+ });
1317
+
1318
+ const obsDir = join(dirs.base, 'observations', intent.intent_id);
1319
+ mkdirSync(obsDir, { recursive: true });
1320
+
1321
+ safeWriteJson(intentPath, intent);
1322
+ return {
1323
+ ok: true,
1324
+ intent,
1325
+ previous_status: previousStatus,
1326
+ new_status: 'completed',
1327
+ run_outcome: 'completed',
1328
+ no_change: false,
1329
+ exitCode: 0,
1330
+ };
1331
+ }
1332
+
1301
1333
  if (intent.target_workstream) {
1302
1334
  return resolveCoordinatorBackedIntent(root, intentPath, dirs, intent);
1303
1335
  }
@@ -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',
@@ -50,6 +53,7 @@ const ORCHESTRATOR_STATE_FILES = [
50
53
  '.agentxchain/human-escalations.jsonl',
51
54
  '.agentxchain/sla-reminders.json',
52
55
  'TALK.md',
56
+ 'HUMAN_TASKS.md',
53
57
  ];
54
58
 
55
59
  // Evidence paths may legitimately remain dirty across turns without blocking the
@@ -70,7 +74,7 @@ export function isOperationalPath(filePath) {
70
74
  || ORCHESTRATOR_STATE_FILES.includes(filePath);
71
75
  }
72
76
 
73
- function isBaselineExemptPath(filePath) {
77
+ export function isBaselineExemptPath(filePath) {
74
78
  return isOperationalPath(filePath)
75
79
  || BASELINE_EXEMPT_PATH_PREFIXES.some(prefix => filePath.startsWith(prefix));
76
80
  }
@@ -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,186 @@ export function summarizeVerificationReplay(payload) {
69
80
  ...(payload.reason ? { reason: payload.reason } : {}),
70
81
  };
71
82
  }
83
+
84
+ function createReplayWorkspaceGuard(root) {
85
+ if (!isGitRepo(root)) {
86
+ return {
87
+ cleanup() {
88
+ return { ok: true, restored_files: [], cleanup_error: null };
89
+ },
90
+ };
91
+ }
92
+
93
+ const backupDir = mkdtempSync(join(tmpdir(), 'axc-verification-replay-'));
94
+ const beforeState = captureDirtyWorkspaceState(root, backupDir);
95
+
96
+ return {
97
+ cleanup() {
98
+ const restoredFiles = new Set();
99
+
100
+ try {
101
+ const afterFiles = collectDirtyActorFiles(root);
102
+ const candidateFiles = new Set([...beforeState.files.keys(), ...afterFiles]);
103
+
104
+ for (const filePath of candidateFiles) {
105
+ const beforeInfo = beforeState.files.get(filePath);
106
+ const currentMarker = getWorkspaceFileMarker(root, filePath);
107
+
108
+ if (beforeInfo) {
109
+ if (currentMarker !== beforeInfo.marker) {
110
+ restorePreReplayPath(root, filePath, beforeInfo);
111
+ restoredFiles.add(filePath);
112
+ }
113
+ continue;
114
+ }
115
+
116
+ restoreCleanPath(root, filePath);
117
+ restoredFiles.add(filePath);
118
+ }
119
+
120
+ const remainingDrift = detectWorkspaceDrift(root, beforeState.files);
121
+ if (remainingDrift.length > 0) {
122
+ return {
123
+ ok: false,
124
+ restored_files: [...restoredFiles].sort(),
125
+ cleanup_error: `Verification replay left actor-owned workspace drift after cleanup: ${remainingDrift.join(', ')}`,
126
+ };
127
+ }
128
+
129
+ return {
130
+ ok: true,
131
+ restored_files: [...restoredFiles].sort(),
132
+ cleanup_error: null,
133
+ };
134
+ } catch (err) {
135
+ return {
136
+ ok: false,
137
+ restored_files: [...restoredFiles].sort(),
138
+ cleanup_error: err?.message || String(err),
139
+ };
140
+ } finally {
141
+ rmSync(backupDir, { recursive: true, force: true });
142
+ }
143
+ },
144
+ };
145
+ }
146
+
147
+ function captureDirtyWorkspaceState(root, backupDir) {
148
+ const files = new Map();
149
+ for (const filePath of collectDirtyActorFiles(root)) {
150
+ const absPath = join(root, filePath);
151
+ const backupPath = join(backupDir, filePath);
152
+ const existed = existsSync(absPath);
153
+ const marker = getWorkspaceFileMarker(root, filePath);
154
+
155
+ if (existed) {
156
+ mkdirSync(dirname(backupPath), { recursive: true });
157
+ cpSync(absPath, backupPath, { force: true, recursive: true });
158
+ }
159
+
160
+ files.set(filePath, { existed, marker, backupPath });
161
+ }
162
+ return { files };
163
+ }
164
+
165
+ function detectWorkspaceDrift(root, beforeFiles) {
166
+ const afterFiles = collectDirtyActorFiles(root);
167
+ const candidates = new Set([...beforeFiles.keys(), ...afterFiles]);
168
+ const drift = [];
169
+
170
+ for (const filePath of candidates) {
171
+ const beforeInfo = beforeFiles.get(filePath);
172
+ const currentMarker = getWorkspaceFileMarker(root, filePath);
173
+ if (beforeInfo) {
174
+ if (currentMarker !== beforeInfo.marker) {
175
+ drift.push(filePath);
176
+ }
177
+ continue;
178
+ }
179
+ drift.push(filePath);
180
+ }
181
+
182
+ return drift.sort();
183
+ }
184
+
185
+ function restorePreReplayPath(root, filePath, beforeInfo) {
186
+ const absPath = join(root, filePath);
187
+ if (!beforeInfo.existed) {
188
+ rmSync(absPath, { recursive: true, force: true });
189
+ return;
190
+ }
191
+
192
+ rmSync(absPath, { recursive: true, force: true });
193
+ mkdirSync(dirname(absPath), { recursive: true });
194
+ cpSync(beforeInfo.backupPath, absPath, { force: true, recursive: true });
195
+ }
196
+
197
+ function restoreCleanPath(root, filePath) {
198
+ const absPath = join(root, filePath);
199
+ if (isTrackedPath(root, filePath)) {
200
+ execFileSync('git', ['restore', '--source=HEAD', '--staged', '--worktree', '--', filePath], {
201
+ cwd: root,
202
+ stdio: ['ignore', 'pipe', 'pipe'],
203
+ });
204
+ return;
205
+ }
206
+
207
+ rmSync(absPath, { recursive: true, force: true });
208
+ }
209
+
210
+ function collectDirtyActorFiles(root) {
211
+ return [...new Set([
212
+ ...readGitLines(root, ['diff', '--name-only', 'HEAD']),
213
+ ...readGitLines(root, ['diff', '--name-only', '--cached']),
214
+ ...readGitLines(root, ['ls-files', '--others', '--exclude-standard']),
215
+ ])]
216
+ .filter((filePath) => filePath && !isOperationalPath(filePath))
217
+ .sort();
218
+ }
219
+
220
+ function readGitLines(root, args) {
221
+ try {
222
+ const output = execFileSync('git', args, {
223
+ cwd: root,
224
+ encoding: 'utf8',
225
+ stdio: ['ignore', 'pipe', 'pipe'],
226
+ }).trim();
227
+ return output ? output.split('\n').filter(Boolean) : [];
228
+ } catch {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ function isTrackedPath(root, filePath) {
234
+ try {
235
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', filePath], {
236
+ cwd: root,
237
+ stdio: ['ignore', 'pipe', 'pipe'],
238
+ });
239
+ return true;
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ function isGitRepo(root) {
246
+ try {
247
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
248
+ cwd: root,
249
+ stdio: ['ignore', 'pipe', 'pipe'],
250
+ });
251
+ return true;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+
257
+ function getWorkspaceFileMarker(root, filePath) {
258
+ const absPath = join(root, filePath);
259
+ if (!existsSync(absPath)) {
260
+ return '__deleted__';
261
+ }
262
+
263
+ const content = readFileSync(absPath);
264
+ return createHash('sha1').update(content).digest('hex');
265
+ }