diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 63fb349d..b6b9e30c 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -46,6 +46,12 @@ steps: # [0] of the pnpm chain, so severing that chain would silence it together # with everything it guards; this direct line keeps one instrument running. - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh + # Tool-index gate: a shipped wrapper that appears in no resident index doc + # is undiscoverable from inside a session, and an agent that cannot learn a + # wrapper exists reaches for raw curl instead — which is how a Gitea review + # got filed PENDING three times. Ships-and-documented is one commit, or red. + - bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test + - bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh # Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH # and sandboxes a throwaway git repo, so it resolves no real credentials and # joins CI directly rather than the exclusions file. diff --git a/packages/mosaic/framework/defaults/STANDARDS.md b/packages/mosaic/framework/defaults/STANDARDS.md index d9ea7f40..a1cf2bc0 100644 --- a/packages/mosaic/framework/defaults/STANDARDS.md +++ b/packages/mosaic/framework/defaults/STANDARDS.md @@ -52,6 +52,52 @@ If a repo does not expose these scripts, run equivalent local workflow commands - Do not auto-resolve data conflicts in shared state files. - Keep commits scoped to a single logical change set. +## Model Tiering + +Model choice is a standard, not a preference. Delegating a mechanical grep to a +frontier reasoning model wastes budget; sending a security review to a cheap tier +produces a review that passes and proves nothing. Both are defects. + +Tiers are named by **capability class**, so the standard survives a model +generation. An operator binds each class to a concrete model id. + +| Class | Use for | +| --- | --- | +| `search` | grep/glob, file location, status and health checks, one-line mechanical edits | +| `build` | feature implementation, test writing, bugfixes, routine refactors | +| `judge` | code review, planning, API/compat-sensitive changes | +| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive | + +Rules: + +1. **Start at the cheapest class that can do the task; escalate on evidence, not + on nerves.** Omitting a tier is not neutral — it inherits the caller's model, + which is usually the most expensive one. +2. **Compat-sensitive work escalates one class.** A change that must interoperate + with an existing contract is judged, not just built. +3. **A tier assignment is benchmarked, not asserted.** Move a task class to a + cheaper tier only against a blind A/B on real work from this codebase, ranked + by someone other than the author. "It seemed fine" is not evidence. +4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from + the model that wrote the code is not a second opinion (see Constitution gate 16). + +### Where the binding lives + +The class→model map is operator configuration, never framework source: model +availability, cost, and quotas differ per operator and per host. + +Resolution order, first hit wins: + +1. the config service (DB-backed, surfaced and editable in the Mosaic webUI) +2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime + injects it) +3. the framework default — the class names above, with no binding + +Only layer 1 is auditable across a fleet, so it is the target end state; layers 2 +and 3 exist so a host with no config service still runs. A local override that +silently disagrees with the config service is drift — the same failure class the +tool-index gate exists to catch, and it belongs in `mosaic doctor`. + ## Prompting Contract All runtime adapters should inject: diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index 0eca6c5a..5ba36d1e 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -11,22 +11,105 @@ All tool suites are located at `~/.config/mosaic/tools/`. Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands. +This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh` +fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no +longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable +from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened. + +Every command takes `--help`. All of them accept `--login ` to pin the acting identity; +supply it explicitly on any host where the provider CLI's default account is an admin. + +| Issues | | +| --- | --- | +| `issue-create.sh` | Create an issue (Gitea or GitHub) | +| `issue-view.sh` | Show one issue | +| `issue-list.sh` | List issues | +| `issue-edit.sh` | Edit title/body/labels/milestone | +| `issue-comment.sh` | Add a comment | +| `issue-assign.sh` | Assign or unassign | +| `issue-close.sh` | Close an issue | +| `issue-reopen.sh` | Reopen a closed issue | + +| Pull requests | | +| --- | --- | +| `pr-create.sh` | Open a pull request | +| `pr-view.sh` | Show one PR | +| `pr-list.sh` | List PRs | +| `pr-diff.sh` | Fetch a PR's diff | +| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) | +| `pr-review.sh` | **Place a review verdict — see the dialect note below** | +| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state | +| `pr-merge.sh` | Merge a PR | +| `pr-close.sh` | Close a PR without merging | + +| Milestones | | +| --- | --- | +| `milestone-create.sh` | Create a milestone | +| `milestone-list.sh` | List milestones | +| `milestone-close.sh` | Close a milestone | + +| Gates and guards | | +| --- | --- | +| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) | +| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) | +| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard | +| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree | + +| Context | | +| --- | --- | +| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it | +| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider | + +| Workspace | | +| --- | --- | +| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below | +| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand | + +**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new ` takes a branch +name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree, +repo name, parent dir, then `/-worktrees/`. There is no placement flag +because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work +filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories +anyway, under five simultaneous conventions on a single host. + ```bash -# Issues -~/.config/mosaic/tools/git/issue-create.sh -~/.config/mosaic/tools/git/issue-close.sh +~/.config/mosaic/tools/git/mosaic-worktree.sh new [--from ] +~/.config/mosaic/tools/git/mosaic-worktree.sh path # derived path, no side effect +~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state +~/.config/mosaic/tools/git/mosaic-worktree.sh rm # removal is part of the task +~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones +``` -# PRs -~/.config/mosaic/tools/git/pr-create.sh -~/.config/mosaic/tools/git/pr-merge.sh +Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare +clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and +`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when +`git status --porcelain` is empty *and* `git rev-list --count HEAD --not --remotes` is 0. Anything +else is preserved and reported. `--force` exists and is yours to type deliberately. -# Milestones -~/.config/mosaic/tools/git/milestone-create.sh +`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see +`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through: +a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that +already has a wrapper above (reads are untouched — they are how you gather evidence); and the +literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix +`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has +a missing flag — extend the wrapper. + +```bash +~/.config/mosaic/tools/git/issue-create.sh --help +~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..." # CI queue guard (required before push/merge; defaults to the checked-out branch) ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge ``` +**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is +`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP +200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the +verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the +approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider. +Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict +placed. + The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head ` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. ### Code Review (Codex) diff --git a/packages/mosaic/framework/runtime/claude/settings.json b/packages/mosaic/framework/runtime/claude/settings.json index eada96fe..1f60a7c3 100644 --- a/packages/mosaic/framework/runtime/claude/settings.json +++ b/packages/mosaic/framework/runtime/claude/settings.json @@ -52,6 +52,16 @@ "timeout": 10 } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.config/mosaic/tools/git/wrapper-guard.sh", + "timeout": 10 + } + ] } ], "PostToolUse": [ diff --git a/packages/mosaic/framework/tools/git/mosaic-worktree.sh b/packages/mosaic/framework/tools/git/mosaic-worktree.sh new file mode 100755 index 00000000..7d230934 --- /dev/null +++ b/packages/mosaic/framework/tools/git/mosaic-worktree.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# mosaic-worktree.sh — the only supported way to create and dispose of a git +# worktree on a fleet host. +# +# Why this exists as a helper and not as a rule: the rule already existed, in +# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in +# $HOME across 842 directories anyway. Five placement conventions were live on +# one fleet host simultaneously. Every one was a decision an agent had to make, +# and a decision an agent has to make is a decision that drifts. +# +# So this script makes NO placement decision available. The caller supplies a +# branch name. Every path is DERIVED: +# +# main worktree <- git worktree list --porcelain (never cwd, which may +# itself already be a worktree) +# REPO_NAME <- basename of the main worktree +# REPO_PARENT <- dirname of the main worktree +# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees +# SLUG <- branch with '/' replaced by '-' +# WT_PATH <- $WT_ROOT/$SLUG +# +# The derivation puts the worktree on the same filesystem as the object store +# it shares, as a sibling of the repo, under one root per repo. Those are the +# properties that make the checkout cheap and — via `git worktree list` — +# enumerable, which is the only reason automated cleanup can ever be safe. +# +# Usage: +# mosaic-worktree.sh new [--from ] create (branch may exist) +# mosaic-worktree.sh path print derived path, no side effect +# mosaic-worktree.sh list this repo's worktrees + state +# mosaic-worktree.sh rm [--force] remove; refuses to lose work +# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees +# +# `rm` and `gc` refuse to delete a worktree with uncommitted changes or with +# commits absent from every remote. That check is by EVIDENCE, never by size or +# age. --force overrides it and is yours to type deliberately. +# +# Run from anywhere inside the repo, or pass --repo . + +set -euo pipefail + +die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; } + +REPO_HINT="" +ARGS=() +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO_HINT="${2:-}"; shift 2 ;; + *) ARGS+=("$1"); shift ;; + esac +done +set -- "${ARGS[@]+"${ARGS[@]}"}" + +CMD="${1:-}" +[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc" +shift || true + +# ---- mechanical derivation ------------------------------------------------- +# The FIRST entry of `git worktree list --porcelain` is always the main +# worktree, regardless of which worktree we are standing in. Deriving from cwd +# would nest worktrees inside worktrees. +resolve_repo() { + local start="${REPO_HINT:-$PWD}" + git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \ + || die "not inside a git repository: $start" + MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')" + [ -n "$MAIN_WT" ] || die "could not resolve the main worktree" + REPO_NAME="$(basename -- "$MAIN_WT")" + REPO_PARENT="$(dirname -- "$MAIN_WT")" + WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees" +} + +slugify() { printf '%s' "$1" | tr '/' '-'; } + +derive_path() { + local branch="$1" + [ -n "$branch" ] || die "branch name required" + printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")" +} + +# A worktree root under $HOME defeats the entire point: wrong filesystem, and +# $HOME is for configuration and state, not work products. Refuse rather than +# silently produce the layout we are trying to eliminate. +assert_not_home() { + local p="$1" home_real repo_real + home_real="$(cd "$HOME" && pwd -P)" + repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")" + case "$repo_real/" in + "$home_real"/*) + die "refusing: derived path is under \$HOME ($p). +The repo itself lives under \$HOME, so its worktrees would too. Move the repo +to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds +configuration, credentials, state and caches — not checkouts." ;; + esac +} + +# ---- work-loss evidence ---------------------------------------------------- +# Two independent questions, both answered from git, neither from size or age: +# dirty — anything uncommitted in the tree +# unpushed — commits reachable from HEAD that no remote ref contains +wt_dirty() { git -C "$1" status --porcelain 2>/dev/null | head -200 | wc -l; } +wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || echo "?"; } + +wt_state() { + local wt="$1" d u + d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")" + if [ "$d" -eq 0 ] && [ "$u" = "0" ]; then + printf 'SAFE\tclean; 0 unpushed' + else + printf 'PRESERVE\t%s uncommitted; %s unpushed' "$d" "$u" + fi +} + +# ---- commands -------------------------------------------------------------- +cmd_path() { resolve_repo; derive_path "${1:-}"; echo; } + +cmd_new() { + local branch="${1:-}" base="" + shift || true + while [ $# -gt 0 ]; do + case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac + done + [ -n "$branch" ] || die "usage: mosaic-worktree.sh new [--from ]" + + resolve_repo + local path; path="$(derive_path "$branch")" + assert_not_home "$path" + + if [ -e "$path" ]; then + echo "exists: $path" + echo "(already checked out — reuse it, or 'rm' it first)" + return 0 + fi + + mkdir -p "$WT_ROOT" + + # Existing branch -> check it out. New branch -> create from base (default: + # the remote's default branch if resolvable, else current HEAD). + if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \ + || git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then + git -C "$MAIN_WT" worktree add "$path" "$branch" + else + if [ -z "$base" ]; then + base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" + [ -n "$base" ] || base="HEAD" + fi + git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base" + fi + + cat < [--force]" + + resolve_repo + local path; path="$(derive_path "$branch")" + [ -d "$path" ] || die "no worktree at $path" + + local d u + d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")" + if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ]; }; then + die "refusing to remove $path + uncommitted files: $d + unpushed commits: $u +Commit and push first — that is the contract. If this work is genuinely +disposable, re-run with --force." + fi + + # NB: ${force:+--force} would expand for force=0 too ("0" is non-empty). + if [ "$force" -eq 1 ]; then + git -C "$MAIN_WT" worktree remove --force "$path" + else + git -C "$MAIN_WT" worktree remove "$path" + fi + git -C "$MAIN_WT" worktree prune + echo "removed: $path" + rmdir "$WT_ROOT" 2>/dev/null || true +} + +cmd_gc() { + local apply=0 + [ "${1:-}" = "--apply" ] && apply=1 + resolve_repo + git -C "$MAIN_WT" worktree prune + git -C "$MAIN_WT" worktree list --porcelain \ + | awk '/^worktree /{print substr($0,10)}' \ + | while read -r wt; do + [ "$wt" = "$MAIN_WT" ] && continue + local_state="$(wt_state "$wt")" + case "$local_state" in + SAFE*) + if [ "$apply" -eq 1 ]; then + git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt" + else + echo "reclaimable (clean + fully pushed): $wt" + fi ;; + *) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;; + esac + done + git -C "$MAIN_WT" worktree prune + [ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)' +} + +case "$CMD" in + new) cmd_new "$@" ;; + path) cmd_path "$@" ;; + list) cmd_list "$@" ;; + rm) cmd_rm "$@" ;; + gc) cmd_gc "$@" ;; + -h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;; + *) die "unknown command: $CMD (new | path | list | rm | gc)" ;; +esac diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh new file mode 100755 index 00000000..685ea328 --- /dev/null +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# wrapper-guard.sh — PreToolUse hook on Bash. +# +# Blocks three specific, mechanically-detectable mistakes that prose has +# repeatedly failed to prevent: +# +# 1. A checkout (git clone / git worktree add) targeting $HOME. +# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs. +# +# 2. A raw provider API WRITE against an endpoint that already has a Mosaic +# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows +# provider dialect, identity, and queue-guard ordering that raw curl does +# not. Reads are untouched — they are how you gather evidence. +# +# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED; +# it accepts APPROVE with HTTP 200, silently files the review PENDING, +# and then 422s on submit. This one is unconditionally wrong on Gitea and +# is what a verdict silently failing to land looks like. +# +# Design constraint: this hook must not become something agents route around. +# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw +# curl for reads, for registry/manifest calls, and for endpoints with no +# wrapper (there are many) all pass untouched. +# +# Break-glass, for a genuine gap where no wrapper can express the call: +# MOSAIC_WRAPPER_OVERRIDE=1 +# Using it means "no wrapper covers this" — if that is wrong, the fix is to +# extend the wrapper, not to keep typing the override. +# +# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message. + +set -euo pipefail + +INPUT="$(cat)" +CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" +[ -z "$CMD" ] && exit 0 + +# Honour the override only when it is set in the command itself or the env. +case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac +[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0 + +W="$HOME/.config/mosaic/tools/git" + +# ---- 1. checkout into $HOME ------------------------------------------------ +if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then + # Any argument that resolves under $HOME and is not under a work filesystem. + if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])(~|\\\$HOME|$HOME)/"; then + cat < # /src/-worktrees/ + ~/.config/mosaic/tools/git/mosaic-worktree.sh path # show where it would go + ~/.config/mosaic/tools/git/mosaic-worktree.sh rm # removal is part of the task + +Worktrees, not clones: they share the object store, and \`git worktree list\` +makes every one of them enumerable — which is the only reason cleanup can +ever be safe. +EOF + exit 2 + fi +fi + +# ---- 2/3. provider API writes --------------------------------------------- +# Only consider calls that are (a) to a provider API path and (b) mutating. +is_api=0 +printf '%s' "$CMD" | grep -Eq '/api/v1/repos/|api\.github\.com/repos/' && is_api=1 +if [ "$is_api" -eq 1 ]; then + is_write=0 + printf '%s' "$CMD" | grep -Eq -- '-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]]*(POST|PATCH|PUT|DELETE)' && is_write=1 + # curl sends POST implicitly when given a body. + printf '%s' "$CMD" | grep -Eq -- '--data|-d[[:space:]]' && is_write=1 + + if [ "$is_write" -eq 1 ]; then + endpoint=""; wrapper="" + case "$CMD" in + *"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*) + endpoint="pull-request review"; wrapper="$W/pr-review.sh" ;; + *"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="$W/pr-merge.sh" ;; + *"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="$W/issue-comment.sh" ;; + *"/pulls"*) endpoint="pull request"; wrapper="$W/pr-create.sh" ;; + *"/issues"*) endpoint="issue"; wrapper="$W/issue-create.sh" ;; + *"/milestones"*) endpoint="milestone"; wrapper="$W/milestone-create.sh" ;; + esac + + if [ -n "$wrapper" ] && [ -x "$wrapper" ]; then + cat < undiscoverable -> FAIL) +# reverse every `.sh` an index document attributes to an enforced +# suite exists on disk (stale reference -> agent runs a ghost -> FAIL) +# +# Suites outside the enforced set are reported with a coverage percentage but do +# not fail the build, so the ratchet can be tightened one suite per PR instead of +# landing as one unreviewable sweep. `--strict` fails on those too. +# +# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC +# ------------------------------------------------------------------- +# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it +# would let an operator silence this gate by editing their own copy — the gate +# would then be strongest exactly where it is least needed and absent where it +# is needed most. The list is framework-owned and changes only through a +# reviewed PR. +# +# Usage: +# check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test] +# +# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage + +set -euo pipefail + +# Suites whose coverage is a HARD requirement. Add a suite here only together +# with the doc changes that make it pass. +# +# `git` is first because it is the suite Constitution gates 6-8 make mandatory: +# an undiscoverable git wrapper converts a hard gate into a coin flip. +ENFORCED_SUITES=(git) + +# Files that are not agent-callable tools and must not be required in an index. +EXCLUDE_GLOBS=( + 'test-*' # hermetic regression scripts, invoked by CI not by agents + '_*' # private helpers (_lib, _scripts internals) + '*.bak' # editor/installer debris + '*.pre-*' # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak) + 'README.md' +) + +STRICT=0 +SELF_TEST=0 +TOOLS_DIR="" +DOCS=() + +die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; } + +while [ $# -gt 0 ]; do + case "$1" in + --tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;; + --doc) DOCS+=("${2:-}"); shift 2 ;; + --strict) STRICT=1; shift ;; + --self-test) SELF_TEST=1; shift ;; + -h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +# ---- location resolution --------------------------------------------------- +# Runs from two places with different layouts, and must not silently check the +# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME). +resolve_locations() { + local here framework + here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + # .../framework/tools/quality/scripts -> .../framework + framework="$(cd -- "$here/../../.." && pwd)" + + if [ -z "$TOOLS_DIR" ]; then + if [ -d "$framework/tools" ]; then + TOOLS_DIR="$framework/tools" + else + TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools" + fi + fi + + if [ ${#DOCS[@]} -eq 0 ]; then + # The two layouts are mutually exclusive on purpose. Unioning them would let + # a well-maintained operator TOOLS.md on the developer's own machine mask a + # gap in the shipped defaults — the check would pass locally and the defect + # would still install on every other host. Repo layout wins when present. + if [ -f "$framework/defaults/TOOLS.md" ]; then + DOCS+=("$framework/defaults/TOOLS.md") + [ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md") + else + local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}" + [ -f "$mosaic_home/TOOLS.md" ] && DOCS+=("$mosaic_home/TOOLS.md") + [ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md") + fi + fi + + [ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR" + [ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)" +} + +is_excluded() { + local name="$1" glob + for glob in "${EXCLUDE_GLOBS[@]}"; do + # shellcheck disable=SC2254 # glob is intentionally a pattern + case "$name" in $glob) return 0 ;; esac + done + return 1 +} + +# A tool counts as documented when its basename appears anywhere in the corpus. +# Deliberately permissive about *form* (table cell, code fence, prose) and strict +# about *presence*: the gate's job is "an agent can find it", not house style. +documented() { grep -qF -- "$1" "$CORPUS"; } + +# ---- the check ------------------------------------------------------------- +run_check() { + local rc=0 suite dir tool base enforced + + CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN + cat "${DOCS[@]}" > "$CORPUS" + + printf 'tools: %s\n' "$TOOLS_DIR" + for d in "${DOCS[@]}"; do printf 'index: %s\n' "$d"; done + printf '\n' + + for dir in "$TOOLS_DIR"/*/; do + [ -d "$dir" ] || continue + suite="$(basename -- "$dir")" + case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac + [ "$STRICT" -eq 1 ] && enforced=1 + case "$suite" in _*) continue ;; esac + + local total=0 found=0 + local -a suite_missing=() + for tool in "$dir"*.sh; do + [ -e "$tool" ] || continue + base="$(basename -- "$tool")" + is_excluded "$base" && continue + total=$((total + 1)) + if documented "$base"; then + found=$((found + 1)) + else + suite_missing+=("$base") + fi + done + [ "$total" -eq 0 ] && continue + + local pct=$(( found * 100 / total )) + if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then + printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \ + "$suite" "$pct" "$found" "$total" "${suite_missing[*]}" + rc=1 + elif [ "$enforced" -eq 1 ]; then + printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total" + else + printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total" + fi + + # Reverse: an index that names a tool this suite does not have sends agents + # after something that cannot run. Only checked for enforced suites, where + # the naming is unambiguous enough to attribute. + if [ "$enforced" -eq 1 ]; then + local -a stale=() + local ref + while read -r ref; do + [ -n "$ref" ] || continue + is_excluded "$ref" && continue + [ -e "$dir$ref" ] || stale+=("$ref") + done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \ + | sed "s|^$suite/||" | sort -u) + if [ ${#stale[@]} -gt 0 ]; then + printf 'FAIL %-12s stale index references (no such file): %s\n' \ + "$suite" "${stale[*]}" + rc=1 + fi + fi + done + + printf '\n' + if [ "$rc" -ne 0 ]; then + cat < "$tmp/tools/git/documented-tool.sh" + printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh" + + # run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a + # command-prefix assignment, so point the globals at the fixture directly. + TOOLS_DIR="$tmp/tools" + DOCS=("$tmp/doc.md") + + # Case 1: fully documented -> pass. + printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md" + if run_check >/dev/null; then + printf 'self-test 1/3 ok (complete index passes)\n' + else + printf 'self-test 1/3 FAIL (complete index should pass)\n'; return 1 + fi + + # Case 2: an undocumented tool -> fail. + printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh" + rc=0; run_check >/dev/null || rc=$? + if [ "$rc" -eq 1 ]; then + printf 'self-test 2/3 ok (undocumented tool fails the gate)\n' + else + printf 'self-test 2/3 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1 + fi + + # Case 3: a stale index reference -> fail. + rm "$tmp/tools/git/undocumented-tool.sh" + printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md" + rc=0; run_check >/dev/null || rc=$? + if [ "$rc" -eq 1 ]; then + printf 'self-test 3/3 ok (stale index reference fails the gate)\n' + else + printf 'self-test 3/3 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1 + fi + + printf '\nself-test passed: the gate demonstrably reds on both drift directions.\n' +} + +if [ "$SELF_TEST" -eq 1 ]; then + self_test +else + resolve_locations + run_check +fi