# Repo profile  -  how one repo works, derived once, read by every phase

> **TLDR**  -  A local-only JSON file per repo that tells generic skills what this repo does differently: paths a bot owns, generated trees and their inputs, the commit subject convention, the exact CI commands, hooks that rewrite a commit. Derived from the repo itself, confirmed once in attended runs, honoured without asking in unattended ones. Script: `repo-profile.mjs`. Schema: `schemas/repo-profile.schema.json`.

## Where it lives

`~/.claude/projects/<slug>/repo-profile.json`, next to `figma-config.json`. The slug is the main checkout's absolute path with every non-alphanumeric character replaced by `-` (the host's own project-directory slug), so every worktree of a repo reads one profile. Written atomically at `0600`. Never inside the repo and never committed: the save refuses a path that resolves inside the repo root. It is not the learnings ledger's `<repo-profile>` prompt block; that one holds free-text learnings.

An unattended run's OS sandbox denies writes under `~/.claude`, so when that write is refused (`EACCES`, `EPERM`, `EROFS`) the profile goes to `<unattended run root>/repo-profiles/<slug>/repo-profile.json` (`MA_UNATTENDED_RUN_ROOT`, else `~/.multi-agent-unattended`, the directory the sandbox's `allowWrite` names). An unattended run reads whichever of the two copies was derived last. When neither is writable the profile is used from memory for the run, `persisted` is `false`, and the run continues.

## What it holds

Every role is `{ value, source, evidence, confidence }`. `source` is `derived`, `confirmed` or `manual`; `evidence` is `file:line`, `commit:<sha>` or `git:<ref>`; `confidence` is `high`, `medium` or `low`. A `null` value means nothing was found and the consumer keeps its default.

| Role | Derived from |
|---|---|
| `ownedPaths[]` | candidates from a 2000-commit window: directories (and single files) only `[bot]` authors or `--bot-pattern` touched, at least 3 commits. Each candidate (at most 50) is then checked against its whole history in one `git log`; one with any human commit narrows to the subdirectories and files inside it that only the bot ever touched. `basis: "history"` rules are at most `medium`. Plus workflow rules: `case` arms (a case `*` crosses `/`: a star-only segment becomes `**`, a last segment such as `*.swift` becomes `**/*.swift`; any other star inside a segment widens an owned arm to its literal directory plus `/**` and drops an exception arm) and `git diff/log/ls-files -- <paths>` pathspecs (git semantics kept, stored repo-relative without `./`), in workflows that name a bot in code or read one from `vars.*BOT*` / `*BOT_LOGIN`; `#` comments are stripped first, so a login in a comment is an example, not an owner. The owner is the bots that actually committed under the job's paths, else the ones it names. A history directory that a same-owner workflow glob only partly covers folds into that glob (`basis: "history+workflow"`, `high`). An `on.*.paths` trigger filter only says when a workflow runs and is never read as ownership. A `labels.*.name` check becomes `bypass.label` |
| `generators[]` | files whose first 5 lines carry a comment stamp (`//`, `#`, `/*`, `<!--` followed by `generated by`, `auto-generated` or `do not edit`; a docstring does not count), grouped into the highest directory that is 90% stamped; any tracked `Generated/` directory; `input` is the common parent of the directories that change in the same commits, `sameCommit` is true at 80%; `command` is the CI step naming the stamp's tool with `2>&1` and `\| tee` removed; a step with `--check`, `--verify` or `--dry-run` is recorded as `check`, never as `command` |
| `commit.format` | last 200 non-merge, non-bot subjects: ticket `prefix` / `suffix` / `none` by majority, its style (`[KEY-123]`, `KEY-123:`), whether a conventional scope is required. A trailing `(#123)` is the host's PR number and is ignored |
| `repo.workBranch`, `repo.defaultBranch` | `pull_request: branches:` targets in workflows, else `push: branches:` targets, else the most active of the usual integration branches, remote or local; `origin/HEAD` |
| `build`, `test`, `lint` | CI `run:` steps, exact flags kept, continuations joined; `high` when a pull request triggers the step or it appears twice; `-testPlan` values and `*.xctestplan` files; `package.json` scripts as a low-confidence fallback |
| `accessors.*`, `di.*` | top-level types a generated localization / testing-id / token tree declares, ranked by `<Root>.<Seg>.<Seg>` usage outside it; the registrar suffix is the compound most files of the commonest `Registrar` / `Configurator` / `Assembly` / `Container` / `Module` family end in, read from the file names; its commonest registration method (`registerMethod`) and, apart from it, its commonest `validate*` / `verify*` / `check*` method (`validateMethod`); the inject attribute (`@Inject`, `@Injected`, `@Dependency` and similar keyed wrappers). `--conventions <file>` or `--with-conventions <platform>` folds in `extract-conventions.sh` buckets |
| `module`, `mock` | repeated module manifests as a glob; a module validator script or step; mock framework imports or a fixture tree; a `Custom*` tree beside a generated one. `mock.system` and `di.injectAttribute` count only files of the repo's dominant language and carry `file:line` evidence; otherwise they are `null` |
| `hooks[]` | `.githooks/`, `scripts/git-hooks/`, `.husky/`, pre-commit and lefthook configs; side effects `amends-commit`, `stages-files`, `writes-files`, `network`; the script that sets `core.hooksPath` |
| `requiredChecks[]`, `docs.authoritative[]`, `inRepoSkills[]` | workflow jobs with a `required` comment (always low); the documents `claude-md-links.mjs` admits from the repo's `CLAUDE.md` files (same bounds, evidence is the CLAUDE.md that points at it); `.claude/skills/*/SKILL.md` with `trigger: explicit` when `disable-model-invocation: true` |

Derivation reads and never writes: one bounded `git log`, one whole-history `git log` limited to the owned-path candidates, one `git ls-files`, a 600-byte header per regular source file (symlinks, submodules and anything `lstat` does not call a regular file are skipped), one fixed-string `git grep` for accessor, inject and mock usage. Paths are read NUL-separated with `core.quotePath` off, so non-ASCII paths arrive as written. It stays within a few seconds on a 50k-file repo.

## Run start: one call

```bash
node $HOME/.claude/scripts/repo-profile.mjs ensure --repo "$PROJECT_ROOT" --state "$STATE_FILE"
```

It prints `{action, mode, policy, gatesActive, path, persisted, writeError, confirmed, needsConfirmation, stale, report}` and persists nothing but the profile. Persist the result as `state.repoProfile = {path, action, mode, confirmed, staleReasons, ignored}` (`staleReasons` from `stale.reasons`, `ignored` from `report.ignored`). `path` is `null` and `persisted` is `false` when no location was writable.

The mode comes from `lib/unattended.mjs` (`runPosture`): only `MULTI_AGENT_UNATTENDED=1` is unattended. Terminal autopilot (`state.autopilot`) is attended with the gates active: its profile is stored under `~/.claude`, it is never asked to confirm, and because nobody confirms anything it applies the unattended confidence policy (`policy` in the output), so owned paths and generators fail safe.

| Situation | Unattended (`MULTI_AGENT_UNATTENDED=1`) | Terminal autopilot | Attended |
|---|---|---|---|
| no profile | derive, save as `derived`, continue | derive, save, continue | derive, save, ask once |
| stale | re-derive, keep confirmed and manual roles, continue | same, continue | re-derive, keep them, ask again |
| fresh, confirmed | use it | use it | use it |
| fresh, never confirmed | use it | use it | ask once |

Stale means: the stored `repoHead` is no longer an ancestor of HEAD, HEAD is more than 200 commits ahead of it (`--max-behind`), or any file under `.github/workflows/` changed between the two.

**The confirmation (attended only).** Render the derived roles as a table (role, value, confidence, first evidence) and ask one question through the picker contract (`question`, `label`, `description` in `outputLanguage`, `header` English, at most 12 characters, `"Profile"`): confirm (Recommended), edit, or skip for this run. Confirm runs `repo-profile.mjs confirm`. Edit starts from `show`, sets the corrected values with `source: "manual"`, and writes the whole profile back through `save --from -`, which replaces the stored one as given: a manual value wins over a confirmed one and an entry left out is removed. Then confirm. Skip leaves the profile derived and asks again next run.

## Consuming a field

`repo-profile.mjs resolve <field>` applies the policy and prints `{use, value, reason, ignored}`:

| Role state | Used? |
|---|---|
| `confirmed` or `manual` | yes, whatever the confidence |
| `derived`, high | yes |
| `derived`, medium | only where a wrong value fails safe: `ownedPaths`, `generators`, `commit.format` |
| `derived`, low | no; the default is kept and the field is recorded as ignored |
| `ownedPaths` entry with `basis: "history"` (no CI job enforces it) | attended: no until confirmed, recorded as ignored; unattended and terminal autopilot: yes, and listed in `report.historyOnly` |
| `ownedPaths`, `generators` in an unattended run | always, even low and unconfirmed: blocking an edit a person can undo is safer than editing a path a bot overwrites or CI rejects |

## Who reads it

- **Phase 1 (plan)** runs `ensure`, cites `ownedPaths` and `generators` in the plan so no task writes into them, and reads `docs.authoritative` and `inRepoSkills` as repo guidance.
- **Phase 2 (dev)** checks every target path against `resolve ownedPaths` and `resolve generators` before writing; an edit there moves to the generator's `input` or stops with the owner named. Its exit gate runs `owned-path-gate.mjs` (below) before the build, and a violation blocks the phase.
- **Phase 3 (review)** re-runs the gate at Step 1.761 and merges its `findings[]` (`tag: owned_path`, `blocking`) into triage; `review-decision-gate.mjs` keeps them blocking on the `owned-path` basis.
- **The agent commit hook** (`pre-commit-check.sh`) runs the gate on the commit's own paths: the index, plus what the same command stages (`git add <pathspec>`, `-A`, `-u`) and what `commit -a` or `commit <pathspec>` takes (`agent-guard.py --commit-scope`; without python3, every dirty and untracked file). A dirty file the commit leaves out is not judged. It passes `--state auto`, which finds the live run whose `worktreePath` is the checkout. A profile kept only in memory (`persisted: false`) gives the hook nothing to read; Phase 2 Gate 0 and Phase 3 Step 1.761 still check that run.
- **Phase 4 (commit)** takes the ticket position and style from `resolve commit.format`; `none` keeps the default suffix unless the role is confirmed or manual. It reads `hooks[]`: a hook that `amends-commit` or `writes-files` means the SHA after `git commit` is re-read, never assumed.
- **Phase 5 (report)** prints `repo-profile.mjs report`: which fields were derived, confirmed or manual, which were used, and which were ignored for confidence.

## Owned-path gate

`owned-path-gate.mjs` turns `ownedPaths[]` into a deterministic check. For each changed path an honoured entry's `glob` covers and none of its `except[]` globs does, it reports a violation unless every author of the change matches the entry's `owner` (the account's own sync), every author matches `bypass.author`, or the PR carries `bypass.label`. `owner` and `bypass.author` are regular expressions when anchored (`^` or `$`) and otherwise one literal name compared exactly; an unanchored value with regex syntax (`\`, `*`, `+`, `?`, `()`, `{}`, `|`) fails validation. A merge commit counts as changing only the files its author edited while resolving it.

```bash
node $HOME/.claude/scripts/owned-path-gate.mjs --repo "$WORKTREE" --base "origin/$BASE_BRANCH" --state "$STATE_FILE"
```

| Input | Change set | Authors |
|---|---|---|
| default | merge-base with the profile's `repo.workBranch`, else `repo.defaultBranch`, `origin/HEAD`, `origin/main`, `origin/master`, `main`, `master`, plus uncommitted and untracked files | commits since the merge-base; the local identity for uncommitted files |
| `--base <ref>` | the default, from the merge-base with `<ref>` | same |
| `--range <base>..<head>` | `git diff <base>...<head>` | commits of the range that touch the path |
| `--staged` | the index | the local identity |
| `--paths <a,b>` or `--paths -` | the list given (stdin: NUL or newline separated) | the local identity, or `--author` |

The policy is the profile's own (`resolve ownedPaths`): attended runs honour confirmed, manual, high and medium entries, except a history-only one that is still derived, and list the rest in `ignored[]`; runs that ask nothing (unattended, or terminal autopilot through `--state`) honour all. The commit hook passes the worktree's `agent-state.json` as `--state` when one exists. Without a stored profile, attended exits 0 with `skipped: true` and `note: "no repo profile, gate skipped"`; a run that asks nothing derives one first through `ensure`, which stores it under `~/.claude` or the unattended run root (or keeps it in memory) and never writes into the repo. Labels come from `--pr-labels a,b`; when a violation's bypass label is not among them and `gh` is installed, the PR's labels are read once, read-only, and a missing `gh` or PR changes nothing (`--no-gh` turns it off).

Globs: `**` spans any number of segments, `*` and `?` stay inside one segment, `[...]` is a class, and a glob with no wildcard names a file or a whole directory. Output (stdout, or `--out <file>`): `{violations: [{path, glob, owner, evidence, confidence, fix}], exempt, findings, ignored, checked, mode, skipped, profileSource, profileAction}`. `fix` names where the change belongs: the matching `generators[].input` and `command`, the `resourceSource.authoring` commands, or the next sync. Exit 0 clean or skipped, 1 violations, 2 usage or environment error.

The commit hook computes the profile path itself and reads the globs' literal prefixes with `sed`, so a commit that touches no owned prefix never starts node. No profile, no node or a gate that cannot run lets the commit through with one stderr line; a violation blocks it with exit 2.

## Commands

`derive` (print a draft), `save [--from <file|->] [--replace]`, `confirm`, `ensure [--max-behind <n>]`, `show`, `get <field>`, `resolve <field>`, `report`, `path`, `--help`. All take `--repo <path>`. `--history` and `--max-behind` must be positive integers. Exit `2` means no stored profile.
