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.
- package/CHANGELOG.md +30 -0
- 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 +2 -2
- package/src/commands/init.js +5 -1
- package/src/commands/install.js +58 -32
- package/src/commands/postlex.js +53 -35
- package/src/commands/postlex.test.js +48 -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 +34 -22
- 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/utils/intent.js +22 -1
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Tests for the hand-rolled envelope validator. These should PASS on the
|
|
3
|
+
// branch even before Session 2 — the validator is pure logic over a closed
|
|
4
|
+
// schema, so the contract is testable in isolation. Failures here mean the
|
|
5
|
+
// validator itself is wrong, not that the CLI implementation lags.
|
|
6
|
+
|
|
7
|
+
const { describe, it } = require('node:test')
|
|
8
|
+
const assert = require('node:assert/strict')
|
|
9
|
+
const {
|
|
10
|
+
validate_envelope,
|
|
11
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
12
|
+
} = require('./envelope_validator')
|
|
13
|
+
|
|
14
|
+
const success_envelope = (overrides = {}) => ({
|
|
15
|
+
ok: true,
|
|
16
|
+
data: {},
|
|
17
|
+
error: {},
|
|
18
|
+
next_step: {},
|
|
19
|
+
warnings: [],
|
|
20
|
+
meta: {
|
|
21
|
+
command: 'whoami',
|
|
22
|
+
exit_code: 0,
|
|
23
|
+
envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
24
|
+
},
|
|
25
|
+
...overrides,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const error_envelope = (overrides = {}) => ({
|
|
29
|
+
ok: false,
|
|
30
|
+
data: {},
|
|
31
|
+
error: { code: 'USAGE_ERROR', message: 'bad args' },
|
|
32
|
+
next_step: {},
|
|
33
|
+
warnings: [],
|
|
34
|
+
meta: {
|
|
35
|
+
command: 'install',
|
|
36
|
+
exit_code: 2,
|
|
37
|
+
envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
38
|
+
},
|
|
39
|
+
...overrides,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('validate_envelope — happy paths', () => {
|
|
43
|
+
it('accepts a minimal success envelope', () => {
|
|
44
|
+
const r = validate_envelope(success_envelope())
|
|
45
|
+
assert.strictEqual(r.ok, true, JSON.stringify(r.errors))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('accepts a minimal error envelope', () => {
|
|
49
|
+
const r = validate_envelope(error_envelope())
|
|
50
|
+
assert.strictEqual(r.ok, true, JSON.stringify(r.errors))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('accepts a success-with-followup envelope', () => {
|
|
54
|
+
const env = success_envelope({
|
|
55
|
+
data: { skill: 'acme/regressed', drift_state: 'regression' },
|
|
56
|
+
next_step: {
|
|
57
|
+
kind: 'decision',
|
|
58
|
+
action: 'resolve_regression',
|
|
59
|
+
instructions: 'Pick a resolution.',
|
|
60
|
+
context: { options: ['restore_from_lock_version', 'abandon'] },
|
|
61
|
+
principal_authorization_required: true,
|
|
62
|
+
route_to_skill: 'happyskills-sync',
|
|
63
|
+
},
|
|
64
|
+
meta: {
|
|
65
|
+
command: 'reconcile',
|
|
66
|
+
exit_code: 0,
|
|
67
|
+
envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
68
|
+
cli_version: '1.0.0',
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
const r = validate_envelope(env)
|
|
72
|
+
assert.strictEqual(r.ok, true, JSON.stringify(r.errors))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('accepts a failure-with-recovery envelope', () => {
|
|
76
|
+
const env = error_envelope({
|
|
77
|
+
error: { code: 'DRIFT_DETECTED', message: 'Regression drift.' },
|
|
78
|
+
next_step: {
|
|
79
|
+
kind: 'recovery',
|
|
80
|
+
action: 'reconcile_first',
|
|
81
|
+
instructions: 'Reconcile before retrying.',
|
|
82
|
+
context: { commands: ['npx happyskills reconcile foo --json'] },
|
|
83
|
+
},
|
|
84
|
+
meta: {
|
|
85
|
+
command: 'release',
|
|
86
|
+
exit_code: 1,
|
|
87
|
+
envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
const r = validate_envelope(env)
|
|
91
|
+
assert.strictEqual(r.ok, true, JSON.stringify(r.errors))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('accepts non-empty warnings', () => {
|
|
95
|
+
const env = success_envelope({
|
|
96
|
+
warnings: [
|
|
97
|
+
{ code: 'integrity_skipped', message: 'Skipped integrity check.' },
|
|
98
|
+
],
|
|
99
|
+
})
|
|
100
|
+
const r = validate_envelope(env)
|
|
101
|
+
assert.strictEqual(r.ok, true, JSON.stringify(r.errors))
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('validate_envelope — top-level shape', () => {
|
|
106
|
+
it('rejects when not an object', () => {
|
|
107
|
+
assert.strictEqual(validate_envelope(null).ok, false)
|
|
108
|
+
assert.strictEqual(validate_envelope([]).ok, false)
|
|
109
|
+
assert.strictEqual(validate_envelope('hi').ok, false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('rejects when a required key is missing', () => {
|
|
113
|
+
for (const k of ['ok', 'data', 'error', 'next_step', 'warnings', 'meta']) {
|
|
114
|
+
const env = success_envelope()
|
|
115
|
+
delete env[k]
|
|
116
|
+
const r = validate_envelope(env)
|
|
117
|
+
assert.strictEqual(r.ok, false, `expected failure when ${k} is missing`)
|
|
118
|
+
assert.ok(r.errors.some(e => e.path === k), `expected an error mentioning ${k}`)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('rejects unexpected top-level keys', () => {
|
|
123
|
+
const env = success_envelope()
|
|
124
|
+
env.extra = 1
|
|
125
|
+
const r = validate_envelope(env)
|
|
126
|
+
assert.strictEqual(r.ok, false)
|
|
127
|
+
assert.ok(r.errors.some(e => e.path === 'extra'))
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('rejects data: null', () => {
|
|
131
|
+
const env = success_envelope({ data: null })
|
|
132
|
+
const r = validate_envelope(env)
|
|
133
|
+
assert.strictEqual(r.ok, false)
|
|
134
|
+
assert.ok(r.errors.some(e => e.path === 'data'))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('rejects data as a bare array', () => {
|
|
138
|
+
const env = success_envelope({ data: [{ x: 1 }] })
|
|
139
|
+
const r = validate_envelope(env)
|
|
140
|
+
assert.strictEqual(r.ok, false)
|
|
141
|
+
assert.ok(r.errors.some(e => e.path === 'data'))
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('rejects warnings as a non-array', () => {
|
|
145
|
+
const env = success_envelope({ warnings: 'oops' })
|
|
146
|
+
const r = validate_envelope(env)
|
|
147
|
+
assert.strictEqual(r.ok, false)
|
|
148
|
+
assert.ok(r.errors.some(e => e.path === 'warnings'))
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('validate_envelope — error slot', () => {
|
|
153
|
+
it('rejects error.code not in the closed enum', () => {
|
|
154
|
+
const env = error_envelope({
|
|
155
|
+
error: { code: 'NOT_A_REAL_CODE', message: 'invented' },
|
|
156
|
+
})
|
|
157
|
+
const r = validate_envelope(env)
|
|
158
|
+
assert.strictEqual(r.ok, false)
|
|
159
|
+
assert.ok(r.errors.some(e => e.path === 'error.code'))
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('rejects error with only code (missing message)', () => {
|
|
163
|
+
const env = error_envelope({
|
|
164
|
+
error: { code: 'USAGE_ERROR' },
|
|
165
|
+
})
|
|
166
|
+
const r = validate_envelope(env)
|
|
167
|
+
assert.strictEqual(r.ok, false)
|
|
168
|
+
assert.ok(r.errors.some(e => e.path === 'error'))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('rejects error with only message (missing code)', () => {
|
|
172
|
+
const env = error_envelope({
|
|
173
|
+
error: { message: 'orphan message' },
|
|
174
|
+
})
|
|
175
|
+
const r = validate_envelope(env)
|
|
176
|
+
assert.strictEqual(r.ok, false)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('accepts error.details when present and an object', () => {
|
|
180
|
+
const env = error_envelope({
|
|
181
|
+
error: {
|
|
182
|
+
code: 'RATE_LIMITED',
|
|
183
|
+
message: 'Rate limited.',
|
|
184
|
+
details: { retry_after_seconds: 30 },
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
const r = validate_envelope(env)
|
|
188
|
+
assert.strictEqual(r.ok, true, JSON.stringify(r.errors))
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('rejects error.details when not an object', () => {
|
|
192
|
+
const env = error_envelope({
|
|
193
|
+
error: { code: 'USAGE_ERROR', message: 'bad', details: 'not an object' },
|
|
194
|
+
})
|
|
195
|
+
const r = validate_envelope(env)
|
|
196
|
+
assert.strictEqual(r.ok, false)
|
|
197
|
+
assert.ok(r.errors.some(e => e.path === 'error.details'))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('accepts the UNKNOWN_CODE forward-compat escape hatch', () => {
|
|
201
|
+
const env = error_envelope({
|
|
202
|
+
error: {
|
|
203
|
+
code: 'UNKNOWN_CODE',
|
|
204
|
+
message: 'Unknown to this CLI version.',
|
|
205
|
+
details: { original_code: 'NEW_API_CODE_2027' },
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
const r = validate_envelope(env)
|
|
209
|
+
assert.strictEqual(r.ok, true, JSON.stringify(r.errors))
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('validate_envelope — next_step slot', () => {
|
|
214
|
+
it('rejects next_step.kind not in the closed enum', () => {
|
|
215
|
+
const env = success_envelope({
|
|
216
|
+
next_step: { kind: 'feedback_created', action: 'login', instructions: 'go' },
|
|
217
|
+
})
|
|
218
|
+
const r = validate_envelope(env)
|
|
219
|
+
assert.strictEqual(r.ok, false)
|
|
220
|
+
assert.ok(r.errors.some(e => e.path === 'next_step.kind'))
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('rejects next_step.action not in the closed enum', () => {
|
|
224
|
+
const env = success_envelope({
|
|
225
|
+
next_step: { kind: 'recovery', action: 'do_a_thing', instructions: 'go' },
|
|
226
|
+
})
|
|
227
|
+
const r = validate_envelope(env)
|
|
228
|
+
assert.strictEqual(r.ok, false)
|
|
229
|
+
assert.ok(r.errors.some(e => e.path === 'next_step.action'))
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('rejects next_step missing instructions when kind+action are present', () => {
|
|
233
|
+
const env = success_envelope({
|
|
234
|
+
next_step: { kind: 'recovery', action: 'login' },
|
|
235
|
+
})
|
|
236
|
+
const r = validate_envelope(env)
|
|
237
|
+
assert.strictEqual(r.ok, false)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('rejects mismatched kind/action pair', () => {
|
|
241
|
+
// `login` is kind=recovery; `decision` is wrong.
|
|
242
|
+
const env = success_envelope({
|
|
243
|
+
next_step: { kind: 'decision', action: 'login', instructions: 'go' },
|
|
244
|
+
})
|
|
245
|
+
const r = validate_envelope(env)
|
|
246
|
+
assert.strictEqual(r.ok, false)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('accepts the empty next_step {}', () => {
|
|
250
|
+
const r = validate_envelope(success_envelope({ next_step: {} }))
|
|
251
|
+
assert.strictEqual(r.ok, true)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('rejects stray sibling keys on an empty next_step', () => {
|
|
255
|
+
const env = success_envelope({ next_step: { not_a_real_key: 1 } })
|
|
256
|
+
const r = validate_envelope(env)
|
|
257
|
+
assert.strictEqual(r.ok, false)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('validate_envelope — meta slot', () => {
|
|
262
|
+
it('rejects when meta.command is missing', () => {
|
|
263
|
+
const env = success_envelope()
|
|
264
|
+
delete env.meta.command
|
|
265
|
+
const r = validate_envelope(env)
|
|
266
|
+
assert.strictEqual(r.ok, false)
|
|
267
|
+
assert.ok(r.errors.some(e => e.path === 'meta.command'))
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('rejects when meta.envelope_schema_version is missing', () => {
|
|
271
|
+
const env = success_envelope()
|
|
272
|
+
delete env.meta.envelope_schema_version
|
|
273
|
+
const r = validate_envelope(env)
|
|
274
|
+
assert.strictEqual(r.ok, false)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('rejects negative meta.exit_code', () => {
|
|
278
|
+
const env = success_envelope()
|
|
279
|
+
env.meta.exit_code = -1
|
|
280
|
+
const r = validate_envelope(env)
|
|
281
|
+
assert.strictEqual(r.ok, false)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe('validate_envelope — derived invariants', () => {
|
|
286
|
+
it('rejects ok=true paired with a populated error', () => {
|
|
287
|
+
const env = success_envelope({
|
|
288
|
+
error: { code: 'USAGE_ERROR', message: 'oops' },
|
|
289
|
+
})
|
|
290
|
+
const r = validate_envelope(env)
|
|
291
|
+
assert.strictEqual(r.ok, false)
|
|
292
|
+
assert.ok(r.errors.some(e => e.path === 'ok'))
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('rejects ok=false paired with an empty error', () => {
|
|
296
|
+
const env = error_envelope({
|
|
297
|
+
ok: false,
|
|
298
|
+
error: {},
|
|
299
|
+
})
|
|
300
|
+
const r = validate_envelope(env)
|
|
301
|
+
assert.strictEqual(r.ok, false)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('rejects ok=true with non-zero exit_code', () => {
|
|
305
|
+
const env = success_envelope()
|
|
306
|
+
env.meta.exit_code = 1
|
|
307
|
+
const r = validate_envelope(env)
|
|
308
|
+
assert.strictEqual(r.ok, false)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('rejects ok=false with exit_code=0', () => {
|
|
312
|
+
const env = error_envelope()
|
|
313
|
+
env.meta.exit_code = 0
|
|
314
|
+
const r = validate_envelope(env)
|
|
315
|
+
assert.strictEqual(r.ok, false)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('validate_envelope — warnings slot', () => {
|
|
320
|
+
it('rejects warning entries missing code or message', () => {
|
|
321
|
+
const env = success_envelope({ warnings: [{ message: 'no code' }] })
|
|
322
|
+
const r = validate_envelope(env)
|
|
323
|
+
assert.strictEqual(r.ok, false)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('warning codes are free-form (not the closed enum)', () => {
|
|
327
|
+
const env = success_envelope({
|
|
328
|
+
warnings: [{ code: 'whatever_we_want_here', message: 'free-form code' }],
|
|
329
|
+
})
|
|
330
|
+
const r = validate_envelope(env)
|
|
331
|
+
assert.strictEqual(r.ok, true, JSON.stringify(r.errors))
|
|
332
|
+
})
|
|
333
|
+
})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// The single chokepoint for CLI JSON output. Spec 260525-cli-default-json
|
|
3
|
+
// § 4 + § 8. Every command's --json path goes through emit_envelope; the
|
|
4
|
+
// helper builds the canonical six-key envelope, derives `ok`, mirrors the
|
|
5
|
+
// exit code into `meta`, validates against the closed schema (dev
|
|
6
|
+
// assertion only — never throws in prod), serialises with 2-space indent,
|
|
7
|
+
// writes to stdout, and (when called via emit_and_exit) calls process.exit.
|
|
8
|
+
|
|
9
|
+
const { is_json_mode } = require('../state')
|
|
10
|
+
const { CLI_VERSION } = require('../constants')
|
|
11
|
+
const { ENVELOPE_SCHEMA_VERSION, TOP_LEVEL_KEYS } = require('../schema/envelope_validator')
|
|
12
|
+
const { exit_code_for_error } = require('../constants/exit_codes')
|
|
13
|
+
const { validate_envelope } = require('../schema/envelope_validator')
|
|
14
|
+
|
|
15
|
+
// Module-level command context. Set once by index.js immediately after
|
|
16
|
+
// alias resolution, read by every emit_envelope call. Avoids threading the
|
|
17
|
+
// command name through every call site.
|
|
18
|
+
let command_name = '<unknown>'
|
|
19
|
+
let workspace = null
|
|
20
|
+
let request_id = null
|
|
21
|
+
const set_command = (name) => { command_name = name || '<unknown>' }
|
|
22
|
+
const set_workspace = (slug) => { workspace = slug || null }
|
|
23
|
+
const set_request_id = (id) => { request_id = id || null }
|
|
24
|
+
const get_command = () => command_name
|
|
25
|
+
|
|
26
|
+
const is_plain_object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
|
|
27
|
+
|
|
28
|
+
// Generic instructions per action — used to auto-fill the dependentRequired
|
|
29
|
+
// cluster when a command emits a partial next_step ({ action, context }
|
|
30
|
+
// without kind/instructions). Keeps per-command code terse and lets the
|
|
31
|
+
// envelope helper enforce the closed-schema contract centrally.
|
|
32
|
+
const DEFAULT_INSTRUCTIONS = {
|
|
33
|
+
login: 'Sign in to HappySkills, then retry.',
|
|
34
|
+
retry: 'Transient failure. Retry shortly.',
|
|
35
|
+
reconcile_first: 'Drift must be resolved before this operation. Run reconcile, follow its next_step, then retry.',
|
|
36
|
+
pull_rebase_first: 'The local skill has diverged from the registry. Pull with --rebase, resolve any rejected patches, then retry.',
|
|
37
|
+
fix_validation_errors: 'Fix the listed validation errors and re-run.',
|
|
38
|
+
provide_changelog: 'CHANGELOG.md is missing the target version entry. Add it and re-run.',
|
|
39
|
+
self_update: 'Upgrade the happyskills CLI and re-run.',
|
|
40
|
+
show_format: 'Format the input correctly and re-run.',
|
|
41
|
+
retry_rank: 'Re-emit the ranking matching the response schema and re-pipe.',
|
|
42
|
+
clarify_query: 'Ask the principal a clarifying question, then re-run search.',
|
|
43
|
+
resolve_regression: 'Pick a resolution and re-run with --apply <option>.',
|
|
44
|
+
resolve_missing_skill_json: 'Pick a resolution and re-run with --apply <option>.',
|
|
45
|
+
resolve_missing_dir: 'Pick a resolution and re-run with --apply <option>.',
|
|
46
|
+
resolve_conflicts: 'Pick a conflict-resolution strategy and re-run.',
|
|
47
|
+
resolve_patch_rejections: 'Resolve the rejected patches and continue, or abandon and restore.',
|
|
48
|
+
specify_workspace: 'Pick a workspace and re-run.',
|
|
49
|
+
specify_bump_type: 'Pick a bump type and re-run.',
|
|
50
|
+
resolve_bump_disagreement: 'Reconcile the --bump value with the disk version and re-run.',
|
|
51
|
+
pick_version: 'Pick an available version and re-run.',
|
|
52
|
+
confirm_discard_or_snapshot_first: 'Snapshot the local edits, or re-run with --force-discard-local to discard them.',
|
|
53
|
+
confirm_cascade: 'Confirm the cascading operation by re-running with --confirm.',
|
|
54
|
+
confirm_destructive:'Confirm the destructive operation by re-running with -y.',
|
|
55
|
+
pass_yes_flag: 'Re-run with -y to bypass the interactive prompt.',
|
|
56
|
+
rank_digests_inline:'Rank the candidates per the system prompt and pipe to postlex.',
|
|
57
|
+
present_to_user: 'Render the data to the principal — no further protocol action required.',
|
|
58
|
+
attach_screenshot: 'Optionally attach a screenshot via the feedback attach flow.',
|
|
59
|
+
install_first: 'Install the skill first, then retry.',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Look up the canonical kind for a given action — used by enrich_next_step.
|
|
63
|
+
const kind_for_action = (action) => {
|
|
64
|
+
const { ACTION_KIND } = require('../constants/next_step_actions')
|
|
65
|
+
return ACTION_KIND[action] || null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const enrich_next_step = (ns) => {
|
|
69
|
+
if (!is_plain_object(ns) || Object.keys(ns).length === 0) return ns || {}
|
|
70
|
+
if (!ns.action) return ns
|
|
71
|
+
const out = { ...ns }
|
|
72
|
+
if (!out.kind) out.kind = kind_for_action(out.action) || 'decision'
|
|
73
|
+
if (!out.instructions) out.instructions = DEFAULT_INSTRUCTIONS[out.action] || 'Follow the protocol step indicated by `action`.'
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Sentinel for unrecoverable schema violations during development. In
|
|
78
|
+
// production we still emit (lossy is better than crash); tests can opt in
|
|
79
|
+
// via HAPPYSKILLS_ENVELOPE_STRICT=1.
|
|
80
|
+
const STRICT = process.env.HAPPYSKILLS_ENVELOPE_STRICT === '1'
|
|
81
|
+
|
|
82
|
+
const build_envelope = ({
|
|
83
|
+
data = {},
|
|
84
|
+
error = null,
|
|
85
|
+
next_step = null,
|
|
86
|
+
warnings = [],
|
|
87
|
+
meta_overrides = {},
|
|
88
|
+
} = {}) => {
|
|
89
|
+
// `data` must always be an object on the wire (spec § 4.3). When a
|
|
90
|
+
// caller passes a bare array, wrap as `{ results: [...] }` to match the
|
|
91
|
+
// API helper's `normalise_data` and the spec's canonical array key.
|
|
92
|
+
// Other non-object scalars (string/number/boolean) fall back to `value`.
|
|
93
|
+
const safe_data = is_plain_object(data)
|
|
94
|
+
? data
|
|
95
|
+
: (data == null
|
|
96
|
+
? {}
|
|
97
|
+
: (Array.isArray(data) ? { results: data } : { value: data }))
|
|
98
|
+
const safe_error = is_plain_object(error) && Object.keys(error).length > 0 ? error : {}
|
|
99
|
+
const raw_next_step = is_plain_object(next_step) && Object.keys(next_step).length > 0 ? next_step : {}
|
|
100
|
+
const safe_next_step = enrich_next_step(raw_next_step)
|
|
101
|
+
const safe_warnings = Array.isArray(warnings) ? warnings : []
|
|
102
|
+
|
|
103
|
+
const ok = !('code' in safe_error)
|
|
104
|
+
const exit_code = ok
|
|
105
|
+
? 0
|
|
106
|
+
: (meta_overrides.exit_code != null ? meta_overrides.exit_code : exit_code_for_error(safe_error.code))
|
|
107
|
+
|
|
108
|
+
const meta = {
|
|
109
|
+
command: meta_overrides.command || command_name,
|
|
110
|
+
cli_version: CLI_VERSION,
|
|
111
|
+
exit_code,
|
|
112
|
+
envelope_schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
113
|
+
...(workspace ? { workspace } : {}),
|
|
114
|
+
...(request_id ? { request_id } : {}),
|
|
115
|
+
...(meta_overrides.workspace ? { workspace: meta_overrides.workspace } : {}),
|
|
116
|
+
...(meta_overrides.api_version ? { api_version: meta_overrides.api_version } : {}),
|
|
117
|
+
...(meta_overrides.min_cli_version ? { min_cli_version: meta_overrides.min_cli_version } : {}),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const envelope = {
|
|
121
|
+
ok,
|
|
122
|
+
data: safe_data,
|
|
123
|
+
error: safe_error,
|
|
124
|
+
next_step: safe_next_step,
|
|
125
|
+
warnings: safe_warnings,
|
|
126
|
+
meta,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (STRICT) {
|
|
130
|
+
const { ok: valid, errors } = validate_envelope(envelope)
|
|
131
|
+
if (!valid) {
|
|
132
|
+
process.stderr.write(`[envelope] schema violation:\n${errors.map(e => ' - ' + (e.path || '<root>') + ': ' + e.message).join('\n')}\n`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return envelope
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Print the envelope to stdout. Uses console.log so tests stubbing it
|
|
140
|
+
// observe the output, and so it composes with NO_COLOR / TTY conventions
|
|
141
|
+
// the rest of the UI layer relies on.
|
|
142
|
+
const emit_envelope = (input) => {
|
|
143
|
+
const env = build_envelope(input)
|
|
144
|
+
console.log(JSON.stringify(env, null, 2))
|
|
145
|
+
return env
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Convenience: build the envelope, emit it, then exit with the mirrored
|
|
149
|
+
// exit code. Used by the central exit_with_error path.
|
|
150
|
+
const emit_and_exit = (input) => {
|
|
151
|
+
const env = emit_envelope(input)
|
|
152
|
+
process.exit(env.meta.exit_code)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// JSON-mode guard helper for commands that want to short-circuit non-JSON
|
|
156
|
+
// rendering when the user passed --json. Kept here so command modules can
|
|
157
|
+
// import a single helper.
|
|
158
|
+
const json_active = () => is_json_mode()
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
emit_envelope,
|
|
162
|
+
emit_and_exit,
|
|
163
|
+
build_envelope,
|
|
164
|
+
set_command,
|
|
165
|
+
set_workspace,
|
|
166
|
+
set_request_id,
|
|
167
|
+
get_command,
|
|
168
|
+
json_active,
|
|
169
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
170
|
+
TOP_LEVEL_KEYS,
|
|
171
|
+
}
|
package/src/ui/output.js
CHANGED
|
@@ -108,8 +108,72 @@ const summarize_warnings = (warnings, skill_name) => {
|
|
|
108
108
|
return `${warnings.length} warning${warnings.length === 1 ? '' : 's'}: ${parts.join(', ')} — run 'happyskills validate ${skill_name}' for details`
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
// print_json now routes through the central envelope chokepoint when given
|
|
112
|
+
// an envelope-shaped input ({ data }, { error }, { next_step }, or a full
|
|
113
|
+
// six-key envelope). This retrofits every legacy `print_json({ data: ... })`
|
|
114
|
+
// call site to emit the new schema without changing the call. Raw inputs
|
|
115
|
+
// (e.g. a bare array) fall through to the legacy JSON.stringify path so
|
|
116
|
+
// scripts/validators that print free-form JSON still work.
|
|
117
|
+
//
|
|
118
|
+
// Audit follow-up #6 — dev-mode strict assertion. When
|
|
119
|
+
// HAPPYSKILLS_ENVELOPE_STRICT=1 (or NODE_ENV=test), any top-level key
|
|
120
|
+
// outside the six-key envelope is written to stderr as a warning. This
|
|
121
|
+
// catches the `uninstall.js:79` / `install.js:244` class bug — callers
|
|
122
|
+
// passing a sibling field (`errors:` plural, `attachments:`, etc.) that
|
|
123
|
+
// the retrofit would silently drop. Production stays quiet.
|
|
124
|
+
const ENVELOPE_KEYS = new Set(['ok', 'data', 'error', 'next_step', 'warnings', 'meta'])
|
|
125
|
+
const print_json = (input) => {
|
|
126
|
+
const is_obj = input !== null && typeof input === 'object' && !Array.isArray(input)
|
|
127
|
+
if (!is_obj) {
|
|
128
|
+
console.log(JSON.stringify(input, null, 2))
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
const has_envelope_key =
|
|
132
|
+
'data' in input || 'error' in input || 'next_step' in input ||
|
|
133
|
+
'warnings' in input || 'meta' in input || 'ok' in input
|
|
134
|
+
if (!has_envelope_key) {
|
|
135
|
+
console.log(JSON.stringify(input, null, 2))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// Strict-mode warning. Dropped keys are not just noise — they are
|
|
139
|
+
// per-row failures, attachment lists, or other domain payload the
|
|
140
|
+
// caller meant to surface and the retrofit silently swallows.
|
|
141
|
+
// Opt in via HAPPYSKILLS_ENVELOPE_STRICT=1; tests turn it on globally
|
|
142
|
+
// (see cli/src/integration/* spawn helpers).
|
|
143
|
+
if (process.env.HAPPYSKILLS_ENVELOPE_STRICT === '1') {
|
|
144
|
+
const dropped = Object.keys(input).filter(k => !ENVELOPE_KEYS.has(k))
|
|
145
|
+
if (dropped.length > 0) {
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
`[print_json] retrofit ignored top-level key(s): ${dropped.join(', ')}. ` +
|
|
148
|
+
`Fold them into data.* or into the envelope per spec § 4.\n`
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const { emit_envelope } = require('./envelope')
|
|
153
|
+
const { error } = input
|
|
154
|
+
// Legacy error shape carried { error: { code, message, exit_code } }. The
|
|
155
|
+
// new envelope keeps code+message inside error and moves exit_code to
|
|
156
|
+
// meta. Strip exit_code here so the schema-validator stays happy.
|
|
157
|
+
let meta_overrides = {}
|
|
158
|
+
if (error && typeof error === 'object' && 'exit_code' in error) {
|
|
159
|
+
meta_overrides = { exit_code: error.exit_code }
|
|
160
|
+
const cleaned = { ...error }
|
|
161
|
+
delete cleaned.exit_code
|
|
162
|
+
emit_envelope({
|
|
163
|
+
data: input.data || {},
|
|
164
|
+
error: cleaned,
|
|
165
|
+
next_step: input.next_step,
|
|
166
|
+
warnings: input.warnings,
|
|
167
|
+
meta_overrides,
|
|
168
|
+
})
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
emit_envelope({
|
|
172
|
+
data: input.data,
|
|
173
|
+
error: input.error,
|
|
174
|
+
next_step: input.next_step,
|
|
175
|
+
warnings: input.warnings,
|
|
176
|
+
})
|
|
113
177
|
}
|
|
114
178
|
|
|
115
179
|
const print_label = (label, value) => {
|