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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.49.0] - 2026-05-23
11
+
12
+ This release combines two streams of work: **spec 260523-02 (Skill Update Determinism)** which introduces four new CLI primitives plus an `ahead` status to make skill update flows deterministic and safe-to-attempt, AND a **bundle-size enforcement** pass that adds a total-bundle cap to pre-publish validation.
13
+
14
+ ### Added
15
+
16
+ #### Determinism primitives (spec 260523-02)
17
+
18
+ - **New `snapshot` command** — capture and restore skill state. Subcommands: `create`, `list`, `restore`, `delete`, `prune`. Snapshots live under `.happyskills/snapshots/<workspace>/<skill>/<snapshot_id>/` and contain a full directory copy plus the lock entry. Default retention 10 per skill; auto-prune on create; configurable via `--keep <n>`. Atomic restore via filesystem rename. Surfaced as the safety net for every non-trivial mutation in the new primitives below.
19
+ - **New `reconcile` command** — deterministic drift repair. Diagnoses the drift subtype and either fixes it on the spot (with `--apply <action>`) or emits a structured `next_step` envelope for operator adjudication. **No-ops on `ahead`** (disk version > lock — that's normal authoring, not drift). Replaces the prose drift-repair procedure that previously routed through `install --fresh`.
20
+ - **New `release` command** — atomic release pipeline. Wraps snapshot + validate + bump (when needed) + changelog verification + publish + lock update + snapshot cleanup as a single deterministic CLI invocation. Recognizes the `ahead` state directly: when `skill.json` is already ahead of lock, the disk version IS the version to publish — no revert, no re-bump. On any failure restores the snapshot and returns a structured `next_step` envelope (`fix_validation_errors`, `specify_bump_type`, `provide_changelog`, `pull_rebase_first`, `specify_workspace`, `reconcile_first`, `resolve_bump_disagreement`).
21
+ - **New `pull --rebase` flag** — snapshot-backed rebase-style pull. Captures local edits as patches, fast-forwards to the registry head, reapplies the patches. On rejection emits a structured envelope with per-file expected/actual context so an operator (LLM) can produce a corrected patch without re-reading the entire file. The standard 3-way merge mode remains the default.
22
+
23
+ #### Status taxonomy (spec 260523-02 § 10.5)
24
+
25
+ - **New `ahead` top-level status** in `list`, `status`, and `check` JSON envelopes. Indicates the normal authoring-ahead state (disk version > lock version) — the author has bumped locally and not yet published. Carries an `ahead: { lock_version, disk_version, has_changelog_entry, changelog_version }` object. Replaces the previous false-positive `drift.version_mismatch` classification for this case.
26
+ - **Narrowed `drift` reasons.** The previous `version_mismatch` reason is removed; `drift.reason` now ranges over `regression` (disk semver-LESS than lock), `missing_skill_json`, and `missing_dir`. Drift is now strictly genuine inconsistency in the local install record.
27
+
28
+ #### `install --fresh` hardening (spec 260523-02 § 8.5)
29
+
30
+ - **Pre-flight version-existence check.** Before any disk mutation, `install <skill>@<version> --fresh` calls the registry to confirm `<version>` exists. If not (or registry unreachable), hard-fails with `USAGE_ERROR` (exit 2) and a `VERSION_NOT_FOUND` message. **Closes the silent-fallback footgun** documented in spec 260523-02 § 2.3 — previously the CLI quietly installed the latest published version when the requested version was missing, with no error envelope.
31
+ - **Snapshot-first.** When the skill directory exists, `--fresh` now captures a snapshot before wiping and exposes `data.snapshot_id` in the success response so the operator can restore manually if needed.
32
+ - **New `--force-discard-local` flag.** When the skill directory has local edits (integrity hash differs from `base_integrity`), `--fresh` refuses with `LOCAL_EDITS_PRESENT` (exit 2) unless `--force-discard-local` is passed. Makes the "throw away my edits" intent explicit.
33
+
34
+ #### Bundle-size enforcement
35
+
36
+ - **New `cli/src/config/limits.js`** — single source of truth for the size limits enforced before publish. Exports `MAX_FILE_SIZE` (1 MB per file) and `MAX_TOTAL_SIZE` (1 MB total bundle). The header comment pins this file as the mirror of `api/app/config/limits.js`; drift between the two will cause the API to reject a publish that the CLI just told the user was fine, so they must be bumped together.
37
+ - **New `max_total_size` validation rule** in `cli/src/validation/file_size_rules.js`. The rule sums the bytes of every non-hidden, non-`node_modules` file under the skill directory and fails pre-publish validation when the total exceeds `MAX_TOTAL_SIZE`. Previously only the per-file `max_file_size` rule existed — a skill made of many sub-1 MB files whose total exceeded the bundle cap would pass `happyskills validate` locally and only fail server-side at `POST /push/initiate` with a `PAYLOAD_TOO_LARGE`. The new rule surfaces the same verdict locally with a clearer message (`"Skill bundle exceeds 1MB limit (X.XX MB)"`) before any presigned-URL or upload work happens.
38
+
39
+ ### Changed
40
+
41
+ - **`bump` no longer touches the lock file** (spec 260523-02 § 8.6). Previously `bump` updated both `skill.json` and the lock entry atomically. Under the new lock-as-registry-view principle, a local bump is not a registry interaction — the lock catches up at publish time, atomically with registry acceptance. After bump, the skill enters the `ahead` state until the next publish. Hand-editing `skill.json`'s version field is now functionally equivalent to `bump` for the lock contract (both produce `ahead`); `bump` retains its ergonomic advantages (validation, semver arithmetic, remote-exists warning) but is no longer privileged for correctness.
42
+ - **`publish` now writes the lock file on first publish.** Previously the lock-write block was gated on an existing lock file; in fresh projects (no lock yet), first publish succeeded against the registry but left no local lock entry, causing downstream `list`/`status`/`check` to treat the just-published skill as external. Now `publish` initializes a new lock structure if none exists and persists the new entry atomically.
43
+ - **`list`/`status`/`check` human output** updated to surface the `ahead` state distinctly from `drift`. Stale guidance pointing users at `install --fresh` for drift repair replaced with pointers to `reconcile`.
44
+ - **`pull.js`** dispatches to a new `merge/rebase.js` module when `--rebase` is set; the existing 3-way merge mode is unchanged.
45
+ - **`cli/src/validation/file_size_rules.js`** no longer hardcodes its own `1 * 1024 * 1024` literal — it imports `MAX_FILE_SIZE` and `MAX_TOTAL_SIZE` from `cli/src/config/limits.js`. Same numeric behavior for the per-file rule; the user-facing message now interpolates the limit from the constant rather than reading "1MB" verbatim, so a future bump only requires editing the config file.
46
+ - **CLI registers four new commands** in `constants.js` (`snapshot`, `reconcile`, `release`, plus the `pull --rebase` flag on the existing `pull` command). `index.js` help text updated to surface them.
47
+
48
+ ### Fixed
49
+
50
+ - **Silent first-publish lock-creation bug.** `publish.js`'s lock-write was wrapped in `if (!lock_err && lock_data)` so a fresh project (no lock file) silently never created one even after a successful registry publish. The newly-published skill then appeared as `external` in `list` output. Unrelated to spec 260523-02 in origin but surfaced during its E2E verification.
51
+
52
+ ### Notes for skill authors
53
+
54
+ - The CLI status taxonomy change is observable: code that depended on `drift.reason === "version_mismatch"` should now branch on `status === "ahead"` for the disk > lock case, or on `drift.reason === "regression"` for disk < lock. All first-party HappySkills skills (`happyskills`, `happyskills-publish`, `happyskills-sync`) have been updated and require this CLI version or newer (`requires.happyskills >= 0.49.0`).
55
+ - The `bump` behavior change is observable but strictly safer. No documented contract was broken: bump's lock-update was internal bookkeeping, not a published interface. Third-party code that depended on `bump` keeping the lock in sync should switch to `release` (which performs the atomic update at publish time as designed).
56
+ - `install --fresh @<missing-version>` previously succeeded with a silent fallback; it now fails fast. Strictly safer — any legitimate caller is rare and would benefit from the explicit error.
57
+
10
58
  ## [0.48.0] - 2026-05-22
11
59
 
12
60
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -6,9 +6,6 @@ const { inc, valid } = require('../utils/semver')
6
6
  const { resolve_skill_dir } = require('../utils/resolve_skill')
7
7
  const { find_project_root } = require('../config/paths')
8
8
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
9
- const { write_lock, update_lock_skills } = require('../lock/writer')
10
- const { hash_directory } = require('../lock/integrity')
11
- const { verify_lock_disk_consistency } = require('../lock/verify')
12
9
  const repos_api = require('../api/repos')
13
10
  const { print_help, print_success, print_warn, print_json, code } = require('../ui/output')
14
11
  const { exit_with_error, UsageError } = require('../utils/errors')
@@ -52,13 +49,15 @@ const run = (args) => catch_errors('Bump failed', async () => {
52
49
 
53
50
  const old_version = manifest.version
54
51
 
55
- // Read lock file early to resolve full skill name for remote check + lock update
52
+ // Read lock file only to resolve the full skill name for the remote
53
+ // existence warning. The lock is NOT updated here — bump only touches
54
+ // skill.json. The lock catches up at publish time, atomically with
55
+ // registry acceptance. (Spec 260523-02 § 8.6.)
56
56
  const project_root = find_project_root()
57
57
  const [lock_err, lock_data] = await read_lock(project_root)
58
58
  let lock_key = null
59
- let all_skills = null
60
59
  if (!lock_err && lock_data) {
61
- all_skills = get_all_locked_skills(lock_data)
60
+ const all_skills = get_all_locked_skills(lock_data)
62
61
  const suffix = `/${skill_name}`
63
62
  lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix)) || null
64
63
  }
@@ -90,29 +89,10 @@ const run = (args) => catch_errors('Bump failed', async () => {
90
89
  const [write_err] = await write_manifest(dir, manifest)
91
90
  if (write_err) throw e('Failed to update skill.json', write_err)
92
91
 
93
- if (lock_key && all_skills?.[lock_key]) {
94
- const [hash_err, integrity] = await hash_directory(dir)
95
- const updated_entry = {
96
- ...all_skills[lock_key],
97
- version: manifest.version,
98
- ref: `refs/tags/v${manifest.version}`
99
- }
100
- if (!hash_err && integrity) updated_entry.integrity = integrity
101
- const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
102
- await write_lock(project_root, updated_skills)
103
-
104
- // Post-write verification — bump touches both files, so confirm they
105
- // agree before declaring success. An interrupted bump (e.g. process
106
- // killed between write_manifest and write_lock above) is exactly the
107
- // failure mode this guard exists to catch.
108
- const [, verify_result] = await verify_lock_disk_consistency(updated_entry, dir)
109
- if (verify_result && !verify_result.ok) {
110
- throw new Error(
111
- `Bump completed but lock and disk disagree (lock ${verify_result.expected}, disk ${verify_result.actual || 'none'}). ` +
112
- `Re-run ${code(`happyskills install ${lock_key} --fresh`)} to restore.`
113
- )
114
- }
115
- }
92
+ // § 8.6: bump no longer updates the lock. The lock represents what was
93
+ // last successfully installed or published from the registry, and a
94
+ // local bump is not a registry interaction. The skill enters the `ahead`
95
+ // state (disk version > lock version) until the next publish.
116
96
 
117
97
  if (args.flags.json) {
118
98
  const bump_type = BUMP_TYPES.includes(input) ? input : 'explicit'
@@ -1,6 +1,6 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
3
- const { verify_lock_disk_consistency } = require('../lock/verify')
3
+ const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
4
4
  const repos_api = require('../api/repos')
5
5
  const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
6
6
  const { green, yellow, red } = require('../ui/colors')
@@ -63,21 +63,39 @@ const run = (args) => catch_errors('Check failed', async () => {
63
63
  // Verify lock-vs-disk consistency for every skill before classifying. Drift
64
64
  // must outrank up-to-date / outdated / conflicts because all those statuses
65
65
  // trust the lock as a baseline — and drift means the baseline is broken.
66
+ // Also detect the ahead state — disk > lock is normal authoring, not drift,
67
+ // and should be reported as such per §10.5.
66
68
  const base_dir = skills_dir(false, project_root)
67
69
  const drift_by_skill = {}
70
+ const ahead_by_skill = {}
68
71
  await Promise.all(to_check.map(async ([name, data]) => {
69
72
  const short_name = name.split('/')[1] || name
70
73
  const dir = skill_install_dir(base_dir, short_name)
71
74
  const [, drift] = await verify_lock_disk_consistency(data, dir)
72
- if (drift && !drift.ok) drift_by_skill[name] = drift
75
+ if (drift && !drift.ok) {
76
+ drift_by_skill[name] = drift
77
+ return
78
+ }
79
+ const [, ahead] = await detect_ahead_state(data, dir)
80
+ if (ahead && ahead.ahead) ahead_by_skill[name] = ahead
73
81
  }))
74
82
 
83
+ const build_ahead_obj = (ahead) => ({
84
+ lock_version: ahead.lock_version,
85
+ disk_version: ahead.disk_version,
86
+ has_changelog_entry: ahead.has_changelog_entry || false,
87
+ changelog_version: ahead.changelog_version || null
88
+ })
89
+
75
90
  const results = []
76
91
  if (batch_err) {
77
92
  for (const [name, data] of to_check) {
78
93
  const drift = drift_by_skill[name]
94
+ const ahead = ahead_by_skill[name]
79
95
  if (drift) {
80
96
  results.push({ skill: name, installed: data.version, latest: '-', status: 'drift', via: get_via(data), drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
97
+ } else if (ahead) {
98
+ results.push({ skill: name, installed: data.version, latest: '-', status: 'ahead', via: get_via(data), ahead: build_ahead_obj(ahead) })
81
99
  } else {
82
100
  results.push({ skill: name, installed: data.version, latest: 'error', status: 'error', via: get_via(data) })
83
101
  }
@@ -88,8 +106,11 @@ const run = (args) => catch_errors('Check failed', async () => {
88
106
  const via = get_via(data)
89
107
  const has_conflicts = (data.conflict_files || []).length > 0
90
108
  const drift = drift_by_skill[name]
109
+ const ahead = ahead_by_skill[name]
91
110
  if (drift) {
92
111
  results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'drift', via, drift: { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } })
112
+ } else if (ahead) {
113
+ results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'ahead', via, ahead: build_ahead_obj(ahead) })
93
114
  } else if (has_conflicts) {
94
115
  results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts', via })
95
116
  } else if (info?.access_denied) {
@@ -112,7 +133,8 @@ const run = (args) => catch_errors('Check failed', async () => {
112
133
  const up_to_date_count = results.filter(r => r.status === 'up-to-date').length
113
134
  const conflicts_count = results.filter(r => r.status === 'conflicts').length
114
135
  const drift_count = results.filter(r => r.status === 'drift').length
115
- print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count, drift_count } })
136
+ const ahead_count = results.filter(r => r.status === 'ahead').length
137
+ print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count, drift_count, ahead_count } })
116
138
  return
117
139
  }
118
140
 
@@ -121,6 +143,7 @@ const run = (args) => catch_errors('Check failed', async () => {
121
143
  'outdated': yellow,
122
144
  'conflicts': red,
123
145
  'drift': red,
146
+ 'ahead': (s) => s,
124
147
  'no-access': yellow,
125
148
  'error': red,
126
149
  'unknown': (s) => s
@@ -153,7 +176,16 @@ const run = (args) => catch_errors('Check failed', async () => {
153
176
  const disk = d.drift?.disk_version || 'none'
154
177
  console.error(` - ${d.skill} (lock ${d.drift?.lock_version}, disk ${disk})`)
155
178
  }
156
- print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills install <skill> --fresh')} to restore.`)
179
+ print_hint(`Repair with ${code('happyskills reconcile <skill>')}.`)
180
+ }
181
+ const ahead_results = results.filter(r => r.status === 'ahead')
182
+ if (ahead_results.length > 0) {
183
+ console.log()
184
+ print_info(`${ahead_results.length} skill(s) ahead of lock — bumped locally, not yet published.`)
185
+ for (const a of ahead_results) {
186
+ console.error(` - ${a.skill} (lock ${a.ahead?.lock_version}, disk ${a.ahead?.disk_version})`)
187
+ }
188
+ print_hint(`Publish with ${code('happyskills publish <skill>')}.`)
157
189
  }
158
190
  if (conflicts.length > 0) {
159
191
  console.log()
@@ -3,9 +3,15 @@ const { install, install_from_manifest, install_from_lock } = require('../engine
3
3
  const { read_lock } = require('../lock/reader')
4
4
  const { read_manifest } = require('../manifest/reader')
5
5
  const { print_help, print_hint, print_json, print_warn, code } = require('../ui/output')
6
- const { exit_with_error, UsageError } = require('../utils/errors')
7
- const { find_project_root } = require('../config/paths')
6
+ const { exit_with_error, UsageError, CliError, AuthError } = require('../utils/errors')
7
+ const { find_project_root, skills_dir, skill_install_dir } = require('../config/paths')
8
8
  const { EXIT_CODES } = require('../constants')
9
+ const { get_refs } = require('../api/repos')
10
+ const { extract_version } = require('./versions')
11
+ const { file_exists } = require('../utils/fs')
12
+ const { hash_directory } = require('../lock/integrity')
13
+ const { get_all_locked_skills } = require('../lock/reader')
14
+ const snapshot_storage = require('../snapshot/storage')
9
15
 
10
16
  const HELP_TEXT = `Usage: happyskills install [<owner>/<skill>[@<version>]] [...] [options]
11
17
 
@@ -101,6 +107,79 @@ const run = (args) => catch_errors('Install failed', async () => {
101
107
  throw new UsageError('--version flag cannot be used with multiple skills. Use inline @version syntax instead (e.g., acme/foo@1.2.0 acme/bar@2.0.0).')
102
108
  }
103
109
 
110
+ // § 8.5 — Pre-flight hardening for --fresh.
111
+ // Closes the § 2 root-cause B (silent fallback to latest when the requested
112
+ // version isn't on the registry). Done at the command level so the failure
113
+ // mode surfaces BEFORE any disk mutation.
114
+ const fresh_snapshots = []
115
+ if (base_options.fresh) {
116
+ for (const { skill, version: inline_version } of parsed) {
117
+ const requested = flag_version || inline_version
118
+ if (!requested || requested === 'latest') continue
119
+
120
+ // (1) Verify the version exists on the registry. Hard-fail if not.
121
+ const [owner, name] = skill.split('/')
122
+ const [refs_err, refs] = await get_refs(owner, name)
123
+ if (refs_err) {
124
+ // Network failure or skill-not-found. Treat both as VERSION_NOT_FOUND
125
+ // because we cannot prove the version exists.
126
+ throw new UsageError(
127
+ `VERSION_NOT_FOUND: Cannot verify that ${skill}@${requested} exists on the registry (registry unreachable or skill unknown). ` +
128
+ `--fresh would wipe local content; refusing without confirmation.`
129
+ )
130
+ }
131
+ const available = (refs || []).map(r => extract_version(r.name)).filter(Boolean)
132
+ if (!available.includes(requested)) {
133
+ throw new UsageError(
134
+ `VERSION_NOT_FOUND: ${skill}@${requested} is not on the registry. ` +
135
+ `Available versions: ${available.join(', ') || '(none)'}. ` +
136
+ `This was previously a silent-fallback footgun (issue 260523-02 § 2.3) — --fresh now hard-fails.`
137
+ )
138
+ }
139
+
140
+ // (2) Snapshot before wiping. Always — so a failed install is reversible.
141
+ const base_dir = skills_dir(base_options.global, base_options.project_root)
142
+ const skill_dir = skill_install_dir(base_dir, name)
143
+ const [, dir_present] = await file_exists(skill_dir)
144
+ if (dir_present) {
145
+ // (3) Local-edit check. Compare on-disk hash to lock baseline; if
146
+ // they disagree, require explicit --force-discard-local.
147
+ const [, lock_data_pre] = await read_lock(base_options.project_root)
148
+ let has_local_edits = false
149
+ if (lock_data_pre) {
150
+ const all = get_all_locked_skills(lock_data_pre)
151
+ const lock_entry_pre = all[skill] || null
152
+ if (lock_entry_pre?.base_integrity) {
153
+ const [, current_hash] = await hash_directory(skill_dir)
154
+ has_local_edits = current_hash && current_hash !== lock_entry_pre.base_integrity
155
+ }
156
+ }
157
+ if (has_local_edits && !args.flags['force-discard-local']) {
158
+ throw new UsageError(
159
+ `LOCAL_EDITS_PRESENT: ${skill} has local edits that would be destroyed by --fresh. ` +
160
+ `Snapshot first (\`happyskills snapshot create ${skill}\`) and pass --force-discard-local to proceed.`
161
+ )
162
+ }
163
+ const [snap_err, snap] = await snapshot_storage.create({
164
+ skill_dir,
165
+ workspace: owner,
166
+ skill: name,
167
+ lock_entry: lock_data_pre ? get_all_locked_skills(lock_data_pre)[skill] : null,
168
+ note: `pre-install-fresh: ${requested}`,
169
+ is_global: base_options.global,
170
+ project_root: base_options.project_root
171
+ })
172
+ if (snap_err) {
173
+ throw new CliError(
174
+ `SNAPSHOT_FAILED: Could not snapshot ${skill} before --fresh wipe. Refusing to proceed.`,
175
+ EXIT_CODES.ERROR
176
+ )
177
+ }
178
+ fresh_snapshots.push({ skill, snapshot_id: snap.snapshot_id, path: snap.path })
179
+ }
180
+ }
181
+ }
182
+
104
183
  const results = []
105
184
  const failures = []
106
185
  for (const { skill, version: inline_version } of parsed) {
@@ -121,16 +200,22 @@ const run = (args) => catch_errors('Install failed', async () => {
121
200
  throw new Error(detail)
122
201
  }
123
202
 
203
+ const snapshot_by_skill = Object.fromEntries(fresh_snapshots.map(s => [s.skill, s.snapshot_id]))
204
+
124
205
  if (args.flags.json) {
125
- const items = results.map(({ skill, result }) => ({
126
- skill,
127
- version: result.version,
128
- installed: result.installed || [],
129
- skipped: result.skipped || [],
130
- skipped_deps: result.skipped_deps || [],
131
- warnings: result.warnings || [],
132
- forced: result.forced || []
133
- }))
206
+ const items = results.map(({ skill, result }) => {
207
+ const item = {
208
+ skill,
209
+ version: result.version,
210
+ installed: result.installed || [],
211
+ skipped: result.skipped || [],
212
+ skipped_deps: result.skipped_deps || [],
213
+ warnings: result.warnings || [],
214
+ forced: result.forced || []
215
+ }
216
+ if (snapshot_by_skill[skill]) item.snapshot_id = snapshot_by_skill[skill]
217
+ return item
218
+ })
134
219
  const data = results.length === 1 && failures.length === 0 ? items[0] : items
135
220
  if (failures.length > 0) {
136
221
  print_json({ data, errors: failures })
@@ -1,6 +1,6 @@
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
- const { verify_lock_disk_consistency } = require('../lock/verify')
3
+ const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
4
4
  const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
5
5
  const { file_exists, read_json } = require('../utils/fs')
6
6
  const { scan_skills_dir, scan_agent_orphan_skills } = require('../utils/skill_scanner')
@@ -76,14 +76,25 @@ const run = (args) => catch_errors('List failed', async () => {
76
76
  return manifest?.type || SKILL_TYPES.SKILL
77
77
  }
78
78
 
79
- // Compute drift up-front for every managed entry. Cheap (one skill.json read
80
- // per skill) and lets both the JSON and human paths report it identically.
79
+ // Compute drift AND ahead state up-front for every managed entry. Cheap
80
+ // (one skill.json read per skill, plus an optional CHANGELOG read) and lets
81
+ // both the JSON and human paths report identically.
82
+ //
83
+ // §10.5: drift is narrowed to genuine inconsistency (regression, missing
84
+ // files); the disk-greater-than-lock case is reported under top-level
85
+ // status:ahead, not under drift.
81
86
  const drift_by_skill = {}
87
+ const ahead_by_skill = {}
82
88
  await Promise.all(managed_entries.map(async ([name, data]) => {
83
89
  const short = name.split('/')[1]
84
90
  const dir = skill_install_dir(base_dir, short)
85
91
  const [, drift] = await verify_lock_disk_consistency(data, dir)
86
- if (drift && !drift.ok) drift_by_skill[name] = drift
92
+ if (drift && !drift.ok) {
93
+ drift_by_skill[name] = drift
94
+ return
95
+ }
96
+ const [, ahead] = await detect_ahead_state(data, dir)
97
+ if (ahead && ahead.ahead) ahead_by_skill[name] = ahead
87
98
  }))
88
99
 
89
100
  if (args.flags.json) {
@@ -93,12 +104,23 @@ const run = (args) => catch_errors('List failed', async () => {
93
104
  const dir = skill_install_dir(base_dir, short)
94
105
  const [, exists] = await file_exists(dir)
95
106
  const drift = drift_by_skill[name]
96
- const status = drift ? 'drift' : (exists ? 'installed' : 'missing')
107
+ const ahead = ahead_by_skill[name]
108
+ let status
109
+ if (drift) status = 'drift'
110
+ else if (ahead) status = 'ahead'
111
+ else if (exists) status = 'installed'
112
+ else status = 'missing'
97
113
  const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
98
114
  const type = await resolve_type(name, data)
99
115
  const enabled = enabled_map?.get(short) ?? true
100
116
  const entry = { version: data.version, type, source, status, enabled }
101
117
  if (drift) entry.drift = { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
118
+ if (ahead) entry.ahead = {
119
+ lock_version: ahead.lock_version,
120
+ disk_version: ahead.disk_version,
121
+ has_changelog_entry: ahead.has_changelog_entry || false,
122
+ changelog_version: ahead.changelog_version || null
123
+ }
102
124
  skills_map[name] = entry
103
125
  }
104
126
  const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
@@ -117,9 +139,12 @@ const run = (args) => catch_errors('List failed', async () => {
117
139
  const dir = skill_install_dir(base_dir, short)
118
140
  const [, exists] = await file_exists(dir)
119
141
  const drift = drift_by_skill[name]
120
- const status_label = drift
121
- ? red(`drift (disk ${drift.actual || 'none'})`)
122
- : (exists ? 'installed' : yellow('missing'))
142
+ const ahead = ahead_by_skill[name]
143
+ let status_label
144
+ if (drift) status_label = red(`drift (${drift.reason})`)
145
+ else if (ahead) status_label = `ahead (disk ${ahead.disk_version})`
146
+ else if (exists) status_label = 'installed'
147
+ else status_label = yellow('missing')
123
148
  const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
124
149
  const type = await resolve_type(name, data)
125
150
  const display_name = type === SKILL_TYPES.KIT ? `${name} [kit]` : name
@@ -140,10 +165,15 @@ const run = (args) => catch_errors('List failed', async () => {
140
165
  print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
141
166
 
142
167
  const drift_count = Object.keys(drift_by_skill).length
168
+ const ahead_count = Object.keys(ahead_by_skill).length
143
169
  if (drift_count > 0) {
144
170
  console.log()
145
171
  print_warn(`${drift_count} skill(s) drifted: lock and on-disk skill.json disagree.`)
146
- print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills install <skill> --fresh')} to restore.`)
172
+ print_hint(`Run ${code('happyskills status')} for details, or ${code('happyskills reconcile <skill>')} to repair.`)
173
+ }
174
+ if (ahead_count > 0) {
175
+ console.log()
176
+ print_info(`${ahead_count} skill(s) ahead of lock — bumped locally, not yet published. Run publish when ready.`)
147
177
  }
148
178
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
149
179
 
@@ -260,45 +260,52 @@ const run = (args) => catch_errors('Publish failed', async () => {
260
260
 
261
261
  spinner.succeed(`Published ${workspace.slug}/${manifest.name}@${manifest.version}`)
262
262
 
263
- // Update lock file: set base_commit and base_integrity to new values
263
+ // Update lock file: set base_commit and base_integrity to new values.
264
+ // When no lock file exists yet (first publish in a fresh project), we
265
+ // still create one — otherwise downstream `list`/`status`/`check` would
266
+ // treat the just-published skill as external. This was a latent bug
267
+ // pre-260523-02; the spec's lock-as-registry-view principle (§ 4.5) makes
268
+ // it especially important to keep the lock authoritative.
264
269
  const full_name = full_name_pre
265
270
  let post_publish_entry = null
266
- if (!lock_err && lock_data) {
267
- const all_skills = get_all_locked_skills(lock_data)
268
- const suffix = `/${skill_name}`
269
- const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
270
- const [hash_err, integrity] = await hash_directory(dir)
271
- if (lock_key && all_skills[lock_key]) {
272
- const updated_entry = {
273
- ...all_skills[lock_key],
274
- version: manifest.version,
275
- ref: push_data?.ref || `refs/tags/v${manifest.version}`,
276
- commit: push_data?.commit || null,
277
- base_commit: push_data?.commit || null,
278
- base_integrity: (!hash_err && integrity) ? integrity : null,
279
- dependencies: manifest.dependencies || {}
280
- }
281
- if (!hash_err && integrity) updated_entry.integrity = integrity
282
- delete updated_entry.merge_parents
283
- delete updated_entry.conflict_files
284
- const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
285
- await write_lock(project_root, updated_skills)
286
- post_publish_entry = updated_entry
287
- } else {
288
- const new_entry = {
289
- version: manifest.version,
290
- ref: push_data?.ref || `refs/tags/v${manifest.version}`,
291
- commit: push_data?.commit || null,
292
- integrity: (!hash_err && integrity) ? integrity : null,
293
- base_commit: push_data?.commit || null,
294
- base_integrity: (!hash_err && integrity) ? integrity : null,
295
- requested_by: ['__root__'],
296
- dependencies: manifest.dependencies || {}
297
- }
298
- const updated_skills = update_lock_skills(lock_data, { [full_name]: new_entry })
299
- await write_lock(project_root, updated_skills)
300
- post_publish_entry = new_entry
271
+ const [hash_err, integrity] = await hash_directory(dir)
272
+
273
+ const new_entry = {
274
+ version: manifest.version,
275
+ ref: push_data?.ref || `refs/tags/v${manifest.version}`,
276
+ commit: push_data?.commit || null,
277
+ integrity: (!hash_err && integrity) ? integrity : null,
278
+ base_commit: push_data?.commit || null,
279
+ base_integrity: (!hash_err && integrity) ? integrity : null,
280
+ requested_by: ['__root__'],
281
+ dependencies: manifest.dependencies || {}
282
+ }
283
+
284
+ const lock_data_to_use = (!lock_err && lock_data) ? lock_data : { lockVersion: 2, generatedAt: new Date().toISOString(), skills: {} }
285
+ const all_skills = get_all_locked_skills(lock_data_to_use)
286
+ const suffix = `/${skill_name}`
287
+ const existing_lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
288
+
289
+ if (existing_lock_key && all_skills[existing_lock_key]) {
290
+ const updated_entry = {
291
+ ...all_skills[existing_lock_key],
292
+ version: manifest.version,
293
+ ref: push_data?.ref || `refs/tags/v${manifest.version}`,
294
+ commit: push_data?.commit || null,
295
+ base_commit: push_data?.commit || null,
296
+ base_integrity: (!hash_err && integrity) ? integrity : null,
297
+ dependencies: manifest.dependencies || {}
301
298
  }
299
+ if (!hash_err && integrity) updated_entry.integrity = integrity
300
+ delete updated_entry.merge_parents
301
+ delete updated_entry.conflict_files
302
+ const updated_skills = update_lock_skills(lock_data_to_use, { [existing_lock_key]: updated_entry })
303
+ await write_lock(project_root, updated_skills)
304
+ post_publish_entry = updated_entry
305
+ } else {
306
+ const updated_skills = update_lock_skills(lock_data_to_use, { [full_name]: new_entry })
307
+ await write_lock(project_root, updated_skills)
308
+ post_publish_entry = new_entry
302
309
  }
303
310
 
304
311
  // Post-write verification — confirm the lock entry now agrees with the
@@ -33,6 +33,7 @@ Options:
33
33
  --theirs [files] Take remote version on conflicts (all, or comma-separated file list)
34
34
  --ours [files] Keep local version on conflicts (all, or comma-separated file list)
35
35
  --force Discard all local changes, take remote entirely
36
+ --rebase Rebase local edits onto the remote head (snapshot-backed, structured rejection envelope on failure)
36
37
  -g, --global Pull globally installed skill
37
38
  --strict Fail on incompatible dependency ranges instead of warning
38
39
  --json Output as JSON
@@ -40,6 +41,7 @@ Options:
40
41
 
41
42
  Examples:
42
43
  happyskills pull acme/deploy-aws
44
+ happyskills pull acme/deploy-aws --rebase --json
43
45
  happyskills pull acme/deploy-aws --theirs
44
46
  happyskills pull acme/deploy-aws --theirs SKILL.md,skill.json --ours references/foo.md
45
47
  happyskills pull acme/deploy-aws --json --full-report`
@@ -111,6 +113,44 @@ const run = (args) => catch_errors('Pull failed', async () => {
111
113
  const is_global = args.flags.global || false
112
114
  const full_report = !!(args.flags.json && args.flags['full-report'])
113
115
 
116
+ // § 8.3 — rebase-style pull. Delegates to merge/rebase.js for the snapshot-
117
+ // first capture/fast-forward/reapply flow with structured rejection envelopes.
118
+ if (args.flags.rebase) {
119
+ const { rebase_pull } = require('../merge/rebase')
120
+ const [err, result] = await rebase_pull(skill_name, { project_root: find_project_root(), is_global })
121
+ if (err) throw err
122
+ if (args.flags.json) {
123
+ print_json({
124
+ data: result.data || null,
125
+ next_step: result.next_step || null,
126
+ error: result.error || null
127
+ })
128
+ if (result.next_step) process.exit(EXIT_CODES.ERROR)
129
+ return
130
+ }
131
+ if (result.error) {
132
+ print_warn(result.error.message || 'Pull --rebase failed')
133
+ if (result.next_step) print_hint(`Next: ${result.next_step.action}`)
134
+ process.exit(EXIT_CODES.ERROR)
135
+ return
136
+ }
137
+ if (result.next_step?.action === 'resolve_patch_rejections') {
138
+ print_warn(`Pull --rebase: ${result.data.patches_rejected.length} patch(es) need resolution.`)
139
+ for (const r of result.data.patches_rejected) {
140
+ print_info(` - ${r.file}: ${r.reason}`)
141
+ }
142
+ print_hint(`Restore the pre-rebase state with ${code(`happyskills snapshot restore ${result.data.snapshot_id}`)} if needed.`)
143
+ process.exit(EXIT_CODES.ERROR)
144
+ return
145
+ }
146
+ if (result.data?.status === 'up_to_date') {
147
+ print_info(`${skill_name} is already up to date.`)
148
+ return
149
+ }
150
+ print_success(`Rebased ${skill_name} → ${result.data.version || 'latest'}`)
151
+ return
152
+ }
153
+
114
154
  // Parse per-file strategies: --theirs SKILL.md,skill.json --ours references/foo.md
115
155
  const theirs_files = typeof args.flags.theirs === 'string'
116
156
  ? new Set(args.flags.theirs.split(',').map(s => s.trim()))