happyskills 0.47.1 → 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.
- package/CHANGELOG.md +63 -0
- package/package.json +1 -1
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/install.js +96 -11
- package/src/commands/list.js +39 -9
- package/src/commands/postlex.js +101 -10
- package/src/commands/postlex.test.js +141 -0
- package/src/commands/publish.js +43 -36
- package/src/commands/pull.js +40 -0
- package/src/commands/reconcile.js +229 -0
- package/src/commands/release.js +451 -0
- package/src/commands/release.test.js +209 -0
- package/src/commands/search.js +2 -0
- package/src/commands/snapshot.js +252 -0
- package/src/commands/status.js +45 -23
- package/src/config/limits.js +11 -0
- package/src/constants.js +4 -1
- package/src/index.js +3 -0
- package/src/integration/bump.test.js +170 -0
- package/src/integration/drift.test.js +63 -8
- package/src/integration/install_fresh.test.js +167 -0
- package/src/integration/reconcile.test.js +188 -0
- package/src/integration/release.test.js +183 -0
- package/src/lock/verify.js +151 -16
- package/src/lock/verify.test.js +182 -10
- package/src/merge/rebase.js +322 -0
- package/src/merge/rebase.test.js +110 -0
- package/src/snapshot/paths.js +31 -0
- package/src/snapshot/storage.js +237 -0
- package/src/snapshot/storage.test.js +298 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
|
@@ -108,7 +108,7 @@ const scaffold_drifted = (skills) => {
|
|
|
108
108
|
// ─── status ───────────────────────────────────────────────────────────────────
|
|
109
109
|
|
|
110
110
|
describe('status — drift detection', () => {
|
|
111
|
-
it('reports drift when
|
|
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: '
|
|
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('
|
|
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: '
|
|
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: '
|
|
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
|
+
})
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for `happyskills release` — § 8.2.
|
|
4
|
+
*
|
|
5
|
+
* Focus: the failure-mode envelopes (drift, missing_version, missing_changelog
|
|
6
|
+
* 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.
|
|
10
|
+
*/
|
|
11
|
+
const { describe, it } = require('node:test')
|
|
12
|
+
const assert = require('node:assert/strict')
|
|
13
|
+
const { spawnSync } = require('child_process')
|
|
14
|
+
const fs = require('fs')
|
|
15
|
+
const os = require('os')
|
|
16
|
+
const path = require('path')
|
|
17
|
+
|
|
18
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
19
|
+
const NODE = process.execPath
|
|
20
|
+
|
|
21
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-release-test-'))
|
|
22
|
+
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 || {}) },
|
|
25
|
+
encoding: 'utf-8',
|
|
26
|
+
timeout: 15000,
|
|
27
|
+
cwd: opts?.cwd
|
|
28
|
+
})
|
|
29
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
30
|
+
}
|
|
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
|
+
|
|
36
|
+
const scaffold = ({ full, short, lock_version, disk_version, changelog }) => {
|
|
37
|
+
const root = make_tmp()
|
|
38
|
+
const skill_dir = path.join(root, '.agents', 'skills', short)
|
|
39
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
path.join(skill_dir, 'SKILL.md'),
|
|
42
|
+
`---\nname: ${short}\ndescription: release test skill for integration coverage\n---\n# ${short}\n\nbody\n`
|
|
43
|
+
)
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
path.join(skill_dir, 'skill.json'),
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
name: short,
|
|
48
|
+
version: disk_version,
|
|
49
|
+
type: 'skill',
|
|
50
|
+
description: 'release test skill for integration coverage',
|
|
51
|
+
keywords: ['testing']
|
|
52
|
+
}, null, '\t')
|
|
53
|
+
)
|
|
54
|
+
if (changelog) {
|
|
55
|
+
fs.writeFileSync(path.join(skill_dir, 'CHANGELOG.md'), changelog)
|
|
56
|
+
}
|
|
57
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
58
|
+
lockVersion: 2,
|
|
59
|
+
generatedAt: new Date().toISOString(),
|
|
60
|
+
skills: {
|
|
61
|
+
[full]: {
|
|
62
|
+
version: lock_version,
|
|
63
|
+
type: 'skill',
|
|
64
|
+
ref: `refs/tags/v${lock_version}`,
|
|
65
|
+
commit: 'lockcommit',
|
|
66
|
+
integrity: 'sha256-baseline',
|
|
67
|
+
base_commit: 'lockcommit',
|
|
68
|
+
base_integrity: 'sha256-baseline',
|
|
69
|
+
requested_by: ['__root__'],
|
|
70
|
+
dependencies: {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}, null, '\t'))
|
|
74
|
+
return {
|
|
75
|
+
root,
|
|
76
|
+
skill_dir,
|
|
77
|
+
read_manifest: () => JSON.parse(fs.readFileSync(path.join(skill_dir, 'skill.json'), 'utf-8')),
|
|
78
|
+
read_lock: () => JSON.parse(fs.readFileSync(path.join(root, 'skills-lock.json'), 'utf-8')),
|
|
79
|
+
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('release — orchestration envelopes', () => {
|
|
84
|
+
it('blocks on regression drift and routes to reconcile', () => {
|
|
85
|
+
const ctx = scaffold({
|
|
86
|
+
full: 'acme/regressed', short: 'regressed',
|
|
87
|
+
lock_version: '1.0.0', disk_version: '0.9.0'
|
|
88
|
+
})
|
|
89
|
+
try {
|
|
90
|
+
const { code, stdout } = run(['release', 'regressed', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
91
|
+
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/)
|
|
96
|
+
} finally { ctx.cleanup() }
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('recognizes ahead via --dry-run and uses the disk version (no revert)', () => {
|
|
100
|
+
const ctx = scaffold({
|
|
101
|
+
full: 'acme/ahead-dry', short: 'ahead-dry',
|
|
102
|
+
lock_version: '0.3.2', disk_version: '0.3.3',
|
|
103
|
+
changelog: '# Changelog\n\n## [0.3.3]\n- added new thing\n'
|
|
104
|
+
})
|
|
105
|
+
try {
|
|
106
|
+
const { code, stdout } = run(['release', 'ahead-dry', '--workspace', 'acme', '--dry-run', '--json'], { cwd: ctx.root })
|
|
107
|
+
assert.strictEqual(code, 0, 'dry-run on ahead must succeed')
|
|
108
|
+
const env = parse_json(stdout, 'release ahead dry-run')
|
|
109
|
+
assert.strictEqual(env.data.dry_run, true)
|
|
110
|
+
assert.strictEqual(env.data.target_version, '0.3.3')
|
|
111
|
+
assert.strictEqual(env.data.ahead_recognized, true)
|
|
112
|
+
assert.strictEqual(env.data.bump_applied, false, 'ahead must NOT trigger a re-bump')
|
|
113
|
+
// Critical: skill.json must NOT have been mutated by release.
|
|
114
|
+
assert.strictEqual(ctx.read_manifest().version, '0.3.3')
|
|
115
|
+
} finally { ctx.cleanup() }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('blocks with MISSING_VERSION on clean + --no-bump', () => {
|
|
119
|
+
const ctx = scaffold({
|
|
120
|
+
full: 'acme/clean', short: 'clean',
|
|
121
|
+
lock_version: '1.0.0', disk_version: '1.0.0'
|
|
122
|
+
})
|
|
123
|
+
try {
|
|
124
|
+
const { code, stdout } = run(['release', 'clean', '--workspace', 'acme', '--no-bump', '--json'], { cwd: ctx.root })
|
|
125
|
+
assert.notStrictEqual(code, 0)
|
|
126
|
+
const env = parse_json(stdout, 'release clean --no-bump')
|
|
127
|
+
assert.strictEqual(env.error.code, 'MISSING_VERSION')
|
|
128
|
+
} finally { ctx.cleanup() }
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('blocks with MISSING_CHANGELOG_ENTRY when bump succeeds but CHANGELOG lacks the new version', () => {
|
|
132
|
+
const ctx = scaffold({
|
|
133
|
+
full: 'acme/no-cl', short: 'no-cl',
|
|
134
|
+
lock_version: '1.0.0', disk_version: '1.0.0',
|
|
135
|
+
changelog: '# Changelog\n\n## [1.0.0]\n- initial\n'
|
|
136
|
+
})
|
|
137
|
+
try {
|
|
138
|
+
const { code, stdout } = run(['release', 'no-cl', '--bump', 'patch', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
139
|
+
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')
|
|
143
|
+
} finally { ctx.cleanup() }
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('emits bump_disagreement when --bump value contradicts an already-ahead disk', () => {
|
|
147
|
+
const ctx = scaffold({
|
|
148
|
+
full: 'acme/disagree', short: 'disagree',
|
|
149
|
+
lock_version: '0.1.0', disk_version: '0.5.0'
|
|
150
|
+
})
|
|
151
|
+
try {
|
|
152
|
+
const { code, stdout } = run(['release', 'disagree', '--bump', 'patch', '--workspace', 'acme', '--json'], { cwd: ctx.root })
|
|
153
|
+
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')
|
|
157
|
+
assert.strictEqual(env.next_step.context.disk_version, '0.5.0')
|
|
158
|
+
} finally { ctx.cleanup() }
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('§2 scenario re-test: ahead state ends in a successful dry-run with NO revert and NO drift error', () => {
|
|
162
|
+
// This is the canonical regression test for the original failure.
|
|
163
|
+
// 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.
|
|
167
|
+
const ctx = scaffold({
|
|
168
|
+
full: 'happyskillsai/happyskills-help', short: 'happyskills-help',
|
|
169
|
+
lock_version: '0.3.2', disk_version: '0.3.3',
|
|
170
|
+
changelog: '# Changelog\n\n## [0.3.3] - 2026-05-23\n\n### Added\n- directive vs invitation register routing\n'
|
|
171
|
+
})
|
|
172
|
+
try {
|
|
173
|
+
const { code, stdout } = run(['release', 'happyskills-help', '--workspace', 'happyskillsai', '--dry-run', '--json'], { cwd: ctx.root })
|
|
174
|
+
assert.strictEqual(code, 0, '§2 scenario must NOT block — ahead is normal authoring')
|
|
175
|
+
const env = parse_json(stdout, '§2 scenario re-test')
|
|
176
|
+
assert.strictEqual(env.data.dry_run, true)
|
|
177
|
+
assert.strictEqual(env.data.target_version, '0.3.3', 'must publish the disk version')
|
|
178
|
+
assert.strictEqual(env.data.ahead_recognized, true)
|
|
179
|
+
// Snapshot was created and (because dry-run) restored — disk must be unchanged.
|
|
180
|
+
assert.strictEqual(ctx.read_manifest().version, '0.3.3')
|
|
181
|
+
} finally { ctx.cleanup() }
|
|
182
|
+
})
|
|
183
|
+
})
|