sweet-search 2.5.2 → 2.5.4

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 (155) hide show
  1. package/core/cli.js +24 -3
  2. package/core/graph/graph-expansion.js +215 -36
  3. package/core/graph/graph-extractor.js +196 -11
  4. package/core/graph/graph-search.js +395 -92
  5. package/core/graph/hcgs-generator.js +2 -1
  6. package/core/graph/index.js +2 -0
  7. package/core/graph/repo-map.js +28 -6
  8. package/core/graph/structural-answer-cues.js +168 -0
  9. package/core/graph/structural-callsite-hints.js +40 -0
  10. package/core/graph/structural-context-format.js +40 -0
  11. package/core/graph/structural-context.js +450 -0
  12. package/core/graph/structural-forward-push.js +156 -0
  13. package/core/graph/structural-header-context.js +19 -0
  14. package/core/graph/structural-importance.js +148 -0
  15. package/core/graph/structural-pagerank.js +197 -0
  16. package/core/graph/summary-manager.js +13 -9
  17. package/core/incremental-indexing/application/dirty-scan.mjs +236 -0
  18. package/core/incremental-indexing/application/file-watcher.mjs +197 -0
  19. package/core/incremental-indexing/application/maintenance-handlers.mjs +519 -0
  20. package/core/incremental-indexing/application/maintenance-worker.mjs +380 -0
  21. package/core/incremental-indexing/application/operator-cli.mjs +554 -0
  22. package/core/incremental-indexing/application/production-li-delta.mjs +192 -0
  23. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +107 -0
  24. package/core/incremental-indexing/application/production-reconciler.mjs +583 -0
  25. package/core/incremental-indexing/application/reconciler.mjs +477 -0
  26. package/core/incremental-indexing/application/tombstone-injector.mjs +148 -0
  27. package/core/incremental-indexing/domain/chunk-identity.mjs +260 -0
  28. package/core/incremental-indexing/domain/encoder-deps.mjs +193 -0
  29. package/core/incremental-indexing/domain/encoder-input.mjs +225 -0
  30. package/core/incremental-indexing/domain/interval-autotune.mjs +255 -0
  31. package/core/incremental-indexing/domain/reconcile-counters.mjs +149 -0
  32. package/core/incremental-indexing/domain/watermark-scheduler.mjs +239 -0
  33. package/core/incremental-indexing/infrastructure/artifact-temp-sweep.mjs +163 -0
  34. package/core/incremental-indexing/infrastructure/baseline-readiness.mjs +121 -0
  35. package/core/incremental-indexing/infrastructure/dirty-set.mjs +233 -0
  36. package/core/incremental-indexing/infrastructure/graph-gc.mjs +314 -0
  37. package/core/incremental-indexing/infrastructure/hashing.mjs +298 -0
  38. package/core/incremental-indexing/infrastructure/hcgs-invalidation.mjs +182 -0
  39. package/core/incremental-indexing/infrastructure/li-segment-merge.mjs +278 -0
  40. package/core/incremental-indexing/infrastructure/li-segment-state.mjs +173 -0
  41. package/core/incremental-indexing/infrastructure/lockfile.mjs +119 -0
  42. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +283 -0
  43. package/core/incremental-indexing/infrastructure/manifest.mjs +194 -0
  44. package/core/incremental-indexing/infrastructure/path-filter.mjs +190 -0
  45. package/core/incremental-indexing/infrastructure/reader-heartbeat.mjs +201 -0
  46. package/core/incremental-indexing/infrastructure/schema-migrations.mjs +257 -0
  47. package/core/incremental-indexing/infrastructure/sparse-gram-delta.mjs +335 -0
  48. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +176 -0
  49. package/core/incremental-indexing/infrastructure/staleness-display.mjs +105 -0
  50. package/core/incremental-indexing/infrastructure/tombstone-bitmap.mjs +234 -0
  51. package/core/incremental-indexing/infrastructure/vector-delta-writer.mjs +359 -0
  52. package/core/incremental-indexing/infrastructure/vector-gc.mjs +133 -0
  53. package/core/incremental-indexing/infrastructure/worktree-stamp.mjs +155 -0
  54. package/core/incremental-indexing/infrastructure/wsl2-detect.mjs +115 -0
  55. package/core/indexing/admission-policy.js +139 -0
  56. package/core/indexing/artifact-builder.js +29 -12
  57. package/core/indexing/ast-chunker.js +107 -30
  58. package/core/indexing/dedup/exemplar-selector.js +19 -1
  59. package/core/indexing/gitignore-filter.js +223 -0
  60. package/core/indexing/incremental-tracker.js +99 -30
  61. package/core/indexing/index-codebase-v21.js +6 -5
  62. package/core/indexing/index-maintainer.mjs +698 -6
  63. package/core/indexing/indexer-ann.js +99 -15
  64. package/core/indexing/indexer-build.js +158 -45
  65. package/core/indexing/indexer-empty-baseline.js +80 -0
  66. package/core/indexing/indexer-manifest.js +66 -0
  67. package/core/indexing/indexer-phases.js +56 -23
  68. package/core/indexing/indexer-sparse-gram.js +54 -13
  69. package/core/indexing/indexer-utils.js +26 -208
  70. package/core/indexing/indexing-file-policy.js +32 -7
  71. package/core/indexing/maintainer-launcher.mjs +137 -0
  72. package/core/indexing/merkle-tracker.js +251 -244
  73. package/core/indexing/model-pool.js +46 -5
  74. package/core/infrastructure/code-graph-repository.js +758 -6
  75. package/core/infrastructure/code-graph-visibility.js +157 -0
  76. package/core/infrastructure/codebase-repository.js +100 -13
  77. package/core/infrastructure/config/search.js +1 -1
  78. package/core/infrastructure/db-utils.js +118 -0
  79. package/core/infrastructure/dedup-hashing.js +10 -13
  80. package/core/infrastructure/hardware-capability.js +17 -7
  81. package/core/infrastructure/index.js +8 -2
  82. package/core/infrastructure/language-patterns/maps.js +4 -1
  83. package/core/infrastructure/language-patterns/registry-core.js +56 -17
  84. package/core/infrastructure/language-patterns/registry-object-oriented.js +12 -5
  85. package/core/infrastructure/language-patterns.js +69 -0
  86. package/core/infrastructure/model-registry.js +20 -0
  87. package/core/infrastructure/native-inference.js +7 -12
  88. package/core/infrastructure/native-resolver.js +52 -37
  89. package/core/infrastructure/native-sparse-gram.js +261 -20
  90. package/core/infrastructure/native-tokenizer.js +6 -15
  91. package/core/infrastructure/simd-distance.js +10 -16
  92. package/core/infrastructure/sparse-gram-delta-reader.js +76 -0
  93. package/core/infrastructure/structural-alias-resolver.js +122 -0
  94. package/core/infrastructure/structural-candidate-ranker.js +34 -0
  95. package/core/infrastructure/structural-context-repository.js +472 -0
  96. package/core/infrastructure/structural-context-utils.js +51 -0
  97. package/core/infrastructure/structural-graph-signals.js +121 -0
  98. package/core/infrastructure/structural-qualified-resolution.js +15 -0
  99. package/core/infrastructure/structural-source-definitions.js +100 -0
  100. package/core/infrastructure/tombstone-bitmap-reader.js +139 -0
  101. package/core/infrastructure/tree-sitter-provider.js +811 -37
  102. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +50 -0
  103. package/core/query/query-router.js +55 -5
  104. package/core/ranking/file-kind-ranking.js +2192 -15
  105. package/core/ranking/late-interaction-index.js +87 -12
  106. package/core/search/cli-decoration.js +290 -0
  107. package/core/search/context-expander.js +988 -78
  108. package/core/search/index.js +1 -0
  109. package/core/search/output-policy.js +275 -0
  110. package/core/search/search-anchor.js +499 -0
  111. package/core/search/search-boost.js +93 -1
  112. package/core/search/search-cli.js +61 -204
  113. package/core/search/search-hybrid.js +250 -10
  114. package/core/search/search-pattern-chunks.js +57 -8
  115. package/core/search/search-pattern-planner.js +68 -9
  116. package/core/search/search-pattern-prefilter.js +30 -10
  117. package/core/search/search-pattern-ripgrep.js +40 -4
  118. package/core/search/search-pattern-sparse-overlay.js +256 -0
  119. package/core/search/search-pattern.js +117 -29
  120. package/core/search/search-postprocess.js +479 -5
  121. package/core/search/search-read-semantic.js +260 -23
  122. package/core/search/search-read.js +82 -64
  123. package/core/search/search-reader-pin.js +71 -0
  124. package/core/search/search-rrf.js +279 -0
  125. package/core/search/search-semantic.js +110 -5
  126. package/core/search/search-server.js +130 -57
  127. package/core/search/search-trace.js +107 -0
  128. package/core/search/server-identity.js +93 -0
  129. package/core/search/session-daemon-prewarm.mjs +33 -10
  130. package/core/search/sweet-search.js +399 -7
  131. package/core/skills/sweet-index/SKILL.md +8 -6
  132. package/core/vector-store/binary-hnsw-index.js +194 -30
  133. package/core/vector-store/float-vector-store.js +96 -6
  134. package/core/vector-store/hnsw-index.js +220 -49
  135. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +471 -0
  136. package/eval/agent-read-workflows/bin/ss-find +15 -0
  137. package/eval/agent-read-workflows/bin/ss-grep +12 -0
  138. package/eval/agent-read-workflows/bin/ss-read +14 -0
  139. package/eval/agent-read-workflows/bin/ss-search +18 -0
  140. package/eval/agent-read-workflows/bin/ss-semantic +12 -0
  141. package/eval/agent-read-workflows/bin/ss-trace +11 -0
  142. package/mcp/read-tool.js +109 -0
  143. package/mcp/server.js +55 -15
  144. package/mcp/tool-handlers.js +14 -124
  145. package/mcp/trace-tool.js +81 -0
  146. package/package.json +25 -10
  147. package/scripts/hooks/intercept-read.mjs +55 -0
  148. package/scripts/hooks/remind-tools.mjs +40 -0
  149. package/scripts/init.js +698 -54
  150. package/scripts/inject-agent-instructions.js +431 -0
  151. package/scripts/install-prompt-reminders.js +188 -0
  152. package/scripts/install-tool-enforcement.js +220 -0
  153. package/scripts/smoke-test.js +12 -9
  154. package/scripts/uninstall.js +276 -18
  155. package/scripts/write-claude-rules.js +110 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Install / remove sweet-search Claude-specific tool-enforcement.
3
+ *
4
+ * Plan reference: §4D / §10 step 17. Opt-in via `--enforce-tools` because
5
+ * strict enforcement is opinionated and Claude-specific. Universally gated
6
+ * by `--no-claude` (handled in init.js — when --no-claude, never called).
7
+ *
8
+ * Two-part install (per §4D):
9
+ * 1. Deny native `Grep` in `.claude/settings.json::permissions.deny`.
10
+ * Sweet-search wants `ss-grep` to be the default; denying Grep
11
+ * forces the agent toward the indexed path.
12
+ * 2. PreToolUse hint hook for `Read` — *hints*, never blocks. Edit
13
+ * workflows require Read before Edit, so a deny would break tooling.
14
+ *
15
+ * Removable cleanly: tracks the deny entry by exact value match and the
16
+ * hook entry by filename match (same pattern as prewarm SessionStart hook).
17
+ */
18
+
19
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
20
+ import { dirname, isAbsolute, join, relative } from 'node:path';
21
+
22
+ export const INTERCEPT_READ_HOOK_FILENAME = 'sweet-search-intercept-read.mjs';
23
+ const SOURCE_REL = ['scripts', 'hooks', 'intercept-read.mjs'];
24
+ const GREP_DENY_VALUE = 'Grep';
25
+
26
+ /**
27
+ * Install the strict-mode enforcement: Grep deny + Read hint hook.
28
+ * Idempotent. Never throws — surfaces errors via the status object.
29
+ *
30
+ * @param {object} args
31
+ * @param {string} args.projectRoot
32
+ * @param {string} args.packageRoot
33
+ * @param {boolean} [args.skipped=false] — set when --enforce-tools is OFF
34
+ * @returns {{ status: string, detail: string, hookPath?: string }}
35
+ * status ∈ { installed, skipped, error }
36
+ */
37
+ export function installToolEnforcement({ projectRoot, packageRoot, skipped = false } = {}) {
38
+ if (skipped) return { status: 'skipped', detail: '--enforce-tools not set' };
39
+ if (!projectRoot) return { status: 'error', detail: 'install-tool-enforcement: projectRoot is required' };
40
+ if (!packageRoot) return { status: 'error', detail: 'install-tool-enforcement: packageRoot is required' };
41
+
42
+ // Hook source must exist in the package (covered by package.json files).
43
+ const hookSrcAbs = join(packageRoot, ...SOURCE_REL);
44
+ if (!existsSync(hookSrcAbs)) {
45
+ return { status: 'error', detail: `hook source missing: ${hookSrcAbs}` };
46
+ }
47
+
48
+ const hookDestAbs = join(projectRoot, '.claude', 'hooks', INTERCEPT_READ_HOOK_FILENAME);
49
+ const hookRelFromProject = relative(projectRoot, hookDestAbs);
50
+ if (hookRelFromProject.startsWith('..') || isAbsolute(hookRelFromProject)) {
51
+ return { status: 'error', detail: 'hook destination escapes projectRoot' };
52
+ }
53
+
54
+ try {
55
+ mkdirSync(dirname(hookDestAbs), { recursive: true });
56
+ copyFileSync(hookSrcAbs, hookDestAbs);
57
+ } catch (err) {
58
+ return { status: 'error', detail: `hook copy failed: ${err.message}` };
59
+ }
60
+
61
+ const settingsResult = upsertSettings({
62
+ projectRoot,
63
+ addDeny: GREP_DENY_VALUE,
64
+ addPreToolUseHook: {
65
+ matcher: 'Read',
66
+ command: `node ${hookRelFromProject}`,
67
+ ownershipFilename: INTERCEPT_READ_HOOK_FILENAME,
68
+ },
69
+ });
70
+ if (settingsResult.status !== 'installed') return settingsResult;
71
+ return { ...settingsResult, hookPath: hookRelFromProject };
72
+ }
73
+
74
+ /**
75
+ * Reverse `installToolEnforcement`. Removes the Grep deny entry (only
76
+ * when present), the PreToolUse hook entry (by filename match), and the
77
+ * hook script file. Other denies / hooks are preserved.
78
+ *
79
+ * @returns {{ status: string, detail: string }} status ∈
80
+ * { removed, not-found, dry-run, error }
81
+ */
82
+ export function removeToolEnforcement({ projectRoot, dryRun = false } = {}) {
83
+ if (!projectRoot) return { status: 'error', detail: 'remove-tool-enforcement: projectRoot is required' };
84
+
85
+ const hookDestAbs = join(projectRoot, '.claude', 'hooks', INTERCEPT_READ_HOOK_FILENAME);
86
+ const settingsPath = join(projectRoot, '.claude', 'settings.json');
87
+
88
+ const hookExists = existsSync(hookDestAbs);
89
+ const denyExists = settingsHasDeny(settingsPath, GREP_DENY_VALUE);
90
+ const hookEntryExists = settingsHasPreToolUseHook(settingsPath, INTERCEPT_READ_HOOK_FILENAME);
91
+
92
+ if (!hookExists && !denyExists && !hookEntryExists) {
93
+ return { status: 'not-found', detail: 'no enforcement to undo' };
94
+ }
95
+ if (dryRun) {
96
+ const parts = [
97
+ hookExists && 'hook file',
98
+ denyExists && 'Grep deny',
99
+ hookEntryExists && 'PreToolUse entry',
100
+ ].filter(Boolean);
101
+ return { status: 'dry-run', detail: parts.join(' + ') };
102
+ }
103
+
104
+ const removed = [];
105
+ if (hookExists) {
106
+ try { unlinkSync(hookDestAbs); removed.push('hook file'); }
107
+ catch (err) { return { status: 'error', detail: `failed to remove hook: ${err.message}` }; }
108
+ }
109
+ if (denyExists || hookEntryExists) {
110
+ const r = upsertSettings({
111
+ projectRoot,
112
+ removeDeny: denyExists ? GREP_DENY_VALUE : null,
113
+ removePreToolUseHook: hookEntryExists ? INTERCEPT_READ_HOOK_FILENAME : null,
114
+ });
115
+ if (r.status === 'error') return r;
116
+ if (denyExists) removed.push('Grep deny');
117
+ if (hookEntryExists) removed.push('PreToolUse entry');
118
+ }
119
+ return { status: 'removed', detail: removed.join(' + ') };
120
+ }
121
+
122
+ // ─── Settings.json upsert (read → mutate → atomic write) ────────────────────
123
+
124
+ function upsertSettings({
125
+ projectRoot,
126
+ addDeny = null,
127
+ removeDeny = null,
128
+ addPreToolUseHook = null,
129
+ removePreToolUseHook = null,
130
+ }) {
131
+ const settingsDir = join(projectRoot, '.claude');
132
+ const settingsPath = join(settingsDir, 'settings.json');
133
+
134
+ let settings = {};
135
+ if (existsSync(settingsPath)) {
136
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); }
137
+ catch (err) { return { status: 'error', detail: `settings.json invalid: ${err.message}` }; }
138
+ }
139
+
140
+ // Permissions deny.
141
+ if (addDeny) {
142
+ settings.permissions = settings.permissions || {};
143
+ const deny = Array.isArray(settings.permissions.deny) ? settings.permissions.deny : [];
144
+ if (!deny.includes(addDeny)) deny.push(addDeny);
145
+ settings.permissions.deny = deny;
146
+ }
147
+ if (removeDeny) {
148
+ const deny = Array.isArray(settings?.permissions?.deny) ? settings.permissions.deny : [];
149
+ const next = deny.filter((v) => v !== removeDeny);
150
+ if (next.length === 0) {
151
+ if (settings.permissions) delete settings.permissions.deny;
152
+ } else {
153
+ settings.permissions.deny = next;
154
+ }
155
+ if (settings.permissions && Object.keys(settings.permissions).length === 0) delete settings.permissions;
156
+ }
157
+
158
+ // PreToolUse hook entry.
159
+ if (addPreToolUseHook) {
160
+ settings.hooks = settings.hooks || {};
161
+ const arr = Array.isArray(settings.hooks.PreToolUse) ? settings.hooks.PreToolUse : [];
162
+ const entry = {
163
+ matcher: addPreToolUseHook.matcher,
164
+ hooks: [
165
+ { type: 'command', command: addPreToolUseHook.command, timeout: 4000, continueOnError: true },
166
+ ],
167
+ };
168
+ const ownedIdx = arr.findIndex(group =>
169
+ Array.isArray(group?.hooks)
170
+ && group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(addPreToolUseHook.ownershipFilename)),
171
+ );
172
+ if (ownedIdx >= 0) arr[ownedIdx] = entry;
173
+ else arr.push(entry);
174
+ settings.hooks.PreToolUse = arr;
175
+ }
176
+ if (removePreToolUseHook) {
177
+ const arr = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
178
+ const next = arr.filter(group =>
179
+ !Array.isArray(group?.hooks)
180
+ || !group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(removePreToolUseHook)),
181
+ );
182
+ if (next.length === 0) {
183
+ if (settings.hooks) delete settings.hooks.PreToolUse;
184
+ } else {
185
+ settings.hooks.PreToolUse = next;
186
+ }
187
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
188
+ }
189
+
190
+ try {
191
+ mkdirSync(settingsDir, { recursive: true });
192
+ const tmp = settingsPath + '.tmp';
193
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf8');
194
+ renameSync(tmp, settingsPath);
195
+ } catch (err) {
196
+ return { status: 'error', detail: err.message };
197
+ }
198
+ return { status: 'installed', detail: 'permissions + PreToolUse updated' };
199
+ }
200
+
201
+ function settingsHasDeny(settingsPath, value) {
202
+ if (!existsSync(settingsPath)) return false;
203
+ try {
204
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
205
+ const deny = Array.isArray(settings?.permissions?.deny) ? settings.permissions.deny : [];
206
+ return deny.includes(value);
207
+ } catch { return false; }
208
+ }
209
+
210
+ function settingsHasPreToolUseHook(settingsPath, ownershipFilename) {
211
+ if (!existsSync(settingsPath)) return false;
212
+ try {
213
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
214
+ const arr = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
215
+ return arr.some(group =>
216
+ Array.isArray(group?.hooks)
217
+ && group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(ownershipFilename)),
218
+ );
219
+ } catch { return false; }
220
+ }
@@ -259,16 +259,19 @@ for (const profile of profiles) {
259
259
 
260
260
  if (profile === 'full') {
261
261
  assert(config.runtime.allowRuntimeModelDownload === false, 'Runtime downloads should be disabled for full');
262
- // Model count: full profile registers all profile=='full' entries from
263
- // core/infrastructure/model-registry.js. Currently 6 (4 retrieval models
264
- // + FlashRank cross-encoder + semantic-cache MiniLM); was 4 before
265
- // FlashRank and the semantic cache landed. The original hard-coded "===4"
266
- // assertion is documented as drifted in docs/INIT_STRATEGY.md. We just
267
- // assert presence + a reasonable lower bound rather than pin a number
268
- // that drifts every time a model is added.
262
+ // Model count is hardware-conditional since commit d282bb0 ("use ORT CPU
263
+ // without accelerators"): a NO-ACCELERATOR host (no CoreML/CUDA/Metal
264
+ // e.g. CI's linux-x64 ubuntu runner, or the darwin-x64 binary running
265
+ // cross-arch on an arm64 macos runner) correctly registers only the 3
266
+ // core ORT-INT8 models (embed-int8 + LI-int8 + semantic-cache MiniLM) and
267
+ // SKIPS the 2 native FP32 variants (coderankembed-fp32, lateon-code-fp32),
268
+ // since indexing falls back to ORT INT8 CPU. An accelerator host adds those
269
+ // 2 → 5 (opt-in cross-encoders excluded by default). So 3 is the true
270
+ // floor; pinning ≥4 wrongly failed no-accelerator CI runners. Assert the
271
+ // floor — dropping below the 3 core models is a real regression.
269
272
  assert(
270
- Object.keys(config.models).length >= 4,
271
- `Expected ≥4 models, got ${Object.keys(config.models).length}`,
273
+ Object.keys(config.models).length >= 3,
274
+ `Expected ≥3 core models, got ${Object.keys(config.models).length}`,
272
275
  );
273
276
  } else {
274
277
  assert(config.runtime.allowRuntimeModelDownload === true, 'Runtime downloads should be enabled for core');
@@ -18,12 +18,11 @@ import { fileURLToPath } from 'node:url';
18
18
 
19
19
  import { getCoremlCascadeRoot, getCoremlCascadeState } from '../core/infrastructure/coreml-cascade.js';
20
20
  import { PREWARM_HOOK_FILENAME } from './init.js';
21
-
22
- // Default paths for the running daemon. Env-overridable so both the prewarm
23
- // hook, the CLI, and this module agree on where to look. Tests pass custom
24
- // values to `stopRunningDaemon` for isolation.
25
- const DEFAULT_PID_FILE = process.env.SWEET_SEARCH_PID_FILE || '/tmp/sweet-search-server.pid';
26
- const DEFAULT_SOCKET_PATH = process.env.SWEET_SEARCH_SOCKET_PATH || '/tmp/sweet-search.sock';
21
+ import { removeAgentInstructions } from './inject-agent-instructions.js';
22
+ import { removeClaudeRules } from './write-claude-rules.js';
23
+ import { removePromptReminderHook } from './install-prompt-reminders.js';
24
+ import { removeToolEnforcement } from './install-tool-enforcement.js';
25
+ import { projectSocketPath, projectPidFile } from '../core/search/server-identity.js';
27
26
 
28
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
28
  const PACKAGE_ROOT = join(__dirname, '..');
@@ -175,8 +174,12 @@ export function getCoremlCascadeRemovals() {
175
174
  */
176
175
  export function stopRunningDaemon({
177
176
  projectRoot,
178
- pidFile = DEFAULT_PID_FILE,
179
- socketPath = DEFAULT_SOCKET_PATH,
177
+ // Per-project socket/pidfile (C3) — derived from this project's root so
178
+ // uninstalling project A never stops project B's server. Honors explicit
179
+ // SWEET_SEARCH_SOCKET_PATH / SWEET_SEARCH_PID_FILE overrides. Tests pass
180
+ // explicit values for isolation.
181
+ pidFile = projectPidFile(process.env, projectRoot || process.cwd()),
182
+ socketPath = projectSocketPath(process.env, projectRoot || process.cwd()),
180
183
  } = {}) {
181
184
  const result = { gracefulAttempted: false, killed: false, pidFileRemoved: false, socketRemoved: false };
182
185
 
@@ -221,6 +224,63 @@ export function stopRunningDaemon({
221
224
  return result;
222
225
  }
223
226
 
227
+ const MAINTAINER_LOCK_FILENAME = 'index-maintainer.lock';
228
+
229
+ /** Synchronous sleep (uninstall is one-shot; a sub-second block is fine). */
230
+ function sleepSyncMs(ms) {
231
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
232
+ }
233
+
234
+ /** Is this pid alive right now? EPERM (foreign owner) counts as alive. */
235
+ function pidAlive(pid) {
236
+ if (!Number.isFinite(pid) || pid <= 0) return false;
237
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
238
+ }
239
+
240
+ /**
241
+ * Stop a reconcile-v2 incremental-index maintainer that an earlier SessionStart
242
+ * prewarm hook auto-launched. The maintainer records its pid in
243
+ * `<stateDir>/index-maintainer.lock`.
244
+ *
245
+ * Strategy:
246
+ * 1. SIGTERM for a clean shutdown (the daemon flushes + releases its lock).
247
+ * 2. Escalate to SIGKILL if it is still alive after a short grace. Its tick
248
+ * interval can be up to 5 minutes — far longer than uninstall can wait —
249
+ * so we do not block for graceful exit. Maintainer writes are atomic
250
+ * temp+rename, so a SIGKILL is crash-safe (validated by the RC soak +
251
+ * crash probe).
252
+ * 3. Remove the lock file so the next start is clean.
253
+ *
254
+ * Never throws — every branch swallows errors (the daemon may not be running).
255
+ *
256
+ * Returns `{ present, pid, signalled, killed, lockRemoved }`.
257
+ */
258
+ export function stopRunningMaintainer({
259
+ projectRoot,
260
+ stateDir = projectRoot ? join(projectRoot, DATA_DIR_NAME) : null,
261
+ } = {}) {
262
+ const result = { present: false, pid: null, signalled: false, killed: false, lockRemoved: false };
263
+ if (!stateDir) return result;
264
+ const lockFile = join(stateDir, MAINTAINER_LOCK_FILENAME);
265
+ if (!existsSync(lockFile)) return result;
266
+ result.present = true;
267
+
268
+ let pid = null;
269
+ try { pid = Number(JSON.parse(readFileSync(lockFile, 'utf-8')).pid); } catch { pid = null; }
270
+
271
+ if (pidAlive(pid)) {
272
+ result.pid = pid;
273
+ try { process.kill(pid, 'SIGTERM'); result.signalled = true; } catch { /* ignore */ }
274
+ sleepSyncMs(300);
275
+ if (pidAlive(pid)) {
276
+ try { process.kill(pid, 'SIGKILL'); result.killed = true; } catch { /* ignore */ }
277
+ }
278
+ }
279
+
280
+ try { unlinkSync(lockFile); result.lockRemoved = true; } catch { /* ignore */ }
281
+ return result;
282
+ }
283
+
224
284
  /**
225
285
  * Remove the index-maintainer daemon hook init copied into
226
286
  * `.claude/hooks/index-maintainer.mjs`. Only removes the file when it
@@ -414,6 +474,84 @@ export function removePrewarmSessionStartHook(projectRoot, { dryRun = false } =
414
474
  return { status: 'removed', detail: `spliced out ${sessionStart.length - filtered.length} entry` };
415
475
  }
416
476
 
477
+ /**
478
+ * Remove the Codex CLI SessionStart hook entry that `--codex` init wrote into
479
+ * `.codex/hooks.json`. Mirrors `removePrewarmSessionStartHook`: only the
480
+ * sweet-search-owned entry (matched by the launcher filename) is spliced out;
481
+ * other events/entries are preserved. When our entry was the only content the
482
+ * file is deleted rather than left as an empty shell. The `[features] hooks`
483
+ * feature flag in config.toml is intentionally left in place — it's harmless and
484
+ * may be shared with other tooling.
485
+ *
486
+ * Returns `{ status, detail }`:
487
+ * removed — our entry was spliced out (file rewritten or deleted)
488
+ * not-found — no .codex/hooks.json, no SessionStart, or no matching entry
489
+ * dry-run — would remove (no write)
490
+ * error — non-fatal (unreadable / invalid JSON / write failed)
491
+ */
492
+ export function removeCodexSessionStartHook(projectRoot, { dryRun = false } = {}) {
493
+ const hooksPath = join(projectRoot, '.codex', 'hooks.json');
494
+ if (!existsSync(hooksPath)) {
495
+ return { status: 'not-found', detail: 'no .codex/hooks.json' };
496
+ }
497
+
498
+ let raw;
499
+ try {
500
+ raw = readFileSync(hooksPath, 'utf-8');
501
+ } catch (err) {
502
+ return { status: 'error', detail: `read failed: ${err.message}` };
503
+ }
504
+
505
+ let doc;
506
+ try {
507
+ doc = JSON.parse(raw);
508
+ } catch (err) {
509
+ return { status: 'error', detail: `.codex/hooks.json is not valid JSON: ${err.message}` };
510
+ }
511
+
512
+ const sessionStart = doc?.hooks?.SessionStart;
513
+ if (!Array.isArray(sessionStart) || sessionStart.length === 0) {
514
+ return { status: 'not-found', detail: 'no SessionStart entries' };
515
+ }
516
+
517
+ const filtered = sessionStart.filter((group) =>
518
+ !(Array.isArray(group?.hooks) &&
519
+ group.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(PREWARM_HOOK_FILENAME)))
520
+ );
521
+
522
+ if (filtered.length === sessionStart.length) {
523
+ return { status: 'not-found', detail: 'no matching entry' };
524
+ }
525
+
526
+ if (dryRun) {
527
+ return { status: 'dry-run', detail: `would remove ${sessionStart.length - filtered.length} entry` };
528
+ }
529
+
530
+ if (filtered.length === 0) {
531
+ delete doc.hooks.SessionStart;
532
+ if (doc.hooks && Object.keys(doc.hooks).length === 0) {
533
+ delete doc.hooks;
534
+ }
535
+ } else {
536
+ doc.hooks.SessionStart = filtered;
537
+ }
538
+
539
+ try {
540
+ if (doc && Object.keys(doc).length === 0) {
541
+ // Our hook was the only content — remove the file rather than leave `{}`.
542
+ unlinkSync(hooksPath);
543
+ } else {
544
+ const tmpPath = hooksPath + '.tmp';
545
+ writeFileSync(tmpPath, JSON.stringify(doc, null, 2) + '\n', 'utf-8');
546
+ renameSync(tmpPath, hooksPath);
547
+ }
548
+ } catch (err) {
549
+ return { status: 'error', detail: `write failed: ${err.message}` };
550
+ }
551
+
552
+ return { status: 'removed', detail: `spliced out ${sessionStart.length - filtered.length} entry` };
553
+ }
554
+
417
555
  // ---------------------------------------------------------------------------
418
556
  // Optional native package list (derived from package.json)
419
557
  // ---------------------------------------------------------------------------
@@ -550,8 +688,35 @@ export async function runUninstall(args) {
550
688
  const indexMaintainerSkippedReason =
551
689
  indexMaintainerPreview.status === 'skipped' ? indexMaintainerPreview.detail : null;
552
690
 
691
+ // P1: agent-instruction files (AGENTS.md / CLAUDE.md / GEMINI.md /
692
+ // .cursor/rules/sweet-search.mdc) and the .claude/rules/sweet-search.md
693
+ // sentinel file. The marker block contract guarantees we only strip
694
+ // sweet-search-managed content; user prose outside the marker is preserved.
695
+ const agentInstructionsPreview = removeAgentInstructions({ projectRoot, dryRun: true });
696
+ const agentInstructionsTouched = Object.values(agentInstructionsPreview.harnesses ?? {})
697
+ .some(s => s === 'dry-run');
698
+ const claudeRulesPreview = removeClaudeRules({ projectRoot, dryRun: true });
699
+ const hasClaudeRules = claudeRulesPreview === 'dry-run';
700
+
701
+ // P2: UserPromptSubmit reminder hook (.claude/hooks/sweet-search-remind-tools.mjs
702
+ // + the matching settings.json entry).
703
+ const promptReminderPreview = removePromptReminderHook({ projectRoot, dryRun: true });
704
+ const hasPromptReminder = promptReminderPreview.status === 'dry-run';
705
+
706
+ // P3: Tool enforcement (Grep deny + PreToolUse hint hook for Read).
707
+ const toolEnforcementPreview = removeToolEnforcement({ projectRoot, dryRun: true });
708
+ const hasToolEnforcement = toolEnforcementPreview.status === 'dry-run';
709
+
710
+ // Codex CLI SessionStart hook (.codex/hooks.json), written by `init --codex`.
711
+ const codexHookPreview = removeCodexSessionStartHook(projectRoot, { dryRun: true });
712
+ const hasCodexHook = codexHookPreview.status === 'dry-run';
713
+
553
714
  // Nothing to remove?
554
- if (removals.length === 0 && !hasHookEntry && !hasSkillEntry && !hasIndexMaintainerHook) {
715
+ if (
716
+ removals.length === 0 && !hasHookEntry && !hasSkillEntry && !hasIndexMaintainerHook
717
+ && !agentInstructionsTouched && !hasClaudeRules
718
+ && !hasPromptReminder && !hasToolEnforcement && !hasCodexHook
719
+ ) {
555
720
  console.log('Nothing to remove — Sweet Search is not initialized in this project.');
556
721
  return;
557
722
  }
@@ -576,6 +741,23 @@ export async function runUninstall(args) {
576
741
  } else if (indexMaintainerSkippedReason) {
577
742
  console.log(` [skipped] ${indexMaintainerSkippedReason}`);
578
743
  }
744
+ if (agentInstructionsTouched) {
745
+ const targets = Object.entries(agentInstructionsPreview.harnesses)
746
+ .filter(([, v]) => v === 'dry-run').map(([k]) => k).join(', ');
747
+ console.log(` agent-instruction marker blocks (${targets})`);
748
+ }
749
+ if (hasClaudeRules) {
750
+ console.log(` .claude/rules/sweet-search.md`);
751
+ }
752
+ if (hasPromptReminder) {
753
+ console.log(` UserPromptSubmit reminder hook (${promptReminderPreview.detail})`);
754
+ }
755
+ if (hasToolEnforcement) {
756
+ console.log(` tool-enforcement strict mode (${toolEnforcementPreview.detail})`);
757
+ }
758
+ if (hasCodexHook) {
759
+ console.log(` Codex SessionStart hook (.codex/hooks.json)`);
760
+ }
579
761
  console.log(` Total: ${formatBytes(totalBytes)}`);
580
762
  if (parsed.keepModels) {
581
763
  console.log(' Model cache: kept (--keep-models)');
@@ -597,6 +779,10 @@ export async function runUninstall(args) {
597
779
  } else if (dryMaintainer.status === 'skipped') {
598
780
  console.log(` Would skip: index-maintainer hook — ${dryMaintainer.detail}`);
599
781
  }
782
+ const dryCodex = removeCodexSessionStartHook(projectRoot, { dryRun: true });
783
+ if (dryCodex.status === 'dry-run') {
784
+ console.log(` Would also remove: Codex SessionStart hook (.codex/hooks.json — ${dryCodex.detail})`);
785
+ }
600
786
  console.log('Dry run — nothing was removed.');
601
787
  return;
602
788
  }
@@ -615,6 +801,30 @@ export async function runUninstall(args) {
615
801
  }
616
802
  }
617
803
 
804
+ // Stop the running daemon + maintainer BEFORE deleting .sweet-search/. The
805
+ // maintainer records its pid in .sweet-search/index-maintainer.lock; if we
806
+ // removed the state dir first, stopRunningMaintainer() would have no pid to
807
+ // signal — the maintainer would leak and, because its tick loop recreates the
808
+ // state dir (mkdirSync), resurrect the very directory we just deleted. So this
809
+ // must run after the confirmation/dry-run gates but before any removal.
810
+ const daemonResult = stopRunningDaemon({ projectRoot });
811
+ if (daemonResult.killed) {
812
+ console.log(' Stopped: running prewarm daemon (SIGKILL via PID file)');
813
+ } else if (daemonResult.gracefulAttempted) {
814
+ console.log(' Stopped: running prewarm daemon (graceful via CLI)');
815
+ }
816
+ // If neither happened, daemon wasn't running — silent.
817
+
818
+ const maintainerResult = stopRunningMaintainer({ projectRoot });
819
+ if (maintainerResult.killed) {
820
+ console.log(` Stopped: incremental-index maintainer (SIGKILL after grace, pid ${maintainerResult.pid})`);
821
+ } else if (maintainerResult.signalled) {
822
+ console.log(` Stopped: incremental-index maintainer (SIGTERM, pid ${maintainerResult.pid})`);
823
+ } else if (maintainerResult.lockRemoved) {
824
+ console.log(' Cleared: stale incremental-index maintainer lock');
825
+ }
826
+ // If none happened, the maintainer wasn't running — silent.
827
+
618
828
  // Remove
619
829
  let removed = 0;
620
830
  let kept = 0;
@@ -680,16 +890,64 @@ export async function runUninstall(args) {
680
890
  kept++;
681
891
  }
682
892
 
683
- // Stop any daemon that an earlier SessionStart hook spawned. Otherwise the
684
- // old daemon keeps running and holding the socket after uninstall, which
685
- // surprises users. Never throws `stopRunningDaemon` swallows every error.
686
- const daemonResult = stopRunningDaemon({ projectRoot });
687
- if (daemonResult.killed) {
688
- console.log(' Stopped: running prewarm daemon (SIGKILL via PID file)');
689
- } else if (daemonResult.gracefulAttempted) {
690
- console.log(' Stopped: running prewarm daemon (graceful via CLI)');
893
+ // Reverse the Codex SessionStart hook written by `init --codex`. The
894
+ // config.toml feature flag is left in place (harmless, possibly shared).
895
+ const codexHookResult = removeCodexSessionStartHook(projectRoot, { dryRun: parsed.dryRun });
896
+ if (codexHookResult.status === 'removed') {
897
+ console.log(` Removed: Codex SessionStart hook (.codex/hooks.json — ${codexHookResult.detail})`);
898
+ removed++;
899
+ } else if (codexHookResult.status === 'error') {
900
+ console.log(` Failed to remove Codex SessionStart hook: ${codexHookResult.detail}`);
901
+ kept++;
902
+ }
903
+ // 'not-found' and 'dry-run' are silent in the main output.
904
+
905
+ // P1: strip agent-instruction marker blocks across all five harness files.
906
+ // The marker contract guarantees we never delete user prose outside of it.
907
+ const agentInstructionsResult = removeAgentInstructions({ projectRoot, dryRun: parsed.dryRun });
908
+ for (const [harness, status] of Object.entries(agentInstructionsResult.harnesses)) {
909
+ if (status === 'removed') {
910
+ console.log(` Removed: ${harness} agent-instruction block`);
911
+ removed++;
912
+ } else if (status === 'file-deleted') {
913
+ console.log(` Removed: ${harness} agent-instruction file (wholly sweet-search-managed)`);
914
+ removed++;
915
+ }
916
+ // 'not-found' / 'not-our-symlink' / 'dry-run' are silent.
917
+ }
918
+
919
+ // Remove the .claude/rules/sweet-search.md sentinel file. CLAUDE.md import
920
+ // line was already stripped by removeAgentInstructions above (it lived
921
+ // inside the agent-instructions marker).
922
+ const claudeRulesResult = removeClaudeRules({ projectRoot, dryRun: parsed.dryRun });
923
+ if (claudeRulesResult === 'removed') {
924
+ console.log(` Removed: .claude/rules/sweet-search.md`);
925
+ removed++;
926
+ } else if (claudeRulesResult === 'preserved-user-file') {
927
+ console.log(` Kept: .claude/rules/sweet-search.md — no sweet-search sentinel (user-edited)`);
928
+ kept++;
929
+ }
930
+ // 'not-found' / 'dry-run' are silent.
931
+
932
+ // P2: strip the UserPromptSubmit reminder hook + settings entry.
933
+ const promptReminderResult = removePromptReminderHook({ projectRoot, dryRun: parsed.dryRun });
934
+ if (promptReminderResult.status === 'removed') {
935
+ console.log(` Removed: UserPromptSubmit reminder hook (${promptReminderResult.detail})`);
936
+ removed++;
937
+ } else if (promptReminderResult.status === 'error') {
938
+ console.log(` Failed to remove UserPromptSubmit reminder hook: ${promptReminderResult.detail}`);
939
+ kept++;
940
+ }
941
+
942
+ // P3: strip the tool-enforcement Grep deny + PreToolUse hook + hook file.
943
+ const toolEnforcementResult = removeToolEnforcement({ projectRoot, dryRun: parsed.dryRun });
944
+ if (toolEnforcementResult.status === 'removed') {
945
+ console.log(` Removed: tool-enforcement (${toolEnforcementResult.detail})`);
946
+ removed++;
947
+ } else if (toolEnforcementResult.status === 'error') {
948
+ console.log(` Failed to remove tool-enforcement: ${toolEnforcementResult.detail}`);
949
+ kept++;
691
950
  }
692
- // If neither happened, daemon wasn't running — silent.
693
951
 
694
952
  // Purge npm packages
695
953
  if (parsed.purge) {