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.
@@ -0,0 +1,252 @@
1
+ const path = require('path')
2
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
3
+ const storage = require('../snapshot/storage')
4
+ const { find_project_root, skills_dir, skill_install_dir } = require('../config/paths')
5
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
6
+ const { print_help, print_success, print_info, print_table, print_json, print_warn } = require('../ui/output')
7
+ const { exit_with_error, UsageError } = require('../utils/errors')
8
+ const { EXIT_CODES } = require('../constants')
9
+
10
+ const HELP_TEXT = `Usage: happyskills snapshot <subcommand> [args] [options]
11
+
12
+ Capture and restore skill state — the safety net for every mutation.
13
+
14
+ Subcommands:
15
+ create <owner/skill> Capture skill directory + lock entry
16
+ list <owner/skill> List existing snapshots for a skill
17
+ restore <snapshot-id> Restore a skill from a snapshot
18
+ delete <snapshot-id> Delete a snapshot
19
+ prune <owner/skill> Keep only the N most recent snapshots
20
+
21
+ Options:
22
+ --note <text> Attach a note to a new snapshot (create only)
23
+ --keep <n> Retention count for prune (default: 10)
24
+ -g, --global Operate on the global snapshot store
25
+ --json Output as JSON
26
+
27
+ Examples:
28
+ happyskills snapshot create acme/deploy-aws --note "pre-release" --json
29
+ happyskills snapshot list acme/deploy-aws --json
30
+ happyskills snapshot restore snap_20260523T103045Z_abc123 --json
31
+ happyskills snapshot delete snap_20260523T103045Z_abc123 --json
32
+ happyskills snapshot prune acme/deploy-aws --keep 5 --json`
33
+
34
+ const parse_skill_arg = (raw) => {
35
+ if (!raw) return null
36
+ if (raw.includes('/')) {
37
+ const [workspace, ...rest] = raw.split('/')
38
+ return { workspace, skill: rest.join('/'), short: rest.join('/') }
39
+ }
40
+ return { workspace: null, skill: raw, short: raw }
41
+ }
42
+
43
+ const resolve_from_lock = (short_name, project_root) => catch_errors('Failed to resolve skill from lock', async () => {
44
+ const [, lock_data] = await read_lock(project_root)
45
+ if (!lock_data) return null
46
+ const all = get_all_locked_skills(lock_data)
47
+ const suffix = `/${short_name}`
48
+ const key = Object.keys(all).find(k => k === short_name || k.endsWith(suffix))
49
+ if (!key) return null
50
+ const [workspace, name] = key.includes('/') ? key.split('/') : [null, key]
51
+ return { workspace, skill: name, lock_entry: all[key] }
52
+ })
53
+
54
+ const do_create = (args) => catch_errors('Snapshot create failed', async () => {
55
+ const raw = args._[1]
56
+ if (!raw) throw new UsageError('Usage: happyskills snapshot create <owner/skill> [--note <text>]')
57
+
58
+ const project_root = find_project_root()
59
+ const is_global = !!args.flags.global
60
+ const parsed = parse_skill_arg(raw)
61
+ let workspace = parsed.workspace
62
+ let skill = parsed.skill
63
+ let lock_entry = null
64
+
65
+ const [, resolved] = await resolve_from_lock(parsed.short, project_root)
66
+ if (resolved) {
67
+ if (!workspace) workspace = resolved.workspace
68
+ skill = resolved.skill
69
+ lock_entry = resolved.lock_entry
70
+ }
71
+
72
+ if (!workspace) {
73
+ throw new UsageError(`Cannot determine workspace for "${raw}" — pass it as owner/skill or install via the registry first.`)
74
+ }
75
+
76
+ const base_dir = skills_dir(is_global, project_root)
77
+ const skill_dir = skill_install_dir(base_dir, skill)
78
+
79
+ const [err, result] = await storage.create({
80
+ skill_dir, workspace, skill, lock_entry,
81
+ note: typeof args.flags.note === 'string' ? args.flags.note : null,
82
+ is_global, project_root
83
+ })
84
+ if (err) throw e('Failed to create snapshot', err)
85
+
86
+ if (args.flags.json) {
87
+ print_json({ data: result })
88
+ return
89
+ }
90
+ print_success(`Snapshot ${result.snapshot_id}`)
91
+ print_info(` Path: ${result.path}`)
92
+ if (result.note) print_info(` Note: ${result.note}`)
93
+ if (result.pruned?.length > 0) print_info(` Pruned ${result.pruned.length} older snapshot(s)`)
94
+ })
95
+
96
+ const do_list = (args) => catch_errors('Snapshot list failed', async () => {
97
+ const raw = args._[1]
98
+ if (!raw) throw new UsageError('Usage: happyskills snapshot list <owner/skill>')
99
+
100
+ const project_root = find_project_root()
101
+ const is_global = !!args.flags.global
102
+ const parsed = parse_skill_arg(raw)
103
+ let workspace = parsed.workspace
104
+ let skill = parsed.skill
105
+
106
+ if (!workspace) {
107
+ const [, resolved] = await resolve_from_lock(parsed.short, project_root)
108
+ if (resolved) {
109
+ workspace = resolved.workspace
110
+ skill = resolved.skill
111
+ }
112
+ }
113
+
114
+ if (!workspace) {
115
+ if (args.flags.json) {
116
+ print_json({ data: { snapshots: [] } })
117
+ return
118
+ }
119
+ print_info('No workspace resolved — no snapshots to list.')
120
+ return
121
+ }
122
+
123
+ const [err, result] = await storage.list({ workspace, skill, is_global, project_root })
124
+ if (err) throw e('Failed to list snapshots', err)
125
+
126
+ if (args.flags.json) {
127
+ print_json({ data: { workspace, skill, snapshots: result.snapshots } })
128
+ return
129
+ }
130
+
131
+ if (result.snapshots.length === 0) {
132
+ print_info(`No snapshots for ${workspace}/${skill}.`)
133
+ return
134
+ }
135
+ const rows = result.snapshots.map(s => [
136
+ s.snapshot_id,
137
+ s.timestamp,
138
+ (s.note || '').slice(0, 60)
139
+ ])
140
+ print_table(['Snapshot ID', 'Timestamp', 'Note'], rows)
141
+ })
142
+
143
+ const do_restore = (args) => catch_errors('Snapshot restore failed', async () => {
144
+ const snapshot_id = args._[1]
145
+ if (!snapshot_id) throw new UsageError('Usage: happyskills snapshot restore <snapshot-id>')
146
+
147
+ const project_root = find_project_root()
148
+ const is_global = !!args.flags.global
149
+
150
+ const [find_err, found] = await storage.find_snapshot(snapshot_id, { is_global, project_root })
151
+ if (find_err) throw e('Failed to look up snapshot', find_err)
152
+ if (!found) {
153
+ throw new Error(`Snapshot not found: ${snapshot_id}`)
154
+ }
155
+
156
+ const base_dir = skills_dir(is_global, project_root)
157
+ const skill_dir = skill_install_dir(base_dir, found.skill)
158
+
159
+ const [err, result] = await storage.restore(snapshot_id, { skill_dir, is_global, project_root })
160
+ if (err) throw e('Failed to restore snapshot', err)
161
+
162
+ if (args.flags.json) {
163
+ print_json({ data: result })
164
+ return
165
+ }
166
+ print_success(`Restored ${result.workspace}/${result.skill} from ${snapshot_id}`)
167
+ })
168
+
169
+ const do_delete = (args) => catch_errors('Snapshot delete failed', async () => {
170
+ const snapshot_id = args._[1]
171
+ if (!snapshot_id) throw new UsageError('Usage: happyskills snapshot delete <snapshot-id>')
172
+ const project_root = find_project_root()
173
+ const is_global = !!args.flags.global
174
+
175
+ const [err, result] = await storage.remove(snapshot_id, { is_global, project_root })
176
+ if (err) throw e('Failed to delete snapshot', err)
177
+
178
+ if (args.flags.json) {
179
+ print_json({ data: result })
180
+ return
181
+ }
182
+ if (result.deleted) print_success(`Deleted ${snapshot_id}`)
183
+ else print_warn(`No snapshot deleted (${result.reason || 'unknown'}).`)
184
+ })
185
+
186
+ const do_prune = (args) => catch_errors('Snapshot prune failed', async () => {
187
+ const raw = args._[1]
188
+ if (!raw) throw new UsageError('Usage: happyskills snapshot prune <owner/skill> [--keep <n>]')
189
+
190
+ const project_root = find_project_root()
191
+ const is_global = !!args.flags.global
192
+ const parsed = parse_skill_arg(raw)
193
+ let workspace = parsed.workspace
194
+ let skill = parsed.skill
195
+
196
+ if (!workspace) {
197
+ const [, resolved] = await resolve_from_lock(parsed.short, project_root)
198
+ if (resolved) {
199
+ workspace = resolved.workspace
200
+ skill = resolved.skill
201
+ }
202
+ }
203
+
204
+ if (!workspace) {
205
+ throw new UsageError(`Cannot determine workspace for "${raw}" — pass it as owner/skill.`)
206
+ }
207
+
208
+ const keep_raw = args.flags.keep
209
+ const keep = keep_raw === undefined || keep_raw === true ? storage.DEFAULT_RETENTION : parseInt(keep_raw, 10)
210
+ if (!Number.isFinite(keep) || keep < 0) {
211
+ throw new UsageError(`--keep must be a non-negative integer (got "${keep_raw}")`)
212
+ }
213
+
214
+ const [err, result] = await storage.prune({ workspace, skill, is_global, project_root, keep })
215
+ if (err) throw e('Failed to prune snapshots', err)
216
+
217
+ if (args.flags.json) {
218
+ print_json({ data: result })
219
+ return
220
+ }
221
+ print_success(`Kept ${result.kept}, deleted ${result.deleted}`)
222
+ })
223
+
224
+ const SUBCOMMANDS = {
225
+ create: do_create,
226
+ list: do_list,
227
+ restore: do_restore,
228
+ delete: do_delete,
229
+ rm: do_delete,
230
+ prune: do_prune
231
+ }
232
+
233
+ const run = (args) => catch_errors('Snapshot failed', async () => {
234
+ if (args.flags._show_help) {
235
+ print_help(HELP_TEXT)
236
+ return process.exit(EXIT_CODES.SUCCESS)
237
+ }
238
+
239
+ const sub = args._[0]
240
+ if (!sub) {
241
+ throw new UsageError('Usage: happyskills snapshot <create|list|restore|delete|prune> ...')
242
+ }
243
+ const handler = SUBCOMMANDS[sub]
244
+ if (!handler) {
245
+ throw new UsageError(`Unknown snapshot subcommand: "${sub}". Run \`happyskills snapshot --help\`.`)
246
+ }
247
+
248
+ const [err] = await handler(args)
249
+ if (err) throw err
250
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
251
+
252
+ module.exports = { run }
@@ -1,7 +1,7 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
3
3
  const { detect_status } = require('../merge/detector')
4
- const { verify_lock_disk_consistency, describe_drift } = require('../lock/verify')
4
+ const { verify_lock_disk_consistency, detect_ahead_state, describe_drift } = require('../lock/verify')
5
5
  const repos_api = require('../api/repos')
6
6
  const { print_help, print_info, print_json, print_warn, print_hint, code } = require('../ui/output')
7
7
  const { exit_with_error, UsageError } = require('../utils/errors')
@@ -26,12 +26,13 @@ Examples:
26
26
  happyskills st acme/deploy-aws
27
27
  happyskills status --json`
28
28
 
29
- // Drift outranks every other status: when the lock and the on-disk skill.json
30
- // disagree about what's installed, every other comparison (modified/outdated/
31
- // diverged/conflicts) is computed against an untrustworthy baseline.
32
- const classify = (drift, local_modified, remote_updated, has_conflicts) => {
29
+ // §10.5 — seven canonical states. Drift outranks every other status because
30
+ // the install-record baseline is broken; ahead is reported above local/remote
31
+ // composites because it represents authoring intent the system should preserve.
32
+ const classify = (drift, ahead, local_modified, remote_updated, has_conflicts) => {
33
33
  if (drift && !drift.ok) return 'drift'
34
34
  if (has_conflicts) return 'conflicts'
35
+ if (ahead) return 'ahead'
35
36
  if (local_modified && remote_updated) return 'diverged'
36
37
  if (local_modified) return 'modified'
37
38
  if (remote_updated) return 'outdated'
@@ -92,20 +93,27 @@ const run = (args) => catch_errors('Status failed', async () => {
92
93
 
93
94
  const base_dir = skills_dir(is_global, project_root)
94
95
 
95
- // Detect drift (lock-vs-disk version mismatch) and local modifications in parallel.
96
- // The drift probe is cheap (one skill.json read + version compare); detect_status
97
- // is the heavier integrity check. Running both lets us distinguish "user edited
98
- // content" from "structural baseline broken".
96
+ // Detect drift, ahead, and local modifications in parallel. The drift probe
97
+ // is cheap (one skill.json read + version compare); detect_status is the
98
+ // heavier integrity check. Running them all lets us distinguish "user edited
99
+ // content" from "structural baseline broken" from "author bumped locally".
99
100
  const detections = await Promise.all(entries.map(async ([name, data]) => {
100
- if (!data) return { name, data, det: null, drift: null }
101
+ if (!data) return { name, data, det: null, drift: null, ahead: null }
101
102
  const short_name = name.split('/')[1] || name
102
103
  const dir = skill_install_dir(base_dir, short_name)
103
104
  const [, drift] = await verify_lock_disk_consistency(data, dir)
104
105
  const [, det] = await detect_status(data, dir, { skill_name: name, project_root, is_global })
105
- return { name, data, det, drift }
106
+ // Only check ahead when drift is clean — drift represents a broken
107
+ // baseline that should be reported first.
108
+ let ahead = null
109
+ if (drift && drift.ok) {
110
+ const [, ahead_state] = await detect_ahead_state(data, dir)
111
+ if (ahead_state && ahead_state.ahead) ahead = ahead_state
112
+ }
113
+ return { name, data, det, drift, ahead }
106
114
  }))
107
115
 
108
- const results = detections.map(({ name, data, det, drift }) => {
116
+ const results = detections.map(({ name, data, det, drift, ahead }) => {
109
117
  if (!data) return { skill: name, status: 'not_found', local_modified: false, remote_updated: false }
110
118
  const has_conflicts = (data.conflict_files || []).length > 0
111
119
  return {
@@ -122,7 +130,13 @@ const run = (args) => catch_errors('Status failed', async () => {
122
130
  drift: drift && !drift.ok
123
131
  ? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
124
132
  : null,
125
- status: drift && !drift.ok ? 'drift' : (has_conflicts ? 'conflicts' : 'clean')
133
+ ahead: ahead ? {
134
+ lock_version: ahead.lock_version,
135
+ disk_version: ahead.disk_version,
136
+ has_changelog_entry: ahead.has_changelog_entry || false,
137
+ changelog_version: ahead.changelog_version || null
138
+ } : null,
139
+ status: drift && !drift.ok ? 'drift' : (has_conflicts ? 'conflicts' : (ahead ? 'ahead' : 'clean'))
126
140
  }
127
141
  })
128
142
 
@@ -145,11 +159,13 @@ const run = (args) => catch_errors('Status failed', async () => {
145
159
  }
146
160
 
147
161
  // Classify each result. The drift field is the canonical structural-baseline
148
- // signal; pass it to classify so it can outrank everything else.
162
+ // signal; pass it to classify so it can outrank everything else. The ahead
163
+ // field is the canonical author-intent signal — it preserves the local bump
164
+ // during the publish flow.
149
165
  for (const r of results) {
150
166
  if (r.status !== 'not_found') {
151
167
  const drift_check = r.drift ? { ok: false } : { ok: true }
152
- r.status = classify(drift_check, r.local_modified, r.remote_updated, r.conflict_files.length > 0)
168
+ r.status = classify(drift_check, !!r.ahead, r.local_modified, r.remote_updated, r.conflict_files.length > 0)
153
169
  }
154
170
  }
155
171
 
@@ -174,12 +190,13 @@ const run = (args) => catch_errors('Status failed', async () => {
174
190
  expected: r.drift?.lock_version,
175
191
  actual: r.drift?.disk_version
176
192
  })
177
- : r.status === 'conflicts' ? 'conflicts (unresolved merge conflicts)'
178
- : r.status === 'diverged' ? 'diverged (local + remote changes)'
179
- : r.status === 'modified' ? 'modified (local changes)'
180
- : r.status === 'outdated' ? 'outdated (remote changes)'
181
- : r.status === 'not_found' ? 'not found'
182
- : 'clean'
193
+ : r.status === 'ahead' ? `ahead (disk ${r.ahead?.disk_version}, lock ${r.ahead?.lock_version})`
194
+ : r.status === 'conflicts' ? 'conflicts (unresolved merge conflicts)'
195
+ : r.status === 'diverged' ? 'diverged (local + remote changes)'
196
+ : r.status === 'modified' ? 'modified (local changes)'
197
+ : r.status === 'outdated' ? 'outdated (remote changes)'
198
+ : r.status === 'not_found' ? 'not found'
199
+ : 'clean'
183
200
  }))
184
201
 
185
202
  const w_skill = Math.max(col_skill.length, ...rows.map(r => r.skill.length))
@@ -194,11 +211,16 @@ const run = (args) => catch_errors('Status failed', async () => {
194
211
  }
195
212
 
196
213
  const drifted = results.filter(r => r.status === 'drift')
214
+ const ahead_skills = results.filter(r => r.status === 'ahead')
197
215
  if (drifted.length > 0) {
198
216
  console.log()
199
217
  print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
200
- print_hint(`Restore install record: ${code('happyskills install <skill> --fresh')}`)
201
- print_hint(`Or accept the on-disk version: bump the lock by reinstalling at the disk version.`)
218
+ print_hint(`Repair: ${code('happyskills reconcile <skill>')}`)
219
+ }
220
+ if (ahead_skills.length > 0) {
221
+ console.log()
222
+ print_info(`${ahead_skills.length} skill(s) ahead of lock — bumped locally, not yet published.`)
223
+ print_hint(`Publish: ${code('happyskills publish <skill>')} (lock catches up atomically)`)
202
224
  }
203
225
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
204
226
 
@@ -0,0 +1,11 @@
1
+ // Size limits enforced before publish. Must stay in sync with
2
+ // api/app/config/limits.js — a drift means a publish that passes CLI
3
+ // validation will be rejected by the API.
4
+
5
+ const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB per file
6
+ const MAX_TOTAL_SIZE = 1 * 1024 * 1024 // 1 MB per skill bundle
7
+
8
+ module.exports = {
9
+ MAX_FILE_SIZE,
10
+ MAX_TOTAL_SIZE
11
+ }
package/src/constants.js CHANGED
@@ -74,7 +74,10 @@ const COMMANDS = [
74
74
  'versions',
75
75
  'changelog',
76
76
  'agents',
77
- 'postlex'
77
+ 'postlex',
78
+ 'snapshot',
79
+ 'reconcile',
80
+ 'release'
78
81
  ]
79
82
 
80
83
  module.exports = {
package/src/index.js CHANGED
@@ -113,6 +113,9 @@ Commands:
113
113
  login Authenticate with the registry
114
114
  logout Clear stored credentials
115
115
  whoami Show current user
116
+ snapshot <sub> Capture and restore skill state (create, list, restore, delete, prune)
117
+ reconcile <owner/skill> Diagnose and repair lock-vs-disk drift
118
+ release <skill-name> Atomic release: snapshot + validate + bump + publish
116
119
 
117
120
  Global flags:
118
121
  --help Show help for a command
@@ -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
+ })