Add profile-aware scaffold system so SF does not lay down irrelevant templates in infra/ops/docs repos. ## What ships Phase 1 — data model - scaffold-versioning.js: add 'disabled' to VALID_STATES; readScaffoldManifest returns profile field; recordScaffoldApply preserves manifest.profile (fixes roundtrip bug where profile was stripped on every write). - scaffold-constants.js: PROFILES (app/library/infra/docs/minimal as Set<string>) and PROFILE_NAMES exports. Phase 2 — profile-aware drift detection - scaffold-drift.js: disabled bucket in emptyCounts, resolveActiveProfileSet integration, profile param on detectScaffoldDrift/migrateLegacyScaffold. - doc-checker.js: filter to active profile, skip disabled-state files. Phase 3 — auto-detection on first run - scaffold-profiles.js: detectRepoProfile() heuristics (nix→infra, terraform→infra, react→app, node-no-ui→library, docs-only→docs, else→app). - agentic-docs-scaffold.js: reads profile from manifest, auto-detects on first run, persists to manifest, filters SCAFFOLD_FILES to active profile. Phase 4 — migrate command - commands-scaffold-migrate.js: sf scaffold migrate --profile <name> Re-enables pending files entering the new profile; stamps state=disabled (or prunes with --prune) files leaving it; warns on editing/completed files. - commands/handlers/ops.js, commands/catalog.js: registered and tab-completed. Phase 5 — custom profiles + PREFERENCES.md frontmatter - scaffold-profiles.js: readPreferencesProfile(), loadCustomProfileSet() (~/.sf/profiles/<name>.yaml with extends/add/remove), resolveActiveProfileSet() implementing full ADR-022 §6 precedence. - All callers updated to use resolveActiveProfileSet as the single source of truth. Tests: 28 new tests in adr-022-scaffold-profiles.test.mjs — all passing. Pre-existing node:test stubs (3 files) unaffected. ADR: docs/dev/ADR-022-scaffold-profiles.md Misc: triage TODO.md dump into BACKLOG.md (phases-helpers export error T1, /todo triage typed-handler gap T1, structured triage tiers T2, sha-track markdown files T2, cross-repo triage T3). Reset TODO.md to empty template. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
245 lines
11 KiB
Markdown
245 lines
11 KiB
Markdown
# 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<string>`. 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:
|
||
```
|
||
<!-- sf-doc: version=2.75.x template=docs/FRONTEND.md state=disabled hash=sha256:… -->
|
||
```
|
||
|
||
`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 <name> [--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 <name> [--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 <correct>`. 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`
|