@intutic/sync-daemon 1.9.0 → 1.10.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.
Files changed (200) hide show
  1. package/README.md +2 -2
  2. package/dist/agentReporter.d.ts +205 -0
  3. package/dist/agentReporter.d.ts.map +1 -0
  4. package/dist/agentReporter.js +394 -0
  5. package/dist/agentReporter.js.map +1 -0
  6. package/dist/configReader.d.ts +39 -2
  7. package/dist/configReader.d.ts.map +1 -1
  8. package/dist/configReader.js +56 -2
  9. package/dist/configReader.js.map +1 -1
  10. package/dist/configWriter.d.ts +51 -1
  11. package/dist/configWriter.d.ts.map +1 -1
  12. package/dist/configWriter.js +490 -63
  13. package/dist/configWriter.js.map +1 -1
  14. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  15. package/dist/harness/antigravityHooks.js +33 -55
  16. package/dist/harness/antigravityHooks.js.map +1 -1
  17. package/dist/harness/claudeCodeHooks.d.ts +47 -13
  18. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  19. package/dist/harness/claudeCodeHooks.js +323 -55
  20. package/dist/harness/claudeCodeHooks.js.map +1 -1
  21. package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
  22. package/dist/harness/claudeDesktopHooks.js +35 -48
  23. package/dist/harness/claudeDesktopHooks.js.map +1 -1
  24. package/dist/harness/clineHooks.d.ts.map +1 -1
  25. package/dist/harness/clineHooks.js +42 -34
  26. package/dist/harness/clineHooks.js.map +1 -1
  27. package/dist/harness/codexHooks.d.ts +36 -0
  28. package/dist/harness/codexHooks.d.ts.map +1 -0
  29. package/dist/harness/codexHooks.js +227 -0
  30. package/dist/harness/codexHooks.js.map +1 -0
  31. package/dist/harness/continueHooks.d.ts +45 -5
  32. package/dist/harness/continueHooks.d.ts.map +1 -1
  33. package/dist/harness/continueHooks.js +225 -5
  34. package/dist/harness/continueHooks.js.map +1 -1
  35. package/dist/harness/cursorHooks.d.ts +31 -0
  36. package/dist/harness/cursorHooks.d.ts.map +1 -1
  37. package/dist/harness/cursorHooks.js +78 -49
  38. package/dist/harness/cursorHooks.js.map +1 -1
  39. package/dist/harness/dshHooks.d.ts +198 -0
  40. package/dist/harness/dshHooks.d.ts.map +1 -0
  41. package/dist/harness/dshHooks.js +569 -0
  42. package/dist/harness/dshHooks.js.map +1 -0
  43. package/dist/harness/gateBody.d.ts +422 -0
  44. package/dist/harness/gateBody.d.ts.map +1 -0
  45. package/dist/harness/gateBody.js +1816 -0
  46. package/dist/harness/gateBody.js.map +1 -0
  47. package/dist/harness/gateKind.d.ts +164 -0
  48. package/dist/harness/gateKind.d.ts.map +1 -0
  49. package/dist/harness/gateKind.js +143 -0
  50. package/dist/harness/gateKind.js.map +1 -0
  51. package/dist/harness/githubCopilotHooks.d.ts +54 -0
  52. package/dist/harness/githubCopilotHooks.d.ts.map +1 -0
  53. package/dist/harness/githubCopilotHooks.js +218 -0
  54. package/dist/harness/githubCopilotHooks.js.map +1 -0
  55. package/dist/harness/gooseHooks.d.ts.map +1 -1
  56. package/dist/harness/gooseHooks.js +67 -37
  57. package/dist/harness/gooseHooks.js.map +1 -1
  58. package/dist/harness/grokHooks.d.ts +77 -0
  59. package/dist/harness/grokHooks.d.ts.map +1 -0
  60. package/dist/harness/grokHooks.js +350 -0
  61. package/dist/harness/grokHooks.js.map +1 -0
  62. package/dist/harness/hermesHooks.d.ts.map +1 -1
  63. package/dist/harness/hermesHooks.js +26 -55
  64. package/dist/harness/hermesHooks.js.map +1 -1
  65. package/dist/harness/holdRedaction.d.ts +69 -0
  66. package/dist/harness/holdRedaction.d.ts.map +1 -0
  67. package/dist/harness/holdRedaction.js +211 -0
  68. package/dist/harness/holdRedaction.js.map +1 -0
  69. package/dist/harness/jetbrainsXmlConfig.d.ts +51 -0
  70. package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -0
  71. package/dist/harness/jetbrainsXmlConfig.js +151 -0
  72. package/dist/harness/jetbrainsXmlConfig.js.map +1 -0
  73. package/dist/harness/mcpAutoWrite.d.ts +100 -3
  74. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  75. package/dist/harness/mcpAutoWrite.js +712 -90
  76. package/dist/harness/mcpAutoWrite.js.map +1 -1
  77. package/dist/harness/museHooks.d.ts +53 -0
  78. package/dist/harness/museHooks.d.ts.map +1 -0
  79. package/dist/harness/museHooks.js +273 -0
  80. package/dist/harness/museHooks.js.map +1 -0
  81. package/dist/harness/n8nHooks.d.ts +25 -9
  82. package/dist/harness/n8nHooks.d.ts.map +1 -1
  83. package/dist/harness/n8nHooks.js +174 -9
  84. package/dist/harness/n8nHooks.js.map +1 -1
  85. package/dist/harness/openCodeHooks.d.ts +93 -0
  86. package/dist/harness/openCodeHooks.d.ts.map +1 -0
  87. package/dist/harness/openCodeHooks.js +248 -0
  88. package/dist/harness/openCodeHooks.js.map +1 -0
  89. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  90. package/dist/harness/openWebuiHooks.js +56 -11
  91. package/dist/harness/openWebuiHooks.js.map +1 -1
  92. package/dist/harness/openclawHooks.d.ts.map +1 -1
  93. package/dist/harness/openclawHooks.js +30 -54
  94. package/dist/harness/openclawHooks.js.map +1 -1
  95. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  96. package/dist/harness/openhandsHooks.js +35 -41
  97. package/dist/harness/openhandsHooks.js.map +1 -1
  98. package/dist/harness/piHooks.d.ts.map +1 -1
  99. package/dist/harness/piHooks.js +31 -58
  100. package/dist/harness/piHooks.js.map +1 -1
  101. package/dist/harness/protectedPaths.d.ts +353 -0
  102. package/dist/harness/protectedPaths.d.ts.map +1 -0
  103. package/dist/harness/protectedPaths.js +805 -0
  104. package/dist/harness/protectedPaths.js.map +1 -0
  105. package/dist/harness/rooCodeHooks.d.ts.map +1 -1
  106. package/dist/harness/rooCodeHooks.js +34 -48
  107. package/dist/harness/rooCodeHooks.js.map +1 -1
  108. package/dist/harness/windsurfHooks.d.ts +72 -0
  109. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  110. package/dist/harness/windsurfHooks.js +172 -58
  111. package/dist/harness/windsurfHooks.js.map +1 -1
  112. package/dist/harness/windsurfJetBrainsProxy.d.ts +78 -0
  113. package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -0
  114. package/dist/harness/windsurfJetBrainsProxy.js +159 -0
  115. package/dist/harness/windsurfJetBrainsProxy.js.map +1 -0
  116. package/dist/hashReporter.js +1 -1
  117. package/dist/hashReporter.js.map +1 -1
  118. package/dist/index.d.ts +12 -6
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +14 -6
  121. package/dist/index.js.map +1 -1
  122. package/dist/integrityStore.d.ts +0 -8
  123. package/dist/integrityStore.d.ts.map +1 -1
  124. package/dist/integrityStore.js +6 -29
  125. package/dist/integrityStore.js.map +1 -1
  126. package/dist/lib/approvedBypasses.d.ts +76 -0
  127. package/dist/lib/approvedBypasses.d.ts.map +1 -0
  128. package/dist/lib/approvedBypasses.js +140 -0
  129. package/dist/lib/approvedBypasses.js.map +1 -0
  130. package/dist/lib/complianceProbes.d.ts.map +1 -1
  131. package/dist/lib/complianceProbes.js +5 -0
  132. package/dist/lib/complianceProbes.js.map +1 -1
  133. package/dist/lib/decisionsDigest.d.ts +99 -0
  134. package/dist/lib/decisionsDigest.d.ts.map +1 -0
  135. package/dist/lib/decisionsDigest.js +199 -0
  136. package/dist/lib/decisionsDigest.js.map +1 -0
  137. package/dist/lib/egressPolicy.d.ts +69 -0
  138. package/dist/lib/egressPolicy.d.ts.map +1 -0
  139. package/dist/lib/egressPolicy.js +126 -0
  140. package/dist/lib/egressPolicy.js.map +1 -0
  141. package/dist/lib/gitWorktrees.d.ts +140 -0
  142. package/dist/lib/gitWorktrees.d.ts.map +1 -0
  143. package/dist/lib/gitWorktrees.js +191 -0
  144. package/dist/lib/gitWorktrees.js.map +1 -0
  145. package/dist/lib/policySnapshot.d.ts +224 -0
  146. package/dist/lib/policySnapshot.d.ts.map +1 -0
  147. package/dist/lib/policySnapshot.js +637 -0
  148. package/dist/lib/policySnapshot.js.map +1 -0
  149. package/dist/lib/processPoller.d.ts +44 -0
  150. package/dist/lib/processPoller.d.ts.map +1 -1
  151. package/dist/lib/processPoller.js +176 -1
  152. package/dist/lib/processPoller.js.map +1 -1
  153. package/dist/lib/runtimeEnv.d.ts +8 -0
  154. package/dist/lib/runtimeEnv.d.ts.map +1 -1
  155. package/dist/lib/runtimeEnv.js +3 -0
  156. package/dist/lib/runtimeEnv.js.map +1 -1
  157. package/dist/localTrajectoryHeuristics.d.ts +51 -0
  158. package/dist/localTrajectoryHeuristics.d.ts.map +1 -0
  159. package/dist/localTrajectoryHeuristics.js +90 -0
  160. package/dist/localTrajectoryHeuristics.js.map +1 -0
  161. package/dist/sessionReporter.d.ts +70 -0
  162. package/dist/sessionReporter.d.ts.map +1 -0
  163. package/dist/sessionReporter.js +140 -0
  164. package/dist/sessionReporter.js.map +1 -0
  165. package/dist/skillWriter.d.ts +1 -1
  166. package/dist/skillWriter.d.ts.map +1 -1
  167. package/dist/skillWriter.js +6 -3
  168. package/dist/skillWriter.js.map +1 -1
  169. package/dist/syncLoop.d.ts +105 -3
  170. package/dist/syncLoop.d.ts.map +1 -1
  171. package/dist/syncLoop.js +397 -65
  172. package/dist/syncLoop.js.map +1 -1
  173. package/dist/trajectoryMonitor.d.ts +2 -0
  174. package/dist/trajectoryMonitor.d.ts.map +1 -1
  175. package/dist/trajectoryMonitor.js +57 -0
  176. package/dist/trajectoryMonitor.js.map +1 -1
  177. package/dist/watcher/driftWatcher.d.ts.map +1 -1
  178. package/dist/watcher/driftWatcher.js +12 -3
  179. package/dist/watcher/driftWatcher.js.map +1 -1
  180. package/dist/watcher/settingsGuard.d.ts +23 -0
  181. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  182. package/dist/watcher/settingsGuard.js +202 -17
  183. package/dist/watcher/settingsGuard.js.map +1 -1
  184. package/package.json +37 -6
  185. package/dist/brainIndexer.d.ts +0 -51
  186. package/dist/brainIndexer.d.ts.map +0 -1
  187. package/dist/brainIndexer.js +0 -148
  188. package/dist/brainIndexer.js.map +0 -1
  189. package/dist/harness/cursorHooksJson.d.ts +0 -29
  190. package/dist/harness/cursorHooksJson.d.ts.map +0 -1
  191. package/dist/harness/cursorHooksJson.js +0 -130
  192. package/dist/harness/cursorHooksJson.js.map +0 -1
  193. package/dist/harness/gooseHooksWriter.d.ts +0 -27
  194. package/dist/harness/gooseHooksWriter.d.ts.map +0 -1
  195. package/dist/harness/gooseHooksWriter.js +0 -147
  196. package/dist/harness/gooseHooksWriter.js.map +0 -1
  197. package/dist/harness/openhandsHooksWriter.d.ts +0 -23
  198. package/dist/harness/openhandsHooksWriter.d.ts.map +0 -1
  199. package/dist/harness/openhandsHooksWriter.js +0 -146
  200. package/dist/harness/openhandsHooksWriter.js.map +0 -1
@@ -53,9 +53,11 @@ import { writeN8nHooks } from './harness/n8nHooks.js';
53
53
  import { writeHermesHooks } from './harness/hermesHooks.js';
54
54
  import { writeOpenclawHooks } from './harness/openclawHooks.js';
55
55
  import { writePiHooks } from './harness/piHooks.js';
56
- import { writeCursorHooksJson } from './harness/cursorHooksJson.js';
57
- import { writeGooseHooks as writeGooseHooksNative } from './harness/gooseHooksWriter.js';
58
- import { writeOpenHandsHooks as writeOpenHandsHooksNative } from './harness/openhandsHooksWriter.js';
56
+ import { writeCodexHooks } from './harness/codexHooks.js';
57
+ import { writeGithubCopilotHooks } from './harness/githubCopilotHooks.js';
58
+ import { writeMuseHooks } from './harness/museHooks.js';
59
+ import { writeOpenCodeHooks } from './harness/openCodeHooks.js';
60
+ import { writeGrokHooks } from './harness/grokHooks.js';
59
61
  // ─── Harness config file mapping ─────────────────────────────────────
60
62
  /**
61
63
  * Maps each harness type to its expected config filename relative to
@@ -81,34 +83,100 @@ export const HARNESS_FILES = {
81
83
  'claude-desktop': 'claude_desktop_config.json',
82
84
  goose: '.agents/plugins/intutic-governance/hooks/hooks.json',
83
85
  'open-webui': '.open-webui/intutic-governance-filter.py',
86
+ langgraph: '.env.intutic',
87
+ // Muse Code reads AGENTS.md (falling back to CLAUDE.md) — see muse.ts (CLI)
88
+ // and museHooks.ts (the hook writer invoked below, same split codex uses).
89
+ 'muse-code': 'AGENTS.md',
90
+ // Grok Build's native rules file — same cross-tool convention Codex/Amp
91
+ // read. Formatted by `formatMarkdown`, the same `---`-separated formatter
92
+ // Cursor/Claude Code/Windsurf/GitHub Copilot already share below.
93
+ grok: 'AGENTS.md',
94
+ // OpenCode reads AGENTS.md (CLAUDE.md fallback) — the same markdown rules
95
+ // file Muse/Grok get. The gate is the plugin `writeOpenCodeHooks` writes
96
+ // below, not this file.
97
+ opencode: 'AGENTS.md',
98
+ // dsh has no workspace-relative rules file (governance lives entirely
99
+ // under $DSH_HOME) — empty, same as goose/openhands below: this writer's
100
+ // per-harness chain deliberately does not call writeDshHooks either (see
101
+ // the "goose and openhands are deliberately absent here" comment above);
102
+ // dshHooks.ts is invoked by tools/cli/src/harness/dsh.ts's own
103
+ // writeConfig() and re-run on tamper by settingsGuard.ts, the same
104
+ // two-path coverage goose/openhands rely on instead of this loop.
105
+ dsh: '',
106
+ // Xirp writes no config of its own (orchestrates other already-gated
107
+ // harnesses — see gateRegistry.ts's NO_GATE row and tools/cli/src/harness/
108
+ // xirp.ts). Empty filename => the loop below reports it `(deferred)`,
109
+ // same as n8n before it gained a writer.
110
+ xirp: '',
111
+ // Agentic Orchestrator writes no config of its own either — same
112
+ // "delegated" shape as Xirp (orchestrates already-gated Claude Code, Codex
113
+ // and OpenCode — see gateRegistry.ts's NO_GATE row and
114
+ // tools/cli/src/harness/agenticOrchestrator.ts).
115
+ 'agentic-orchestrator': '',
116
+ // Wave 1 SDK-gated frameworks — same rationale as langgraph: no on-disk
117
+ // hook/config file exists to gate tool calls, so each writes .env.intutic
118
+ // (proxy base-URL vars + an SDK-gate pointer comment). See formatContent's
119
+ // switch below and SDK_GATED_FRAMEWORKS.
120
+ langchain: '.env.intutic',
121
+ crewai: '.env.intutic',
122
+ autogen: '.env.intutic',
123
+ ag2: '.env.intutic',
124
+ 'google-adk': '.env.intutic',
125
+ 'openai-agents': '.env.intutic',
126
+ 'pydantic-ai': '.env.intutic',
127
+ smolagents: '.env.intutic',
128
+ // A4: AWS Strands Agents — same Python SDK-gated rationale as the Wave 1
129
+ // family above (gate ships in intutic_clawde.gate.adapters.strands).
130
+ strands: '.env.intutic',
131
+ // T2: JS/TS SDK-gated frameworks — same rationale as the Wave 1 Python
132
+ // family above, but the blocking gate ships in @intutic/gate
133
+ // (packages/gate-js) rather than intutic-clawde. See formatContent's
134
+ // switch below and JS_SDK_GATED_FRAMEWORKS.
135
+ mastra: '.env.intutic',
136
+ 'vercel-ai-sdk': '.env.intutic',
137
+ // eve (Vercel, PREVIEW) — same JS/TS SDK-gated family: the gate is
138
+ // @intutic/gate/eve's per-tool/per-connection approval policies, attached
139
+ // in the developer's own agent/ directory; this file only carries the
140
+ // proxy vars + pointer comment. See JS_SDK_GATED_FRAMEWORKS below.
141
+ eve: '.env.intutic',
142
+ // TrueForge (embedded-library mode only — see its HarnessType doc) — same
143
+ // JS/TS SDK-gated family, gate ships in @intutic/gate/trueforge. See
144
+ // formatContent's switch below and JS_SDK_GATED_FRAMEWORKS.
145
+ trueforge: '.env.intutic',
146
+ // A3: Vercel platform-agent runtimes — same @intutic/gate family as the T2
147
+ // rows above. For ai-sdk-harness the env vars are weaker still: tool
148
+ // execution is server-side in Vercel Sandbox microVMs the local proxy
149
+ // never sees (the generated comment says so — see JS_SDK_GATED_FRAMEWORKS'
150
+ // preamble override below and @intutic/gate/harness's module doc).
151
+ 'ai-sdk-harness': '.env.intutic',
152
+ 'ai-sdk-workflow': '.env.intutic',
153
+ // B2: AWS Bedrock AgentCore Runtime — hosts the customer's own framework
154
+ // code unchanged, so it writes no config of its own; the real tool-call
155
+ // gate belongs to whichever already-supported framework adapter that code
156
+ // uses (Strands, LangGraph, ...). Same 'delegated'/empty-filename shape as
157
+ // xirp/agentic-orchestrator above — see tools/cli/src/harness/agentcore.ts
158
+ // and gateKind.ts's DELEGATED_GATE_HARNESSES.
159
+ 'agentcore-runtime': '',
160
+ // B3: TrueForge, standalone/hosted server (see its HarnessType doc) —
161
+ // writes no config of its own: it is not detected by intutic init at all
162
+ // (an operator-configured deployment, not a repo dependency this daemon's
163
+ // scanner can find), and its gate is entirely out-of-process in
164
+ // services/trueforge-bridge, not a file this daemon writes. Empty filename
165
+ // => the loop below reports it `(deferred)`, same shape as
166
+ // xirp/agentic-orchestrator/agentcore-runtime above, though for a
167
+ // genuinely different reason — those are 'delegated' (governed by a
168
+ // wrapped harness's own gate); this is 'bridge' (governed by a service
169
+ // this repo ships and operates). See gateKind.ts's BRIDGE_GATED_HARNESSES.
170
+ 'trueforge-server': '',
84
171
  };
85
172
  /**
86
- * Write SOP content to all targeted harness config files.
87
- *
88
- * For each harness type, resolves the config file path, formats the
89
- * content according to the harness-specific format, and performs an
90
- * atomic write (tmp file → rename).
91
- *
92
- * @param workspaceRoot - Absolute path to the workspace root.
93
- * @param sops - SOPs to write, each specifying which harnesses to target.
94
- * @param proxyUrl - Intutic proxy URL for LLM API redirect.
95
- * @param harnesses - Harness types detected in the workspace.
96
- * @returns WriteResult with written and skipped file paths.
173
+ * The workspace's local SOPs (`.intutic/sops/<dir>/*.md`, narrowed by
174
+ * `session-context.json`'s `activeLocalSops` when present). Shared by the
175
+ * config writer and by the gate-cache refresh, which compiles their
176
+ * `review_before:` tokens into hold rules (TD-474 item 4). A missing
177
+ * directory is an empty list.
97
178
  */
98
- export async function writeConfigFiles(workspaceRoot, sops, proxyUrl, harnesses, workspaceId = '',
99
- /** WS-5 Q3: 'rewrite' (default) | 'immutable' | 'alert-only' */
100
- bypassEnforcementTier) {
101
- const filesWritten = [];
102
- const filesSkipped = [];
103
- // Bundled agent skills — write-if-missing, not drift-enforced
104
- try {
105
- const { writeBundledSkills } = await import('./skillWriter.js');
106
- await writeBundledSkills(workspaceRoot);
107
- }
108
- catch (e) {
109
- console.warn('[sync-daemon] writeBundledSkills failed (non-fatal):', e);
110
- }
111
- // Load and compile local SOP entries
179
+ export async function loadLocalSopEntries(workspaceRoot, harnesses) {
112
180
  const localSopEntries = [];
113
181
  try {
114
182
  const sessionContextPath = node_path.join(workspaceRoot, '.intutic', 'session-context.json');
@@ -144,9 +212,38 @@ bypassEnforcementTier) {
144
212
  }
145
213
  }
146
214
  }
147
- catch (err) {
215
+ catch {
148
216
  // ignore directory read errors (e.g. if sops folder doesn't exist)
149
217
  }
218
+ return localSopEntries;
219
+ }
220
+ /**
221
+ * Write SOP content to all targeted harness config files.
222
+ *
223
+ * For each harness type, resolves the config file path, formats the
224
+ * content according to the harness-specific format, and performs an
225
+ * atomic write (tmp file → rename).
226
+ *
227
+ * @param workspaceRoot - Absolute path to the workspace root.
228
+ * @param sops - SOPs to write, each specifying which harnesses to target.
229
+ * @param proxyUrl - Intutic proxy URL for LLM API redirect.
230
+ * @param harnesses - Harness types detected in the workspace.
231
+ * @returns WriteResult with written and skipped file paths.
232
+ */
233
+ export async function writeConfigFiles(workspaceRoot, sops, proxyUrl, harnesses, workspaceId = '',
234
+ /** WS-5 Q3: 'rewrite' (default) | 'immutable' | 'alert-only' */
235
+ bypassEnforcementTier) {
236
+ const filesWritten = [];
237
+ const filesSkipped = [];
238
+ // Bundled agent skills — write-if-missing, not drift-enforced
239
+ try {
240
+ const { writeBundledSkills } = await import('./skillWriter.js');
241
+ await writeBundledSkills(workspaceRoot);
242
+ }
243
+ catch (e) {
244
+ console.warn('[sync-daemon] writeBundledSkills failed (non-fatal):', e);
245
+ }
246
+ const localSopEntries = await loadLocalSopEntries(workspaceRoot, harnesses);
150
247
  const combinedSops = [...sops, ...localSopEntries];
151
248
  for (const harness of harnesses) {
152
249
  const filename = HARNESS_FILES[harness];
@@ -178,31 +275,29 @@ bypassEnforcementTier) {
178
275
  }
179
276
  continue;
180
277
  }
181
- // Phase 4 WS-4C — native hook script writers
182
- if (harness === 'cursor') {
183
- try {
184
- await writeCursorHooksJson(workspaceRoot);
185
- }
186
- catch (e) {
187
- console.warn('[sync-daemon] writeCursorHooksJson failed (non-fatal):', e);
188
- }
189
- }
190
- if (harness === 'goose') {
191
- try {
192
- await writeGooseHooksNative(workspaceRoot);
193
- }
194
- catch (e) {
195
- console.warn('[sync-daemon] writeGooseHooksNative failed (non-fatal):', e);
196
- }
197
- }
198
- if (harness === 'openhands') {
199
- try {
200
- await writeOpenHandsHooksNative(workspaceRoot);
201
- }
202
- catch (e) {
203
- console.warn('[sync-daemon] writeOpenHandsHooksNative failed (non-fatal):', e);
204
- }
205
- }
278
+ // cursor is deliberately absent here, for the same reason goose and
279
+ // openhands are below.
280
+ //
281
+ // `cursorHooksJson` was a second writer of `.cursor/hooks.json`, and the two
282
+ // disagreed about the file's schema: `cursorHooks` writes an object per
283
+ // event carrying `failClosed: true`, while `cursorHooksJson.mergeHooks`
284
+ // tested `Array.isArray(existing[event])`, found false, and REPLACED it with
285
+ // a bare `{command}` array. So it silently dropped fail-closed and repointed
286
+ // the user-level shell and MCP gates at `~/.intutic/hooks/cursor-check.js`,
287
+ // a path no writer produces. `cursorHooks` already registers a superset of
288
+ // its events at both levels, so it is deleted rather than repaired.
289
+ // goose and openhands are deliberately absent here.
290
+ //
291
+ // They each used to get a *second* gate from this call site —
292
+ // gooseHooksWriter.ts and openhandsHooksWriter.ts — installed alongside the
293
+ // one their own writer produces. The two had contradictory models: the
294
+ // survivors enforce locally and fail closed, these POSTed every tool call to
295
+ // the control plane and failed open on any error or timeout. For openhands
296
+ // both wrote `.openhands/hooks.json`, so whichever ran last silently won.
297
+ //
298
+ // Deleted rather than deprecated: two modules exporting the same symbol for
299
+ // one harness *is* the defect. And they are not replaced by calls to the
300
+ // survivors here — a second call site is how this started.
206
301
  // Phase 2 deferred or unknown harness
207
302
  if (!filename) {
208
303
  filesSkipped.push(`[${harness}] (deferred)`);
@@ -270,6 +365,58 @@ bypassEnforcementTier) {
270
365
  console.warn('[sync-daemon] writeN8nHooks failed (non-fatal):', e);
271
366
  }
272
367
  }
368
+ // The .env.intutic proxy routing above governs LLM egress only; the gate
369
+ // is what refuses tool calls. Written alongside, same as claude-desktop.
370
+ if (harness === 'codex') {
371
+ try {
372
+ await writeCodexHooks(workspaceRoot, proxyUrl, workspaceId);
373
+ }
374
+ catch (e) {
375
+ console.warn('[sync-daemon] writeCodexHooks failed (non-fatal):', e);
376
+ }
377
+ }
378
+ // Preview mechanism (VS Code agent hooks) — the writer's header says so,
379
+ // and the gate fails closed if the stdin shape shifts.
380
+ if (harness === 'github-copilot') {
381
+ try {
382
+ await writeGithubCopilotHooks(workspaceRoot, proxyUrl, workspaceId);
383
+ }
384
+ catch (e) {
385
+ console.warn('[sync-daemon] writeGithubCopilotHooks failed (non-fatal):', e);
386
+ }
387
+ }
388
+ // Same split as codex/claude-desktop: AGENTS.md above carries the rules
389
+ // text, this installs the PreToolUse/PermissionRequest gate (project +
390
+ // managed tiers) and the managed_hooks_path merge into settings.json.
391
+ if (harness === 'muse-code') {
392
+ try {
393
+ await writeMuseHooks(workspaceRoot, proxyUrl, workspaceId);
394
+ }
395
+ catch (e) {
396
+ console.warn('[sync-daemon] writeMuseHooks failed (non-fatal):', e);
397
+ }
398
+ }
399
+ // AGENTS.md above carries the rules text; this writes the plugin
400
+ // OpenCode loads into its own process, which is the gate (TD-397).
401
+ if (harness === 'opencode') {
402
+ try {
403
+ await writeOpenCodeHooks(workspaceRoot, proxyUrl, workspaceId);
404
+ }
405
+ catch (e) {
406
+ console.warn('[sync-daemon] writeOpenCodeHooks failed (non-fatal):', e);
407
+ }
408
+ }
409
+ // AGENTS.md above governs prompt-level rules only; the gate is what
410
+ // refuses tool calls, and the config.toml model base_url merge is what
411
+ // routes LLM egress. Written alongside, same as codex/claude-desktop.
412
+ if (harness === 'grok') {
413
+ try {
414
+ await writeGrokHooks(workspaceRoot, proxyUrl, workspaceId);
415
+ }
416
+ catch (e) {
417
+ console.warn('[sync-daemon] writeGrokHooks failed (non-fatal):', e);
418
+ }
419
+ }
273
420
  }
274
421
  catch (err) {
275
422
  console.warn(`[sync-daemon] writeConfigFiles failed for ${filename}:`, err);
@@ -312,12 +459,37 @@ function findFuzzyMatch(content, target) {
312
459
  }
313
460
  /**
314
461
  * Apply a list of custom config edits (ADD, DELETE, REPLACE) to a workspace rule file.
462
+ *
463
+ * Returns one `ConfigEditApplyOutcome` per input suggestion (TD-349) instead
464
+ * of `void` — every operation's real fate (fuzzy-match miss on DELETE/
465
+ * REPLACE, an unresolvable target file, or the final `atomicWrite` itself
466
+ * failing) is recorded here rather than only `console.warn`'d, so the caller
467
+ * (syncLoop.ts) can ack the daemon's actual outcome back to control-plane
468
+ * instead of the daemon-side write being invisible to it.
315
469
  */
316
470
  export async function applyConfigEdits(workspaceRoot, appliedEdits, bypassEnforcementTier) {
471
+ const outcomes = [];
317
472
  for (const applied of appliedEdits) {
318
473
  const filename = HARNESS_FILES[applied.harnessType] || applied.filePath;
319
- if (!filename)
474
+ const editsList = typeof applied.edits === 'string'
475
+ ? JSON.parse(applied.edits)
476
+ : applied.edits;
477
+ if (!filename) {
478
+ // No writable target for this harness — every operation fails to land.
479
+ outcomes.push({
480
+ suggestionId: applied.suggestionId,
481
+ // Vacuously ok only when there was nothing to apply in the first
482
+ // place — matches the `perOperation.every(...)` rule used below.
483
+ ok: editsList.length === 0,
484
+ perOperation: editsList.map((edit, index) => ({
485
+ index,
486
+ operation: edit.operation,
487
+ applied: false,
488
+ reason: `No config file resolved for harness "${applied.harnessType}"`,
489
+ })),
490
+ });
320
491
  continue;
492
+ }
321
493
  const filePath = node_path.join(workspaceRoot, filename);
322
494
  let currentContent = '';
323
495
  try {
@@ -327,14 +499,14 @@ export async function applyConfigEdits(workspaceRoot, appliedEdits, bypassEnforc
327
499
  // file doesn't exist
328
500
  }
329
501
  let updatedContent = currentContent;
330
- const editsList = typeof applied.edits === 'string'
331
- ? JSON.parse(applied.edits)
332
- : applied.edits;
333
- for (const edit of editsList) {
502
+ const perOperation = [];
503
+ editsList.forEach((edit, index) => {
334
504
  if (edit.operation === 'ADD') {
335
- // Idempotency: skip if edit content already exists in file
505
+ // Idempotency: skip if edit content already exists in file — already
506
+ // landed, so this counts as applied, not a no-op failure.
336
507
  if (edit.content && updatedContent.includes(edit.content)) {
337
- continue;
508
+ perOperation.push({ index, operation: edit.operation, applied: true });
509
+ return;
338
510
  }
339
511
  const header = `## ${edit.section}`;
340
512
  if (updatedContent.includes(header)) {
@@ -343,38 +515,76 @@ export async function applyConfigEdits(workspaceRoot, appliedEdits, bypassEnforc
343
515
  else {
344
516
  updatedContent += `\n\n${header}\n${edit.content ?? ''}`;
345
517
  }
518
+ perOperation.push({ index, operation: edit.operation, applied: true });
346
519
  }
347
520
  else if (edit.operation === 'DELETE') {
348
521
  if (edit.content) {
349
522
  const match = findFuzzyMatch(updatedContent, edit.content);
350
523
  if (match) {
351
524
  updatedContent = updatedContent.replace(match, '');
525
+ perOperation.push({ index, operation: edit.operation, applied: true });
352
526
  }
353
527
  else {
354
528
  console.warn(`[sync-daemon] [DELETE] Pattern not found in ${filename}:`, edit.content.slice(0, 100));
529
+ perOperation.push({
530
+ index,
531
+ operation: edit.operation,
532
+ applied: false,
533
+ reason: `DELETE target pattern not found in ${filename}`,
534
+ });
355
535
  }
356
536
  }
537
+ else {
538
+ perOperation.push({ index, operation: edit.operation, applied: false, reason: 'DELETE edit had no content to match' });
539
+ }
357
540
  }
358
541
  else if (edit.operation === 'REPLACE') {
359
542
  if (edit.target) {
360
543
  const match = findFuzzyMatch(updatedContent, edit.target);
361
544
  if (match) {
362
545
  updatedContent = updatedContent.replace(match, edit.content ?? '');
546
+ perOperation.push({ index, operation: edit.operation, applied: true });
363
547
  }
364
548
  else {
365
549
  console.warn(`[sync-daemon] [REPLACE] Target pattern not found in ${filename}:`, edit.target.slice(0, 100));
550
+ perOperation.push({
551
+ index,
552
+ operation: edit.operation,
553
+ applied: false,
554
+ reason: `REPLACE target pattern not found in ${filename}`,
555
+ });
366
556
  }
367
557
  }
558
+ else {
559
+ perOperation.push({ index, operation: edit.operation, applied: false, reason: 'REPLACE edit had no target to match' });
560
+ }
368
561
  }
369
- }
562
+ else {
563
+ perOperation.push({ index, operation: edit.operation, applied: false, reason: `Unknown operation "${edit.operation}"` });
564
+ }
565
+ });
370
566
  try {
371
567
  await atomicWrite(filePath, updatedContent, bypassEnforcementTier);
372
568
  console.log(`[sync-daemon] Applied SkillOpt config edits to ${filename} (suggestion: ${applied.suggestionId})`);
373
569
  }
374
570
  catch (err) {
571
+ // The write itself failed (e.g. protected-path/tamper guard) — nothing
572
+ // that "landed" in memory above actually reached disk. Every operation
573
+ // that had been recorded as applied must be walked back to failed.
574
+ const reason = `atomicWrite failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`;
375
575
  console.warn(`[sync-daemon] Failed to apply config edits to ${filename}:`, err);
576
+ for (const op of perOperation) {
577
+ op.applied = false;
578
+ op.reason = op.reason ?? reason;
579
+ }
376
580
  }
581
+ outcomes.push({
582
+ suggestionId: applied.suggestionId,
583
+ ok: perOperation.every((op) => op.applied),
584
+ perOperation,
585
+ });
377
586
  }
587
+ return outcomes;
378
588
  }
379
589
  // ─── Formatters ──────────────────────────────────────────────────────
380
590
  /** Standard file header injected at the top of every auto-generated file. */
@@ -403,6 +613,7 @@ function formatContent(harness, sops, proxyUrl) {
403
613
  case 'claude-code':
404
614
  case 'windsurf':
405
615
  case 'github-copilot':
616
+ case 'grok':
406
617
  return formatMarkdown(sops);
407
618
  case 'antigravity':
408
619
  return formatAntigravity(sops);
@@ -412,6 +623,29 @@ function formatContent(harness, sops, proxyUrl) {
412
623
  return formatOpenHands(sops);
413
624
  case 'codex':
414
625
  return formatCodex(sops, proxyUrl);
626
+ case 'langgraph':
627
+ return formatLanggraph(sops, proxyUrl);
628
+ case 'muse-code':
629
+ case 'opencode':
630
+ // AGENTS.md — same markdown shape as CLAUDE.md/.cursorrules/.windsurfrules.
631
+ return formatMarkdown(sops);
632
+ case 'langchain':
633
+ case 'crewai':
634
+ case 'autogen':
635
+ case 'ag2':
636
+ case 'google-adk':
637
+ case 'openai-agents':
638
+ case 'pydantic-ai':
639
+ case 'smolagents':
640
+ case 'strands':
641
+ return formatSdkGatedEnv(sops, proxyUrl, SDK_GATED_FRAMEWORKS[harness]);
642
+ case 'mastra':
643
+ case 'vercel-ai-sdk':
644
+ case 'eve':
645
+ case 'trueforge':
646
+ case 'ai-sdk-harness':
647
+ case 'ai-sdk-workflow':
648
+ return formatJsSdkGatedEnv(sops, proxyUrl, JS_SDK_GATED_FRAMEWORKS[harness]);
415
649
  default:
416
650
  return formatMarkdown(sops);
417
651
  }
@@ -475,6 +709,195 @@ function formatCodex(sops, proxyUrl) {
475
709
  `INTUTIC_SOP_IDS=${sopIds}\n` +
476
710
  `INTUTIC_LAST_SYNC=${newIso()}\n`);
477
711
  }
712
+ /**
713
+ * LangGraph: `.env.intutic` with proxy URL vars, plus a pointer at the
714
+ * SDK-side tool gate. LangGraph tools are plain Python callables in the
715
+ * agent's own process — there is no config or hook file this daemon could
716
+ * write a tool-call gate into, so the env vars govern LLM egress only and
717
+ * the blocking gate ships in `intutic-clawde` (`intutic_clawde.gate`).
718
+ */
719
+ function formatLanggraph(sops, proxyUrl) {
720
+ return (formatCodex(sops, proxyUrl) +
721
+ '\n' +
722
+ '# These env vars govern LLM egress only. LangGraph tools run in your own\n' +
723
+ '# Python process, where no config or hook file can gate them — the\n' +
724
+ '# blocking tool gate ships SDK-side:\n' +
725
+ '# pip install intutic-clawde\n' +
726
+ '# from intutic_clawde.gate import guard_tools\n');
727
+ }
728
+ /**
729
+ * Wave 1: seven more frameworks in LangGraph's family — tools are plain
730
+ * Python callables/objects in the agent's own process, so the daemon has no
731
+ * on-disk hook/config file to gate a call with, and every one of these
732
+ * writes the same `.env.intutic` shape `formatLanggraph` does (proxy URL
733
+ * vars + a comment pointing at the SDK-side gate). Declared as data here,
734
+ * rather than as seven more hand-copied `formatXxx` functions, so the shape
735
+ * cannot drift per-framework the way `gateBody.ts`'s module doc warns
736
+ * hand-copied gate logic does.
737
+ *
738
+ * `pipExtra` is omitted for the three frameworks with no dedicated
739
+ * `intutic_clawde.gate.adapters.*` module yet (autogen, ag2, pydantic-ai,
740
+ * smolagents ship in a later wave — see `gateRegistry.ts`'s NO_GATE rows) —
741
+ * their tools are governed today through the framework-agnostic
742
+ * `@guard`/`guard_tools` helpers, which need no optional import.
743
+ */
744
+ const SDK_GATED_FRAMEWORKS = {
745
+ langchain: {
746
+ label: 'LangChain',
747
+ pipExtra: 'langchain',
748
+ importLine: 'from intutic_clawde.gate.adapters.langchain import IntuticMiddleware',
749
+ docsSlug: 'langchain',
750
+ },
751
+ crewai: {
752
+ label: 'CrewAI',
753
+ pipExtra: 'crewai',
754
+ importLine: 'from intutic_clawde.gate.adapters.crewai import install',
755
+ docsSlug: 'crewai',
756
+ },
757
+ autogen: {
758
+ label: 'AutoGen',
759
+ importLine: 'from intutic_clawde.gate import guard, guard_tools',
760
+ docsSlug: 'autogen',
761
+ },
762
+ ag2: {
763
+ label: 'AG2',
764
+ importLine: 'from intutic_clawde.gate import guard, guard_tools',
765
+ docsSlug: 'ag2',
766
+ },
767
+ 'google-adk': {
768
+ label: 'Google ADK',
769
+ pipExtra: 'google-adk',
770
+ importLine: 'from intutic_clawde.gate.adapters.google_adk import IntuticPlugin',
771
+ docsSlug: 'google-adk',
772
+ },
773
+ 'openai-agents': {
774
+ label: 'OpenAI Agents SDK',
775
+ pipExtra: 'openai-agents',
776
+ importLine: 'from intutic_clawde.gate.adapters.openai_agents import intutic_tool_guardrail',
777
+ docsSlug: 'openai-agents',
778
+ },
779
+ 'pydantic-ai': {
780
+ label: 'Pydantic AI',
781
+ importLine: 'from intutic_clawde.gate import guard, guard_tools',
782
+ docsSlug: 'pydantic-ai',
783
+ },
784
+ smolagents: {
785
+ label: 'smolagents',
786
+ importLine: 'from intutic_clawde.gate import guard, guard_tools',
787
+ docsSlug: 'smolagents',
788
+ },
789
+ // A4: AWS Strands Agents — dedicated adapter module from day one
790
+ // (intutic_clawde.gate.adapters.strands), unlike the four `pipExtra`-less
791
+ // rows above that started on the framework-agnostic helpers.
792
+ strands: {
793
+ label: 'Strands Agents',
794
+ pipExtra: 'strands',
795
+ importLine: 'from intutic_clawde.gate.adapters.strands import IntuticHookProvider',
796
+ docsSlug: 'strands',
797
+ },
798
+ };
799
+ /** Shared formatter for every Wave 1 SDK-gated framework — see SDK_GATED_FRAMEWORKS. */
800
+ function formatSdkGatedEnv(sops, proxyUrl, info) {
801
+ const pipInstall = info.pipExtra ? `intutic-clawde[${info.pipExtra}]` : 'intutic-clawde';
802
+ return (formatCodex(sops, proxyUrl) +
803
+ '\n' +
804
+ `# These env vars govern LLM egress only. ${info.label} tools run in your own\n` +
805
+ '# Python process, where no config or hook file can gate them — the\n' +
806
+ '# blocking tool gate ships SDK-side:\n' +
807
+ `# pip install ${pipInstall}\n` +
808
+ `# ${info.importLine}\n` +
809
+ `# See https://docs.intutic.ai/integrations/${info.docsSlug}\n`);
810
+ }
811
+ /**
812
+ * T2: two more frameworks in LangGraph's family — tools are plain
813
+ * objects/callables in the agent's own Node.js process, so the daemon has no
814
+ * on-disk hook/config file to gate a call with, and both write the same
815
+ * `.env.intutic` shape the Python family does. JS/TS-native, so the pointer
816
+ * comment reads `npm install`/`import { ... } from '@intutic/gate/<subpath>'`
817
+ * rather than `pip install`/`from intutic_clawde...` — see
818
+ * `formatJsSdkGatedEnv`.
819
+ */
820
+ const JS_SDK_GATED_FRAMEWORKS = {
821
+ mastra: {
822
+ label: 'Mastra',
823
+ importLine: "import { intuticHooks } from '@intutic/gate/mastra'",
824
+ usageSummary: "new Agent({ ..., hooks: intuticHooks() }) — Mastra's beforeToolCall veto point. NOTE: " +
825
+ 'per-call `hooks` on .generate()/.stream() OVERRIDE this, not merge with it.',
826
+ docsSlug: 'mastra',
827
+ },
828
+ 'vercel-ai-sdk': {
829
+ label: 'Vercel AI SDK',
830
+ importLine: "import { intuticToolApproval, withIntuticProxy } from '@intutic/gate/vercel'",
831
+ usageSummary: 'generateText({ ..., toolApproval: intuticToolApproval() }) — the toolApproval veto ' +
832
+ 'point. NOTE: this framework has NO env-var LLM-egress routing; the vars above do not ' +
833
+ 'route it — use withIntuticProxy(createOpenAI)(...) or equivalent in code.',
834
+ docsSlug: 'vercel-ai-sdk',
835
+ },
836
+ eve: {
837
+ label: 'eve',
838
+ importLine: "import { intuticApproval, intuticAuditHooks } from '@intutic/gate/eve'",
839
+ usageSummary: 'defineTool({ ..., approval: intuticApproval() }) per tool (and intuticConnectionApproval() ' +
840
+ "per MCP/OpenAPI connection) — eve has no agent-level default approval field. NOTE: eve's " +
841
+ 'default AI Gateway model routing is NOT proxy-governable; the vars above only reach a ' +
842
+ 'direct-provider model built in code via withIntuticProxy(...). PREVIEW product.',
843
+ docsSlug: 'eve',
844
+ },
845
+ trueforge: {
846
+ label: 'TrueForge (embedded)',
847
+ importLine: "import { intuticApprovalResponder } from '@intutic/gate/trueforge'",
848
+ usageSummary: 'intuticApprovalResponder() answers tool.approval_required pauses with a real ' +
849
+ 'Gate.guard() verdict, producing user.tool_approval items for your next ' +
850
+ 'session.createTurn() call — TrueForge has no synchronous approval callback to hang a ' +
851
+ "function off (confirmed against a real install); see that module's doc. Covers ONLY " +
852
+ 'the embedded-library deployment mode — TrueForge run as its own standalone/hosted ' +
853
+ 'server is not yet supported.',
854
+ docsSlug: 'trueforge',
855
+ },
856
+ 'ai-sdk-harness': {
857
+ label: 'AI SDK Harness',
858
+ importLine: "import { intuticApprovalResponder, intuticStaticApprovals, recommendedHarnessSettings } from '@intutic/gate/harness'",
859
+ usageSummary: 'toolApproval: intuticStaticApprovals(tools) routes every custom tool through the approval ' +
860
+ 'flow; answer pauses with intuticApprovalResponder(). NOTE: built-in sandbox tools ignore ' +
861
+ "toolApproval entirely — set permissionMode (defaults to 'allow-all') via " +
862
+ 'recommendedHarnessSettings(); sandbox egress never crosses this proxy — set a sandbox ' +
863
+ 'networkPolicy.',
864
+ docsSlug: 'ai-sdk-harness',
865
+ envPreamble: [
866
+ 'These env vars govern LLM egress from THIS process only. AI SDK Harness',
867
+ 'agents execute their tools server-side in Vercel Sandbox microVMs — that',
868
+ 'traffic never crosses this proxy, and no config or hook file can gate the',
869
+ 'sandbox from here. The blocking tool gate (custom host-executed tools',
870
+ 'only) ships SDK-side:',
871
+ ],
872
+ },
873
+ 'ai-sdk-workflow': {
874
+ label: 'AI SDK Workflow',
875
+ importLine: "import { intuticNeedsApproval, withIntuticApproval } from '@intutic/gate/workflow'",
876
+ usageSummary: 'new WorkflowAgent({ ..., tools: withIntuticApproval(tools) }) — attaches an async ' +
877
+ 'needsApproval per tool (WorkflowAgent itself has no approval option). BLOCK throws a ' +
878
+ 'FatalError-compatible refusal so the durable runtime aborts instead of retry-looping ' +
879
+ "the denial; ALLOW resolves false (or true with { onAllow: 'human' }).",
880
+ docsSlug: 'ai-sdk-workflow',
881
+ },
882
+ };
883
+ /** Shared formatter for every T2 JS/TS SDK-gated framework — see
884
+ * JS_SDK_GATED_FRAMEWORKS. Same env-var block `formatSdkGatedEnv` writes,
885
+ * but "npm install"/an ESM import line/"Node.js process" in the pointer
886
+ * comment, not "pip install"/"Python process". */
887
+ function formatJsSdkGatedEnv(sops, proxyUrl, info) {
888
+ const preamble = info.envPreamble ?? [
889
+ `These env vars govern LLM egress only. ${info.label} tools run in your own`,
890
+ 'Node.js process, where no config or hook file can gate them — the',
891
+ 'blocking tool gate ships SDK-side:',
892
+ ];
893
+ return (formatCodex(sops, proxyUrl) +
894
+ '\n' +
895
+ preamble.map((line) => `# ${line}\n`).join('') +
896
+ `# npm install @intutic/gate\n` +
897
+ `# ${info.importLine}\n` +
898
+ `# ${info.usageSummary}\n` +
899
+ `# See https://docs.intutic.ai/integrations/${info.docsSlug}\n`);
900
+ }
478
901
  // ─── Atomic file write ───────────────────────────────────────────────
479
902
  /**
480
903
  * Write content to a file atomically.
@@ -484,8 +907,12 @@ function formatCodex(sops, proxyUrl) {
484
907
  *
485
908
  * If bypassEnforcementTier === 'immutable', clears the macOS user-immutable
486
909
  * flag before writing and re-sets it after (WS-5 Q3).
910
+ *
911
+ * Exported for reuse by other full-regenerate writers in this daemon (e.g.
912
+ * `lib/decisionsDigest.ts`) that want the same write-tmp-then-rename
913
+ * atomicity without duplicating it.
487
914
  */
488
- async function atomicWrite(filePath, content, bypassEnforcementTier) {
915
+ export async function atomicWrite(filePath, content, bypassEnforcementTier) {
489
916
  const dir = node_path.dirname(filePath);
490
917
  await node_fs.mkdir(dir, { recursive: true });
491
918
  // Clear immutable flag before writing (macOS only, opt-in)