happyskills 1.0.0 → 1.0.2

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,11 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.2] - 2026-06-01
11
+
12
+ ### Fixed
13
+
14
+ - Stop `uninstall` from deleting a shared dependency that another installed skill still needs. When a skill depended on by two parents (e.g. `happyskills-design`, required by both `happyskills` and another skill) was resolved across separate install passes, the second pass overwrote its `requested_by` instead of unioning the parents — so uninstalling one parent orphan-pruned the dependency even though the other parent still declared it. Two fixes: `requested_by` is now unioned across install passes, and the orphan pruner additionally cross-checks every surviving skill's `dependencies` map before removing anything.
15
+
16
+ ## [1.0.1] - 2026-06-01
17
+
18
+ ### Fixed
19
+
20
+ - Fix the post-rerank clarification step in `postlex`: the `clarify_triggered` telemetry beacon and the text-mode clarification prompt checked for a `clarify` action and read `suggested_questions` at the wrong depth, but the command emits `clarify_query` with the questions nested under `next_step.context` — so neither fired. The `--json` envelope was already correct; this restores the CLI's own telemetry and human-readable clarification output.
21
+
10
22
  ## [1.0.0] - 2026-05-29
11
23
 
12
24
  ### Added
13
25
 
14
- - **Universal response envelope on every `--json` command** (spec 260525-cli-default-json). All structured output now emits a canonical six-key shape — `{ ok, data, error, next_step, warnings, meta }` — with `ok` derived from error presence, `data` always an object, and the process exit code mirrored into `meta.exit_code`. Agents can determine success/failure and the recovery path without parsing prose.
26
+ - **Canonical six-key response envelope on every `--json` command** (spec 260525-cli-default-json). All structured output now emits a canonical six-key shape — `{ ok, data, error, next_step, warnings, meta }` — with `ok` derived from error presence, `data` always an object, and the process exit code mirrored into `meta.exit_code`. Agents can determine success/failure and the recovery path without parsing prose.
15
27
  - **`happyskills schema --json` command** — emits a machine-readable description of the entire CLI surface: every command, the closed `error_codes`, `next_step_actions`, and `next_step_kinds` enums. A generic agent can discover the full CLI contract in one call.
16
28
  - **Closed enums for the envelope** — `error.code` (state/auth/network/validation/etc.), `next_step.kind` (six kinds: recovery, clarification, decision, confirmation, continuation, routing), and `next_step.action` (~27 actions). Shipped as `cli/src/constants/{error_codes,next_step_actions}.js`, kept byte-identical with the API.
17
29
  - **Hand-rolled envelope validator** (`cli/src/schema/envelope_validator.js`) — enforces the closed schema, the `dependentRequired` clusters (code+message, kind+action+instructions), and the derived invariants. No new runtime dependency.
@@ -19,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
19
31
 
20
32
  ### Changed
21
33
 
22
- - **BREAKING: `--json` output format.** Every command's JSON output moved from the legacy `{ data }` / `{ error: { code, message, exit_code } }` shapes to the six-key envelope. `exit_code` now lives on `meta.exit_code`, not inside `error`. Consumers that parsed the old shapes must update — this is why the release is `1.0.0`.
34
+ - **BREAKING: `--json` output format.** Every command's JSON output moved from the legacy `{ data }` / `{ error: { code, message, exit_code } }` shapes to the canonical six-key response envelope. `exit_code` now lives on `meta.exit_code`, not inside `error`. Consumers that parsed the old shapes must update — this is why the release is `1.0.0`.
23
35
  - **CLI consumes the new API envelope** while remaining backward-compatible with the current (pre-envelope) API. The `search`, `feedback`, and device-login clients detect the envelope and translate; against the old API they fall through to the legacy path, so the new CLI works against both.
24
36
  - **`error.code` is a closed enum end-to-end** — unknown codes from a newer API coerce to `UNKNOWN_CODE` with the original preserved under `error.details.original_code`.
25
37
  - **Per-command `next_step` recoveries** now use the closed action enum across `install`, `reconcile`, `release`, `search`, `postlex`, `validate`, `delete`, and `feedback` (e.g. `VERSION_NOT_FOUND` → `pick_version`, `LOCAL_EDITS_PRESENT` → `confirm_discard_or_snapshot_first`, `DRIFT_DETECTED` → `reconcile_first`).
@@ -181,14 +193,14 @@ This release combines two streams of work: **spec 260523-02 (Skill Update Determ
181
193
  ## [0.47.0] - 2026-05-21
182
194
 
183
195
  ### Added
184
- - Add `--with-rerank` flag to `happyskills search`. When set, the CLI passes `with_rerank_digests=true` to `POST /repos:search` (via `dispatch_search()`) and wraps the `--json` response in the universal envelope shape `{ data, error, next_step }`. The `data` payload gains four new fields alongside the existing `results`: `rerank_digests`, `rerank_system_prompt`, `rerank_prompt_version`, `rerank_response_schema`, plus `formulated_query`. `next_step.action` is one of `rank_digests_inline` (digests came back — agent ranks them and pipes to `postlex`), `clarify` (semantic match_notice fired and clarification budget remains — agent uses its native question mechanism with a calibrated narrowing question), or `present_to_user` (budget exhausted / non-semantic mode — render results). Silently ignored on non-semantic dispatches (slug-shape, scoped, exact). Combining `--with-rerank --exact` exits 2 (`USAGE_ERROR`). Intended for use inside an agentic session — see the `happyskills-help@0.3.0` skill's `references/discovery-protocol.md` for the envelope contract.
196
+ - Add `--with-rerank` flag to `happyskills search`. When set, the CLI passes `with_rerank_digests=true` to `POST /repos:search` (via `dispatch_search()`) and wraps the `--json` response in the canonical six-key response envelope shape `{ data, error, next_step }`. The `data` payload gains four new fields alongside the existing `results`: `rerank_digests`, `rerank_system_prompt`, `rerank_prompt_version`, `rerank_response_schema`, plus `formulated_query`. `next_step.action` is one of `rank_digests_inline` (digests came back — agent ranks them and pipes to `postlex`), `clarify` (semantic match_notice fired and clarification budget remains — agent uses its native question mechanism with a calibrated narrowing question), or `present_to_user` (budget exhausted / non-semantic mode — render results). Silently ignored on non-semantic dispatches (slug-shape, scoped, exact). Combining `--with-rerank --exact` exits 2 (`USAGE_ERROR`). Intended for use inside an agentic session — see the `happyskills-help@0.3.0` skill's `references/discovery-protocol.md` for the envelope contract.
185
197
  - Add `--clarification-turns-used <N>` companion flag on `search`. Carries the clarification budget (0–2, default 0) across re-searches in the discovery protocol's clarification flow. The CLI clamps to the hard cap of 2 and emits `present_to_user` (instead of `clarify`) once the budget is spent.
186
198
  - Add new `happyskills postlex` subcommand — deterministic post-lex finalization for the discovery protocol. Takes the LLM's `ranking[]` JSON plus the original `data[]` array (from stdin via `--ranking -`, or from separate `--ranking <file>` + `--data <file>` paths), applies the canonical 30-line slug-overlap promotion algorithm, and emits a `next_step` envelope (`present_to_user` on success, `clarify` if the post-rerank top results are still weak and budget remains, `retry_rank` with an `input_template` when the ranking fails schema validation). Stateless — the agent carries all cross-call state via `--clarification-turns-used` and `next_step.context`. Human-readable output renders only the LLM-ranked subset (the unranked tail is not shown — the LLM chose not to rank those rows). Refuses to crash on out-of-range `candidate_id` values: invalid entries are dropped with a stderr warning; if every entry drops out, exit 1.
187
199
  - Add `cli/src/utils/slug_tokens.js` — STOP_TOKENS + `slug_tokens()` + `slug_token_set()` + `compute_lex_tier()`. Byte-identical mirror of `api/app/utils/slug_tokens.js`. The companion test (`slug_tokens.test.js`) cross-imports the API canonical version and asserts STOP_TOKENS set-equality in both directions — fails CI loud if either side drifts. This is the load-bearing invariant from spec 260521-01 v2 § 6: if the CLI's view of which candidate is "exact" disagrees with the API's slug-boost behavior, the post-lex stage promotes the wrong candidate (or none).
188
200
  - Add `cli/src/api/telemetry.js` — fire-and-forget client for `POST /telemetry/discovery`. 2-second `AbortController` timeout; all errors swallowed; never affects exit codes. Called from `search` (events `rerank_started` when emitting `rank_digests_inline`, `clarify_triggered` when emitting `clarify`, `clarify_completed` when re-running after a clarify cycle) and from `postlex` (event `rerank_completed` after the algorithm runs, plus `clarify_triggered` when emitting a post-rerank clarify). Server-side aggregation in CloudWatch Insights drives the two key signals: postlex fire rate (0.5%–5% target) and protocol completion rate (≥85% target — `count(rerank_completed)/count(rerank_started)`).
189
201
 
190
202
  ### Changed
191
- - `search --json` output is wrapped in the universal envelope `{ data, error, next_step }` when (and only when) `--with-rerank` is set. Plain `search --json` (without the flag) keeps its existing `{ data: { … } }` shape for backward compatibility — no consumer of the existing JSON contract sees a change.
203
+ - `search --json` output is wrapped in the canonical six-key response envelope `{ data, error, next_step }` when (and only when) `--with-rerank` is set. Plain `search --json` (without the flag) keeps its existing `{ data: { … } }` shape for backward compatibility — no consumer of the existing JSON contract sees a change.
192
204
  - Argument parser (`parse_args` in `cli/src/index.js`) now treats a literal `-` as a flag value instead of as the prefix of another flag. This is the standard Unix stdin sentinel and lets `happyskills postlex --ranking -` parse correctly. Conservative change: `next.startsWith('-') && next !== '-'` is the new gate, so any flag whose value would previously have been swallowed and replaced with `true` because the next arg happened to be `-` now correctly receives `'-'`. No existing command relied on the previous behavior.
193
205
 
194
206
  ## [0.46.1] - 2026-05-20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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)",
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  // Post-Session-3 translator: near-identity. The public API now emits the
3
- // canonical six-key envelope natively (spec 260525-cli-default-json § 11),
3
+ // canonical six-key response envelope natively (spec 260525-cli-default-json § 11),
4
4
  // so this module only stamps the CLI's local `meta.cli_version` on top of
5
5
  // the response — meta.api_version is preserved verbatim. Forward-compat
6
6
  // for unknown error codes still routes through UNKNOWN_CODE per § 5.2.
@@ -3,7 +3,7 @@
3
3
  // Surfaces:
4
4
  // happyskills feedback <category> [body] [--subject "..."] [--attach a,b,c] [--json]
5
5
  //
6
- // --json mode emits the API's response wrapped in the universal CLI envelope:
6
+ // --json mode emits the API's response wrapped in the canonical six-key response envelope:
7
7
  // { data: { feedback, next_step, attachments? }, error: null }
8
8
  // The `next_step` envelope is what the `happyskills-help` skill reads to
9
9
  // route the post-creation conversation (offer attachment, etc.) — see spec
@@ -240,7 +240,7 @@ const run = (args) => catch_errors('Feedback command failed', async () => {
240
240
  }
241
241
 
242
242
  if (json_mode) {
243
- // Universal CLI envelope shape. The skill's envelope-reader (per spec
243
+ // Canonical six-key response envelope shape. The skill's envelope-reader (per spec
244
244
  // § 11) consumes `next_step` at the response root.
245
245
  const data = { feedback, attachments }
246
246
  print_json({ data, error: null, next_step })
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Spec 260521-01 v2 § 5.2 — this command is the deterministic safety net
6
6
  // for the rerank protocol. Frontier model emits ranking → postlex finalizes
7
- // → agent renders. The envelope makes the protocol legible to the agent
7
+ // → agent renders. The canonical six-key response envelope makes the protocol legible to the agent
8
8
  // without piling conditional logic into SKILL.md.
9
9
 
10
10
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
@@ -218,8 +218,30 @@ const determine_next_step = (final_ordering, query, clarification_turns_used) =>
218
218
  }
219
219
  }
220
220
 
221
+ // ─── next_step consumers (pure — exported for unit testing) ─────────────────
222
+ // run() reacts to the next_step that determine_next_step emitted: it fires a
223
+ // `clarify_triggered` telemetry beacon and renders a clarification notice in
224
+ // text mode. Those two decisions are factored out here so they can be
225
+ // unit-tested without spawning the process or hitting the telemetry endpoint.
226
+
227
+ // True when the emitted next_step is the post-rerank clarification step.
228
+ // The closed action enum value is `clarify_query` (next_step_actions.js) —
229
+ // matching the bare label "clarify" here silently disables the clarification
230
+ // beacon and notice, since determine_next_step never emits "clarify".
231
+ const is_clarify_next_step = (next_step) =>
232
+ !!next_step && next_step.action === 'clarify_query'
233
+
234
+ // The first suggested clarifying question to surface in text mode, or null.
235
+ // suggested_questions lives under next_step.context (spec § 4.5), not at the
236
+ // bare next_step root.
237
+ const first_clarify_question = (next_step) => {
238
+ const list = next_step && next_step.context && next_step.context.suggested_questions
239
+ const q = Array.isArray(list) && list[0]
240
+ return q && typeof q.question === 'string' ? q.question : null
241
+ }
242
+
221
243
  // Envelope used when the LLM's ranking is malformed and we need to ask it
222
- // to re-emit. Returns the canonical six-key envelope (spec § 4) so postlex
244
+ // to re-emit. Returns the canonical six-key response envelope (spec § 4) so postlex
223
245
  // can be unit-tested against the closed schema.
224
246
  const build_retry_envelope = (query, reason, clarification_turns_used, retry_count) => {
225
247
  const { build_envelope } = require('../ui/envelope')
@@ -450,7 +472,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
450
472
  promoted_from_rank,
451
473
  exact_match_count_in_window,
452
474
  })
453
- if (next_step.action === 'clarify') {
475
+ if (is_clarify_next_step(next_step)) {
454
476
  fire_discovery_telemetry({
455
477
  event: 'clarify_triggered',
456
478
  intent_id,
@@ -487,9 +509,9 @@ const run = (args) => catch_errors('Postlex failed', async () => {
487
509
  console.log(format_human_row(row))
488
510
  if (i < final_ordering.length - 1) console.log('')
489
511
  })
490
- if (next_step.action === 'clarify') {
512
+ if (is_clarify_next_step(next_step)) {
491
513
  console.log(`\n ${yellow('No top result is a strong match. Suggested clarification:')}`)
492
- console.log(` ${dim(next_step.suggested_questions[0].question)}`)
514
+ console.log(` ${dim(first_clarify_question(next_step))}`)
493
515
  } else if (next_step.action === 'present_to_user' && !final_ordering.slice(0, 3).every(r => STRONG_TIERS.has(r.match_quality))) {
494
516
  console.log(`\n ${yellow('No top result is a strong match (clarification budget spent).')}`)
495
517
  }
@@ -503,6 +525,8 @@ module.exports = {
503
525
  apply_postlex,
504
526
  build_final_ordering,
505
527
  determine_next_step,
528
+ is_clarify_next_step,
529
+ first_clarify_question,
506
530
  build_retry_envelope,
507
531
  parse_input,
508
532
  resolve_row_name,
@@ -14,6 +14,8 @@ const {
14
14
  apply_postlex,
15
15
  build_final_ordering,
16
16
  determine_next_step,
17
+ is_clarify_next_step,
18
+ first_clarify_question,
17
19
  build_retry_envelope,
18
20
  parse_input,
19
21
  resolve_row_name,
@@ -224,6 +226,52 @@ describe('determine_next_step', () => {
224
226
  })
225
227
  })
226
228
 
229
+ // ─── next_step consumer wiring (clarify_query drift regression) ────────────
230
+ // determine_next_step (the producer) emits next_step.action === "clarify_query"
231
+ // with suggested_questions nested under next_step.context (spec § 4.5). run()'s
232
+ // telemetry + text-render consumers must recognize that exact contract. The
233
+ // historical bug: the consumers checked for the bare label "clarify" and read
234
+ // next_step.suggested_questions at the wrong depth, so the clarify_triggered
235
+ // beacon never fired and the clarification notice never rendered — a SILENT
236
+ // failure (no error) at the one moment the harness is meant to engage.
237
+ describe('postlex clarify_query consumer wiring', () => {
238
+ const weak_ordering = [
239
+ { rank: 1, candidate_id: 1, match_quality: 'weak' },
240
+ { rank: 2, candidate_id: 2, match_quality: 'partial' },
241
+ { rank: 3, candidate_id: 3, match_quality: 'weak' },
242
+ ]
243
+
244
+ it('recognizes the producer\'s clarify_query next_step (telemetry + render gate)', () => {
245
+ const ns = determine_next_step(weak_ordering, 'deploy aws', 0)
246
+ assert.equal(ns.action, 'clarify_query', 'producer emits the canonical action')
247
+ assert.equal(
248
+ is_clarify_next_step(ns), true,
249
+ 'consumer must recognize action "clarify_query" so the clarify_triggered beacon fires and the notice renders',
250
+ )
251
+ })
252
+
253
+ it('reads the suggested question from next_step.context.suggested_questions', () => {
254
+ const ns = determine_next_step(weak_ordering, 'deploy aws', 0)
255
+ const q = first_clarify_question(ns)
256
+ assert.equal(typeof q, 'string', 'suggested question must be resolved from next_step.context')
257
+ assert.ok(q.length > 0, 'must surface the first suggested clarifying question for text-mode rendering')
258
+ })
259
+
260
+ it('does not treat the success path (present_to_user) as a clarify step', () => {
261
+ const ns = determine_next_step(
262
+ [
263
+ { rank: 1, candidate_id: 1, match_quality: 'strong' },
264
+ { rank: 2, candidate_id: 2, match_quality: 'good' },
265
+ { rank: 3, candidate_id: 3, match_quality: 'strong' },
266
+ ],
267
+ 'q', 0,
268
+ )
269
+ assert.equal(ns.action, 'present_to_user')
270
+ assert.equal(is_clarify_next_step(ns), false)
271
+ assert.equal(first_clarify_question(ns), null)
272
+ })
273
+ })
274
+
227
275
  // ─── build_retry_envelope ─────────────────────────────────────────────────
228
276
  // Envelope contract: six-key { ok, data, error, next_step, warnings, meta }
229
277
  // with data={}, error.code=RANKING_SCHEMA_MISMATCH (closed enum), kind=recovery,
@@ -305,7 +305,7 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
305
305
  data.rerank_prompt_version = response?.rerank_prompt_version || null
306
306
  data.rerank_response_schema = response?.rerank_response_schema || null
307
307
  }
308
- // Wrap in the universal envelope shape only when --with-rerank is set.
308
+ // Wrap in the canonical six-key response envelope shape only when --with-rerank is set.
309
309
  // Plain search keeps its existing { data: ... } shape for backward compat.
310
310
  print_json(with_rerank ? { data, error: null, next_step } : { data })
311
311
  return
@@ -91,7 +91,7 @@ describe('build_search_next_step — envelope shape', () => {
91
91
  const r = build_search_next_step(make_response({
92
92
  match_notice: 'No strong or good matches.',
93
93
  }), 'vague query', { with_rerank: true, clarification_turns_used: 0 })
94
- assert_envelope_next_step(r, NEXT_STEP_ACTIONS.CLARIFY_QUERY, 'clarify')
94
+ assert_envelope_next_step(r, NEXT_STEP_ACTIONS.CLARIFY_QUERY, 'clarify_query')
95
95
  // suggested_questions and max_turns_remaining now live INSIDE context.
96
96
  assert.strictEqual(r.context.max_turns_remaining, 2)
97
97
  assert.ok(Array.isArray(r.context.suggested_questions))
@@ -1,5 +1,5 @@
1
1
  'use strict'
2
- // Closed enum of error codes emitted by the CLI envelope. Mirrors spec
2
+ // Closed enum of error codes emitted by the canonical six-key response envelope. Mirrors spec
3
3
  // 260525-cli-default-json § 5. Skeleton-only — Session 2 wires it into
4
4
  // emit_envelope / exit_with_error. Until then, only the test suite consumes
5
5
  // it (via envelope_validator).
@@ -22,6 +22,34 @@ const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
22
22
  return `Missing system dependency: ${dep.name} required by ${dep.skill}${hint}`
23
23
  })
24
24
 
25
+ // Order-preserving, de-duplicated union of requester lists.
26
+ const _union = (...lists) => {
27
+ const seen = new Set()
28
+ const out = []
29
+ for (const list of lists) {
30
+ for (const requester of (list || [])) {
31
+ if (!seen.has(requester)) {
32
+ seen.add(requester)
33
+ out.push(requester)
34
+ }
35
+ }
36
+ }
37
+ return out
38
+ }
39
+
40
+ // Compute the `requested_by` for a package's lock entry. A package is requested
41
+ // either by the user directly (`__root__`) when it IS the skill being installed,
42
+ // or by `root_skill` when it is a (transitive) dependency. We union that with
43
+ // whatever the lock already recorded so a SHARED dependency keeps every parent
44
+ // across separate install passes — install() resolves one root's tree at a time,
45
+ // so a dependency of two roots (e.g. happyskills + create-release-skill both
46
+ // needing happyskills-design) would otherwise have its `requested_by` clobbered
47
+ // by the last pass, and later be wrongly orphan-pruned on uninstall.
48
+ const merge_requested_by = (prev_requested_by, pkg_skill, root_skill) => {
49
+ const own = pkg_skill === root_skill ? ['__root__'] : [root_skill]
50
+ return _union(own, prev_requested_by)
51
+ }
52
+
25
53
  const install = (skill, options = {}) => catch_errors('Install failed', async () => {
26
54
  const { version, global: is_global = false, force = false, fresh = false, project_root, agents: agents_flag } = options
27
55
  const base_dir = skills_dir(is_global, project_root)
@@ -246,7 +274,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
246
274
  integrity: integrity || null,
247
275
  base_commit: pkg.commit || null,
248
276
  base_integrity: integrity || null,
249
- requested_by: pkg.skill === skill ? ['__root__'] : [skill],
277
+ requested_by: merge_requested_by(lock_data?.skills?.[pkg.skill]?.requested_by, pkg.skill, skill),
250
278
  dependencies: pkg.dependencies || {},
251
279
  ...(pkg_type ? { type: pkg_type } : {}),
252
280
  ...(pkg.forced ? { forced: true } : {})
@@ -378,4 +406,4 @@ const install_from_lock = (lock_data, options = {}) => catch_errors('Install fro
378
406
  return { source: 'skills-lock.json', installed: all_installed, skipped: all_skipped, warnings: all_warnings }
379
407
  })
380
408
 
381
- module.exports = { install, install_from_manifest, install_from_lock }
409
+ module.exports = { install, install_from_manifest, install_from_lock, merge_requested_by }
@@ -0,0 +1,41 @@
1
+ const { describe, it } = require('node:test')
2
+ const assert = require('node:assert')
3
+ const { merge_requested_by } = require('./installer')
4
+
5
+ describe('merge_requested_by', () => {
6
+ it('records the root skill for a fresh dependency', () => {
7
+ const result = merge_requested_by(undefined, 'happyskillsai/happyskills-design', 'happyskillsai/happyskills')
8
+ assert.deepStrictEqual(result, ['happyskillsai/happyskills'])
9
+ })
10
+
11
+ it('records __root__ when the package IS the skill being installed', () => {
12
+ const result = merge_requested_by(['__root__'], 'happyskillsai/happyskills', 'happyskillsai/happyskills')
13
+ assert.deepStrictEqual(result, ['__root__'])
14
+ })
15
+
16
+ it('unions a shared dependency across separate install passes', () => {
17
+ // Pass 1 wrote design as a dependency of happyskills.
18
+ // Pass 2 re-resolves design as a dependency of create-release-skill and
19
+ // must NOT clobber the existing parent — both must be retained.
20
+ const result = merge_requested_by(
21
+ ['happyskillsai/happyskills'],
22
+ 'happyskillsai/happyskills-design',
23
+ 'nicolasdao/create-release-skill'
24
+ )
25
+ assert.deepStrictEqual(result, ['nicolasdao/create-release-skill', 'happyskillsai/happyskills'])
26
+ })
27
+
28
+ it('de-duplicates when the same requester reappears', () => {
29
+ const result = merge_requested_by(
30
+ ['happyskillsai/happyskills'],
31
+ 'happyskillsai/happyskills-design',
32
+ 'happyskillsai/happyskills'
33
+ )
34
+ assert.deepStrictEqual(result, ['happyskillsai/happyskills'])
35
+ })
36
+
37
+ it('tolerates a null/empty previous list', () => {
38
+ assert.deepStrictEqual(merge_requested_by(null, 'a/dep', 'a/root'), ['a/root'])
39
+ assert.deepStrictEqual(merge_requested_by([], 'a/dep', 'a/root'), ['a/root'])
40
+ })
41
+ })
@@ -7,12 +7,29 @@ const { resolve_agents, unlink_from_agents } = require('../agents')
7
7
  const { print_success, print_info } = require('../ui/output')
8
8
 
9
9
  const find_orphans = (skills, removed_skill) => {
10
+ // A skill is still needed if a SURVIVING skill (anything other than the one
11
+ // being removed) declares it in its `dependencies` map. The dependencies map
12
+ // mirrors each skill's skill.json and is the authoritative statement of what a
13
+ // skill needs — independent of `requested_by`, which can be stale or clobbered
14
+ // when a shared dependency is resolved across multiple install passes. Without
15
+ // this cross-check, a dependency whose `requested_by` was overwritten to point
16
+ // only at the removed skill gets pruned even though another installed skill
17
+ // still requires it (the happyskills-design data-loss bug).
18
+ const declared_by_survivor = (name) => {
19
+ for (const [other_name, other] of Object.entries(skills)) {
20
+ if (other_name === removed_skill) continue
21
+ const deps = (other && other.dependencies) || {}
22
+ if (Object.prototype.hasOwnProperty.call(deps, name)) return true
23
+ }
24
+ return false
25
+ }
26
+
10
27
  const orphans = []
11
28
  for (const [name, data] of Object.entries(skills)) {
12
29
  if (name === removed_skill) continue
13
30
  const requested_by = data.requested_by || []
14
31
  const remaining = requested_by.filter(r => r === '__root__' || (r !== removed_skill && skills[r]))
15
- if (remaining.length === 0) {
32
+ if (remaining.length === 0 && !declared_by_survivor(name)) {
16
33
  orphans.push(name)
17
34
  }
18
35
  }
@@ -95,4 +95,42 @@ describe('find_orphans', () => {
95
95
  const result = find_orphans(skills, 'acme/other')
96
96
  assert.ok(!result.includes('acme/deploy'))
97
97
  })
98
+
99
+ it('keeps a shared dependency a surviving skill still declares, even when requested_by points only at the removed skill', () => {
100
+ // Reproduces the happyskills-design data-loss bug: design's requested_by was
101
+ // clobbered to point only at create-release-skill, but happyskills still
102
+ // declares it in its dependencies map. Uninstalling create-release-skill must
103
+ // NOT prune design.
104
+ const skills = {
105
+ 'happyskillsai/happyskills': {
106
+ requested_by: ['__root__'],
107
+ dependencies: { 'happyskillsai/happyskills-design': '^0.1.0' }
108
+ },
109
+ 'nicolasdao/create-release-skill': {
110
+ requested_by: ['__root__'],
111
+ dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
112
+ },
113
+ 'happyskillsai/happyskills-design': {
114
+ requested_by: ['nicolasdao/create-release-skill']
115
+ }
116
+ }
117
+ const result = find_orphans(skills, 'nicolasdao/create-release-skill')
118
+ assert.ok(!result.includes('happyskillsai/happyskills-design'))
119
+ })
120
+
121
+ it('prunes a dependency once no surviving skill declares it', () => {
122
+ // Same shape, but happyskills does NOT declare design — removing its sole
123
+ // remaining requester should orphan it.
124
+ const skills = {
125
+ 'nicolasdao/create-release-skill': {
126
+ requested_by: ['__root__'],
127
+ dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
128
+ },
129
+ 'happyskillsai/happyskills-design': {
130
+ requested_by: ['nicolasdao/create-release-skill']
131
+ }
132
+ }
133
+ const result = find_orphans(skills, 'nicolasdao/create-release-skill')
134
+ assert.ok(result.includes('happyskillsai/happyskills-design'))
135
+ })
98
136
  })
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
  /**
3
3
  * End-to-end integration tests: CLI ↔ stubbed API speaking the new
4
- * canonical six-key envelope (spec 260525-cli-default-json § 4).
4
+ * canonical six-key response envelope (spec 260525-cli-default-json § 4).
5
5
  *
6
6
  * We spin up a tiny `http.createServer` per test that emits the EXACT
7
7
  * envelope the production API now returns (post-Session 3), point the
@@ -33,7 +33,7 @@ const NODE = process.execPath
33
33
 
34
34
  // Minimal stub-server harness. Each test installs a route table that maps
35
35
  // `${METHOD} ${PATH}` → handler({ url, body }) returning { status, body }.
36
- // The body is always JSON-encoded with the new six-key envelope shape.
36
+ // The body is always JSON-encoded with the new canonical six-key response envelope shape.
37
37
  //
38
38
  // IMPORTANT: every response carries `Connection: close`. Without this,
39
39
  // undici's connection pool keeps the socket warm and prevents the CLI
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
  /**
3
3
  * Integration tests for `happyskills reconcile` — § 8.4 + envelope refactor
4
- * (spec 260525-cli-default-json). Asserts on the SIX-KEY envelope shape:
4
+ * (spec 260525-cli-default-json). Asserts on the canonical six-key response envelope shape:
5
5
  * top-level `ok / data / error / next_step / warnings / meta`, closed enums,
6
6
  * dependentRequired clusters. Every --json invocation is validated via the
7
7
  * shared parse_envelope helper so this file doubles as a conformance test.
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
  /**
3
3
  * Integration tests for `happyskills release` — § 8.2 + envelope refactor
4
- * (spec 260525-cli-default-json). Asserts on the six-key envelope shape and
4
+ * (spec 260525-cli-default-json). Asserts on the canonical six-key response envelope shape and
5
5
  * the closed `next_step.action` enum.
6
6
  *
7
7
  * Focus: the failure-mode envelopes (drift, missing_version, missing_changelog
@@ -6,7 +6,7 @@
6
6
  * command, every error code, and every next_step.action in one call.
7
7
  *
8
8
  * Coverage:
9
- * - Envelope conforms to the closed schema (validated via parse_envelope).
9
+ * - The canonical six-key response envelope conforms to the closed schema (validated via parse_envelope).
10
10
  * - data.envelope_schema_version + data.envelope_schema_uri present.
11
11
  * - data.commands carries one entry per known CLI command, each with
12
12
  * name, audience, input, output, errors[].
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
  // The single chokepoint for CLI JSON output. Spec 260525-cli-default-json
3
3
  // § 4 + § 8. Every command's --json path goes through emit_envelope; the
4
- // helper builds the canonical six-key envelope, derives `ok`, mirrors the
4
+ // helper builds the canonical six-key response envelope, derives `ok`, mirrors the
5
5
  // exit code into `meta`, validates against the closed schema (dev
6
6
  // assertion only — never throws in prod), serialises with 2-space indent,
7
7
  // writes to stdout, and (when called via emit_and_exit) calls process.exit.
package/src/ui/output.js CHANGED
@@ -110,14 +110,14 @@ const summarize_warnings = (warnings, skill_name) => {
110
110
 
111
111
  // print_json now routes through the central envelope chokepoint when given
112
112
  // an envelope-shaped input ({ data }, { error }, { next_step }, or a full
113
- // six-key envelope). This retrofits every legacy `print_json({ data: ... })`
113
+ // canonical six-key response envelope). This retrofits every legacy `print_json({ data: ... })`
114
114
  // call site to emit the new schema without changing the call. Raw inputs
115
115
  // (e.g. a bare array) fall through to the legacy JSON.stringify path so
116
116
  // scripts/validators that print free-form JSON still work.
117
117
  //
118
118
  // Audit follow-up #6 — dev-mode strict assertion. When
119
119
  // HAPPYSKILLS_ENVELOPE_STRICT=1 (or NODE_ENV=test), any top-level key
120
- // outside the six-key envelope is written to stderr as a warning. This
120
+ // outside the canonical six-key response envelope is written to stderr as a warning. This
121
121
  // catches the `uninstall.js:79` / `install.js:244` class bug — callers
122
122
  // passing a sibling field (`errors:` plural, `attachments:`, etc.) that
123
123
  // the retrofit would silently drop. Production stays quiet.