triflux 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 (263) hide show
  1. package/CLAUDE.md +57 -41
  2. package/README.ko.md +41 -45
  3. package/README.md +41 -45
  4. package/adapters/codex/skills/tfx-live/SKILL.md +25 -11
  5. package/bin/tfx-live.mjs +341 -104
  6. package/bin/triflux.mjs +147 -2624
  7. package/config/mcp-registry.json +3 -12
  8. package/cto/brief.mjs +0 -20
  9. package/cto/collect.mjs +1 -200
  10. package/cto/current.schema.json +2 -23
  11. package/cto/hygiene.mjs +2 -61
  12. package/cto/lake-root.mjs +0 -56
  13. package/cto/status.mjs +5 -187
  14. package/hooks/agy-session-hook.mjs +11 -56
  15. package/hooks/codex-session-hook.mjs +11 -101
  16. package/hub/bridge.mjs +65 -1250
  17. package/hub/cli-adapter-base.mjs +12 -90
  18. package/hub/codex-adapter.mjs +0 -1
  19. package/hub/codex-preflight.mjs +5 -28
  20. package/hub/lib/process-utils.mjs +1 -156
  21. package/hub/lib/spawn-trace.mjs +3 -2
  22. package/hub/lib/timeout-defaults.mjs +0 -1
  23. package/hub/team/agent-map.json +0 -1
  24. package/hub/team/agents-row-attach.py +120 -0
  25. package/hub/team/agents-row.mjs +200 -0
  26. package/hub/team/ansi.mjs +1 -35
  27. package/hub/team/backend.mjs +9 -145
  28. package/hub/team/claude-daemon-control.mjs +1 -2
  29. package/hub/team/claude-session-projection.mjs +5 -73
  30. package/hub/team/cli/commands/attach.mjs +1 -17
  31. package/hub/team/cli/commands/control.mjs +12 -38
  32. package/hub/team/cli/commands/debug.mjs +2 -39
  33. package/hub/team/cli/commands/focus.mjs +5 -31
  34. package/hub/team/cli/commands/interrupt.mjs +6 -25
  35. package/hub/team/cli/commands/kill.mjs +1 -30
  36. package/hub/team/cli/commands/list.mjs +0 -16
  37. package/hub/team/cli/commands/send.mjs +6 -26
  38. package/hub/team/cli/commands/start/index.mjs +42 -133
  39. package/hub/team/cli/commands/start/parse-args.mjs +15 -40
  40. package/hub/team/cli/commands/start/start-headless.mjs +138 -105
  41. package/hub/team/cli/commands/start/start-mux.mjs +2 -7
  42. package/hub/team/cli/commands/status.mjs +2 -57
  43. package/hub/team/cli/commands/stop.mjs +14 -19
  44. package/hub/team/cli/help.mjs +4 -5
  45. package/hub/team/cli/manifest.mjs +5 -1
  46. package/hub/team/cli/render.mjs +0 -12
  47. package/hub/team/cli/services/runtime-mode.mjs +27 -98
  48. package/hub/team/cli/services/state-store.mjs +5 -20
  49. package/hub/team/codex-queue.mjs +385 -0
  50. package/hub/team/handoff.mjs +3 -34
  51. package/hub/team/headless.mjs +108 -1296
  52. package/hub/team/orchestrator.mjs +4 -30
  53. package/hub/team/pane.mjs +6 -28
  54. package/hub/team/psmux.mjs +3 -34
  55. package/hub/team/remote-session.mjs +1 -2
  56. package/hub/team/session-context.mjs +1 -1
  57. package/hub/team/session.mjs +7 -91
  58. package/hub/team/tui-core.mjs +1 -1
  59. package/hub/team/tui-viewer.mjs +11 -58
  60. package/hub/team/tui.mjs +8 -306
  61. package/hub/team/uds-orchestrator.mjs +2 -580
  62. package/hub/team/wt-manager.mjs +2 -2
  63. package/hub/workers/factory.mjs +6 -84
  64. package/hub/workers/interface.mjs +1 -7
  65. package/hub/workers/lib/jsonrpc-core.mjs +11 -0
  66. package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -8
  67. package/hud/cli-policy.mjs +0 -22
  68. package/hud/constants.mjs +13 -26
  69. package/hud/hud-qos-status.mjs +42 -54
  70. package/hud/providers/gemini.mjs +125 -20
  71. package/hud/renderers.mjs +101 -122
  72. package/hud/utils.mjs +10 -6
  73. package/package.json +7 -12
  74. package/scripts/__tests__/mcp-guard-engine-http-headers.test.mjs +6 -6
  75. package/scripts/__tests__/mcp-guard-engine-policy-sync.test.mjs +1 -4
  76. package/scripts/__tests__/mcp-guard-engine-proactive-sync.test.mjs +16 -21
  77. package/scripts/__tests__/mcp-guard-engine-stdio-sync.test.mjs +1 -1
  78. package/scripts/__tests__/mcp-guard-engine-sync-http-headers.test.mjs +2 -2
  79. package/scripts/__tests__/mcp-guard-engine.test.mjs +56 -63
  80. package/scripts/__tests__/spawn-trace.test.mjs +16 -0
  81. package/scripts/check-codex-config-stable.mjs +4 -37
  82. package/scripts/claude-login-detect.mjs +0 -1
  83. package/scripts/claudemd-sync.mjs +1 -28
  84. package/scripts/lib/agent-route-policy.mjs +143 -62
  85. package/scripts/lib/cli-claude.mjs +0 -14
  86. package/scripts/lib/env-probe.mjs +2 -131
  87. package/scripts/lib/gemini-profiles.mjs +2 -69
  88. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  89. package/scripts/lib/machine-profile.mjs +3 -17
  90. package/scripts/lib/mcp-filter.mjs +109 -53
  91. package/scripts/lib/mcp-guard-engine.mjs +40 -98
  92. package/scripts/lib/mcp-health.mjs +2 -2
  93. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  94. package/scripts/lib/psmux-info.mjs +11 -59
  95. package/scripts/lib/session-presence.mjs +2 -100
  96. package/scripts/lib/skill-template.mjs +0 -245
  97. package/scripts/pack.mjs +8 -83
  98. package/scripts/preflight-cache.mjs +5 -18
  99. package/scripts/release/check-packages-mirror.mjs +2 -8
  100. package/scripts/release/version-manifest.json +9 -1
  101. package/scripts/setup.mjs +100 -417
  102. package/scripts/test-lock.mjs +0 -1
  103. package/scripts/tfx-route-post.mjs +5 -69
  104. package/scripts/tfx-route-worker.mjs +4 -79
  105. package/scripts/tfx-route.sh +169 -1495
  106. package/scripts/wt-cli.mjs +1 -2
  107. package/skills/tfx-auto/SKILL.md +123 -741
  108. package/skills/tfx-auto/references/consensus-schema.md +161 -0
  109. package/skills/tfx-auto/references/tmux-observation.md +97 -0
  110. package/skills/tfx-doctor/SKILL.md +5 -6
  111. package/skills/tfx-lead/SKILL.md +195 -0
  112. package/skills/tfx-live/SKILL.md +25 -11
  113. package/skills/tfx-remote/SKILL.md +24 -87
  114. package/skills/tfx-research/SKILL.md +13 -20
  115. package/skills/tfx-review/SKILL.md +33 -35
  116. package/skills/tfx-setup/SKILL.md +1 -1
  117. package/skills/tfx-ship/SKILL.md +6 -27
  118. package/skills/tfx-wt/SKILL.md +34 -68
  119. package/bin/tfx-doctor-tui.mjs +0 -7
  120. package/bin/tfx-setup-tui.mjs +0 -7
  121. package/config/routing-policy.json +0 -124
  122. package/docs/assets/consensus-flow.svg +0 -125
  123. package/docs/assets/deep-vs-light.svg +0 -193
  124. package/docs/assets/demo-dark.gif +0 -0
  125. package/docs/assets/demo-light.gif +0 -0
  126. package/docs/assets/demo-psmux.gif +0 -0
  127. package/hub/adaptive-diagnostic.mjs +0 -350
  128. package/hub/adaptive-inject.mjs +0 -224
  129. package/hub/adaptive-memory.mjs +0 -277
  130. package/hub/adaptive.mjs +0 -172
  131. package/hub/assign-callbacks.mjs +0 -170
  132. package/hub/delegator/contracts.mjs +0 -38
  133. package/hub/delegator/index.mjs +0 -14
  134. package/hub/delegator/schema/delegator-tools.schema.json +0 -250
  135. package/hub/delegator/service.mjs +0 -336
  136. package/hub/delegator/tool-definitions.mjs +0 -35
  137. package/hub/dynamic-routing-engine.mjs +0 -511
  138. package/hub/hitl.mjs +0 -202
  139. package/hub/hub-lifecycle.mjs +0 -248
  140. package/hub/lib/known-errors.json +0 -72
  141. package/hub/lib/memory-store.mjs +0 -1001
  142. package/hub/lib/phase-manager.mjs +0 -493
  143. package/hub/lib/risk-tier.mjs +0 -53
  144. package/hub/lib/ssh-command.mjs +0 -231
  145. package/hub/lib/staged-file-detect.mjs +0 -43
  146. package/hub/lib/state-snapshot.mjs +0 -293
  147. package/hub/lib/tfx-route-args.mjs +0 -353
  148. package/hub/lib/trace-recorder.mjs +0 -153
  149. package/hub/lib/uuidv7.mjs +0 -45
  150. package/hub/memory-doctor.mjs +0 -640
  151. package/hub/middleware/quota-middleware.mjs +0 -70
  152. package/hub/middleware/request-logger.mjs +0 -191
  153. package/hub/paths.mjs +0 -24
  154. package/hub/pipe.mjs +0 -825
  155. package/hub/pipeline/index.mjs +0 -163
  156. package/hub/pipeline/state.mjs +0 -191
  157. package/hub/pipeline/transitions.mjs +0 -131
  158. package/hub/promote-penalties.mjs +0 -138
  159. package/hub/public/dashboard.html +0 -416
  160. package/hub/reflexion.mjs +0 -290
  161. package/hub/role-contract.mjs +0 -314
  162. package/hub/router.mjs +0 -2026
  163. package/hub/routing-snapshot.mjs +0 -261
  164. package/hub/schema.sql +0 -209
  165. package/hub/server.mjs +0 -3048
  166. package/hub/session-fingerprint.mjs +0 -418
  167. package/hub/state.mjs +0 -300
  168. package/hub/store-adapter.mjs +0 -182
  169. package/hub/store.mjs +0 -1736
  170. package/hub/team/build-worker-prompt.mjs +0 -92
  171. package/hub/team/check-mcp-hub.mjs +0 -49
  172. package/hub/team/claude-native-bridge.mjs +0 -987
  173. package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
  174. package/hub/team/cli/commands/start/start-wt.mjs +0 -86
  175. package/hub/team/cli/services/hub-client.mjs +0 -264
  176. package/hub/team/cli/services/native-control.mjs +0 -145
  177. package/hub/team/codex-review.mjs +0 -520
  178. package/hub/team/conductor-mesh-bridge.mjs +0 -122
  179. package/hub/team/conductor-registry.mjs +0 -121
  180. package/hub/team/conductor.mjs +0 -1529
  181. package/hub/team/consensus-meta.mjs +0 -176
  182. package/hub/team/cto-auto-collect.mjs +0 -115
  183. package/hub/team/cto-pull.mjs +0 -31
  184. package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
  185. package/hub/team/dashboard.mjs +0 -322
  186. package/hub/team/event-log.mjs +0 -84
  187. package/hub/team/execution-mode.mjs +0 -324
  188. package/hub/team/extract-completion-payload.mjs +0 -110
  189. package/hub/team/headless-bridge-session.mjs +0 -17
  190. package/hub/team/health-probe.mjs +0 -435
  191. package/hub/team/interactive-native-launcher.mjs +0 -119
  192. package/hub/team/interactive-native-worker.mjs +0 -80
  193. package/hub/team/interactive-tui-transport.mjs +0 -479
  194. package/hub/team/launcher-template.mjs +0 -144
  195. package/hub/team/lead-control.mjs +0 -191
  196. package/hub/team/mcp-selector.mjs +0 -145
  197. package/hub/team/native-supervisor.mjs +0 -460
  198. package/hub/team/native.mjs +0 -727
  199. package/hub/team/recovery-store.mjs +0 -149
  200. package/hub/team/remote-probe.mjs +0 -292
  201. package/hub/team/remote-watcher.mjs +0 -506
  202. package/hub/team/runtime-strategy.mjs +0 -200
  203. package/hub/team/schemas/worker-completion.json +0 -39
  204. package/hub/team/sentinel-capture.mjs +0 -127
  205. package/hub/team/session-sync.mjs +0 -316
  206. package/hub/team/swarm-cli.mjs +0 -390
  207. package/hub/team/swarm-hypervisor.mjs +0 -2206
  208. package/hub/team/swarm-intent.mjs +0 -216
  209. package/hub/team/swarm-locks.mjs +0 -335
  210. package/hub/team/swarm-planner.mjs +0 -371
  211. package/hub/team/swarm-preflight.mjs +0 -366
  212. package/hub/team/swarm-reconciler.mjs +0 -242
  213. package/hub/team/synapse-cli.mjs +0 -458
  214. package/hub/team/synapse-http.mjs +0 -311
  215. package/hub/team/synapse-registry.mjs +0 -618
  216. package/hub/team/tui-remote-adapter.mjs +0 -377
  217. package/hub/team/tui-synapse.mjs +0 -223
  218. package/hub/team/worker-completion-validator.mjs +0 -79
  219. package/hub/team/worker-sandbox.mjs +0 -125
  220. package/hub/team/worktree-lifecycle.mjs +0 -639
  221. package/hub/team/wt-templates.json +0 -43
  222. package/hub/team-bridge.mjs +0 -27
  223. package/hub/tools.mjs +0 -900
  224. package/hub/workers/antigravity-route-worker.mjs +0 -179
  225. package/hub/workers/codex-app-server-worker.mjs +0 -1067
  226. package/hub/workers/delegator-mcp.mjs +0 -1098
  227. package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
  228. package/mesh/index.mjs +0 -69
  229. package/mesh/mesh-budget.mjs +0 -137
  230. package/mesh/mesh-heartbeat.mjs +0 -100
  231. package/mesh/mesh-protocol.mjs +0 -96
  232. package/mesh/mesh-queue.mjs +0 -163
  233. package/mesh/mesh-registry.mjs +0 -78
  234. package/mesh/mesh-router.mjs +0 -77
  235. package/scripts/__tests__/skill-surface.test.mjs +0 -93
  236. package/scripts/cache-doctor.mjs +0 -156
  237. package/scripts/cache-warmup.mjs +0 -630
  238. package/scripts/completions/tfx.bash +0 -74
  239. package/scripts/completions/tfx.fish +0 -57
  240. package/scripts/completions/tfx.zsh +0 -98
  241. package/scripts/demo.mjs +0 -197
  242. package/scripts/doctor-dynamic-routing.mjs +0 -87
  243. package/scripts/hub-ensure.mjs +0 -842
  244. package/scripts/hub-watchdog.mjs +0 -90
  245. package/scripts/lib/claudemd-scanner.mjs +0 -223
  246. package/scripts/lib/context.mjs +0 -67
  247. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  248. package/scripts/lib/handoff.mjs +0 -187
  249. package/scripts/lib/logger.mjs +0 -122
  250. package/scripts/lib/stealth-fetch.mjs +0 -176
  251. package/scripts/notion-read.mjs +0 -686
  252. package/scripts/snapshot-codex-state.mjs +0 -37
  253. package/scripts/snapshot-gemini-state.mjs +0 -37
  254. package/scripts/sync-codex-auth.mjs +0 -71
  255. package/scripts/sync-hub-mcp-settings.mjs +0 -697
  256. package/skills/_templates/base.md +0 -4
  257. package/skills/_templates/deep.md +0 -6
  258. package/skills/shared/telemetry-segment.md +0 -6
  259. package/tui/core.mjs +0 -266
  260. package/tui/doctor.mjs +0 -375
  261. package/tui/monitor-data.mjs +0 -152
  262. package/tui/monitor.mjs +0 -318
  263. package/tui/setup.mjs +0 -506
@@ -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 "../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
- }