# ADR-022: Scaffold Profiles **Status:** Proposed **Date:** 2026-05-12 **Deciders:** Mikael Hugo **Extends:** [ADR-021 — Versioned Documents and Upgrade Path](ADR-021-versioned-documents-and-upgrade-path.md) ## Context ADR-021 introduced a per-file state machine (`pending` / `editing` / `completed`), version markers, drift detection, and an automatic upgrade pipeline for the `SCAFFOLD_FILES` template set. That set is a single flat list tuned for a code-app shaped repo. When SF is bootstrapped into a non-code-shaped repo — infra, ops, GitOps, documentation-only — it writes ~60–70 % of templates that don't apply. Files like `docs/FRONTEND.md`, `docs/DESIGN.md`, and `docs/PRODUCT_SENSE.md` have no meaning in a Kubernetes config repo. They sit at `state=pending` forever, polluting the agent's scaffold context and producing spurious `upgradable` findings in `/sf doctor` on every run. SF is being adopted in non-code repos. Each new repo type that doesn't fit the `app` profile creates the same friction without a general solution. ## Decision Introduce a **profile** system for scaffold file sets. A profile is a named subset of `SCAFFOLD_FILES` describing which templates apply to a given repo shape. Add a `disabled` state to the ADR-021 state machine for templates that are explicitly out-of-scope for the active profile. ### 1. Built-in profiles Defined in `scaffold-constants.js`: | Profile | Included templates | Typical repo shape | |---------|-------------------|-------------------| | `app` | Full `SCAFFOLD_FILES` list | Product or CLI with UI, tests, frontend | | `library` | `app` minus `docs/FRONTEND.md`, `docs/PRODUCT_SENSE.md`, `docs/DESIGN.md` | Library, SDK, CLI tool without UI | | `infra` | AGENTS.md, ARCHITECTURE.md, .siftignore, docs/SECURITY.md, docs/RELIABILITY.md, docs/records/**, .sf/PRINCIPLES.md, .sf/STYLE.md, .sf/NON-GOALS.md, .sf/harness/** | GitOps, Kubernetes, Flux, Helm, Terraform | | `docs` | AGENTS.md, .sf/STYLE.md | Pure documentation repo | | `minimal` | .siftignore, AGENTS.md | Any repo; smallest footprint | `app` is the default (preserves existing behaviour for code repos). Profiles are expressed as sets of template paths from `SCAFFOLD_FILES`. The `PROFILES` object maps profile names to `Set`. A file not in the active profile is treated as if it were absent from `SCAFFOLD_FILES` for all scaffold operations. ### 2. `disabled` state (4th ADR-021 state) A new state alongside `pending` / `editing` / `completed`: | State | Definition | SF action on drift | |-------|------------|--------------------| | `disabled` | Template does not apply to this repo. Written by migrate command or manually. | Never created. Never modified. Treated as out-of-scope. | The marker format is unchanged: ``` ``` `disabled` is valid in `VALID_STATES`. Any existing code path that calls `parseMarker` on a disabled file will parse it correctly (not fall through to `untracked`). #### State derivation (extends ADR-021 §1 table) | Marker? | state field | Body hash = marker.hash? | → State | |---------|-------------|--------------------------|---------| | yes | `disabled` | any | `disabled` | | (existing ADR-021 rows) | … | … | … | #### What `disabled` means for drift detection - Files with `state=disabled` → `disabled` drift bucket. Never `missing`, `upgradable`, or `editing-drift`. - Files not in the active profile that don't exist on disk → skipped entirely. Not reported as `missing`. SF does not write them. - `/sf doctor` does not count `disabled` files as actionable. The doctor finding counts only `missing + upgradable + editing-drift`. ### 3. Profile storage Profile is stored in `.sf/scaffold-manifest.json` under a new `profile` field. Added **additively** — no `schemaVersion` bump (a bump would wipe existing `applied` records on first upgrade). ```json { "schemaVersion": 1, "profile": "infra", "applied": [ … ] } ``` `readScaffoldManifest` returns `profile: parsed.profile ?? null`. `recordScaffoldApply` preserves the `profile` field on every write (prior implementation stripped all fields beyond `schemaVersion` + `applied`). ### 4. Profile auto-detection `detectRepoProfile(basePath)` returns the best-fit built-in profile for a repo it has never seen before. Runs once on first scaffold; result stored in manifest. Detection logic (uses `repo-profiler.js` / `detectStacks()` as signal source): | Signal | → Profile | |--------|-----------| | `kustomization.yaml`, `flux-system/`, `Chart.yaml`, or `helmrelease.yaml` at root | `infra` | | `flake.nix` / `shell.nix` (nix stack) with no `package.json` | `infra` | | `package.json` with UI framework dep (`next`, `vite`, `remix`, `sveltekit`, `@sveltejs/kit`, `nuxt`) | `app` | | `package.json` without UI framework dep | `library` | | `Cargo.toml`, `go.mod`, or `pyproject.toml` | `library` | | No source files, only `.md` docs | `docs` | | No matching signals | `app` (default) | Signals are evaluated top-to-bottom; first match wins. The manifest `profile` field overrides auto-detection on all subsequent runs (explicit beats inferred). `ensureAgenticDocsScaffold` reads the active profile from the manifest (or auto-detects it on first run) and filters `SCAFFOLD_FILES` to only those paths in the active profile before writing any files. ### 5. `sf scaffold migrate --profile [--prune]` Manual command for repos already bootstrapped under the wrong profile. ``` sf scaffold migrate --profile infra [--prune] ``` Algorithm: 1. Read existing markers + manifest. Validate target profile name. 2. **Re-enable step** — for each file IN the target profile with `state=disabled` on disk: - If `bodyHash(body) === marker.hash` (no user edits since disable): re-stamp `state=pending`. - If hash diverged (user edited the disabled file): warn, leave `disabled`. 3. **Disable step** — for each file NOT in the target profile: - Compute `bodyHash(body)` from on-disk content. - If `state=pending` AND `bodyHash(body) === marker.hash` (no user edits): re-stamp `state=disabled`. - If `state=pending` AND hash diverged: treat as editing-drift — warn, leave alone. (User has edits; SF will not silently disable them.) - If `state=editing` or `state=completed`: warn, leave alone. - If `--prune` AND `state=pending` AND `bodyHash(body) === marker.hash`: delete the file. The same hash guard applies — prune never deletes user-edited files. 4. Update `scaffold-manifest.json` `profile` field to target profile. 5. Run drift-aware sync for files in the new profile (applies `missing` and `upgradable` items). Migrate is idempotent: running it twice produces the same result. ### 6. Profile precedence Multiple sources may specify a profile. Precedence (highest first): 1. **`PREFERENCES.md` frontmatter** — `sf_profile: infra` (Phase 5; explicit user override, per-repo, committed to git) 2. **Manifest `profile` field** — set by migrate or first-run auto-detection (per-repo runtime, gitignored) 3. **Auto-detection** — `detectRepoProfile(basePath)` result (fallback) This rule is defined now so Phase 5 (custom profiles) has a clear contract. ### 7. Custom profiles (Phase 5) Future work. Custom profiles extend built-ins: ```yaml # ~/.sf/profiles/monorepo.yaml extends: library add: - docs/DESIGN.md remove: - docs/exec-plans/active/index.md ``` Resolved at profile load time. Phase 5 must document how `PREFERENCES.md` frontmatter references a custom profile name (precedence rule §6 already establishes where they fit). ## Implementation Phases | Phase | Scope | Constraint | |-------|-------|------------| | **1** | `PROFILES` constant; `disabled` in `VALID_STATES`; manifest `profile` field round-trip fix | Must ship atomically with Phases 2 and 4 | | **2** | Profile-aware `detectScaffoldDrift`; `disabled` bucket; profile-filtered `migrateLegacyScaffold`; update 3 unlisted callers; `doc-checker.js` fix | Must ship atomically with Phases 1 and 4 | | **3** | `detectRepoProfile`; profile-filtered `ensureAgenticDocsScaffold` | Depends on Phases 1 + 2 | | **4** | `sf scaffold migrate --profile [--prune]` command | Must ship atomically with Phases 1 and 2 | | **5** | Custom profiles (`~/.sf/profiles/*.yaml`); PREFERENCES.md frontmatter | Depends on Phase 4 | Phases 1 + 2 + 4 must ship in the same release. The `disabled` state is not parseable by `parseMarker` until Phase 1 lands; any code that stamps `disabled` before Phase 1 ships causes affected files to fall into the `untracked` bucket and be silently re-rendered on the next `ensureAgenticDocsScaffold` call. ## Consequences ### Becomes possible - SF can be bootstrapped into any repo shape without laying down irrelevant templates. - Existing over-scaffolded repos can be cleaned up with a single migrate command. - Profile auto-detection removes the need for explicit `sf new-project --profile infra` flags in the common case. - `disabled` state is a clean per-file escape hatch without forking the profile. ### Becomes harder - Profile set and `SCAFFOLD_FILES` must stay in sync; a template added to `SCAFFOLD_FILES` needs explicit placement in every affected built-in profile. - `detectRepoProfile` heuristics will misclassify hybrid repos (a repo with both `package.json` and `kustomization.yaml`). Fallback is `app`; migrate exists for correction. ### Failure modes | Failure | Behaviour | |---------|-----------| | Unknown profile name in manifest | Fall back to `app`; log warning. | | `detectRepoProfile` misclassifies repo | User runs `sf scaffold migrate --profile `. One-time correction. | | Hash divergence on a `disabled` file during migrate | Warn; leave alone. User must manually resolve or mark `completed`. | | Manifest `profile` field missing (pre-ADR-022 manifest) | `null` → fall back to auto-detection on next scaffold run. | ## Alternatives Considered | Alternative | Why rejected | |-------------|--------------| | Per-file `applicable: false` flag in `SCAFFOLD_FILES` | Static; can't adapt to different repo shapes without forking the array. | | Delete out-of-profile files on migrate | Destructive for `editing`/`completed` files; `disabled` with `--prune` for `pending` is safer and reversible. | | Auto-migrate on every startup | Surprising; migrate is an explicit user action that changes profile context. Auto-detection for first scaffold only. | | Single `minimal` profile as the new default | Breaks existing repos; `app` as default preserves current behaviour. | ## References - ADR-021 — Versioned Documents and Upgrade Path (§1 state machine, §3 manifest, §4 drift detection, §7 legacy migration — all extended here) - `src/resources/extensions/sf/repo-profiler.js` — `detectStacks()` signal source for `detectRepoProfile`