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,431 @@
1
+ /**
2
+ * Multi-file injection of the sweet-search agent policy across every major
3
+ * coding-agent harness (CLAUDE.md / AGENTS.md / GEMINI.md / Cursor rule).
4
+ *
5
+ * Canonical source: **CLAUDE.md** by default (sweet-search is Claude-first;
6
+ * the existing project CLAUDE.md is where users look). Other harnesses get
7
+ * `@CLAUDE.md` import shims (or symlinks). When the user disables Claude
8
+ * Code with `--no-claude-code`, AGENTS.md is promoted to canonical so other
9
+ * harnesses still receive the policy body.
10
+ *
11
+ * NOTE: this design diverges from the plan's §3.3 / §10 which framed
12
+ * AGENTS.md as canonical. The flip was a user-driven product call (sweet-
13
+ * search ships best on Claude, the existing CLAUDE.md is the natural home,
14
+ * and Codex / Gemini both follow `@imports` so the cross-harness chain works
15
+ * with either canonical). Plan doc update is tracked as a follow-up.
16
+ *
17
+ * Marker contract (idempotent rewrite — never modify content outside it):
18
+ * <!-- sweet-search:agent-instructions:begin -->
19
+ * ... managed body ...
20
+ * <!-- sweet-search:agent-instructions:end -->
21
+ */
22
+
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
24
+ import { dirname, join, relative } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ export const MARKER_BEGIN = '<!-- sweet-search:agent-instructions:begin -->';
28
+ export const MARKER_END = '<!-- sweet-search:agent-instructions:end -->';
29
+
30
+ export const AGENTS_FILE = 'AGENTS.md';
31
+ export const CLAUDE_FILE = 'CLAUDE.md';
32
+ export const GEMINI_FILE = 'GEMINI.md';
33
+ export const CURSOR_FILE = '.cursor/rules/sweet-search.mdc';
34
+
35
+ /**
36
+ * Public harness identifiers used by the per-harness `--no-<name>` flags
37
+ * in `scripts/init.js`. Order matches `--help` output.
38
+ */
39
+ export const ALL_HARNESSES = ['claude-code', 'agents', 'gemini', 'cursor'];
40
+
41
+ const MARKER_RE = new RegExp(
42
+ `${escapeRegex(MARKER_BEGIN)}[\\s\\S]*?${escapeRegex(MARKER_END)}\\n?`,
43
+ );
44
+
45
+ function escapeRegex(s) {
46
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47
+ }
48
+
49
+ // ─── Canonical policy body = the frozen M++ champion (integration seam) ──────
50
+ //
51
+ // Plan §10 / §3.7.1 step 13 / DDD "Integration seam": scripts/init.js is the
52
+ // SOLE consumer of the prompt-optimization ship-file. We read that artifact at
53
+ // load time and strip its YAML front-matter; the remaining body is the frozen
54
+ // M++ champion (PHASE7), injected VERBATIM into the harness files so users get
55
+ // exactly the prompt that was benchmarked (held-out 0.988 Maximin, OOD 0.952,
56
+ // HOMP/SCS/counter all pass; 5-cell cross-harness validated). Per-harness shims
57
+ // (Cursor frontmatter, @imports) are applied at write-time, not embedded here.
58
+ //
59
+ // The artifact is generated from Mpp.md by
60
+ // `core/prompt-optimization/sweep/finalize-mpp.mjs` and shipped via the
61
+ // package.json "files" list. If it is missing we fail LOUDLY rather than
62
+ // silently shipping a placeholder/older policy.
63
+
64
+ const SHIP_FILE_REL = 'core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md';
65
+
66
+ /** Strip a leading YAML front-matter block (`---\n … \n---\n`) if present. */
67
+ export function stripFrontMatter(text) {
68
+ return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
69
+ }
70
+
71
+ function readShippedPolicy() {
72
+ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/scripts
73
+ const shipPath = join(here, '..', SHIP_FILE_REL);
74
+ let raw;
75
+ try {
76
+ raw = readFileSync(shipPath, 'utf8');
77
+ } catch (err) {
78
+ throw new Error(
79
+ `inject-agent-instructions: cannot read the M++ ship-file at ${shipPath}. ` +
80
+ 'It MUST be present (packaged via package.json "files"). Regenerate with ' +
81
+ '`node core/prompt-optimization/sweep/finalize-mpp.mjs`. ' +
82
+ `Cause: ${err.message}`,
83
+ );
84
+ }
85
+ const body = stripFrontMatter(raw).trimEnd();
86
+ if (!body) {
87
+ throw new Error(`inject-agent-instructions: M++ ship-file at ${shipPath} has an empty body.`);
88
+ }
89
+ return body;
90
+ }
91
+
92
+ export const CANONICAL_POLICY_BODY = readShippedPolicy();
93
+
94
+ const CURSOR_FRONTMATTER = `---
95
+ description: Sweet Search tool-routing, stopping, and citation policy
96
+ alwaysApply: false
97
+ filePattern: "**/*"
98
+ ---
99
+
100
+ `;
101
+
102
+ // ─── Block builders ──────────────────────────────────────────────────────────
103
+
104
+ function wrapMarker(body) {
105
+ return `${MARKER_BEGIN}\n${body.trimEnd()}\n${MARKER_END}\n`;
106
+ }
107
+
108
+ /**
109
+ * Body for the canonical-source file (CLAUDE.md by default; AGENTS.md when
110
+ * the user opts out of Claude Code with `--no-claude-code`). Inlines the
111
+ * full policy plus, for CLAUDE.md, an extra `@.claude/rules/sweet-search.md`
112
+ * import line so the Claude-specific shim is loaded.
113
+ */
114
+ export function buildCanonicalBlock({ extraImports = [] } = {}) {
115
+ if (extraImports.length === 0) {
116
+ return wrapMarker(CANONICAL_POLICY_BODY);
117
+ }
118
+ const importLines = extraImports.map(t => `@${t}`).join('\n');
119
+ return wrapMarker(`${CANONICAL_POLICY_BODY}\n${importLines}\n`);
120
+ }
121
+
122
+ /**
123
+ * Body for non-canonical harnesses that prefer to @import the canonical
124
+ * file (Codex CLI, Gemini CLI when symlinks aren't used, AGENTS.md when
125
+ * canonical is CLAUDE.md). Cursor doesn't get this — it inlines the body
126
+ * because its frontmatter is required.
127
+ */
128
+ export function buildImportBlock({ importTargets }) {
129
+ const lines = importTargets.map(t => `@${t}`).join('\n');
130
+ return wrapMarker(
131
+ `<!-- Auto-generated by \`sweet-search init\`. Edit the canonical file (CLAUDE.md by default, AGENTS.md when --no-claude) instead. -->\n\n${lines}`,
132
+ );
133
+ }
134
+
135
+ /** Body for the cursor .mdc (frontmatter + inlined canonical body). */
136
+ export function buildCursorFile() {
137
+ return CURSOR_FRONTMATTER + wrapMarker(CANONICAL_POLICY_BODY);
138
+ }
139
+
140
+ // ─── Marker injection ───────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Idempotent rewrite of the marker block.
144
+ * - File missing: write `block` (with optional `prefix` like cursor frontmatter).
145
+ * - Marker present: replace in-place.
146
+ * - File exists, no marker: prepend `block` followed by an empty line.
147
+ * @returns 'created' | 'replaced' | 'prepended' | 'unchanged'
148
+ */
149
+ export function injectMarkerBlock({ filePath, block, prefix = '' }) {
150
+ if (!existsSync(filePath)) {
151
+ mkdirSync(dirname(filePath), { recursive: true });
152
+ writeFileSync(filePath, prefix + block);
153
+ return 'created';
154
+ }
155
+ const current = readFileSync(filePath, 'utf8');
156
+ if (MARKER_RE.test(current)) {
157
+ const next = current.replace(MARKER_RE, block);
158
+ if (next === current) return 'unchanged';
159
+ writeFileSync(filePath, next);
160
+ return 'replaced';
161
+ }
162
+ // No marker — prepend the block. Keeps user content intact.
163
+ writeFileSync(filePath, block + '\n' + current);
164
+ return 'prepended';
165
+ }
166
+
167
+ /**
168
+ * Strip just the marker block (keep all surrounding user content).
169
+ * Used by uninstall.
170
+ * @returns 'removed' | 'not-found' | 'file-deleted'
171
+ * `file-deleted` means the file became empty after stripping the marker
172
+ * (i.e. it was wholly sweet-search-managed) and the file itself was unlinked.
173
+ */
174
+ export function stripMarkerBlock({ filePath, deleteIfEmpty = true }) {
175
+ if (!existsSync(filePath)) return 'not-found';
176
+ const stat = lstatSync(filePath);
177
+ // Symlinks: never edit the link target — caller handles symlink removal
178
+ // explicitly via removeSymlinkIfOurs.
179
+ if (stat.isSymbolicLink()) return 'not-found';
180
+ const current = readFileSync(filePath, 'utf8');
181
+ if (!MARKER_RE.test(current)) return 'not-found';
182
+ const next = current.replace(MARKER_RE, '').replace(/^\n+/, '').trimEnd() + '\n';
183
+ if (deleteIfEmpty && next.trim() === '') {
184
+ unlinkSync(filePath);
185
+ return 'file-deleted';
186
+ }
187
+ writeFileSync(filePath, next);
188
+ return 'removed';
189
+ }
190
+
191
+ // ─── Symlink helpers (GEMINI.md → canonical, etc.) ──────────────────────────
192
+
193
+ /**
194
+ * Create a relative symlink `linkPath → targetPath` only if `linkPath`
195
+ * doesn't already exist (or is already the same symlink). Falls back to
196
+ * `inline` if the link can't be created (Windows w/o privilege, target on
197
+ * different volume).
198
+ *
199
+ * @returns 'created' | 'already-correct' | 'fell-back-to-copy' | 'preserved-existing'
200
+ */
201
+ export function symlinkOrFallback({ linkPath, targetPath, fallbackInject }) {
202
+ const relTarget = relative(dirname(linkPath), targetPath);
203
+ if (existsSync(linkPath) || lstatExists(linkPath)) {
204
+ let stat;
205
+ try { stat = lstatSync(linkPath); } catch { stat = null; }
206
+ if (stat && stat.isSymbolicLink()) {
207
+ const current = readlinkSync(linkPath);
208
+ if (current === relTarget) return 'already-correct';
209
+ // Different symlink target — leave it alone, user may have customised.
210
+ return 'preserved-existing';
211
+ }
212
+ // Regular file already exists — never overwrite. Inject marker into the
213
+ // existing file so the policy still flows through.
214
+ fallbackInject();
215
+ return 'fell-back-to-copy';
216
+ }
217
+ try {
218
+ mkdirSync(dirname(linkPath), { recursive: true });
219
+ symlinkSync(relTarget, linkPath);
220
+ return 'created';
221
+ } catch {
222
+ fallbackInject();
223
+ return 'fell-back-to-copy';
224
+ }
225
+ }
226
+
227
+ function lstatExists(p) {
228
+ try { lstatSync(p); return true; } catch { return false; }
229
+ }
230
+
231
+ /**
232
+ * Remove a symlink only if it points at the expected sweet-search target.
233
+ * @returns 'removed' | 'not-our-symlink' | 'not-found'
234
+ */
235
+ export function removeSymlinkIfOurs({ linkPath, expectedTargets }) {
236
+ if (!lstatExists(linkPath)) return 'not-found';
237
+ let stat;
238
+ try { stat = lstatSync(linkPath); } catch { return 'not-found'; }
239
+ if (!stat.isSymbolicLink()) return 'not-our-symlink';
240
+ let target;
241
+ try { target = readlinkSync(linkPath); } catch { return 'not-our-symlink'; }
242
+ const expected = Array.isArray(expectedTargets) ? expectedTargets : [expectedTargets];
243
+ if (!expected.includes(target)) return 'not-our-symlink';
244
+ unlinkSync(linkPath);
245
+ return 'removed';
246
+ }
247
+
248
+ // ─── Public API: install + uninstall ────────────────────────────────────────
249
+
250
+ /**
251
+ * Install the canonical policy file plus per-harness shims/symlinks.
252
+ * Idempotent. Returns a per-file status report.
253
+ *
254
+ * @param {object} args
255
+ * @param {string} args.projectRoot
256
+ * @param {string[]} [args.harnesses] defaults to ALL_HARNESSES
257
+ * @param {boolean} [args.useSymlinks] default true; only governs GEMINI.md
258
+ *
259
+ * Canonical resolution:
260
+ * - claude-code enabled → CLAUDE.md is canonical (body inside marker)
261
+ * - claude-code disabled → AGENTS.md is canonical
262
+ * Other harnesses always import / symlink to whichever file is canonical.
263
+ */
264
+ export function injectAgentInstructions({
265
+ projectRoot,
266
+ harnesses = ALL_HARNESSES,
267
+ useSymlinks = true,
268
+ } = {}) {
269
+ if (!projectRoot) throw new TypeError('inject-agent-instructions: projectRoot is required');
270
+ const enabled = new Set(harnesses);
271
+ const report = { harnesses: {}, canonical: null };
272
+
273
+ if (enabled.size === 0) return report;
274
+
275
+ // 1. Canonical file: CLAUDE.md when Claude Code is enabled, else AGENTS.md.
276
+ // Body is the full policy plus (Claude-only) the @.claude/rules import.
277
+ let canonicalFile;
278
+ let canonicalBlock;
279
+ if (enabled.has('claude-code')) {
280
+ canonicalFile = CLAUDE_FILE;
281
+ canonicalBlock = buildCanonicalBlock({
282
+ extraImports: ['.claude/rules/sweet-search.md'],
283
+ });
284
+ report.canonical = 'claude-code';
285
+ } else if (enabled.has('agents') || enabled.has('gemini') || enabled.has('cursor')) {
286
+ canonicalFile = AGENTS_FILE;
287
+ canonicalBlock = buildCanonicalBlock();
288
+ report.canonical = 'agents'; // AGENTS.md is the multi-harness convention (Codex, OpenCode, …)
289
+ } else {
290
+ return report; // no canonical, nothing to write
291
+ }
292
+
293
+ if (enabled.has('claude-code')) {
294
+ const claudePath = join(projectRoot, CLAUDE_FILE);
295
+ report.harnesses['claude-code'] = injectMarkerBlock({
296
+ filePath: claudePath,
297
+ block: canonicalBlock,
298
+ });
299
+ }
300
+
301
+ // 2. AGENTS.md — canonical when Claude Code is disabled, otherwise a
302
+ // @CLAUDE.md import shim for Codex / OpenCode.
303
+ if (enabled.has('agents')) {
304
+ const agentsPath = join(projectRoot, AGENTS_FILE);
305
+ if (canonicalFile === AGENTS_FILE) {
306
+ report.harnesses.agents = injectMarkerBlock({
307
+ filePath: agentsPath,
308
+ block: canonicalBlock,
309
+ });
310
+ } else {
311
+ report.harnesses.agents = injectMarkerBlock({
312
+ filePath: agentsPath,
313
+ block: buildImportBlock({ importTargets: [canonicalFile] }),
314
+ });
315
+ }
316
+ }
317
+
318
+ // 3. GEMINI.md — symlink → canonical when possible, else marker block with @import.
319
+ if (enabled.has('gemini')) {
320
+ const geminiPath = join(projectRoot, GEMINI_FILE);
321
+ if (useSymlinks) {
322
+ report.harnesses.gemini = symlinkOrFallback({
323
+ linkPath: geminiPath,
324
+ targetPath: join(projectRoot, canonicalFile),
325
+ fallbackInject: () => injectMarkerBlock({
326
+ filePath: geminiPath,
327
+ block: buildImportBlock({ importTargets: [canonicalFile] }),
328
+ }),
329
+ });
330
+ } else {
331
+ report.harnesses.gemini = injectMarkerBlock({
332
+ filePath: geminiPath,
333
+ block: buildImportBlock({ importTargets: [canonicalFile] }),
334
+ });
335
+ }
336
+ }
337
+
338
+ // 4. .cursor/rules/sweet-search.mdc — frontmatter + inlined body (no symlink).
339
+ if (enabled.has('cursor')) {
340
+ const cursorPath = join(projectRoot, CURSOR_FILE);
341
+ if (existsSync(cursorPath)) {
342
+ // Existing file — replace marker block in-place; preserve frontmatter
343
+ // and any user notes outside the markers.
344
+ report.harnesses.cursor = injectMarkerBlock({
345
+ filePath: cursorPath,
346
+ block: buildCanonicalBlock(),
347
+ });
348
+ } else {
349
+ // Fresh file — write frontmatter + canonical body in marker block.
350
+ mkdirSync(dirname(cursorPath), { recursive: true });
351
+ writeFileSync(cursorPath, buildCursorFile());
352
+ report.harnesses.cursor = 'created';
353
+ }
354
+ }
355
+
356
+ return report;
357
+ }
358
+
359
+ /**
360
+ * Reverse `injectAgentInstructions`. Strips marker blocks (preserving user
361
+ * content) and removes our symlinks. Per §4A: never modify content outside
362
+ * the marker; never delete a file the user created.
363
+ */
364
+ export function removeAgentInstructions({ projectRoot, dryRun = false } = {}) {
365
+ if (!projectRoot) throw new TypeError('remove-agent-instructions: projectRoot is required');
366
+ const report = { harnesses: {} };
367
+ const claudePath = join(projectRoot, CLAUDE_FILE);
368
+ const agentsPath = join(projectRoot, AGENTS_FILE);
369
+ const geminiPath = join(projectRoot, GEMINI_FILE);
370
+ const cursorPath = join(projectRoot, CURSOR_FILE);
371
+
372
+ if (dryRun) {
373
+ report.harnesses['claude-code'] = previewMarkerStrip(claudePath);
374
+ report.harnesses.agents = previewMarkerStrip(agentsPath);
375
+ report.harnesses.gemini = previewSymlinkRemoval(geminiPath, [CLAUDE_FILE, AGENTS_FILE])
376
+ || previewMarkerStrip(geminiPath);
377
+ report.harnesses.cursor = previewWholeFileRemoval(cursorPath);
378
+ return report;
379
+ }
380
+
381
+ report.harnesses['claude-code'] = stripMarkerBlock({ filePath: claudePath });
382
+ report.harnesses.agents = stripMarkerBlock({ filePath: agentsPath });
383
+ // GEMINI.md: try symlink removal first (accept either canonical target),
384
+ // then fall back to marker strip if it was a copy.
385
+ const geminiSymlink = removeSymlinkIfOurs({
386
+ linkPath: geminiPath,
387
+ expectedTargets: [CLAUDE_FILE, AGENTS_FILE],
388
+ });
389
+ report.harnesses.gemini = geminiSymlink !== 'not-our-symlink'
390
+ ? geminiSymlink
391
+ : stripMarkerBlock({ filePath: geminiPath });
392
+ // Cursor file: we always wholly own it (frontmatter is ours), so safe to remove
393
+ // when our marker is present. Preserve user-customised cursor files.
394
+ report.harnesses.cursor = removeWholeFileIfOurs(cursorPath);
395
+
396
+ return report;
397
+ }
398
+
399
+ function previewMarkerStrip(filePath) {
400
+ if (!existsSync(filePath)) return 'not-found';
401
+ let stat;
402
+ try { stat = lstatSync(filePath); } catch { return 'not-found'; }
403
+ if (stat.isSymbolicLink()) return 'not-found';
404
+ const text = readFileSync(filePath, 'utf8');
405
+ return MARKER_RE.test(text) ? 'dry-run' : 'not-found';
406
+ }
407
+
408
+ function previewSymlinkRemoval(linkPath, expectedTargets) {
409
+ if (!lstatExists(linkPath)) return null;
410
+ let stat;
411
+ try { stat = lstatSync(linkPath); } catch { return null; }
412
+ if (!stat.isSymbolicLink()) return null;
413
+ let target;
414
+ try { target = readlinkSync(linkPath); } catch { return null; }
415
+ const expected = Array.isArray(expectedTargets) ? expectedTargets : [expectedTargets];
416
+ return expected.includes(target) ? 'dry-run' : null;
417
+ }
418
+
419
+ function previewWholeFileRemoval(filePath) {
420
+ if (!existsSync(filePath)) return 'not-found';
421
+ const text = readFileSync(filePath, 'utf8');
422
+ return MARKER_RE.test(text) ? 'dry-run' : 'not-found';
423
+ }
424
+
425
+ function removeWholeFileIfOurs(filePath) {
426
+ if (!existsSync(filePath)) return 'not-found';
427
+ const text = readFileSync(filePath, 'utf8');
428
+ if (!MARKER_RE.test(text)) return 'not-our-symlink'; // user-owned cursor file
429
+ unlinkSync(filePath);
430
+ return 'file-deleted';
431
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Install / remove the sweet-search UserPromptSubmit reminder hook.
3
+ *
4
+ * Plan reference: §4C / §10 step 16. Default-on; `--no-prompt-reminders`
5
+ * opts out. Universally gated by `--no-claude` (handled in init.js — when
6
+ * --no-claude, this installer is never called).
7
+ *
8
+ * Mirrors the existing prewarm SessionStart pattern in init.js
9
+ * (registerPrewarmSessionStartHook): sweet-search-owned by filename match,
10
+ * so re-running init updates the entry rather than appending duplicates.
11
+ */
12
+
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
14
+ import { dirname, isAbsolute, join, relative } from 'node:path';
15
+
16
+ // Filename doubles as the ownership marker — settings.json entries are
17
+ // matched by `command.includes(PROMPT_REMINDER_HOOK_FILENAME)`.
18
+ export const PROMPT_REMINDER_HOOK_FILENAME = 'sweet-search-remind-tools.mjs';
19
+ const SOURCE_REL = ['scripts', 'hooks', 'remind-tools.mjs'];
20
+
21
+ /**
22
+ * Copy the hook script into `.claude/hooks/` and register a
23
+ * `hooks.UserPromptSubmit` entry in `.claude/settings.json`. Idempotent.
24
+ *
25
+ * @param {object} args
26
+ * @param {string} args.projectRoot
27
+ * @param {string} args.packageRoot — sweet-search install root (for source resolution)
28
+ * @param {boolean} [args.skipped=false] — set when --no-prompt-reminders
29
+ * @returns {{ status: string, detail: string, hookPath?: string }}
30
+ * status ∈ { registered, skipped, error }
31
+ */
32
+ export function installPromptReminderHook({ projectRoot, packageRoot, skipped = false } = {}) {
33
+ if (skipped) return { status: 'skipped', detail: '--no-prompt-reminders flag' };
34
+ if (!projectRoot) return { status: 'error', detail: 'install-prompt-reminders: projectRoot is required' };
35
+ if (!packageRoot) return { status: 'error', detail: 'install-prompt-reminders: packageRoot is required' };
36
+
37
+ const hookSrcAbs = join(packageRoot, ...SOURCE_REL);
38
+ if (!existsSync(hookSrcAbs)) {
39
+ return { status: 'error', detail: `hook source missing: ${hookSrcAbs}` };
40
+ }
41
+
42
+ const hookDestAbs = join(projectRoot, '.claude', 'hooks', PROMPT_REMINDER_HOOK_FILENAME);
43
+ const hookRelFromProject = relative(projectRoot, hookDestAbs);
44
+ if (hookRelFromProject.startsWith('..') || isAbsolute(hookRelFromProject)) {
45
+ // Defensive — hookDestAbs is constructed from projectRoot so this can't
46
+ // happen, but the same guardrail used by registerPrewarmSessionStartHook
47
+ // catches anyone refactoring the join() above incorrectly.
48
+ return { status: 'error', detail: 'hook destination escapes projectRoot' };
49
+ }
50
+
51
+ try {
52
+ mkdirSync(dirname(hookDestAbs), { recursive: true });
53
+ copyFileSync(hookSrcAbs, hookDestAbs);
54
+ } catch (err) {
55
+ return { status: 'error', detail: `hook copy failed: ${err.message}` };
56
+ }
57
+
58
+ const command = `node ${hookRelFromProject}`;
59
+ const settingsResult = writeUserPromptSubmitEntry({
60
+ projectRoot,
61
+ command,
62
+ ownershipFilename: PROMPT_REMINDER_HOOK_FILENAME,
63
+ });
64
+ if (settingsResult.status !== 'registered') return settingsResult;
65
+ return { ...settingsResult, hookPath: hookRelFromProject };
66
+ }
67
+
68
+ /**
69
+ * Reverse `installPromptReminderHook`. Removes the hook file and the
70
+ * UserPromptSubmit settings entry sweet-search owns. Never touches other
71
+ * UserPromptSubmit entries the user added.
72
+ *
73
+ * @returns {{ status: string, detail: string }} — status ∈
74
+ * { removed, not-found, dry-run, error }
75
+ */
76
+ export function removePromptReminderHook({ projectRoot, dryRun = false } = {}) {
77
+ if (!projectRoot) return { status: 'error', detail: 'remove-prompt-reminders: projectRoot is required' };
78
+
79
+ const hookDestAbs = join(projectRoot, '.claude', 'hooks', PROMPT_REMINDER_HOOK_FILENAME);
80
+ const settingsPath = join(projectRoot, '.claude', 'settings.json');
81
+
82
+ const hookExists = existsSync(hookDestAbs);
83
+ const entryExists = settingsHasOurEntry(settingsPath, PROMPT_REMINDER_HOOK_FILENAME);
84
+
85
+ if (!hookExists && !entryExists) return { status: 'not-found', detail: 'no hook file, no settings entry' };
86
+ if (dryRun) {
87
+ return {
88
+ status: 'dry-run',
89
+ detail: [hookExists && 'hook file', entryExists && 'settings entry'].filter(Boolean).join(' + '),
90
+ };
91
+ }
92
+
93
+ const removedParts = [];
94
+ if (hookExists) {
95
+ try { unlinkSync(hookDestAbs); removedParts.push('hook file'); }
96
+ catch (err) { return { status: 'error', detail: `failed to remove hook: ${err.message}` }; }
97
+ }
98
+ if (entryExists) {
99
+ const r = removeUserPromptSubmitEntry({ projectRoot, ownershipFilename: PROMPT_REMINDER_HOOK_FILENAME });
100
+ if (r.status === 'error') return r;
101
+ if (r.status === 'removed') removedParts.push('settings entry');
102
+ }
103
+ return { status: 'removed', detail: removedParts.join(' + ') };
104
+ }
105
+
106
+ // ─── Shared helpers (factored so install-tool-enforcement.js can reuse) ─────
107
+
108
+ /**
109
+ * Write or update a `.claude/settings.json` `hooks.UserPromptSubmit` entry
110
+ * sweet-search owns. Find-by-filename so re-running never duplicates.
111
+ */
112
+ export function writeUserPromptSubmitEntry({ projectRoot, command, ownershipFilename }) {
113
+ const settingsDir = join(projectRoot, '.claude');
114
+ const settingsPath = join(settingsDir, 'settings.json');
115
+
116
+ let settings = {};
117
+ if (existsSync(settingsPath)) {
118
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); }
119
+ catch (err) { return { status: 'error', detail: `existing settings.json is not valid JSON: ${err.message}` }; }
120
+ }
121
+ settings.hooks = settings.hooks || {};
122
+ const arr = Array.isArray(settings.hooks.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
123
+
124
+ const entry = {
125
+ hooks: [
126
+ { type: 'command', command, timeout: 4000, continueOnError: true },
127
+ ],
128
+ };
129
+ const ownedIdx = arr.findIndex(group =>
130
+ Array.isArray(group?.hooks)
131
+ && group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(ownershipFilename)),
132
+ );
133
+ let detail;
134
+ if (ownedIdx >= 0) { arr[ownedIdx] = entry; detail = 'updated existing entry'; }
135
+ else { arr.push(entry); detail = 'added new entry'; }
136
+ settings.hooks.UserPromptSubmit = arr;
137
+
138
+ try {
139
+ mkdirSync(settingsDir, { recursive: true });
140
+ const tmp = settingsPath + '.tmp';
141
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf8');
142
+ renameSync(tmp, settingsPath);
143
+ } catch (err) {
144
+ return { status: 'error', detail: err.message };
145
+ }
146
+ return { status: 'registered', detail };
147
+ }
148
+
149
+ export function removeUserPromptSubmitEntry({ projectRoot, ownershipFilename }) {
150
+ const settingsPath = join(projectRoot, '.claude', 'settings.json');
151
+ if (!existsSync(settingsPath)) return { status: 'not-found', detail: 'no settings.json' };
152
+ let settings;
153
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); }
154
+ catch (err) { return { status: 'error', detail: `settings.json invalid: ${err.message}` }; }
155
+ const arr = Array.isArray(settings?.hooks?.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
156
+ const next = arr.filter(group =>
157
+ !Array.isArray(group?.hooks)
158
+ || !group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(ownershipFilename)),
159
+ );
160
+ if (next.length === arr.length) return { status: 'not-found', detail: 'no sweet-search entry' };
161
+ if (next.length === 0) {
162
+ if (settings.hooks) delete settings.hooks.UserPromptSubmit;
163
+ } else {
164
+ settings.hooks.UserPromptSubmit = next;
165
+ }
166
+ // Drop empty `hooks` to keep settings.json tidy.
167
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) delete settings.hooks;
168
+ try {
169
+ const tmp = settingsPath + '.tmp';
170
+ writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf8');
171
+ renameSync(tmp, settingsPath);
172
+ } catch (err) {
173
+ return { status: 'error', detail: err.message };
174
+ }
175
+ return { status: 'removed', detail: 'stripped UserPromptSubmit entry' };
176
+ }
177
+
178
+ function settingsHasOurEntry(settingsPath, ownershipFilename) {
179
+ if (!existsSync(settingsPath)) return false;
180
+ let settings;
181
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); }
182
+ catch { return false; }
183
+ const arr = Array.isArray(settings?.hooks?.UserPromptSubmit) ? settings.hooks.UserPromptSubmit : [];
184
+ return arr.some(group =>
185
+ Array.isArray(group?.hooks)
186
+ && group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(ownershipFilename)),
187
+ );
188
+ }