happyskills 0.54.0 → 1.0.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.
- package/CHANGELOG.md +32 -2
- package/package.json +1 -1
- package/src/api/auth.js +18 -2
- package/src/api/client.js +29 -3
- package/src/api/feedback.js +14 -5
- package/src/api/repos.js +28 -10
- package/src/api/translate.js +90 -0
- package/src/commands/delete.js +15 -1
- package/src/commands/feedback.js +4 -4
- package/src/commands/init.js +5 -1
- package/src/commands/install.js +58 -32
- package/src/commands/postlex.js +74 -38
- package/src/commands/postlex.test.js +96 -18
- package/src/commands/pull.js +5 -1
- package/src/commands/reconcile.js +52 -4
- package/src/commands/release.js +45 -15
- package/src/commands/schema.js +179 -0
- package/src/commands/search.js +27 -23
- package/src/commands/search.test.js +59 -33
- package/src/commands/uninstall.js +20 -11
- package/src/commands/validate.js +33 -11
- package/src/constants/error_codes.js +197 -0
- package/src/constants/exit_codes.js +54 -0
- package/src/constants/next_step_actions.js +133 -0
- package/src/constants/next_step_by_error_code.js +249 -0
- package/src/constants.js +2 -1
- package/src/index.js +51 -7
- package/src/integration/api_envelope.test.js +499 -0
- package/src/integration/bump.test.js +13 -4
- package/src/integration/cli.test.js +169 -147
- package/src/integration/drift.test.js +16 -4
- package/src/integration/install_fresh.test.js +37 -29
- package/src/integration/reconcile.test.js +77 -56
- package/src/integration/release.test.js +48 -31
- package/src/integration/schema.test.js +167 -0
- package/src/schema/envelope.schema.json +73 -0
- package/src/schema/envelope_test_helpers.js +94 -0
- package/src/schema/envelope_validator.js +239 -0
- package/src/schema/envelope_validator.test.js +333 -0
- package/src/ui/envelope.js +171 -0
- package/src/ui/output.js +66 -2
- package/src/utils/errors.js +116 -47
package/src/commands/postlex.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Spec 260521-01 v2 § 5.2 — this command is the deterministic safety net
|
|
6
6
|
// for the rerank protocol. Frontier model emits ranking → postlex finalizes
|
|
7
|
-
// → agent renders. The envelope makes the protocol legible to the agent
|
|
7
|
+
// → agent renders. The canonical six-key response envelope makes the protocol legible to the agent
|
|
8
8
|
// without piling conditional logic into SKILL.md.
|
|
9
9
|
|
|
10
10
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
@@ -178,61 +178,92 @@ const determine_next_step = (final_ordering, query, clarification_turns_used) =>
|
|
|
178
178
|
|
|
179
179
|
if (all_strong || final_ordering.length === 0) {
|
|
180
180
|
return {
|
|
181
|
+
kind: 'continuation',
|
|
181
182
|
action: 'present_to_user',
|
|
182
183
|
instructions: 'Render `data.final_ordering` as a table to the user, with skill name, slug, description, and rationale. Include `data.formulated_query` in your preamble.',
|
|
183
|
-
context:
|
|
184
|
+
context: { original_query: query, clarification_turns_used: turns_used },
|
|
184
185
|
}
|
|
185
186
|
}
|
|
186
187
|
|
|
187
188
|
if (turns_remaining <= 0) {
|
|
188
189
|
return {
|
|
190
|
+
kind: 'continuation',
|
|
189
191
|
action: 'present_to_user',
|
|
190
192
|
instructions: 'Even after reranking, no top result is a strong match — but the clarification budget (2 turns) is spent. Render `data.final_ordering` honestly: note that no result was a strong match, and present what you have so the user can decide. Do NOT ask another clarifying question.',
|
|
191
|
-
context:
|
|
193
|
+
context: { original_query: query, clarification_turns_used: turns_used },
|
|
192
194
|
}
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
return {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
{
|
|
200
|
-
question: 'After reranking I still don\'t have a strong match. Could you narrow the scope a bit?',
|
|
201
|
-
options: [
|
|
202
|
-
{ label: 'Focus on a specific stack (Node / Python / Go / etc.)', refined_query_hint: 'stack-specific' },
|
|
203
|
-
{ label: 'Focus on a specific platform (AWS / GCP / Vercel / etc.)', refined_query_hint: 'platform-specific' },
|
|
204
|
-
{ label: 'Broaden the search — show me more options', refined_query_hint: 'broader' },
|
|
205
|
-
{ label: 'Just search anyway', refined_query_hint: null },
|
|
206
|
-
],
|
|
207
|
-
},
|
|
208
|
-
],
|
|
209
|
-
max_turns_remaining: turns_remaining,
|
|
198
|
+
kind: 'clarification',
|
|
199
|
+
action: 'clarify_query',
|
|
200
|
+
instructions: `After reranking, no top result is a strong match. Ask the user one of \`context.suggested_questions\` using your agent's question mechanism (e.g. AskUserQuestion in Claude Code), then re-run search with the refined query and add --clarification-turns-used ${turns_used + 1}. The last option is always "Just search anyway" — honor it by re-running with the original query unchanged.`,
|
|
210
201
|
context: {
|
|
211
202
|
original_query: query,
|
|
212
203
|
clarification_turns_used: turns_used,
|
|
204
|
+
max_turns_remaining: turns_remaining,
|
|
205
|
+
suggested_questions: [
|
|
206
|
+
{
|
|
207
|
+
question: 'After reranking I still don\'t have a strong match. Could you narrow the scope a bit?',
|
|
208
|
+
options: [
|
|
209
|
+
{ label: 'Focus on a specific stack (Node / Python / Go / etc.)', refined_query_hint: 'stack-specific' },
|
|
210
|
+
{ label: 'Focus on a specific platform (AWS / GCP / Vercel / etc.)', refined_query_hint: 'platform-specific' },
|
|
211
|
+
{ label: 'Broaden the search — show me more options', refined_query_hint: 'broader' },
|
|
212
|
+
{ label: 'Just search anyway', refined_query_hint: null },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
213
216
|
},
|
|
217
|
+
principal_authorization_required: true,
|
|
214
218
|
}
|
|
215
219
|
}
|
|
216
220
|
|
|
221
|
+
// ─── next_step consumers (pure — exported for unit testing) ─────────────────
|
|
222
|
+
// run() reacts to the next_step that determine_next_step emitted: it fires a
|
|
223
|
+
// `clarify_triggered` telemetry beacon and renders a clarification notice in
|
|
224
|
+
// text mode. Those two decisions are factored out here so they can be
|
|
225
|
+
// unit-tested without spawning the process or hitting the telemetry endpoint.
|
|
226
|
+
|
|
227
|
+
// True when the emitted next_step is the post-rerank clarification step.
|
|
228
|
+
// The closed action enum value is `clarify_query` (next_step_actions.js) —
|
|
229
|
+
// matching the bare label "clarify" here silently disables the clarification
|
|
230
|
+
// beacon and notice, since determine_next_step never emits "clarify".
|
|
231
|
+
const is_clarify_next_step = (next_step) =>
|
|
232
|
+
!!next_step && next_step.action === 'clarify_query'
|
|
233
|
+
|
|
234
|
+
// The first suggested clarifying question to surface in text mode, or null.
|
|
235
|
+
// suggested_questions lives under next_step.context (spec § 4.5), not at the
|
|
236
|
+
// bare next_step root.
|
|
237
|
+
const first_clarify_question = (next_step) => {
|
|
238
|
+
const list = next_step && next_step.context && next_step.context.suggested_questions
|
|
239
|
+
const q = Array.isArray(list) && list[0]
|
|
240
|
+
return q && typeof q.question === 'string' ? q.question : null
|
|
241
|
+
}
|
|
242
|
+
|
|
217
243
|
// Envelope used when the LLM's ranking is malformed and we need to ask it
|
|
218
|
-
// to re-emit.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
action: 'retry_rank',
|
|
228
|
-
instructions: 'Your ranking failed validation. Re-emit a JSON object matching the `rerank_response_schema` from the original search response, then re-pipe to `happyskills postlex --query "<q>" --ranking -`. Max one retry — if it fails again, render the search results without rerank (the API\'s relevance_score order is the fallback).',
|
|
229
|
-
context: {
|
|
230
|
-
original_query: query,
|
|
231
|
-
clarification_turns_used: clarification_turns_used | 0,
|
|
232
|
-
retry_count: (retry_count | 0) + 1,
|
|
244
|
+
// to re-emit. Returns the canonical six-key response envelope (spec § 4) so postlex
|
|
245
|
+
// can be unit-tested against the closed schema.
|
|
246
|
+
const build_retry_envelope = (query, reason, clarification_turns_used, retry_count) => {
|
|
247
|
+
const { build_envelope } = require('../ui/envelope')
|
|
248
|
+
return build_envelope({
|
|
249
|
+
data: {},
|
|
250
|
+
error: {
|
|
251
|
+
code: 'RANKING_SCHEMA_MISMATCH',
|
|
252
|
+
message: reason,
|
|
233
253
|
},
|
|
234
|
-
|
|
235
|
-
|
|
254
|
+
next_step: {
|
|
255
|
+
kind: 'recovery',
|
|
256
|
+
action: 'retry_rank',
|
|
257
|
+
instructions: 'Your ranking failed validation. Re-emit a JSON object matching the `rerank_response_schema` from the original search response, then re-pipe to `happyskills postlex --query "<q>" --ranking -`. Max one retry — if it fails again, render the search results without rerank (the API\'s relevance_score order is the fallback).',
|
|
258
|
+
context: {
|
|
259
|
+
original_query: query,
|
|
260
|
+
clarification_turns_used: clarification_turns_used | 0,
|
|
261
|
+
retry_count: (retry_count | 0) + 1,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
meta_overrides: { command: 'postlex' },
|
|
265
|
+
})
|
|
266
|
+
}
|
|
236
267
|
|
|
237
268
|
// ─── I/O helpers ───────────────────────────────────────────────────────────
|
|
238
269
|
|
|
@@ -405,7 +436,10 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
405
436
|
process.stderr.write(`postlex: ${parse_error}\n`)
|
|
406
437
|
const env = build_retry_envelope(query, parse_error, clarification_turns_used, 0)
|
|
407
438
|
print_json(env)
|
|
408
|
-
|
|
439
|
+
// Spec § 9.2 — process exit code must mirror meta.exit_code. The
|
|
440
|
+
// retry envelope carries error.code=RANKING_SCHEMA_MISMATCH which
|
|
441
|
+
// maps to a non-zero exit; honor that rather than forcing 0.
|
|
442
|
+
return process.exit(env.meta.exit_code)
|
|
409
443
|
}
|
|
410
444
|
|
|
411
445
|
// Validate per-item
|
|
@@ -438,7 +472,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
438
472
|
promoted_from_rank,
|
|
439
473
|
exact_match_count_in_window,
|
|
440
474
|
})
|
|
441
|
-
if (next_step
|
|
475
|
+
if (is_clarify_next_step(next_step)) {
|
|
442
476
|
fire_discovery_telemetry({
|
|
443
477
|
event: 'clarify_triggered',
|
|
444
478
|
intent_id,
|
|
@@ -475,9 +509,9 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
475
509
|
console.log(format_human_row(row))
|
|
476
510
|
if (i < final_ordering.length - 1) console.log('')
|
|
477
511
|
})
|
|
478
|
-
if (next_step
|
|
512
|
+
if (is_clarify_next_step(next_step)) {
|
|
479
513
|
console.log(`\n ${yellow('No top result is a strong match. Suggested clarification:')}`)
|
|
480
|
-
console.log(` ${dim(next_step
|
|
514
|
+
console.log(` ${dim(first_clarify_question(next_step))}`)
|
|
481
515
|
} else if (next_step.action === 'present_to_user' && !final_ordering.slice(0, 3).every(r => STRONG_TIERS.has(r.match_quality))) {
|
|
482
516
|
console.log(`\n ${yellow('No top result is a strong match (clarification budget spent).')}`)
|
|
483
517
|
}
|
|
@@ -491,6 +525,8 @@ module.exports = {
|
|
|
491
525
|
apply_postlex,
|
|
492
526
|
build_final_ordering,
|
|
493
527
|
determine_next_step,
|
|
528
|
+
is_clarify_next_step,
|
|
529
|
+
first_clarify_question,
|
|
494
530
|
build_retry_envelope,
|
|
495
531
|
parse_input,
|
|
496
532
|
resolve_row_name,
|
|
@@ -14,6 +14,8 @@ const {
|
|
|
14
14
|
apply_postlex,
|
|
15
15
|
build_final_ordering,
|
|
16
16
|
determine_next_step,
|
|
17
|
+
is_clarify_next_step,
|
|
18
|
+
first_clarify_question,
|
|
17
19
|
build_retry_envelope,
|
|
18
20
|
parse_input,
|
|
19
21
|
resolve_row_name,
|
|
@@ -172,25 +174,32 @@ describe('determine_next_step', () => {
|
|
|
172
174
|
rationale: '',
|
|
173
175
|
}))
|
|
174
176
|
|
|
175
|
-
|
|
177
|
+
// Per spec 260525-cli-default-json § 7.1 the closed action names are
|
|
178
|
+
// "clarify_query" (kind=clarification) and "present_to_user"
|
|
179
|
+
// (kind=continuation). Tests below use those — the older bare "clarify"
|
|
180
|
+
// label is deprecated.
|
|
181
|
+
|
|
182
|
+
it('emits present_to_user (kind=continuation) when top 3 are all strong/good', () => {
|
|
176
183
|
const ns = determine_next_step(fo(['strong', 'good', 'strong']), 'q', 0)
|
|
177
184
|
assert.equal(ns.action, 'present_to_user')
|
|
185
|
+
assert.equal(ns.kind, 'continuation')
|
|
178
186
|
})
|
|
179
187
|
|
|
180
|
-
it('emits
|
|
188
|
+
it('emits clarify_query (kind=clarification) when top 3 include a partial AND budget remains', () => {
|
|
181
189
|
const ns = determine_next_step(fo(['strong', 'partial', 'good']), 'q', 0)
|
|
182
|
-
assert.equal(ns.action, '
|
|
183
|
-
assert.equal(ns.
|
|
190
|
+
assert.equal(ns.action, 'clarify_query')
|
|
191
|
+
assert.equal(ns.kind, 'clarification')
|
|
192
|
+
// max_turns_remaining + suggested_questions live inside context (spec § 4.5).
|
|
193
|
+
assert.equal(ns.context.max_turns_remaining, 2)
|
|
184
194
|
assert.equal(ns.context.clarification_turns_used, 0)
|
|
185
|
-
|
|
186
|
-
const last = ns.suggested_questions[0].options[ns.suggested_questions[0].options.length - 1]
|
|
195
|
+
const last = ns.context.suggested_questions[0].options[ns.context.suggested_questions[0].options.length - 1]
|
|
187
196
|
assert.match(last.label, /search anyway/i)
|
|
188
197
|
})
|
|
189
198
|
|
|
190
|
-
it('emits
|
|
199
|
+
it('emits clarify_query when top 3 include a weak/null AND budget remains', () => {
|
|
191
200
|
const ns = determine_next_step(fo(['weak', 'good', null]), 'q', 1)
|
|
192
|
-
assert.equal(ns.action, '
|
|
193
|
-
assert.equal(ns.max_turns_remaining, 1)
|
|
201
|
+
assert.equal(ns.action, 'clarify_query')
|
|
202
|
+
assert.equal(ns.context.max_turns_remaining, 1)
|
|
194
203
|
})
|
|
195
204
|
|
|
196
205
|
it('emits present_to_user with budget-spent note when budget exhausted', () => {
|
|
@@ -201,7 +210,7 @@ describe('determine_next_step', () => {
|
|
|
201
210
|
|
|
202
211
|
it('clamps clarification_turns_used to [0, 2]', () => {
|
|
203
212
|
const ns_neg = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', -1)
|
|
204
|
-
assert.equal(ns_neg.max_turns_remaining, 2)
|
|
213
|
+
assert.equal(ns_neg.context.max_turns_remaining, 2)
|
|
205
214
|
const ns_big = determine_next_step(fo(['partial', 'partial', 'partial']), 'q', 99)
|
|
206
215
|
assert.equal(ns_big.action, 'present_to_user')
|
|
207
216
|
})
|
|
@@ -217,17 +226,75 @@ describe('determine_next_step', () => {
|
|
|
217
226
|
})
|
|
218
227
|
})
|
|
219
228
|
|
|
229
|
+
// ─── next_step consumer wiring (clarify_query drift regression) ────────────
|
|
230
|
+
// determine_next_step (the producer) emits next_step.action === "clarify_query"
|
|
231
|
+
// with suggested_questions nested under next_step.context (spec § 4.5). run()'s
|
|
232
|
+
// telemetry + text-render consumers must recognize that exact contract. The
|
|
233
|
+
// historical bug: the consumers checked for the bare label "clarify" and read
|
|
234
|
+
// next_step.suggested_questions at the wrong depth, so the clarify_triggered
|
|
235
|
+
// beacon never fired and the clarification notice never rendered — a SILENT
|
|
236
|
+
// failure (no error) at the one moment the harness is meant to engage.
|
|
237
|
+
describe('postlex clarify_query consumer wiring', () => {
|
|
238
|
+
const weak_ordering = [
|
|
239
|
+
{ rank: 1, candidate_id: 1, match_quality: 'weak' },
|
|
240
|
+
{ rank: 2, candidate_id: 2, match_quality: 'partial' },
|
|
241
|
+
{ rank: 3, candidate_id: 3, match_quality: 'weak' },
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
it('recognizes the producer\'s clarify_query next_step (telemetry + render gate)', () => {
|
|
245
|
+
const ns = determine_next_step(weak_ordering, 'deploy aws', 0)
|
|
246
|
+
assert.equal(ns.action, 'clarify_query', 'producer emits the canonical action')
|
|
247
|
+
assert.equal(
|
|
248
|
+
is_clarify_next_step(ns), true,
|
|
249
|
+
'consumer must recognize action "clarify_query" so the clarify_triggered beacon fires and the notice renders',
|
|
250
|
+
)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('reads the suggested question from next_step.context.suggested_questions', () => {
|
|
254
|
+
const ns = determine_next_step(weak_ordering, 'deploy aws', 0)
|
|
255
|
+
const q = first_clarify_question(ns)
|
|
256
|
+
assert.equal(typeof q, 'string', 'suggested question must be resolved from next_step.context')
|
|
257
|
+
assert.ok(q.length > 0, 'must surface the first suggested clarifying question for text-mode rendering')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('does not treat the success path (present_to_user) as a clarify step', () => {
|
|
261
|
+
const ns = determine_next_step(
|
|
262
|
+
[
|
|
263
|
+
{ rank: 1, candidate_id: 1, match_quality: 'strong' },
|
|
264
|
+
{ rank: 2, candidate_id: 2, match_quality: 'good' },
|
|
265
|
+
{ rank: 3, candidate_id: 3, match_quality: 'strong' },
|
|
266
|
+
],
|
|
267
|
+
'q', 0,
|
|
268
|
+
)
|
|
269
|
+
assert.equal(ns.action, 'present_to_user')
|
|
270
|
+
assert.equal(is_clarify_next_step(ns), false)
|
|
271
|
+
assert.equal(first_clarify_question(ns), null)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
220
275
|
// ─── build_retry_envelope ─────────────────────────────────────────────────
|
|
276
|
+
// Envelope contract: six-key { ok, data, error, next_step, warnings, meta }
|
|
277
|
+
// with data={}, error.code=RANKING_SCHEMA_MISMATCH (closed enum), kind=recovery,
|
|
278
|
+
// action=retry_rank, exit_code mirrored on meta (NOT on error).
|
|
279
|
+
|
|
280
|
+
const { validate_envelope } = require('../schema/envelope_validator')
|
|
221
281
|
|
|
222
282
|
describe('build_retry_envelope', () => {
|
|
223
|
-
it('produces a retry_rank envelope
|
|
283
|
+
it('produces a retry_rank envelope conforming to the closed schema', () => {
|
|
224
284
|
const env = build_retry_envelope('the query', 'ranking missing field rationale', 1, 0)
|
|
225
|
-
|
|
226
|
-
assert.equal(
|
|
227
|
-
assert.equal(env.
|
|
285
|
+
const { ok, errors } = validate_envelope(env)
|
|
286
|
+
assert.equal(ok, true, `envelope failed validation: ${JSON.stringify(errors)}`)
|
|
287
|
+
assert.equal(env.ok, false, 'retry envelope is a failure (ok=false)')
|
|
288
|
+
assert.deepEqual(env.data, {}, 'data must be the empty object, never null')
|
|
289
|
+
assert.equal(env.error.code, 'RANKING_SCHEMA_MISMATCH')
|
|
290
|
+
assert.equal(typeof env.error.message, 'string')
|
|
291
|
+
assert.equal('exit_code' in env.error, false, 'exit_code lives on meta, not error')
|
|
292
|
+
assert.equal(env.next_step.kind, 'recovery')
|
|
228
293
|
assert.equal(env.next_step.action, 'retry_rank')
|
|
229
294
|
assert.equal(env.next_step.context.clarification_turns_used, 1)
|
|
230
295
|
assert.equal(env.next_step.context.retry_count, 1)
|
|
296
|
+
assert.equal(env.meta.command, 'postlex')
|
|
297
|
+
assert.ok(env.meta.exit_code > 0, 'retry envelope must mirror a non-zero exit code')
|
|
231
298
|
})
|
|
232
299
|
|
|
233
300
|
it('increments retry_count on each call', () => {
|
|
@@ -344,8 +411,16 @@ describe('normalize_data_rows', () => {
|
|
|
344
411
|
// ─── extract_data_array_from_search_output (v0.48.0) ──────────────────────
|
|
345
412
|
|
|
346
413
|
describe('extract_data_array_from_search_output', () => {
|
|
347
|
-
it('extracts data.results from the canonical envelope shape', () => {
|
|
348
|
-
|
|
414
|
+
it('extracts data.results from the canonical (spec 260525) envelope shape', () => {
|
|
415
|
+
// Canonical envelope: every key always an object, never null.
|
|
416
|
+
const env = {
|
|
417
|
+
ok: true,
|
|
418
|
+
data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] },
|
|
419
|
+
error: {},
|
|
420
|
+
next_step: {},
|
|
421
|
+
warnings: [],
|
|
422
|
+
meta: { command: 'search', exit_code: 0, envelope_schema_version: '1.0.0' },
|
|
423
|
+
}
|
|
349
424
|
const r = extract_data_array_from_search_output(env)
|
|
350
425
|
assert.equal(r.length, 1)
|
|
351
426
|
assert.equal(r[0].name, 'foo')
|
|
@@ -380,9 +455,12 @@ describe('parse_input with --search-output', () => {
|
|
|
380
455
|
it('extracts data.results from a full search envelope passed as the third argument', () => {
|
|
381
456
|
const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'top' }] })
|
|
382
457
|
const search_out = JSON.stringify({
|
|
458
|
+
ok: true,
|
|
383
459
|
data: { query: 'q', mode: 'semantic', results: [{ name: 'deploy-aws', workspace_slug: 'acme' }] },
|
|
384
|
-
error:
|
|
385
|
-
next_step:
|
|
460
|
+
error: {},
|
|
461
|
+
next_step: {},
|
|
462
|
+
warnings: [],
|
|
463
|
+
meta: { command: 'search', exit_code: 0, envelope_schema_version: '1.0.0' },
|
|
386
464
|
})
|
|
387
465
|
const r = parse_input(ranking, null, search_out)
|
|
388
466
|
assert.equal(r.parse_error, null)
|
package/src/commands/pull.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
package/src/commands/release.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
223
|
-
skill:
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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:
|
|
279
|
+
disk_version: target_info.disk_version,
|
|
260
280
|
requested_bump: target_info.requested_bump,
|
|
261
|
-
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: {
|
|
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
|
-
|
|
315
|
-
|
|
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 }
|