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 +16 -4
- package/package.json +1 -1
- package/src/api/translate.js +1 -1
- package/src/commands/feedback.js +2 -2
- package/src/commands/postlex.js +29 -5
- package/src/commands/postlex.test.js +48 -0
- package/src/commands/search.js +1 -1
- package/src/commands/search.test.js +1 -1
- package/src/constants/error_codes.js +1 -1
- package/src/engine/installer.js +30 -2
- package/src/engine/installer.test.js +41 -0
- package/src/engine/uninstaller.js +18 -1
- package/src/engine/uninstaller.test.js +38 -0
- package/src/integration/api_envelope.test.js +2 -2
- package/src/integration/reconcile.test.js +1 -1
- package/src/integration/release.test.js +1 -1
- package/src/integration/schema.test.js +1 -1
- package/src/ui/envelope.js +1 -1
- package/src/ui/output.js +2 -2
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
|
-
- **
|
|
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
|
|
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
|
|
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
package/src/api/translate.js
CHANGED
|
@@ -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.
|
package/src/commands/feedback.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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 })
|
package/src/commands/postlex.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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,
|
package/src/commands/search.js
CHANGED
|
@@ -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
|
|
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, '
|
|
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
|
|
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).
|
package/src/engine/installer.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
* -
|
|
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[].
|
package/src/ui/envelope.js
CHANGED
|
@@ -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.
|