happyskills 0.47.1 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/package.json +1 -1
- package/src/commands/bump.js +9 -29
- package/src/commands/check.js +36 -4
- package/src/commands/install.js +96 -11
- package/src/commands/list.js +39 -9
- package/src/commands/postlex.js +101 -10
- package/src/commands/postlex.test.js +141 -0
- package/src/commands/publish.js +43 -36
- package/src/commands/pull.js +40 -0
- package/src/commands/reconcile.js +229 -0
- package/src/commands/release.js +451 -0
- package/src/commands/release.test.js +209 -0
- package/src/commands/search.js +2 -0
- package/src/commands/snapshot.js +252 -0
- package/src/commands/status.js +45 -23
- package/src/config/limits.js +11 -0
- package/src/constants.js +4 -1
- package/src/index.js +3 -0
- package/src/integration/bump.test.js +170 -0
- package/src/integration/drift.test.js +63 -8
- package/src/integration/install_fresh.test.js +167 -0
- package/src/integration/reconcile.test.js +188 -0
- package/src/integration/release.test.js +183 -0
- package/src/lock/verify.js +151 -16
- package/src/lock/verify.test.js +182 -10
- package/src/merge/rebase.js +322 -0
- package/src/merge/rebase.test.js +110 -0
- package/src/snapshot/paths.js +31 -0
- package/src/snapshot/storage.js +237 -0
- package/src/snapshot/storage.test.js +298 -0
- package/src/validation/file_size_rules.js +20 -9
- package/src/validation/file_size_rules.test.js +23 -18
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,69 @@ 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
|
+
|
|
58
|
+
## [0.48.0] - 2026-05-22
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
- Add `--search-output <file>` flag to `happyskills postlex`. When set, postlex reads the full `search --with-rerank --json` response envelope from that file and extracts `data.results` internally — the calling agent's stdin payload shrinks to just `{"ranking": [...]}`. **Recommended path** for agentic callers: the agent never has to construct the `data` array by hand, which eliminates two observed failure modes in production (missing `data` field consuming the one retry budget, and `skill` vs `name` field mismatch dropping every ranking entry). Backward-compatible: the legacy `{"ranking", "data"}` stdin shape and the separate `--data <file>` flag both still work and take precedence only when `--search-output` is absent.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
- Fix `to_smart_json` in `search.js` stripping the bare `name` field from `data.results` rows. The function emitted `skill` (the composite "workspace/name" slug) but consumed the underlying `name` field into the template literal, so downstream consumers of `data.results` — most notably `happyskills postlex` — received rows without a usable `name`. postlex's `validate_ranking` then dropped every ranking entry with `data row missing name`. The function now emits both `name` (raw, for downstream pipeline consumers) AND `skill` (composite, for human-readable display), keeping backward compatibility with anything reading `skill`.
|
|
65
|
+
- Add `star_count` to `to_smart_json` output (in addition to the existing `stars` field) so `postlex`'s human-readable table renderer — which expected `row.star_count` — can find the value on rows that originated from the search response. Same root cause as the `name` issue: the search-output and postlex-input shapes had drifted apart silently.
|
|
66
|
+
- Add `resolve_row_name` + `normalize_data_rows` helpers in `postlex.js` that handle the legacy case where `data.results` rows have `skill` but no `name`. `resolve_row_name` falls back to the last `/`-separated segment of `skill`; `normalize_data_rows` runs the resolution across the array. Idempotent. This is defense-in-depth — anyone passing rows from an older CLI version or a hand-crafted payload no longer trips the validator.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
- `parse_input` in `postlex.js` accepts a new optional third argument carrying the raw `--search-output` content. When provided, it's parsed and `data.results` is extracted via `extract_data_array_from_search_output`, which tolerates the canonical envelope shape (`{ data: { results: [...] } }`), the legacy `{ data: [...] }` shape, a bare array, and a defensive double-wrapped `{ data: { data: [...] } }`. When both inline `data` (from the stdin payload) and `--search-output` are provided, the search-output wins.
|
|
70
|
+
- `postlex`'s help text rewritten to lead with the recommended `--search-output` recipe and demote the legacy stdin `{ranking, data}` shape to a "still supported" alternative.
|
|
71
|
+
- When `data` cannot be located from any source (no inline `data`, no `--data` file, no `--search-output`), the error message now points the caller at `--search-output` explicitly so they know which input is missing.
|
|
72
|
+
|
|
10
73
|
## [0.47.1] - 2026-05-22
|
|
11
74
|
|
|
12
75
|
### Fixed
|
package/package.json
CHANGED
package/src/commands/bump.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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'
|
package/src/commands/check.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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(`
|
|
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()
|
package/src/commands/install.js
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 })
|
package/src/commands/list.js
CHANGED
|
@@ -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
|
|
80
|
-
//
|
|
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)
|
|
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
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
|
package/src/commands/postlex.js
CHANGED
|
@@ -32,22 +32,61 @@ Required:
|
|
|
32
32
|
--ranking <file|-> Path to the ranking JSON, or \`-\` for stdin
|
|
33
33
|
|
|
34
34
|
Optional:
|
|
35
|
-
--
|
|
35
|
+
--search-output <file> Path to the full \`search --with-rerank --json\` response
|
|
36
|
+
envelope. postlex extracts \`data.results\` from it
|
|
37
|
+
internally, so the ranking payload only needs the
|
|
38
|
+
ranking array. **Recommended path** for agentic
|
|
39
|
+
callers — eliminates the join the agent would
|
|
40
|
+
otherwise have to assemble by hand.
|
|
41
|
+
--data <file> Legacy: separate data file (when --ranking does not
|
|
42
|
+
embed it AND --search-output isn't used)
|
|
36
43
|
--clarification-turns-used <N>
|
|
37
44
|
Clarification budget already spent (0-2, default 0)
|
|
38
45
|
--original-query <q> Original user query (opaque context from prior step)
|
|
39
46
|
--json Output as JSON (default: human-readable table)
|
|
40
47
|
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
|
|
48
|
+
Recommended shape (v0.48.0+) — agent emits ONLY the ranking, postlex does the join:
|
|
49
|
+
echo '{"ranking":[{"rank":1,"candidate_id":5,"rationale":"..."}, ...]}' | \\
|
|
50
|
+
happyskills postlex --query "deploy aws" \\
|
|
51
|
+
--search-output /tmp/search-out.json \\
|
|
52
|
+
--ranking -
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
Legacy shape (still supported):
|
|
46
55
|
echo '{"ranking":[...],"data":[...]}' | happyskills postlex --query "deploy aws" --ranking -
|
|
47
56
|
happyskills postlex --query "deploy aws" --ranking r.json --data d.json --json`
|
|
48
57
|
|
|
49
58
|
// ─── Pure logic — exported for unit testing ────────────────────────────────
|
|
50
59
|
|
|
60
|
+
// Resolve a candidate's bare name, tolerating multiple field conventions.
|
|
61
|
+
// The API row shape has `name`. The CLI's `to_smart_json` output (as of
|
|
62
|
+
// happyskills@0.48.0) emits both `name` and the composite `skill`
|
|
63
|
+
// ("workspace/name"). Older callers may pass through rows that only have
|
|
64
|
+
// `skill`, so we fall back to extracting the bare name from the slug's last
|
|
65
|
+
// `/`-separated segment. Returns null when no name can be derived.
|
|
66
|
+
const resolve_row_name = (row) => {
|
|
67
|
+
if (!row || typeof row !== 'object') return null
|
|
68
|
+
if (typeof row.name === 'string' && row.name) return row.name
|
|
69
|
+
if (typeof row.skill === 'string' && row.skill) {
|
|
70
|
+
const parts = row.skill.split('/')
|
|
71
|
+
const tail = parts[parts.length - 1]
|
|
72
|
+
if (tail) return tail
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Mutate `data` rows in-place to ensure they have a `name` field.
|
|
78
|
+
// Idempotent. Used before validate_ranking / apply_postlex / build_final_ordering
|
|
79
|
+
// so downstream code can keep its simple `row.name` access pattern.
|
|
80
|
+
const normalize_data_rows = (data) => {
|
|
81
|
+
if (!Array.isArray(data)) return
|
|
82
|
+
for (const row of data) {
|
|
83
|
+
if (row && typeof row === 'object' && !row.name) {
|
|
84
|
+
const resolved = resolve_row_name(row)
|
|
85
|
+
if (resolved) row.name = resolved
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
51
90
|
// Validate the ranking shape. Returns { valid_items, dropped } — invalid
|
|
52
91
|
// entries are dropped with a reason rather than crashing.
|
|
53
92
|
const validate_ranking = (ranking, data) => {
|
|
@@ -204,9 +243,33 @@ const read_stdin_sync = () => {
|
|
|
204
243
|
}
|
|
205
244
|
}
|
|
206
245
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
246
|
+
// Extract the data array (the rerank candidate set) from a full
|
|
247
|
+
// `happyskills search --with-rerank --json` response envelope. The
|
|
248
|
+
// envelope's shape is `{ data: { query, mode, results: [...], ... },
|
|
249
|
+
// error, next_step }` — we want `envelope.data.results`. Also tolerates a
|
|
250
|
+
// bare array, a `{data: [...]}` shape (legacy callers), and the
|
|
251
|
+
// `{data: {data: [...]}}` shape that appears if someone double-wraps.
|
|
252
|
+
// Returns the array, or null when no array can be located.
|
|
253
|
+
const extract_data_array_from_search_output = (parsed) => {
|
|
254
|
+
if (Array.isArray(parsed)) return parsed
|
|
255
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
256
|
+
const inner = parsed.data
|
|
257
|
+
if (Array.isArray(inner)) return inner
|
|
258
|
+
if (inner && typeof inner === 'object') {
|
|
259
|
+
if (Array.isArray(inner.results)) return inner.results
|
|
260
|
+
if (Array.isArray(inner.data)) return inner.data
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(parsed.results)) return parsed.results
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const parse_input = (raw_ranking_input, raw_data_input, raw_search_output_input) => {
|
|
267
|
+
// Accept either:
|
|
268
|
+
// - combined `{ranking, data}` from stdin/one file (legacy v0.47.x shape), OR
|
|
269
|
+
// - separate ranking + data files (`--ranking <file>` + `--data <file>`), OR
|
|
270
|
+
// - just `{ranking: ...}` or a bare ranking array PLUS a `--search-output` file
|
|
271
|
+
// containing the full search envelope from which we extract `data.results`
|
|
272
|
+
// (v0.48.0+ recommended shape — agent never has to construct `data`).
|
|
210
273
|
const parse_one = (raw, label) => {
|
|
211
274
|
if (raw == null || raw === '') return { value: null, parse_error: `${label} input is empty` }
|
|
212
275
|
try {
|
|
@@ -240,8 +303,25 @@ const parse_input = (raw_ranking_input, raw_data_input) => {
|
|
|
240
303
|
else return { ranking, data: null, parse_error: 'data file does not contain a data array' }
|
|
241
304
|
}
|
|
242
305
|
|
|
306
|
+
if (raw_search_output_input != null) {
|
|
307
|
+
const so_parse = parse_one(raw_search_output_input, 'search-output')
|
|
308
|
+
if (so_parse.parse_error) return { ranking, data, parse_error: so_parse.parse_error }
|
|
309
|
+
const extracted = extract_data_array_from_search_output(so_parse.value)
|
|
310
|
+
if (!Array.isArray(extracted)) {
|
|
311
|
+
return { ranking, data: null, parse_error: 'search-output does not contain a data.results array' }
|
|
312
|
+
}
|
|
313
|
+
// search-output is the recommended source — it overrides any inline data.
|
|
314
|
+
data = extracted
|
|
315
|
+
}
|
|
316
|
+
|
|
243
317
|
if (!Array.isArray(ranking)) return { ranking: null, data, parse_error: 'ranking field is missing or not an array' }
|
|
244
|
-
if (!Array.isArray(data)) return { ranking, data: null, parse_error: 'data field is missing or not an array' }
|
|
318
|
+
if (!Array.isArray(data)) return { ranking, data: null, parse_error: 'data field is missing or not an array — provide --search-output <file> with the search response, or include "data" in the stdin payload' }
|
|
319
|
+
|
|
320
|
+
// Normalize row names (handles the `skill`-without-`name` case from older
|
|
321
|
+
// CLI versions or hand-crafted payloads). Idempotent on rows that already
|
|
322
|
+
// have a `name` field.
|
|
323
|
+
normalize_data_rows(data)
|
|
324
|
+
|
|
245
325
|
return { ranking, data, parse_error: null }
|
|
246
326
|
}
|
|
247
327
|
|
|
@@ -287,6 +367,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
287
367
|
const query = args.flags.query
|
|
288
368
|
const ranking_path = args.flags.ranking
|
|
289
369
|
const data_path = args.flags.data
|
|
370
|
+
const search_output_path = args.flags['search-output']
|
|
290
371
|
const clarification_turns_used = parseInt(args.flags['clarification-turns-used'] || '0', 10) || 0
|
|
291
372
|
|
|
292
373
|
if (!query || typeof query !== 'string')
|
|
@@ -309,9 +390,16 @@ const run = (args) => catch_errors('Postlex failed', async () => {
|
|
|
309
390
|
return r.content
|
|
310
391
|
})()
|
|
311
392
|
: null
|
|
393
|
+
const raw_search_output = search_output_path
|
|
394
|
+
? (() => {
|
|
395
|
+
const r = read_file(search_output_path)
|
|
396
|
+
if (r.err) throw new UsageError(`Cannot read --search-output file: ${r.err}`)
|
|
397
|
+
return r.content
|
|
398
|
+
})()
|
|
399
|
+
: null
|
|
312
400
|
|
|
313
401
|
// Parse
|
|
314
|
-
const { ranking, data, parse_error } = parse_input(raw_ranking, raw_data)
|
|
402
|
+
const { ranking, data, parse_error } = parse_input(raw_ranking, raw_data, raw_search_output)
|
|
315
403
|
if (parse_error) {
|
|
316
404
|
process.stderr.write(`postlex: ${parse_error}\n`)
|
|
317
405
|
const env = build_retry_envelope(query, parse_error, clarification_turns_used, 0)
|
|
@@ -399,4 +487,7 @@ module.exports = {
|
|
|
399
487
|
determine_next_step,
|
|
400
488
|
build_retry_envelope,
|
|
401
489
|
parse_input,
|
|
490
|
+
resolve_row_name,
|
|
491
|
+
normalize_data_rows,
|
|
492
|
+
extract_data_array_from_search_output,
|
|
402
493
|
}
|