Context
There are two independent validators for the same .loopover.yml/focus-manifest file shape in this
repo, and only one of them catches unrecognized top-level keys (e.g. a typo'd gates: instead of
gate:, or preferedLabels: instead of preferredLabels:):
-
src/selfhost/config-lint.ts (lintManifestText, wired up via scripts/loopover-config-lint.ts
as npm run selfhost:config-lint -- [path]) maintains an explicit TOP_LEVEL_FIELDS allowlist and
an unknownTopLevelWarnings() helper that diffs the parsed manifest's keys against it, emitting
"Manifest contains unknown top-level field(s): <name>." for anything not recognized (plus a
dedicated migration message for retired fields via RETIRED_FIELD_MIGRATION_WARNINGS).
-
src/services/focus-manifest-validation.ts (buildFocusManifestValidation) — the validator behind
POST /v1/validate/focus-manifest, which is what loopover-mcp validate-config --file <path>
and the loopover_validate_config MCP tool actually call (per printValidateConfigHelp() in
packages/loopover-mcp/bin/loopover-mcp.js: "Mirrors the loopover_validate_config MCP tool and
POST /v1/validate/focus-manifest"). This validator calls parseFocusManifestContent /
parseFocusManifest in packages/loopover-engine/src/focus-manifest.ts, which reads only the
known fields directly off the parsed record (record.wantedPaths, record.gate, record.review,
...) and never inspects Object.keys(record) for anything unrecognized. The only "nothing
recognized" signal is the catch-all warning emitted when every field is empty/default — a
manifest with a correct wantedPaths: but a typo'd gates: block (instead of gate:) still has
at least one recognized, non-default field, so that catch-all never fires either. The gate:
config is silently dropped with zero warnings and status: "ok".
So a contributor who runs the documented, contributor-facing validation path
(loopover-mcp validate-config --file .loopover.yml) gets a clean "ok" for a manifest that the
self-host-only npm run selfhost:config-lint tool would correctly flag as having an unknown field.
The detection logic already exists and is tested (src/selfhost/config-lint.ts); it's just not
reachable from the validator contributors are actually told to use.
Requirements
- Extract
unknownTopLevelWarnings (and its TOP_LEVEL_FIELDS/RETIRED_FIELD_MIGRATION_WARNINGS
allowlist) from src/selfhost/config-lint.ts into a location both src/selfhost/config-lint.ts
and src/services/focus-manifest-validation.ts can import (e.g. into
packages/loopover-engine/src/focus-manifest.ts alongside parseFocusManifestContent, since that
is already the shared source of truth for the manifest shape, or a new small shared module — pick
whichever keeps src/selfhost/config-lint.ts's existing behavior byte-identical).
- Wire the unknown-field warnings into
buildFocusManifestValidation in
src/services/focus-manifest-validation.ts, added to its warnings array alongside the existing
manifest.warnings, and reflected in resolveValidationStatus (an unknown-field warning should
produce status: "warn", matching how every other warning already downgrades status from "ok").
- Do not change
src/selfhost/config-lint.ts's own output/behavior (lintManifestText must keep
returning byte-identical results for every existing test case) — this is a reuse/dedup, not a
rewrite of the self-host tool.
- Keep the allowlist as the single source of truth: if a new top-level manifest field is ever added,
updating one list should fix unknown-field detection in both places (today's four-copies-per-field
pattern documented for review.fields doesn't apply here since this is about the top-level key
list, not the review.fields sub-block, but the same "don't let it drift" principle applies).
Deliverables
Test Coverage Requirements
99%+ Codecov patch coverage (branch-counted) on every changed line in
src/services/focus-manifest-validation.ts, src/selfhost/config-lint.ts, and the new shared
module — including both the "known fields only" and "has an unknown field" branches.
Expected Outcome
loopover-mcp validate-config --file .loopover.yml (and the loopover_validate_config MCP tool and
POST /v1/validate/focus-manifest route it calls) now reports the same "unknown top-level field"
warning that npm run selfhost:config-lint already reports for the same input, closing the gap
between the two validators for the one file format they both parse.
Links & Resources
src/selfhost/config-lint.ts — existing TOP_LEVEL_FIELDS, unknownTopLevelWarnings(),
RETIRED_FIELD_MIGRATION_WARNINGS.
src/services/focus-manifest-validation.ts — buildFocusManifestValidation,
resolveValidationStatus.
packages/loopover-engine/src/focus-manifest.ts — parseFocusManifest (~L3062),
parseFocusManifestContent (~L3119), the likely home for a shared allowlist.
packages/loopover-mcp/bin/loopover-mcp.js — printValidateConfigHelp() (~L2035),
validateConfigCli (~L2048) — the contributor-facing entrypoint this fixes.
Context
There are two independent validators for the same
.loopover.yml/focus-manifest file shape in thisrepo, and only one of them catches unrecognized top-level keys (e.g. a typo'd
gates:instead ofgate:, orpreferedLabels:instead ofpreferredLabels:):src/selfhost/config-lint.ts(lintManifestText, wired up viascripts/loopover-config-lint.tsas
npm run selfhost:config-lint -- [path]) maintains an explicitTOP_LEVEL_FIELDSallowlist andan
unknownTopLevelWarnings()helper that diffs the parsed manifest's keys against it, emitting"Manifest contains unknown top-level field(s): <name>."for anything not recognized (plus adedicated migration message for retired fields via
RETIRED_FIELD_MIGRATION_WARNINGS).src/services/focus-manifest-validation.ts(buildFocusManifestValidation) — the validator behindPOST /v1/validate/focus-manifest, which is whatloopover-mcp validate-config --file <path>and the
loopover_validate_configMCP tool actually call (perprintValidateConfigHelp()inpackages/loopover-mcp/bin/loopover-mcp.js: "Mirrors the loopover_validate_config MCP tool andPOST /v1/validate/focus-manifest"). This validator calls
parseFocusManifestContent/parseFocusManifestinpackages/loopover-engine/src/focus-manifest.ts, which reads only theknown fields directly off the parsed record (
record.wantedPaths,record.gate,record.review,...) and never inspects
Object.keys(record)for anything unrecognized. The only "nothingrecognized" signal is the catch-all warning emitted when every field is empty/default — a
manifest with a correct
wantedPaths:but a typo'dgates:block (instead ofgate:) still hasat least one recognized, non-default field, so that catch-all never fires either. The
gate:config is silently dropped with zero warnings and
status: "ok".So a contributor who runs the documented, contributor-facing validation path
(
loopover-mcp validate-config --file .loopover.yml) gets a clean "ok" for a manifest that theself-host-only
npm run selfhost:config-linttool would correctly flag as having an unknown field.The detection logic already exists and is tested (
src/selfhost/config-lint.ts); it's just notreachable from the validator contributors are actually told to use.
Requirements
unknownTopLevelWarnings(and itsTOP_LEVEL_FIELDS/RETIRED_FIELD_MIGRATION_WARNINGSallowlist) from
src/selfhost/config-lint.tsinto a location bothsrc/selfhost/config-lint.tsand
src/services/focus-manifest-validation.tscan import (e.g. intopackages/loopover-engine/src/focus-manifest.tsalongsideparseFocusManifestContent, since thatis already the shared source of truth for the manifest shape, or a new small shared module — pick
whichever keeps
src/selfhost/config-lint.ts's existing behavior byte-identical).buildFocusManifestValidationinsrc/services/focus-manifest-validation.ts, added to itswarningsarray alongside the existingmanifest.warnings, and reflected inresolveValidationStatus(an unknown-field warning shouldproduce
status: "warn", matching how every other warning already downgrades status from"ok").src/selfhost/config-lint.ts's own output/behavior (lintManifestTextmust keepreturning byte-identical results for every existing test case) — this is a reuse/dedup, not a
rewrite of the self-host tool.
updating one list should fix unknown-field detection in both places (today's four-copies-per-field
pattern documented for
review.fieldsdoesn't apply here since this is about the top-level keylist, not the
review.fieldssub-block, but the same "don't let it drift" principle applies).Deliverables
src/selfhost/config-lint.tsinto amodule importable by both
src/selfhost/config-lint.tsandsrc/services/focus-manifest-validation.ts.buildFocusManifestValidationincludes an"unknown top-level field(s)"-style warning (andstatus: "warn") for a manifest containing a key outside the known field set.test/unit/coveringbuildFocusManifestValidation(or wherever itsexisting tests live) for a manifest with a typo'd top-level key (e.g.
gates:instead ofgate:), asserting the new warning appears andstatus !== "ok".src/selfhost/config-lint.tstest suite passes unchanged (proving no behavior driftin the self-host tool during extraction).
Test Coverage Requirements
99%+ Codecov patch coverage (branch-counted) on every changed line in
src/services/focus-manifest-validation.ts,src/selfhost/config-lint.ts, and the new sharedmodule — including both the "known fields only" and "has an unknown field" branches.
Expected Outcome
loopover-mcp validate-config --file .loopover.yml(and theloopover_validate_configMCP tool andPOST /v1/validate/focus-manifestroute it calls) now reports the same "unknown top-level field"warning that
npm run selfhost:config-lintalready reports for the same input, closing the gapbetween the two validators for the one file format they both parse.
Links & Resources
src/selfhost/config-lint.ts— existingTOP_LEVEL_FIELDS,unknownTopLevelWarnings(),RETIRED_FIELD_MIGRATION_WARNINGS.src/services/focus-manifest-validation.ts—buildFocusManifestValidation,resolveValidationStatus.packages/loopover-engine/src/focus-manifest.ts—parseFocusManifest(~L3062),parseFocusManifestContent(~L3119), the likely home for a shared allowlist.packages/loopover-mcp/bin/loopover-mcp.js—printValidateConfigHelp()(~L2035),validateConfigCli(~L2048) — the contributor-facing entrypoint this fixes.