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
@@ -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
- const print_json = (data) => {
112
- console.log(JSON.stringify(data, null, 2))
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) => {