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,167 @@
1
+ 'use strict'
2
+ /**
3
+ * Integration tests for `happyskills schema --json` — spec
4
+ * 260525-cli-default-json § 10. The schema command emits a machine-readable
5
+ * description of the entire CLI surface so a generic agent can learn every
6
+ * command, every error code, and every next_step.action in one call.
7
+ *
8
+ * Coverage:
9
+ * - Envelope conforms to the closed schema (validated via parse_envelope).
10
+ * - data.envelope_schema_version + data.envelope_schema_uri present.
11
+ * - data.commands carries one entry per known CLI command, each with
12
+ * name, audience, input, output, errors[].
13
+ * - data.error_codes mirrors the closed enum from constants/error_codes.js.
14
+ * - data.next_step_actions mirrors the closed action list.
15
+ * - data.next_step_kinds is the six-element closed enum.
16
+ * - --text mode produces a human listing (no JSON envelope).
17
+ */
18
+ const { describe, it } = require('node:test')
19
+ const assert = require('node:assert/strict')
20
+ const { spawnSync } = require('child_process')
21
+ const path = require('path')
22
+ const {
23
+ parse_envelope,
24
+ assert_success_envelope,
25
+ } = require('../schema/envelope_test_helpers')
26
+ const { ERROR_CODE_LIST } = require('../constants/error_codes')
27
+ const {
28
+ NEXT_STEP_ACTION_LIST,
29
+ NEXT_STEP_KINDS,
30
+ } = require('../constants/next_step_actions')
31
+ const { COMMANDS } = require('../constants')
32
+
33
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
34
+ const NODE = process.execPath
35
+
36
+ const run = (args = [], env_override = {}) => {
37
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
38
+ const final_args = has_mode_flag ? args : [...args, '--text']
39
+ const result = spawnSync(NODE, [CLI, ...final_args], {
40
+ env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...env_override },
41
+ encoding: 'utf-8',
42
+ timeout: 10000,
43
+ })
44
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
45
+ }
46
+
47
+ describe('happyskills schema --json', () => {
48
+ it('emits a success envelope with command=schema', () => {
49
+ const { stdout, code } = run(['schema', '--json'])
50
+ assert.strictEqual(code, 0)
51
+ const env = parse_envelope(stdout, 'schema --json')
52
+ assert_success_envelope(env, 'schema --json')
53
+ assert.strictEqual(env.meta.command, 'schema')
54
+ assert.strictEqual(env.meta.envelope_schema_version, '1.0.0')
55
+ })
56
+
57
+ it('data.envelope_schema_version + envelope_schema_uri are exposed', () => {
58
+ const { stdout } = run(['schema', '--json'])
59
+ const env = parse_envelope(stdout, 'schema --json envelope meta')
60
+ assert.strictEqual(env.data.envelope_schema_version, '1.0.0')
61
+ assert.strictEqual(env.data.envelope_schema_uri, 'https://schemas.happyskills.dev/envelope/v1.json')
62
+ assert.ok(typeof env.data.cli_version === 'string')
63
+ })
64
+
65
+ it('data.commands covers every known CLI command (one entry each)', () => {
66
+ const { stdout } = run(['schema', '--json'])
67
+ const env = parse_envelope(stdout, 'schema --json commands')
68
+ assert.ok(Array.isArray(env.data.commands), 'data.commands must be an array')
69
+ assert.ok(env.data.commands.length > 0)
70
+ const names = new Set(env.data.commands.map(c => c.name))
71
+ // schema itself is a command — it must be in the registry.
72
+ assert.ok(names.has('schema'), 'schema must list itself')
73
+ // Spot-check that the central commands are present.
74
+ for (const cmd of ['install', 'uninstall', 'search', 'release', 'reconcile', 'whoami']) {
75
+ assert.ok(names.has(cmd), `commands must include ${cmd}`)
76
+ }
77
+ // And every name in COMMANDS appears in the schema (modulo aliases).
78
+ for (const cmd of COMMANDS) {
79
+ assert.ok(names.has(cmd), `every CLI command must export schema metadata: missing ${cmd}`)
80
+ }
81
+ })
82
+
83
+ it('each command schema entry carries the required fields', () => {
84
+ const { stdout } = run(['schema', '--json'])
85
+ const env = parse_envelope(stdout, 'schema --json command shape')
86
+ for (const cmd of env.data.commands) {
87
+ assert.ok(typeof cmd.name === 'string' && cmd.name.length > 0, `command.name must be set: ${JSON.stringify(cmd)}`)
88
+ assert.ok(['consumer', 'author', 'account', 'system'].includes(cmd.audience), `command.audience must be a known group, got ${cmd.audience} for ${cmd.name}`)
89
+ assert.ok(typeof cmd.purpose === 'string' && cmd.purpose.length > 0, `command.purpose must be set for ${cmd.name}`)
90
+ assert.ok(typeof cmd.mutation === 'boolean', `command.mutation must be a boolean for ${cmd.name}`)
91
+ assert.ok(cmd.input && typeof cmd.input === 'object', `command.input must be an object for ${cmd.name}`)
92
+ assert.ok(cmd.output && typeof cmd.output === 'object', `command.output must be an object for ${cmd.name}`)
93
+ assert.ok(Array.isArray(cmd.errors), `command.errors must be an array for ${cmd.name}`)
94
+ }
95
+ })
96
+
97
+ it('every command.errors[].code is in the closed enum, and the kind/action pair is valid', () => {
98
+ const { stdout } = run(['schema', '--json'])
99
+ const env = parse_envelope(stdout, 'schema --json error refs')
100
+ const error_set = new Set(ERROR_CODE_LIST)
101
+ const action_set = new Set(NEXT_STEP_ACTION_LIST)
102
+ const kind_set = new Set(NEXT_STEP_KINDS)
103
+ for (const cmd of env.data.commands) {
104
+ for (const e of cmd.errors) {
105
+ assert.ok(error_set.has(e.code), `${cmd.name}.errors[].code "${e.code}" not in closed enum`)
106
+ if (e.next_step) {
107
+ assert.ok(kind_set.has(e.next_step.kind), `${cmd.name}: kind "${e.next_step.kind}" not in closed enum`)
108
+ assert.ok(action_set.has(e.next_step.action), `${cmd.name}: action "${e.next_step.action}" not in closed enum`)
109
+ }
110
+ }
111
+ }
112
+ })
113
+
114
+ it('data.error_codes mirrors the closed error-code enum', () => {
115
+ const { stdout } = run(['schema', '--json'])
116
+ const env = parse_envelope(stdout, 'schema --json error_codes')
117
+ assert.ok(Array.isArray(env.data.error_codes))
118
+ // The schema output may carry richer per-code metadata; we only assert
119
+ // the closed-set membership.
120
+ const codes = new Set(env.data.error_codes.map(e => typeof e === 'string' ? e : e.code))
121
+ for (const c of ERROR_CODE_LIST) {
122
+ assert.ok(codes.has(c), `data.error_codes must include ${c}`)
123
+ }
124
+ })
125
+
126
+ it('data.next_step_actions mirrors the closed action enum (with kind annotation)', () => {
127
+ const { stdout } = run(['schema', '--json'])
128
+ const env = parse_envelope(stdout, 'schema --json actions')
129
+ assert.ok(Array.isArray(env.data.next_step_actions))
130
+ const action_names = new Set(env.data.next_step_actions.map(a => typeof a === 'string' ? a : a.action))
131
+ for (const a of NEXT_STEP_ACTION_LIST) {
132
+ assert.ok(action_names.has(a), `data.next_step_actions must include ${a}`)
133
+ }
134
+ // Each entry should annotate which kind it belongs to (spec § 7.1).
135
+ for (const entry of env.data.next_step_actions) {
136
+ if (typeof entry === 'object' && entry !== null) {
137
+ assert.ok(NEXT_STEP_KINDS.includes(entry.kind), `${entry.action}: kind "${entry.kind}" not in closed enum`)
138
+ }
139
+ }
140
+ })
141
+
142
+ it('data.next_step_kinds is the six-element closed enum, in canonical order', () => {
143
+ const { stdout } = run(['schema', '--json'])
144
+ const env = parse_envelope(stdout, 'schema --json kinds')
145
+ assert.deepStrictEqual(
146
+ env.data.next_step_kinds,
147
+ ['recovery', 'clarification', 'decision', 'confirmation', 'continuation', 'routing']
148
+ )
149
+ })
150
+
151
+ it('--text mode emits a human-readable listing (no JSON envelope)', () => {
152
+ const { stdout, code } = run(['schema', '--text'])
153
+ assert.strictEqual(code, 0)
154
+ // Not an envelope — must not start with `{`.
155
+ assert.ok(!stdout.trimStart().startsWith('{'), 'schema --text must not emit JSON')
156
+ // At minimum, the listing names a recognisable section header and at
157
+ // least one core command.
158
+ assert.match(stdout, /install/, '--text listing should mention `install`')
159
+ })
160
+
161
+ it('--help exits 0 and describes the schema command', () => {
162
+ const { stdout, code } = run(['schema', '--help'])
163
+ assert.strictEqual(code, 0)
164
+ assert.match(stdout, /schema/i)
165
+ assert.match(stdout, /--json/)
166
+ })
167
+ })
@@ -0,0 +1,73 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.happyskills.dev/envelope/v1.json",
4
+ "title": "HappySkills Response Envelope",
5
+ "description": "The canonical CLI response envelope. Spec 260525-cli-default-json § 4.",
6
+ "type": "object",
7
+ "required": ["ok", "data", "error", "next_step", "warnings", "meta"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "ok": { "type": "boolean" },
11
+ "data": { "type": "object" },
12
+ "error": { "$ref": "#/$defs/Error" },
13
+ "next_step": { "$ref": "#/$defs/NextStep" },
14
+ "warnings": { "type": "array", "items": { "$ref": "#/$defs/Warning" } },
15
+ "meta": { "$ref": "#/$defs/Meta" }
16
+ },
17
+ "$defs": {
18
+ "Error": {
19
+ "type": "object",
20
+ "properties": {
21
+ "code": { "type": "string" },
22
+ "message": { "type": "string", "minLength": 1 },
23
+ "details": { "type": "object" }
24
+ },
25
+ "dependentRequired": {
26
+ "code": ["message"],
27
+ "message": ["code"]
28
+ },
29
+ "additionalProperties": true
30
+ },
31
+ "NextStep": {
32
+ "type": "object",
33
+ "properties": {
34
+ "kind": { "type": "string", "enum": ["recovery", "clarification", "decision", "confirmation", "continuation", "routing"] },
35
+ "action": { "type": "string" },
36
+ "instructions": { "type": "string", "minLength": 1 },
37
+ "context": { "type": "object" },
38
+ "principal_authorization_required": { "type": "boolean" },
39
+ "route_to_skill": { "type": "string" }
40
+ },
41
+ "dependentRequired": {
42
+ "kind": ["action", "instructions"],
43
+ "action": ["kind", "instructions"],
44
+ "instructions": ["kind", "action"]
45
+ },
46
+ "additionalProperties": false
47
+ },
48
+ "Warning": {
49
+ "type": "object",
50
+ "required": ["code", "message"],
51
+ "properties": {
52
+ "code": { "type": "string" },
53
+ "message": { "type": "string" }
54
+ },
55
+ "additionalProperties": false
56
+ },
57
+ "Meta": {
58
+ "type": "object",
59
+ "required": ["command", "exit_code", "envelope_schema_version"],
60
+ "properties": {
61
+ "command": { "type": "string" },
62
+ "cli_version": { "type": "string" },
63
+ "api_version": { "type": "string" },
64
+ "exit_code": { "type": "integer", "minimum": 0 },
65
+ "envelope_schema_version": { "type": "string" },
66
+ "workspace": { "type": "string" },
67
+ "request_id": { "type": "string" },
68
+ "min_cli_version": { "type": "string" }
69
+ },
70
+ "additionalProperties": false
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,94 @@
1
+ 'use strict'
2
+ // Shared assertions for the CLI envelope. Every --json CLI output produced
3
+ // by the integration test suite passes through assert_envelope. The pure-JS
4
+ // unit tests reuse the same primitives so the rules stay in one place.
5
+ //
6
+ // All helpers throw node:assert AssertionError on failure — they are not
7
+ // catch_errors-wrapped.
8
+
9
+ const assert = require('node:assert/strict')
10
+ const { validate_envelope } = require('./envelope_validator')
11
+
12
+ const format_errors = (errors) =>
13
+ errors.map(e => ` - ${e.path || '<root>'}: ${e.message}`).join('\n')
14
+
15
+ // Parse stdout as JSON and validate as an envelope. Throws on either step.
16
+ const parse_envelope = (stdout, label = '') => {
17
+ let parsed
18
+ try {
19
+ parsed = JSON.parse(stdout)
20
+ } catch (e) {
21
+ assert.fail(`${label ? label + ': ' : ''}stdout is not valid JSON:\n${stdout}`)
22
+ }
23
+ const { ok, errors } = validate_envelope(parsed)
24
+ if (!ok) {
25
+ assert.fail(
26
+ `${label ? label + ': ' : ''}envelope failed validation:\n${format_errors(errors)}\n` +
27
+ `envelope:\n${JSON.stringify(parsed, null, 2)}`
28
+ )
29
+ }
30
+ return parsed
31
+ }
32
+
33
+ // Validate an already-parsed envelope object.
34
+ const assert_envelope = (env, label = '') => {
35
+ const { ok, errors } = validate_envelope(env)
36
+ if (!ok) {
37
+ assert.fail(
38
+ `${label ? label + ': ' : ''}envelope failed validation:\n${format_errors(errors)}\n` +
39
+ `envelope:\n${JSON.stringify(env, null, 2)}`
40
+ )
41
+ }
42
+ return env
43
+ }
44
+
45
+ const assert_success_envelope = (env, label = '') => {
46
+ assert_envelope(env, label)
47
+ assert.strictEqual(env.ok, true, `${label}: expected ok=true, got ${env.ok}`)
48
+ assert.deepStrictEqual(env.error, {}, `${label}: success envelope must have error={}`)
49
+ assert.strictEqual(env.meta.exit_code, 0, `${label}: success must have meta.exit_code=0`)
50
+ return env
51
+ }
52
+
53
+ const assert_error_envelope = (env, expected_code, label = '') => {
54
+ assert_envelope(env, label)
55
+ assert.strictEqual(env.ok, false, `${label}: expected ok=false, got ${env.ok}`)
56
+ assert.ok(env.error && env.error.code, `${label}: error envelope must have error.code`)
57
+ if (expected_code) {
58
+ assert.strictEqual(
59
+ env.error.code,
60
+ expected_code,
61
+ `${label}: expected error.code=${expected_code}, got ${env.error.code}`
62
+ )
63
+ }
64
+ assert.notStrictEqual(env.meta.exit_code, 0, `${label}: error envelope must not have exit_code=0`)
65
+ return env
66
+ }
67
+
68
+ const assert_has_next_step = (env, expected_action, label = '') => {
69
+ assert_envelope(env, label)
70
+ assert.ok(env.next_step && env.next_step.action, `${label}: next_step must be populated`)
71
+ if (expected_action) {
72
+ assert.strictEqual(
73
+ env.next_step.action,
74
+ expected_action,
75
+ `${label}: expected next_step.action=${expected_action}, got ${env.next_step.action}`
76
+ )
77
+ }
78
+ return env
79
+ }
80
+
81
+ const assert_no_next_step = (env, label = '') => {
82
+ assert_envelope(env, label)
83
+ assert.deepStrictEqual(env.next_step, {}, `${label}: expected next_step={}`)
84
+ return env
85
+ }
86
+
87
+ module.exports = {
88
+ parse_envelope,
89
+ assert_envelope,
90
+ assert_success_envelope,
91
+ assert_error_envelope,
92
+ assert_has_next_step,
93
+ assert_no_next_step,
94
+ }
@@ -0,0 +1,239 @@
1
+ 'use strict'
2
+ // Hand-rolled envelope validator. Per spec 260525-cli-default-json § 13
3
+ // Session 1b: zero new npm runtime deps. Enforces only the load-bearing
4
+ // invariants — top-level shape, closed enums, dependentRequired clusters,
5
+ // and the derived `ok === (error.code === undefined)` rule. Domain extras
6
+ // inside data / error / next_step.context are accepted permissively
7
+ // (additionalProperties: true) so per-command payloads don't need bespoke
8
+ // schemas.
9
+ //
10
+ // The validator returns a structured report; it never throws on invalid
11
+ // input. Callers (test helpers, exit_with_error in Session 2) decide
12
+ // whether to fail loudly or surface warnings.
13
+
14
+ const { ERROR_CODE_SET } = require('../constants/error_codes')
15
+ const {
16
+ NEXT_STEP_ACTION_SET,
17
+ NEXT_STEP_KIND_SET,
18
+ ACTION_KIND,
19
+ } = require('../constants/next_step_actions')
20
+
21
+ const ENVELOPE_SCHEMA_VERSION = '1.0.0'
22
+
23
+ const TOP_LEVEL_KEYS = ['ok', 'data', 'error', 'next_step', 'warnings', 'meta']
24
+ const TOP_LEVEL_KEY_SET = new Set(TOP_LEVEL_KEYS)
25
+ const ERROR_REQUIRED_KEYS = ['code', 'message']
26
+ const NEXT_STEP_REQUIRED_KEYS = ['kind', 'action', 'instructions']
27
+
28
+ const is_plain_object = (x) =>
29
+ x !== null && typeof x === 'object' && !Array.isArray(x)
30
+
31
+ const push = (errors, path, msg) => {
32
+ errors.push({ path, message: msg })
33
+ }
34
+
35
+ const validate_error_slot = (error, errors) => {
36
+ if (!is_plain_object(error)) {
37
+ push(errors, 'error', 'must be an object (use {} when no error)')
38
+ return
39
+ }
40
+ const present = ERROR_REQUIRED_KEYS.filter(k => k in error)
41
+ if (present.length === 0) return // empty form is valid
42
+ // dependentRequired: code <-> message travel together.
43
+ if (present.length !== ERROR_REQUIRED_KEYS.length) {
44
+ const missing = ERROR_REQUIRED_KEYS.filter(k => !(k in error))
45
+ push(
46
+ errors,
47
+ 'error',
48
+ `populated error must include both code and message (missing: ${missing.join(', ')})`
49
+ )
50
+ }
51
+ if ('code' in error) {
52
+ if (typeof error.code !== 'string') {
53
+ push(errors, 'error.code', 'must be a string')
54
+ } else if (!ERROR_CODE_SET.has(error.code)) {
55
+ push(errors, 'error.code', `not in the closed enum: ${error.code}`)
56
+ }
57
+ }
58
+ if ('message' in error) {
59
+ if (typeof error.message !== 'string' || error.message.length === 0) {
60
+ push(errors, 'error.message', 'must be a non-empty string')
61
+ }
62
+ }
63
+ if ('details' in error && !is_plain_object(error.details)) {
64
+ push(errors, 'error.details', 'must be an object when present')
65
+ }
66
+ }
67
+
68
+ const validate_next_step_slot = (ns, errors) => {
69
+ if (!is_plain_object(ns)) {
70
+ push(errors, 'next_step', 'must be an object (use {} when no follow-up)')
71
+ return
72
+ }
73
+ const present = NEXT_STEP_REQUIRED_KEYS.filter(k => k in ns)
74
+ if (present.length === 0) {
75
+ // Empty form valid, but stray sibling keys aren't.
76
+ for (const k of Object.keys(ns)) {
77
+ if (!['principal_authorization_required', 'route_to_skill', 'context'].includes(k)) {
78
+ push(errors, `next_step.${k}`, 'unexpected key in empty next_step (only the three-key cluster is allowed when populated)')
79
+ }
80
+ }
81
+ return
82
+ }
83
+ if (present.length !== NEXT_STEP_REQUIRED_KEYS.length) {
84
+ const missing = NEXT_STEP_REQUIRED_KEYS.filter(k => !(k in ns))
85
+ push(
86
+ errors,
87
+ 'next_step',
88
+ `populated next_step must include kind, action, instructions (missing: ${missing.join(', ')})`
89
+ )
90
+ }
91
+ if ('kind' in ns) {
92
+ if (typeof ns.kind !== 'string') {
93
+ push(errors, 'next_step.kind', 'must be a string')
94
+ } else if (!NEXT_STEP_KIND_SET.has(ns.kind)) {
95
+ push(errors, 'next_step.kind', `not in the closed enum: ${ns.kind}`)
96
+ }
97
+ }
98
+ if ('action' in ns) {
99
+ if (typeof ns.action !== 'string') {
100
+ push(errors, 'next_step.action', 'must be a string')
101
+ } else if (!NEXT_STEP_ACTION_SET.has(ns.action)) {
102
+ push(errors, 'next_step.action', `not in the closed enum: ${ns.action}`)
103
+ }
104
+ }
105
+ if ('kind' in ns && 'action' in ns && NEXT_STEP_ACTION_SET.has(ns.action)) {
106
+ const expected_kind = ACTION_KIND[ns.action]
107
+ if (expected_kind && expected_kind !== ns.kind) {
108
+ push(
109
+ errors,
110
+ 'next_step',
111
+ `action "${ns.action}" belongs to kind "${expected_kind}", not "${ns.kind}"`
112
+ )
113
+ }
114
+ }
115
+ if ('instructions' in ns) {
116
+ if (typeof ns.instructions !== 'string' || ns.instructions.length === 0) {
117
+ push(errors, 'next_step.instructions', 'must be a non-empty string')
118
+ }
119
+ }
120
+ if ('context' in ns && !is_plain_object(ns.context)) {
121
+ push(errors, 'next_step.context', 'must be an object when present')
122
+ }
123
+ if ('principal_authorization_required' in ns
124
+ && typeof ns.principal_authorization_required !== 'boolean') {
125
+ push(errors, 'next_step.principal_authorization_required', 'must be a boolean')
126
+ }
127
+ if ('route_to_skill' in ns && typeof ns.route_to_skill !== 'string') {
128
+ push(errors, 'next_step.route_to_skill', 'must be a string')
129
+ }
130
+ }
131
+
132
+ const validate_warnings_slot = (warnings, errors) => {
133
+ if (!Array.isArray(warnings)) {
134
+ push(errors, 'warnings', 'must be an array (use [] when none)')
135
+ return
136
+ }
137
+ warnings.forEach((w, i) => {
138
+ if (!is_plain_object(w)) {
139
+ push(errors, `warnings[${i}]`, 'must be an object')
140
+ return
141
+ }
142
+ if (typeof w.code !== 'string' || w.code.length === 0) {
143
+ push(errors, `warnings[${i}].code`, 'must be a non-empty string')
144
+ }
145
+ if (typeof w.message !== 'string' || w.message.length === 0) {
146
+ push(errors, `warnings[${i}].message`, 'must be a non-empty string')
147
+ }
148
+ })
149
+ }
150
+
151
+ const validate_meta_slot = (meta, errors) => {
152
+ if (!is_plain_object(meta)) {
153
+ push(errors, 'meta', 'must be an object')
154
+ return
155
+ }
156
+ if (typeof meta.command !== 'string' || meta.command.length === 0) {
157
+ push(errors, 'meta.command', 'must be a non-empty string')
158
+ }
159
+ if (!Number.isInteger(meta.exit_code) || meta.exit_code < 0) {
160
+ push(errors, 'meta.exit_code', 'must be a non-negative integer')
161
+ }
162
+ if (typeof meta.envelope_schema_version !== 'string'
163
+ || meta.envelope_schema_version.length === 0) {
164
+ push(errors, 'meta.envelope_schema_version', 'must be a non-empty string')
165
+ }
166
+ if ('cli_version' in meta && typeof meta.cli_version !== 'string') {
167
+ push(errors, 'meta.cli_version', 'must be a string when present')
168
+ }
169
+ if ('api_version' in meta && typeof meta.api_version !== 'string') {
170
+ push(errors, 'meta.api_version', 'must be a string when present')
171
+ }
172
+ if ('workspace' in meta && typeof meta.workspace !== 'string') {
173
+ push(errors, 'meta.workspace', 'must be a string when present')
174
+ }
175
+ if ('request_id' in meta && typeof meta.request_id !== 'string') {
176
+ push(errors, 'meta.request_id', 'must be a string when present')
177
+ }
178
+ if ('min_cli_version' in meta && typeof meta.min_cli_version !== 'string') {
179
+ push(errors, 'meta.min_cli_version', 'must be a string when present')
180
+ }
181
+ }
182
+
183
+ const validate_envelope = (env) => {
184
+ const errors = []
185
+
186
+ if (!is_plain_object(env)) {
187
+ return { ok: false, errors: [{ path: '', message: 'envelope must be an object' }] }
188
+ }
189
+
190
+ // Top-level keys: exactly six, no extras.
191
+ for (const k of TOP_LEVEL_KEYS) {
192
+ if (!(k in env)) push(errors, k, 'top-level key is required')
193
+ }
194
+ for (const k of Object.keys(env)) {
195
+ if (!TOP_LEVEL_KEY_SET.has(k)) {
196
+ push(errors, k, 'unexpected top-level key (envelope keys are closed)')
197
+ }
198
+ }
199
+
200
+ // Per-slot types.
201
+ if ('ok' in env && typeof env.ok !== 'boolean') {
202
+ push(errors, 'ok', 'must be a boolean')
203
+ }
204
+ if ('data' in env && !is_plain_object(env.data)) {
205
+ push(errors, 'data', 'must be an object (never null, never array)')
206
+ }
207
+ if ('error' in env) validate_error_slot(env.error, errors)
208
+ if ('next_step' in env) validate_next_step_slot(env.next_step, errors)
209
+ if ('warnings' in env) validate_warnings_slot(env.warnings, errors)
210
+ if ('meta' in env) validate_meta_slot(env.meta, errors)
211
+
212
+ // Derived invariant: ok === (error.code === undefined).
213
+ if (typeof env.ok === 'boolean' && is_plain_object(env.error)) {
214
+ const has_code = 'code' in env.error
215
+ if (env.ok === has_code) {
216
+ push(
217
+ errors,
218
+ 'ok',
219
+ `derived invariant violated: ok=${env.ok} but error.code ${has_code ? 'is' : 'is not'} present`
220
+ )
221
+ }
222
+ }
223
+
224
+ // meta.exit_code mirror: when ok is true, exit_code must be 0.
225
+ if (env.ok === true && is_plain_object(env.meta) && env.meta.exit_code !== 0) {
226
+ push(errors, 'meta.exit_code', `must be 0 when ok=true, got ${env.meta.exit_code}`)
227
+ }
228
+ if (env.ok === false && is_plain_object(env.meta) && env.meta.exit_code === 0) {
229
+ push(errors, 'meta.exit_code', 'must be non-zero when ok=false')
230
+ }
231
+
232
+ return { ok: errors.length === 0, errors }
233
+ }
234
+
235
+ module.exports = {
236
+ validate_envelope,
237
+ ENVELOPE_SCHEMA_VERSION,
238
+ TOP_LEVEL_KEYS,
239
+ }