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
@@ -19,14 +19,21 @@ const { spawnSync } = require('child_process')
19
19
  const fs = require('fs')
20
20
  const os = require('os')
21
21
  const path = require('path')
22
+ const {
23
+ parse_envelope,
24
+ assert_error_envelope,
25
+ assert_has_next_step,
26
+ } = require('../schema/envelope_test_helpers')
22
27
 
23
28
  const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
24
29
  const NODE = process.execPath
25
30
 
26
31
  const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-install-fresh-test-'))
27
32
  const run = (args, opts) => {
28
- const result = spawnSync(NODE, [CLI, ...args], {
29
- env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', ...(opts?.env || {}) },
33
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
34
+ const final_args = has_mode_flag ? args : [...args, '--text']
35
+ const result = spawnSync(NODE, [CLI, ...final_args], {
36
+ env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
30
37
  encoding: 'utf-8',
31
38
  timeout: 10000,
32
39
  cwd: opts?.cwd
@@ -93,50 +100,51 @@ const scaffold_installed = ({ full, short, version, modified }) => {
93
100
  }
94
101
 
95
102
  describe('install --fresh — § 8.5 hardening', () => {
96
- it('hard-fails with VERSION_NOT_FOUND when registry is unreachable (no silent fallback)', async () => {
103
+ it('hard-fails with VERSION_NOT_FOUND envelope when registry is unreachable (no silent fallback)', async () => {
97
104
  const ctx = await scaffold_installed({ full: 'acme/test', short: 'test', version: '1.0.0' })
98
105
  try {
99
- const { code, stderr, stdout } = run(
106
+ const { code, stdout } = run(
100
107
  ['install', 'acme/test@0.3.3', '--fresh', '--json', '-y'],
101
108
  { cwd: ctx.root }
102
109
  )
103
- // Exit code 2 (USER_ERROR) per spec § 8.5.
104
- assert.strictEqual(code, 2, 'must exit with USER_ERROR (2), not silently succeed')
105
- // Combined output must mention VERSION_NOT_FOUND.
106
- const combined = stdout + stderr
107
- assert.match(combined, /VERSION_NOT_FOUND/, 'must surface VERSION_NOT_FOUND, not silently fall back to latest')
108
- // JSON envelope's error.code must be USAGE_ERROR (not bare ERROR) so
109
- // downstream consumers can parse on the code rather than the message.
110
- const json_match = stdout.match(/^{[\s\S]*}\s*$/m)
111
- if (json_match) {
112
- const parsed = JSON.parse(json_match[0])
113
- assert.strictEqual(parsed.error?.code, 'USAGE_ERROR', `error.code must be USAGE_ERROR, got ${parsed.error?.code}`)
114
- }
110
+ // Exit code 2 per spec § 5.3 mapping table (VERSION_NOT_FOUND → 2).
111
+ assert.strictEqual(code, 2, 'VERSION_NOT_FOUND maps to exit 2 per spec § 5.3')
112
+ const env = parse_envelope(stdout, 'install --fresh VERSION_NOT_FOUND')
113
+ assert_error_envelope(env, 'VERSION_NOT_FOUND', 'install --fresh missing version')
114
+ // Decision next_step: pick_version with available list + safe command.
115
+ assert_has_next_step(env, 'pick_version', 'install --fresh missing version')
116
+ assert.strictEqual(env.next_step.kind, 'decision')
117
+ assert.strictEqual(env.next_step.context.requested, '0.3.3')
118
+ assert.strictEqual(env.next_step.principal_authorization_required, true)
115
119
 
116
120
  // Critical: disk content must be UNCHANGED — no silent overwrite.
117
121
  assert.strictEqual(ctx.read_manifest().version, '1.0.0', 'skill.json must NOT have been overwritten')
118
122
  } finally { ctx.cleanup() }
119
123
  })
120
124
 
121
- it('refuses --fresh when local edits are present unless --force-discard-local', async () => {
125
+ it('refuses --fresh when local edits are present, emitting LOCAL_EDITS_PRESENT + confirm_discard_or_snapshot_first', async () => {
122
126
  const ctx = await scaffold_installed({ full: 'acme/edited', short: 'edited', version: '1.0.0', modified: true })
123
127
  try {
124
- const { code, stdout, stderr } = run(
128
+ const { code, stdout } = run(
125
129
  ['install', 'acme/edited@1.0.0', '--fresh', '--json', '-y'],
126
130
  { cwd: ctx.root }
127
131
  )
128
- // Should hard-fail at the LOCAL_EDITS_PRESENT step (before any registry call would matter).
129
132
  assert.notStrictEqual(code, 0, 'must not succeed silently')
130
- const combined = stdout + stderr
131
- // Either LOCAL_EDITS_PRESENT (preferred, when registry is unreachable
132
- // and we never get to the version check, we still expect LOCAL_EDITS
133
- // to surface but registry-failure currently runs first, surfacing
134
- // VERSION_NOT_FOUND). Either way, NO silent overwrite must occur.
135
- assert.ok(
136
- /LOCAL_EDITS_PRESENT|VERSION_NOT_FOUND/.test(combined),
137
- `must refuse to clobber local edits, got: ${combined}`
138
- )
139
- // The mutated content must still be on disk.
133
+ const env = parse_envelope(stdout, 'install --fresh LOCAL_EDITS_PRESENT')
134
+ // LOCAL_EDITS_PRESENT must surface BEFORE the registry-call failure
135
+ // path the local-state gate is structurally earlier than the
136
+ // network call. Registry unreachability is irrelevant here.
137
+ assert_error_envelope(env, 'LOCAL_EDITS_PRESENT', 'install --fresh local edits')
138
+ assert_has_next_step(env, 'confirm_discard_or_snapshot_first', 'install --fresh local edits')
139
+ assert.strictEqual(env.next_step.kind, 'confirmation')
140
+ assert.strictEqual(env.next_step.principal_authorization_required, true)
141
+ // Commands array: SAFE default first (snapshot), destructive second.
142
+ const commands = env.next_step.context.commands
143
+ assert.ok(Array.isArray(commands) && commands.length >= 2)
144
+ assert.match(commands[0], /snapshot/, 'safe command (snapshot) must come first')
145
+ assert.match(commands[1], /--force-discard-local/, 'destructive variant second')
146
+
147
+ // The mutated content must still be on disk — confirmation gate held.
140
148
  assert.ok(/MUTATED BODY/.test(ctx.read_skill_md()), 'local mutation must be preserved')
141
149
  } finally { ctx.cleanup() }
142
150
  })
@@ -1,14 +1,15 @@
1
1
  'use strict'
2
2
  /**
3
- * Integration tests for `happyskills reconcile` — § 8.4. The command's
4
- * single job: handle GENUINE drift (regression / missing_skill_json /
5
- * missing_dir / corrupted) deterministically when possible, emit next_step
6
- * envelopes when user adjudication is required, and **no-op on the `ahead`
7
- * state** (ahead is not drift; it routes back to publish).
3
+ * Integration tests for `happyskills reconcile` — § 8.4 + envelope refactor
4
+ * (spec 260525-cli-default-json). Asserts on the SIX-KEY envelope shape:
5
+ * top-level `ok / data / error / next_step / warnings / meta`, closed enums,
6
+ * dependentRequired clusters. Every --json invocation is validated via the
7
+ * shared parse_envelope helper so this file doubles as a conformance test.
8
8
  *
9
- * These tests prevent the spec § 17.7 anti-pattern from recurring the
10
- * earlier draft auto-repaired `ahead` here, which was exactly the category
11
- * error that produced the § 2 incident.
9
+ * The other invariant locked here: reconcile NO-OPS on the `ahead` state.
10
+ * Ahead is not drift it must produce a success envelope with NO populated
11
+ * next_step (next_step === {}), routing the agent back to publish through a
12
+ * data.hint or similar in-data signal, never via the envelope's next_step.
12
13
  */
13
14
  const { describe, it } = require('node:test')
14
15
  const assert = require('node:assert/strict')
@@ -16,24 +17,28 @@ const { spawnSync } = require('child_process')
16
17
  const fs = require('fs')
17
18
  const os = require('os')
18
19
  const path = require('path')
20
+ const {
21
+ parse_envelope,
22
+ assert_success_envelope,
23
+ assert_has_next_step,
24
+ assert_no_next_step,
25
+ } = require('../schema/envelope_test_helpers')
19
26
 
20
27
  const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
21
28
  const NODE = process.execPath
22
29
 
23
30
  const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-reconcile-test-'))
24
31
  const run = (args, opts) => {
25
- const result = spawnSync(NODE, [CLI, ...args], {
26
- env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', ...(opts?.env || {}) },
32
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
33
+ const final_args = has_mode_flag ? args : [...args, '--text']
34
+ const result = spawnSync(NODE, [CLI, ...final_args], {
35
+ env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
27
36
  encoding: 'utf-8',
28
37
  timeout: 10000,
29
38
  cwd: opts?.cwd
30
39
  })
31
40
  return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
32
41
  }
33
- const parse_json = (stdout, label) => {
34
- try { return JSON.parse(stdout) }
35
- catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
36
- }
37
42
 
38
43
  const scaffold = ({ full, short, lock_version, disk_version, omit_skill_json, omit_dir }) => {
39
44
  const root = make_tmp()
@@ -77,33 +82,38 @@ const scaffold = ({ full, short, lock_version, disk_version, omit_skill_json, om
77
82
  }
78
83
  }
79
84
 
80
- describe('reconcile — § 8.4', () => {
81
- it('reports clean (no work to do) when lock and disk agree', () => {
85
+ describe('reconcile — envelope shape (§ 8.4 + envelope refactor)', () => {
86
+ it('reports clean as a success envelope with no follow-up', () => {
82
87
  const ctx = scaffold({ full: 'acme/clean', short: 'clean', lock_version: '1.0.0', disk_version: '1.0.0' })
83
88
  try {
84
89
  const { code, stdout } = run(['reconcile', 'acme/clean', '--json'], { cwd: ctx.root })
85
90
  assert.strictEqual(code, 0)
86
- const out = parse_json(stdout, 'reconcile clean')
87
- assert.strictEqual(out.data.no_drift, true)
88
- assert.strictEqual(out.data.status, 'clean')
89
- assert.strictEqual(out.next_step, null)
91
+ const env = parse_envelope(stdout, 'reconcile clean')
92
+ assert_success_envelope(env, 'reconcile clean')
93
+ assert_no_next_step(env, 'reconcile clean')
94
+ assert.strictEqual(env.data.no_drift, true)
95
+ assert.strictEqual(env.data.status, 'clean')
96
+ assert.strictEqual(env.meta.command, 'reconcile')
90
97
  } finally { ctx.cleanup() }
91
98
  })
92
99
 
93
- it('NO-OPS on ahead (disk > lock) — points back to publish, does NOT mutate', () => {
94
- // This is the spec § 17.7 anti-pattern test: the earlier design
95
- // auto-repaired ahead via revert-and-rebump. That behavior is rejected.
100
+ it('NO-OPS on ahead (disk > lock) — success envelope, empty next_step, hint inside data', () => {
101
+ // Spec § 17.7 anti-pattern guard: ahead is NOT drift. The envelope
102
+ // must reflect that with an empty next_step. The "go publish" hint
103
+ // lives inside data, never in the envelope.next_step slot (that
104
+ // would mis-signal a protocol followup).
96
105
  const ctx = scaffold({ full: 'acme/ahead', short: 'ahead', lock_version: '0.3.2', disk_version: '0.3.3' })
97
106
  try {
98
107
  const { code, stdout } = run(['reconcile', 'acme/ahead', '--json'], { cwd: ctx.root })
99
108
  assert.strictEqual(code, 0)
100
- const out = parse_json(stdout, 'reconcile ahead')
101
- assert.strictEqual(out.data.no_drift, true)
102
- assert.strictEqual(out.data.status, 'ahead')
103
- assert.strictEqual(out.data.ahead.lock_version, '0.3.2')
104
- assert.strictEqual(out.data.ahead.disk_version, '0.3.3')
105
- assert.strictEqual(out.next_step, null, 'must NOT emit a next_step for ahead — it is not drift')
106
- assert.match(out.hint, /publish/i, 'hint should point at publish/release, not at repair')
109
+ const env = parse_envelope(stdout, 'reconcile ahead')
110
+ assert_success_envelope(env, 'reconcile ahead')
111
+ assert_no_next_step(env, 'reconcile ahead')
112
+ assert.strictEqual(env.data.no_drift, true)
113
+ assert.strictEqual(env.data.status, 'ahead')
114
+ assert.strictEqual(env.data.ahead.lock_version, '0.3.2')
115
+ assert.strictEqual(env.data.ahead.disk_version, '0.3.3')
116
+ assert.match(env.data.hint, /publish/i, 'hint inside data should point at publish/release')
107
117
 
108
118
  // Confirm no file mutation.
109
119
  assert.strictEqual(ctx.read_manifest().version, '0.3.3', 'skill.json must be unchanged')
@@ -111,29 +121,35 @@ describe('reconcile — § 8.4', () => {
111
121
  } finally { ctx.cleanup() }
112
122
  })
113
123
 
114
- it('emits resolve_regression next_step for disk < lock', () => {
124
+ it('emits resolve_regression decision next_step for disk < lock', () => {
115
125
  const ctx = scaffold({ full: 'acme/regression', short: 'regression', lock_version: '0.4.0', disk_version: '0.3.0' })
116
126
  try {
117
127
  const { code, stdout } = run(['reconcile', 'acme/regression', '--json'], { cwd: ctx.root })
128
+ // Diagnosis-with-followup is success — exit 0, but next_step populated.
118
129
  assert.strictEqual(code, 0)
119
- const out = parse_json(stdout, 'reconcile regression')
120
- assert.strictEqual(out.data.drift_state, 'regression')
121
- assert.ok(out.next_step)
122
- assert.strictEqual(out.next_step.action, 'resolve_regression')
123
- assert.ok(out.next_step.context.options.includes('restore_from_lock_version'))
124
- assert.ok(out.next_step.context.options.includes('accept_disk_as_explicit_downgrade'))
130
+ const env = parse_envelope(stdout, 'reconcile regression')
131
+ assert_success_envelope(env, 'reconcile regression')
132
+ assert_has_next_step(env, 'resolve_regression', 'reconcile regression')
133
+ assert.strictEqual(env.next_step.kind, 'decision')
134
+ assert.strictEqual(env.next_step.principal_authorization_required, true)
135
+ assert.strictEqual(env.data.drift_state, 'regression')
136
+ assert.ok(env.next_step.context.options.includes('restore_from_lock_version'))
137
+ assert.ok(env.next_step.context.options.includes('accept_disk_as_explicit_downgrade'))
138
+ // Spec § 4.5.2: reconcile routes to happyskills-sync.
139
+ assert.strictEqual(env.next_step.route_to_skill, 'happyskills-sync')
125
140
  } finally { ctx.cleanup() }
126
141
  })
127
142
 
128
- it('--apply restore_from_lock_version repairs a regression deterministically', () => {
143
+ it('--apply restore_from_lock_version repairs a regression and emits success with no follow-up', () => {
129
144
  const ctx = scaffold({ full: 'acme/fix-regression', short: 'fix-regression', lock_version: '0.4.0', disk_version: '0.3.0' })
130
145
  try {
131
146
  const { code, stdout } = run(['reconcile', 'acme/fix-regression', '--apply', 'restore_from_lock_version', '--json'], { cwd: ctx.root })
132
147
  assert.strictEqual(code, 0)
133
- const out = parse_json(stdout, 'reconcile regression apply')
134
- assert.strictEqual(out.data.applied.applied, 'restore_from_lock_version')
135
- assert.strictEqual(out.data.applied.new_disk_version, '0.4.0')
136
- assert.strictEqual(out.next_step, null)
148
+ const env = parse_envelope(stdout, 'reconcile regression apply')
149
+ assert_success_envelope(env, 'reconcile apply')
150
+ assert_no_next_step(env, 'reconcile apply')
151
+ assert.strictEqual(env.data.applied.applied, 'restore_from_lock_version')
152
+ assert.strictEqual(env.data.applied.new_disk_version, '0.4.0')
137
153
 
138
154
  // skill.json now matches the lock.
139
155
  assert.strictEqual(ctx.read_manifest().version, '0.4.0')
@@ -150,12 +166,14 @@ describe('reconcile — § 8.4', () => {
150
166
  try {
151
167
  const { code, stdout } = run(['reconcile', 'acme/no-manifest', '--json'], { cwd: ctx.root })
152
168
  assert.strictEqual(code, 0)
153
- const out = parse_json(stdout, 'reconcile missing-manifest')
154
- assert.strictEqual(out.data.drift_state, 'missing_skill_json')
155
- assert.strictEqual(out.next_step.action, 'resolve_missing_skill_json')
156
- assert.ok(out.next_step.context.options.includes('restore_from_git'))
157
- assert.ok(out.next_step.context.options.includes('restore_from_registry_at_lock_version'))
158
- assert.ok(out.next_step.context.options.includes('abandon'))
169
+ const env = parse_envelope(stdout, 'reconcile missing-manifest')
170
+ assert_success_envelope(env, 'reconcile missing-manifest')
171
+ assert_has_next_step(env, 'resolve_missing_skill_json', 'reconcile missing-manifest')
172
+ assert.strictEqual(env.next_step.kind, 'decision')
173
+ assert.strictEqual(env.data.drift_state, 'missing_skill_json')
174
+ assert.ok(env.next_step.context.options.includes('restore_from_git'))
175
+ assert.ok(env.next_step.context.options.includes('restore_from_registry_at_lock_version'))
176
+ assert.ok(env.next_step.context.options.includes('abandon'))
159
177
  } finally { ctx.cleanup() }
160
178
  })
161
179
 
@@ -167,11 +185,13 @@ describe('reconcile — § 8.4', () => {
167
185
  try {
168
186
  const { code, stdout } = run(['reconcile', 'acme/no-dir', '--json'], { cwd: ctx.root })
169
187
  assert.strictEqual(code, 0)
170
- const out = parse_json(stdout, 'reconcile missing-dir')
171
- assert.strictEqual(out.data.drift_state, 'missing_dir')
172
- assert.strictEqual(out.next_step.action, 'resolve_missing_dir')
173
- assert.ok(out.next_step.context.options.includes('reinstall_at_lock_version'))
174
- assert.ok(out.next_step.context.options.includes('abandon'))
188
+ const env = parse_envelope(stdout, 'reconcile missing-dir')
189
+ assert_success_envelope(env, 'reconcile missing-dir')
190
+ assert_has_next_step(env, 'resolve_missing_dir', 'reconcile missing-dir')
191
+ assert.strictEqual(env.next_step.kind, 'decision')
192
+ assert.strictEqual(env.data.drift_state, 'missing_dir')
193
+ assert.ok(env.next_step.context.options.includes('reinstall_at_lock_version'))
194
+ assert.ok(env.next_step.context.options.includes('abandon'))
175
195
  } finally { ctx.cleanup() }
176
196
  })
177
197
 
@@ -180,9 +200,10 @@ describe('reconcile — § 8.4', () => {
180
200
  try {
181
201
  const { code, stdout } = run(['reconcile', 'bare', '--json'], { cwd: ctx.root })
182
202
  assert.strictEqual(code, 0)
183
- const out = parse_json(stdout, 'reconcile bare')
184
- assert.strictEqual(out.data.skill, 'acme/bare')
185
- assert.strictEqual(out.data.no_drift, true)
203
+ const env = parse_envelope(stdout, 'reconcile bare')
204
+ assert_success_envelope(env)
205
+ assert.strictEqual(env.data.skill, 'acme/bare')
206
+ assert.strictEqual(env.data.no_drift, true)
186
207
  } finally { ctx.cleanup() }
187
208
  })
188
209
  })
@@ -1,12 +1,14 @@
1
1
  'use strict'
2
2
  /**
3
- * Integration tests for `happyskills release` — § 8.2.
3
+ * Integration tests for `happyskills release` — § 8.2 + envelope refactor
4
+ * (spec 260525-cli-default-json). Asserts on the six-key envelope shape and
5
+ * the closed `next_step.action` enum.
4
6
  *
5
7
  * Focus: the failure-mode envelopes (drift, missing_version, missing_changelog
6
8
  * entry) and the ahead-recognition path through --dry-run. The full happy-path
7
- * publish requires a real registry and is covered by the existing publish
8
- * pipeline tests; here we verify orchestration, snapshot capture/restore, and
9
- * the structured next_step envelopes.
9
+ * publish requires a real registry and is covered by the publish pipeline
10
+ * tests; here we verify orchestration, snapshot capture/restore, and the
11
+ * structured next_step envelopes.
10
12
  */
11
13
  const { describe, it } = require('node:test')
12
14
  const assert = require('node:assert/strict')
@@ -14,24 +16,28 @@ const { spawnSync } = require('child_process')
14
16
  const fs = require('fs')
15
17
  const os = require('os')
16
18
  const path = require('path')
19
+ const {
20
+ parse_envelope,
21
+ assert_success_envelope,
22
+ assert_error_envelope,
23
+ assert_has_next_step,
24
+ } = require('../schema/envelope_test_helpers')
17
25
 
18
26
  const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
19
27
  const NODE = process.execPath
20
28
 
21
29
  const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-release-test-'))
22
30
  const run = (args, opts) => {
23
- const result = spawnSync(NODE, [CLI, ...args], {
24
- env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', ...(opts?.env || {}) },
31
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
32
+ const final_args = has_mode_flag ? args : [...args, '--text']
33
+ const result = spawnSync(NODE, [CLI, ...final_args], {
34
+ env: { ...process.env, NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1', HAPPYSKILLS_API_URL: 'http://localhost:0', CI: '', ...(opts?.env || {}) },
25
35
  encoding: 'utf-8',
26
36
  timeout: 15000,
27
37
  cwd: opts?.cwd
28
38
  })
29
39
  return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
30
40
  }
31
- const parse_json = (stdout, label) => {
32
- try { return JSON.parse(stdout) }
33
- catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
34
- }
35
41
 
36
42
  const scaffold = ({ full, short, lock_version, disk_version, changelog }) => {
37
43
  const root = make_tmp()
@@ -81,7 +87,7 @@ const scaffold = ({ full, short, lock_version, disk_version, changelog }) => {
81
87
  }
82
88
 
83
89
  describe('release — orchestration envelopes', () => {
84
- it('blocks on regression drift and routes to reconcile', () => {
90
+ it('blocks on regression drift and routes to reconcile via recovery next_step', () => {
85
91
  const ctx = scaffold({
86
92
  full: 'acme/regressed', short: 'regressed',
87
93
  lock_version: '1.0.0', disk_version: '0.9.0'
@@ -89,10 +95,15 @@ describe('release — orchestration envelopes', () => {
89
95
  try {
90
96
  const { code, stdout } = run(['release', 'regressed', '--workspace', 'acme', '--json'], { cwd: ctx.root })
91
97
  assert.notStrictEqual(code, 0)
92
- const env = parse_json(stdout, 'release regressed')
93
- assert.strictEqual(env.error.code, 'DRIFT_DETECTED')
94
- assert.strictEqual(env.next_step.action, 'reconcile_first')
95
- assert.match(env.next_step.context.reconcile_command, /reconcile/)
98
+ const env = parse_envelope(stdout, 'release regressed')
99
+ assert_error_envelope(env, 'DRIFT_DETECTED', 'release regressed')
100
+ assert_has_next_step(env, 'reconcile_first', 'release regressed')
101
+ assert.strictEqual(env.next_step.kind, 'recovery')
102
+ // Recovery commands array carries the verbatim follow-up command(s).
103
+ assert.ok(Array.isArray(env.next_step.context.commands))
104
+ assert.ok(env.next_step.context.commands.some(c => /reconcile/.test(c)))
105
+ // Drift detail propagates through error.drift.
106
+ assert.strictEqual(env.error.drift.reason, 'regression')
96
107
  } finally { ctx.cleanup() }
97
108
  })
98
109
 
@@ -105,7 +116,8 @@ describe('release — orchestration envelopes', () => {
105
116
  try {
106
117
  const { code, stdout } = run(['release', 'ahead-dry', '--workspace', 'acme', '--dry-run', '--json'], { cwd: ctx.root })
107
118
  assert.strictEqual(code, 0, 'dry-run on ahead must succeed')
108
- const env = parse_json(stdout, 'release ahead dry-run')
119
+ const env = parse_envelope(stdout, 'release ahead dry-run')
120
+ assert_success_envelope(env, 'release ahead dry-run')
109
121
  assert.strictEqual(env.data.dry_run, true)
110
122
  assert.strictEqual(env.data.target_version, '0.3.3')
111
123
  assert.strictEqual(env.data.ahead_recognized, true)
@@ -115,7 +127,7 @@ describe('release — orchestration envelopes', () => {
115
127
  } finally { ctx.cleanup() }
116
128
  })
117
129
 
118
- it('blocks with MISSING_VERSION on clean + --no-bump', () => {
130
+ it('blocks with MISSING_VERSION + specify_bump_type on clean + --no-bump', () => {
119
131
  const ctx = scaffold({
120
132
  full: 'acme/clean', short: 'clean',
121
133
  lock_version: '1.0.0', disk_version: '1.0.0'
@@ -123,8 +135,11 @@ describe('release — orchestration envelopes', () => {
123
135
  try {
124
136
  const { code, stdout } = run(['release', 'clean', '--workspace', 'acme', '--no-bump', '--json'], { cwd: ctx.root })
125
137
  assert.notStrictEqual(code, 0)
126
- const env = parse_json(stdout, 'release clean --no-bump')
127
- assert.strictEqual(env.error.code, 'MISSING_VERSION')
138
+ const env = parse_envelope(stdout, 'release clean --no-bump')
139
+ assert_error_envelope(env, 'MISSING_VERSION', 'release clean --no-bump')
140
+ assert_has_next_step(env, 'specify_bump_type', 'release missing version')
141
+ assert.strictEqual(env.next_step.kind, 'decision')
142
+ assert.deepStrictEqual(env.next_step.context.options, ['patch', 'minor', 'major'])
128
143
  } finally { ctx.cleanup() }
129
144
  })
130
145
 
@@ -137,13 +152,15 @@ describe('release — orchestration envelopes', () => {
137
152
  try {
138
153
  const { code, stdout } = run(['release', 'no-cl', '--bump', 'patch', '--workspace', 'acme', '--json'], { cwd: ctx.root })
139
154
  assert.notStrictEqual(code, 0)
140
- const env = parse_json(stdout, 'release no-cl')
141
- assert.strictEqual(env.error.code, 'MISSING_CHANGELOG_ENTRY')
142
- assert.strictEqual(env.next_step.action, 'provide_changelog')
155
+ const env = parse_envelope(stdout, 'release no-cl')
156
+ assert_error_envelope(env, 'MISSING_CHANGELOG_ENTRY', 'release no-cl')
157
+ assert_has_next_step(env, 'provide_changelog', 'release no-cl')
158
+ assert.strictEqual(env.next_step.kind, 'recovery')
159
+ assert.strictEqual(env.next_step.principal_authorization_required, true)
143
160
  } finally { ctx.cleanup() }
144
161
  })
145
162
 
146
- it('emits bump_disagreement when --bump value contradicts an already-ahead disk', () => {
163
+ it('emits BUMP_DISAGREEMENT + resolve_bump_disagreement when --bump contradicts an already-ahead disk', () => {
147
164
  const ctx = scaffold({
148
165
  full: 'acme/disagree', short: 'disagree',
149
166
  lock_version: '0.1.0', disk_version: '0.5.0'
@@ -151,19 +168,19 @@ describe('release — orchestration envelopes', () => {
151
168
  try {
152
169
  const { code, stdout } = run(['release', 'disagree', '--bump', 'patch', '--workspace', 'acme', '--json'], { cwd: ctx.root })
153
170
  assert.notStrictEqual(code, 0)
154
- const env = parse_json(stdout, 'release disagree')
155
- assert.strictEqual(env.error.code, 'BUMP_DISAGREEMENT')
156
- assert.strictEqual(env.next_step.action, 'resolve_bump_disagreement')
171
+ const env = parse_envelope(stdout, 'release disagree')
172
+ assert_error_envelope(env, 'BUMP_DISAGREEMENT', 'release disagree')
173
+ assert_has_next_step(env, 'resolve_bump_disagreement', 'release disagree')
174
+ assert.strictEqual(env.next_step.kind, 'decision')
157
175
  assert.strictEqual(env.next_step.context.disk_version, '0.5.0')
176
+ assert.strictEqual(env.next_step.context.requested_bump, 'patch')
158
177
  } finally { ctx.cleanup() }
159
178
  })
160
179
 
161
180
  it('§2 scenario re-test: ahead state ends in a successful dry-run with NO revert and NO drift error', () => {
162
181
  // This is the canonical regression test for the original failure.
163
182
  // Setup mirrors §2 exactly: lock 0.3.2, disk 0.3.3 (hand-edited bump),
164
- // CHANGELOG already has the 0.3.3 entry. The full publish would need
165
- // a real registry — but the orchestration must reach the publish
166
- // step cleanly, not block on a misclassified drift error.
183
+ // CHANGELOG already has the 0.3.3 entry.
167
184
  const ctx = scaffold({
168
185
  full: 'happyskillsai/happyskills-help', short: 'happyskills-help',
169
186
  lock_version: '0.3.2', disk_version: '0.3.3',
@@ -172,11 +189,11 @@ describe('release — orchestration envelopes', () => {
172
189
  try {
173
190
  const { code, stdout } = run(['release', 'happyskills-help', '--workspace', 'happyskillsai', '--dry-run', '--json'], { cwd: ctx.root })
174
191
  assert.strictEqual(code, 0, '§2 scenario must NOT block — ahead is normal authoring')
175
- const env = parse_json(stdout, '§2 scenario re-test')
192
+ const env = parse_envelope(stdout, '§2 scenario re-test')
193
+ assert_success_envelope(env, '§2 scenario re-test')
176
194
  assert.strictEqual(env.data.dry_run, true)
177
195
  assert.strictEqual(env.data.target_version, '0.3.3', 'must publish the disk version')
178
196
  assert.strictEqual(env.data.ahead_recognized, true)
179
- // Snapshot was created and (because dry-run) restored — disk must be unchanged.
180
197
  assert.strictEqual(ctx.read_manifest().version, '0.3.3')
181
198
  } finally { ctx.cleanup() }
182
199
  })