@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
package/dist/syncLoop.js CHANGED
@@ -23,33 +23,165 @@ import * as path from 'node:path';
23
23
  import * as fs from 'node:fs';
24
24
  import * as os from 'node:os';
25
25
  import { newIso } from '@intutic/id';
26
- import { writeConfigFiles, HARNESS_FILES, applyConfigEdits } from './configWriter.js';
26
+ import { deriveEnforcementInputs } from '@intutic/shared-types';
27
+ import { writeConfigFiles, HARNESS_FILES, applyConfigEdits, loadLocalSopEntries } from './configWriter.js';
28
+ import { injectMcpServer } from './harness/mcpAutoWrite.js';
29
+ import { discoverWorktrees } from './lib/gitWorktrees.js';
27
30
  import { computeFileHashes } from './hashReporter.js';
28
31
  import { loadIntegrity, saveIntegrity } from './integrityStore.js';
29
- import { drainHookEvents } from './harness/claudeCodeHooks.js';
32
+ import { drainHookEvents, drainReviewRequests, REVIEW_REQUESTS_LOG, parseSopConstraints, } from './harness/claudeCodeHooks.js';
30
33
  import { writeRuntimeEnv } from './lib/runtimeEnv.js';
34
+ import { refreshPolicySnapshot } from './lib/policySnapshot.js';
35
+ import { refreshApprovedBypasses } from './lib/approvedBypasses.js';
36
+ import { refreshEgressPolicy } from './lib/egressPolicy.js';
37
+ import { refreshDecisionsDigest } from './lib/decisionsDigest.js';
31
38
  import { runComplianceProbes } from './lib/complianceProbes.js';
32
39
  import { startWatcher } from './watcher/driftWatcher.js';
33
40
  import { shouldCaptureThisIteration, captureAndUpload } from './configReader.js';
34
41
  import { watch } from 'chokidar';
35
- import Redis from 'ioredis';
42
+ // Named import, not default: this package is ESM ("type": "module") and under
43
+ // Node16 resolution the default import of ioredis's CJS typings resolves to the
44
+ // module namespace, which is neither constructable nor usable as a type. The
45
+ // named export is the class itself. (The old `new (Redis as any)(...)` cast
46
+ // silenced exactly this, at the cost of also erasing the instance type.)
47
+ import { Redis } from 'ioredis';
36
48
  import { TrajectoryMonitor } from './trajectoryMonitor.js';
49
+ import { collectAgentReport, reportAgent, fetchLocalProxyInstanceId } from './agentReporter.js';
50
+ import { startHarnessSession, endAllOpenSessions } from './sessionReporter.js';
51
+ /** Narrow an unknown thrown value to a printable message. */
52
+ function errorMessage(err) {
53
+ return err instanceof Error ? err.message : String(err);
54
+ }
55
+ /**
56
+ * Structural guard for trace events arriving over the `trace:live:*` Valkey
57
+ * channel. The payload is JSON off the wire, so it is genuinely unknown here;
58
+ * `handleTraceEvent` indexes `sessionId`/`workspaceId` immediately and a
59
+ * malformed publish would otherwise fault inside the buffer bookkeeping.
60
+ */
61
+ function isTraceEvent(value) {
62
+ if (typeof value !== 'object' || value === null)
63
+ return false;
64
+ const e = value;
65
+ return typeof e['sessionId'] === 'string' && typeof e['workspaceId'] === 'string';
66
+ }
67
+ /**
68
+ * Turns a non-clean `scanSkillContent` result — already computed as part of
69
+ * `collectAgentReport`'s `facets.skills` — into a `skill_flagged` hook event
70
+ * (TD-358).
71
+ *
72
+ * Deliberately reuses the report the daemon already built for this cycle
73
+ * rather than re-scanning: `agentReporter.ts`'s `collectSkills` runs
74
+ * `scanSkillContent` on every `.agents/skills/**\/SKILL.md` as part of
75
+ * building `facets.skills` already, so this only turns a result the daemon
76
+ * already has into a visible signal — it does not add a second scan pass.
77
+ *
78
+ * Appends directly to `.intutic/events/hook-events.jsonl`, the SAME file
79
+ * every emitted PreToolUse gate's `log_event`/`logEvent` writes to and the
80
+ * SAME mechanism `onDriftDetected` below already uses for `config_tamper` —
81
+ * not a new event pipeline. `drainHookEvents` (`claudeCodeHooks.ts`) picks
82
+ * this line up on its next pass exactly like a gate-written one, because it
83
+ * only ever reads the file; it does not care who appended a given line.
84
+ *
85
+ * Report-only, per `scanSkillContent`'s own doc comment on the unmeasured
86
+ * false-positive rate against real, benign skill markdown: this NEVER
87
+ * blocks, mutates, or removes a skill. `hookEvents.ts`'s
88
+ * `HookEventSchema.event` enum (control plane) counts `skill_flagged`
89
+ * exactly like `tool_flagged` — advisory telemetry, not an incident.
90
+ *
91
+ * A failure to append is caught and warned, never thrown: a governance
92
+ * telemetry write must not be able to abort the sync cycle it rides in on.
93
+ */
94
+ export function emitSkillFlaggedEvents(opts) {
95
+ const eventsLog = path.join(opts.workspaceRoot, '.intutic', 'events', 'hook-events.jsonl');
96
+ for (const skill of opts.skills) {
97
+ if (!skill.scanned || skill.clean)
98
+ continue;
99
+ const key = `${skill.source}:${skill.name}`;
100
+ if (opts.alreadyEmitted.has(key))
101
+ continue;
102
+ opts.alreadyEmitted.add(key);
103
+ try {
104
+ const ts = newIso();
105
+ const entry = JSON.stringify({
106
+ event: 'skill_flagged',
107
+ toolName: `skill:${skill.name}`,
108
+ reason: `scanSkillContent found ${skill.findingsCount} finding(s) in ` +
109
+ `${skill.source}/${skill.name}/SKILL.md — advisory only, see skillScan.ts`,
110
+ workspaceId: opts.workspaceId,
111
+ harnessType: opts.harnessType,
112
+ timestamp: ts,
113
+ incidentId: crypto
114
+ .createHash('sha1')
115
+ .update(ts + skill.name + opts.workspaceId)
116
+ .digest('hex')
117
+ .slice(0, 16),
118
+ filePath: `${skill.source}/${skill.name}/SKILL.md`,
119
+ }) + '\n';
120
+ fs.appendFileSync(eventsLog, entry, { flag: 'a' });
121
+ }
122
+ catch (err) {
123
+ console.warn('[sync-daemon] failed to write skill_flagged event (non-fatal):', err);
124
+ }
125
+ }
126
+ }
37
127
  // ─── Default constants ───────────────────────────────────────────────
38
128
  const DEFAULT_POLL_INTERVAL_MS = 30_000;
39
- // ─── Core sync loop ──────────────────────────────────────────────────
40
129
  /**
41
- * Start the sync loop. This function runs indefinitely until the
42
- * AbortSignal is triggered or the process exits.
130
+ * Best-effort reachability probe for the control plane, modelled on
131
+ * `agentReporter.fetchEgressStatus()`'s bounded-fetch-and-swallow shape.
43
132
  *
44
- * @param options - Sync loop configuration.
133
+ * The trajectory monitor used to start on `VALKEY_URL || apiKey` alone —
134
+ * never checking `controlPlaneUrl` at all, so an unset URL produced
135
+ * `undefined/api/v1/...` on every submit attempt. This is the missing check:
136
+ * a monitor that will publish its summaries to a URL that does not resolve
137
+ * (or does not answer) should not start believing it has somewhere to send
138
+ * them.
139
+ */
140
+ async function controlPlaneIsReachable(controlPlaneUrl) {
141
+ if (!controlPlaneUrl)
142
+ return false;
143
+ try {
144
+ const res = await fetch(`${controlPlaneUrl}/healthz`, { signal: AbortSignal.timeout(2000) });
145
+ return res.ok;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ /**
152
+ * The `review_before:` tokens the snapshot compiles into hold rules: from the
153
+ * synced SOPs' front matter and JSON blocks, the workspace settings, and the
154
+ * local `.intutic/sops`. The same parse the Claude Code writer runs for its
155
+ * pattern blacklist, so the two cannot disagree about what is held.
45
156
  */
157
+ export async function localHoldTokensFor(workspaceRoot, sops, settings, harnesses = []) {
158
+ const local = await loadLocalSopEntries(workspaceRoot, harnesses);
159
+ return parseSopConstraints([...sops, ...local], settings).reviewBefore;
160
+ }
161
+ /**
162
+ * Refresh the three gate-side caches every hook reads: the policy snapshot
163
+ * (`~/.intutic/hooks/policy-snapshot.rules`), the approved review-hold
164
+ * bypasses and the central egress policy. Called on EVERY sync cycle by both
165
+ * loops — this one and `intutic connect`'s — never only when the config
166
+ * version moved: policy changes without the version changing, and before
167
+ * this helper existed `connect` refreshed once at startup and never again, so
168
+ * a guardrail promoted mid-session reached a connected machine only on
169
+ * restart (TD-488). None of the three throw; the return says which of them
170
+ * landed.
171
+ */
172
+ export async function refreshGateCaches(opts) {
173
+ const snapshot = (await refreshPolicySnapshot(opts)) !== null;
174
+ const bypasses = (await refreshApprovedBypasses(opts)) !== null;
175
+ const egress = (await refreshEgressPolicy(opts)) !== null;
176
+ return { snapshot, bypasses, egress };
177
+ }
46
178
  export async function startSyncLoop(options) {
47
- const { controlPlaneUrl, apiKey, workspaceId, workspaceRoot, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, onSync, signal, adapterId, } = options;
179
+ const { controlPlaneUrl, apiKey, workspaceId, workspaceRoot, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, onSync, signal, } = options;
48
180
  // Step -1: Start the Trajectory Monitor & Valkey Subscriber
49
181
  let trajectoryMonitor = null;
50
182
  let trajectorySubscriber = null;
51
- if (process.env.VALKEY_URL || apiKey) {
52
- const valkeyUrl = process.env.VALKEY_URL ?? 'redis://127.0.0.1:6379';
183
+ const valkeyUrl = process.env.VALKEY_URL ?? 'redis://127.0.0.1:6379';
184
+ if ((process.env.VALKEY_URL || apiKey) && (await controlPlaneIsReachable(controlPlaneUrl))) {
53
185
  trajectoryMonitor = new TrajectoryMonitor({
54
186
  valkeyUrl,
55
187
  controlPlaneUrl,
@@ -61,9 +193,13 @@ export async function startSyncLoop(options) {
61
193
  await trajectoryMonitor.start();
62
194
  trajectorySubscriber = new Redis(valkeyUrl);
63
195
  await trajectorySubscriber.psubscribe('trace:live:*');
64
- trajectorySubscriber.on('pmessage', (pattern, channel, message) => {
196
+ trajectorySubscriber.on('pmessage', (_pattern, _channel, message) => {
65
197
  try {
66
198
  const event = JSON.parse(message);
199
+ if (!isTraceEvent(event)) {
200
+ console.warn('[sync-daemon] Discarding malformed trajectory trace event');
201
+ return;
202
+ }
67
203
  trajectoryMonitor?.handleTraceEvent(event);
68
204
  }
69
205
  catch (err) {
@@ -82,14 +218,32 @@ export async function startSyncLoop(options) {
82
218
  let latestSops = [];
83
219
  let latestProxyUrl = '';
84
220
  let latestHarnesses = [];
221
+ let latestGovernanceInputs = {};
85
222
  // Step 0: Write runtime env file (hook scripts source this for credentials)
86
223
  // Runs once at startup and then again on every iteration.
87
224
  try {
88
- await writeRuntimeEnv({ controlPlaneUrl, apiKey, workspaceId });
225
+ // The Valkey URL rides along only when this loop is itself using one
226
+ // (the trajectory monitor connected): the MCP proxies share their session
227
+ // window through it (Wave 5.3, TD-437).
228
+ await writeRuntimeEnv({ controlPlaneUrl, apiKey, workspaceId, valkeyUrl: trajectoryMonitor ? valkeyUrl : undefined });
89
229
  }
90
230
  catch (err) {
91
231
  console.warn('[sync-daemon] Could not write runtime env file (non-fatal):', err);
92
232
  }
233
+ // Step 0b–0d: Refresh the three gate-side caches every hook reads —
234
+ // policy snapshot, approved review_before bypasses, central egress policy.
235
+ //
236
+ // Deliberately here beside writeRuntimeEnv, and deliberately NOT inside
237
+ // writeConfigFiles: that path is gated on the config version, so a snapshot
238
+ // written there would go stale by construction on every cycle where the
239
+ // version did not move. Policy changes without the config version changing,
240
+ // and a bypass an operator just approved has to reach the gate before the
241
+ // developer's very next retry. One helper, shared with `intutic connect`'s
242
+ // own loop, so the two cannot drift apart again (TD-488).
243
+ await refreshGateCaches({
244
+ controlPlaneUrl, apiKey, workspaceId,
245
+ localHoldTokens: await localHoldTokensFor(workspaceRoot, [], undefined),
246
+ });
93
247
  // Try to sync offline traces back to PostgreSQL
94
248
  try {
95
249
  await syncOfflineTraces(controlPlaneUrl, apiKey);
@@ -101,6 +255,10 @@ export async function startSyncLoop(options) {
101
255
  // Drains immediately when the hook-events.jsonl file changes (written by hook scripts).
102
256
  // Falls back to a 60s safety-net interval for NFS/Docker mounts where inotify may not fire.
103
257
  const hookEventsLog = `${workspaceRoot}/.intutic/events/hook-events.jsonl`;
258
+ // The review-hold log, watched alongside it. Two named files rather than the
259
+ // directory: a directory watch would also fire on `.rejected.jsonl`, which the
260
+ // drain itself writes, and a drain that retriggers itself is a loop.
261
+ const reviewHoldsLog = `${workspaceRoot}/${REVIEW_REQUESTS_LOG}`;
104
262
  let fsWatcher = null;
105
263
  let drainSafetyTimer = null;
106
264
  const runDrain = async () => {
@@ -113,9 +271,20 @@ export async function startSyncLoop(options) {
113
271
  catch (err) {
114
272
  console.warn('[sync-daemon] Hook event drain error (non-fatal):', err);
115
273
  }
274
+ // Separate try: a failure draining events must not skip the holds. They go
275
+ // to different endpoints and one being down says nothing about the other.
276
+ try {
277
+ const held = await drainReviewRequests(workspaceRoot, controlPlaneUrl, apiKey);
278
+ if (held > 0) {
279
+ console.log(`[sync-daemon] Drained ${held} review hold(s) to control plane`);
280
+ }
281
+ }
282
+ catch (err) {
283
+ console.warn('[sync-daemon] Review hold drain error (non-fatal):', err);
284
+ }
116
285
  };
117
286
  try {
118
- fsWatcher = watch(hookEventsLog, { ignoreInitial: true, persistent: false });
287
+ fsWatcher = watch([hookEventsLog, reviewHoldsLog], { ignoreInitial: true, persistent: false });
119
288
  fsWatcher.on('change', runDrain);
120
289
  fsWatcher.on('add', runDrain);
121
290
  }
@@ -173,7 +342,6 @@ export async function startSyncLoop(options) {
173
342
  workspaceId,
174
343
  workspaceRoot,
175
344
  onSync,
176
- adapterId,
177
345
  });
178
346
  // Update cached state for drift handler
179
347
  if (result) {
@@ -181,6 +349,7 @@ export async function startSyncLoop(options) {
181
349
  latestSops = result.sops ?? [];
182
350
  latestProxyUrl = result.proxyUrl ?? '';
183
351
  latestHarnesses = result.harnesses ?? [];
352
+ latestGovernanceInputs = result.harnessGovernanceInputs ?? {};
184
353
  await writeRuntimeEnv({
185
354
  controlPlaneUrl,
186
355
  apiKey,
@@ -188,7 +357,32 @@ export async function startSyncLoop(options) {
188
357
  mcpProxyFailBehavior: result.settings?.mcpProxyFailBehavior,
189
358
  mcpProxyMode: result.settings?.mcpProxyMode,
190
359
  bypassEnforcementTier: result.settings?.bypassEnforcementTier,
360
+ valkeyUrl: trajectoryMonitor ? valkeyUrl : undefined,
361
+ });
362
+ // Same reasoning as Step 0b–0d: policy rides the sync cycle, not the
363
+ // config version.
364
+ await refreshGateCaches({
365
+ controlPlaneUrl, apiKey, workspaceId,
366
+ localHoldTokens: await localHoldTokensFor(workspaceRoot, latestSops, latestSettings, latestHarnesses),
191
367
  });
368
+ // Governed decisions log — opt-in only (WorkspaceSettings.decisionsLogEnabled,
369
+ // default off: a growing context file is token spend the product must not
370
+ // silently impose on every workspace). No Step-0-equivalent unconditional
371
+ // call before the loop, unlike egress/policy/bypasses above: this gate can
372
+ // only be evaluated once `result.settings` is known, which is not yet true
373
+ // before the first iteration completes. When disabled, this is simply not
374
+ // called — existing files (if any, from before the workspace disabled the
375
+ // feature) are left as last written rather than force-deleted or overwritten
376
+ // with a stale/wrong "disabled" digest.
377
+ if (result.settings?.decisionsLogEnabled) {
378
+ await refreshDecisionsDigest({
379
+ controlPlaneUrl,
380
+ apiKey,
381
+ workspaceId,
382
+ workspaceRoot,
383
+ harnesses: latestHarnesses,
384
+ });
385
+ }
192
386
  }
193
387
  // Start the drift watcher on first successful sync (once harnesses are known)
194
388
  if (!driftWatcher && latestHarnesses.length > 0) {
@@ -216,7 +410,7 @@ export async function startSyncLoop(options) {
216
410
  // 4b. Capture harness configs back to control plane (every Nth iteration)
217
411
  if (shouldCaptureThisIteration(iterationCount)) {
218
412
  try {
219
- await captureAndUpload(controlPlaneUrl, apiKey, workspaceId, workspaceRoot, latestHarnesses);
413
+ await captureAndUpload(controlPlaneUrl, apiKey, workspaceId, workspaceRoot, latestHarnesses, latestGovernanceInputs);
220
414
  }
221
415
  catch (err) {
222
416
  console.warn('[sync-daemon] Config capture failed (non-fatal):', err);
@@ -234,6 +428,9 @@ export async function startSyncLoop(options) {
234
428
  driftWatcher?.stop();
235
429
  if (drainSafetyTimer)
236
430
  clearInterval(drainSafetyTimer);
431
+ // Mark this run's harness sessions ended so the dashboard's live list
432
+ // reflects reality.
433
+ await endAllOpenSessions(controlPlaneUrl, apiKey);
237
434
  }
238
435
  /**
239
436
  * Execute a single sync iteration:
@@ -243,9 +440,16 @@ export async function startSyncLoop(options) {
243
440
  * 4. Report status back to control plane
244
441
  * 5. Update local integrity store
245
442
  * 6. Call onSync callback
443
+ *
444
+ * Exported (in addition to being used internally by `startSyncLoop`'s
445
+ * polling loop) as a test seam — it is the unit that exercises exactly one
446
+ * cycle's worth of work, including the injectMcpServer call (step 3c),
447
+ * without the surrounding while-loop's extra one-time/Nth-iteration network
448
+ * calls (policy snapshot, approved bypasses, egress policy, compliance
449
+ * probes, drift watcher, decisions digest, config capture).
246
450
  */
247
- async function runSyncIteration(ctx) {
248
- const { controlPlaneUrl, apiKey, workspaceId, workspaceRoot, onSync, adapterId } = ctx;
451
+ export async function runSyncIteration(ctx) {
452
+ const { controlPlaneUrl, apiKey, workspaceId, workspaceRoot, onSync } = ctx;
249
453
  // 1. Fetch config from control plane
250
454
  const config = await fetchConfig(controlPlaneUrl, apiKey, workspaceId);
251
455
  // 2. Load local integrity store
@@ -276,8 +480,21 @@ async function runSyncIteration(ctx) {
276
480
  : config.appliedEdits.filter(edit => !localAppliedIds.includes(edit.suggestionId));
277
481
  if (toApply.length > 0) {
278
482
  try {
279
- await applyConfigEdits(workspaceRoot, toApply, config.settings.bypassEnforcementTier);
280
- const newlyAppliedIds = Array.from(new Set([...localAppliedIds, ...toApply.map(e => e.suggestionId)]));
483
+ const results = await applyConfigEdits(workspaceRoot, toApply, config.settings.bypassEnforcementTier);
484
+ // Ack every result back to control-plane (TD-349). Best-effort per
485
+ // suggestion — one failed ack must not block the others or abort
486
+ // the sync cycle; see reportApplyResult's own non-fatal handling.
487
+ for (const result of results) {
488
+ await reportApplyResult(controlPlaneUrl, apiKey, result.suggestionId, result);
489
+ }
490
+ // Only remember ids that fully landed (every operation for that
491
+ // suggestion applied). Recording every ATTEMPTED id regardless of
492
+ // outcome (the old behavior) meant a fuzzy-match miss was silently
493
+ // never retried — the id was in applied-suggestions.json forever,
494
+ // and forceApply is the only other thing that re-attempts an id
495
+ // already in this file. A failed edit must remain retryable.
496
+ const landedIds = results.filter((r) => r.ok).map((r) => r.suggestionId);
497
+ const newlyAppliedIds = Array.from(new Set([...localAppliedIds, ...landedIds]));
281
498
  try {
282
499
  fs.mkdirSync(path.dirname(appliedSuggestionsPath), { recursive: true });
283
500
  fs.writeFileSync(appliedSuggestionsPath, JSON.stringify(newlyAppliedIds, null, 2), 'utf-8');
@@ -291,6 +508,56 @@ async function runSyncIteration(ctx) {
291
508
  }
292
509
  }
293
510
  }
511
+ // 3c. Proxy-wrap MCP servers across all supported harness configs. This is
512
+ // a continuous invariant, not a one-shot done only at `connect` time: a
513
+ // server a user adds to a harness config after their first `connect` would
514
+ // otherwise never get wrapped and stay invisible to governance. Safe to run
515
+ // every cycle because mcpAutoWrite's writeJsonFile is write-if-changed — an
516
+ // already-wrapped, unchanged config writes zero bytes. Non-fatal, matching
517
+ // step 3b above: a failure here must not abort the sync cycle.
518
+ try {
519
+ await injectMcpServer(workspaceRoot, workspaceId);
520
+ }
521
+ catch (err) {
522
+ console.warn('[sync-daemon] injectMcpServer failed (non-fatal):', err);
523
+ }
524
+ // 3d. Worktree propagation (O1). `workspaceRoot` above only ever covers the
525
+ // ONE checkout this daemon was pointed at — a `git worktree add`-created
526
+ // sibling checkout has its own independent working tree, and every
527
+ // project-tier governance file this product writes is untracked, so none
528
+ // of it exists in a worktree by default (see gitWorktrees.ts's module doc
529
+ // for the full mechanics and why Xirp is what surfaced this). Discovering
530
+ // worktrees fresh every cycle — never caching the list across
531
+ // iterations — is what makes worktree REMOVAL fall out for free: a
532
+ // worktree `git worktree remove`d since the last cycle simply stops being
533
+ // returned here, with no stale-entry cleanup logic required.
534
+ //
535
+ // Config-content writes reuse the SAME `config.configVersion > localVersion`
536
+ // gate step 3 already used — every worktree of one repo shares the one
537
+ // control-plane config, so re-deriving per-worktree versioning would just
538
+ // reproduce a decision already made above. `injectMcpServer` runs
539
+ // unconditionally per worktree, same as it does for the main root: it is
540
+ // write-if-changed internally (mcpAutoWrite's writeJsonFile), so an
541
+ // unchanged worktree costs a few file reads and no writes.
542
+ try {
543
+ const worktrees = await discoverWorktrees(workspaceRoot);
544
+ for (const worktreeRoot of worktrees) {
545
+ if (worktreeRoot === workspaceRoot)
546
+ continue; // already covered above
547
+ try {
548
+ if (config.configVersion > localVersion) {
549
+ await writeConfigFiles(worktreeRoot, config.sops, config.proxyUrl, extractHarnesses(config));
550
+ }
551
+ await injectMcpServer(worktreeRoot, workspaceId);
552
+ }
553
+ catch (err) {
554
+ console.warn(`[sync-daemon] worktree propagation failed for ${worktreeRoot} (non-fatal):`, err);
555
+ }
556
+ }
557
+ }
558
+ catch (err) {
559
+ console.warn('[sync-daemon] discoverWorktrees failed (non-fatal):', err);
560
+ }
294
561
  // 4. Compute file hashes for drift detection
295
562
  const harnesses = extractHarnesses(config);
296
563
  const canonicalHashes = buildCanonicalHashMap(config);
@@ -306,6 +573,71 @@ async function runSyncIteration(ctx) {
306
573
  await reportStatus(controlPlaneUrl, apiKey, workspaceId, harness, harnessHashes);
307
574
  }
308
575
  }
576
+ // 5b. Register each harness as a durable agent with its facets, so the
577
+ // dashboard agent graph + posture ring stay live. Best-effort: a failed
578
+ // report never blocks the sync loop.
579
+ //
580
+ // Skills scanned this cycle whose finding has already been emitted, keyed
581
+ // `source:name`. `collectAgentReport` recomputes the same workspace-wide
582
+ // skill scan once per harness in this loop — a skill is not harness-
583
+ // specific — so without this a workspace with two active harnesses would
584
+ // double-emit every flagged skill every cycle.
585
+ const skillFlaggedThisCycle = new Set();
586
+ // Per-harness governance-coverage enforcement signals for this cycle,
587
+ // derived from the same facets `collectAgentReport` just computed — see
588
+ // `SyncResult.harnessGovernanceInputs`'s doc comment. Uses the shared
589
+ // `deriveEnforcementInputs` (`@intutic/shared-types`, TD-443) so this
590
+ // mapping cannot drift from `harnessGradeSweep.ts`'s control-plane-side
591
+ // sweep the way the two hand-kept copies previously did — safe to depend
592
+ // on since `@intutic/shared-types` is a leaf package, not
593
+ // `services/control-plane` itself, so this publicly-mirrored module still
594
+ // never imports from the enterprise-only service.
595
+ const harnessGovernanceInputs = {};
596
+ // The local proxy's instance id, read once per iteration (not per harness):
597
+ // step 5c registers each harness's git/task context onto that process's own
598
+ // session row (TD-231, Wave 5.6). `null` — no local proxy, or a shared
599
+ // gateway — keeps the `ses_` fallback. Skipped when there is no harness to
600
+ // report, so a workspace with no SOP targets never probes the proxy.
601
+ const proxyInstanceId = harnesses.length > 0 ? await fetchLocalProxyInstanceId() : null;
602
+ for (const harness of harnesses) {
603
+ const filename = HARNESS_FILES[harness];
604
+ const configSynced = filename
605
+ ? !fileHashes.some((f) => f.filePath === filename && f.drifted)
606
+ : false;
607
+ const report = await collectAgentReport({
608
+ workspaceRoot,
609
+ harnessType: harness,
610
+ configSynced,
611
+ dlpEnabled: true, // the daemon-managed proxy ships DLP on by default
612
+ policyEnforced: Boolean(controlPlaneUrl),
613
+ allowLocalVaults: config.settings?.allowLocalMemoryVaults,
614
+ });
615
+ await reportAgent(controlPlaneUrl, apiKey, workspaceId, report);
616
+ harnessGovernanceInputs[harness] = deriveEnforcementInputs(report.facets);
617
+ // 5b-i. Skill-content scan findings, surfaced as `skill_flagged` events
618
+ // (TD-358). See `emitSkillFlaggedEvents`'s own doc comment for why this
619
+ // rides the existing hook-events drain rather than a new pipeline.
620
+ emitSkillFlaggedEvents({
621
+ workspaceRoot,
622
+ workspaceId,
623
+ harnessType: harness,
624
+ skills: report.facets.skills,
625
+ alreadyEmitted: skillFlaggedThisCycle,
626
+ });
627
+ // 5c. Report the harness session (once per harness per proxy process —
628
+ // the reporter dedupes). This is what switches on branch/commit capture
629
+ // and the task-context cascade. With a local proxy the context lands on
630
+ // the proxy's own row, the one its traces are filed under; without one a
631
+ // `ses_` row is opened (see sessionReporter.ts).
632
+ await startHarnessSession({
633
+ controlPlaneUrl,
634
+ apiKey,
635
+ workspaceId,
636
+ harnessType: harness,
637
+ workspaceRoot,
638
+ ...(proxyInstanceId ? { proxyInstanceId } : {}),
639
+ });
640
+ }
309
641
  // 6. Update local integrity store
310
642
  const newFiles = {};
311
643
  for (const fh of fileHashes) {
@@ -318,48 +650,13 @@ async function runSyncIteration(ctx) {
318
650
  configVersion: config.configVersion,
319
651
  files: newFiles,
320
652
  });
321
- // 6b. ContextGraph Indexing Scan & Sync (WS1: LLD #16)
322
- if (adapterId) {
323
- try {
324
- const { BrainIndexer } = await import('./brainIndexer.js');
325
- const { loadContextIntegrity, saveContextIntegrity } = await import('./integrityStore.js');
326
- const scanResult = await BrainIndexer.scanWorkspace(workspaceRoot);
327
- const contextIntegrity = await loadContextIntegrity(workspaceRoot);
328
- const delta = BrainIndexer.computeDelta(scanResult, contextIntegrity);
329
- if (delta.upserted.length > 0 || delta.deleted.length > 0) {
330
- const syncUrl = `${controlPlaneUrl}/api/v1/context/sync`;
331
- const res = await fetch(syncUrl, {
332
- method: 'POST',
333
- headers: {
334
- 'Content-Type': 'application/json',
335
- Authorization: `Bearer ${apiKey}`,
336
- },
337
- body: JSON.stringify({
338
- adapterId,
339
- delta,
340
- }),
341
- signal: AbortSignal.timeout(15_000),
342
- });
343
- if (res.ok) {
344
- const newContextFiles = {};
345
- for (const [filePath, file] of Object.entries(scanResult.files)) {
346
- newContextFiles[filePath] = file.hash;
347
- }
348
- await saveContextIntegrity(workspaceRoot, {
349
- lastSyncAt: newIso(),
350
- configVersion: config.configVersion,
351
- files: newContextFiles,
352
- });
353
- }
354
- else {
355
- console.warn(`[sync-daemon] ContextGraph sync failed: ${res.status} ${res.statusText}`);
356
- }
357
- }
358
- }
359
- catch (err) {
360
- console.warn('[sync-daemon] ContextGraph sync error:', err);
361
- }
362
- }
653
+ // 6b. ContextGraph indexing — REMOVED.
654
+ //
655
+ // This block scanned the whole workspace every sync cycle and POSTed the
656
+ // delta to /api/v1/context/sync, an ingest stripped from the control plane
657
+ // with the Context Graph feature (commit 9cfc0200). Worse than dead: the
658
+ // integrity file is only written on a 2xx, so every cycle re-scanned and
659
+ // re-posted the same delta forever on every developer machine.
363
660
  // 6c. Hook event drain is now FSEvents-driven (see startSyncLoop).
364
661
  // No per-iteration drain needed here; the chokidar watcher handles it.
365
662
  // 7. Invoke onSync callback
@@ -372,6 +669,7 @@ async function runSyncIteration(ctx) {
372
669
  sops: config.sops,
373
670
  proxyUrl: config.proxyUrl,
374
671
  harnesses,
672
+ harnessGovernanceInputs,
375
673
  };
376
674
  onSync?.(result);
377
675
  return result;
@@ -424,6 +722,40 @@ async function reportStatus(controlPlaneUrl, apiKey, workspaceId, harnessType, f
424
722
  console.warn(`[sync-daemon] reportStatus failed for ${harnessType}: ${res.status} ${res.statusText}`);
425
723
  }
426
724
  }
725
+ /**
726
+ * Ack a SkillOpt config-edit apply outcome back to the control plane
727
+ * (TD-349) — POST /api/v1/skillopt/:suggestionId/apply-result.
728
+ *
729
+ * Same HTTP call shape as `reportGovernanceCoverageSnapshot`
730
+ * (configReader.ts): `fetch` + `AbortSignal.timeout(10_000)`, fully
731
+ * non-fatal. This ack closing the loop is important, but it must never be
732
+ * allowed to abort a sync cycle that otherwise succeeded — a dropped ack
733
+ * just means the suggestion re-syncs and gets acked again next cycle
734
+ * (getSyncConfig re-pushes 'applied' and 'apply_unconfirmed' rows alike).
735
+ */
736
+ async function reportApplyResult(controlPlaneUrl, apiKey, suggestionId, result) {
737
+ try {
738
+ const res = await fetch(`${controlPlaneUrl}/api/v1/skillopt/${suggestionId}/apply-result`, {
739
+ method: 'POST',
740
+ headers: {
741
+ 'Content-Type': 'application/json',
742
+ Authorization: `Bearer ${apiKey}`,
743
+ },
744
+ body: JSON.stringify({
745
+ ok: result.ok,
746
+ perOperation: result.perOperation,
747
+ }),
748
+ signal: AbortSignal.timeout(10_000),
749
+ });
750
+ if (!res.ok) {
751
+ const body = await res.text().catch(() => '');
752
+ console.warn(`[sync-daemon] reportApplyResult failed for ${suggestionId} (${res.status}): ${body}`);
753
+ }
754
+ }
755
+ catch (err) {
756
+ console.warn(`[sync-daemon] reportApplyResult request failed for ${suggestionId}:`, err);
757
+ }
758
+ }
427
759
  // ─── Helpers ─────────────────────────────────────────────────────────
428
760
  /**
429
761
  * Extract unique harness types from a sync config payload.
@@ -492,7 +824,7 @@ export async function syncOfflineTraces(controlPlaneUrl, apiKey) {
492
824
  fs.renameSync(originalPath, syncingPath);
493
825
  }
494
826
  catch (renameErr) {
495
- console.error(`[sync-daemon] Failed to lock/rename file ${file}:`, renameErr.message);
827
+ console.error(`[sync-daemon] Failed to lock/rename file ${file}:`, errorMessage(renameErr));
496
828
  continue;
497
829
  }
498
830
  try {
@@ -526,7 +858,7 @@ export async function syncOfflineTraces(controlPlaneUrl, apiKey) {
526
858
  console.log(`[sync-daemon] Successfully synced ${traces.length} offline traces back from ${file}.`);
527
859
  }
528
860
  catch (err) {
529
- console.error(`[sync-daemon] Failed to sync offline traces back from ${file}:`, err.message);
861
+ console.error(`[sync-daemon] Failed to sync offline traces back from ${file}:`, errorMessage(err));
530
862
  // Revert rename on failure to allow retry on next cycle
531
863
  try {
532
864
  if (fs.existsSync(syncingPath)) {
@@ -534,13 +866,13 @@ export async function syncOfflineTraces(controlPlaneUrl, apiKey) {
534
866
  }
535
867
  }
536
868
  catch (revertErr) {
537
- console.error(`[sync-daemon] Failed to revert rename for ${file}:`, revertErr.message);
869
+ console.error(`[sync-daemon] Failed to revert rename for ${file}:`, errorMessage(revertErr));
538
870
  }
539
871
  }
540
872
  }
541
873
  }
542
874
  catch (err) {
543
- console.error(`[sync-daemon] Failed to scan offline traces directory:`, err.message);
875
+ console.error(`[sync-daemon] Failed to scan offline traces directory:`, errorMessage(err));
544
876
  }
545
877
  }
546
878
  //# sourceMappingURL=syncLoop.js.map