npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@clossys/controller

v0.10.0

Published

Owns every rule: package lifecycle and process orchestration (catalog, composition, gates, release, repository-profile, review-evidence, workspace-cleanup), account-neutral agent conventions (branch provenance, skill naming, agent interoperability, routin

Readme

@clossys/controller

Control-loop contract

This block is derived from the schema-v5 role contract shipped with this package. The consumer (the client operating the loop) owns its concrete setpoint and review cadence; Controller supplies neither.

Job. Are the business's operating rules expressible, current, and followed?

Metric. rule conformance rate: declared rules independently observed well-formed and followed / all declared rules evaluated (ratio; increase).

Mode. reconcile.

Secondary modes. None.

Stages. sense → judge → act → verify → learn.

Boundary. Owns Operating-rule grammar, identity, lifecycle, and content binding. It excludes judging a proposed change, materializing declared state, authorizing provider mutations.

Close condition. Independent consumer evidence shows the position's owned metric meets its setpoint over the declared review cadence.

Rule conformance rate

The owned metric rule conformance rate is computed by assessRuleConformanceRate(). An empty evaluated set is indeterminate, never a perfect rate of 1. This package does not measure consumer evidence and does not close the loop; a consumer binds the condition against their own declared rules and independent observations. A green run of this package's tests is not a close.

Independent consumer evidence shows the position's owned metric meets its setpoint over the declared review cadence. The owned metric is rule conformance rate, computed by assessRuleConformanceRate(). Controller does not invent observations, does not call foundry-check, and does not judge a proposed change.

controller-check assessment.json

The command prints JSON and exits 0 for satisfied, 1 for violated, and 2 for indeterminate, unreadable, or invalid input.

This package declares that command as its first-day assessment surface in its own manifest:

"foundry": { "assessment": { "bin": "controller-check", "invocation": "single-json-input" } }

Onboarding discovers that declaration from the installed manifest and never infers a surface. Controller is not a required first-day role; Advisor remains the only required first-day assessment.

This is a rename and a merge, not a rewrite: the former package surfaces are now provided by this package's subpaths. The former package names are retired; new integrations use the current subpaths directly.

Install

npm install @clossys/controller

This package is published to the public npm registry, https://registry.npmjs.org. Installing it needs no authentication: no npm token, no .npmrc registry override, and no GitHub credential of any kind.

Boundaries

controller does not write a scaffold, invoke a declared command, publish a package, deploy anything, read credentials, or supply a provider setting. A caller owns all of those actions and values.

  • planNewPackage is creation planning only: without a profile, it returns a private starter and explicit remaining actions. It does not touch disk. A profile is owned by the consuming repository and supplies its actual metadata, tooling, license text, and dated changelog entry.
  • validatePackageLifecycle is a pure schema check. It distinguishes an incubating source package, a published package, a qualified package, and an adopted package. Deprecated and retired packages need a viable replacement and range (or a terminal no-successor reason), dated evidence, and durable decision and migration references.
  • runGovernanceCheck calls the included ./gates subpath for real workspace discovery and deterministic build order, then requires the lifecycle registry to match that catalog exactly.
  • preflightGovernedPackage calls the included ./release subpath for its packed-install proof and adds the workspace governance result. It does not publish or authenticate. A private registry proof remains the caller's deliberate release operation.
  • ./cleanup's classifyCleanupCandidate performs no I/O of any kind — no Git, no filesystem, no GitHub, no scheduler, no credential, no network, and no deletion. It classifies caller-gathered evidence and returns a typed proposal; see its own section below for the full boundary, and note in particular that this subpath exports no deletion API at all.

Package-process subpaths

Install @clossys/controller once and import the focused capability you need. The root exposes lifecycle-registry validation and package-scaffold planning APIs; it does not declare that a package has completed the repository's seven-state evidence ladder. That ladder is derived separately from recorded producer and consumer evidence. The subpaths keep their established contracts separate without making consumers select many separately versioned packages.

| Subpath | Includes | | --- | --- | | @clossys/controller/catalog | Workspace discovery and dependency-graph evaluation. | | @clossys/controller/gates | Foundation checks, deterministic build order, a ratchet primitive, override-range and dependency-scope gates, and foundry-check. Does not require typescript — the source-aware secret-surface gates that need it moved to ./gates/secrets (below) as of this version; see "Requirements" for why. | | @clossys/controller/gates/secrets | Source-aware secret-surface gates: credential inventory, provider-resource naming, local secret files, and raw-secret-read AST detection. Requires typescript — install it to use this subpath; ./gates itself does not need it. Breaking change from earlier versions: these exports used to live on ./gates directly; import them from here instead. | | @clossys/controller/release | Isolated packed-artifact and installed-import proof. | | @clossys/controller/repository | Consumer-owned repository profiles, upward requirements, exact-root declarations, pure evaluation, repository-check, the full runner (runRepositoryProfileCheck / repository-profile-check), and one-package repository adoption evidence (RepositoryPackageAdoptionV1 / repository-package-adoption-check). repository-package-adoption-check accepts two forms: the original <adoption.json> <evaluation.json> (unchanged: human-readable, phase-local text) and, as of this version, a single <evaluation-input.json> (the same evaluation input as one file, adoption inlined) that emits one canonical {"state":"satisfied"\|"violated"\|"indeterminate","findings":[...]} JSON line, with exit code 0/1/2 agreeing with state. --help documents both. | | @clossys/controller/positions | Pure installed-position ledger and completion-evidence validation. foundry-position-check <ledger.json> [role-contract.json] validates positions only; the optional role-contract.json must equal either the role-loop-archetypes.json shipped by this exact version of @clossys/controller, or the exact copy of a role contract a previous version of this package shipped -- currently only 0.9.10's, from before issue #1194's rename and the 0.9.11 @clossys/customer role, matched by deep equality against a small, explicit, embedded table, never a loose comparison. A caller's exact 0.9.10 copy validates against this version's current rules (already compatible with a 0.9.10 ledger, see below) and reports a non-failing legacy-contract-copy advisory naming the matched version (readable as result.advisories for a direct validateInstalledPositionContract call); anything else -- any drift from a known shipped contract, including a historical copy with even one field changed -- is still refused with noncanonical-role-contract, exactly as the exact-match rule always has. validateInstalledPositionLedger's roleContract argument carries the same rule, and so does the exported validateInstalledPositionContract(contract) against installed-position-contract.json (a position contract, not a role contract): a caller's exact 0.9.10 copy of installed-position-contract.json likewise validates with a legacy-contract-copy advisory rather than noncanonical-installed-position-contract, and any other drift is still refused. Drop the argument to use the contract shipped inside this package, or re-copy it from this version. A legacy-format ledger written against 0.9.10 still validates -- precisely, one where no position uses the current learn stageBindings key, and either at least one position uses the pre-rename learnOrEscalate key or the ledger has no positions at all: stageBindings.learnOrEscalate and a missing disposition for a role this package added after 0.9.10 (currently @clossys/customer, added in 0.9.11) are each accepted with a non-failing advisory, never a failure, and foundry-position-check prints any advisory to stderr (stdout for a passing ledger is unchanged: exactly one INSTALLED POSITION LEDGER OK line). A current-format or mixed-vocabulary ledger (any position uses learn) missing that same disposition still fails with missing-role-disposition, exactly as it did before this package accepted legacy ledgers -- the exemption applies only to a ledger that could actually have come from 0.9.10. foundry-completion-evidence-check <completion-evidence.json> <position-ledger.json> validates one linked consumer-retained record for an open position: an exact-version artifact declaration with retained references, recorded invocation and distinct red/green controls, duplicate disposition and rollback records, linked review-cadence evidence, and separately attributed before/after outcome and close-window verdicts. Reference and locator strings have a 65,536-code-unit cap and lexically reject explicit inline sensitive-payload assignments and URL authority userinfo after bounded percent decoding plus NFKC/case/default-ignorable normalization and a separate URL-style tab/CR/LF authority scan; form-style + is treated as a label separator only at root/query/fragment assignment contexts. Ordinary identifiers containing those words remain valid. Evidence instants are known-offset RFC3339 with at most millisecond precision and obey before < invocation/red/green ≤ rollback ≤ after ≤ startedAt < satisfied recurrence ≤ endedAt. A violated or indeterminate in-window cadence result prevents satisfaction. The outcome retains the position baseline and source locator, moves into the linked setpoint, and cannot be owned by the position action authority. References, authorship, provider truth, and adoption are not authenticated or inferred. | | @clossys/controller/review | Provider-neutral review evidence contracts, validation, and review-check. | | @clossys/controller/review/github | Pure normalization of caller-provided GitHub-shaped review evidence. | | @clossys/controller/artifacts | Deterministic, fail-closed verification for a consumer-owned governed artifact: declared kind + schema version, exact-content checksum, and structural provenance. | | @clossys/controller/cleanup | Pure workspace-cleanup classification: caller-normalized inventory and observations in, a typed owned / safe-candidate / blocked proposal out. No I/O, no deletion API. | | @clossys/controller/composition | Pure caller-owned cross-plane constraint, supply, decision, exception, and effective-value resolution. | | @clossys/controller/conventions | Account-neutral agent conventions two parties can share without either owning the other: branch provenance, skill naming, agent interoperability, routine and schedule declarations, CI gate naming, CI conventions and their pure evaluator (ci-conventions-check), and the capability-first skill registry. Ships the documents/adapters/data/templates below as defaults and enforces only their grammar — never byte-identity with its own prose. | | @clossys/controller/conventions/documents/* | The shipped convention documents themselves (branch-provenance.md, skill-grammar.md, agent-interoperability.md, routine-declaration.md, schedule-declaration.md, live-state-reconciliation.md, skill-registry.md, machine-guidance.md, machine-baseline.md, gate-naming.md, runner-conventions.md, ci-conventions.md) as real files a provisioning step can copy or template onto a machine. | | @clossys/controller/conventions/adapters/* | The shipped adapter files (agent-policy.rules, shell-integration.zsh, branch-provenance-hook.sh, heavy-cmd-hook.sh, scoped-main-push.sh, workspace-shell.zsh) as real files, same shape as the documents above. | | @clossys/controller/conventions/data/* | Dated data files an evaluator reads as input, never hard-coded in code: runner-pricing.json (asOf, source URLs). | | @clossys/controller/conventions/templates/* | Ready-to-adopt CI workflow templates: ci-workflow.yml and product-ci-workflow.yml (a lockfile-selected npm or pnpm workflow for a client product repository), each of which a test in this package proves passes ci-conventions-check as shipped. | | @clossys/controller/policy | The content-addressed PolicyBinding primitive: compute a digest, validate a binding's shape, verify a binding against materialized content. Zero I/O, zero dependency of its own — the primitive ./gates and ./artifacts bind rules and artifacts to documents with, without ever committing the document itself. |

@clossys/controller/positions exports validateInstalledPositionLedger, validateInstalledPositionContract, validateCompletionEvidence, validateCompletionEvidenceContract, and the corresponding field vocabularies: POSITION_FIELDS, POSITION_RECOMMENDATIONS, WORKER_COMPONENT_KINDS, REFERENCE_VALUE_RULE, COMPLETION_EVIDENCE_FIELDS, COMPLETION_EVIDENCE_INDETERMINATE_REASONS, COMPLETION_VERDICTS, DUPLICATE_STATES, INVOCATION_KINDS, and PLACEMENT_MODES. InstalledPositionFinding, InstalledPositionAdvisory, InstalledPositionLedgerReport (its advisories field is optional on the type only, so a report a caller builds or mocks by hand still satisfies the interface; every report this package itself returns always sets it, present as an empty array when there are no advisories and never changing ok, findings, openRoles, or positions -- it is how a legacy 0.9.10 ledger's accepted migrations are reported), CompletionEvidenceFinding, CompletionEvidenceIndeterminateReason, and CompletionEvidenceReport expose pure results. Completion evidence reuses the shared satisfied / violated / indeterminate result grammar: it validates the shape and linkage of consumer-retained evidence, refuses the measured package or position as its own outcome owner, and derives the outcome verdict from the shipped role's metric direction plus the linked position's setpoint. Its proof is fail-closed: exact semver and RFC3339 evidence (at most millisecond precision) must retain the linked open position's baseline, source locator, and review cadence; the outcome must move into the role setpoint; and the causal sequence is before < invocation/red/green ≤ rollback ≤ after ≤ startedAt < recurrence ≤ endedAt. A violated or indeterminate cadence run in that window cannot be hidden by a satisfied one. It validates supplied consumer-retained records only; it does not infer a provider observation from them.

Reference safety is a lexical backstop, not a secret scanner: a reference has a 65,536-code-unit cap and is examined as the original plus two bounded percent-decoded NFKC/case/default-ignorable-normalized layers. It rejects explicit sensitive-category labels (including form-style + label-separator variants) carrying a nonempty : or = payload. Its URL-authority check uses only literal delimiters realized in a layer, scans preserved and TAB/CR/LF- stripped structural views, and considers candidates only at a standalone root atom or a query/fragment value (including bracketed, quoted, and JSON values). Path and opaque portions are protected scalar by scalar before the next layer; literal or unprotected realized encoded Unicode whitespace reopens prose, while encoded delimiters inside an established protected path remain data. A scheme-looking substring inside an opaque bare token is not a URL candidate. When a remaining decode can still establish an authority, query, fragment, or relative-path boundary, the scanner defers that suffix to the next layer rather than projecting a premature protected path; on the final bounded layer, actual delimiters and authority userinfo take precedence. JSON string values admit only their leading-wrapper and whitespace-delimited URL candidates; JSON punctuation inside the string remains ordinary text. Ordinary identifiers that merely contain those words remain valid. Unicode default-ignorables are removed for this lexical check and for linked position/action-authority identity comparison; no reference is resolved or authenticated.

The installed-position ledger keeps its schema-v1 compatibility rule that a baseline observedAt is nonempty text. Completion validation is deliberately stricter: that linked baseline must be exactly retained by a readable outcome observation whose RFC3339 instant has at most millisecond precision before it can support a completion result. It never claims a provider observation is true, installs a package, or measures a provider itself.

The lifecycle vocabulary: absent / found / draft / approved / verified / retired (issue #1228)

One lifecycle vocabulary for every capability and pack item, an owner decision recorded 2026-09-22: the loop lifecycle (below) and pack item statuses were designed as two state models side by side before this, using different words for the same underlying position. LIFECYCLE_STATES (typed LifecycleState) -- absent, found, draft, approved, verified, retired -- and LIFECYCLE_CONDITIONS (typed LifecycleCondition) -- current, stale, blocked, shared across every state -- are this package's own single definition, mirrored word for word by this repository's own canonical lifecycle contract. This package's own loop engine (below) uses these states directly; any other package with a status-like surface imports them from here rather than declaring its own.

A surface that needs a richer vocabulary specializes this one instead of inventing new words. Pack item statuses are the one example so far: PACK_STATUSES (typed PackStatus) -- absent, found, draft, in-review, kept, published -- and packStatusToLifecycle(status) resolves each one to its PackStatusLifecyclePosition: in-review is draft with a pending judgment, kept is approved by the Customer keep, published is verified, sealed and live; the other three map onto the identically-named state with no extra meaning.

The loop engine: sense / judge / act / verify / learn (issue #1195)

One pure implementation of the loop every role runs, installed in every staffed repository as an exact dev dependency. Roles declare data -- their own clossys/<role>/loop.json -- and this package runs them: per issue #1187's own "who does what" split, packages own definition, judgment, and deterministic mechanics, and this engine is the deterministic-mechanics half. LOOP_STAGES (typed LoopStage) is the fixed five: sense, judge, act, verify, learn -- learn was named learnOrEscalate before issue #1194's rename; escalation is one of learn's own outcomes, handing an unresolved problem to the enclosing loop, not a separate stage. Every role is invoked with loop (/clossys-<role> loop in Claude Code, @clossys-<role> loop in Cursor); one invocation runs one iteration and stops at the approval gate inside judge.

Triggers. TRIGGER_KINDS (typed TriggerKind) names what can change -- inputs-changed, role-changed, freshness-window, review-window, outcome-missed, client-request -- and reentryStageForTrigger(trigger) / reentryScopeForTrigger(trigger) (TRIGGER_SCOPES, typed TriggerScope) are the fixed, total maps from each one to where the loop re-enters and how much of the role's own capability set that touches. A freshness window re-enters at judge (the evidence might be stale; re-decide before acting on it); a review window re-enters at learn (it is time to adapt or close). isTriggerKind(value) narrows an unknown value.

Blockers. BLOCKER_KINDS (typed BlockerKind) is the fixed five -- missing-input, missing-authority, failing-evidence, unavailable-environment, contradiction -- each with exactly one owner in BLOCKER_OWNERS. blockerFor(capabilityId, kind, nextAction, since) builds one Blocker record with its owner always derived from kind, never caller-assigned. isBlockerOverdue(blocker, now?) and overdueBlockers(blockers, now?) are the escalation check: an unparseable nextAction.byWhen counts as already overdue, never as on schedule. A blocked capability's own state never touches another capability's -- blocking is per capability, so the rest of a role's loop keeps running.

Staleness. fingerprintInputs(inputs) (each a FingerprintInput) hashes every supplied input's content into a Fingerprint (path -> sha256 digest, reusing this package's own content-addressed primitive from ./policy rather than a second hashing scheme). isStale(recorded, current) is a deterministic comparison over two fingerprints -- a changed digest, a dropped path, or an added path are all staleness. changedInputs(recorded, current) names exactly which paths changed. affectedCapabilities(changedPaths, capabilityInputPaths) is change propagation: only a capability that declared a changed path as its own input is ever affected by it.

Artifact operations. isOwnedByRole(role, path) is the one boundary every operation below shares: a path must fall under that role's own clossys/<role>/ folder. planCreateOrUpdate(input) returns a CreateOrUpdatePlan (or a RefusedPlan for a path outside the role's own folder) -- an update whose file no longer matches this role's own last write comes back requiresMerge: true, the mechanism behind "a human's edit is detected by fingerprint and merged, never overwritten." planMove(input) returns a MovePlan naming every other file whose content cites the old path, so a move's references are fixed in the same pull request. planSupersede(input) returns a SupersedePlan: a new entry is added, the superseded one is named, nothing is proposed for deletion. planRetire(input) returns a RetirePlan only when the path is one the role's own manifest lists as an output and nothing depends on it; a listed path with a live dependent comes back blocked (a BlockedRetirePlan naming every dependent), and an unlisted path is refused outright rather than guessed at. Every planning function only ever computes what should change from already-read state; none of them write a file or open a pull request -- that is the coding agent's own work, applying an approved plan, the same boundary @clossys/launcher already draws for composing skills.

Stale-plan refusal. "A plan is bound to the assessment it came from; if that assessment changes, the plan is re-proposed as a diff and never executed as written." bindPlan(plan, boundFingerprint) attaches the fingerprint a plan (any of the artifact-operation plans above, or a caller's own) was computed from. decidePlanExecution(binding, currentFingerprint) is the one place that compares it against the current inputs before anything executes: execute carries the plan through, stale-refuse (PLAN_EXECUTION_OUTCOMES, typed PlanExecutionOutcome) carries null instead, so a caller that reads a stale plan's contents off the decision has necessarily skipped the check this module exists to enforce. Returns a PlanExecutionDecision.

State and status. validateLoopState(value) validates a parsed clossys/<role>/loop.json document (a LoopState, keyed by capability id to a LoopCapabilityState) and returns every LoopStateFinding; isValidLoopState(value) narrows. resumeStage(capability) returns a capability's own recorded stage exactly as written -- an interrupted run resumes from disk, never from re-derived or guessed position. renderStatusDocument(role, sections) is the generic five-section renderer -- StatusSections: Mandate / Where we are / Recommended next / Decisions / Blockers, in that fixed order -- deliberately decoupled from LoopState so the Advisor lane's own parallel STATUS document (written in the same wave, with the same five sections) stays compatible with this renderer taking it over later. renderLoopStatus(role, state, mandate, now?) is this package's own use of it, building those five section bodies from one role's loop state. The installed foundry-loop-status <loop.json> <mandate.txt> [--out <STATUS document path>] executable is the CLI form, on the same 0 / 2 ternary as this package's other gates (there is no 1: a report is rendered or it is not).

The shared check-output-envelope (issue #1174)

The repository contract docs/contracts/check-output-envelope.json, which does not ship with this package, is one JSON report shape for every check command's report, across every role -- shipped in Stage A with no real emitter yet. buildCheckOutputEnvelope(options) is the first one: it builds a CheckOutputEnvelope (package, version, verdict, summary, findings: CheckFinding[], an optional metric: CheckMetric and nextAction, all typed from BuildEnvelopeOptions), refusing at construction time to build a non-satisfied verdict with an empty findings list -- a report that says something is wrong while refusing to say what it is. envelopeToExitCode(envelope) folds the envelope's own verdict onto this package's 0 / 1 / 2 exit-code convention, reusing the same GateVerdict vocabulary ./gates already declares rather than a second copy. The schema-version and heartbeat checks below are its first two real emitters.

Schema versions and migrations for every clossys/ record (issue #1224)

Every record a package writes under clossys/ carries a schemaVersion; this module is the deterministic migration engine every package's own forward migrations run through. classifyRecordVersion(table, record) is a read-only answer -- RecordVersionClassification: already-current / migratable / future / no-path -- over one record against a MigrationTable (kind, currentVersion, and an ordered list of MigrationSteps, each a pure fromVersion -> toVersion hop). migrateRecord(table, record) actually walks the chain, returning a MigrationOutcome: AlreadyCurrentOutcome (idempotent -- re-running this on an already-migrated record is a no-op), MigratedOutcome (every applied step's description, plus backup, the record exactly as it was before any step ran), or IndeterminateOutcome (a missing/non-numeric schemaVersion, a schemaVersion newer than this package knows -- never downgraded -- or a gap in the step chain; the record comes back byte-identical in every case, never partially migrated).

createRecordKindRegistry() returns an empty, open RecordKindRegistry any caller populates with its own tables; defaultRecordKindRegistry() is the one this package's own CLI uses, seeded with the two record kinds actually found under clossys/ on main today -- LOOP_STATE_KIND (clossys/<role>/loop.json) and COVERAGE_DECLARATION_KIND (clossys/coverage.json) -- an extension point, not a closed list: a package like Strategist registers its own table into its own registry instance when it adopts this engine.

discoverRecords(repoRoot, locations?) walks <repoRoot>/clossys/ for files matching a known RecordLocation (DEFAULT_RECORD_LOCATIONS, the same two kinds above) into a list of DiscoveredRecords, skipping clossys/.state/ (generated files only, never a source record). runMigrations(repoRoot, registry, options?) (RunMigrationsOptions) classifies/migrates every discovered record into a RecordMigrationReport, and -- only with apply: true -- writes the migrated record back to its own path plus a backup of its pre-migration bytes at clossys/.state/schema-backups/<relative-path>.v<oldVersion>.json. The installed foundry-schema-migrate [repoRoot] [--apply] executable is the CLI form: report-only (dry run) by default, emitting one CheckOutputEnvelope (see above) -- indeterminate if any record could not be classified, satisfied otherwise (a migrated record still counts satisfied; the gate is about a record shape shipping with no migration at all, not about migrations never running).

Operating cadence: a zero-token heartbeat (issue #1221)

A business runs continuously, but a loop only runs when a person types /clossys-<role> loop. The heartbeat closes that gap without a model: computeHeartbeat(roles, now?) deterministically finds every capability, across a Readonly<Record<string, LoopState>> of roles, that is blocked-capability (one entry per open blocker, overdue reused directly from ./blockers's own isBlockerOverdue rather than a second copy), pending-decision (stage judge), stale-capability (condition stale), or review-waiting (stage learn) -- the fixed four HeartbeatFindingKinds in HEARTBEAT_FINDING_KINDS, each one a DigestEntry, joined into one HeartbeatDigest sorted overdue-blockers-first, then pending decisions, then the rest, ties broken by role then capability id for determinism. renderDigest(entries, now?) is a plain, mechanical Markdown renderer, in the same generic-renderer style as renderStatusDocument above -- Advisor's own later wording/prioritization pass supersedes it, per this feature's own ownership split (engine and computation here, installing the workflow is Launcher's job, digest wording is Advisor's). "Nothing waiting" renders one plain line, never an empty file.

loadLoopStates(repoRoot) reads every clossys/<role>/loop.json under a repository root into a LoadedLoopStates map, validating each with isValidLoopState and reporting an unreadable or invalid one as an UnreadableLoopState rather than throwing (a role directory with no loop.json is silently skipped -- it has not adopted the loop engine yet). computeHeartbeatForRepo(repoRoot, now?) composes that read with computeHeartbeat into one HeartbeatRunResult, and writeHeartbeatDigest(repoRoot, digest, now?) renders the decisions file into the consumer repository's own state directory, at the path named by the exported HEARTBEAT_DIGEST_PATH constant -- kept as a separate write step so a caller can run in report mode by simply not calling it. The installed foundry-heartbeat [repoRoot] [--write] executable is the CLI form: report mode by default, --write also renders the digest file, emitting one CheckOutputEnvelope -- satisfied whenever the digest computed successfully (a populated digest is not itself a violation), indeterminate only when a loop.json could not be read or validated. Never calls a model; never makes a live external change.

controllerHeartbeatSchedule(scope) builds the reference ScheduleDeclaration (id controller-heartbeat, a business-days-only cadence, artifact: "scripts/run-heartbeat.mjs" -- this repository's own demonstration wrapper, which does not ship with this package) this package's existing schedule conventions already define -- "work that runs without a model is a schedule, never a routine." validateHeartbeatSchedule(declaration, registry) is a thin, named call to the existing validateScheduleDeclaration, so a caller never re-derives that validation by hand. A declaration is not a deployment: installing the workflow that actually runs this on a clock is a consuming repository's own job (via @clossys/launcher), not this package's.

First-day onboarding: discovering and invoking role-owned assessments

One parameterized workflow that discovers and invokes role-owned assessments for a consuming repository's first day. It is orchestration, and deliberately nothing more: it carries no assessment content, and it is not a substitute for any role it opens.

It reaches consumers two ways: as the installed foundry-onboarding-run executable, and as named exports on this package's root entry point (@clossys/controller) rather than a subpath of its own. That placement is a constraint, not a preference. The frozen public-npm aggregate canary plan pins an immutable optional-peer matrix covering every declared export specifier of every package carrying an optional peer, and that plan may not be rewritten — so a new subpath on this package has nowhere to be recorded. The root specifier is already in that matrix and its recorded imports outcome stays truthful, because nothing in this module needs typescript. Declaring the subpath in a way the matrix could not see would have been the other option, and would have been a false green.

runFirstDayOnboarding(request, options) does four things in order.

  1. Select. selectRoles opens a role when — and only when — one of the fixed SELECTION_RULES fires over facts the consumer declared: engagement-baseline always opens the advisory role; unresolved-direction opens the direction role when the request declares any of business-model, value-formula, northstars or causal-metric-tree unresolved; unmapped-operating-system opens the operating-system role when it declares ontology or repository-topology unmapped; independent-outcome always opens the outcome role; and consumer-requested opens any operating role the consumer named. Every other active role is excluded under the single reason no-selection-rule-opened-this-role. Deciding whether a direction question is unresolved is expertise; reading a declared unresolved list is arithmetic, and only the second happens here.
  2. Discover. discoverRoleAssessmentSurface reads the role's own installed manifest for its own declaration — "foundry": { "assessment": { "bin": "<binName>", "invocation": "single-json-input" } } — and resolves it against that same manifest's bin map. It never infers a surface. A role that ships five gate CLIs and declares none has no assessment surface, because choosing which of the five is the assessment would be this package deciding what another role's assessment is.
  3. Invoke. observeRoleAssessment runs the resolved executable with no shell, no caller-supplied command, argument list or executable path, a bounded deadline, and registry-credential environment variables removed. It parses what the role printed and carries it onward untouched: an unparseable answer is assessment-output-unreadable, never an empty assessment, and a printed state that disagrees with the exit code is assessment-exit-inconsistent.
  4. Join. joinFirstDayOnboarding produces the report: which roles a rule opened, what each one's own surface returned, and what could not be observed. That is the whole of its output.

Two constraints, enforced structurally rather than by convention

The workflow carries no assessment content. A role's assessment is typed unknown throughout this subpath, so no code here has a vocabulary with which to construct one that typechecks as content. assertPassThroughAssessments re-checks that by reference identity before any report is returned: a record whose assessment is not the very object the role returned — because it was authored, defaulted, normalized or merged — throws rather than ships. assertRoleAuthoredPositions applies the same test to every position in a proposed ledger.

The workflow claims no role's expertise. Its report has no baseline, target, setpoint, causalHypothesis, recommendation, criticalPath or openQuestions field of its own; those appear only inside a role's returned assessment. A role's violation stays the role's finding. And the run state is derived, never asserted: satisfied requires that every selected role returned its own assessment cleanly, indeterminate dominates violated, and a run that opened no role at all is indeterminate rather than vacuously clean.

A role with no assessment surface is a result, not a skip

Each AssessmentSurfaceAbsence — package-not-installed, manifest-unreadable, no-assessment-declaration, invalid-assessment-declaration, undeclared-assessment-bin, assessment-executable-missing — and each AssessmentInvocationFailure — assessment-input-missing, assessment-not-executed, assessment-output-unreadable, assessment-exit-inconsistent, assessment-timed-out — reaches the report as a named gap against the role that was opened, with the rule that opened it. Any gap makes the run indeterminate. A capability that could not be observed must not grade identically to one that was observed and was fine, so an unassessed role and an assessed clean role never produce the same exit code.

Producing the contract, and refusing mutation without it

proposeInstalledPositionLedger(run, activeRoles) derives one complete installed-position ledger covering every active role: dispositions from the deterministic selection, positions copied by reference from each role's own proposedPositions. It returns ledger: null whenever a selected role produced no assessment. There is no "assume not-applicable" path — deciding that an opened role is not needed is that role's call or the decision owner's.

authorizeMutation(run, ledger, approval) is the gate. It authorizes nothing unless the run is satisfied with no gaps, the ledger validates under validateInstalledPositionLedger — which is what makes baseline, setpoint, authority, guardrails and escalation path mandatory on every open position — and this engagement's own decision owner has approved that exact run by onboardingRunDigest. A stale digest, a different approver, a missing baseline evidence reference or an absent action authority each refuse.

foundry-onboarding-run <request.json> <install-root> <evidence-dir> [--report <path>] [--ledger <path>] is the installed entry point, on the same 0 / 1 / 2 ternary as every other gate here. The active role set comes from the role contract shipped beside this package, never from a list maintained in this subpath.

Extended manifest discovery: intake, outputs, status, fit, solves, needs, feeds (issues #1172, #1176)

discoverRoleAssessmentSurface above reads one manifest key, foundry.assessment. Issue #1172 extends the same discipline to four more role-owned manifest keys, and issue #1176 (owner decision recorded 2026-09-22: "kits are composed, not a fixed grouping") adds three more as schema version 2. Every one of these seven fields is discovered the same way: read only the role's own installed manifest, never infer a surface from what the package ships, and report absence as a named, determinate value rather than guessing or silently skipping.

  • foundry.intake (INTAKE_DECLARATION_PATH) — a package-relative path to the role's own shipped intake-question-cards file, discovered by discoverRoleIntakeSurface / discoverRoleIntakeSurfaces into an IntakeSurface. Absence reaches the caller as one of INTAKE_SURFACE_ABSENCES (IntakeSurfaceAbsence).
  • foundry.fit (FIT_DECLARATION_PATH) — the same shape, for the role's own shipped fit-signal-declarations file. discoverRoleFitSurface / discoverRoleFitSurfaces resolve a FitSurface; absence is one of FIT_SURFACE_ABSENCES (FitSurfaceAbsence).
  • foundry.status (STATUS_DECLARATION_PATH) — a read-only status probe, declared and resolved exactly like assessment: { bin, invocation } resolved against the manifest's own bin map. discoverRoleStatusSurface / discoverRoleStatusSurfaces produce a StatusSurface; absence is one of STATUS_SURFACE_ABSENCES (StatusSurfaceAbsence).
  • foundry.outputs (OUTPUTS_DECLARATION_PATH) — the paths a role declares it owns. discoverRoleOutputsDeclaration / discoverRoleOutputsDeclarations resolve an OutputsDeclaration, and enforce the one structural rule #1171's layout requires: every declared path must fall under that role's own clossys/<role>/ folder. A path outside it is not silently kept — it is reported as the dedicated absence output-path-outside-role-folder (OUTPUTS_DECLARATION_ABSENCES, OutputsDeclarationAbsence).

Schema version 2 (issue #1176) adds three more manifest keys, discovered the same manifest-only, shape-level way. None of the deeper cross-file checks — resolving solves.problem against a client-problem vocabulary, matching solves.metric or solves.proofCase against this role's own owned metric or qualification adapter, matching a needs entry against some role's feeds entry, or detecting a cycle in the resulting needs/feeds handoff graph — happen in this package. Those are this repository's own dev-time questions, answered by this repository's own gate script in its --enforce mode, not ones a runtime orchestration can answer for an arbitrary consumer.

  • foundry.solves (SOLVES_DECLARATION_PATH) — a list of verifiable claims about the client problems this role solves, each a SolvesEntry (problem, statement, metric, proofCase, and an evidence level drawn from SOLVES_EVIDENCE_LEVELS / SolvesEvidenceLevel: designed, qualified, proven). discoverRoleSolvesDeclaration / discoverRoleSolvesDeclarations resolve a SolvesDeclaration; absence is one of SOLVES_DECLARATION_ABSENCES (SolvesDeclarationAbsence).
  • foundry.needs (NEEDS_DECLARATION_PATH) — artifacts this role consumes from another role's own feeds, each a NeedsEntry (producerRole, artifact). discoverRoleNeedsDeclaration / discoverRoleNeedsDeclarations resolve a NeedsDeclaration; absence is one of NEEDS_DECLARATION_ABSENCES (NeedsDeclarationAbsence).
  • foundry.feeds (FEEDS_DECLARATION_PATH) — artifacts this role produces for other roles, each a FeedsEntry (artifact, path), under the same clossys/<role>/ rule outputs already enforces. discoverRoleFeedsDeclaration / discoverRoleFeedsDeclarations resolve a FeedsDeclaration; a path outside the role's own folder is feeds-path-outside-role-folder, and any other malformed entry is one of FEEDS_DECLARATION_ABSENCES (FeedsDeclarationAbsence).

./artifacts: governed artifact verification

A reusable contract for verifying a consumer-owned governed artifact that combines a declared kind + schema version, an exact-content checksum, and structural source/revision provenance — closing the gap issue #195 opened over: a checksum could pass while the schema version was unsupported, or provenance could be attached without ever being checked.

import { verifyGovernedArtifact, verifyGovernedArtifacts } from "@clossys/controller/artifacts";
import type { GovernedArtifactManifest, GovernedArtifactVerificationOptions } from "@clossys/controller/artifacts";

const manifest: GovernedArtifactManifest = {
  kind: "widget-catalog",
  schemaVersion: "2",
  checksum: { algorithm: "sha256", digest: "…64 lowercase hex characters…" },
  provenance: { source: "https://example.invalid/repo", revision: "abc123" },
};

const options: GovernedArtifactVerificationOptions = {
  artifactKind: "widget-catalog",
  supportedSchemaVersions: ["1", "2"],
};

const findings = verifyGovernedArtifact(manifest, rawContentBytes, options);
if (findings.length > 0) process.exitCode = 1;

The verification order is fixed, deterministic, and documented

verifyGovernedArtifact runs five stages, always in this order, and short-circuits on the first stage that reports an error:

  1. Caller options — artifactKind non-empty, supportedSchemaVersions non-empty. An empty supportedSchemaVersions is a caller configuration error, never an artifact that trivially passes.
  2. Manifest structure, including provenance shape (presence and shape only — folded into the one mandatory stage every successful verification passes through, so provenance can never be attached without being checked).
  3. Artifact kind — manifest.kind must equal options.artifactKind.
  4. Schema version — manifest.schemaVersion must be one of options.supportedSchemaVersions. Checked before the checksum, deliberately: this is the exact ordering #195 was opened over. An unsupported schema version is rejected even when the bytes match exactly.
  5. Exact-content checksum — delegated entirely to @clossys/controller/policy's own verifyBinding; this package hashes nothing itself. Checked last, both because it is the most expensive check and because checking it last means a caller can never see a passing checksum for an artifact whose kind or schema version were never actually accepted.

verifyGovernedArtifact returns [] only after all five stages ran and every one produced zero error findings — there is no path that returns [] having skipped a stage. See src/artifacts/verify.ts's own doc comment for the full reasoning and src/artifacts/verify.test.ts for tests that use fixtures broken at more than one stage simultaneously to prove only the earliest stage's findings are ever reported.

verifyGovernedArtifacts(entries, options) verifies a batch sharing one trust configuration, prefixing each finding's path with that entry's own id. An empty entries array is itself a failure ("artifact/empty-batch"), never a clean [] — the same "a check that cannot run must fail" discipline documented in CONTRIBUTING.md (precedent: commit 01bd520).

What a clean result proves, and what it does not

A clean result proves the manifest is structurally well-formed (including provenance), its kind and schemaVersion are both accepted by the caller, and content is byte-for-byte identical to what the checksum committed to. It never proves the payload is semantically valid under that schema version (schema-specific validation stays entirely caller-owned), that provenance.source/provenance.revision are genuine or that the named revision actually produced this content (only their shape is checked, never their truth), or — the sharpest distinction — who produced the content. Matching content is not attribution: a checksum proves bytes are unchanged from what was committed to, never who committed to them. This subpath implements no signature or identity-attestation scheme.

Digest comparison is delegated, not reimplemented

checksum.algorithm is typed as @clossys/controller/policy's own DigestAlgorithm — currently just "sha256" — rather than a second, independent union, so this contract can never claim to accept an algorithm policy itself does not support. Both the digest's SHAPE (is it the right number of lowercase hex characters for its algorithm) and its VALUE (does it match content) are checked by handing a small synthetic PolicyBinding to policy's own validateBindingShape/verifyBinding — this package never re-derives which algorithms are known, how long a digest should be, or how to hash anything.

Fail-closed vocabulary

Every finding is a Finding from @clossys/controller/policy, shaped { rule, severity, message, path? } and re-exported from this subpath. Rules prefixed artifact/ are owned here; policy-id-shape, digest-algorithm-known, digest-shape, and digest-mismatch are @clossys/controller/policy's own rule names, passed through verbatim so a caller can see exactly which layer reported the problem. See GovernedArtifactFindingRule (documentation-only — Finding.rule itself stays plain string) for the full vocabulary.

The package owns structural metadata validation and deterministic orchestration only. The consumer owns artifact bytes, semantic decoding, schema-specific validation, storage, transport, and trust policy — including deciding what provenance.source/provenance.revision actually mean and whether to trust them.

./gates additions: ratchet, override bounds, dependency scope, gate-result ternary

Three small, independent, pure gates alongside the existing foundation, build-order, and policy checks — none of them do I/O; a caller reads whatever real data each one needs and passes it in. A fourth addition, the gate-result ternary below, is not a gate itself but the shared vocabulary the other three (and foundry-check's own CLI) already independently converged on. A fifth, adaptLegacyCheckResult, folds a pre-existing, non-GateResult check outcome onto that same ternary. (The source-aware secret-surface checks that used to be listed alongside these now live at ./gates/secrets instead — see "Requirements" below for why they moved.)

The gate-result ternary: satisfied / violated / indeterminate

Every gate result in this repository is exactly one of three states, never collapsed into a binary pass/fail: satisfied (evaluated, condition holds), violated (evaluated, condition does not hold), or indeterminate (could not evaluate — fails closed, and carries a required, machine-readable reason). This is not new: foundry-check's own CLI already ships this ternary as its 0/1/2 exit-code contract, evaluateRatchet's status: "clean" | "regression" | "invalid" is the identical three states under different names, and Designer, Writer, and Strategist each independently reinvented a third shape (unchecked: UncheckedItem[], non-empty meaning "cannot vouch for this scan"). GateResult is that shape, named once, so the next gate reuses it instead of reinventing it a fifth time.

import {
  createGateReasons,
  foldGateResults,
  gateResultToExitCode,
  gateSatisfied,
  gateViolated,
} from "@clossys/controller/gates";

// A gate declares its own finite, reviewable set of indeterminate reasons.
const reasons = createGateReasons(["missing-credential", "no-applicable-inputs"] as const);

function evaluateOne(input: RegistryProbe) {
  if (input.token === undefined) return reasons.indeterminate("missing-credential");
  return input.registryReachable ? gateSatisfied(1) : gateViolated(["registry unreachable"]);
}

const overall = foldGateResults(probes.map(evaluateOne), { emptyReason: "no-applicable-inputs" });
process.exitCode = gateResultToExitCode(overall); // 0 satisfied / 1 violated / 2 indeterminate, unconditionally

gateSatisfied(evaluated) refuses to construct a passing result unless evaluated is a positive integer — the mechanical form of the meta-check this contract exists to enforce: a gate built on this function cannot report a pass on a code path that evaluated nothing. createGateReasons scopes indeterminate() to exactly the reasons a gate declares up front; naming an undeclared reason throws rather than silently widening what "indeterminate" means for that gate. gateResultToExitCode takes no override — indeterminate always maps to 2, never 0, regardless of reason; see this function's own doc comment for why that is a deliberate, non-configurable choice. assertNeverVacuouslySatisfied(evaluate, input) is a reusable regression-test helper: call it with an input engineered to produce no real evaluation and it throws if the gate under test reports satisfied anyway. gateResultFromRatchet(result) converts an existing evaluateRatchet result into this shape, as a worked proof that the two are the same contract rather than parallel ones.

adaptLegacyCheckResult(legacy, options)

Folds a pre-existing, non-GateResult check outcome onto this same ternary. A repository rarely adopts GateResult for every check on day one — more often, one existing script already reports its own ad hoc pass/fail/can't-tell shape, and a caller further up (an observation transport, a report aggregator, a CI summary) wants to fold that outcome in next to every other GateResult-native check without rewriting the original script. This has been hand-written more than once by consumers of this package: a short switch over the legacy check's own three states, a per-finding severity remap (a legacy check's own severity words are almost never the caller's own Finding vocabulary), and a required fallback finding for the one case gateViolated itself refuses — reporting a violation with nothing wrong in it.

import { adaptLegacyCheckResult } from "@clossys/controller/gates";

type LegacySeverity = "error" | "warning" | "info";
const legacyResult = runMyExistingCheck(); // { verdict: "violated", findings: [{ severity: "error", message: "..." }] }

const result = adaptLegacyCheckResult(legacyResult, {
  severityMap: { error: "high", warning: "medium", info: "low" } as Record<LegacySeverity, "high" | "medium" | "low">,
  defaultSeverity: "high",
  fallbackMessage: "myExistingCheck reported violated with no findings.",
});

legacy must already be reduced to the three verdict tags GateResult itself uses ("satisfied" | "violated" | "indeterminate") — translating a genuinely bespoke result shape (its own status/ok fields, its own nesting) down to that is the caller's own job, since it is the one part that is truly different for every legacy check. Everything past that point is common: a "satisfied" legacy result with no evaluated count becomes gateSatisfied(1) (matching gateResultFromRatchet's own convention for an equivalent single-question check); a "violated" legacy finding's path, when present, is folded into message; and severityMap / defaultSeverity are fully generic over whichever severity vocabulary the caller's own Finding type uses — this function never assumes one severity vocabulary is universal.

evaluateRatchet(current, baseline)

A generic "warn-first with a checked-in baseline, ratchet monotonically toward zero" primitive — count whatever a caller wants to track down to zero (lint warnings, TODOs, any usages, anything), read a checked-in baseline, and call this with both numbers.

import { evaluateRatchet } from "@clossys/controller/gates";

const result = evaluateRatchet(currentWarningCount, baselineFromDisk);
if (!result.ok) process.exitCode = result.status === "invalid" ? 2 : 1;

| current vs baseline | ok | status | Notes | | --- | --- | --- | --- | | current === baseline | true | "clean" | improved: false, no findings. | | current < baseline | true | "clean" | improved: true plus a "ratchet/baseline-stale" warning finding — real progress, reported explicitly, never silently dropped. Lowering the baseline is always a separate, explicit action; this function never does it for you. | | current > baseline | false | "regression" | A "ratchet/regression" error finding. | | either input is nonsense | false | "invalid" | A negative or non-integer current/baseline, or a missing (undefined/null) baseline, fails closed — this is "could not run", not a clean or regressed result, and current/baseline are not echoed back. |

checkOverrideTargetRanges(overrides)

A package.json overrides entry's target range must be upper-bounded to the vulnerable major — never a bare >=x.y.z. An unbounded target lets a resolver hoist a dependent across a major version boundary and break it at runtime; a security audit cannot catch this class of break, since an audit only confirms the vulnerable version is gone, never that the replacement stays API-compatible with what depends on it.

import { checkOverrideTargetRanges } from "@clossys/controller/gates";

checkOverrideTargetRanges({ "left-pad": ">=1.2.3" });
// -> one "overrides/range-unbounded" finding
checkOverrideTargetRanges({ "left-pad": ">=1.2.3 <2.0.0" });
// -> []

Range parsing is hand-rolled (no semver dependency) and deliberately narrow. It recognizes exactly: an exact pin ("1.2.3", "=1.2.3"), a ~ or ^ range, an explicit space-hyphen-space range ("1.2.3 - 2.0.0"), and a single or paired >=/>/</<= comparator range. Anything else — OR ranges ("... || ..."), x-ranges/wildcards, dist-tags, git/file/workspace specifiers, three or more space-separated comparators — is reported as "overrides/range-unparseable", a finding, not a pass: an unparseable range is exactly the case where this gate must not assume the best.

checkDependencyScope(catalog, scope, allowlist, options?)

Mechanical enforcement of CONTRIBUTING.md's "Dependencies: the default answer is no": every dependencies entry in a packages/*/package.json must be <scope>/*-scoped, unless it is named in a small, checked-in allowlist entry.

import { buildCatalog } from "@clossys/controller/catalog";
import { checkDependencyScope } from "@clossys/controller/gates";

const catalog = buildCatalog(process.cwd());
const allowlist = JSON.parse(readFileSync("dependency-scope-allowlist.json", "utf8"));
const findings = checkDependencyScope(catalog, "@example", allowlist);

| Allowlist entry field | Type | Notes | | --- | --- | --- | | name | string | The exact, non-scoped dependency name being exempted. Non-empty. | | reason | string | Why this dependency was deliberately admitted. Non-empty. | | reviewBy | string | YYYY-MM-DD. Once passed, the entry stops exempting anything and is itself reported as "dependency-scope/allowlist-expired". |

{ "version": 1, "entries": [] }

A malformed allowlist document or entry is a finding ("dependency-scope/allowlist-shape" / "dependency-scope/allowlist-entry-shape") and exempts nothing — never a silent exemption. Deliberately scoped small: every runtime dependency in this repository was verified by inspection to already be @clossys/*-scoped, so this is a floor that matches that reality today, not a full dependency admission-and-retirement register; it can grow richer if a third-party runtime dependency is ever legitimately admitted.

./repository: profiles, requirements, and exact roots

@clossys/controller/repository owns a strict grammar and pure, deterministic evaluation. It ships no profile, root entry, requirement, observation, repository inventory, machine value, precedence rule, retention decision, or default. It performs no filesystem, Git, provider, scheduler, credential, installation, or mutation I/O. A caller owns discovery and every name and value supplied to the validators.

Importing either this public subpath or its importable CLI API is inert: it does not inspect the filesystem or process arguments, write output, change an exit code or environment value, or mutate arguments. The installed repository-check command enters through a separate executable-only wrapper; only an explicit command invocation reads the requested profile and reports.

Canonical declaration location (issue #315)

A consumer's declaration lives at governance/repository-profile.json — CANONICAL_REPOSITORY_PROFILE_PATH, exported alongside the schema version constants below. This is settled, not merely a convention: an aggregator has to know where to look, and "wherever that repository decided" defeats the point of packaging one evaluator for every consumer to share.

Locating a declaration is repository-check's job, not this subpath's — the pure library still performs no I/O. Run with no argument, or with a directory argument, and the CLI searches that root for a declaration without being told exactly where it is:

$ repository-check                      # searches the current working directory
$ repository-check path/to/repository   # searches an explicit repository root
$ repository-check path/to/profile.json # validates exactly that file, no search

The canonical path is always checked first and, when present, is always what gets used — nothing found elsewhere can shadow it. When nothing is at the canonical path, the search continues for a declaration parked somewhere else (the canonical filename under another directory, or a known former filename under the canonical directory). A declaration found there is never reported the same way as no declaration at all: it produces its own declaration-non-canonical-location finding, distinct from declaration-not-found, so a repository that has a declaration in the wrong place is never read as a repository that declares nothing.

Requirements flow one way: a repository declares what it needs, an account workspace discovers and aggregates those declarations, and machine bootstrap may use the resulting report to compose an explicit configuration. Guidance and mutation do not flow upward through this API. If a caller later chooses to apply a configuration, it resolves that manifest itself and passes it to a separate engine; this subpath neither produces nor applies one.

Repository-root vocabulary belongs here because it describes the direct children of one repository. Account-container discovery and composition do not: they coordinate multiple repositories and stay caller-owned. For that reason this package adds no broad top-level workspace package and no @clossys/controller/workspace subpath.

Profile schema v3

import {
  REPOSITORY_PROFILE_VERSION,
  validateRepositoryProfile,
  type RepositoryProfileV3,
} from "@clossys/controller/repository";

const profile: RepositoryProfileV3 = {
  schemaVersion: REPOSITORY_PROFILE_VERSION,
  defaultBranch: "main",
  releaseBranch: "release",
  commands: [{ name: "check", run: "npm run check" }],
  protectedPaths: [".github/workflows/**"],
  requirements: [
    {
      id: "runtime.example",
      scope: "machine",
      constraint: { kind: "one-of", values: ["variant-a", "variant-b"] },
    },
    {
      id: "tool.formatter",
      scope: "repository",
      constraint: { kind: "present" },
    },
    {
      id: "runtime.node",
      scope: "machine",
      constraint: { kind: "minimum-version", floor: "20" },
    },
  ],
  rootEntries: [
    { name: "source", classification: "canonical", disposition: "required" },
    { name: ".tooling", classification: "extension", disposition: "allowed" },
    { name: "special-case", classification: "exception", disposition: "allowed" },
    { name: "old-link", classification: "compatibility-alias", disposition: "prohibited" },
    { name: "archive", classification: "legacy-artifact", disposition: "allowed" },
  ],
};

const findings = validateRepositoryProfile(profile);

| Field | Type | Notes | | --- | --- | --- | | schemaVersion | 3 | New declarations use REPOSITORY_PROFILE_VERSION. The closed v1 and v2 shapes remain accepted deliberately; see compatibility below. | | defaultBranch | string | A valid Git branch name. | | releaseBranch | string (optional) | The separate long-lived branch this repository deploys from, when it has one. A valid Git branch name, and never equal to defaultBranch. v3 only — see below. | | commands | RepositoryCommand[] | Ordered, dense array (at most 10,000 entries); names are unique. | | protectedPaths | string[] | Ordered, dense repository-relative paths or the supported */** patterns; duplicates are rejected. | | requirements | RepositoryRequirement[] | Ordered, dense array of unique (scope, id) declarations. Foundry supplies no entries. | | rootEntries | RepositoryRootEntry[] | The caller's exact direct-child vocabulary. Names are unique single path segments; every entry has an explicit classification and disposition. |

The branch topology, and the exemption derived from it (issue #929)

releaseBranch is optional, and deliberately so. A repository with one long-lived branch has nothing truthful to put there, and a required field would be satisfied by repeating defaultBranch — which validation then refuses as a collision, leaving such a repository no valid declaration at all. Two separate verdicts, because they are two different defects with two different fixes:

| Rule | When | | --- | --- | | release-branch | releaseBranch is present and is not a valid Git branch name — the same check defaultBranch gets. | | release-branch-collision | releaseBranch is a perfectly good branch name that happens to equal defaultBranch. A repository that deploys from its default branch has one long-lived branch: omit the field rather than repeating it. |

The field is declared on v3 only. releaseBranch on a v1 or v2 profile is reported as unknown-field and is not additionally judged on its value — those shapes are closed, and a legacy declaration must not be able to state a topology its own version does not define.

This is the half that makes the branch-provenance exemption checkable. See branchExemptionsFromProfile under Conventions: until the topology existed as data, an exemption list and the repository it claimed to describe could not be compared, because only one of them existed.

Requirement-id grammar (issue #316)

A requirement id is exactly two dot-separated segments, <category>.<subject>, where category is one of REQUIREMENT_ID_CATEGORIES (runtime, tool, dependency) and subject is lowercase words joined by hyphens — for example runtime.node, tool.git, `tool.package-manager