@cleocode/core 2026.8.9 → 2026.9.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 (171) hide show
  1. package/dist/conduit/conduit-client.d.ts.map +1 -1
  2. package/dist/conduit/conduit-client.js +10 -2
  3. package/dist/conduit/conduit-client.js.map +1 -1
  4. package/dist/dispatch/mvi-projection.d.ts +38 -0
  5. package/dist/dispatch/mvi-projection.d.ts.map +1 -1
  6. package/dist/dispatch/mvi-projection.js +189 -11
  7. package/dist/dispatch/mvi-projection.js.map +1 -1
  8. package/dist/docs/export-document.js +41 -3
  9. package/dist/docs/export-document.js.map +2 -2
  10. package/dist/doctor/acceptance-drift.d.ts +258 -0
  11. package/dist/doctor/acceptance-drift.d.ts.map +1 -0
  12. package/dist/doctor/acceptance-drift.js +366 -0
  13. package/dist/doctor/acceptance-drift.js.map +1 -0
  14. package/dist/doctor/index.d.ts +4 -0
  15. package/dist/doctor/index.d.ts.map +1 -1
  16. package/dist/doctor/index.js +2 -0
  17. package/dist/doctor/index.js.map +1 -1
  18. package/dist/doctor/legacy-reaper.d.ts +105 -0
  19. package/dist/doctor/legacy-reaper.d.ts.map +1 -0
  20. package/dist/doctor/legacy-reaper.js +151 -0
  21. package/dist/doctor/legacy-reaper.js.map +1 -0
  22. package/dist/doctor/malformed-task-ids.d.ts +111 -0
  23. package/dist/doctor/malformed-task-ids.d.ts.map +1 -0
  24. package/dist/doctor/malformed-task-ids.js +139 -0
  25. package/dist/doctor/malformed-task-ids.js.map +1 -0
  26. package/dist/doctor/nexus-schema-residency.d.ts +115 -0
  27. package/dist/doctor/nexus-schema-residency.d.ts.map +1 -0
  28. package/dist/doctor/nexus-schema-residency.js +178 -0
  29. package/dist/doctor/nexus-schema-residency.js.map +1 -0
  30. package/dist/error-registry.d.ts.map +1 -1
  31. package/dist/error-registry.js +18 -0
  32. package/dist/error-registry.js.map +1 -1
  33. package/dist/internal.d.ts +1 -0
  34. package/dist/internal.d.ts.map +1 -1
  35. package/dist/internal.js +1 -0
  36. package/dist/internal.js.map +1 -1
  37. package/dist/llm/ai-sdk-warnings.d.ts +55 -0
  38. package/dist/llm/ai-sdk-warnings.d.ts.map +1 -0
  39. package/dist/llm/ai-sdk-warnings.js +83 -0
  40. package/dist/llm/ai-sdk-warnings.js.map +1 -0
  41. package/dist/llm/model-runner.d.ts +0 -8
  42. package/dist/llm/model-runner.d.ts.map +1 -1
  43. package/dist/llm/model-runner.js +8 -0
  44. package/dist/llm/model-runner.js.map +1 -1
  45. package/dist/llm/plugin-facade.js +73 -8
  46. package/dist/llm/plugin-facade.js.map +3 -3
  47. package/dist/memory/embedding-queue.d.ts +12 -0
  48. package/dist/memory/embedding-queue.d.ts.map +1 -1
  49. package/dist/memory/embedding-queue.js +39 -5
  50. package/dist/memory/embedding-queue.js.map +1 -1
  51. package/dist/orchestrate/worktree-complete.d.ts.map +1 -1
  52. package/dist/orchestrate/worktree-complete.js +26 -0
  53. package/dist/orchestrate/worktree-complete.js.map +1 -1
  54. package/dist/output.d.ts.map +1 -1
  55. package/dist/output.js +25 -0
  56. package/dist/output.js.map +1 -1
  57. package/dist/release/pr-evidence.d.ts +13 -0
  58. package/dist/release/pr-evidence.d.ts.map +1 -1
  59. package/dist/release/pr-evidence.js +54 -1
  60. package/dist/release/pr-evidence.js.map +1 -1
  61. package/dist/render/session/briefing.d.ts.map +1 -1
  62. package/dist/render/session/briefing.js +9 -0
  63. package/dist/render/session/briefing.js.map +1 -1
  64. package/dist/selfimprove/fix-gen.js +73 -8
  65. package/dist/selfimprove/fix-gen.js.map +3 -3
  66. package/dist/sessions/briefing.d.ts +13 -0
  67. package/dist/sessions/briefing.d.ts.map +1 -1
  68. package/dist/sessions/briefing.js +64 -7
  69. package/dist/sessions/briefing.js.map +1 -1
  70. package/dist/sessions/handoff.d.ts +32 -4
  71. package/dist/sessions/handoff.d.ts.map +1 -1
  72. package/dist/sessions/handoff.js +7 -7
  73. package/dist/sessions/handoff.js.map +1 -1
  74. package/dist/shutdown-deadline.d.ts +144 -0
  75. package/dist/shutdown-deadline.d.ts.map +1 -0
  76. package/dist/shutdown-deadline.js +187 -0
  77. package/dist/shutdown-deadline.js.map +1 -0
  78. package/dist/shutdown.d.ts +5 -2
  79. package/dist/shutdown.d.ts.map +1 -1
  80. package/dist/shutdown.js +19 -14
  81. package/dist/shutdown.js.map +1 -1
  82. package/dist/spawn/branch-lock.d.ts.map +1 -1
  83. package/dist/spawn/branch-lock.js +33 -0
  84. package/dist/spawn/branch-lock.js.map +1 -1
  85. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  86. package/dist/store/sqlite-data-accessor.js +14 -0
  87. package/dist/store/sqlite-data-accessor.js.map +1 -1
  88. package/dist/store/tasks-sqlite.d.ts.map +1 -1
  89. package/dist/store/tasks-sqlite.js +16 -1
  90. package/dist/store/tasks-sqlite.js.map +1 -1
  91. package/dist/tasks/ac-immutability.d.ts.map +1 -1
  92. package/dist/tasks/ac-immutability.js +36 -0
  93. package/dist/tasks/ac-immutability.js.map +1 -1
  94. package/dist/tasks/add.d.ts +18 -0
  95. package/dist/tasks/add.d.ts.map +1 -1
  96. package/dist/tasks/add.js +28 -5
  97. package/dist/tasks/add.js.map +1 -1
  98. package/dist/tasks/axis-filters.d.ts +53 -0
  99. package/dist/tasks/axis-filters.d.ts.map +1 -0
  100. package/dist/tasks/axis-filters.js +77 -0
  101. package/dist/tasks/axis-filters.js.map +1 -0
  102. package/dist/tasks/complete.d.ts.map +1 -1
  103. package/dist/tasks/complete.js +10 -1
  104. package/dist/tasks/complete.js.map +1 -1
  105. package/dist/tasks/duplicate-detector.d.ts +67 -14
  106. package/dist/tasks/duplicate-detector.d.ts.map +1 -1
  107. package/dist/tasks/duplicate-detector.js +197 -54
  108. package/dist/tasks/duplicate-detector.js.map +1 -1
  109. package/dist/tasks/evidence.d.ts +139 -0
  110. package/dist/tasks/evidence.d.ts.map +1 -1
  111. package/dist/tasks/evidence.js +294 -25
  112. package/dist/tasks/evidence.js.map +1 -1
  113. package/dist/tasks/find.d.ts.map +1 -1
  114. package/dist/tasks/find.js +9 -2
  115. package/dist/tasks/find.js.map +1 -1
  116. package/dist/tasks/gate-runner.d.ts.map +1 -1
  117. package/dist/tasks/gate-runner.js +25 -4
  118. package/dist/tasks/gate-runner.js.map +1 -1
  119. package/dist/tasks/heavy-tool-env.d.ts +20 -0
  120. package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
  121. package/dist/tasks/heavy-tool-env.js +74 -4
  122. package/dist/tasks/heavy-tool-env.js.map +1 -1
  123. package/dist/tasks/heavy-tool-limit.d.ts +134 -0
  124. package/dist/tasks/heavy-tool-limit.d.ts.map +1 -0
  125. package/dist/tasks/heavy-tool-limit.js +196 -0
  126. package/dist/tasks/heavy-tool-limit.js.map +1 -0
  127. package/dist/tasks/index.d.ts +1 -1
  128. package/dist/tasks/index.d.ts.map +1 -1
  129. package/dist/tasks/index.js +1 -1
  130. package/dist/tasks/index.js.map +1 -1
  131. package/dist/tasks/infer-add-params.d.ts +28 -0
  132. package/dist/tasks/infer-add-params.d.ts.map +1 -1
  133. package/dist/tasks/infer-add-params.js +96 -6
  134. package/dist/tasks/infer-add-params.js.map +1 -1
  135. package/dist/tasks/list.d.ts +30 -1
  136. package/dist/tasks/list.d.ts.map +1 -1
  137. package/dist/tasks/list.js +14 -0
  138. package/dist/tasks/list.js.map +1 -1
  139. package/dist/tasks/ops.d.ts +4 -0
  140. package/dist/tasks/ops.d.ts.map +1 -1
  141. package/dist/tasks/ops.js +2 -0
  142. package/dist/tasks/ops.js.map +1 -1
  143. package/dist/tasks/session-scope.d.ts +51 -4
  144. package/dist/tasks/session-scope.d.ts.map +1 -1
  145. package/dist/tasks/session-scope.js +68 -10
  146. package/dist/tasks/session-scope.js.map +1 -1
  147. package/dist/tasks/tool-cache.d.ts +116 -9
  148. package/dist/tasks/tool-cache.d.ts.map +1 -1
  149. package/dist/tasks/tool-cache.js +127 -16
  150. package/dist/tasks/tool-cache.js.map +1 -1
  151. package/dist/tasks/tool-semaphore.d.ts +91 -0
  152. package/dist/tasks/tool-semaphore.d.ts.map +1 -1
  153. package/dist/tasks/tool-semaphore.js +203 -14
  154. package/dist/tasks/tool-semaphore.js.map +1 -1
  155. package/dist/validation/doctor/checks.d.ts +64 -0
  156. package/dist/validation/doctor/checks.d.ts.map +1 -1
  157. package/dist/validation/doctor/checks.js +249 -0
  158. package/dist/validation/doctor/checks.js.map +1 -1
  159. package/dist/validation/engine-ops.d.ts.map +1 -1
  160. package/dist/validation/engine-ops.js +41 -5
  161. package/dist/validation/engine-ops.js.map +1 -1
  162. package/package.json +13 -13
  163. package/templates/CLEO-INJECTION.md +28 -44
  164. package/dist/backfill/adr-backfill-walker.d.ts +0 -57
  165. package/dist/backfill/adr-backfill-walker.d.ts.map +0 -1
  166. package/dist/backfill/adr-backfill-walker.js +0 -631
  167. package/dist/backfill/adr-backfill-walker.js.map +0 -1
  168. package/dist/tasks/graph-rag.d.ts +0 -49
  169. package/dist/tasks/graph-rag.d.ts.map +0 -1
  170. package/dist/tasks/graph-rag.js +0 -328
  171. package/dist/tasks/graph-rag.js.map +0 -1
@@ -6,7 +6,11 @@
6
6
  *
7
7
  * Three-tier escalation (T1681):
8
8
  *
9
- * Tier 1 — BM25 / vector similarity (current: Jaccard trigrams or cosine):
9
+ * Tier 1 — lexical OR vector similarity. NOT BM25, despite the historical
10
+ * naming: there is no IDF, no term frequency and no length normalisation.
11
+ * Two implementations, selected at runtime (see `runDuplicateCheck`):
12
+ * - lexical: Jaccard over CHARACTER TRIGRAMS of a title-2x-weighted blob
13
+ * - vector: cosine over embeddings, when an embedding provider is loaded
10
14
  * score >= 0.92 → reject (clear match)
11
15
  * score < 0.50 → insert (clear different)
12
16
  * score in [0.50, 0.92) → escalate to Tier 2
@@ -19,11 +23,11 @@
19
23
  * Tier 3 — LLM reasoning (max 1 call per cleo add):
20
24
  * are_duplicate=true → reject
21
25
  * are_duplicate=false → insert
22
- * LLM error / timeout → fall back to BM25-only decision (never block on error)
26
+ * LLM error / timeout → fall back to Tier-1-only decision (never block on error)
23
27
  *
24
- * Thresholds (original T1633 behavior preserved for BM25 clear-match path):
25
- * BM25 score >= 0.85 → warning emitted to stderr (non-blocking)
26
- * BM25 score >= 0.92 → rejected with E_DUPLICATE_TASK_LIKELY
28
+ * Thresholds (original T1633 behavior preserved for the Tier-1 clear-match path):
29
+ * Tier-1 score >= 0.85 → warning emitted to stderr (non-blocking)
30
+ * Tier-1 score >= 0.92 → rejected with E_DUPLICATE_TASK_LIKELY
27
31
  *
28
32
  * @epic T1627
29
33
  * @task T1633
@@ -32,9 +36,9 @@
32
36
  import type { Task } from '@cleocode/contracts';
33
37
  import { z } from 'zod';
34
38
  import type { DataAccessor } from '../store/data-accessor.js';
35
- /** BM25 score at which a non-blocking warning is emitted. */
39
+ /** Tier-1 score at which a non-blocking warning is emitted. (Name kept: exported API.) */
36
40
  export declare const DUPLICATE_WARN_THRESHOLD = 0.85;
37
- /** BM25 score at which task creation is rejected (clear match — no escalation). */
41
+ /** Tier-1 score at which creation is rejected (clear match). (Name kept: exported API.) */
38
42
  export declare const DUPLICATE_REJECT_THRESHOLD = 0.92;
39
43
  /** Maximum number of candidates to surface in the warning/rejection message. */
40
44
  export declare const MAX_CANDIDATES = 3;
@@ -64,6 +68,14 @@ export interface DuplicateCheckResult {
64
68
  * Which tier produced the final decision.
65
69
  * @task T1681
66
70
  */
71
+ /**
72
+ * Which tier produced the decision.
73
+ *
74
+ * `'bm25'` is a HISTORICAL label for the Tier-1 measure, kept because it is
75
+ * emitted in the envelope and renaming it would break consumers. Tier 1 is
76
+ * not BM25 — see the module docblock. Reporting WHICH Tier-1 implementation
77
+ * ran (lexical vs vector) is tracked separately.
78
+ */
67
79
  tier?: 'bm25' | 'jaccard' | 'llm';
68
80
  }
69
81
  /**
@@ -87,7 +99,7 @@ export type DuplicateReasoning = z.infer<typeof DuplicateReasoningSchema>;
87
99
  *
88
100
  * Uses Jaccard similarity over character trigrams of a weighted blob
89
101
  * (title 2×, description 1×). This is zero-dependency, deterministic,
90
- * and symmetrical. Used as the BM25 proxy in Tier 1.
102
+ * and symmetrical. This is the LEXICAL Tier-1 measure — it is not BM25.
91
103
  *
92
104
  * @param titleA - First title.
93
105
  * @param descA - First description.
@@ -98,7 +110,7 @@ export type DuplicateReasoning = z.infer<typeof DuplicateReasoningSchema>;
98
110
  export declare function computeLexicalSimilarity(titleA: string, descA: string, titleB: string, descB: string): number;
99
111
  /**
100
112
  * Compute Jaccard similarity over word-level n-grams including labels.
101
- * Used as Tier-2 discriminator when BM25 is ambiguous.
113
+ * Used as Tier-2 discriminator when the Tier-1 score is ambiguous.
102
114
  *
103
115
  * @param titleA - First title.
104
116
  * @param descA - First description.
@@ -114,7 +126,7 @@ export declare function computeJaccardWordSimilarity(titleA: string, descA: stri
114
126
  *
115
127
  * Cost cap: max 1 call per `cleo add` invocation (enforced by the caller via
116
128
  * a `llmCallMade` flag). Never throws — returns null on error/timeout so the
117
- * caller can fall back to BM25-only decision.
129
+ * caller can fall back to a Tier-1-only decision.
118
130
  *
119
131
  * @param incomingTitle - Title of the task being added.
120
132
  * @param incomingDescription - Description of the task being added.
@@ -132,11 +144,11 @@ export declare function callLlmDuplicateReasoning(incomingTitle: string, incomin
132
144
  *
133
145
  * Algorithm:
134
146
  *
135
- * Tier 1 — BM25 / vector similarity (per candidate):
147
+ * Tier 1 — lexical or vector similarity (per candidate):
136
148
  * For each active task, compute vector cosine similarity (if embedding is available)
137
- * or Jaccard character-trigram similarity as a proxy. This produces a BM25-like score.
149
+ * Jaccard over character trigrams, or cosine over embeddings when a provider is loaded.
138
150
  * - score >= DUPLICATE_REJECT_THRESHOLD (0.92): clear match → reject immediately.
139
- * - score < BM25_ESCALATE_LOW (0.50): clear different → skip Tier 2 + 3 for this candidate.
151
+ * - score < TIER1_ESCALATE_LOW (0.50): clear different → skip Tier 2 + 3 for this candidate.
140
152
  * - score in [0.50, 0.92): ambiguous → collect for Tier-2 Jaccard evaluation.
141
153
  *
142
154
  * Tier 2 — Jaccard word n-grams (title+description+labels):
@@ -148,7 +160,7 @@ export declare function callLlmDuplicateReasoning(incomingTitle: string, incomin
148
160
  *
149
161
  * Tier 3 — LLM reasoning (max 1 call per `cleo add`):
150
162
  * Call the daemon provider with both task descriptions and the structured-output schema.
151
- * On error/timeout: fall back to Tier-1 BM25 decision for the candidate (never block).
163
+ * On error/timeout: fall back to the Tier-1 decision for the candidate (never block).
152
164
  *
153
165
  * @param title - Title of the task being added.
154
166
  * @param description - Description of the task being added (empty string if not provided).
@@ -189,4 +201,45 @@ export declare function buildRejectMessage(candidates: DuplicateCandidate[]): st
189
201
  * @returns Array of non-terminal tasks (pending, active, blocked).
190
202
  */
191
203
  export declare function loadActiveTasks(accessor: DataAccessor): Promise<Task[]>;
204
+ /** Env var overriding {@link DUPLICATE_CHECK_BUDGET_MS}. */
205
+ export declare const DUPLICATE_CHECK_BUDGET_ENV = "CLEO_DUPLICATE_CHECK_BUDGET_MS";
206
+ /**
207
+ * Resolve the duplicate-detection budget, honouring an operator override.
208
+ *
209
+ * @param env - environment to read the override from.
210
+ * @returns budget in ms; the default when unset or malformed.
211
+ */
212
+ export declare function resolveDuplicateCheckBudgetMs(env?: NodeJS.ProcessEnv): number;
213
+ /**
214
+ * {@link checkDuplicates} with a hard deadline and a fail-OPEN result.
215
+ *
216
+ * ## Why fail open (T12117 · issue #1244)
217
+ *
218
+ * Duplicate detection runs before the insert, so anything that stalls it
219
+ * doesn't slow the write down — it *loses* it. Reported from the field: three
220
+ * `cleo add` attempts at 280 s each, none of which created a row, on a host
221
+ * under memory pressure. No error, no id, no task. A silent write failure is
222
+ * the worst failure mode a task tracker has, because the agent that filed the
223
+ * task believes the work is recorded.
224
+ *
225
+ * It is also silent in the dangerous direction relative to CLEO's documented
226
+ * recovery advice. A killed mutation is supposed to mean "query before
227
+ * retrying — the write has usually landed", which was independently confirmed
228
+ * true for a timeout-killed write. But these writes had NOT landed, and nothing
229
+ * in the output distinguishes the two cases. An agent following the contract
230
+ * correctly reaches the wrong conclusion in one direction or the other.
231
+ *
232
+ * So: the row is the product, detection is enrichment. When the budget is
233
+ * blown, report that on stderr and let the insert proceed. A duplicate that
234
+ * slips through is recoverable by a human in seconds; a task that was never
235
+ * created is lost work nobody knows to look for.
236
+ *
237
+ * @param args - forwarded verbatim to {@link checkDuplicates}.
238
+ * @returns the real verdict, or a permissive one when the budget was exceeded.
239
+ *
240
+ * @task T12117
241
+ */
242
+ export declare function checkDuplicatesBounded(...args: Parameters<typeof checkDuplicates>): Promise<DuplicateCheckResult & {
243
+ timedOut: boolean;
244
+ }>;
192
245
  //# sourceMappingURL=duplicate-detector.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"duplicate-detector.d.ts","sourceRoot":"","sources":["../../src/tasks/duplicate-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAChD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAM9D,6DAA6D;AAC7D,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAE7C,mFAAmF;AACnF,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAuB/C,gFAAgF;AAChF,eAAO,MAAM,cAAc,IAAI,CAAC;AAShC,uEAAuE;AACvE,MAAM,WAAW,kBAAkB;IACjC,mCAAmC;IACnC,EAAE,EAAE,MAAM,CAAC;IACX,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;CACf;AAED,kDAAkD;AAClD,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,UAAU,EAAE,kBAAkB,EAAE,CAAC;IACjC,0DAA0D;IAC1D,YAAY,EAAE,OAAO,CAAC;IACtB,uFAAuF;IACvF,UAAU,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,KAAK,CAAC;CACnC;AAMD;;;GAGG;AACH,eAAO,MAAM,wBAAwB;;;;;;;;;iBAYnC,CAAC;AAEH,kDAAkD;AAClD,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAwE1E;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GACZ,MAAM,CAWR;AAuDD;;;;;;;;;;;GAWG;AACH,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EAAE,EACjB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EAAE,GAChB,MAAM,CAIR;AA4FD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAC7C,aAAa,EAAE,MAAM,EACrB,mBAAmB,EAAE,MAAM,EAC3B,SAAS,EAAE,kBAAkB,GAAG;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,EACxD,GAAG,CAAC,EAAE,MAAM,GACX,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAoGpC;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,YAAY,EACtB,MAAM,CAAC,EAAE,MAAM,EAAE,EACjB,GAAG,CAAC,EAAE,MAAM,GACX,OAAO,CAAC,oBAAoB,CAAC,CA6M/B;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,kBAAkB,EAAE,GAAG,MAAM,CAI5E;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,kBAAkB,EAAE,GAAG,MAAM,CAMzE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,kBAAkB,EAAE,GAAG,MAAM,CAM3E;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC,CAM7E"}
1
+ {"version":3,"file":"duplicate-detector.d.ts","sourceRoot":"","sources":["../../src/tasks/duplicate-detector.ts"],"names":[],"mappings":"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"}
@@ -6,7 +6,11 @@
6
6
  *
7
7
  * Three-tier escalation (T1681):
8
8
  *
9
- * Tier 1 — BM25 / vector similarity (current: Jaccard trigrams or cosine):
9
+ * Tier 1 — lexical OR vector similarity. NOT BM25, despite the historical
10
+ * naming: there is no IDF, no term frequency and no length normalisation.
11
+ * Two implementations, selected at runtime (see `runDuplicateCheck`):
12
+ * - lexical: Jaccard over CHARACTER TRIGRAMS of a title-2x-weighted blob
13
+ * - vector: cosine over embeddings, when an embedding provider is loaded
10
14
  * score >= 0.92 → reject (clear match)
11
15
  * score < 0.50 → insert (clear different)
12
16
  * score in [0.50, 0.92) → escalate to Tier 2
@@ -19,11 +23,11 @@
19
23
  * Tier 3 — LLM reasoning (max 1 call per cleo add):
20
24
  * are_duplicate=true → reject
21
25
  * are_duplicate=false → insert
22
- * LLM error / timeout → fall back to BM25-only decision (never block on error)
26
+ * LLM error / timeout → fall back to Tier-1-only decision (never block on error)
23
27
  *
24
- * Thresholds (original T1633 behavior preserved for BM25 clear-match path):
25
- * BM25 score >= 0.85 → warning emitted to stderr (non-blocking)
26
- * BM25 score >= 0.92 → rejected with E_DUPLICATE_TASK_LIKELY
28
+ * Thresholds (original T1633 behavior preserved for the Tier-1 clear-match path):
29
+ * Tier-1 score >= 0.85 → warning emitted to stderr (non-blocking)
30
+ * Tier-1 score >= 0.92 → rejected with E_DUPLICATE_TASK_LIKELY
27
31
  *
28
32
  * @epic T1627
29
33
  * @task T1633
@@ -33,15 +37,15 @@ import { z } from 'zod';
33
37
  // ============================================================================
34
38
  // Thresholds
35
39
  // ============================================================================
36
- /** BM25 score at which a non-blocking warning is emitted. */
40
+ /** Tier-1 score at which a non-blocking warning is emitted. (Name kept: exported API.) */
37
41
  export const DUPLICATE_WARN_THRESHOLD = 0.85;
38
- /** BM25 score at which task creation is rejected (clear match — no escalation). */
42
+ /** Tier-1 score at which creation is rejected (clear match). (Name kept: exported API.) */
39
43
  export const DUPLICATE_REJECT_THRESHOLD = 0.92;
40
44
  /**
41
- * BM25 lower bound for the ambiguous range.
45
+ * Tier-1 lower bound for the ambiguous range.
42
46
  * Scores below this are considered clear-different and skip Jaccard + LLM.
43
47
  */
44
- const BM25_ESCALATE_LOW = 0.5;
48
+ const TIER1_ESCALATE_LOW = 0.5;
45
49
  /**
46
50
  * Jaccard reject threshold.
47
51
  * Scores >= this after Tier-2 escalation cause rejection without calling LLM.
@@ -58,6 +62,29 @@ const MAX_ACTIVE_TASKS_SCAN = 500;
58
62
  export const MAX_CANDIDATES = 3;
59
63
  /** LLM call timeout in milliseconds. */
60
64
  const LLM_TIMEOUT_MS = 15_000;
65
+ /**
66
+ * Maximum candidates that may be scored with local embeddings (T12117).
67
+ *
68
+ * The vector tier used to run inside the per-candidate loop with no bound, so
69
+ * `cleo add` performed one local model inference **per active task** before
70
+ * committing the row — O(active tasks) inferences on the write path. In a
71
+ * project with ~1,000 active tasks that is thousands of inferences to create
72
+ * one row, which is why `cleo add` was observed taking 180-280 s and failing
73
+ * outright on a memory-constrained host (issue #1244).
74
+ *
75
+ * Candidates beyond this budget fall back to the lexical score, which is the
76
+ * same fallback already used whenever embeddings are unavailable — so this
77
+ * bounds cost without introducing a new code path.
78
+ */
79
+ const MAX_VECTOR_CANDIDATES = 25;
80
+ /**
81
+ * Wall-clock budget for the whole vector-scoring phase, in milliseconds.
82
+ *
83
+ * A second bound beside {@link MAX_VECTOR_CANDIDATES} because the count cap
84
+ * assumes each inference is fast, and a cold model load is not. Whichever
85
+ * bound trips first ends the phase.
86
+ */
87
+ const VECTOR_PHASE_BUDGET_MS = 5_000;
61
88
  // ============================================================================
62
89
  // LLM structured-output schema (Tier 3)
63
90
  // ============================================================================
@@ -79,7 +106,7 @@ export const DuplicateReasoningSchema = z.object({
79
106
  suggestion: z.enum(['merge', 'keep-both', 'block-new']),
80
107
  });
81
108
  // ============================================================================
82
- // Similarity Primitives — Tier 1 (BM25 proxy)
109
+ // Similarity Primitives — Tier 1 (lexical: character-trigram Jaccard)
83
110
  // ============================================================================
84
111
  /**
85
112
  * Normalise text for comparison: lowercase, collapse whitespace,
@@ -151,7 +178,7 @@ function buildSearchBlob(title, description) {
151
178
  *
152
179
  * Uses Jaccard similarity over character trigrams of a weighted blob
153
180
  * (title 2×, description 1×). This is zero-dependency, deterministic,
154
- * and symmetrical. Used as the BM25 proxy in Tier 1.
181
+ * and symmetrical. This is the LEXICAL Tier-1 measure — it is not BM25.
155
182
  *
156
183
  * @param titleA - First title.
157
184
  * @param descA - First description.
@@ -202,7 +229,7 @@ function tokenise(text) {
202
229
  }
203
230
  /**
204
231
  * Build word-level n-gram set from title + description + labels.
205
- * This differs from the BM25 blob (which uses character trigrams, no labels).
232
+ * This differs from the Tier-1 lexical blob (character trigrams, no labels).
206
233
  *
207
234
  * @param title - Task title.
208
235
  * @param description - Task description.
@@ -210,7 +237,7 @@ function tokenise(text) {
210
237
  * @returns Set of word unigrams + bigrams.
211
238
  */
212
239
  function buildWordNgramSet(title, description, labels) {
213
- // Title carries 2× weight via repetition (matches BM25 weighting)
240
+ // Title carries 2x weight via repetition (matches the Tier-1 blob weighting)
214
241
  const titleTokens = tokenise(title);
215
242
  const descTokens = tokenise(description);
216
243
  const labelTokens = labels.flatMap((l) => tokenise(l));
@@ -219,7 +246,7 @@ function buildWordNgramSet(title, description, labels) {
219
246
  }
220
247
  /**
221
248
  * Compute Jaccard similarity over word-level n-grams including labels.
222
- * Used as Tier-2 discriminator when BM25 is ambiguous.
249
+ * Used as Tier-2 discriminator when the Tier-1 score is ambiguous.
223
250
  *
224
251
  * @param titleA - First title.
225
252
  * @param descA - First description.
@@ -238,24 +265,45 @@ export function computeJaccardWordSimilarity(titleA, descA, labelsA, titleB, des
238
265
  // BRAIN-powered Vector Similarity (opportunistic, Tier 1)
239
266
  // ============================================================================
240
267
  /**
241
- * Attempt to compute vector-based similarity for a single active task.
242
- *
243
- * Embeds the incoming blob and the candidate task's blob, then computes
244
- * cosine similarity from the dot product (assuming unit-norm vectors from
245
- * standard embedding models).
268
+ * Embed the incoming search blob ONCE for the whole duplicate check.
246
269
  *
247
- * Returns `null` when embedding is unavailable (triggers lexical fallback).
270
+ * Returns `null` when embedding is unavailable, which makes the caller fall
271
+ * back to the lexical (trigram) measure for every candidate.
248
272
  *
249
273
  * @param incomingBlob - Normalised search blob for the new task.
250
- * @param candidateBlob - Normalised search blob for the candidate task.
251
- * @returns Cosine similarity in [0, 1], or null when unavailable.
274
+ * @returns The incoming embedding, or `null` when embedding is unavailable.
275
+ */
276
+ async function embedIncomingOnce(incomingBlob) {
277
+ try {
278
+ const { isEmbeddingAvailable, embedText } = await import('../memory/brain-embedding.js');
279
+ if (!isEmbeddingAvailable())
280
+ return null;
281
+ return (await embedText(incomingBlob)) ?? null;
282
+ }
283
+ catch {
284
+ return null;
285
+ }
286
+ }
287
+ /**
288
+ * Cosine similarity between a PRE-COMPUTED incoming vector and a candidate blob.
289
+ *
290
+ * The incoming vector is computed once per duplicate check by
291
+ * {@link embedIncomingOnce} and passed in. It used to be embedded inside this
292
+ * function, which ran once per candidate — so a store with 1,126 active tasks
293
+ * embedded the same incoming text 1,126 times per `cleo add`. Only the candidate
294
+ * is embedded here.
295
+ *
296
+ * @param incomingVec - Pre-computed embedding of the incoming blob.
297
+ * @param candidateBlob - Candidate search blob to embed and compare.
298
+ * @returns Cosine similarity in [0, 1], or `null` when embedding is unavailable.
252
299
  */
253
- async function tryVectorSimilarity(incomingBlob, candidateBlob) {
300
+ async function tryVectorSimilarity(incomingVec, candidateBlob) {
254
301
  try {
255
302
  const { isEmbeddingAvailable, embedText } = await import('../memory/brain-embedding.js');
256
303
  if (!isEmbeddingAvailable())
257
304
  return null;
258
- const [vecA, vecB] = await Promise.all([embedText(incomingBlob), embedText(candidateBlob)]);
305
+ const vecA = incomingVec;
306
+ const vecB = await embedText(candidateBlob);
259
307
  if (!vecA || !vecB || vecA.length !== vecB.length)
260
308
  return null;
261
309
  // Cosine similarity (assumes unit-norm vectors from embedding model)
@@ -317,7 +365,7 @@ function buildDuplicateUserPrompt(incomingTitle, incomingDescription, candidateT
317
365
  *
318
366
  * Cost cap: max 1 call per `cleo add` invocation (enforced by the caller via
319
367
  * a `llmCallMade` flag). Never throws — returns null on error/timeout so the
320
- * caller can fall back to BM25-only decision.
368
+ * caller can fall back to a Tier-1-only decision.
321
369
  *
322
370
  * @param incomingTitle - Title of the task being added.
323
371
  * @param incomingDescription - Description of the task being added.
@@ -331,7 +379,7 @@ export async function callLlmDuplicateReasoning(incomingTitle, incomingDescripti
331
379
  const { authHeadersFromSealed } = await import('../llm/credentials.js');
332
380
  const { resolveLLMForRole } = await import('../llm/role-resolver.js');
333
381
  // T9255: route through the role-based resolver. Duplicate-detection is a
334
- // consolidation-tier call (LLM acts as the tie-breaker between BM25 and
382
+ // consolidation-tier call (LLM acts as the tie-breaker between Tier 1 and
335
383
  // Jaccard tiers). The resolver walks `llm.roles.consolidation` →
336
384
  // `llm.default` → `llm.daemon` → implicit fallback, preserving the
337
385
  // prior `config.llm.daemon.*` defaulting behaviour.
@@ -412,7 +460,7 @@ export async function callLlmDuplicateReasoning(incomingTitle, incomingDescripti
412
460
  return validation.data;
413
461
  }
414
462
  catch {
415
- // LLM call errors always fall back to BM25-only decision
463
+ // LLM call errors always fall back to a Tier-1-only decision
416
464
  return null;
417
465
  }
418
466
  }
@@ -425,11 +473,11 @@ export async function callLlmDuplicateReasoning(incomingTitle, incomingDescripti
425
473
  *
426
474
  * Algorithm:
427
475
  *
428
- * Tier 1 — BM25 / vector similarity (per candidate):
476
+ * Tier 1 — lexical or vector similarity (per candidate):
429
477
  * For each active task, compute vector cosine similarity (if embedding is available)
430
- * or Jaccard character-trigram similarity as a proxy. This produces a BM25-like score.
478
+ * Jaccard over character trigrams, or cosine over embeddings when a provider is loaded.
431
479
  * - score >= DUPLICATE_REJECT_THRESHOLD (0.92): clear match → reject immediately.
432
- * - score < BM25_ESCALATE_LOW (0.50): clear different → skip Tier 2 + 3 for this candidate.
480
+ * - score < TIER1_ESCALATE_LOW (0.50): clear different → skip Tier 2 + 3 for this candidate.
433
481
  * - score in [0.50, 0.92): ambiguous → collect for Tier-2 Jaccard evaluation.
434
482
  *
435
483
  * Tier 2 — Jaccard word n-grams (title+description+labels):
@@ -441,7 +489,7 @@ export async function callLlmDuplicateReasoning(incomingTitle, incomingDescripti
441
489
  *
442
490
  * Tier 3 — LLM reasoning (max 1 call per `cleo add`):
443
491
  * Call the daemon provider with both task descriptions and the structured-output schema.
444
- * On error/timeout: fall back to Tier-1 BM25 decision for the candidate (never block).
492
+ * On error/timeout: fall back to the Tier-1 decision for the candidate (never block).
445
493
  *
446
494
  * @param title - Title of the task being added.
447
495
  * @param description - Description of the task being added (empty string if not provided).
@@ -473,12 +521,22 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
473
521
  return false;
474
522
  }
475
523
  })();
476
- // ---- Tier 1: BM25 / vector ------------------------------------------------
477
- // Candidates that need Tier-2 Jaccard evaluation (BM25 ambiguous range).
478
- // Map: candidate → bm25Score
524
+ // Embed the incoming blob ONCE for the whole check. This used to happen inside
525
+ // `tryVectorSimilarity`, which runs once per candidate — so a store with 1,126
526
+ // active tasks embedded the same incoming text 1,126 times per `cleo add`.
527
+ // `null` here simply means every candidate uses the lexical measure.
528
+ const incomingVec = embeddingEnabled ? await embedIncomingOnce(incomingBlob) : null;
529
+ // ---- Tier 1: lexical or vector --------------------------------------------
530
+ // Candidates that need Tier-2 Jaccard evaluation (Tier-1 ambiguous range).
531
+ // Map: candidate → tier1Score
479
532
  const tier1Ambiguous = [];
480
- // Clear-match candidates (bm25 >= DUPLICATE_REJECT_THRESHOLD or >= DUPLICATE_WARN_THRESHOLD)
533
+ // Clear-match candidates (tier1 >= DUPLICATE_REJECT_THRESHOLD or >= DUPLICATE_WARN_THRESHOLD)
481
534
  const clearMatchCandidates = [];
535
+ // T12117: bound the vector tier. Exceeding either budget degrades the
536
+ // remaining candidates to the lexical score — the same path taken when
537
+ // embeddings are unavailable — rather than extending the write.
538
+ let vectorBudget = MAX_VECTOR_CANDIDATES;
539
+ const vectorDeadline = Date.now() + VECTOR_PHASE_BUDGET_MS;
482
540
  for (const task of activeTasks) {
483
541
  // Do not compare a task with itself (e.g. if being re-added after creation)
484
542
  if (task.title === title) {
@@ -487,31 +545,38 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
487
545
  continue; // will be caught by findRecentDuplicate
488
546
  }
489
547
  const candidateBlob = buildSearchBlob(task.title, task.description ?? '');
490
- let bm25Score;
491
- if (embeddingEnabled) {
492
- const vecScore = await tryVectorSimilarity(incomingBlob, candidateBlob);
493
- bm25Score =
548
+ let tier1Score;
549
+ // Both bounds apply. #1258 caps the vector phase by COUNT and DEADLINE so a
550
+ // large store cannot stall a write; this branch additionally passes the
551
+ // PRE-COMPUTED incoming vector, so the incoming blob is embedded once for
552
+ // the whole check rather than once per candidate. `incomingVec` is non-null
553
+ // only when embedding is available, so it subsumes the old
554
+ // `embeddingEnabled` test.
555
+ if (incomingVec && vectorBudget > 0 && Date.now() < vectorDeadline) {
556
+ vectorBudget--;
557
+ const vecScore = await tryVectorSimilarity(incomingVec, candidateBlob);
558
+ tier1Score =
494
559
  vecScore ??
495
560
  computeLexicalSimilarity(title, description, task.title, task.description ?? '');
496
561
  }
497
562
  else {
498
- bm25Score = computeLexicalSimilarity(title, description, task.title, task.description ?? '');
563
+ tier1Score = computeLexicalSimilarity(title, description, task.title, task.description ?? '');
499
564
  }
500
- if (bm25Score >= DUPLICATE_REJECT_THRESHOLD) {
565
+ if (tier1Score >= DUPLICATE_REJECT_THRESHOLD) {
501
566
  // Clear match — reject without escalating
502
- clearMatchCandidates.push({ id: task.id, title: task.title, score: bm25Score });
567
+ clearMatchCandidates.push({ id: task.id, title: task.title, score: tier1Score });
503
568
  }
504
- else if (bm25Score >= BM25_ESCALATE_LOW) {
569
+ else if (tier1Score >= TIER1_ESCALATE_LOW) {
505
570
  // Ambiguous — collect for Tier 2
506
- tier1Ambiguous.push({ task, bm25Score });
571
+ tier1Ambiguous.push({ task, tier1Score });
507
572
  // Also surface in warn range (>= DUPLICATE_WARN_THRESHOLD) even before Tier 2
508
- if (bm25Score >= DUPLICATE_WARN_THRESHOLD) {
509
- clearMatchCandidates.push({ id: task.id, title: task.title, score: bm25Score });
573
+ if (tier1Score >= DUPLICATE_WARN_THRESHOLD) {
574
+ clearMatchCandidates.push({ id: task.id, title: task.title, score: tier1Score });
510
575
  }
511
576
  }
512
- // bm25Score < BM25_ESCALATE_LOW → clear different, skip
577
+ // tier1Score < TIER1_ESCALATE_LOW → clear different, skip
513
578
  }
514
- // If we already have a clear-match reject, return immediately (BM25-only path).
579
+ // If we already have a clear-match reject, return immediately (Tier-1-only path).
515
580
  if (clearMatchCandidates.some((c) => c.score >= DUPLICATE_REJECT_THRESHOLD)) {
516
581
  const sorted = clearMatchCandidates
517
582
  .filter((c) => c.score >= DUPLICATE_REJECT_THRESHOLD)
@@ -530,7 +595,7 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
530
595
  // Candidates that are still ambiguous after Tier 2 collect for LLM escalation.
531
596
  const tier2Ambiguous = [];
532
597
  const tier2Candidates = [];
533
- for (const { task, bm25Score: _bm25 } of tier1Ambiguous) {
598
+ for (const { task, tier1Score: _tier1 } of tier1Ambiguous) {
534
599
  const jScore = computeJaccardWordSimilarity(title, description, incomingLabels, task.title, task.description ?? '', task.labels ?? []);
535
600
  if (jScore >= JACCARD_REJECT_THRESHOLD) {
536
601
  // Jaccard says: reject
@@ -592,11 +657,11 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
592
657
  tier: 'llm',
593
658
  };
594
659
  }
595
- // LLM failed/timed out — fall back to BM25-only decision.
596
- // The BM25 score for the top candidate is in [0.5, 0.92), which means
660
+ // LLM failed/timed out — fall back to a Tier-1-only decision.
661
+ // The Tier-1 score for the top candidate is in [0.5, 0.92), which means
597
662
  // it's in the warn zone only if >= DUPLICATE_WARN_THRESHOLD.
598
- const bm25Entry = tier1Ambiguous.find((e) => e.task.id === topCandidate.task.id);
599
- const fallbackScore = bm25Entry?.bm25Score ?? topCandidate.jaccardScore;
663
+ const tier1Entry = tier1Ambiguous.find((e) => e.task.id === topCandidate.task.id);
664
+ const fallbackScore = tier1Entry?.tier1Score ?? topCandidate.jaccardScore;
600
665
  if (fallbackScore >= DUPLICATE_WARN_THRESHOLD) {
601
666
  const candidate = {
602
667
  id: topCandidate.task.id,
@@ -612,7 +677,7 @@ export async function checkDuplicates(title, description, accessor, labels, cwd)
612
677
  };
613
678
  }
614
679
  }
615
- // ---- Collect any BM25 warn-zone candidates (non-rejecting) ----------------
680
+ // ---- Collect any Tier-1 warn-zone candidates (non-rejecting) --------------
616
681
  const warnCandidates = clearMatchCandidates
617
682
  .filter((c) => c.score >= DUPLICATE_WARN_THRESHOLD && c.score < DUPLICATE_REJECT_THRESHOLD)
618
683
  .sort((a, b) => b.score - a.score)
@@ -675,4 +740,82 @@ export async function loadActiveTasks(accessor) {
675
740
  });
676
741
  return tasks;
677
742
  }
743
+ /**
744
+ * Total wall-clock budget for duplicate detection on the write path, in ms.
745
+ *
746
+ * Detection is three tiers deep and two of them can call out of process (local
747
+ * embeddings, then an LLM). Each has its own bound, but "bounded individually"
748
+ * is not "bounded in aggregate" — and this runs BEFORE the row is inserted.
749
+ */
750
+ const DUPLICATE_CHECK_BUDGET_MS = 20_000;
751
+ /** Env var overriding {@link DUPLICATE_CHECK_BUDGET_MS}. */
752
+ export const DUPLICATE_CHECK_BUDGET_ENV = 'CLEO_DUPLICATE_CHECK_BUDGET_MS';
753
+ /**
754
+ * Resolve the duplicate-detection budget, honouring an operator override.
755
+ *
756
+ * @param env - environment to read the override from.
757
+ * @returns budget in ms; the default when unset or malformed.
758
+ */
759
+ export function resolveDuplicateCheckBudgetMs(env = process.env) {
760
+ const raw = Number.parseInt(env[DUPLICATE_CHECK_BUDGET_ENV] ?? '', 10);
761
+ return Number.isFinite(raw) && raw > 0 ? raw : DUPLICATE_CHECK_BUDGET_MS;
762
+ }
763
+ /**
764
+ * {@link checkDuplicates} with a hard deadline and a fail-OPEN result.
765
+ *
766
+ * ## Why fail open (T12117 · issue #1244)
767
+ *
768
+ * Duplicate detection runs before the insert, so anything that stalls it
769
+ * doesn't slow the write down — it *loses* it. Reported from the field: three
770
+ * `cleo add` attempts at 280 s each, none of which created a row, on a host
771
+ * under memory pressure. No error, no id, no task. A silent write failure is
772
+ * the worst failure mode a task tracker has, because the agent that filed the
773
+ * task believes the work is recorded.
774
+ *
775
+ * It is also silent in the dangerous direction relative to CLEO's documented
776
+ * recovery advice. A killed mutation is supposed to mean "query before
777
+ * retrying — the write has usually landed", which was independently confirmed
778
+ * true for a timeout-killed write. But these writes had NOT landed, and nothing
779
+ * in the output distinguishes the two cases. An agent following the contract
780
+ * correctly reaches the wrong conclusion in one direction or the other.
781
+ *
782
+ * So: the row is the product, detection is enrichment. When the budget is
783
+ * blown, report that on stderr and let the insert proceed. A duplicate that
784
+ * slips through is recoverable by a human in seconds; a task that was never
785
+ * created is lost work nobody knows to look for.
786
+ *
787
+ * @param args - forwarded verbatim to {@link checkDuplicates}.
788
+ * @returns the real verdict, or a permissive one when the budget was exceeded.
789
+ *
790
+ * @task T12117
791
+ */
792
+ export async function checkDuplicatesBounded(...args) {
793
+ let timer;
794
+ const budgetMs = resolveDuplicateCheckBudgetMs();
795
+ const timeout = new Promise((resolve) => {
796
+ timer = setTimeout(() => resolve(null), budgetMs);
797
+ // Must not hold the CLI's event loop open on its own.
798
+ timer.unref();
799
+ });
800
+ try {
801
+ const verdict = await Promise.race([checkDuplicates(...args), timeout]);
802
+ if (verdict === null) {
803
+ process.stderr.write(`cleo: duplicate detection exceeded ${budgetMs}ms and was skipped; ` +
804
+ `creating the task anyway. Run 'cleo find' to check for duplicates.\n`);
805
+ return {
806
+ maxScore: 0,
807
+ candidates: [],
808
+ shouldReject: false,
809
+ shouldWarn: false,
810
+ tier: 'bm25',
811
+ timedOut: true,
812
+ };
813
+ }
814
+ return { ...verdict, timedOut: false };
815
+ }
816
+ finally {
817
+ if (timer)
818
+ clearTimeout(timer);
819
+ }
820
+ }
678
821
  //# sourceMappingURL=duplicate-detector.js.map