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>
11 KiB
ADR-022: Scaffold Profiles
Status: Proposed Date: 2026-05-12 Deciders: Mikael Hugo Extends: ADR-021 — Versioned Documents and Upgrade Path
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→disableddrift bucket. Nevermissing,upgradable, orediting-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 doctordoes not countdisabledfiles as actionable. The doctor finding counts onlymissing + 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).
{
"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:
- Read existing markers + manifest. Validate target profile name.
- Re-enable step — for each file IN the target profile with
state=disabledon disk:- If
bodyHash(body) === marker.hash(no user edits since disable): re-stampstate=pending. - If hash diverged (user edited the disabled file): warn, leave
disabled.
- If
- Disable step — for each file NOT in the target profile:
- Compute
bodyHash(body)from on-disk content. - If
state=pendingANDbodyHash(body) === marker.hash(no user edits): re-stampstate=disabled. - If
state=pendingAND hash diverged: treat as editing-drift — warn, leave alone. (User has edits; SF will not silently disable them.) - If
state=editingorstate=completed: warn, leave alone. - If
--pruneANDstate=pendingANDbodyHash(body) === marker.hash: delete the file. The same hash guard applies — prune never deletes user-edited files.
- Compute
- Update
scaffold-manifest.jsonprofilefield to target profile. - Run drift-aware sync for files in the new profile (applies
missingandupgradableitems).
Migrate is idempotent: running it twice produces the same result.
6. Profile precedence
Multiple sources may specify a profile. Precedence (highest first):
PREFERENCES.mdfrontmatter —sf_profile: infra(Phase 5; explicit user override, per-repo, committed to git)- Manifest
profilefield — set by migrate or first-run auto-detection (per-repo runtime, gitignored) - 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:
# ~/.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 infraflags in the common case. disabledstate is a clean per-file escape hatch without forking the profile.
Becomes harder
- Profile set and
SCAFFOLD_FILESmust stay in sync; a template added toSCAFFOLD_FILESneeds explicit placement in every affected built-in profile. detectRepoProfileheuristics will misclassify hybrid repos (a repo with bothpackage.jsonandkustomization.yaml). Fallback isapp; 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 fordetectRepoProfile