singularity-forge/docs/dev/ADR-022-scaffold-profiles.md
Mikael Hugo 2bb9cdbeef feat(scaffold): ADR-022 scaffold profiles (all phases)
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>
2026-05-12 15:28:03 +02:00

245 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 ~6070 % 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`