happyskills 0.48.0 → 0.49.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.
@@ -0,0 +1,170 @@
1
+ 'use strict'
2
+ /**
3
+ * Integration tests for `happyskills bump` — § 8.6 (bump narrowed to
4
+ * skill.json only). After bump, skill.json's version is the new value AND
5
+ * the lock entry's version is UNCHANGED. Subsequent `list --json` reports
6
+ * the skill as `status: "ahead"`.
7
+ *
8
+ * This test prevents regression of the lock-as-registry-view principle:
9
+ * a local bump is not a registry interaction, so the lock has no business
10
+ * being mutated by it.
11
+ */
12
+ const { describe, it } = require('node:test')
13
+ const assert = require('node:assert/strict')
14
+ const { spawnSync } = require('child_process')
15
+ const fs = require('fs')
16
+ const os = require('os')
17
+ const path = require('path')
18
+
19
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
20
+ const NODE = process.execPath
21
+
22
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-bump-test-'))
23
+ const run = (args, opts) => {
24
+ const result = spawnSync(NODE, [CLI, ...args], {
25
+ env: {
26
+ ...process.env,
27
+ NO_COLOR: '1',
28
+ HAPPYSKILLS_API_URL: 'http://localhost:0',
29
+ ...(opts?.env || {})
30
+ },
31
+ encoding: 'utf-8',
32
+ timeout: 10000,
33
+ cwd: opts?.cwd
34
+ })
35
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
36
+ }
37
+ const parse_json = (stdout, label) => {
38
+ try { return JSON.parse(stdout) }
39
+ catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
40
+ }
41
+
42
+ // Scaffold a project with one locked-and-installed skill.
43
+ const scaffold = ({ full, short, lock_version, disk_version }) => {
44
+ const root = make_tmp()
45
+ const skill_dir = path.join(root, '.agents', 'skills', short)
46
+ fs.mkdirSync(skill_dir, { recursive: true })
47
+ fs.writeFileSync(
48
+ path.join(skill_dir, 'SKILL.md'),
49
+ `---\nname: ${short}\ndescription: bump test skill\n---\nbody\n`
50
+ )
51
+ fs.writeFileSync(
52
+ path.join(skill_dir, 'skill.json'),
53
+ JSON.stringify({
54
+ name: short,
55
+ version: disk_version,
56
+ type: 'skill',
57
+ description: 'bump test skill'
58
+ }, null, '\t')
59
+ )
60
+ fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
61
+ lockVersion: 2,
62
+ generatedAt: new Date().toISOString(),
63
+ skills: {
64
+ [full]: {
65
+ version: lock_version,
66
+ type: 'skill',
67
+ ref: `refs/tags/v${lock_version}`,
68
+ commit: 'lockcommit',
69
+ integrity: 'sha256-baseline',
70
+ base_commit: 'lockcommit',
71
+ base_integrity: 'sha256-baseline',
72
+ requested_by: ['__root__'],
73
+ dependencies: {}
74
+ }
75
+ }
76
+ }, null, '\t'))
77
+ return {
78
+ root,
79
+ skill_dir,
80
+ read_lock: () => JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')),
81
+ read_manifest: () => JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8')),
82
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true })
83
+ }
84
+ }
85
+
86
+ describe('bump — § 8.6 narrowed to skill.json only', () => {
87
+ it('updates skill.json version but leaves the lock entry UNCHANGED', () => {
88
+ const ctx = scaffold({ full: 'acme/my-skill', short: 'my-skill', lock_version: '1.0.0', disk_version: '1.0.0' })
89
+ try {
90
+ const { code, stdout } = run(['bump', 'patch', 'my-skill', '--json'], { cwd: ctx.root })
91
+ assert.strictEqual(code, 0, `bump should exit 0, got ${code}`)
92
+ const out = parse_json(stdout, 'bump --json')
93
+ assert.strictEqual(out.data.old_version, '1.0.0')
94
+ assert.strictEqual(out.data.new_version, '1.0.1')
95
+
96
+ // Manifest was updated.
97
+ assert.strictEqual(ctx.read_manifest().version, '1.0.1', 'skill.json must be at the new version')
98
+
99
+ // Lock entry was NOT updated.
100
+ const lock = ctx.read_lock()
101
+ assert.strictEqual(
102
+ lock.skills['acme/my-skill'].version,
103
+ '1.0.0',
104
+ 'lock entry version must be UNCHANGED — lock catches up at publish time, not bump time'
105
+ )
106
+ assert.strictEqual(
107
+ lock.skills['acme/my-skill'].ref,
108
+ 'refs/tags/v1.0.0',
109
+ 'lock entry ref must be UNCHANGED'
110
+ )
111
+ assert.strictEqual(
112
+ lock.skills['acme/my-skill'].integrity,
113
+ 'sha256-baseline',
114
+ 'lock entry integrity must be UNCHANGED'
115
+ )
116
+ } finally { ctx.cleanup() }
117
+ })
118
+
119
+ it('subsequent list --json reports the bumped skill as status:ahead', () => {
120
+ const ctx = scaffold({ full: 'acme/my-skill', short: 'my-skill', lock_version: '0.3.2', disk_version: '0.3.2' })
121
+ try {
122
+ const { code: bump_code } = run(['bump', 'patch', 'my-skill', '--json'], { cwd: ctx.root })
123
+ assert.strictEqual(bump_code, 0)
124
+
125
+ const { code, stdout } = run(['list', '--json'], { cwd: ctx.root })
126
+ assert.strictEqual(code, 0)
127
+ const out = parse_json(stdout, 'list --json')
128
+ const entry = out.data.skills['acme/my-skill']
129
+ assert.ok(entry, 'skill must appear in list output')
130
+ assert.strictEqual(entry.status, 'ahead', 'must be ahead, not drift, not installed')
131
+ assert.strictEqual(entry.ahead.lock_version, '0.3.2')
132
+ assert.strictEqual(entry.ahead.disk_version, '0.3.3')
133
+ assert.strictEqual(entry.drift, undefined, 'ahead must not emit a drift object')
134
+ } finally { ctx.cleanup() }
135
+ })
136
+
137
+ it('explicit version bump leaves lock untouched', () => {
138
+ const ctx = scaffold({ full: 'acme/exact', short: 'exact', lock_version: '0.1.0', disk_version: '0.1.0' })
139
+ try {
140
+ const { code } = run(['bump', '2.5.7', 'exact', '--json'], { cwd: ctx.root })
141
+ assert.strictEqual(code, 0)
142
+ assert.strictEqual(ctx.read_manifest().version, '2.5.7')
143
+ assert.strictEqual(ctx.read_lock().skills['acme/exact'].version, '0.1.0', 'lock untouched')
144
+ } finally { ctx.cleanup() }
145
+ })
146
+
147
+ it('bump still works when there is no lock entry for the skill at all', () => {
148
+ // e.g. a freshly-init'd skill that has never been published. There's
149
+ // no lock key to update, and that's fine — bump only writes to
150
+ // skill.json.
151
+ const root = make_tmp()
152
+ try {
153
+ const skill_dir = path.join(root, '.agents', 'skills', 'fresh')
154
+ fs.mkdirSync(skill_dir, { recursive: true })
155
+ fs.writeFileSync(
156
+ path.join(skill_dir, 'SKILL.md'),
157
+ '---\nname: fresh\ndescription: never-published skill\n---\nbody\n'
158
+ )
159
+ fs.writeFileSync(
160
+ path.join(skill_dir, 'skill.json'),
161
+ JSON.stringify({ name: 'fresh', version: '0.1.0', type: 'skill', description: 'never-published skill' }, null, '\t')
162
+ )
163
+ // No skills-lock.json at all.
164
+ const { code } = run(['bump', 'minor', 'fresh', '--json'], { cwd: root })
165
+ assert.strictEqual(code, 0)
166
+ const manifest = JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8'))
167
+ assert.strictEqual(manifest.version, '0.2.0')
168
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
169
+ })
170
+ })
@@ -108,7 +108,7 @@ const scaffold_drifted = (skills) => {
108
108
  // ─── status ───────────────────────────────────────────────────────────────────
109
109
 
110
110
  describe('status — drift detection', () => {
111
- it('reports drift when lock version differs from disk skill.json version (the linwong bug)', () => {
111
+ it('reports drift:regression when disk version is BEHIND lock (the linwong bug, now narrowed)', () => {
112
112
  const { root, cleanup } = scaffold_drifted([
113
113
  { full: 'happyskillsai/happyskills-design', short: 'happyskills-design', lock_version: '0.4.0', disk_version: '0.3.0' }
114
114
  ])
@@ -120,14 +120,35 @@ describe('status — drift detection', () => {
120
120
  assert.ok(result, 'result for the drifted skill must be present')
121
121
  assert.strictEqual(result.status, 'drift', 'status must be reported as drift, not modified or clean')
122
122
  assert.deepEqual(result.drift, {
123
- reason: 'version_mismatch',
123
+ reason: 'regression',
124
124
  lock_version: '0.4.0',
125
125
  disk_version: '0.3.0'
126
126
  })
127
127
  } finally { cleanup() }
128
128
  })
129
129
 
130
- it('drift outranks modified does not mislabel structural drift as local edits', () => {
130
+ it('reports status:ahead when disk version is AHEAD of lock (the §2 normal authoring case)', () => {
131
+ // The §2 incident's trigger state: agent hand-edited skill.json from
132
+ // 0.3.2 -> 0.3.3, lock still at 0.3.2. Under the new semantics this is
133
+ // the normal `ahead` state, NOT drift. Asserting this prevents the false
134
+ // positive from coming back.
135
+ const { root, cleanup } = scaffold_drifted([
136
+ { full: 'happyskillsai/happyskills-help', short: 'happyskills-help', lock_version: '0.3.2', disk_version: '0.3.3' }
137
+ ])
138
+ try {
139
+ const { code, stdout } = run(['status', '--json'], { cwd: root })
140
+ assert.strictEqual(code, 0, 'status should exit 0')
141
+ const out = parse_json(stdout, 'status --json')
142
+ const result = out.data.results.find(r => r.skill === 'happyskillsai/happyskills-help')
143
+ assert.ok(result, 'result must be present')
144
+ assert.strictEqual(result.status, 'ahead', 'disk > lock must be reported as ahead, not drift')
145
+ assert.strictEqual(result.drift, null, 'ahead must not emit a drift object')
146
+ assert.strictEqual(result.ahead.lock_version, '0.3.2')
147
+ assert.strictEqual(result.ahead.disk_version, '0.3.3')
148
+ } finally { cleanup() }
149
+ })
150
+
151
+ it('drift:regression outranks modified — does not mislabel structural drift as local edits', () => {
131
152
  // Critical regression: previously reported as "modified (local changes)"
132
153
  const { root, cleanup } = scaffold_drifted([
133
154
  { full: 'acme/foo', short: 'foo', lock_version: '2.0.0', disk_version: '1.0.0' }
@@ -138,6 +159,7 @@ describe('status — drift detection', () => {
138
159
  const out = parse_json(stdout, 'status --json')
139
160
  const result = out.data.results[0]
140
161
  assert.strictEqual(result.status, 'drift', 'must be drift, not modified')
162
+ assert.strictEqual(result.drift.reason, 'regression')
141
163
  assert.notStrictEqual(result.status, 'modified', 'modified would mislead — this is structural drift, not user edits')
142
164
  } finally { cleanup() }
143
165
  })
@@ -188,7 +210,7 @@ describe('status — drift detection', () => {
188
210
  // ─── check ────────────────────────────────────────────────────────────────────
189
211
 
190
212
  describe('check — drift detection', () => {
191
- it('reports drift even when the registry API is unreachable', () => {
213
+ it('reports drift:regression even when the registry API is unreachable', () => {
192
214
  // HAPPYSKILLS_API_URL=http://localhost:0 makes the API call fail.
193
215
  // Drift detection is purely local — it must still surface.
194
216
  const { root, cleanup } = scaffold_drifted([
@@ -202,13 +224,30 @@ describe('check — drift detection', () => {
202
224
  assert.ok(result, 'result must be present even with API down')
203
225
  assert.strictEqual(result.status, 'drift')
204
226
  assert.deepEqual(result.drift, {
205
- reason: 'version_mismatch',
227
+ reason: 'regression',
206
228
  lock_version: '0.4.0',
207
229
  disk_version: '0.3.0'
208
230
  })
209
231
  } finally { cleanup() }
210
232
  })
211
233
 
234
+ it('reports status:ahead when disk > lock even with no registry access', () => {
235
+ const { root, cleanup } = scaffold_drifted([
236
+ { full: 'acme/ahead-only', short: 'ahead-only', lock_version: '0.3.2', disk_version: '0.3.3' }
237
+ ])
238
+ try {
239
+ const { code, stdout } = run(['check', '--json'], { cwd: root })
240
+ assert.strictEqual(code, 0)
241
+ const out = parse_json(stdout, 'check --json')
242
+ const result = out.data.results.find(r => r.skill === 'acme/ahead-only')
243
+ assert.ok(result)
244
+ assert.strictEqual(result.status, 'ahead')
245
+ assert.strictEqual(result.ahead.lock_version, '0.3.2')
246
+ assert.strictEqual(result.ahead.disk_version, '0.3.3')
247
+ assert.strictEqual(out.data.ahead_count, 1)
248
+ } finally { cleanup() }
249
+ })
250
+
212
251
  it('drift_count appears in the JSON summary', () => {
213
252
  const { root, cleanup } = scaffold_drifted([
214
253
  { full: 'acme/one', short: 'one', lock_version: '1.0.0', disk_version: '0.9.0' },
@@ -239,8 +278,8 @@ describe('check — drift detection', () => {
239
278
 
240
279
  // ─── list ─────────────────────────────────────────────────────────────────────
241
280
 
242
- describe('list — drift surfacing', () => {
243
- it('list --json marks drifted skills with status "drift" and a drift object', () => {
281
+ describe('list — drift and ahead surfacing', () => {
282
+ it('list --json marks drifted skills with status "drift" and a regression drift object', () => {
244
283
  const { root, cleanup } = scaffold_drifted([
245
284
  { full: 'acme/drifted', short: 'drifted', lock_version: '0.4.0', disk_version: '0.3.0' },
246
285
  { full: 'acme/clean', short: 'clean', lock_version: '1.0.0', disk_version: '1.0.0' }
@@ -251,12 +290,28 @@ describe('list — drift surfacing', () => {
251
290
  const out = parse_json(stdout, 'list --json')
252
291
  assert.strictEqual(out.data.skills['acme/drifted'].status, 'drift')
253
292
  assert.deepEqual(out.data.skills['acme/drifted'].drift, {
254
- reason: 'version_mismatch',
293
+ reason: 'regression',
255
294
  lock_version: '0.4.0',
256
295
  disk_version: '0.3.0'
257
296
  })
258
297
  assert.strictEqual(out.data.skills['acme/clean'].status, 'installed')
259
298
  assert.strictEqual(out.data.skills['acme/clean'].drift, undefined)
299
+ assert.strictEqual(out.data.skills['acme/clean'].ahead, undefined)
300
+ } finally { cleanup() }
301
+ })
302
+
303
+ it('list --json marks ahead skills with status "ahead" and an ahead object (no drift)', () => {
304
+ const { root, cleanup } = scaffold_drifted([
305
+ { full: 'acme/ahead', short: 'ahead', lock_version: '0.3.2', disk_version: '0.3.3' }
306
+ ])
307
+ try {
308
+ const { code, stdout } = run(['list', '--json'], { cwd: root })
309
+ assert.strictEqual(code, 0)
310
+ const out = parse_json(stdout, 'list --json')
311
+ assert.strictEqual(out.data.skills['acme/ahead'].status, 'ahead')
312
+ assert.strictEqual(out.data.skills['acme/ahead'].drift, undefined)
313
+ assert.strictEqual(out.data.skills['acme/ahead'].ahead.lock_version, '0.3.2')
314
+ assert.strictEqual(out.data.skills['acme/ahead'].ahead.disk_version, '0.3.3')
260
315
  } finally { cleanup() }
261
316
  })
262
317
  })
@@ -0,0 +1,167 @@
1
+ 'use strict'
2
+ /**
3
+ * Integration tests for `happyskills install --fresh` — § 8.5 hardening.
4
+ *
5
+ * This closes the § 2 root cause B: previously, `install <skill>@<version> --fresh`
6
+ * silently fell back to the latest published version when the requested version
7
+ * didn't exist. The hardened command must:
8
+ * (a) hard-fail with VERSION_NOT_FOUND before any disk mutation,
9
+ * (b) snapshot before wiping when the directory exists,
10
+ * (c) refuse on local edits unless --force-discard-local.
11
+ *
12
+ * These tests force the registry to be unreachable, which exercises the
13
+ * pre-flight failure mode — the version cannot be verified, so --fresh
14
+ * refuses. That's exactly the safety behavior the spec requires.
15
+ */
16
+ const { describe, it } = require('node:test')
17
+ const assert = require('node:assert/strict')
18
+ const { spawnSync } = require('child_process')
19
+ const fs = require('fs')
20
+ const os = require('os')
21
+ const path = require('path')
22
+
23
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
24
+ const NODE = process.execPath
25
+
26
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-install-fresh-test-'))
27
+ 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 || {}) },
30
+ encoding: 'utf-8',
31
+ timeout: 10000,
32
+ cwd: opts?.cwd
33
+ })
34
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
35
+ }
36
+
37
+ const scaffold_installed = ({ full, short, version, modified }) => {
38
+ const root = make_tmp()
39
+ const skill_dir = path.join(root, '.agents', 'skills', short)
40
+ fs.mkdirSync(skill_dir, { recursive: true })
41
+ fs.writeFileSync(
42
+ path.join(skill_dir, 'SKILL.md'),
43
+ `---\nname: ${short}\ndescription: install-fresh test skill\n---\nbody\n`
44
+ )
45
+ fs.writeFileSync(
46
+ path.join(skill_dir, 'skill.json'),
47
+ JSON.stringify({ name: short, version, type: 'skill', description: 'install-fresh test skill' }, null, '\t')
48
+ )
49
+ const { hash_directory } = require('../lock/integrity')
50
+
51
+ // Compute the clean baseline integrity *before* applying any modification.
52
+ // hash_directory caches results — we'd otherwise get the dirty hash.
53
+ let lock_integrity
54
+ // eslint-disable-next-line no-async-promise-executor
55
+ return new Promise(async (res) => {
56
+ const { clear_integrity_cache } = require('../lock/integrity')
57
+ clear_integrity_cache()
58
+ const [, clean_hash] = await hash_directory(skill_dir)
59
+ lock_integrity = clean_hash
60
+
61
+ if (modified) {
62
+ clear_integrity_cache()
63
+ fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: ' + short + '\ndescription: install-fresh test skill\n---\nMUTATED BODY\n')
64
+ }
65
+
66
+ fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
67
+ lockVersion: 2,
68
+ generatedAt: new Date().toISOString(),
69
+ skills: {
70
+ [full]: {
71
+ version,
72
+ type: 'skill',
73
+ ref: `refs/tags/v${version}`,
74
+ commit: 'lockcommit',
75
+ integrity: lock_integrity,
76
+ base_commit: 'lockcommit',
77
+ base_integrity: lock_integrity,
78
+ requested_by: ['__root__'],
79
+ dependencies: {}
80
+ }
81
+ }
82
+ }, null, '\t'))
83
+
84
+ res({
85
+ root,
86
+ skill_dir,
87
+ read_lock: () => JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')),
88
+ read_manifest: () => JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8')),
89
+ read_skill_md: () => fs.readFileSync(path.join(skill_dir, 'SKILL.md'), 'utf-8'),
90
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true })
91
+ })
92
+ })
93
+ }
94
+
95
+ describe('install --fresh — § 8.5 hardening', () => {
96
+ it('hard-fails with VERSION_NOT_FOUND when registry is unreachable (no silent fallback)', async () => {
97
+ const ctx = await scaffold_installed({ full: 'acme/test', short: 'test', version: '1.0.0' })
98
+ try {
99
+ const { code, stderr, stdout } = run(
100
+ ['install', 'acme/test@0.3.3', '--fresh', '--json', '-y'],
101
+ { cwd: ctx.root }
102
+ )
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
+ }
115
+
116
+ // Critical: disk content must be UNCHANGED — no silent overwrite.
117
+ assert.strictEqual(ctx.read_manifest().version, '1.0.0', 'skill.json must NOT have been overwritten')
118
+ } finally { ctx.cleanup() }
119
+ })
120
+
121
+ it('refuses --fresh when local edits are present unless --force-discard-local', async () => {
122
+ const ctx = await scaffold_installed({ full: 'acme/edited', short: 'edited', version: '1.0.0', modified: true })
123
+ try {
124
+ const { code, stdout, stderr } = run(
125
+ ['install', 'acme/edited@1.0.0', '--fresh', '--json', '-y'],
126
+ { cwd: ctx.root }
127
+ )
128
+ // Should hard-fail at the LOCAL_EDITS_PRESENT step (before any registry call would matter).
129
+ 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.
140
+ assert.ok(/MUTATED BODY/.test(ctx.read_skill_md()), 'local mutation must be preserved')
141
+ } finally { ctx.cleanup() }
142
+ })
143
+
144
+ it('does NOT pre-flight when --fresh is omitted (existing behavior preserved)', async () => {
145
+ // Without --fresh, the hardening doesn't kick in. The install will fail
146
+ // when it tries to actually reach the registry, but for a DIFFERENT
147
+ // reason than VERSION_NOT_FOUND — and no snapshot is taken.
148
+ const ctx = await scaffold_installed({ full: 'acme/no-fresh', short: 'no-fresh', version: '1.0.0' })
149
+ try {
150
+ const { code, stdout, stderr } = run(
151
+ ['install', 'acme/no-fresh@0.3.3', '--json', '-y'],
152
+ { cwd: ctx.root }
153
+ )
154
+ const combined = stdout + stderr
155
+ // The fail path may produce many error shapes; the key invariant
156
+ // is that the hardening's specific VERSION_NOT_FOUND error message
157
+ // does NOT appear (because hardening only triggers on --fresh).
158
+ assert.ok(
159
+ !/VERSION_NOT_FOUND: .* refusing to proceed/.test(combined),
160
+ 'VERSION_NOT_FOUND hardening should not fire without --fresh'
161
+ )
162
+ // Disk should still be untouched (whether the install succeeded or
163
+ // failed via a different error).
164
+ assert.strictEqual(ctx.read_manifest().version, '1.0.0')
165
+ } finally { ctx.cleanup() }
166
+ })
167
+ })
@@ -0,0 +1,188 @@
1
+ 'use strict'
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).
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.
12
+ */
13
+ const { describe, it } = require('node:test')
14
+ const assert = require('node:assert/strict')
15
+ const { spawnSync } = require('child_process')
16
+ const fs = require('fs')
17
+ const os = require('os')
18
+ const path = require('path')
19
+
20
+ const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
21
+ const NODE = process.execPath
22
+
23
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-reconcile-test-'))
24
+ 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 || {}) },
27
+ encoding: 'utf-8',
28
+ timeout: 10000,
29
+ cwd: opts?.cwd
30
+ })
31
+ return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
32
+ }
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
+
38
+ const scaffold = ({ full, short, lock_version, disk_version, omit_skill_json, omit_dir }) => {
39
+ const root = make_tmp()
40
+ const skill_dir = path.join(root, '.agents', 'skills', short)
41
+ if (!omit_dir) {
42
+ fs.mkdirSync(skill_dir, { recursive: true })
43
+ fs.writeFileSync(
44
+ path.join(skill_dir, 'SKILL.md'),
45
+ `---\nname: ${short}\ndescription: reconcile test skill\n---\nbody\n`
46
+ )
47
+ if (!omit_skill_json) {
48
+ fs.writeFileSync(
49
+ path.join(skill_dir, 'skill.json'),
50
+ JSON.stringify({ name: short, version: disk_version, type: 'skill', description: 'reconcile test skill' }, null, '\t')
51
+ )
52
+ }
53
+ }
54
+ fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
55
+ lockVersion: 2,
56
+ generatedAt: new Date().toISOString(),
57
+ skills: {
58
+ [full]: {
59
+ version: lock_version,
60
+ type: 'skill',
61
+ ref: `refs/tags/v${lock_version}`,
62
+ commit: 'lockcommit',
63
+ integrity: 'sha256-baseline',
64
+ base_commit: 'lockcommit',
65
+ base_integrity: 'sha256-baseline',
66
+ requested_by: ['__root__'],
67
+ dependencies: {}
68
+ }
69
+ }
70
+ }, null, '\t'))
71
+ return {
72
+ root,
73
+ skill_dir,
74
+ read_manifest: () => JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8')),
75
+ read_lock: () => JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')),
76
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true })
77
+ }
78
+ }
79
+
80
+ describe('reconcile — § 8.4', () => {
81
+ it('reports clean (no work to do) when lock and disk agree', () => {
82
+ const ctx = scaffold({ full: 'acme/clean', short: 'clean', lock_version: '1.0.0', disk_version: '1.0.0' })
83
+ try {
84
+ const { code, stdout } = run(['reconcile', 'acme/clean', '--json'], { cwd: ctx.root })
85
+ 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)
90
+ } finally { ctx.cleanup() }
91
+ })
92
+
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.
96
+ const ctx = scaffold({ full: 'acme/ahead', short: 'ahead', lock_version: '0.3.2', disk_version: '0.3.3' })
97
+ try {
98
+ const { code, stdout } = run(['reconcile', 'acme/ahead', '--json'], { cwd: ctx.root })
99
+ 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')
107
+
108
+ // Confirm no file mutation.
109
+ assert.strictEqual(ctx.read_manifest().version, '0.3.3', 'skill.json must be unchanged')
110
+ assert.strictEqual(ctx.read_lock().skills['acme/ahead'].version, '0.3.2', 'lock must be unchanged')
111
+ } finally { ctx.cleanup() }
112
+ })
113
+
114
+ it('emits resolve_regression next_step for disk < lock', () => {
115
+ const ctx = scaffold({ full: 'acme/regression', short: 'regression', lock_version: '0.4.0', disk_version: '0.3.0' })
116
+ try {
117
+ const { code, stdout } = run(['reconcile', 'acme/regression', '--json'], { cwd: ctx.root })
118
+ 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'))
125
+ } finally { ctx.cleanup() }
126
+ })
127
+
128
+ it('--apply restore_from_lock_version repairs a regression deterministically', () => {
129
+ const ctx = scaffold({ full: 'acme/fix-regression', short: 'fix-regression', lock_version: '0.4.0', disk_version: '0.3.0' })
130
+ try {
131
+ const { code, stdout } = run(['reconcile', 'acme/fix-regression', '--apply', 'restore_from_lock_version', '--json'], { cwd: ctx.root })
132
+ 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)
137
+
138
+ // skill.json now matches the lock.
139
+ assert.strictEqual(ctx.read_manifest().version, '0.4.0')
140
+ // Lock untouched (it was the source of truth).
141
+ assert.strictEqual(ctx.read_lock().skills['acme/fix-regression'].version, '0.4.0')
142
+ } finally { ctx.cleanup() }
143
+ })
144
+
145
+ it('emits resolve_missing_skill_json next_step when skill.json is absent', () => {
146
+ const ctx = scaffold({
147
+ full: 'acme/no-manifest', short: 'no-manifest',
148
+ lock_version: '1.0.0', disk_version: '1.0.0', omit_skill_json: true
149
+ })
150
+ try {
151
+ const { code, stdout } = run(['reconcile', 'acme/no-manifest', '--json'], { cwd: ctx.root })
152
+ 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'))
159
+ } finally { ctx.cleanup() }
160
+ })
161
+
162
+ it('emits resolve_missing_dir next_step when the skill directory is gone', () => {
163
+ const ctx = scaffold({
164
+ full: 'acme/no-dir', short: 'no-dir',
165
+ lock_version: '1.0.0', disk_version: '1.0.0', omit_dir: true
166
+ })
167
+ try {
168
+ const { code, stdout } = run(['reconcile', 'acme/no-dir', '--json'], { cwd: ctx.root })
169
+ 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'))
175
+ } finally { ctx.cleanup() }
176
+ })
177
+
178
+ it('resolves bare skill name from the lock file', () => {
179
+ const ctx = scaffold({ full: 'acme/bare', short: 'bare', lock_version: '1.0.0', disk_version: '1.0.0' })
180
+ try {
181
+ const { code, stdout } = run(['reconcile', 'bare', '--json'], { cwd: ctx.root })
182
+ 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)
186
+ } finally { ctx.cleanup() }
187
+ })
188
+ })