happyskills 0.46.1 → 0.47.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.
@@ -0,0 +1,303 @@
1
+ // Unit tests for the pure logic inside cli/src/commands/postlex.js.
2
+ // Spec 260521-01 v2 § 7 — the implementation is correct when:
3
+ // - ranking with no exact match → unchanged
4
+ // - exact at rank 2 → promoted to rank 1
5
+ // - exact already at rank 1 → unchanged
6
+ // - exact at rank 11 → unchanged (outside top-10 window)
7
+ // - malformed JSON input → envelope with next_step.action === "retry_rank"
8
+ // - out-of-bounds candidate_id → warned + dropped, not a crash
9
+
10
+ const { describe, it } = require('node:test')
11
+ const assert = require('node:assert/strict')
12
+ const {
13
+ validate_ranking,
14
+ apply_postlex,
15
+ build_final_ordering,
16
+ determine_next_step,
17
+ build_retry_envelope,
18
+ parse_input,
19
+ } = require('./postlex')
20
+
21
+ // ─── Test fixtures ────────────────────────────────────────────────────────
22
+
23
+ const make_data = (names) => names.map((name, i) => ({
24
+ name,
25
+ workspace_slug: 'acme',
26
+ description: `desc for ${name}`,
27
+ match_quality: i === 0 ? 'strong' : i === 1 ? 'good' : 'partial',
28
+ star_count: 10 - i,
29
+ quality_score: 80 - i * 10,
30
+ }))
31
+
32
+ const make_ranking = (ids) => ids.map((cid, i) => ({
33
+ rank: i + 1,
34
+ candidate_id: cid,
35
+ rationale: `rationale for ${cid}`,
36
+ }))
37
+
38
+ // ─── apply_postlex ────────────────────────────────────────────────────────
39
+
40
+ describe('apply_postlex', () => {
41
+ it('returns ranking unchanged when no exact slug match exists in top-10', () => {
42
+ const data = make_data(['deploy-aws-lambda', 'serverless-framework', 'static-hosting'])
43
+ const ranking = make_ranking([1, 2, 3])
44
+ const r = apply_postlex('totally-different-query', ranking, data)
45
+ assert.equal(r.promoted, false)
46
+ assert.equal(r.promoted_from_rank, null)
47
+ assert.equal(r.exact_match_count_in_window, 0)
48
+ assert.deepEqual(r.reordered.map(x => x.candidate_id), [1, 2, 3])
49
+ })
50
+
51
+ it('promotes exact slug match from rank 2 → rank 1', () => {
52
+ const data = make_data(['serverless-framework', 'deploy-aws', 'static-hosting'])
53
+ const ranking = make_ranking([1, 2, 3])
54
+ const r = apply_postlex('deploy aws', ranking, data)
55
+ assert.equal(r.promoted, true)
56
+ assert.equal(r.promoted_from_rank, 2)
57
+ assert.equal(r.exact_match_count_in_window, 1)
58
+ // Now candidate_id=2 (the deploy-aws row) should be at rank 1.
59
+ assert.equal(r.reordered[0].candidate_id, 2)
60
+ assert.equal(r.reordered[0].rank, 1)
61
+ // Ranks should be reassigned 1..N.
62
+ assert.deepEqual(r.reordered.map(x => x.rank), [1, 2, 3])
63
+ })
64
+
65
+ it('leaves ranking unchanged when exact match is already at rank 1', () => {
66
+ const data = make_data(['deploy-aws', 'serverless', 'static'])
67
+ const ranking = make_ranking([1, 2, 3])
68
+ const r = apply_postlex('deploy aws', ranking, data)
69
+ assert.equal(r.promoted, false)
70
+ assert.equal(r.exact_match_count_in_window, 1)
71
+ assert.deepEqual(r.reordered.map(x => x.candidate_id), [1, 2, 3])
72
+ })
73
+
74
+ it('ignores exact match outside the top-10 window', () => {
75
+ const names = []
76
+ for (let i = 1; i <= 11; i++) names.push(i === 11 ? 'deploy-aws' : `other-${i}`)
77
+ const data = make_data(names)
78
+ // rank the 11 candidates 1..11, deploy-aws (candidate_id=11) is at rank 11
79
+ const ranking = make_ranking(Array.from({ length: 11 }, (_, i) => i + 1))
80
+ const r = apply_postlex('deploy aws', ranking, data)
81
+ assert.equal(r.promoted, false)
82
+ assert.equal(r.exact_match_count_in_window, 0)
83
+ // Top-10 window doesn't include rank 11 → no promotion.
84
+ })
85
+
86
+ it('when multiple exact matches exist, promotes the highest-ranked one', () => {
87
+ const data = make_data(['other', 'deploy-aws', 'something', 'deploy-aws-2'])
88
+ // Both rank 2 (candidate 2) and rank 4 (candidate 4) are exact for "deploy aws"
89
+ // because slug_tokens drops the trailing "2" (single-char skip).
90
+ // candidate_id=2 has rank=2; candidate_id=4 has rank=4.
91
+ // The algorithm picks the highest-ranked one (smallest rank value).
92
+ const ranking = make_ranking([1, 2, 3, 4])
93
+ const r = apply_postlex('deploy aws', ranking, data)
94
+ assert.equal(r.promoted, true)
95
+ assert.equal(r.promoted_from_rank, 2)
96
+ assert.equal(r.reordered[0].candidate_id, 2)
97
+ })
98
+ })
99
+
100
+ // ─── validate_ranking ─────────────────────────────────────────────────────
101
+
102
+ describe('validate_ranking', () => {
103
+ const data = make_data(['a', 'b', 'c'])
104
+
105
+ it('accepts well-formed entries', () => {
106
+ const r = validate_ranking(make_ranking([1, 2, 3]), data)
107
+ assert.equal(r.valid_items.length, 3)
108
+ assert.equal(r.dropped.length, 0)
109
+ })
110
+
111
+ it('drops candidate_id < 1', () => {
112
+ const r = validate_ranking([{ rank: 1, candidate_id: 0 }], data)
113
+ assert.equal(r.valid_items.length, 0)
114
+ assert.equal(r.dropped.length, 1)
115
+ assert.match(r.dropped[0].reason, /out of range/)
116
+ })
117
+
118
+ it('drops candidate_id > data.length', () => {
119
+ const r = validate_ranking([{ rank: 1, candidate_id: 99 }], data)
120
+ assert.equal(r.valid_items.length, 0)
121
+ assert.match(r.dropped[0].reason, /out of range/)
122
+ })
123
+
124
+ it('drops candidate_id that is not an integer', () => {
125
+ const r = validate_ranking([{ rank: 1, candidate_id: 'banana' }], data)
126
+ assert.equal(r.valid_items.length, 0)
127
+ assert.match(r.dropped[0].reason, /out of range/)
128
+ })
129
+
130
+ it('drops entries where data row is missing name', () => {
131
+ const broken_data = [{ name: '' }, { name: 'real' }]
132
+ const r = validate_ranking([{ rank: 1, candidate_id: 1 }, { rank: 2, candidate_id: 2 }], broken_data)
133
+ assert.equal(r.valid_items.length, 1)
134
+ assert.equal(r.dropped[0].candidate_id, 1)
135
+ assert.match(r.dropped[0].reason, /missing name/)
136
+ })
137
+
138
+ it('rejects non-array ranking', () => {
139
+ const r = validate_ranking({ not: 'an array' }, data)
140
+ assert.equal(r.valid_items.length, 0)
141
+ assert.equal(r.dropped.length, 1)
142
+ })
143
+
144
+ it('rejects non-array data', () => {
145
+ const r = validate_ranking([{ rank: 1, candidate_id: 1 }], { not: 'an array' })
146
+ assert.equal(r.valid_items.length, 0)
147
+ })
148
+
149
+ it('keeps some, drops some — mixed batch', () => {
150
+ const r = validate_ranking([
151
+ { rank: 1, candidate_id: 1 },
152
+ { rank: 2, candidate_id: 99 }, // out of range
153
+ { rank: 3, candidate_id: 2 },
154
+ ], data)
155
+ assert.equal(r.valid_items.length, 2)
156
+ assert.equal(r.dropped.length, 1)
157
+ })
158
+ })
159
+
160
+ // ─── determine_next_step ──────────────────────────────────────────────────
161
+
162
+ describe('determine_next_step', () => {
163
+ const fo = (qualities) => qualities.map((q, i) => ({
164
+ rank: i + 1,
165
+ candidate_id: i + 1,
166
+ slug: `acme/r-${i}`,
167
+ name: `r-${i}`,
168
+ match_quality: q,
169
+ rationale: '',
170
+ }))
171
+
172
+ it('emits present_to_user when top 3 are all strong/good', () => {
173
+ const ns = determine_next_step(fo(['strong', 'good', 'strong']), 'q', 0)
174
+ assert.equal(ns.action, 'present_to_user')
175
+ })
176
+
177
+ it('emits clarify when top 3 include a partial AND budget remains', () => {
178
+ const ns = determine_next_step(fo(['strong', 'partial', 'good']), 'q', 0)
179
+ assert.equal(ns.action, 'clarify')
180
+ assert.equal(ns.max_turns_remaining, 2)
181
+ assert.equal(ns.context.clarification_turns_used, 0)
182
+ // Always includes a "Just search anyway" option.
183
+ const last = ns.suggested_questions[0].options[ns.suggested_questions[0].options.length - 1]
184
+ assert.match(last.label, /search anyway/i)
185
+ })
186
+
187
+ it('emits clarify when top 3 include a weak/null AND budget remains', () => {
188
+ const ns = determine_next_step(fo(['weak', 'good', null]), 'q', 1)
189
+ assert.equal(ns.action, 'clarify')
190
+ assert.equal(ns.max_turns_remaining, 1)
191
+ })
192
+
193
+ it('emits present_to_user with budget-spent note when budget exhausted', () => {
194
+ const ns = determine_next_step(fo(['weak', 'partial', null]), 'q', 2)
195
+ assert.equal(ns.action, 'present_to_user')
196
+ assert.match(ns.instructions, /budget.*spent/i)
197
+ })
198
+
199
+ it('clamps clarification_turns_used to [0, 2]', () => {
200
+ const ns_neg = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', -1)
201
+ assert.equal(ns_neg.max_turns_remaining, 2)
202
+ const ns_big = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', 99)
203
+ assert.equal(ns_big.action, 'present_to_user')
204
+ })
205
+
206
+ it('emits present_to_user when ordering is empty (no results to clarify against)', () => {
207
+ const ns = determine_next_step([], 'q', 0)
208
+ assert.equal(ns.action, 'present_to_user')
209
+ })
210
+
211
+ it('treats top-1-and-only-1 strong as success (less than 3 results)', () => {
212
+ const ns = determine_next_step(fo(['strong']), 'q', 0)
213
+ assert.equal(ns.action, 'present_to_user')
214
+ })
215
+ })
216
+
217
+ // ─── build_retry_envelope ─────────────────────────────────────────────────
218
+
219
+ describe('build_retry_envelope', () => {
220
+ it('produces a retry_rank envelope with all required fields', () => {
221
+ const env = build_retry_envelope('the query', 'ranking missing field rationale', 1, 0)
222
+ assert.equal(env.data, null)
223
+ assert.equal(env.error.code, 'ranking_schema_mismatch')
224
+ assert.equal(env.error.exit_code, 0)
225
+ assert.equal(env.next_step.action, 'retry_rank')
226
+ assert.equal(env.next_step.context.clarification_turns_used, 1)
227
+ assert.equal(env.next_step.context.retry_count, 1)
228
+ })
229
+
230
+ it('increments retry_count on each call', () => {
231
+ const env1 = build_retry_envelope('q', 'r', 0, 0)
232
+ const env2 = build_retry_envelope('q', 'r', 0, env1.next_step.context.retry_count)
233
+ assert.equal(env2.next_step.context.retry_count, 2)
234
+ })
235
+ })
236
+
237
+ // ─── parse_input ──────────────────────────────────────────────────────────
238
+
239
+ describe('parse_input', () => {
240
+ it('parses combined ranking+data from a single JSON object', () => {
241
+ const raw = JSON.stringify({
242
+ ranking: [{ rank: 1, candidate_id: 1 }],
243
+ data: [{ name: 'foo' }],
244
+ })
245
+ const r = parse_input(raw, null)
246
+ assert.equal(r.parse_error, null)
247
+ assert.equal(r.ranking[0].candidate_id, 1)
248
+ assert.equal(r.data[0].name, 'foo')
249
+ })
250
+
251
+ it('parses separate ranking + data files', () => {
252
+ const ranking_raw = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1 }] })
253
+ const data_raw = JSON.stringify({ data: [{ name: 'foo' }] })
254
+ const r = parse_input(ranking_raw, data_raw)
255
+ assert.equal(r.parse_error, null)
256
+ assert.equal(r.ranking.length, 1)
257
+ assert.equal(r.data[0].name, 'foo')
258
+ })
259
+
260
+ it('accepts bare arrays in either file', () => {
261
+ const r = parse_input(JSON.stringify([{ rank: 1, candidate_id: 1 }]), JSON.stringify([{ name: 'foo' }]))
262
+ assert.equal(r.parse_error, null)
263
+ assert.equal(r.ranking.length, 1)
264
+ assert.equal(r.data.length, 1)
265
+ })
266
+
267
+ it('returns parse_error on malformed JSON', () => {
268
+ const r = parse_input('this is not json at all', null)
269
+ assert.equal(r.ranking, null)
270
+ assert.match(r.parse_error, /not valid JSON/)
271
+ })
272
+
273
+ it('returns parse_error when ranking is missing from single-payload input', () => {
274
+ const r = parse_input(JSON.stringify({ data: [] }), null)
275
+ assert.match(r.parse_error, /ranking field is missing/)
276
+ })
277
+
278
+ it('returns parse_error when data is missing entirely', () => {
279
+ const r = parse_input(JSON.stringify({ ranking: [] }), null)
280
+ assert.match(r.parse_error, /data field is missing/)
281
+ })
282
+ })
283
+
284
+ // ─── build_final_ordering ─────────────────────────────────────────────────
285
+
286
+ describe('build_final_ordering', () => {
287
+ it('joins ranking with data rows, producing slug + rationale', () => {
288
+ const data = make_data(['deploy-aws', 'serverless'])
289
+ const ranking = make_ranking([1, 2])
290
+ const ordered = build_final_ordering(ranking, data)
291
+ assert.equal(ordered.length, 2)
292
+ assert.equal(ordered[0].slug, 'acme/deploy-aws')
293
+ assert.equal(ordered[0].rationale, 'rationale for 1')
294
+ assert.equal(ordered[0].match_quality, 'strong')
295
+ })
296
+
297
+ it('uses bare name when workspace_slug is absent', () => {
298
+ const data = [{ name: 'lone-skill', match_quality: 'good' }]
299
+ const ordered = build_final_ordering([{ rank: 1, candidate_id: 1, rationale: '' }], data)
300
+ assert.equal(ordered[0].slug, 'lone-skill')
301
+ assert.equal(ordered[0].workspace_slug, null)
302
+ })
303
+ })
@@ -5,6 +5,7 @@ const { bold, dim, yellow, cyan, gray } = require('../ui/colors')
5
5
  const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
6
6
  const { EXIT_CODES, VALID_SKILL_TYPES } = require('../constants')
7
7
  const { load_token } = require('../auth/token_store')
8
+ const { fire_discovery_telemetry } = require('../api/telemetry')
8
9
 
9
10
  const HELP_TEXT = `Usage: happyskills search [query] [options]
10
11
 
@@ -23,6 +24,12 @@ Options:
23
24
  --tags <tags> Filter by tags (comma-separated)
24
25
  --type <type> Filter by type (skill, kit)
25
26
  --exact Force keyword-only FTS matching (skip smart routing)
27
+ --with-rerank Include rerank digests + next_step envelope in --json output
28
+ (intended for use inside an agentic session — see the
29
+ happyskills-help skill's discovery-protocol reference)
30
+ --clarification-turns-used <N>
31
+ Clarification budget already spent (0-2, default 0).
32
+ Carried across re-searches in the clarification flow.
26
33
  --limit <n> Max results (required, 1-50)
27
34
  --min-quality <n> Minimum quality score 0-100
28
35
  --json Output as JSON
@@ -112,16 +119,90 @@ const to_smart_json = (item) => ({
112
119
  updated_at: item.updated_at,
113
120
  })
114
121
 
122
+ // Spec 260521-01 v2 § 5.1 — build the next_step envelope for a search response.
123
+ // Pure function: takes the response state + clarification budget + flags and
124
+ // returns either a NextStep object or null. Exported for testing.
125
+ const STRONG_TIERS = new Set(['strong', 'good'])
126
+
127
+ const build_search_next_step = (response, query, opts) => {
128
+ const { with_rerank, clarification_turns_used } = opts
129
+ if (!with_rerank) return null
130
+
131
+ const mode = response?.mode || null
132
+ const digests = Array.isArray(response?.rerank_digests) ? response.rerank_digests : null
133
+ const match_notice = response?.match_notice || null
134
+ const turns_used = Math.max(0, Math.min(2, clarification_turns_used | 0))
135
+ const turns_remaining = 2 - turns_used
136
+
137
+ // Non-semantic modes (slug, scoped, exact-FTS) don't support rerank — server
138
+ // returns no digests. No envelope; agent just renders.
139
+ if (mode !== 'semantic') return null
140
+
141
+ // Semantic + digests present → the protocol's primary path.
142
+ if (digests && digests.length > 0) {
143
+ return {
144
+ action: 'rank_digests_inline',
145
+ instructions: `Rank these candidates by how well their \`digest\` matches the query, using \`data.rerank_system_prompt\` as your system instructions. Emit JSON matching \`data.rerank_response_schema\`. Aim for ~20 items (10-30 acceptable; fewer if remaining candidates aren't clearly differentiated). Then pipe the result to \`npx happyskills postlex --query "${query}" --ranking - --clarification-turns-used ${turns_used}\`.`,
146
+ context: {
147
+ original_query: query,
148
+ clarification_turns_used: turns_used,
149
+ },
150
+ }
151
+ }
152
+
153
+ // Semantic + no digests + match_notice fires → clarify path (when budget
154
+ // remains) OR present_to_user with budget-spent note.
155
+ if (match_notice) {
156
+ if (turns_remaining <= 0) {
157
+ return {
158
+ action: 'present_to_user',
159
+ instructions: 'No top result is a strong match and the clarification budget (2 turns) is spent. Render `data.results` honestly: note the weak signal and present what you have so the user can decide. Do NOT ask another clarifying question.',
160
+ context: null,
161
+ }
162
+ }
163
+ return {
164
+ action: 'clarify',
165
+ instructions: `The search returned weak results. Ask the user one of \`suggested_questions\` using your agent's question mechanism, then re-run \`npx happyskills search "<refined query>" --with-rerank --json --limit 50 --clarification-turns-used ${turns_used + 1}\`. The last option is always "Just search anyway" — honor it by re-running with the original query unchanged.`,
166
+ suggested_questions: [
167
+ {
168
+ question: 'Quick clarification — what specifically are you looking for?',
169
+ options: [
170
+ { label: 'A workflow or how-to guide', refined_query_hint: 'workflow' },
171
+ { label: 'A specific tool or library', refined_query_hint: 'tool' },
172
+ { label: 'A platform-specific recipe (AWS / GCP / Vercel)', refined_query_hint: 'platform' },
173
+ { label: 'Just search anyway', refined_query_hint: null },
174
+ ],
175
+ },
176
+ ],
177
+ max_turns_remaining: turns_remaining,
178
+ context: {
179
+ original_query: query,
180
+ clarification_turns_used: turns_used,
181
+ },
182
+ }
183
+ }
184
+
185
+ // Semantic, no digests, no match_notice (defensive — shouldn't normally
186
+ // happen). Agent renders the baseline results.
187
+ return null
188
+ }
189
+
115
190
  const run_smart_search = (args, query, options) => catch_errors('Smart search failed', async () => {
116
191
  const limit = parseInt(args.flags.limit)
117
192
  const capped_limit = Math.min(Math.max(limit, 1), 50)
118
193
  const min_quality = args.flags['min-quality'] != null ? parseInt(args.flags['min-quality']) : null
194
+ const with_rerank = !!args.flags['with-rerank']
195
+ const clarification_turns_used = parseInt(args.flags['clarification-turns-used'] || '0', 10) || 0
119
196
 
120
197
  const search_opts = { ...options, limit: capped_limit }
198
+ if (with_rerank) search_opts.with_rerank_digests = true
199
+
121
200
  const [errors, response] = await repos_api.dispatch_search(query, search_opts)
122
201
  if (errors) throw e('Search failed', errors)
123
202
 
124
- // Response shape: { data: [...], mode, workspace_match, match_notice }
203
+ // Response shape: { data: [...], mode, workspace_match, match_notice,
204
+ // and when with_rerank_digests=true: rerank_digests, rerank_system_prompt,
205
+ // rerank_prompt_version, rerank_response_schema }
125
206
  const items_raw = Array.isArray(response) ? response : (response?.data || response?.repos || response?.items || [])
126
207
  let items = items_raw
127
208
  const mode = response?.mode || null
@@ -135,12 +216,46 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
135
216
  )
136
217
  }
137
218
 
219
+ const next_step = build_search_next_step(response, query, { with_rerank, clarification_turns_used })
220
+
221
+ // Telemetry beacons (fire-and-forget). Spec § 5.1 + § 5.3.
222
+ if (with_rerank && next_step?.action === 'rank_digests_inline') {
223
+ fire_discovery_telemetry({
224
+ event: 'rerank_started',
225
+ query,
226
+ rerank_prompt_version: response?.rerank_prompt_version || null,
227
+ })
228
+ }
229
+ if (with_rerank && next_step?.action === 'clarify') {
230
+ fire_discovery_telemetry({
231
+ event: 'clarify_triggered',
232
+ query,
233
+ reason: 'match_notice',
234
+ turn_number: clarification_turns_used + 1,
235
+ })
236
+ }
237
+ if (with_rerank && clarification_turns_used > 0) {
238
+ // Re-searches after a clarify cycle. Fired whenever the agent comes back
239
+ // with a refined query.
240
+ fire_discovery_telemetry({
241
+ event: 'clarify_completed',
242
+ query,
243
+ turn_number: clarification_turns_used,
244
+ })
245
+ }
246
+
138
247
  if (items.length === 0) {
139
248
  if (args.flags.json) {
140
- const data = { query, mode, results: [], count: 0 }
249
+ const data = { query, formulated_query: query, mode, results: [], count: 0 }
141
250
  if (workspace_match !== undefined) data.workspace_match = workspace_match
142
251
  if (server_match_notice) data.match_notice = server_match_notice
143
- print_json({ data })
252
+ if (with_rerank) {
253
+ data.rerank_digests = response?.rerank_digests || []
254
+ data.rerank_system_prompt = response?.rerank_system_prompt || null
255
+ data.rerank_prompt_version = response?.rerank_prompt_version || null
256
+ data.rerank_response_schema = response?.rerank_response_schema || null
257
+ }
258
+ print_json(with_rerank ? { data, error: null, next_step } : { data })
144
259
  return
145
260
  }
146
261
  if (mode === 'fuzzy_scoped' && workspace_match === null) {
@@ -167,10 +282,18 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
167
282
  similarity: member.similarity != null ? member.similarity : null,
168
283
  })),
169
284
  }))
170
- const data = { query, mode, results: mapped, count: mapped.length }
285
+ const data = { query, formulated_query: query, mode, results: mapped, count: mapped.length }
171
286
  if (workspace_match !== undefined) data.workspace_match = workspace_match
172
287
  if (match_notice) data.match_notice = match_notice
173
- print_json({ data })
288
+ if (with_rerank) {
289
+ data.rerank_digests = response?.rerank_digests || []
290
+ data.rerank_system_prompt = response?.rerank_system_prompt || null
291
+ data.rerank_prompt_version = response?.rerank_prompt_version || null
292
+ data.rerank_response_schema = response?.rerank_response_schema || null
293
+ }
294
+ // Wrap in the universal envelope shape only when --with-rerank is set.
295
+ // Plain search keeps its existing { data: ... } shape for backward compat.
296
+ print_json(with_rerank ? { data, error: null, next_step } : { data })
174
297
  return
175
298
  }
176
299
 
@@ -183,6 +306,9 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
183
306
  console.log(format_smart_result(item, i))
184
307
  if (i < items.length - 1) console.log('')
185
308
  })
309
+ if (with_rerank) {
310
+ console.log(`\n${dim('(--with-rerank set: rerank payload suppressed in human-readable mode. Use --json to consume.)')}\n`)
311
+ }
186
312
  console.log(`\n${gray(`Showing ${items.length} result${items.length === 1 ? '' : 's'}. Install with: happyskills install <owner>/<name>`)}\n`)
187
313
  })
188
314
 
@@ -242,6 +368,7 @@ const run = (args) => catch_errors('Search failed', async () => {
242
368
  const query = args._.join(' ') || null
243
369
  const { mine, personal, workspace, tags, type } = args.flags
244
370
  const is_exact = !!args.flags.exact
371
+ const with_rerank = !!args.flags['with-rerank']
245
372
  // --smart / -S accepted for backward compat (now the default when a query is provided)
246
373
  const use_smart = query && !is_exact
247
374
  const has_scope_flag = mine || personal || workspace
@@ -258,6 +385,12 @@ const run = (args) => catch_errors('Search failed', async () => {
258
385
  throw new UsageError(`--type must be one of: ${VALID_SKILL_TYPES.join(', ')}`)
259
386
  }
260
387
 
388
+ // Spec 260521-01 v2 § 5.1 — --with-rerank requires semantic dispatch; --exact
389
+ // forces FTS-only mode where rerank is meaningless. Refuse the combination.
390
+ if (with_rerank && is_exact) {
391
+ throw new UsageError('--with-rerank cannot be combined with --exact. The rerank protocol applies only to semantic-mode dispatches.')
392
+ }
393
+
261
394
  const scope = mine ? 'mine' : personal ? 'personal' : undefined
262
395
 
263
396
  if (mine || personal) {
@@ -288,4 +421,4 @@ const run = (args) => catch_errors('Search failed', async () => {
288
421
  }
289
422
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
290
423
 
291
- module.exports = { run }
424
+ module.exports = { run, build_search_next_step }
@@ -0,0 +1,122 @@
1
+ // Unit tests for the next_step envelope emission rules on
2
+ // cli/src/commands/search.js. Spec 260521-01 v2 § 5.1.
3
+ //
4
+ // The orchestration around the actual HTTP call is covered by manual E2E
5
+ // testing (spec § 7); here we lock down the pure decision logic.
6
+
7
+ const { describe, it } = require('node:test')
8
+ const assert = require('node:assert/strict')
9
+ const { build_search_next_step } = require('./search')
10
+
11
+ const make_response = (overrides = {}) => ({
12
+ mode: 'semantic',
13
+ data: [],
14
+ match_notice: null,
15
+ rerank_digests: null,
16
+ rerank_prompt_version: null,
17
+ ...overrides,
18
+ })
19
+
20
+ describe('build_search_next_step', () => {
21
+ it('returns null when --with-rerank is not set', () => {
22
+ const r = build_search_next_step(make_response({ rerank_digests: [{ candidate_id: 1 }] }), 'q', {
23
+ with_rerank: false,
24
+ clarification_turns_used: 0,
25
+ })
26
+ assert.equal(r, null)
27
+ })
28
+
29
+ it('returns null when mode is fuzzy_slug (no rerank for non-semantic modes)', () => {
30
+ const r = build_search_next_step(make_response({ mode: 'fuzzy_slug' }), 'q', {
31
+ with_rerank: true, clarification_turns_used: 0,
32
+ })
33
+ assert.equal(r, null)
34
+ })
35
+
36
+ it('returns null when mode is fuzzy_scoped', () => {
37
+ const r = build_search_next_step(make_response({ mode: 'fuzzy_scoped' }), 'q', {
38
+ with_rerank: true, clarification_turns_used: 0,
39
+ })
40
+ assert.equal(r, null)
41
+ })
42
+
43
+ it('emits rank_digests_inline when semantic mode + digests present', () => {
44
+ const r = build_search_next_step(make_response({
45
+ rerank_digests: [{ candidate_id: 1, digest: '...' }, { candidate_id: 2, digest: '...' }],
46
+ }), 'deploy aws', {
47
+ with_rerank: true, clarification_turns_used: 0,
48
+ })
49
+ assert.equal(r.action, 'rank_digests_inline')
50
+ assert.equal(r.context.original_query, 'deploy aws')
51
+ assert.equal(r.context.clarification_turns_used, 0)
52
+ assert.match(r.instructions, /happyskills postlex/)
53
+ // Instructions should carry the budget forward to the next call.
54
+ assert.match(r.instructions, /--clarification-turns-used 0/)
55
+ })
56
+
57
+ it('propagates clarification_turns_used into the rank instructions', () => {
58
+ const r = build_search_next_step(make_response({
59
+ rerank_digests: [{ candidate_id: 1 }],
60
+ }), 'q', { with_rerank: true, clarification_turns_used: 1 })
61
+ assert.match(r.instructions, /--clarification-turns-used 1/)
62
+ assert.equal(r.context.clarification_turns_used, 1)
63
+ })
64
+
65
+ it('emits clarify when semantic + no digests + match_notice fires + budget remains', () => {
66
+ const r = build_search_next_step(make_response({
67
+ match_notice: 'No strong or good matches.',
68
+ }), 'vague query', { with_rerank: true, clarification_turns_used: 0 })
69
+ assert.equal(r.action, 'clarify')
70
+ assert.equal(r.max_turns_remaining, 2)
71
+ // Always last option is "Just search anyway".
72
+ const opts = r.suggested_questions[0].options
73
+ assert.match(opts[opts.length - 1].label, /search anyway/i)
74
+ // Instructions carry the incremented budget forward.
75
+ assert.match(r.instructions, /--clarification-turns-used 1/)
76
+ })
77
+
78
+ it('emits present_to_user (budget spent) when match_notice fires AND turns_used >= 2', () => {
79
+ const r = build_search_next_step(make_response({
80
+ match_notice: 'No strong or good matches.',
81
+ }), 'q', { with_rerank: true, clarification_turns_used: 2 })
82
+ assert.equal(r.action, 'present_to_user')
83
+ assert.match(r.instructions, /budget.*spent/i)
84
+ })
85
+
86
+ it('returns null when semantic + no digests + no match_notice (no protocol applies)', () => {
87
+ const r = build_search_next_step(make_response(), 'q', {
88
+ with_rerank: true, clarification_turns_used: 0,
89
+ })
90
+ assert.equal(r, null)
91
+ })
92
+
93
+ it('treats empty rerank_digests array as "no digests"', () => {
94
+ const r = build_search_next_step(make_response({ rerank_digests: [] }), 'q', {
95
+ with_rerank: true, clarification_turns_used: 0,
96
+ })
97
+ // No digests → would clarify if notice fired; here no notice, so null.
98
+ assert.equal(r, null)
99
+ })
100
+
101
+ it('clamps clarification_turns_used to [0, 2]', () => {
102
+ const r_neg = build_search_next_step(make_response({ match_notice: 'weak' }), 'q', {
103
+ with_rerank: true, clarification_turns_used: -5,
104
+ })
105
+ assert.equal(r_neg.max_turns_remaining, 2)
106
+ const r_big = build_search_next_step(make_response({ match_notice: 'weak' }), 'q', {
107
+ with_rerank: true, clarification_turns_used: 99,
108
+ })
109
+ assert.equal(r_big.action, 'present_to_user')
110
+ })
111
+
112
+ it('the clarify suggested_questions always includes 4 options ending in "Just search anyway"', () => {
113
+ const r = build_search_next_step(make_response({ match_notice: 'weak' }), 'q', {
114
+ with_rerank: true, clarification_turns_used: 0,
115
+ })
116
+ assert.equal(r.suggested_questions.length, 1)
117
+ const opts = r.suggested_questions[0].options
118
+ assert.equal(opts.length, 4)
119
+ assert.match(opts[3].label, /search anyway/i)
120
+ assert.equal(opts[3].refined_query_hint, null)
121
+ })
122
+ })
package/src/constants.js CHANGED
@@ -73,7 +73,8 @@ const COMMANDS = [
73
73
  'disable',
74
74
  'versions',
75
75
  'changelog',
76
- 'agents'
76
+ 'agents',
77
+ 'postlex'
77
78
  ]
78
79
 
79
80
  module.exports = {
package/src/index.js CHANGED
@@ -36,7 +36,7 @@ const parse_args = (argv) => {
36
36
  if (arg.startsWith('--')) {
37
37
  const key = arg.slice(2)
38
38
  const next = argv[i + 1]
39
- if (!next || next.startsWith('-')) {
39
+ if (!next || (next.startsWith('-') && next !== '-')) {
40
40
  args.flags[key] = true
41
41
  } else {
42
42
  args.flags[key] = next
@@ -45,7 +45,7 @@ const parse_args = (argv) => {
45
45
  } else if (arg.startsWith('-') && arg.length === 2) {
46
46
  const key = arg.slice(1)
47
47
  const next = argv[i + 1]
48
- if (!next || next.startsWith('-')) {
48
+ if (!next || (next.startsWith('-') && next !== '-')) {
49
49
  args.flags[key] = true
50
50
  } else {
51
51
  args.flags[key] = next
@@ -91,6 +91,7 @@ Commands:
91
91
  visibility <owner/skill> Check or set visibility (alias: vis)
92
92
  list List installed skills (alias: ls)
93
93
  search <query> Search the registry (alias: s)
94
+ postlex Apply deterministic rerank finalization (agent-only; see happyskills-help skill)
94
95
  versions <owner/skill> List all published versions of a skill
95
96
  changelog <owner/skill> Print a skill's CHANGELOG.md
96
97
  check [owner/skill] Check for available updates