@triflux/remote 10.50.2 → 10.52.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.
Files changed (134) hide show
  1. package/cto/brief.mjs +0 -20
  2. package/cto/collect.mjs +1 -200
  3. package/cto/current.schema.json +2 -23
  4. package/cto/hygiene.mjs +2 -61
  5. package/cto/lake-root.mjs +0 -56
  6. package/cto/status.mjs +5 -187
  7. package/hub/index.mjs +0 -17
  8. package/hub/team/agent-map.json +0 -1
  9. package/hub/team/agents-row-attach.py +120 -0
  10. package/hub/team/agents-row.mjs +200 -0
  11. package/hub/team/ansi.mjs +1 -35
  12. package/hub/team/backend.mjs +9 -145
  13. package/hub/team/claude-daemon-control.mjs +1 -2
  14. package/hub/team/claude-session-projection.mjs +5 -73
  15. package/hub/team/cli/commands/attach.mjs +1 -17
  16. package/hub/team/cli/commands/control.mjs +12 -38
  17. package/hub/team/cli/commands/debug.mjs +2 -39
  18. package/hub/team/cli/commands/focus.mjs +5 -31
  19. package/hub/team/cli/commands/interrupt.mjs +6 -25
  20. package/hub/team/cli/commands/kill.mjs +1 -30
  21. package/hub/team/cli/commands/list.mjs +0 -16
  22. package/hub/team/cli/commands/send.mjs +6 -26
  23. package/hub/team/cli/commands/start/index.mjs +42 -133
  24. package/hub/team/cli/commands/start/parse-args.mjs +15 -40
  25. package/hub/team/cli/commands/start/start-headless.mjs +138 -105
  26. package/hub/team/cli/commands/start/start-mux.mjs +2 -7
  27. package/hub/team/cli/commands/status.mjs +2 -57
  28. package/hub/team/cli/commands/stop.mjs +14 -19
  29. package/hub/team/cli/help.mjs +4 -5
  30. package/hub/team/cli/manifest.mjs +5 -1
  31. package/hub/team/cli/render.mjs +0 -12
  32. package/hub/team/cli/services/runtime-mode.mjs +27 -98
  33. package/hub/team/cli/services/state-store.mjs +5 -20
  34. package/hub/team/handoff.mjs +3 -34
  35. package/hub/team/headless.mjs +108 -1296
  36. package/hub/team/orchestrator.mjs +4 -30
  37. package/hub/team/pane.mjs +6 -28
  38. package/hub/team/psmux.mjs +3 -34
  39. package/hub/team/remote-session.mjs +1 -2
  40. package/hub/team/session-context.mjs +1 -1
  41. package/hub/team/session.mjs +7 -91
  42. package/hub/team/tui-core.mjs +1 -1
  43. package/hub/team/tui-viewer.mjs +11 -58
  44. package/hub/team/tui.mjs +8 -306
  45. package/hub/team/uds-orchestrator.mjs +2 -580
  46. package/hub/team/wt-manager.mjs +2 -2
  47. package/hub/workers/factory.mjs +6 -84
  48. package/hub/workers/interface.mjs +1 -7
  49. package/hub/workers/lib/jsonrpc-core.mjs +11 -0
  50. package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -8
  51. package/package.json +3 -8
  52. package/scripts/lib/agent-route-policy.mjs +143 -62
  53. package/scripts/lib/cli-claude.mjs +0 -14
  54. package/scripts/lib/env-probe.mjs +2 -131
  55. package/scripts/lib/gemini-profiles.mjs +2 -69
  56. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  57. package/scripts/lib/machine-profile.mjs +3 -17
  58. package/scripts/lib/mcp-filter.mjs +109 -53
  59. package/scripts/lib/mcp-guard-engine.mjs +40 -98
  60. package/scripts/lib/mcp-health.mjs +2 -2
  61. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  62. package/scripts/lib/psmux-info.mjs +11 -59
  63. package/scripts/lib/skill-template.mjs +0 -245
  64. package/hub/hub-lifecycle.mjs +0 -248
  65. package/hub/pipe.mjs +0 -825
  66. package/hub/promote-penalties.mjs +0 -138
  67. package/hub/public/dashboard.html +0 -416
  68. package/hub/schema.sql +0 -209
  69. package/hub/server.mjs +0 -3048
  70. package/hub/store-adapter.mjs +0 -182
  71. package/hub/store.mjs +0 -1736
  72. package/hub/team/build-worker-prompt.mjs +0 -92
  73. package/hub/team/check-mcp-hub.mjs +0 -49
  74. package/hub/team/claude-native-bridge.mjs +0 -987
  75. package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
  76. package/hub/team/cli/commands/start/start-wt.mjs +0 -86
  77. package/hub/team/cli/services/hub-client.mjs +0 -264
  78. package/hub/team/cli/services/native-control.mjs +0 -145
  79. package/hub/team/codex-review.mjs +0 -520
  80. package/hub/team/conductor-mesh-bridge.mjs +0 -122
  81. package/hub/team/conductor-registry.mjs +0 -121
  82. package/hub/team/conductor.mjs +0 -1529
  83. package/hub/team/consensus-meta.mjs +0 -176
  84. package/hub/team/cto-auto-collect.mjs +0 -115
  85. package/hub/team/cto-pull.mjs +0 -31
  86. package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
  87. package/hub/team/dashboard.mjs +0 -322
  88. package/hub/team/event-log.mjs +0 -84
  89. package/hub/team/execution-mode.mjs +0 -324
  90. package/hub/team/extract-completion-payload.mjs +0 -110
  91. package/hub/team/headless-bridge-session.mjs +0 -17
  92. package/hub/team/health-probe.mjs +0 -435
  93. package/hub/team/interactive-native-launcher.mjs +0 -119
  94. package/hub/team/interactive-native-worker.mjs +0 -80
  95. package/hub/team/interactive-tui-transport.mjs +0 -479
  96. package/hub/team/launcher-template.mjs +0 -144
  97. package/hub/team/lead-control.mjs +0 -191
  98. package/hub/team/mcp-selector.mjs +0 -145
  99. package/hub/team/native-supervisor.mjs +0 -460
  100. package/hub/team/native.mjs +0 -727
  101. package/hub/team/recovery-store.mjs +0 -149
  102. package/hub/team/remote-probe.mjs +0 -292
  103. package/hub/team/remote-watcher.mjs +0 -506
  104. package/hub/team/runtime-strategy.mjs +0 -200
  105. package/hub/team/schemas/worker-completion.json +0 -39
  106. package/hub/team/sentinel-capture.mjs +0 -127
  107. package/hub/team/session-sync.mjs +0 -316
  108. package/hub/team/swarm-cli.mjs +0 -390
  109. package/hub/team/swarm-hypervisor.mjs +0 -2206
  110. package/hub/team/swarm-intent.mjs +0 -216
  111. package/hub/team/swarm-locks.mjs +0 -335
  112. package/hub/team/swarm-planner.mjs +0 -371
  113. package/hub/team/swarm-preflight.mjs +0 -366
  114. package/hub/team/swarm-reconciler.mjs +0 -242
  115. package/hub/team/synapse-cli.mjs +0 -458
  116. package/hub/team/synapse-http.mjs +0 -311
  117. package/hub/team/synapse-registry.mjs +0 -618
  118. package/hub/team/tui-remote-adapter.mjs +0 -377
  119. package/hub/team/tui-synapse.mjs +0 -223
  120. package/hub/team/worker-completion-validator.mjs +0 -79
  121. package/hub/team/worker-sandbox.mjs +0 -125
  122. package/hub/team/worktree-lifecycle.mjs +0 -639
  123. package/hub/team/wt-templates.json +0 -43
  124. package/hub/tools.mjs +0 -900
  125. package/hub/workers/antigravity-route-worker.mjs +0 -179
  126. package/hub/workers/codex-app-server-worker.mjs +0 -1067
  127. package/hub/workers/delegator-mcp.mjs +0 -1098
  128. package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
  129. package/scripts/lib/claudemd-scanner.mjs +0 -223
  130. package/scripts/lib/context.mjs +0 -67
  131. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  132. package/scripts/lib/handoff.mjs +0 -187
  133. package/scripts/lib/logger.mjs +0 -122
  134. package/scripts/lib/stealth-fetch.mjs +0 -176
@@ -1,216 +0,0 @@
1
- // hub/team/swarm-intent.mjs — X-Intent helper utilities for semantic merge awareness
2
-
3
- const INTENT_TRAILER_REGEX = /^X-Intent:\s*(.+)$/m;
4
-
5
- function normalizeText(value) {
6
- return typeof value === "string" ? value.trim() : "";
7
- }
8
-
9
- function normalizeTouches(touches) {
10
- if (!Array.isArray(touches)) return null;
11
- return touches
12
- .filter((item) => typeof item === "string")
13
- .map((item) => item.trim().replace(/\\/g, "/"))
14
- .filter(Boolean);
15
- }
16
-
17
- function normalizeIntent(intentObj) {
18
- if (!intentObj || typeof intentObj !== "object" || Array.isArray(intentObj)) {
19
- return null;
20
- }
21
-
22
- const scope = normalizeText(intentObj.scope);
23
- const action = normalizeText(intentObj.action);
24
- const reason = normalizeText(intentObj.reason);
25
- const touches = normalizeTouches(intentObj.touches);
26
-
27
- if (!scope || !action || !reason || !touches) {
28
- return null;
29
- }
30
-
31
- return {
32
- scope,
33
- action,
34
- reason,
35
- touches,
36
- invariant: normalizeText(intentObj.invariant),
37
- conflictsWith: normalizeText(intentObj.conflictsWith),
38
- };
39
- }
40
-
41
- function fallbackIntent(llmResponse) {
42
- const responseText = String(llmResponse ?? "");
43
- return {
44
- scope: "unknown",
45
- action: "unknown",
46
- reason: responseText.slice(0, 100),
47
- touches: [],
48
- invariant: "",
49
- conflictsWith: "",
50
- };
51
- }
52
-
53
- function includesEitherWay(a, b) {
54
- if (!a || !b) return false;
55
- return a.includes(b) || b.includes(a);
56
- }
57
-
58
- function hasConflict(intentA, intentB) {
59
- const conflictsWith = normalizeText(intentA?.conflictsWith).toLowerCase();
60
- const scope = normalizeText(intentB?.scope).toLowerCase();
61
- const action = normalizeText(intentB?.action).toLowerCase();
62
-
63
- if (!conflictsWith) return false;
64
- return (
65
- includesEitherWay(conflictsWith, scope) ||
66
- includesEitherWay(conflictsWith, action)
67
- );
68
- }
69
-
70
- function getTouchesOverlap(intentA, intentB) {
71
- const touchesA = new Set(
72
- (normalizeTouches(intentA?.touches) ?? []).map((item) =>
73
- item.toLowerCase(),
74
- ),
75
- );
76
- const touchesB = (normalizeTouches(intentB?.touches) ?? []).map((item) =>
77
- item.toLowerCase(),
78
- );
79
- return touchesB.filter((item) => touchesA.has(item));
80
- }
81
-
82
- /**
83
- * Build prompts for an LLM to generate an X-Intent JSON object.
84
- * @param {string[]} changedFiles
85
- * @param {string} commitDiff
86
- * @param {string} taskContext
87
- * @returns {{ systemPrompt: string, userPrompt: string }}
88
- */
89
- export function generateIntentPrompt(changedFiles, commitDiff, taskContext) {
90
- const files = Array.isArray(changedFiles)
91
- ? changedFiles.filter((filePath) => typeof filePath === "string")
92
- : [];
93
- const diffSnippet = String(commitDiff ?? "").slice(0, 4000);
94
- const context = String(taskContext ?? "").trim();
95
-
96
- return {
97
- systemPrompt:
98
- "You generate commit intent metadata for semantic merge awareness. Return only valid JSON with keys: scope, action, reason, touches, invariant, conflictsWith.",
99
- userPrompt: [
100
- "Create an intent object from the following commit context.",
101
- "- scope/action/reason must be concise strings.",
102
- "- touches must be an array of touched file paths.",
103
- "- invariant/conflictsWith should be strings (empty if unknown).",
104
- "- Return JSON only (no markdown).",
105
- "",
106
- `Task context:\n${context || "(none provided)"}`,
107
- "",
108
- `Changed files (${files.length}):\n${files.join("\n") || "(none)"}`,
109
- "",
110
- "Commit diff (first 4000 chars):",
111
- diffSnippet || "(empty)",
112
- ].join("\n"),
113
- };
114
- }
115
-
116
- /**
117
- * Format an intent object into an X-Intent commit trailer.
118
- * @param {object} intentObj
119
- * @returns {string}
120
- */
121
- export function formatIntentTrailer(intentObj) {
122
- return `X-Intent: ${JSON.stringify(intentObj)}`;
123
- }
124
-
125
- /**
126
- * Parse an X-Intent trailer from a commit message.
127
- * @param {string} commitMessage
128
- * @returns {object | null}
129
- */
130
- export function parseIntentTrailer(commitMessage) {
131
- const message = String(commitMessage ?? "");
132
- const match = message.match(INTENT_TRAILER_REGEX);
133
- if (!match) return null;
134
-
135
- try {
136
- return JSON.parse(match[1]);
137
- } catch {
138
- return null;
139
- }
140
- }
141
-
142
- /**
143
- * Classify the relation between two intents.
144
- * @param {object} intentA
145
- * @param {object} intentB
146
- * @returns {{ relation: "complementary" | "complementary-risky" | "contradictory" | "independent", reason: string }}
147
- */
148
- export function classifyIntentPair(intentA, intentB) {
149
- if (hasConflict(intentA, intentB) || hasConflict(intentB, intentA)) {
150
- return {
151
- relation: "contradictory",
152
- reason:
153
- "conflictsWith on one intent matches the other intent scope/action",
154
- };
155
- }
156
-
157
- const scopeA = normalizeText(intentA?.scope).toLowerCase();
158
- const scopeB = normalizeText(intentB?.scope).toLowerCase();
159
-
160
- if (scopeA && scopeB && scopeA === scopeB) {
161
- const overlap = getTouchesOverlap(intentA, intentB);
162
- if (overlap.length > 0) {
163
- return {
164
- relation: "complementary-risky",
165
- reason: `same scope with overlapping touched files: ${overlap.join(", ")}`,
166
- };
167
- }
168
-
169
- return {
170
- relation: "complementary",
171
- reason:
172
- "same scope with no conflicting intent and no touched-file overlap",
173
- };
174
- }
175
-
176
- return {
177
- relation: "independent",
178
- reason: "different scopes and no conflictsWith match detected",
179
- };
180
- }
181
-
182
- /**
183
- * Build a validated intent object from raw LLM response text.
184
- * @param {string} llmResponse
185
- * @returns {{ scope: string, action: string, reason: string, touches: string[], invariant: string, conflictsWith: string }}
186
- */
187
- export function buildIntentFromLLMResponse(llmResponse) {
188
- const responseText = String(llmResponse ?? "").trim();
189
- const candidates = [];
190
-
191
- const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
192
- for (const match of responseText.matchAll(codeBlockRegex)) {
193
- if (match[1]) candidates.push(match[1].trim());
194
- }
195
-
196
- candidates.push(responseText);
197
-
198
- const firstBrace = responseText.indexOf("{");
199
- const lastBrace = responseText.lastIndexOf("}");
200
- if (firstBrace >= 0 && lastBrace > firstBrace) {
201
- candidates.push(responseText.slice(firstBrace, lastBrace + 1));
202
- }
203
-
204
- for (const candidate of candidates) {
205
- if (!candidate) continue;
206
- try {
207
- const parsed = JSON.parse(candidate);
208
- const normalized = normalizeIntent(parsed);
209
- if (normalized) return normalized;
210
- } catch {
211
- // ignore parse failures and continue trying other candidates
212
- }
213
- }
214
-
215
- return fallbackIntent(responseText);
216
- }
@@ -1,335 +0,0 @@
1
- // hub/team/swarm-locks.mjs — File-level lease lock manager for swarm execution
2
- // Prevents multiple workers from writing to the same file simultaneously.
3
- // Lock state is kept in-memory (single-process hypervisor) with optional
4
- // JSON persistence to .triflux/swarm-locks.json for crash recovery.
5
-
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { dirname, relative, resolve } from "node:path";
8
- import { DEFAULT_SWARM_LOCK_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
9
-
10
- const LOCK_TTL_MS = DEFAULT_SWARM_LOCK_TTL_MS;
11
-
12
- // Distribution-critical paths. A swarm worker that modifies these without
13
- // having them explicitly listed in its shard lease is almost certainly a
14
- // recovery-patch fallout (worker exited before commit, hypervisor saved
15
- // patch with destructive `+++ /dev/null` deletions). Block by default;
16
- // override via validateChanges options.sensitiveDeny.
17
- //
18
- // Paths use POSIX separators (matches normalizePath output).
19
- export const SENSITIVE_PATH_PREFIXES = [
20
- ".claude-plugin/",
21
- "bin/",
22
- ".github/workflows/",
23
- ];
24
- export const SENSITIVE_PATH_FILES = [
25
- ".gitignore",
26
- ".npmignore",
27
- "package.json",
28
- "package-lock.json",
29
- "biome.json",
30
- ];
31
-
32
- export const DEFAULT_SENSITIVE_DENY = Object.freeze({
33
- prefixes: Object.freeze([...SENSITIVE_PATH_PREFIXES]),
34
- files: Object.freeze([...SENSITIVE_PATH_FILES]),
35
- });
36
-
37
- /**
38
- * Swarm lock manager factory.
39
- * @param {object} [opts]
40
- * @param {string} [opts.repoRoot] — repository root for relative path normalization
41
- * @param {string} [opts.persistPath] — JSON file path for crash recovery persistence
42
- * @param {number} [opts.ttlMs=600000] — lock TTL in ms (auto-expire stale locks)
43
- * @returns {SwarmLockManager}
44
- */
45
- export function createSwarmLocks(opts = {}) {
46
- const { repoRoot = process.cwd(), persistPath, ttlMs = LOCK_TTL_MS } = opts;
47
-
48
- /** @type {Map<string, LockEntry>} normalized relative path → lock */
49
- const locks = new Map();
50
-
51
- // ── helpers ──────────────────────────────────────────────────
52
-
53
- function normalizePath(filePath) {
54
- const abs = resolve(repoRoot, filePath);
55
- return relative(repoRoot, abs).replace(/\\/g, "/");
56
- }
57
-
58
- function now() {
59
- return Date.now();
60
- }
61
-
62
- function normalizeLeaseType(leaseType) {
63
- return leaseType === "shared-read" ? "shared-read" : "exclusive";
64
- }
65
-
66
- function normalizeSessionMeta(sessionMeta) {
67
- return sessionMeta ?? null;
68
- }
69
-
70
- function normalizeEntry(entry) {
71
- return {
72
- workerId: entry.workerId,
73
- acquiredAt: entry.acquiredAt,
74
- leaseType: normalizeLeaseType(entry.leaseType),
75
- sessionMeta: normalizeSessionMeta(entry.sessionMeta),
76
- };
77
- }
78
-
79
- function hasConflict(existing, requesterId, requestedLeaseType) {
80
- if (!existing || existing.workerId === requesterId) return false;
81
- if (
82
- existing.leaseType === "shared-read" &&
83
- requestedLeaseType === "shared-read"
84
- ) {
85
- return false;
86
- }
87
- return true;
88
- }
89
-
90
- function isExpired(entry) {
91
- return now() - entry.acquiredAt > ttlMs;
92
- }
93
-
94
- function pruneExpired() {
95
- for (const [path, entry] of locks) {
96
- if (isExpired(entry)) locks.delete(path);
97
- }
98
- }
99
-
100
- // ── persistence ─────────────────────────────────────────────
101
-
102
- function persist() {
103
- if (!persistPath) return;
104
- try {
105
- mkdirSync(dirname(persistPath), { recursive: true });
106
- const data = Object.fromEntries(
107
- [...locks].map(([k, v]) => [k, { ...v }]),
108
- );
109
- writeFileSync(persistPath, JSON.stringify(data, null, 2), "utf8");
110
- } catch {
111
- /* best-effort */
112
- }
113
- }
114
-
115
- function restore() {
116
- if (!persistPath || !existsSync(persistPath)) return;
117
- try {
118
- const data = JSON.parse(readFileSync(persistPath, "utf8"));
119
- const ts = now();
120
- for (const [path, entry] of Object.entries(data)) {
121
- if (ts - entry.acquiredAt <= ttlMs) {
122
- locks.set(path, normalizeEntry(entry));
123
- }
124
- }
125
- } catch {
126
- /* corrupted file — start fresh */
127
- }
128
- }
129
-
130
- // restore on creation
131
- restore();
132
-
133
- // ── public API ──────────────────────────────────────────────
134
-
135
- /**
136
- * Acquire file leases for a worker.
137
- * @param {string} workerId — worker/shard identifier
138
- * @param {string[]} files — file paths to lock
139
- * @param {{ leaseType?: "exclusive" | "shared-read", sessionMeta?: { sessionId: string, host: string, taskSummary: string } }} [opts]
140
- * @returns {{ ok: boolean, acquired: string[], conflicts: Array<{ file: string, holder: string }> }}
141
- */
142
- function acquire(workerId, files, opts = {}) {
143
- pruneExpired();
144
-
145
- const leaseType = normalizeLeaseType(opts.leaseType);
146
- const sessionMeta = normalizeSessionMeta(opts.sessionMeta);
147
- const normalized = files.map((f) => normalizePath(f));
148
- const conflicts = [];
149
- const toAcquire = [];
150
- const allowedSharedRead = [];
151
-
152
- for (let i = 0; i < normalized.length; i++) {
153
- const path = normalized[i];
154
- const existing = locks.get(path);
155
-
156
- if (!existing || isExpired(existing)) {
157
- toAcquire.push(path);
158
- continue;
159
- }
160
-
161
- if (hasConflict(existing, workerId, leaseType)) {
162
- conflicts.push({ file: files[i], holder: existing.workerId });
163
- } else {
164
- if (
165
- !(
166
- existing.workerId !== workerId &&
167
- existing.leaseType === "shared-read" &&
168
- leaseType === "shared-read"
169
- )
170
- ) {
171
- toAcquire.push(path);
172
- } else {
173
- allowedSharedRead.push(path);
174
- }
175
- }
176
- }
177
-
178
- if (conflicts.length > 0) {
179
- return { ok: false, acquired: [], conflicts };
180
- }
181
-
182
- const ts = now();
183
- for (const path of toAcquire) {
184
- locks.set(path, { workerId, acquiredAt: ts, leaseType, sessionMeta });
185
- }
186
-
187
- persist();
188
- return {
189
- ok: true,
190
- acquired: [...toAcquire, ...allowedSharedRead],
191
- conflicts: [],
192
- };
193
- }
194
-
195
- /**
196
- * Release all locks held by a worker.
197
- * @param {string} workerId
198
- * @returns {number} number of locks released
199
- */
200
- function release(workerId) {
201
- let count = 0;
202
- for (const [path, entry] of locks) {
203
- if (entry.workerId === workerId) {
204
- locks.delete(path);
205
- count += 1;
206
- }
207
- }
208
- if (count > 0) persist();
209
- return count;
210
- }
211
-
212
- function renew(workerId) {
213
- pruneExpired();
214
- const ts = now();
215
- let count = 0;
216
- for (const entry of locks.values()) {
217
- if (entry.workerId === workerId) {
218
- entry.acquiredAt = ts;
219
- count += 1;
220
- }
221
- }
222
- if (count > 0) persist();
223
- return count;
224
- }
225
-
226
- /**
227
- * Check if a file write would violate any lease.
228
- * @param {string} workerId — the worker attempting the write
229
- * @param {string} filePath — the file being written
230
- * @param {"exclusive" | "shared-read"} [leaseType="exclusive"] — requested lease type
231
- * @returns {{ allowed: boolean, holder?: string }}
232
- */
233
- function check(workerId, filePath, leaseType = "exclusive") {
234
- pruneExpired();
235
- const path = normalizePath(filePath);
236
- const entry = locks.get(path);
237
- const normalizedLeaseType = normalizeLeaseType(leaseType);
238
-
239
- if (!entry || isExpired(entry)) return { allowed: true };
240
- if (!hasConflict(entry, workerId, normalizedLeaseType))
241
- return { allowed: true };
242
- return { allowed: false, holder: entry.workerId };
243
- }
244
-
245
- /**
246
- * Validate a set of changed files against the lease map.
247
- * Returns all violations found.
248
- *
249
- * Beyond the basic cross-lease check, when `options.ownLease` is supplied
250
- * (the caller's known lease set), this also flags out-of-lease writes to
251
- * distribution-critical paths — e.g. recovery-patch fallouts where a
252
- * worker's recorded diff carries `+++ /dev/null` deletions of files like
253
- * `.claude-plugin/marketplace.json` (regression of #115 / #34).
254
- *
255
- * `options.ownLease` is opt-in to preserve existing callers; when omitted,
256
- * legacy behavior (cross-lease check only) is kept.
257
- *
258
- * @param {string} workerId
259
- * @param {string[]} changedFiles
260
- * @param {{ ownLease?: string[], sensitiveDeny?: { prefixes?: string[], files?: string[] } }} [options]
261
- * @returns {Array<{ file: string, holder?: string, kind: "other-lease" | "sensitive-out-of-lease" }>}
262
- */
263
- function validateChanges(workerId, changedFiles, options = {}) {
264
- pruneExpired();
265
- const violations = [];
266
- const ownLeaseSet = Array.isArray(options.ownLease)
267
- ? new Set(options.ownLease.map((path) => normalizePath(path)))
268
- : null;
269
- const sensitivePrefixes =
270
- options.sensitiveDeny?.prefixes ?? SENSITIVE_PATH_PREFIXES;
271
- const sensitiveFiles = new Set(
272
- options.sensitiveDeny?.files ?? SENSITIVE_PATH_FILES,
273
- );
274
-
275
- for (const file of changedFiles) {
276
- const path = normalizePath(file);
277
- const entry = locks.get(path);
278
-
279
- if (entry && entry.workerId !== workerId && !isExpired(entry)) {
280
- violations.push({ file, holder: entry.workerId, kind: "other-lease" });
281
- continue;
282
- }
283
-
284
- // Sensitive-path guard only runs when caller supplied ownLease.
285
- // We only flag when the worker did NOT explicitly lease the file —
286
- // an explicit lease means the shard intentionally owns it.
287
- if (ownLeaseSet && !ownLeaseSet.has(path)) {
288
- const isSensitive =
289
- sensitiveFiles.has(path) ||
290
- sensitivePrefixes.some((prefix) => path.startsWith(prefix));
291
- if (isSensitive) {
292
- violations.push({ file, kind: "sensitive-out-of-lease" });
293
- }
294
- }
295
- }
296
-
297
- return violations;
298
- }
299
-
300
- /**
301
- * Get snapshot of all active locks.
302
- * @returns {Array<{ file: string, workerId: string, acquiredAt: number, leaseType: "exclusive" | "shared-read", sessionMeta: { sessionId: string, host: string, taskSummary: string } | null }>}
303
- */
304
- function snapshot() {
305
- pruneExpired();
306
- return [...locks].map(([file, entry]) => ({
307
- file,
308
- workerId: entry.workerId,
309
- acquiredAt: entry.acquiredAt,
310
- leaseType: normalizeLeaseType(entry.leaseType),
311
- sessionMeta: normalizeSessionMeta(entry.sessionMeta),
312
- }));
313
- }
314
-
315
- /**
316
- * Release all locks.
317
- */
318
- function releaseAll() {
319
- locks.clear();
320
- persist();
321
- }
322
-
323
- return Object.freeze({
324
- acquire,
325
- release,
326
- renew,
327
- check,
328
- validateChanges,
329
- snapshot,
330
- releaseAll,
331
- get size() {
332
- return locks.size;
333
- },
334
- });
335
- }