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

11 KiB
Raw Permalink Blame History

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 ~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=disableddisabled 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).

{
  "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 frontmattersf_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-detectiondetectRepoProfile(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 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.jsdetectStacks() signal source for detectRepoProfile