happyskills 0.53.0 → 1.0.0

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 (43) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +1 -1
  3. package/src/api/auth.js +18 -2
  4. package/src/api/client.js +29 -3
  5. package/src/api/feedback.js +14 -5
  6. package/src/api/repos.js +28 -10
  7. package/src/api/translate.js +90 -0
  8. package/src/commands/delete.js +15 -1
  9. package/src/commands/feedback.js +2 -2
  10. package/src/commands/init.js +5 -1
  11. package/src/commands/install.js +58 -32
  12. package/src/commands/postlex.js +53 -35
  13. package/src/commands/postlex.test.js +48 -18
  14. package/src/commands/pull.js +5 -1
  15. package/src/commands/reconcile.js +52 -4
  16. package/src/commands/release.js +45 -15
  17. package/src/commands/schema.js +179 -0
  18. package/src/commands/search.js +34 -22
  19. package/src/commands/search.test.js +59 -33
  20. package/src/commands/uninstall.js +20 -11
  21. package/src/commands/validate.js +33 -11
  22. package/src/constants/error_codes.js +197 -0
  23. package/src/constants/exit_codes.js +54 -0
  24. package/src/constants/next_step_actions.js +133 -0
  25. package/src/constants/next_step_by_error_code.js +249 -0
  26. package/src/constants.js +2 -1
  27. package/src/index.js +51 -7
  28. package/src/integration/api_envelope.test.js +499 -0
  29. package/src/integration/bump.test.js +13 -4
  30. package/src/integration/cli.test.js +169 -147
  31. package/src/integration/drift.test.js +16 -4
  32. package/src/integration/install_fresh.test.js +37 -29
  33. package/src/integration/reconcile.test.js +77 -56
  34. package/src/integration/release.test.js +48 -31
  35. package/src/integration/schema.test.js +167 -0
  36. package/src/schema/envelope.schema.json +73 -0
  37. package/src/schema/envelope_test_helpers.js +94 -0
  38. package/src/schema/envelope_validator.js +239 -0
  39. package/src/schema/envelope_validator.test.js +333 -0
  40. package/src/ui/envelope.js +171 -0
  41. package/src/ui/output.js +66 -2
  42. package/src/utils/errors.js +116 -47
  43. package/src/utils/intent.js +22 -1
@@ -172,25 +172,32 @@ describe('determine_next_step', () => {
172
172
  rationale: '',
173
173
  }))
174
174
 
175
- it('emits present_to_user when top 3 are all strong/good', () => {
175
+ // Per spec 260525-cli-default-json § 7.1 the closed action names are
176
+ // "clarify_query" (kind=clarification) and "present_to_user"
177
+ // (kind=continuation). Tests below use those — the older bare "clarify"
178
+ // label is deprecated.
179
+
180
+ it('emits present_to_user (kind=continuation) when top 3 are all strong/good', () => {
176
181
  const ns = determine_next_step(fo(['strong', 'good', 'strong']), 'q', 0)
177
182
  assert.equal(ns.action, 'present_to_user')
183
+ assert.equal(ns.kind, 'continuation')
178
184
  })
179
185
 
180
- it('emits clarify when top 3 include a partial AND budget remains', () => {
186
+ it('emits clarify_query (kind=clarification) when top 3 include a partial AND budget remains', () => {
181
187
  const ns = determine_next_step(fo(['strong', 'partial', 'good']), 'q', 0)
182
- assert.equal(ns.action, 'clarify')
183
- assert.equal(ns.max_turns_remaining, 2)
188
+ assert.equal(ns.action, 'clarify_query')
189
+ assert.equal(ns.kind, 'clarification')
190
+ // max_turns_remaining + suggested_questions live inside context (spec § 4.5).
191
+ assert.equal(ns.context.max_turns_remaining, 2)
184
192
  assert.equal(ns.context.clarification_turns_used, 0)
185
- // Always includes a "Just search anyway" option.
186
- const last = ns.suggested_questions[0].options[ns.suggested_questions[0].options.length - 1]
193
+ const last = ns.context.suggested_questions[0].options[ns.context.suggested_questions[0].options.length - 1]
187
194
  assert.match(last.label, /search anyway/i)
188
195
  })
189
196
 
190
- it('emits clarify when top 3 include a weak/null AND budget remains', () => {
197
+ it('emits clarify_query when top 3 include a weak/null AND budget remains', () => {
191
198
  const ns = determine_next_step(fo(['weak', 'good', null]), 'q', 1)
192
- assert.equal(ns.action, 'clarify')
193
- assert.equal(ns.max_turns_remaining, 1)
199
+ assert.equal(ns.action, 'clarify_query')
200
+ assert.equal(ns.context.max_turns_remaining, 1)
194
201
  })
195
202
 
196
203
  it('emits present_to_user with budget-spent note when budget exhausted', () => {
@@ -201,7 +208,7 @@ describe('determine_next_step', () => {
201
208
 
202
209
  it('clamps clarification_turns_used to [0, 2]', () => {
203
210
  const ns_neg = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', -1)
204
- assert.equal(ns_neg.max_turns_remaining, 2)
211
+ assert.equal(ns_neg.context.max_turns_remaining, 2)
205
212
  const ns_big = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', 99)
206
213
  assert.equal(ns_big.action, 'present_to_user')
207
214
  })
@@ -218,16 +225,28 @@ describe('determine_next_step', () => {
218
225
  })
219
226
 
220
227
  // ─── build_retry_envelope ─────────────────────────────────────────────────
228
+ // Envelope contract: six-key { ok, data, error, next_step, warnings, meta }
229
+ // with data={}, error.code=RANKING_SCHEMA_MISMATCH (closed enum), kind=recovery,
230
+ // action=retry_rank, exit_code mirrored on meta (NOT on error).
231
+
232
+ const { validate_envelope } = require('../schema/envelope_validator')
221
233
 
222
234
  describe('build_retry_envelope', () => {
223
- it('produces a retry_rank envelope with all required fields', () => {
235
+ it('produces a retry_rank envelope conforming to the closed schema', () => {
224
236
  const env = build_retry_envelope('the query', 'ranking missing field rationale', 1, 0)
225
- assert.equal(env.data, null)
226
- assert.equal(env.error.code, 'ranking_schema_mismatch')
227
- assert.equal(env.error.exit_code, 0)
237
+ const { ok, errors } = validate_envelope(env)
238
+ assert.equal(ok, true, `envelope failed validation: ${JSON.stringify(errors)}`)
239
+ assert.equal(env.ok, false, 'retry envelope is a failure (ok=false)')
240
+ assert.deepEqual(env.data, {}, 'data must be the empty object, never null')
241
+ assert.equal(env.error.code, 'RANKING_SCHEMA_MISMATCH')
242
+ assert.equal(typeof env.error.message, 'string')
243
+ assert.equal('exit_code' in env.error, false, 'exit_code lives on meta, not error')
244
+ assert.equal(env.next_step.kind, 'recovery')
228
245
  assert.equal(env.next_step.action, 'retry_rank')
229
246
  assert.equal(env.next_step.context.clarification_turns_used, 1)
230
247
  assert.equal(env.next_step.context.retry_count, 1)
248
+ assert.equal(env.meta.command, 'postlex')
249
+ assert.ok(env.meta.exit_code > 0, 'retry envelope must mirror a non-zero exit code')
231
250
  })
232
251
 
233
252
  it('increments retry_count on each call', () => {
@@ -344,8 +363,16 @@ describe('normalize_data_rows', () => {
344
363
  // ─── extract_data_array_from_search_output (v0.48.0) ──────────────────────
345
364
 
346
365
  describe('extract_data_array_from_search_output', () => {
347
- it('extracts data.results from the canonical envelope shape', () => {
348
- const env = { data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] }, error: null, next_step: null }
366
+ it('extracts data.results from the canonical (spec 260525) envelope shape', () => {
367
+ // Canonical envelope: every key always an object, never null.
368
+ const env = {
369
+ ok: true,
370
+ data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] },
371
+ error: {},
372
+ next_step: {},
373
+ warnings: [],
374
+ meta: { command: 'search', exit_code: 0, envelope_schema_version: '1.0.0' },
375
+ }
349
376
  const r = extract_data_array_from_search_output(env)
350
377
  assert.equal(r.length, 1)
351
378
  assert.equal(r[0].name, 'foo')
@@ -380,9 +407,12 @@ describe('parse_input with --search-output', () => {
380
407
  it('extracts data.results from a full search envelope passed as the third argument', () => {
381
408
  const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'top' }] })
382
409
  const search_out = JSON.stringify({
410
+ ok: true,
383
411
  data: { query: 'q', mode: 'semantic', results: [{ name: 'deploy-aws', workspace_slug: 'acme' }] },
384
- error: null,
385
- next_step: null,
412
+ error: {},
413
+ next_step: {},
414
+ warnings: [],
415
+ meta: { command: 'search', exit_code: 0, envelope_schema_version: '1.0.0' },
386
416
  })
387
417
  const r = parse_input(ranking, null, search_out)
388
418
  assert.equal(r.parse_error, null)
@@ -125,7 +125,11 @@ const run = (args) => catch_errors('Pull failed', async () => {
125
125
  next_step: result.next_step || null,
126
126
  error: result.error || null
127
127
  })
128
- if (result.next_step) process.exit(EXIT_CODES.ERROR)
128
+ // Spec § 9.1 exit code reflects whether the OPERATION succeeded,
129
+ // not whether a next_step is present. Success-with-followup (data
130
+ // populated, next_step set, error empty) is exit 0. A populated
131
+ // error is the signal for non-zero exit.
132
+ if (result.error && result.error.code) process.exit(EXIT_CODES.ERROR)
129
133
  return
130
134
  }
131
135
  if (result.error) {
@@ -58,6 +58,52 @@ const resolve_lock_entry = (raw, project_root, is_global) => catch_errors('Faile
58
58
  return { full, lock_entry, lock_data }
59
59
  })
60
60
 
61
+ // Expand the internal { action, context: {...} } reconcile result into the
62
+ // closed-enum six-key next_step shape (spec § 4.5 / § 7). Returns {} for
63
+ // the no-followup case.
64
+ const NEXT_STEP_INSTRUCTIONS = {
65
+ resolve_regression: 'Disk version is older than lock. Pick one of the listed options and re-run with --apply <option>.',
66
+ resolve_missing_skill_json: 'skill.json is missing. Pick one of the listed restore options and re-run with --apply <option>.',
67
+ resolve_missing_dir: 'The skill directory is missing. Pick one of the listed options and re-run with --apply <option>.',
68
+ install_first: 'Skill is not in the lock file. Install it first, then re-run reconcile.',
69
+ }
70
+ const NEXT_STEP_KINDS_MAP = {
71
+ resolve_regression: 'decision',
72
+ resolve_missing_skill_json: 'decision',
73
+ resolve_missing_dir: 'decision',
74
+ install_first: 'routing',
75
+ }
76
+ const enrich_reconcile_next_step = (ns, full) => {
77
+ if (!ns || typeof ns !== 'object') return {}
78
+ const action = ns.action
79
+ if (!action) return {}
80
+ const kind = NEXT_STEP_KINDS_MAP[action] || 'decision'
81
+ if (action === 'install_first') {
82
+ return {
83
+ kind,
84
+ action,
85
+ instructions: NEXT_STEP_INSTRUCTIONS.install_first,
86
+ context: { commands: [`npx happyskills install ${full} --json`] },
87
+ route_to_skill: 'happyskills',
88
+ }
89
+ }
90
+ const opts = ns.context?.options || []
91
+ return {
92
+ kind,
93
+ action,
94
+ instructions: NEXT_STEP_INSTRUCTIONS[action] || 'Pick one of the listed options and re-run.',
95
+ context: {
96
+ options: opts,
97
+ ...(ns.context?.drift ? { drift: ns.context.drift } : {}),
98
+ commands: opts.length > 0
99
+ ? [`npx happyskills reconcile ${full} --apply ${opts[0]} --json`]
100
+ : undefined,
101
+ },
102
+ principal_authorization_required: true,
103
+ route_to_skill: 'happyskills-sync',
104
+ }
105
+ }
106
+
61
107
  const apply_restore_from_lock_version = ({ skill_dir, lock_entry }) => catch_errors('Failed to restore from lock', async () => {
62
108
  const [read_err, manifest] = await read_manifest(skill_dir)
63
109
  if (read_err) throw e('Failed to read skill.json', read_err)
@@ -197,10 +243,12 @@ const run = (args) => catch_errors('Reconcile failed', async () => {
197
243
  if (recon_err) throw e('Reconcile failed', recon_err)
198
244
 
199
245
  if (args.flags.json) {
200
- const envelope = { data: result.data }
201
- if (result.next_step !== undefined) envelope.next_step = result.next_step
202
- if (result.hint !== undefined) envelope.hint = result.hint
203
- print_json(envelope)
246
+ const { emit_envelope } = require('../ui/envelope')
247
+ // Inline the human-readable hint into data so the envelope's
248
+ // next_step slot remains reserved for actionable protocol followups.
249
+ const data = result.hint !== undefined ? { ...result.data, hint: result.hint } : result.data
250
+ const next_step = enrich_reconcile_next_step(result.next_step, full)
251
+ emit_envelope({ data, next_step })
204
252
  return
205
253
  }
206
254
 
@@ -217,11 +217,14 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
217
217
  drift: target_info.drift
218
218
  }),
219
219
  next_step: {
220
- action: 'reconcile_first',
220
+ kind: 'recovery',
221
+ action: 'reconcile_first',
222
+ instructions: 'Drift must be resolved before release. Run reconcile, follow its next_step, then retry release.',
221
223
  context: {
222
- reconcile_command: `npx happyskills reconcile ${lock_key} --json`,
223
- skill: lock_key
224
- }
224
+ commands: [`npx happyskills reconcile ${lock_key} --json`],
225
+ skill: lock_key,
226
+ },
227
+ route_to_skill: 'happyskills-sync',
225
228
  }
226
229
  })
227
230
  return { code: EXIT_CODES.ERROR, envelope: restored }
@@ -229,14 +232,26 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
229
232
  if (target_info.status === 'missing_version') {
230
233
  const restored = await restore_and({
231
234
  ...envelope_error('MISSING_VERSION', '--no-bump was passed but disk is not ahead of lock; nothing to publish.'),
232
- next_step: { action: 'specify_bump_type', context: { current_version: manifest.version } }
235
+ next_step: {
236
+ kind: 'decision',
237
+ action: 'specify_bump_type',
238
+ instructions: 'No target version determined. Pick a bump type and re-run.',
239
+ context: { options: ['patch', 'minor', 'major'], current_version: manifest.version },
240
+ principal_authorization_required: true,
241
+ }
233
242
  })
234
243
  return { code: EXIT_CODES.USAGE, envelope: restored }
235
244
  }
236
245
  if (target_info.status === 'invalid_bump') {
237
246
  const restored = await restore_and({
238
247
  ...envelope_error('INVALID_BUMP', `--bump "${target_info.bump}" is not patch/minor/major or a valid semver.`),
239
- next_step: { action: 'specify_bump_type', context: { current_version: manifest.version } }
248
+ next_step: {
249
+ kind: 'decision',
250
+ action: 'specify_bump_type',
251
+ instructions: 'The bump value is not valid. Pick a bump type and re-run.',
252
+ context: { options: ['patch', 'minor', 'major'], current_version: manifest.version, got: target_info.bump },
253
+ principal_authorization_required: true,
254
+ }
240
255
  })
241
256
  return { code: EXIT_CODES.USAGE, envelope: restored }
242
257
  }
@@ -244,8 +259,11 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
244
259
  const restored = await restore_and({
245
260
  ...envelope_error('MISSING_VERSION', 'No --bump provided and no CHANGELOG entry indicates an intended next version.'),
246
261
  next_step: {
247
- action: 'specify_bump_type',
248
- context: { current_version: manifest.version, options: ['patch', 'minor', 'major', 'explicit-version'] }
262
+ kind: 'decision',
263
+ action: 'specify_bump_type',
264
+ instructions: 'No target version provided. Pick a bump type and re-run.',
265
+ context: { options: ['patch', 'minor', 'major'], current_version: manifest.version },
266
+ principal_authorization_required: true,
249
267
  }
250
268
  })
251
269
  return { code: EXIT_CODES.USAGE, envelope: restored }
@@ -254,12 +272,15 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
254
272
  const restored = await restore_and({
255
273
  ...envelope_error('BUMP_DISAGREEMENT', `--bump ${target_info.requested_bump} disagrees with the disk version ${target_info.disk_version}.`),
256
274
  next_step: {
257
- action: 'resolve_bump_disagreement',
275
+ kind: 'decision',
276
+ action: 'resolve_bump_disagreement',
277
+ instructions: 'The --bump value disagrees with the disk version. Pick one and re-run.',
258
278
  context: {
259
- disk_version: target_info.disk_version,
279
+ disk_version: target_info.disk_version,
260
280
  requested_bump: target_info.requested_bump,
261
- lock_version: target_info.lock_version
262
- }
281
+ lock_version: target_info.lock_version,
282
+ },
283
+ principal_authorization_required: true,
263
284
  }
264
285
  })
265
286
  return { code: EXIT_CODES.USAGE, envelope: restored }
@@ -299,7 +320,13 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
299
320
  if (cf_err || !cf_content) {
300
321
  const restored = await restore_and({
301
322
  ...envelope_error('CHANGELOG_SOURCE_UNREADABLE', `Could not read --changelog-from "${changelog_from}".`),
302
- next_step: { action: 'provide_changelog' }
323
+ next_step: {
324
+ kind: 'recovery',
325
+ action: 'provide_changelog',
326
+ instructions: 'The --changelog-from file could not be read. Provide a readable file and re-run.',
327
+ context: {},
328
+ principal_authorization_required: true,
329
+ }
303
330
  })
304
331
  return { code: EXIT_CODES.USAGE, envelope: restored }
305
332
  }
@@ -311,8 +338,11 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
311
338
  const restored = await restore_and({
312
339
  ...envelope_error('MISSING_CHANGELOG_ENTRY', `CHANGELOG.md does not contain a ## [${target_version}] entry.`),
313
340
  next_step: {
314
- action: 'provide_changelog',
315
- context: { target_version, current_top_entry: cl_top }
341
+ kind: 'recovery',
342
+ action: 'provide_changelog',
343
+ instructions: 'CHANGELOG.md is missing an entry for the target version. Add the entry and re-run release.',
344
+ context: { target_version, current_top_entry: cl_top },
345
+ principal_authorization_required: true,
316
346
  }
317
347
  })
318
348
  return { code: EXIT_CODES.USAGE, envelope: restored }
@@ -0,0 +1,179 @@
1
+ 'use strict'
2
+ // happyskills schema — machine-readable description of the CLI surface.
3
+ // Spec 260525-cli-default-json § 10. A generic agent without HappySkills
4
+ // skills installed can learn every command, every error code, and every
5
+ // next_step.action in one call.
6
+ //
7
+ // Generation strategy: each command module MAY export a `schema` object
8
+ // alongside `run`. When that's missing we fall back to a minimal stub so
9
+ // the surface is always discoverable even if a command hasn't been audited
10
+ // yet (defensive, additive). The schema lives next to the implementation
11
+ // so there's no drift.
12
+
13
+ const { error: { catch_errors } } = require('puffy-core')
14
+ const { COMMANDS, COMMAND_ALIASES, CLI_VERSION } = require('../constants')
15
+ const { ERROR_CODE_LIST } = require('../constants/error_codes')
16
+ const {
17
+ NEXT_STEP_ACTION_LIST,
18
+ NEXT_STEP_KINDS,
19
+ ACTION_KIND,
20
+ } = require('../constants/next_step_actions')
21
+ const { ENVELOPE_SCHEMA_VERSION } = require('../schema/envelope_validator')
22
+ const { print_help } = require('../ui/output')
23
+ const { emit_envelope } = require('../ui/envelope')
24
+ const { exit_with_error } = require('../utils/errors')
25
+ const { EXIT_CODES } = require('../constants')
26
+
27
+ const HELP_TEXT = `Usage: happyskills schema [options]
28
+
29
+ Emit a machine-readable description of the CLI surface — every command,
30
+ its input contract, output envelope shape, error codes, and next_step
31
+ actions. Designed for agentic discovery.
32
+
33
+ Options:
34
+ --json Output as JSON envelope (default for non-TTY callers)
35
+ --text Output as a human-readable listing
36
+
37
+ Examples:
38
+ happyskills schema --json
39
+ happyskills schema --text`
40
+
41
+ // Compute aliases by walking COMMAND_ALIASES in reverse — each command
42
+ // gets the list of aliases that resolve to it.
43
+ const aliases_for = (name) => {
44
+ const out = []
45
+ for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) {
46
+ if (canonical === name) out.push(alias)
47
+ }
48
+ return out
49
+ }
50
+
51
+ // Audience inference fallback when a command module doesn't export schema.
52
+ // Mapping aligns with docs/cli-commands-*.md taxonomy.
53
+ const AUDIENCE_HINT = {
54
+ // consumer
55
+ init: 'consumer', install: 'consumer', uninstall: 'consumer',
56
+ list: 'consumer', check: 'consumer', status: 'consumer',
57
+ update: 'consumer', search: 'consumer', postlex: 'consumer',
58
+ diff: 'consumer', pull: 'consumer', snapshot: 'consumer',
59
+ reconcile: 'consumer', enable: 'consumer', disable: 'consumer',
60
+ versions: 'consumer', changelog: 'consumer', bump: 'consumer',
61
+ // author
62
+ publish: 'author', convert: 'author', fork: 'author',
63
+ validate: 'author', delete: 'author', visibility: 'author',
64
+ release: 'author',
65
+ // account
66
+ login: 'account', logout: 'account', whoami: 'account',
67
+ setup: 'account', config: 'account', people: 'account',
68
+ groups: 'account', access: 'account', agents: 'account',
69
+ feedback: 'account', 'self-update': 'account',
70
+ // system
71
+ schema: 'system',
72
+ }
73
+
74
+ const default_schema = (name) => ({
75
+ name,
76
+ aliases: aliases_for(name),
77
+ audience: AUDIENCE_HINT[name] || 'consumer',
78
+ purpose: `(no curated purpose for "${name}" yet)`,
79
+ mutation: ![
80
+ 'list', 'check', 'status', 'search', 'postlex', 'diff', 'versions',
81
+ 'changelog', 'whoami', 'schema',
82
+ ].includes(name),
83
+ interactive_in_text_mode: false,
84
+ input: { positional: [], flags: [] },
85
+ output: { data_shape: {} },
86
+ errors: [],
87
+ })
88
+
89
+ // Load the per-command schema export. Misses fall back to default_schema.
90
+ // We deliberately swallow errors inside the require so a broken module
91
+ // doesn't crash `schema --json`.
92
+ const load_command_schema = (name) => {
93
+ try {
94
+ const mod = require(`./${name}`)
95
+ if (mod && mod.schema && typeof mod.schema === 'object') {
96
+ return { ...default_schema(name), ...mod.schema, aliases: mod.schema.aliases || aliases_for(name) }
97
+ }
98
+ } catch (_) {
99
+ // ignored — fall through to default
100
+ }
101
+ return default_schema(name)
102
+ }
103
+
104
+ const build_command_registry = () => {
105
+ const all = [...COMMANDS]
106
+ if (!all.includes('schema')) all.push('schema')
107
+ return all.map(load_command_schema)
108
+ }
109
+
110
+ const build_error_code_list = () => ERROR_CODE_LIST.map(code => ({ code }))
111
+
112
+ const build_next_step_action_list = () =>
113
+ NEXT_STEP_ACTION_LIST.map(action => ({ action, kind: ACTION_KIND[action] || null }))
114
+
115
+ const build_schema_payload = () => ({
116
+ envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
117
+ envelope_schema_uri: 'https://schemas.happyskills.dev/envelope/v1.json',
118
+ cli_version: CLI_VERSION,
119
+ commands: build_command_registry(),
120
+ error_codes: build_error_code_list(),
121
+ next_step_actions: build_next_step_action_list(),
122
+ next_step_kinds: [...NEXT_STEP_KINDS],
123
+ })
124
+
125
+ const print_text_listing = (payload) => {
126
+ const groups = { consumer: [], author: [], account: [], system: [] }
127
+ for (const cmd of payload.commands) {
128
+ const bucket = groups[cmd.audience] || groups.consumer
129
+ bucket.push(cmd)
130
+ }
131
+ console.log(`happyskills v${payload.cli_version} — CLI surface (envelope schema v${payload.envelope_schema_version})`)
132
+ console.log('')
133
+ for (const [audience, list] of Object.entries(groups)) {
134
+ if (list.length === 0) continue
135
+ console.log(audience.toUpperCase())
136
+ for (const cmd of list.sort((a, b) => a.name.localeCompare(b.name))) {
137
+ const aliases = cmd.aliases && cmd.aliases.length ? ` (${cmd.aliases.join(', ')})` : ''
138
+ console.log(` ${cmd.name.padEnd(14)} ${cmd.purpose}${aliases}`)
139
+ }
140
+ console.log('')
141
+ }
142
+ console.log(`For the full machine-readable contract: happyskills schema --json`)
143
+ }
144
+
145
+ const run = (args) => catch_errors('Schema command failed', async () => {
146
+ if (args.flags._show_help) {
147
+ print_help(HELP_TEXT)
148
+ return process.exit(EXIT_CODES.SUCCESS)
149
+ }
150
+ const payload = build_schema_payload()
151
+ if (args.flags.json) {
152
+ emit_envelope({ data: payload })
153
+ return
154
+ }
155
+ print_text_listing(payload)
156
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
157
+
158
+ // Schema for `schema` itself — recursive but harmless. § 10.
159
+ const schema = {
160
+ name: 'schema',
161
+ aliases: [],
162
+ audience: 'system',
163
+ purpose: 'Emit a machine-readable description of the CLI surface.',
164
+ mutation: false,
165
+ interactive_in_text_mode: false,
166
+ input: { positional: [], flags: [{ name: 'json', type: 'boolean' }, { name: 'text', type: 'boolean' }] },
167
+ output: { data_shape: {
168
+ envelope_schema_version: 'string',
169
+ envelope_schema_uri: 'string',
170
+ cli_version: 'string',
171
+ commands: 'array<CommandSchema>',
172
+ error_codes: 'array<{ code: string }>',
173
+ next_step_actions: 'array<{ action: string, kind: string }>',
174
+ next_step_kinds: 'array<string>',
175
+ } },
176
+ errors: [],
177
+ }
178
+
179
+ module.exports = { run, schema, build_schema_payload }
@@ -6,6 +6,7 @@ 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
8
  const { fire_discovery_telemetry } = require('../api/telemetry')
9
+ const { get_intent_id } = require('../utils/intent')
9
10
 
10
11
  const HELP_TEXT = `Usage: happyskills search [query] [options]
11
12
 
@@ -128,7 +129,7 @@ const STRONG_TIERS = new Set(['strong', 'good'])
128
129
 
129
130
  const build_search_next_step = (response, query, opts) => {
130
131
  const { with_rerank, clarification_turns_used } = opts
131
- if (!with_rerank) return null
132
+ if (!with_rerank) return {}
132
133
 
133
134
  const mode = response?.mode || null
134
135
  const digests = Array.isArray(response?.rerank_digests) ? response.rerank_digests : null
@@ -137,17 +138,19 @@ const build_search_next_step = (response, query, opts) => {
137
138
  const turns_remaining = 2 - turns_used
138
139
 
139
140
  // Non-semantic modes (slug, scoped, exact-FTS) don't support rerank — server
140
- // returns no digests. No envelope; agent just renders.
141
- if (mode !== 'semantic') return null
141
+ // returns no digests. Empty next_step; agent just renders.
142
+ if (mode !== 'semantic') return {}
142
143
 
143
144
  // Semantic + digests present → the protocol's primary path.
144
145
  if (digests && digests.length > 0) {
145
146
  return {
147
+ kind: 'continuation',
146
148
  action: 'rank_digests_inline',
147
149
  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}\`.`,
148
150
  context: {
149
151
  original_query: query,
150
152
  clarification_turns_used: turns_used,
153
+ commands: [`npx happyskills postlex --query "${query}" --ranking - --clarification-turns-used ${turns_used} --json`],
151
154
  },
152
155
  }
153
156
  }
@@ -157,36 +160,38 @@ const build_search_next_step = (response, query, opts) => {
157
160
  if (match_notice) {
158
161
  if (turns_remaining <= 0) {
159
162
  return {
163
+ kind: 'continuation',
160
164
  action: 'present_to_user',
161
165
  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.',
162
- context: null,
166
+ context: { original_query: query, clarification_turns_used: turns_used },
163
167
  }
164
168
  }
165
169
  return {
166
- action: 'clarify',
167
- 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.`,
168
- suggested_questions: [
169
- {
170
- question: 'Quick clarification — what specifically are you looking for?',
171
- options: [
172
- { label: 'A workflow or how-to guide', refined_query_hint: 'workflow' },
173
- { label: 'A specific tool or library', refined_query_hint: 'tool' },
174
- { label: 'A platform-specific recipe (AWS / GCP / Vercel)', refined_query_hint: 'platform' },
175
- { label: 'Just search anyway', refined_query_hint: null },
176
- ],
177
- },
178
- ],
179
- max_turns_remaining: turns_remaining,
170
+ kind: 'clarification',
171
+ action: 'clarify_query',
172
+ instructions: `The search returned weak results. Ask the user one of \`context.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.`,
180
173
  context: {
181
174
  original_query: query,
182
175
  clarification_turns_used: turns_used,
176
+ max_turns_remaining: turns_remaining,
177
+ suggested_questions: [
178
+ {
179
+ question: 'Quick clarification — what specifically are you looking for?',
180
+ options: [
181
+ { label: 'A workflow or how-to guide', refined_query_hint: 'workflow' },
182
+ { label: 'A specific tool or library', refined_query_hint: 'tool' },
183
+ { label: 'A platform-specific recipe (AWS / GCP / Vercel)', refined_query_hint: 'platform' },
184
+ { label: 'Just search anyway', refined_query_hint: null },
185
+ ],
186
+ },
187
+ ],
183
188
  },
189
+ principal_authorization_required: true,
184
190
  }
185
191
  }
186
192
 
187
- // Semantic, no digests, no match_notice (defensive shouldn't normally
188
- // happen). Agent renders the baseline results.
189
- return null
193
+ // Semantic, no digests, no match_notice (defensive). Empty next_step.
194
+ return {}
190
195
  }
191
196
 
192
197
  const run_smart_search = (args, query, options) => catch_errors('Smart search failed', async () => {
@@ -221,16 +226,22 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
221
226
  const next_step = build_search_next_step(response, query, { with_rerank, clarification_turns_used })
222
227
 
223
228
  // Telemetry beacons (fire-and-forget). Spec § 5.1 + § 5.3.
229
+ // `intent_id` is sourced from the active intent envelope (spec 260521-03
230
+ // addendum § 4) so search.rerank / search.clarify rows correlate with
231
+ // the same discovery chain as the search.query that minted the envelope.
232
+ const intent_id = get_intent_id()
224
233
  if (with_rerank && next_step?.action === 'rank_digests_inline') {
225
234
  fire_discovery_telemetry({
226
235
  event: 'rerank_started',
236
+ intent_id,
227
237
  query,
228
238
  rerank_prompt_version: response?.rerank_prompt_version || null,
229
239
  })
230
240
  }
231
- if (with_rerank && next_step?.action === 'clarify') {
241
+ if (with_rerank && next_step?.action === 'clarify_query') {
232
242
  fire_discovery_telemetry({
233
243
  event: 'clarify_triggered',
244
+ intent_id,
234
245
  query,
235
246
  reason: 'match_notice',
236
247
  turn_number: clarification_turns_used + 1,
@@ -241,6 +252,7 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
241
252
  // with a refined query.
242
253
  fire_discovery_telemetry({
243
254
  event: 'clarify_completed',
255
+ intent_id,
244
256
  query,
245
257
  turn_number: clarification_turns_used,
246
258
  })