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,209 @@
1
+ 'use strict'
2
+ const { describe, it } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+
5
+ const { determine_target_version, compute_bump } = require('./release')
6
+
7
+ describe('release.compute_bump', () => {
8
+ it('handles patch/minor/major shortcuts', () => {
9
+ assert.strictEqual(compute_bump('1.2.3', 'patch'), '1.2.4')
10
+ assert.strictEqual(compute_bump('1.2.3', 'minor'), '1.3.0')
11
+ assert.strictEqual(compute_bump('1.2.3', 'major'), '2.0.0')
12
+ })
13
+
14
+ it('passes through explicit semver', () => {
15
+ assert.strictEqual(compute_bump('1.2.3', '5.0.0'), '5.0.0')
16
+ })
17
+
18
+ it('returns null for invalid bump and missing base', () => {
19
+ assert.strictEqual(compute_bump('1.2.3', 'gibberish'), null)
20
+ assert.strictEqual(compute_bump(null, 'patch'), null)
21
+ })
22
+ })
23
+
24
+ const fs = require('fs')
25
+ const os = require('os')
26
+ const path = require('path')
27
+
28
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-release-test-'))
29
+
30
+ const write_skill = (dir, manifest, opts = {}) => {
31
+ fs.mkdirSync(dir, { recursive: true })
32
+ fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(manifest, null, '\t'))
33
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), opts.skill_md || `---\nname: ${manifest.name}\ndescription: release-test skill\n---\nbody\n`)
34
+ if (opts.changelog) {
35
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), opts.changelog)
36
+ }
37
+ }
38
+
39
+ describe('release.determine_target_version', () => {
40
+ it('identifies ahead and uses the disk version', async () => {
41
+ const root = make_tmp()
42
+ try {
43
+ const dir = path.join(root, 'ahead')
44
+ write_skill(dir, { name: 'ahead', version: '0.3.3' })
45
+ const lock_entry = { version: '0.3.2' }
46
+ const r = await determine_target_version({
47
+ manifest: { name: 'ahead', version: '0.3.3' },
48
+ lock_entry,
49
+ skill_dir: dir,
50
+ bump_flag: null,
51
+ no_bump: false
52
+ })
53
+ assert.strictEqual(r.status, 'ahead')
54
+ assert.strictEqual(r.target, '0.3.3')
55
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
56
+ })
57
+
58
+ it('refuses ahead when --bump disagrees with disk', async () => {
59
+ const root = make_tmp()
60
+ try {
61
+ const dir = path.join(root, 'ahead-bump-disagree')
62
+ write_skill(dir, { name: 'ahead-bump-disagree', version: '0.5.0' })
63
+ const r = await determine_target_version({
64
+ manifest: { name: 'ahead-bump-disagree', version: '0.5.0' },
65
+ lock_entry: { version: '0.3.2' },
66
+ skill_dir: dir,
67
+ bump_flag: 'patch', // would compute 0.3.3, disk is 0.5.0
68
+ no_bump: false
69
+ })
70
+ assert.strictEqual(r.status, 'bump_disagreement')
71
+ assert.strictEqual(r.disk_version, '0.5.0')
72
+ assert.strictEqual(r.requested_bump, 'patch')
73
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
74
+ })
75
+
76
+ it('flags drift on regression (disk < lock)', async () => {
77
+ const root = make_tmp()
78
+ try {
79
+ const dir = path.join(root, 'reg')
80
+ write_skill(dir, { name: 'reg', version: '0.3.0' })
81
+ const r = await determine_target_version({
82
+ manifest: { name: 'reg', version: '0.3.0' },
83
+ lock_entry: { version: '0.4.0' },
84
+ skill_dir: dir,
85
+ bump_flag: null,
86
+ no_bump: false
87
+ })
88
+ assert.strictEqual(r.status, 'drift')
89
+ assert.strictEqual(r.drift.reason, 'regression')
90
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
91
+ })
92
+
93
+ it('clean + --bump patch produces the new version', async () => {
94
+ const root = make_tmp()
95
+ try {
96
+ const dir = path.join(root, 'clean-bump')
97
+ write_skill(dir, { name: 'clean-bump', version: '1.0.0' })
98
+ const r = await determine_target_version({
99
+ manifest: { name: 'clean-bump', version: '1.0.0' },
100
+ lock_entry: { version: '1.0.0' },
101
+ skill_dir: dir,
102
+ bump_flag: 'patch',
103
+ no_bump: false
104
+ })
105
+ assert.strictEqual(r.status, 'clean_with_bump')
106
+ assert.strictEqual(r.target, '1.0.1')
107
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
108
+ })
109
+
110
+ it('clean + no bump + no CHANGELOG-hint → specify_bump', async () => {
111
+ const root = make_tmp()
112
+ try {
113
+ const dir = path.join(root, 'clean-no-bump')
114
+ write_skill(dir, { name: 'clean-no-bump', version: '1.0.0' })
115
+ const r = await determine_target_version({
116
+ manifest: { name: 'clean-no-bump', version: '1.0.0' },
117
+ lock_entry: { version: '1.0.0' },
118
+ skill_dir: dir,
119
+ bump_flag: null,
120
+ no_bump: false
121
+ })
122
+ assert.strictEqual(r.status, 'specify_bump')
123
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
124
+ })
125
+
126
+ it('clean + CHANGELOG-hint > current → inferred_from_changelog', async () => {
127
+ const root = make_tmp()
128
+ try {
129
+ const dir = path.join(root, 'cl-hint')
130
+ write_skill(dir, { name: 'cl-hint', version: '1.0.0' }, { changelog: '# Changelog\n\n## [1.1.0]\n- new\n' })
131
+ const r = await determine_target_version({
132
+ manifest: { name: 'cl-hint', version: '1.0.0' },
133
+ lock_entry: { version: '1.0.0' },
134
+ skill_dir: dir,
135
+ bump_flag: null,
136
+ no_bump: false
137
+ })
138
+ assert.strictEqual(r.status, 'inferred_from_changelog')
139
+ assert.strictEqual(r.target, '1.1.0')
140
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
141
+ })
142
+
143
+ it('--no-bump on clean returns missing_version', async () => {
144
+ const root = make_tmp()
145
+ try {
146
+ const dir = path.join(root, 'no-bump-clean')
147
+ write_skill(dir, { name: 'no-bump-clean', version: '1.0.0' })
148
+ const r = await determine_target_version({
149
+ manifest: { name: 'no-bump-clean', version: '1.0.0' },
150
+ lock_entry: { version: '1.0.0' },
151
+ skill_dir: dir,
152
+ bump_flag: null,
153
+ no_bump: true
154
+ })
155
+ assert.strictEqual(r.status, 'missing_version')
156
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
157
+ })
158
+
159
+ it('first-publish (no lock entry) treats the disk version as ahead — no --bump required', async () => {
160
+ const root = make_tmp()
161
+ try {
162
+ const dir = path.join(root, 'first-publish')
163
+ write_skill(dir, { name: 'first-publish', version: '0.1.0' })
164
+ const r = await determine_target_version({
165
+ manifest: { name: 'first-publish', version: '0.1.0' },
166
+ lock_entry: null, // no lock entry yet
167
+ skill_dir: dir,
168
+ bump_flag: null,
169
+ no_bump: false
170
+ })
171
+ assert.strictEqual(r.status, 'ahead')
172
+ assert.strictEqual(r.target, '0.1.0')
173
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
174
+ })
175
+
176
+ it('first-publish with --no-bump works (E2E gap from real-registry test)', async () => {
177
+ const root = make_tmp()
178
+ try {
179
+ const dir = path.join(root, 'first-publish-no-bump')
180
+ write_skill(dir, { name: 'first-publish-no-bump', version: '0.1.0' })
181
+ const r = await determine_target_version({
182
+ manifest: { name: 'first-publish-no-bump', version: '0.1.0' },
183
+ lock_entry: null,
184
+ skill_dir: dir,
185
+ bump_flag: null,
186
+ no_bump: true // explicitly no bump — should NOT error now
187
+ })
188
+ assert.strictEqual(r.status, 'ahead', 'first-publish with --no-bump must NOT emit MISSING_VERSION')
189
+ assert.strictEqual(r.target, '0.1.0')
190
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
191
+ })
192
+
193
+ it('--bump with garbage value returns invalid_bump', async () => {
194
+ const root = make_tmp()
195
+ try {
196
+ const dir = path.join(root, 'bad-bump')
197
+ write_skill(dir, { name: 'bad-bump', version: '1.0.0' })
198
+ const r = await determine_target_version({
199
+ manifest: { name: 'bad-bump', version: '1.0.0' },
200
+ lock_entry: { version: '1.0.0' },
201
+ skill_dir: dir,
202
+ bump_flag: 'gibberish',
203
+ no_bump: false
204
+ })
205
+ assert.strictEqual(r.status, 'invalid_bump')
206
+ assert.strictEqual(r.bump, 'gibberish')
207
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
208
+ })
209
+ })
@@ -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