diff --git a/mqlaunch/commands/recommendations/recommendations-doctor.sh b/mqlaunch/commands/recommendations/recommendations-doctor.sh new file mode 100755 index 0000000..d2f11ca --- /dev/null +++ b/mqlaunch/commands/recommendations/recommendations-doctor.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Health check for the recommendations consumer. Read-only — opens nothing. +# Non-zero exit on any hard problem. +# recommendations-doctor.sh +set -euo pipefail + +LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../lib/recommendations" && pwd)" +source "$LIB/errors.sh" +source "$LIB/resolve.sh" +source "$LIB/parse.sh" +source "$LIB/doctor.sh" + +run_recommendations_doctor diff --git a/mqlaunch/commands/recommendations/recommendations-list.sh b/mqlaunch/commands/recommendations/recommendations-list.sh new file mode 100755 index 0000000..053219c --- /dev/null +++ b/mqlaunch/commands/recommendations/recommendations-list.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# List recommended command patterns (default-visible, by rank). Read-only. +# recommendations-list.sh +set -euo pipefail + +LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../lib/recommendations" && pwd)" +source "$LIB/errors.sh" +source "$LIB/resolve.sh" +source "$LIB/parse.sh" +source "$LIB/render.sh" + +path="$(assert_recommended_json)" || exit 1 + +if [[ "$(rec_visible_count "$path")" -eq 0 ]]; then + render_empty_recommendations_state "$path" + exit 0 +fi + +render_recommendations_list "$path" diff --git a/mqlaunch/commands/recommendations/recommendations-show.sh b/mqlaunch/commands/recommendations/recommendations-show.sh new file mode 100755 index 0000000..4e20833 --- /dev/null +++ b/mqlaunch/commands/recommendations/recommendations-show.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Show full detail for one recommended pattern. Read-only — renders the command +# template for show/copy; never executes it. +# recommendations-show.sh +set -euo pipefail + +LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../lib/recommendations" && pwd)" +source "$LIB/errors.sh" +source "$LIB/resolve.sh" +source "$LIB/parse.sh" +source "$LIB/render.sh" + +if [[ $# -ne 1 ]]; then + rec_error "usage: recommendations-show.sh " + exit 2 +fi + +path="$(assert_recommended_json)" || exit 1 + +if ! rec_pattern_exists "$path" "$1"; then + rec_error "pattern_id not present in recommended.json: $1" + rec_info "list valid ids with: recommendations-list.sh" + exit 2 +fi + +render_recommendation_detail "$path" "$1" diff --git a/mqlaunch/lib/recommendations/doctor.sh b/mqlaunch/lib/recommendations/doctor.sh new file mode 100644 index 0000000..36f550c --- /dev/null +++ b/mqlaunch/lib/recommendations/doctor.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Health check for the recommendations consumer. Read-only — opens/executes +# nothing, never writes. Non-zero exit on any hard problem (missing/unreadable/ +# invalid/wrong-schema source). Depends on: errors.sh, resolve.sh, parse.sh. + +run_recommendations_doctor() { + local path + if ! path="$(assert_recommended_json)"; then + return 1 # assert_* already emitted the precise reason + fi + rec_ok "recommended.json found: $path" + rec_ok "schema: $(jq -r '.schema' "$path") (generated_at: $(jq -r '.generated_at // "?"' "$path"))" + rec_ok "allowed actions: $(rec_allowed_actions "$path")" + + local total visible hidden + total="$(rec_total_count "$path")" + visible="$(rec_visible_count "$path")" + hidden=$(( total - visible )) + rec_ok "$total patterns total" + rec_ok "$visible visible (risk: $(rec_default_visible_risk "$path"))" + if [[ "$hidden" -gt 0 ]]; then + rec_warn "$hidden hidden (non-default risk, e.g. mutating) — opt-in only" + fi + if [[ "$visible" -eq 0 ]]; then + rec_warn "no visible recommendations — list will show the empty state" + fi + return 0 +} diff --git a/mqlaunch/lib/recommendations/errors.sh b/mqlaunch/lib/recommendations/errors.sh new file mode 100644 index 0000000..b4a4f15 --- /dev/null +++ b/mqlaunch/lib/recommendations/errors.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# recommendations consumer — consistent messaging to stderr. Read-only. +# Mirrors lib/mqobsidian/errors.sh so the two consumers feel the same. + +rec_error() { printf '\033[0;31m[recommend][error]\033[0m %s\n' "$*" >&2; } +rec_warn() { printf '\033[0;33m[recommend][warn]\033[0m %s\n' "$*" >&2; } +rec_info() { printf '\033[0;37m[recommend]\033[0m %s\n' "$*" >&2; } +rec_ok() { printf '\033[0;32m[recommend][ok]\033[0m %s\n' "$*" >&2; } diff --git a/mqlaunch/lib/recommendations/parse.sh b/mqlaunch/lib/recommendations/parse.sh new file mode 100644 index 0000000..869f67f --- /dev/null +++ b/mqlaunch/lib/recommendations/parse.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Read-only accessors over recommended.json. All queries go through jq — we do +# NOT re-implement ranking or scoring here; the producer already baked rank, +# score, signals, and ordered slices into the file. We only read them. +# +# Visibility honors the file's own contract: only patterns whose risk_class is +# in .default_visible_risk are listed by default (mutating patterns are hidden, +# exactly as the producer intends). Depends on: errors.sh. + +# Echo default-visible pattern ids, ordered by the producer's rank. +rec_visible_ids() { + local path="$1" + jq -r ' + .default_visible_risk as $vr + | [ .patterns[] | select(.risk_class as $r | $vr | index($r)) ] + | sort_by(.rank) + | .[].id + ' "$path" +} + +# Count of default-visible patterns. +rec_visible_count() { + local path="$1" + jq '[ .default_visible_risk as $vr | .patterns[] | select(.risk_class as $r | $vr | index($r)) ] | length' "$path" +} + +# Total pattern count (including hidden / non-default risk). +rec_total_count() { jq '.patterns | length' "$1"; } + +# True if a pattern_id exists in the file at all (visible or not). +rec_pattern_exists() { + local path="$1" id="$2" + jq -e --arg id "$id" 'any(.patterns[]; .id == $id)' "$path" >/dev/null 2>&1 +} + +# Echo a single scalar field for a pattern (empty if absent). +rec_field() { + local path="$1" id="$2" field="$3" + jq -r --arg id "$id" --arg f "$field" ' + .patterns[] | select(.id == $id) | .[$f] // empty + ' "$path" +} + +# Echo the contract's allowed actions, comma-joined (e.g. "show, copy"). +rec_allowed_actions() { jq -r '.allowed_actions | join(", ")' "$1"; } + +# Echo the contract's default-visible risk classes, comma-joined. +rec_default_visible_risk() { jq -r '.default_visible_risk | join(", ")' "$1"; } diff --git a/mqlaunch/lib/recommendations/render.sh b/mqlaunch/lib/recommendations/render.sh new file mode 100644 index 0000000..86d24f6 --- /dev/null +++ b/mqlaunch/lib/recommendations/render.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Presentation for the recommendations consumer. Read-only — renders to stdout, +# opens/executes nothing. Depends on: errors.sh, parse.sh. + +# Compact, rank-ordered list of default-visible patterns. +render_recommendations_list() { + local path="$1" + printf 'Recommended command patterns — %s, by rank (read-only)\n' "$(rec_default_visible_risk "$path")" + printf -- '-------------------------------------------------------------\n' + jq -r ' + .default_visible_risk as $vr + | [ .patterns[] | select(.risk_class as $r | $vr | index($r)) ] + | sort_by(.rank) + | .[] + | " #\(.rank) \(.id) · score \(.score)\n \(.name)\n use when: \(.use_when)" + ' "$path" + + local total visible hidden + total="$(rec_total_count "$path")" + visible="$(rec_visible_count "$path")" + hidden=$(( total - visible )) + printf -- '-------------------------------------------------------------\n' + printf '%s of %s shown' "$visible" "$total" + if [[ "$hidden" -gt 0 ]]; then + printf ' · %s hidden (non-default risk, e.g. mutating)' "$hidden" + fi + printf '\n' + printf 'detail: recommendations-show.sh (actions: %s)\n' "$(rec_allowed_actions "$path")" +} + +# Full detail for one pattern. Renders the command_template for show/copy only — +# it is never executed by this consumer. +render_recommendation_detail() { + local path="$1" id="$2" + jq -r --arg id "$id" ' + .patterns[] | select(.id == $id) | + "Pattern: \(.id)", + "Name: \(.name)", + "Risk: \(.risk_class) Scope: \(.repo_scope)", + "Rank/score: #\(.rank) · \(.score) Tags: \(.task_tags | join(", "))", + "", + .description, + "", + "Use when: \(.use_when)", + "Avoid when: \(.avoid_when)", + (if .preconditions then "Preconditions: \(.preconditions)" else empty end), + (if .recovery then "Recovery: \(.recovery)" else empty end), + "", + "Command template (show / copy only — not executed by mqlaunch):", + " \(.command_template)", + "", + "Signals: frequency=\(.signals.frequency) success=\(.signals.success // "n/a") reuse=\(.signals.reuse) prior_n=\(.signals.prior_n)" + ' "$path" +} + +# Deliberate empty state — distinguishes "no recommendations yet" from "broken". +render_empty_recommendations_state() { + local path="$1" + printf 'No recommended patterns are currently visible.\n' + printf 'The file resolved and parsed cleanly — there just is nothing to show.\n' + printf ' source: %s\n' "$path" + printf ' next: add observations + run build_views.py in the vault to populate it.\n' +} diff --git a/mqlaunch/lib/recommendations/resolve.sh b/mqlaunch/lib/recommendations/resolve.sh new file mode 100644 index 0000000..1e9c1bb --- /dev/null +++ b/mqlaunch/lib/recommendations/resolve.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Single source of truth for locating the producer's recommended.json. Read-only. +# +# The recommendations file is PRODUCED by mqobsidian (build_views.py) and only +# CONSUMED here. We do not generate, rank, or rewrite it. To avoid path sprawl +# we reuse the vault resolver (lib/mqobsidian/resolve.sh) and append the one +# stable relative path the producer writes to. +# +# Override order: +# 1. $MQ_RECOMMENDED_JSON — explicit absolute path wins +# 2. $MQ_OBSIDIAN_DIR/ — resolved via the shared vault resolver +# 3. clear error — never guess +# +# Depends on: errors.sh (rec_*), lib/mqobsidian/{errors,resolve}.sh. + +_REC_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../mqobsidian/errors.sh +source "$_REC_LIB/../mqobsidian/errors.sh" +# shellcheck source=../mqobsidian/resolve.sh +source "$_REC_LIB/../mqobsidian/resolve.sh" # provides resolve_mqobsidian_dir + +# The single stable output path build_views.py writes to, relative to vault root. +REC_RELATIVE_PATH="memory/commands/mqlaunch/recommended.json" +REC_SCHEMA="command-recommendations.v1" + +resolve_recommended_json_path() { + if [[ -n "${MQ_RECOMMENDED_JSON:-}" ]]; then + printf '%s\n' "$MQ_RECOMMENDED_JSON" + return 0 + fi + local dir + dir="$(resolve_mqobsidian_dir)" + printf '%s\n' "$dir/$REC_RELATIVE_PATH" +} + +# Validate source presence, readability, JSON validity, and schema. Echoes the +# resolved path on success; non-zero with a precise error on any failure. +assert_recommended_json() { + if ! command -v jq >/dev/null 2>&1; then + rec_error "jq is required to read recommended.json but was not found on PATH" + return 1 + fi + local path + path="$(resolve_recommended_json_path)" + if [[ ! -f "$path" ]]; then + rec_error "recommended.json not found: $path" + rec_info "set MQ_RECOMMENDED_JSON, or MQ_OBSIDIAN_DIR, or run build_views.py in the vault" + return 1 + fi + if [[ ! -r "$path" ]]; then + rec_error "recommended.json is not readable: $path" + return 1 + fi + if ! jq -e . "$path" >/dev/null 2>&1; then + rec_error "recommended.json is not valid JSON: $path" + return 1 + fi + local schema + schema="$(jq -r '.schema // empty' "$path")" + if [[ "$schema" != "$REC_SCHEMA" ]]; then + rec_error "unexpected schema: ${schema:-} (expected $REC_SCHEMA): $path" + return 1 + fi + printf '%s\n' "$path" +}