Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions skills/automation-health-dashboard/scripts/org.sh
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,33 @@ enrolled_clone_dirs() {
done
}

# Emit every enrolled "owner/name" whose NAME component == <bare-name> ($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
# =============================================================================
Expand Down
327 changes: 327 additions & 0 deletions skills/repoman/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
---
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/<name>.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 <path> --fork-owner <owner>` |
Comment thread
rubambiza marked this conversation as resolved.
Resolved
| 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 <o> --name <n>)...`. 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 <name>` (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 <name> --mode same|central [--repo <owner/name>]`. 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 <name>
[--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 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:

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 <skill-md>` →
`{"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 <name> --pat-scopes <csv>
--labels-required <csv> --labels-applied <csv>`.

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 <org> --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 — 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — "The run path … is not implemented in this skill" — consider linking the follow-on issue number here so a reader lands on the tracking item rather than inferring it from epic #72.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6f7a53a. The run path is the "program invocation wiring" half of #107 (epic #72) rather than a separate tracker, so I linked both there instead of inventing a new issue number.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — The "7-file parity contract" is asserted but unenforced anywhere. Neither repo's CI has a parity job: agent-skills runs only pr-verifier/project/self-assign, and automation's tests.yml checks its own function surface (test-lib-inventory.sh asserts the 28-function inventory of program-lib.sh), never the bundled agent-skills copies.

The PR body says "agent-skills CI cannot detect the drift" until #110 lands — but it can't detect drift after #110 lands either, in either direction. Worth tracking a real byte-comparison check; the merge-ordering requirement is currently enforced by human discipline alone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and you're right that it's unenforced in both directions even after automation#110 lands — the merge-ordering line in the PR body only covers the pre-#110 window. A real byte-comparison parity check is already tracked in #47 ("Add committed dashboard-copy parity test (automation vs agent-skills)"); I confirmed in a sweep today that it's still NOT DONE. I'll leave the contract documentation as-is here and let #47 carry the enforcement so this PR stays scoped to the skill. The ordering remains human-discipline-enforced until #47 lands.

Loading
Loading