From f5a0bb064cea28576c8860fa74ac04c5ca8479c3 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Tue, 13 Oct 2026 05:42:18 -0400 Subject: [PATCH 1/3] feat: Add /repoman entrypoint skill The RepoMan epic (rossoctl/automation#72) built composable primitives -- setup writer, capability gate, enrolled-set resolver, requirements parser, per-program scanners -- but nothing wired them into the user-facing invocation flow. This adds the entrypoint: a single intent-dispatching /repoman skill that infers setup-vs-activate from ~/.repoman/ state and asks only on genuine ambiguity, always with enumerated options. Implements rossoctl/automation#107; delivers the setup-conversation half of rossoctl/automation#73. Spec: docs/specs/2026-10-12-repoman-entrypoint-skill-design.md (automation). skills/repoman/SKILL.md: - HARD INVARIANT front and center: the driver never writes ~/.repoman/ directly; every mutation goes through a repoman-setup.sh subcommand. - State-inference decision tree (SETUP x INTENT), with the cold-start conflict as the one forced, enumerated question. - Welcome orientation for no-intent branches (new + returning users), a first-class new-user acquisition flow inferred from live state. - Five-question setup conversation mapped to setup subcommands, including Q3 requirement wiring via repoman_parse_requirements -> set-requirements and Q2 Option B org enrollment. - Org-vs-repo detection rule (enrolled-name-wins -> gh probe -> ask), the single place a bare token is classified as an org; both Q2 Option B and reconciliation rule 6 route through it. - Six reconciliation rules keyed to repoman_match_name output count, and the handoff boundary to the (out-of-scope) run path. - Requirements block verified to parse via repoman_parse_requirements: pat_scopes [repo], empty labels, programs [entrypoint]. skills/repoman/scripts/org.sh: - Bundled copy of the automation source org.sh (adds repoman_match_name, the one new primitive this phase introduces). shellcheck-clean at warning severity; byte-identical to the automation source of truth. skills/automation-health-dashboard/scripts/org.sh: - Required parity resync. Adding repoman_match_name to the automation source org.sh moved the shared library; the dashboard's bundled copy is resynced in the same PR cycle to honor the 7-file byte-identical parity contract. No behavior change to the dashboard -- repoman_match_name is additive and unused by it. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- .../scripts/org.sh | 27 ++ skills/repoman/SKILL.md | 319 ++++++++++++++++++ skills/repoman/scripts/org.sh | 251 ++++++++++++++ 3 files changed, 597 insertions(+) create mode 100644 skills/repoman/SKILL.md create mode 100644 skills/repoman/scripts/org.sh diff --git a/skills/automation-health-dashboard/scripts/org.sh b/skills/automation-health-dashboard/scripts/org.sh index 537b6ac..0c411a9 100644 --- a/skills/automation-health-dashboard/scripts/org.sh +++ b/skills/automation-health-dashboard/scripts/org.sh @@ -212,6 +212,33 @@ enrolled_clone_dirs() { done } +# Emit every enrolled "owner/name" whose NAME component == ($1), +# one per line, in enrolled (file) order. Zero matches -> no output, return 0 +# (the caller distinguishes 0/1/>=2 matches by counting output lines, mapping +# onto the entrypoint skill's reconciliation rules 5/3/4). Drives from +# repoman_get_repos, so it honors $REPOMAN_REPOS_FILE and PROPAGATES its +# fail-loud behavior on a missing/empty/malformed file -- a read failure must +# not look like "zero matches", so capture-then-iterate (not a bare pipe, whose +# exit would be the while's 0 regardless of the left side). Whole-name match via +# suffix strip (${line##*/}), substring-safe: "cort" never matches "cortex". +# bash 3.2-safe: no mapfile, no associative arrays. +# Args: $1 - bare repo name (no owner, no slash) +repoman_match_name() { + local want="$1" line repos + repos=$(repoman_get_repos) || return 1 + printf '%s\n' "$repos" | while IFS= read -r line; do + [ -n "$line" ] || continue + # Must end the loop body with a 0-status command: a trailing non-matching + # line would otherwise leave `[ ... ] && printf` at exit 1, making the while + # (and the whole pipe) return 1 on a successful "no match for the last + # repo", which a `set -e` caller reads as a hard failure. The `if` guard + # keeps the body's final status 0 regardless of whether this line matched. + if [ "${line##*/}" = "$want" ]; then + printf '%s\n' "$line" + fi + done +} + # ============================================================================= # REPO SELECTION # ============================================================================= diff --git a/skills/repoman/SKILL.md b/skills/repoman/SKILL.md new file mode 100644 index 0000000..dc4e7a7 --- /dev/null +++ b/skills/repoman/SKILL.md @@ -0,0 +1,319 @@ +--- +name: repoman +description: >- + The RepoMan entrypoint. Invoke for anything RepoMan-related: first-time + setup, enabling or configuring a program, enrolling repos or an org, or + running a program against named repos. A single intent-dispatching skill + that infers setup-vs-activate from ~/.repoman/ state and asks only on + genuine ambiguity, always with enumerated options. +license: Complete terms in LICENSE.txt +--- + +# RepoMan + +RepoMan runs automated maintenance **programs** (PR review, link-health, +dependency bumps, weekly reports, repo sync) across a set of GitHub repos you +enroll once. It handles the capability checks and output wiring for you. + +`/repoman` is a single **intent-dispatching** entrypoint, not a mode you +pre-select. It reads `~/.repoman/` state and the user's message, infers whether +this is first-time setup, activating a program, or running one, and asks only +when something is genuinely ambiguous — always with explicit, enumerated +options (the driver may be a mid/low-tier model, so never require free-form +reasoning to answer). + +## HARD INVARIANT: never write `~/.repoman/` directly + +**This is the single most important rule in this skill.** You (the skill +driver) MUST NOT create, edit, append to, or delete any file under +`~/.repoman/` with your own tools — not `config.json`, not `repos.json`, not +`programs/.json` — not with Write, Edit, `echo >`, `jq > file`, `tee`, or +any other direct means. + +**Every mutation of `~/.repoman/` goes through a `repoman-setup.sh` subcommand** +(`init-config`, `add-repo`, `enable-program`, `set-requirements`, `set-output`). +That writer is the sole owner of validation, schema shape, atomicity, and dedup. +A hand-written file bypasses `validate_repos_dir`, owner/name dedup, the +known-program allowlist, and CSV→array normalization — all of which you would +otherwise have to re-derive and would get subtly wrong. + +You may **read** `~/.repoman/` freely (via the `org.sh` helpers and plain reads) +to infer state. You write it only by calling the setup writer. + +## Where the scripts live + +- **Bundled with this skill** (`scripts/org.sh`): the enrolled-set resolver and + `repoman_match_name` (bare-name → enrolled owner lookup). Source these to read + state and reconcile names. +- **In an automation checkout** (not bundled): the writer `repoman-setup.sh`, + the capability gate `repoman-check.sh`, and the requirements parser + `repoman-requirements.sh` (which exposes `repoman_parse_requirements`). Run + these from an automation checkout. **Run every script with `--help` first** to + see full usage. + +Program-name → agent-skills `SKILL.md` directory map (canonical allowlist name +on the left, skill dir on the right): + +| Program (allowlist) | Skill directory | +|---------------------|-----------------| +| `link-health` | `skills/link-health-scanner/` | +| `dep-bump` | `skills/dep-bump-scanner/` | +| `pr-review` | `skills/github-pr-review/` | +| `automation-health` | `skills/automation-health-dashboard/` | +| `repo-sync` | `skills/repo-sync/` | + +## State-inference decision tree + +Your **first action** is to read `~/.repoman/config.json` and classify. Let +`SETUP = config.json exists and parses`, and `INTENT = the message names a +program and/or a target to act on` — where a *target* is one or more +`owner/name` slugs, bare repo names, or an org. Distinguishing an org token from +a bare repo name is a reconciliation step (see "Org-vs-repo detection"), never a +free-form judgment. + +``` +read ~/.repoman/config.json + |> SETUP? + no -> INTENT? + no -> show the WELCOME ORIENTATION (what RepoMan does, which + programs ship, that it is open source), THEN begin the + first-time setup conversation (five questions) + yes -> AMBIGUOUS COLD START: ask the user (enumerated): + A. set up now, then run the named intent (one flow) + B. just set up now; re-invoke to run later + (never guess) + yes -> INTENT present? + no -> no actionable intent: show the ORIENTATION tailored to an + existing user — summarize current setup, list ENABLED + programs, list what else is AVAILABLE to activate, and the + open-source invitation (read-only; no writes) + yes -> INTENT names a program not yet enabled? + yes -> activate: enable-program + requirement wiring, + then (if INTENT also names a run) reconcile + hand off + no -> classify each named target (org-vs-repo detection), + reconcile targets vs enrolled set, hand off +``` + +The only question this tree forces unprompted is the **cold-start conflict** +(SETUP absent, INTENT present) — the one case where "no setup" and "an explicit +run request" genuinely contradict, so the user decides. Everything else is +inference over state. + +### Cold start with intent (the one forced question) + +When invoked with an intent but no setup, the signals conflict: the request says +"run," the state says "nothing is configured." Surface this with two enumerated +options and do **not** pick for the user: + +- **A — Fold the run into setup:** run the five-question conversation using the + named repos/program as the defaults for Q2 (repos) and Q3 (program), then + reconcile + hand off the requested run. One continuous conversation. +- **B — Setup only:** complete and confirm setup, then stop. The user + re-invokes `/repoman` to run. + +## Welcome orientation (new-user acquisition) + +Whenever you reach a **no-intent branch** — a brand-new user with no setup, or a +returning user who invoked `/repoman` with no concrete ask — lead with a short, +plain-language orientation *before* any question or summary. A user who does not +yet know what RepoMan is cannot sensibly answer "which programs do you want to +run?"; onboarding is a first-class flow, not a side effect of setup. + +Keep it a brief, enumerated briefing (not a wall of prose), inferred from live +state so it never lies about what is installed: + +1. **What RepoMan does, in one or two sentences** — it runs automated + maintenance programs across a set of GitHub repos you enroll once, handling + capability checks and output wiring for you. +2. **Which programs ship and are ready to deploy** — the authoritative names are + the `KNOWN_PROGRAMS` allowlist in `repoman-setup.sh` (`link-health`, + `dep-bump`, `pr-review`, `automation-health`, `repo-sync`), each shown with + its one-line description read from the program's `SKILL.md` (the same source + the gate uses; use the program→skill-dir map above). For a returning user, + split into **enabled** (already active in `~/.repoman/programs/`) vs + **available to activate**. +3. **What else can be activated** — the available-but-not-enabled set, so the + path to "turn on one more program" is visible without the user guessing a + name. +4. **Open invitation** — RepoMan is open source and welcomes new programs; point + to the repo and the program-authoring path (`docs/program-template.md`). + +For a **new user**, the orientation flows directly into the five-question setup +(they have now seen what they are enrolling into). For a **returning user** with +no intent, it ends read-only: a summary of their current setup plus the +enabled/available split, with no writes and no forced question — they say what +they want in a follow-up turn. + +The orientation makes **no** `~/.repoman/` writes; it is pure presentation over +inferred state. + +## Setup conversation (five questions) + +Ask one question at a time; each offers a sensible default so a user can accept +the whole flow quickly. Every write goes through `repoman-setup.sh` — never +write `~/.repoman/` files directly. The questions follow the canonical five in +the architecture spec (§Setup flow), each mapping to one subcommand (or, for Q2 +Option B and Q4, a step that resolves inputs *then* calls the writer). + +| # | Question | Default | Subcommand / step | +|---|----------|---------|-------------------| +| Q1 | Where should repo clones live? Who is the fork owner? | `~/repoman/repos` (a subdir, so `validate_repos_dir` accepts it) + the user's `gh` login | `init-config --repos-dir --fork-owner ` | +| Q2 | Which repos to enroll? **Option A** — individual `owner/name` slugs or URLs (repeatable). **Option B** — enroll an entire org. | from the named intent if present, else prompt | A: `add-repo (--owner --name )...`. B: see "Org enrollment" (resolve the org's repo list, then `add-repo`). | +| Q3 | Which program(s) to enable now? | from the named intent if present, else none | `enable-program --program ` (per program) + requirement wiring (below) | +| Q4 | Set up nightly repo sync? (keeps clones fresh for scanners) | yes | `enable-program --program repo-sync` (deferred: user can add later) | +| Q5 | *(not asked at setup)* Output destination is asked the first time each program is invoked, not here. | — | — | + +**Output mode is NOT a setup question.** The architecture spec defers it to the +first invocation of each program: "Same repo as the finding" (default) vs "one +central repo." That first-invocation prompt drives +`set-output --program --mode same|central [--repo ]`. It is +listed here only to make the five-question boundary explicit — setup does not ask +it, so a user is not forced to decide output routing before they have ever run +anything. + +**Capability check** (`repoman-check.sh --program +[--create-missing-labels]`) likewise runs at invocation time, not as a setup +question. You may offer to run it once at the end of setup as a convenience, but +it is not one of the five. + +**Q1 path rejection.** `validate_repos_dir` (in `org.sh`) rejects empty values, +dangerous system paths, `$HOME` itself, and non-existent dirs. The default +`~/repoman/repos` is a subdir and passes. If a user-offered path is rejected, +re-prompt with the specific reason the validator gave — do **not** silently fall +back to the default. + +**Q3 requirement wiring.** For each program the user enables: + +1. locate that program's `SKILL.md` (via the program→skill-dir map above, e.g. + `skills/github-pr-review/SKILL.md`); +2. call `repoman_parse_requirements ` → + `{"pat_scopes":[...],"labels_required":[...],"labels_applied":[...],"programs":[...]}`; +3. join each JSON array into a comma-separated string (empty array → empty CSV, + which `set-requirements` normalizes back to `[]`); +4. call `set-requirements --program --pat-scopes + --labels-required --labels-applied `. + +Parse `SKILL.md` **only** through `repoman_parse_requirements`; nothing else +reads that block. + +**Q2 Option B — org enrollment.** The spec offers "enroll an entire org" as an +alternative to listing individual repos, and the same applies when a user's +*intent* names an org (e.g. "set up RepoMan for the `rossoctl` org"). First +confirm the token is an org via "Org-vs-repo detection" (below) — do **not** +assume a bare token is an org. The `add-repo` writer takes only explicit +`{owner, name}` pairs and does **not** expand an org, so once detection confirms +an org, expansion is a step you perform *before* calling the writer: + +1. list the org's repos: `gh repo list --json nameWithOwner,isArchived`; +2. filter out archived repos (and optionally forks/templates — ask, or default + to excluding archived only); +3. present the resulting set for confirmation (count + the list, or a chance to + deselect) — enrolling a large org is a big action; +4. enroll the confirmed set in one call by piping the JSON array of + `{owner, name}` to `add-repo` on stdin (its documented array-on-stdin form), + so the writer still owns dedup and validation. + +The org-expansion `gh` call and filtering live here in the skill layer, never in +`add-repo` — the writer stays a pure writer with no `gh`. Enrolling an org is +still a set of `add-repo` entries on disk; there is no separate "org" record, so +later runs and reconciliation treat org-enrolled repos exactly like +individually-enrolled ones. + +## Repo-selection reconciliation (activate / run) + +Scanners resolve the enrolled set **internally** via `repoman_get_repos` (no +`--repos` flag exists anywhere; link-health/dep-bump drive from +`repoman_load_enrolled` + `enrolled_clone_dirs`, pr-review/weekly go straight to +GitHub). Reconciliation therefore lives **here, at the skill layer**; scanners +stay unchanged. Your job is to *narrow* the set when the user names repos, and to +catch names that cannot be run yet. + +### Org-vs-repo detection (the single org-detection rule) + +A bare token like `rossoctl` is lexically indistinguishable from a bare repo +name and a GitHub org slug, and you must **not** guess. This is the one place the +skill classifies a target as an org; both setup Q2 Option B and reconciliation +rule 6 route through it. + +Given a bare token `T` (no `/`), classify in order: + +1. **Enrolled bare name?** If `repoman_match_name T` emits any `owner/name`, `T` + is a **repo name** (reconciliation rules 3–5 take over). An already-enrolled + repo name wins — the user is almost certainly referring to a repo they + enrolled, not an org of the same slug. +2. **Live org?** Otherwise probe once: `gh repo list T --limit 1`. Success + (exit 0, non-empty) means `T` is a reachable org/user with repos → **org + candidate**. +3. **Ambiguous or neither?** If the probe is inconclusive (e.g. `T` is both a + plausible repo name the user has *not* enrolled and a reachable org), **ask** + with enumerated options — "Did you mean the `T` org (enroll its repos), or a + repo named `T` (which owner)?" — never guessing. If the probe fails outright + (no such org, no enrolled match), fall through to rule 5 (not-enrolled bare + name). + +Once `T` is confirmed an org (step 2, or the org branch of step 3), expansion is +mechanical: `gh repo list T --json nameWithOwner,isArchived` → filter archived → +the `{owner, name}` pairs the primitives already consume. Detection is the only +hard part; after confirmation the existing `add-repo` array-on-stdin path and +`repoman_get_repos` reconciliation handle the rest. + +### Reconciliation rules (per named token, setup already done) + +1. **`owner/name` given, enrolled** → use it. +2. **`owner/name` given, not enrolled** → offer to enroll it now (via + `add-repo`) and then run; if declined, drop it from the run. +3. **Bare `name`, exactly one enrolled owner** → resolve to that `owner/name` + (via `repoman_match_name` — exactly one output line). +4. **Bare `name`, multiple enrolled owners** → ambiguous: surface the candidate + `owner/name` list (`repoman_match_name` emits ≥2 lines) and ask which + (enumerated options). +5. **Bare `name`, no enrolled owner** (`repoman_match_name` emits nothing) → + treat as not-enrolled: offer to enroll (ask for the owner), else drop. +6. **An org named (no repo)**, e.g. "run pr-review on the `rossoctl` org" — + classified as an org by "Org-vs-repo detection" (not guessed) → resolve to the + set of that org's enrolled repos. If *some* of the org's live repos are not + yet enrolled, offer to enroll the missing ones first (the Q2 Option B + expansion flow), then run across the resolved org set. + +When the user names **no** repos and **no** org → run across the whole enrolled +set (the current scanner default; hand off without a repo filter). + +`repoman_match_name` output count maps directly onto the rules: **0 → rule 5, +1 → rule 3, ≥2 → rule 4.** Resolution happens here; the helper only answers the +lookup. + +### Handoff to the run path (boundary only) + +After reconciliation you hold a concrete, enrolled, unambiguous repo set (or the +whole set). The **run path** (capability-check → refresh clones → exec scanner → +write reports + `_index.json`) is a named follow-on and is **not implemented in +this skill**. Until it lands, complete reconciliation and **report the resolved +`owner/name` set you *would* run** — do not attempt to exec a scanner yourself. +This keeps the setup/activate/reconcile flows fully usable on their own. + +## Prerequisites + +- `bash` 3.2+ (the macOS system default works; the bundled `org.sh` avoids bash + 4 features) +- `gh` (GitHub CLI, authenticated with org access) — for org detection/expansion +- `jq` (JSON processor) +- An **automation checkout** on `PATH` or at a known location, for + `repoman-setup.sh`, `repoman-check.sh`, and `repoman-requirements.sh`. + +### Requirements (machine-readable) + +- pat_scopes: [repo] +- labels_required: [] +- labels_applied: [] +- programs: [entrypoint] + +## The one new executable: `repoman_match_name` + +Bundled in `scripts/org.sh`. Emits every enrolled `owner/name` whose name +component equals the given bare name, one per line; zero matches → no output, +return 0. Reads the enrolled set via `repoman_get_repos` (honoring +`REPOMAN_REPOS_FILE`) and propagates its fail-loud behavior on a +missing/empty/malformed `repos.json`. Whole-name match (substring-safe: `cort` +never matches `cortex`). bash 3.2-safe. The authoritative source and its unit +test (`tests/test-repoman-match-name.sh`) live in the automation repo; this is a +bundled copy. diff --git a/skills/repoman/scripts/org.sh b/skills/repoman/scripts/org.sh new file mode 100644 index 0000000..0c411a9 --- /dev/null +++ b/skills/repoman/scripts/org.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# RepoMan input model: reads ~/.repoman/config.json and ~/.repoman/repos.json +# (repoman_config, repoman_get_repos, is_enrolled), plus repos-dir validation. +# +# ## Portability +# Targets bash 3.2+ (macOS default) through modern bash. +# +# No intra-library deps: functions invoke gh/git/jq/builtins directly, so this +# module sources no sibling module. Self-contained for vendoring. (repoman_config +# and repoman_get_repos do read runtime JSON data files under ~/.repoman -- that +# is caller data, not a module.) +[ -n "${_ORG_SH_LOADED:-}" ] && return +_ORG_SH_LOADED=1 + +# ============================================================================= +# PATH VALIDATION +# ============================================================================= + +# Validate that a repos directory is set and not pointing to a dangerous path. +# Rejects root filesystem paths, system directories, and $HOME itself +# (without a subdirectory). Intended to prevent accidental scanning or +# modification of system files. +# +# Usage: validate_repos_dir "$REPOS_DIR" [label] +# Args: +# $1 - the repos directory path to validate +# $2 - optional label for how the caller names this path in its own errors +# (default "REPOS_DIR", the env var the scanner/fixer flow exports). +# repoman-setup.sh passes "--repos-dir" so messages match the flag the +# user actually typed, rather than an env var that interface never asks +# for. +# Returns: 0 if valid, returns 1 with an error message if invalid. (Returns, +# not exits: the caller unwinds its own error path -- both the top-level +# scanner/fixer callers under set -e and repoman-setup.sh's cmd_init_config +# treat a non-zero return as the failure it is.) +validate_repos_dir() { + local path="$1" + local label="${2:-REPOS_DIR}" + + if [ -z "$path" ]; then + echo "ERROR: $label is not set." >&2 + echo "Point it at the directory containing your org's cloned repos." >&2 + return 1 + fi + + # Resolve to absolute path for comparison + local resolved + resolved=$(cd "$path" 2>/dev/null && pwd) || resolved="$path" + + # Reject obviously dangerous paths + local dangerous_paths=("/" "/etc" "/usr" "/var" "/sys" "/proc" "/bin" "/sbin" "/lib" "/tmp") + for dangerous in "${dangerous_paths[@]}"; do + if [ "$resolved" = "$dangerous" ]; then + echo "ERROR: $label cannot be '$resolved' -- this is a system directory." >&2 + return 1 + fi + done + + # Reject $HOME itself (must be a subdirectory) + if [ "$resolved" = "$HOME" ]; then + echo "ERROR: $label cannot be your home directory itself." >&2 + echo "Use a subdirectory instead." >&2 + return 1 + fi + + # Check the directory exists + if [ ! -d "$path" ]; then + echo "ERROR: $label '$path' does not exist." >&2 + echo "Create it (and clone your org's repos there) or point $label at an existing directory." >&2 + return 1 + fi + + return 0 +} + +# ============================================================================= +# REPOMAN INPUT MODEL +# ============================================================================= +# +# RepoMan reads two user-managed files under ~/.repoman: +# config.json : deployment-wide constants ({repos_dir, fork_owner}) +# repos.json : the enrolled set (array of {owner, name}) +# There is no $ORG and no core-repos.txt. Repos are addressed as owner/name, +# and clone dirs are owner-namespaced ($REPOS_DIR///). + +# Read ~/.repoman/config.json and export the deployment-wide constants. +# Sets: REPOS_DIR (from .repos_dir, leading ~ expanded to $HOME), FORK_OWNER +# (from .fork_owner). Override the path with $REPOMAN_CONFIG_FILE (tests). +# Fails loud (return 1) on a missing file or a missing/empty required key -- +# report/source targets are NOT read here; they are per-program (Phase 2). +repoman_config() { + local config_file="${REPOMAN_CONFIG_FILE:-$HOME/.repoman/config.json}" + if [ ! -f "$config_file" ]; then + echo "ERROR: RepoMan config not found: $config_file" >&2 + return 1 + fi + + local repos_dir fork_owner + repos_dir=$(jq -r '.repos_dir // empty' "$config_file") + fork_owner=$(jq -r '.fork_owner // empty' "$config_file") + + if [ -z "$repos_dir" ]; then + echo "ERROR: config.json missing required key: repos_dir ($config_file)" >&2 + return 1 + fi + if [ -z "$fork_owner" ]; then + echo "ERROR: config.json missing required key: fork_owner ($config_file)" >&2 + return 1 + fi + + # Expand a leading ~ to $HOME. jq returns the literal string "~", so these + # patterns match a literal tilde and we expand to $HOME by hand -- SC2088's + # "tilde does not expand in quotes" is exactly the intent here, not a bug. + # shellcheck disable=SC2088 + case "$repos_dir" in + "~") repos_dir="$HOME" ;; + "~/"*) repos_dir="$HOME/${repos_dir#"~/"}" ;; + esac + + REPOS_DIR="$repos_dir" + FORK_OWNER="$fork_owner" + export REPOS_DIR FORK_OWNER +} + +# Print the enrolled repo set, one "owner/name" per line, in file order. +# Reads ~/.repoman/repos.json (array of {owner,name}); override the path with +# $REPOMAN_REPOS_FILE (tests). Fails loud (return 1) on a missing/empty file +# or an entry missing owner or name -- never silently scan an empty set. +# +# Usage (portable; no mapfile on bash 3.2): +# REPOS=(); while IFS= read -r r; do [ -n "$r" ] && REPOS+=("$r"); done \ +# < <(repoman_get_repos) +repoman_get_repos() { + local repos_file="${REPOMAN_REPOS_FILE:-$HOME/.repoman/repos.json}" + if [ ! -f "$repos_file" ]; then + echo "ERROR: RepoMan repos file not found: $repos_file" >&2 + return 1 + fi + + # jq -e exits non-zero if the array is empty or any entry lacks owner/name; + # the guarded expression fails the whole read rather than emit a bad ref. + # An empty string is rejected too: jq's `//` fires only on null/false/absent, + # not on "", so a `{"owner":"a","name":""}` typo would otherwise emit "a/". + # select() drops the empty/null value so `//` sees `empty` and errors. + local out + if ! out=$(jq -er ' + if length == 0 then error("empty repos array") + else .[] | ((.owner | select(. != null and . != "")) // error("entry missing owner")) as $o + | ((.name | select(. != null and . != "")) // error("entry missing name")) as $n + | "\($o)/\($n)" + end' "$repos_file" 2>/dev/null); then + echo "ERROR: repos.json is empty or has a malformed entry: $repos_file" >&2 + return 1 + fi + printf '%s\n' "$out" +} + +# Return 0 if the given "owner/name" is in the enrolled set, else 1. +# Exact whole-line match (grep -Fx) to avoid substring false positives. +# Args: $1 - "owner/name" +# +# Re-parses repos.json on every call, so it is a one-off membership check, not a +# per-iteration one. To iterate the enrolled clones in a loop, use +# enrolled_clone_dirs (which drives from the enrolled set and stats each clone). +is_enrolled() { + local repo="$1" + repoman_get_repos | grep -qxF "$repo" +} + +# Load the enrolled set once for in-memory membership checks. A thin semantic +# alias for repoman_get_repos: it prints the same "owner/name"-per-line set and +# fails loud identically, and it re-reads repos.json on every call (there is no +# internal caching -- capturing the result once is the caller's job). Callers +# capture it into a variable before a loop: +# enrolled=$(repoman_load_enrolled) || exit 1 +repoman_load_enrolled() { + repoman_get_repos +} + +# Return 0 if "owner/name" ($1) is in a preloaded enrolled set ($2, newline- +# separated as produced by repoman_load_enrolled), else 1. Exact whole-line +# match (grep -Fx) -- same substring-safe semantics as is_enrolled, but against +# a preloaded set string rather than a file read. For a one-off check of a repo +# discovered outside the enrolled set; the clone loops instead drive from the +# set directly via enrolled_clone_dirs, so they need no per-repo membership test. +# Args: $1 - "owner/name"; $2 - the preloaded enrolled set +is_enrolled_in() { + local repo="$1" enrolled="$2" + printf '%s\n' "$enrolled" | grep -qxF "$repo" +} + +# Emit each ENROLLED "owner/name" whose clone exists under $REPOS_DIR, one per +# line, in enrolled order. This is the scanners'/fixers' loop driver: they +# iterate the enrolled set and stat each clone, rather than globbing +# "$REPOS_DIR"/*/ and filtering. Globbing silently dropped a ".github" repo, +# because bash excludes leading-dot entries from a "*/" glob unless dotglob is +# set; driving from enrollment makes the enrolled set authoritative and treats +# a leading-dot name as the ordinary string it is. A repo enrolled but not yet +# cloned (no "$REPOS_DIR/owner/name/.git") is skipped, not emitted. +# Args: $1 - the preloaded enrolled set (as from repoman_load_enrolled). +# Reads: $REPOS_DIR (must be set by repoman_config). +# Callers capture once and iterate with a here-string, keeping the loop body in +# the current shell so counters accumulate: +# CLONES=$(enrolled_clone_dirs "$ENROLLED") +# while IFS= read -r full_repo; do ... done <<< "$CLONES" +enrolled_clone_dirs() { + local enrolled="$1" full + printf '%s\n' "$enrolled" | while IFS= read -r full; do + [ -n "$full" ] || continue + [ -d "$REPOS_DIR/$full/.git" ] || continue + printf '%s\n' "$full" + done +} + +# Emit every enrolled "owner/name" whose NAME component == ($1), +# one per line, in enrolled (file) order. Zero matches -> no output, return 0 +# (the caller distinguishes 0/1/>=2 matches by counting output lines, mapping +# onto the entrypoint skill's reconciliation rules 5/3/4). Drives from +# repoman_get_repos, so it honors $REPOMAN_REPOS_FILE and PROPAGATES its +# fail-loud behavior on a missing/empty/malformed file -- a read failure must +# not look like "zero matches", so capture-then-iterate (not a bare pipe, whose +# exit would be the while's 0 regardless of the left side). Whole-name match via +# suffix strip (${line##*/}), substring-safe: "cort" never matches "cortex". +# bash 3.2-safe: no mapfile, no associative arrays. +# Args: $1 - bare repo name (no owner, no slash) +repoman_match_name() { + local want="$1" line repos + repos=$(repoman_get_repos) || return 1 + printf '%s\n' "$repos" | while IFS= read -r line; do + [ -n "$line" ] || continue + # Must end the loop body with a 0-status command: a trailing non-matching + # line would otherwise leave `[ ... ] && printf` at exit 1, making the while + # (and the whole pipe) return 1 on a successful "no match for the last + # repo", which a `set -e` caller reads as a hard failure. The `if` guard + # keeps the body's final status 0 regardless of whether this line matched. + if [ "${line##*/}" = "$want" ]; then + printf '%s\n' "$line" + fi + done +} + +# ============================================================================= +# REPO SELECTION +# ============================================================================= +# +# Single source of truth for which repos the programs act on: the enrolled set +# in ~/.repoman/repos.json (see REPOMAN INPUT MODEL above). Programs derive +# their repo set from repoman_get_repos()/is_enrolled(), so coverage is defined +# in one place rather than hardcoded per script. Repos are addressed as +# owner/name and clone dirs are owner-namespaced, so there is no canonical-name +# remap step: each repo's directory is its owner/name pair, unambiguously. From b267833abe4a160119441da6a92c564caab32cac Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Tue, 13 Oct 2026 10:04:15 -0400 Subject: [PATCH 2/3] docs: Clarify Q1 driver must mkdir repos dir Address clawgenti review on #49: on a fresh system ~/repoman/repos does not exist and repoman-setup.sh init-config never creates --repos-dir, so the skill driver must mkdir -p the chosen repos dir before init-config. It is outside ~/.repoman/, so this does not violate the hard invariant. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- skills/repoman/SKILL.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/skills/repoman/SKILL.md b/skills/repoman/SKILL.md index dc4e7a7..909ae21 100644 --- a/skills/repoman/SKILL.md +++ b/skills/repoman/SKILL.md @@ -177,10 +177,15 @@ question. You may offer to run it once at the end of setup as a convenience, but it is not one of the five. **Q1 path rejection.** `validate_repos_dir` (in `org.sh`) rejects empty values, -dangerous system paths, `$HOME` itself, and non-existent dirs. The default -`~/repoman/repos` is a subdir and passes. If a user-offered path is rejected, -re-prompt with the specific reason the validator gave — do **not** silently fall -back to the default. +dangerous system paths, `$HOME` itself, and non-existent dirs. The last clause +matters on a fresh system: `~/repoman/repos` is a safe subdir path, but it does +not exist until something creates it, and `init-config` only `mkdir -p`s the +config file's parent under `~/.repoman/` — never `--repos-dir`. So before +calling `init-config`, `mkdir -p` the chosen repos dir. That directory is +outside `~/.repoman/`, so creating it does **not** violate the hard invariant +(which guards only `~/.repoman/` state). If a user-offered path is rejected for +any other reason, re-prompt with the specific reason the validator gave — do +**not** silently fall back to the default. **Q3 requirement wiring.** For each program the user enables: From 6f7a53a5f863a9ff0af3488cd3bbb6b299faa074 Mon Sep 17 00:00:00 2001 From: Gloire Rubambiza Date: Tue, 13 Oct 2026 11:36:27 -0400 Subject: [PATCH 3/3] docs: Link run-path follow-on to #107 in SKILL.md Address review nit on #49: the run path is the 'program invocation wiring' half of #107 (epic #72), not a separate tracker. Link it so a reader lands on the tracking item instead of inferring from the epic. Assisted-By: Claude Code (Anthropic AI) Signed-off-by: Gloire Rubambiza --- skills/repoman/SKILL.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/skills/repoman/SKILL.md b/skills/repoman/SKILL.md index 909ae21..d33e27b 100644 --- a/skills/repoman/SKILL.md +++ b/skills/repoman/SKILL.md @@ -291,8 +291,11 @@ lookup. After reconciliation you hold a concrete, enrolled, unambiguous repo set (or the whole set). The **run path** (capability-check → refresh clones → exec scanner → -write reports + `_index.json`) is a named follow-on and is **not implemented in -this skill**. Until it lands, complete reconciliation and **report the resolved +write reports + `_index.json`) is a named follow-on — the "program invocation +wiring" half of [#107](https://github.com/rossoctl/automation/issues/107) (epic +[#72](https://github.com/rossoctl/automation/issues/72)) — and is **not +implemented in this skill**. Until it lands, complete reconciliation and +**report the resolved `owner/name` set you *would* run** — do not attempt to exec a scanner yourself. This keeps the setup/activate/reconcile flows fully usable on their own.