Praetor Fast Adoption Guide
Apply Praetor governance scaffolding to a legacy or greenfield repository and retain explicit errors for incomplete steps. Verify the resulting repository before treating adoption as complete.
Prerequisites
praetorctl, installed with the workstation command (workstation install).standardsctlis the older name of the same binary.- A Praetor checkout (a Git clone of the engine; adopt refuses a
git archiveexport, see the source-root rules below), here written/path/to/praetor.--lock-source-rootnames it: the first adoption pins your profile and facets to the content digests of its catalog, and copies the pinned catalog into your repository. Without it a first adoption stops withnew lock pins require an explicit verified lock source root(ErrLockSourceRequiredininternal/adopt/lock.go). A repository that already holds a valid lock and its.config/archetypescatalog needs no source for a later run. - A Git repository with your files committed, run from its root or with
--path. Adoption writes the Go API compatibility gate only where Git tracks ago.mod(Go API compatibility gate), so adopt after the first commit and stage what adoption writes before you audit. - A known hosting forge. An
originremote ongithub.com, orrepository.forgedeclared in.standards.yaml(github,forgejoorgitlab). Any other host with no declaration gets no Paperclip harness, andpraetorctl auditthen fails on the missing.paperclip/harness.json(Hosting forge).
Adopt a repository
Run praetorctl adopt (the older name standardsctl adopt is the same command):
# Adopt current repository (auto-detects language and frameworks)
praetorctl adopt --lock-source-root=/path/to/praetor
# Stage what adoption wrote, then verify the result
git add -A
praetorctl audit --offline
# Dry-run simulation: inspect proposed changes without writing files.
# --lock-source-root is required here too on a first adoption, because the plan resolves the
# policy the lock would pin.
praetorctl adopt --dry-run --lock-source-root=/path/to/praetor
# Regenerate drifted audit-locked files; an existing debt baseline is kept, not re-recorded
praetorctl adopt --force --lock-source-root=/path/to/praetor
# Replace an existing debt baseline with a rescan; a higher count needs the allowance
praetorctl adopt --rerecord-baseline --allow-increase --reason="why the debt is accepted"
# Change the declared profile or facets later, or re-pin to a newer catalog
praetorctl profile set os-image --lock-source-root=/path/to/praetor --dry-run
--force refreshes rather than resets: what it rewrites, merges and keeps is in
What a forced re-adoption changes.
Choose the profile
Adoption detects the profile from the files it finds (the report's Archetype: line) and a dry
run shows it before anything is written. When the detected profile is not the one you want, pass
--profile on the first adoption, or move an adopted repository with
praetorctl profile set <profile> --lock-source-root=/path/to/praetor. profile set rewrites
the declaration, the lock and the catalog, then lists any audit-locked file that no longer
matches the new declaration (such as the DevContainer) and the
praetorctl adopt --force --lock-source-root=... --dry-run command that previews their refresh.
Run praetorctl audit --offline afterwards. See
Changing the profile, facets or catalog.
praetorctl init instead of adoption
praetorctl init writes .standards.yaml, a zero-debt .standards-baseline.json and an
unpinned .standards.lock placeholder with no content digests, and compiles agent files from
AGENTS.md only when that file already exists (onboarding guide). It
takes no --lock-source-root flag; audit and devcontainer generate refuse the placeholder
lock until you pin it with praetorctl profile set --lock-source-root=/path/to/praetor. Even
pinned, an init-only repository does not pass audit --offline: init writes no branch ruleset,
no documentation gate tooling (tools/markdownlint under docs:seo-portal), no API
compatibility gate (tools/apicompat/gate under api:public-contract), no HISS-11 supply-chain
exception, no Paperclip harness, agent definitions or git hooks, so use adoption for a repository
that must audit clean.
--facets names the facets adoption writes when it creates .standards.yaml. Omitted, adoption
writes the defaults of the detected profile (config.DefaultFacetsFor in
internal/config/facets.go): security:high and agent:sandboxed for every profile,
api:public-contract only for framework, library-client and web-package, which publish an
API, and docs:seo-portal only for pages-site, framework and library-client, which publish
documentation (TestDefaultFacetsFor_3D). A gitops-infra cluster repository gets neither an API
gate over its tool modules nor a documentation portal it has no site for. An existing .standards.yaml keeps the
facets it declares, none or an empty list included, and adoption ignores --facets for it
(TestManifestForLock_FacetsScaffoldOnlyANewManifest_3D in internal/adopt/paths_read_test.go).
adopt --help says both (TestAdoptHelpStatesTheDefaultFacets in
cmd/standardsctl/profile_test.go). To change the facets of an adopted repository, see
Changing the profile, facets or catalog.
The report's Facets: line shows the facets the run applies: the declared ones when
.standards.yaml exists, whatever --facets names (TestAdopt_Positive_ReportsTheDeclaredFacets
in internal/adopt/facet_selection_test.go). A --facets that differs from them, --facets=
included, adds a warning naming both lists and the praetorctl profile set --facets=... command
that changes them (TestAdopt_Negative_WarnsAboutIgnoredFacets). An overridden --profile warns
the same way and names praetorctl profile set <profile>.
When a first adoption falls back to the default facets, notes under Facets: say so, say whether
--facets was omitted or given empty (--facets= also declares the defaults), and name what each
default facet raises over the detected profile alone in branch_protection and supply_chain,
read from the pinned catalog (config.EffectivePolicy.FacetEffects in
internal/config/facet_effects.go). For an empty repository adopted as template-seed:
Facets: [security:high agent:sandboxed]
default facets: --facets was omitted, so adoption declares the defaults of profile template-seed, security:high, agent:sandboxed, in the .standards.yaml it creates
security:high raises over template-seed alone: branch_protection.required_approving_reviewers 1 -> 2, branch_protection.require_signed_commits false -> true, supply_chain.slsa_level 1 -> 3, supply_chain.enforce_cosign false -> true, supply_chain.require_sbom false -> true
agent:sandboxed raises over template-seed alone: no branch-protection or supply-chain setting
choose other facets with --facets=<id>,... on a first adoption, or afterwards with praetorctl profile set --facets=<id>,... --lock-source-root=<praetor checkout> (--facets= declares none)
TestFacetEffects_Positive_ShippedDefaultsOverTemplateSeed in
internal/config/facet_effects_test.go pins these values against the shipped catalog. A run that
resolved no pinned policy, such as --dry-run --record-baseline=false without
--lock-source-root, says the effects are not listed instead
(TestAdopt_Boundary_EmptyFacetsFlagAndNoPolicy). The report's JSON carries the same facts as
facet_origin (declared, requested or default) and facet_notes, and the MCP
standards_adopt tool prints each note as a facet note: line
(TestFormatAdoptMCPResultPrintsFacetNotes).
Dry-run ruleset preview
adopt --dry-run writes nothing. For the branch protection ruleset
(.github/rulesets/main.json), it prints what a real run would do and what the file would contain
(AdoptReport.Previews, internal/adopt/preview.go). The MCP standards_adopt tool prints the
same preview text (adopt.FilePreview.Text, TestFormatAdoptMCPResultPrintsPreviews):
| Action | Meaning | Printed |
|---|---|---|
create |
no ruleset yet; the run writes one | the rendered ruleset |
update |
the run replaces the ruleset: the rendering current before the run, which it refreshes, or any differing ruleset under --force while the policy requires one |
unified diff from the file on disk to the rendering |
unchanged |
the ruleset already is the rendering, line endings aside | nothing more |
keep |
the ruleset differs and stays: --force was not passed, or the policy requires neither linear history nor signed commits |
the diff regenerating it would apply |
The preview takes its action from the same keep-or-replace decision as the real run
(internal/adopt/preview_test.go). Its status checks come from the workflows the run leaves
before its branch-ruleset step, not only the ones on disk:
- workflows the dry run records as written, replaced under
--forceor removed, such as the documentation gate's (TestReplaceExisting_3D_DryRunPlansTheReplacedBytesininternal/adopt/replace_test.go); - the workflows of the flavor the run applies for the profile it records
(
flavor.PlannedWorkflows), which a dry run plans but does not write.
On a first adoption the preview is therefore the file the run writes, byte for byte
(TestAdoptDryRun_Positive_FirstAdoptionPreviewIsTheWrittenRuleset).
What a dry run lists
adopt --dry-run, --force included, lists every path the real run writes, with the action the
real run records, under the same effective policy (AdoptReport.EffectivePolicy):
- the flavor templates, which the flavor layer plans through the decisions an apply makes,
writing nothing (
flavor.ApplyOptions.DryRun,internal/flavor/dry_run_test.go); - each pinned catalog file below
.config/archetypes/; - the persona and skill copies of the canonical personas and register skills the run writes
(
compiler.PlanAgentSurfacesOver); - the pre-commit hook that hook activation installs below
.git/hooks. Only runninglefthook installshows whether it succeeds; a failed one installs the fallback hook at the same path. A directory git does not know fails activation in both runs; - the ADR template and
.paperclip/rules.mdunder their own paths. An existing one beside a missing ADR index or harness is kept, not overwritten; - an existing file the run overwrites, as replaced (
Planned Replacements), never as created.
internal/adopt/dryrun_parity_test.go runs a dry run and a real run on two copies of one
fixture, a first adoption and a forced re-adoption among them. It fails when either report has
an entry the other lacks, when the real run writes a file no planned path names, when a report
lists an existing file as created, or when the effective policies differ.
Which jobs the ruleset requires
Adoption, flavor apply, sync and the audit take the ruleset's required status checks from one
function, forge.RequiredStatusContexts (internal/forge/workflow_checks.go). A job's check is
required when the job reports on every pull request:
- Its workflow triggers on
pull_requestwithoutpathsorpaths-ignore. A filtered workflow does not run on every pull request, and a required check that never reports leaves the pull request waiting forever. - The job has no
if:, or a condition that holds on every run:always(),success() || failure()or!cancelled(), bare or as one${{ }}expression. A disjunction containinggithub.event_name != 'schedule', which holds on every pull request run, counts too (TestHoldsOnEveryPullRequestRunininternal/forge/required_contexts_in_test.go). So does a repository guard that holds for the manifest's identity (see Which workflows run where). - A condition that leads with the conjunct
!(startsWith(github.head_ref, 'renovate/') && github.event.pull_request.user.login == 'renovate[bot]')is judged by the rest alone: the rest must be one parenthesised group or hold no top-level||(TestRenovateBranchSkipKeepsAJobRequiredininternal/forge/workflow_guard_test.go). Only that exact term counts; a looser skip, such as the head branch test alone, makes the job optional. The term skips pull requests the Renovate app opened from arenovate/branch only, and requiring the job keeps every other pull request protected. The engine's heavy jobs use it because its landing pipeline takes Renovate pull requests over (Renovate pull requests). A repository without such a takeover should not use it: a skipped job reports success, so nothing then verifies the Renovate pull request it merges. - The job is not advisory:
continue-on-erroris absent orfalse. - No proven aggregate job needs it directly (see the aggregate rule below).
Any other condition makes the job optional, a status function joined with anything else
(always() && ...) included. GitHub reports a job its condition skipped as successful, so a lane
that runs only when a planner job selects it would pass as a required check whether or not its
work ran.
A draft skip, if: github.event.pull_request.draft != true on the job, is such a condition too,
even in a workflow that runs again on ready_for_review: the skipped draft reports success, and
once the draft is marked ready that success satisfies the ruleset until the new run reports
(TestDraftConditionsAndRequiredContexts in internal/forge/workflow_draft_condition_test.go).
The hosted gates adoption writes keep their job unconditional and refuse a draft in a step
instead: on a draft the job reports a failed check by design, and the ready_for_review run
reports the same check on the same head commit, which replaces the failure
(When the workflow runs).
A repository that prefers the draft to skip the gate's work, not to run the gate job, can opt in with
hosted_gates.draft: skip in .standards.yaml (fail is the default and the only other value;
internal/config/hosted_gates.go). Adoption then renders praetor-api.yml and praetor-docs.yml
from the same hosted gate definition with the draft step replaced by a job-level skip,
github.event_name != 'pull_request' || github.event.pull_request.draft == false, on the gate
job. The gate job, now named <context> gate, no longer reports the required check. A second job,
<job id>-result, reports the original context: it needs the gate job, runs under always(),
and fails unless the gate job succeeded (when the gate job was skipped it first prints the draft
annotation the checkpoint planner reads), so a draft that skipped the gate job fails the required
check until the ready_for_review run reports (ghworkflow.RenderDraftSkip,
TestRenderDraftSkipPositive). The audit accepts the opt-in only while the job reporting the
required check is a proven aggregate (the rule above) that needs the gate job and fails when it
is skipped; otherwise it refuses and names the missing condition: no aggregate, an aggregate
that passes on a skip, or one the proof cannot read (forge.DraftSkipFault,
TestDraftSkipFaultRefusesWhatLetsADraftPass). It then locks the rendering byte for byte
(TestAuditLocksTheDraftSkipRendering). The fail-closed text and the skip text refresh into one
another with a plain praetorctl adopt, without --force, and an edited copy of either is kept
(TestAdoptRendersTheSelectedDraftShape). The skip text of an earlier gate text refreshes too, once
that text is in the family's Prior (TestPriorRenderingReadsASkipCopyOfAnOutgoingText); a change
to the skip text itself fails the shipped-text ledger of the family until the outgoing skip digest
is recorded there as well (TestShippedTextLedger). The skip shape is accepted by the HISS-18
workflow trigger audit only under the same guard
(Workflow triggers).
The opt-in is a trade-off, not a way to a green draft. A draft still starts the <job id>-result
job, because it runs under always(), and that job fails, so the required check stays red on a
draft exactly as it does with the fail-closed step; what the skip saves is the gate job's own
work. A repository whose contract allows no job to start on a draft therefore has to declare the
result jobs (api-compatibility-result in praetor-api.yml, documentation-result in praetor-docs.yml) in the list of
jobs that may start there; without that declaration the skip shape breaks the contract it was
chosen to keep.
A repository guard is judged against the manifest identity. An operational fork resolves that
identity to its repository.source, so the ruleset the fork commits equals the canonical one.
On the fork's forge the guard is false, and a guarded matrix job is skipped before its legs
exist, so none of their checks is ever reported. sync --remote therefore adds only the checks
whose jobs report in <repository.owner>/<repository.name> (forge.RequiredStatusContextsIn)
and prints the ones it left off (TestSync_Remote_RequiresOnlyChecksThatReportInTheRepository).
It never removes a check the live ruleset already requires, so it warns about a left-off check
an earlier sync added; remove that check from the live ruleset by hand
(TestSync_Remote_WarnsAboutLeftOffChecksTheLiveRulesetStillRequires).
The Platform Neutrality matrix is the case in point
(HISS-21).
Path-filtered CI therefore gets its protection from an aggregate job. GitHub skips every job
whose needs include a skipped job, and a skipped matrix job reports none of its per-leg checks,
so a lane the planner skips can leave a required leaf check unreported forever. The ruleset
requires the aggregate, and a job the aggregate needs directly is not a required check of its
own when the workflow file proves that the aggregate fails whenever that job failed or was
cancelled (internal/forge/workflow_aggregate.go).
The proof reads an allow-list of aggregate shapes and refuses every other one, so an aggregate it cannot read keeps the jobs it needs required beside it, as before. A proven aggregate:
needsat least one job;- runs under
if: always()alone, bare or as one${{ }}expression. A gate with no condition, withsuccess()or with any other condition is skipped when a need failed, and a skipped gate passes a required check.!cancelled()andsuccess() || failure()make the gate required but cover no job: on a cancelled run such a gate does not run, and whether GitHub then reports it as skipped or cancelled is not verified; - may lead its condition with the Renovate skip above. That gate is skipped on a Renovate pull request, so it covers only the jobs that carry the same skip; a job it needs without the skip stays required;
- is not advisory and passes when every job it needs succeeded;
- has only steps of the two shapes below. Any other step, such as a checkout, is refused: a step can change what a later one runs.
The first shape is an exit step: a run: step with no continue-on-error and no env:, under the
runner's default shell or shell: bash, sh, pwsh, powershell or cmd (a custom template
such as bash {0} is refused, whether the step, the job's or the workflow's defaults.run names
it). Its script is lines of echo, printf, Write-Host or Write-Output, then
exit <1..255>, then only blank and comment lines. Every line holds only ASCII letters, digits,
spaces and .,:_'"=/+-#, and closes each quote it opens, so no line continues onto the next,
opens a here-document, expands a variable, sets a trap or joins a second command. Its if: is a
disjunction, bare or as one ${{ }} expression, of contains(needs.*.result, '<result>'),
needs.<id>.result == '<result>' and needs.<id>.result != '<result>' terms. The aggregate
covers each job whose failure, and whose cancellation, makes that condition hold.
merge-gate:
name: Merge gate
needs: [impact-plan, go, test]
if: always()
runs-on: ubuntu-latest
steps:
- name: Fail when a needed job failed or was cancelled
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "::error::a needed job failed or was cancelled"
exit 1
The second shape is a re-actors/alls-green step
pinned by the full commit SHA of a release whose decision code praetor has read: v1.1.0 to
v1.3.0, listed in allsGreenReleases in internal/forge/workflow_allsgreen.go. A tag, a branch
or any other commit is refused, v1.0.x included, which declares no allowed-skips. The step has
jobs: ${{ toJSON(needs) }}, no continue-on-error and no input the action does not declare.
It has no if:: a condition on another need could skip it on the very run where a covered need
failed, so an alls-green step with any if: is refused and the leaves stay required. allowed-failures and allowed-skips
must be absent or literal lists of job ids, comma-separated or JSON. The action rejects a failed
or cancelled job unless allowed-failures names it, so the aggregate covers every job it needs
except those. It also rejects a skipped job that allowed-skips does not name, so name the
path-filtered lanes there:
merge-gate:
name: Merge gate
needs: [impact-plan, go, test]
if: always()
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0
with:
allowed-skips: go, test
jobs: ${{ toJSON(needs) }}
The proof assumes that a listed commit runs the code read at that release: a commit cannot change, so a moved tag does not affect it. A later release proves nothing until its decision code is read and its commit added to the list.
A job the aggregate reaches only through another job stays required: when it fails, the job
between is skipped, and a skipped need does not fail the aggregate. Put the planner in the
aggregate's needs to cover it. Both examples above are tested as written
(TestRequiredStatusContexts_Positive_DocumentedAggregatesNarrowToTheGate). The other tests are
TestRequiredStatusContexts_Positive_ProvenAggregateIsTheOnlyRequiredCheck,
TestProvenAggregate_SkippedLaneMergesAndFailedLeafBlocks,
TestRequiredStatusContexts_Negative_UnprovenAggregateKeepsTheLeaves,
TestRequiredStatusContexts_Negative_GateConditionMustBeAlways,
TestRequiredStatusContexts_RenovateSkippedGateCoversOnlySkippedNeeds,
TestRequiredStatusContexts_Boundary_AggregateSpellings,
TestRequiredStatusContexts_Boundary_AggregateReach and TestGateScript in
internal/forge/workflow_aggregate_proof_test.go, and the TestAllsGreenAggregate_* tests in
internal/forge/workflow_allsgreen_test.go.
A ruleset rendered before the aggregate was proven requires the leaf checks. The audit reports it
as drift; delete .github/rulesets/main.json and run praetorctl sync to regenerate it.
sync --remote then adds the aggregate to the live ruleset but never removes a check it already
requires (TestSync_Remote_KeepsLiveLeafChecksAndAddsTheAggregate in
cmd/standardsctl/sync_remote_aggregate_test.go). Remove the leaf checks from ruleset
praetor-main-protection by hand in the repository's ruleset settings, then run
praetorctl plan --remote to read the result back.
Declared status contexts
overrides.branch_protection.status_contexts adds checks that workflow job discovery cannot
derive, such as a commit status posted by a workflow_run evaluator. The ruleset uses the
sorted, deduplicated union of these declarations and the checks derived from pull request
workflows (forge.selectContexts, TestDeclaredStatusContextsRuleset). Other contexts remain
outside that set. Only the repository may declare this key; profiles and facets reject it.
The list accepts at most 256 distinct strings. Each must contain 1 to 255 Unicode characters,
with no control characters or surrounding whitespace. An empty list adds nothing. A declaration
that also names a derived check produces one required check (TestStatusContextsPositiveAndBoundary,
TestStatusContextsNegativeNamedReasons). When using a merge queue, ensure the declared status
also reports for each merge group; explicit declarations remain required after workflow filtering.
praetorctl sync writes the union to the branch ruleset (TestSyncDeclaredStatusContexts).
overrides.branch_protection.status_checks_app pins every required status check in the rendered
repository ruleset (.github/rulesets/main.json and praetorctl sync --remote) to one GitHub
App by adding "integration_id" to each entry (forge.RenderRepositoryRuleset,
TestRenderRepositoryRuleset_Positive_StatusChecksAppGithubActionsPinsEveryCheck). The slug
table (config.StatusChecksAppIntegrationID in internal/config/status_contexts.go) maps
github-actions to 15368 (GET apps/github-actions); an unknown slug fails manifest
validation naming the known slugs (TestStatusChecksApp_Negative_UnknownSlugAndNonString). When
the key is unset, required status checks remain unpinned with no "integration_id" field, and
the rendered bytes are unchanged (TestRenderRepositoryRuleset_Boundary_StatusChecksAppUnsetByteIdentical).
Committed-ruleset validation (forge.ValidateRepositoryRuleset) and live branch protection
evaluation (forge.EvaluateBranchProtection) both check integration_id when the key is set
(TestValidateRepositoryRuleset_StatusChecksApp_Negative_MissingOrWrongIntegrationIDNamesContext,
TestEvaluateBranchProtection_StatusChecksApp_PositiveAndNegativeDrift). Only the repository may
declare this key; profiles and facets reject it (TestStatusChecksApp_Negative_RepositoryOnly).
Review mode
A repository with one maintainer declares review_mode: single_maintainer under
overrides.branch_protection (review policy). A first adoption on a
GitHub origin writes it itself when GITHUB_TOKEN or GH_TOKEN is set and the forge lists exactly
one collaborator with write access, who could not approve their own pull request; the
.standards.yaml entry of the report says so (TestSoloMaintainer_Positive_OneWriter in
internal/adopt/review_mode_test.go). Adoption reads the token from those variables only, never
from the gh session. Without a token, on another forge, or when the forge cannot answer, the entry
says approvals may exceed approvers instead, and a re-adoption warns the same way. A re-adoption of
a one-writer repository whose manifest lacks the review mode warns and names the declaration to
add, because adoption never rewrites .standards.yaml (TestWarnUnsatisfiableReview_Boundary). Adoption, sync and the audit
render the ruleset from the same effective policy, so the file adoption writes, with zero
approvals, no code-owner review, the repository admin role as a pull-request bypass actor and the
aggregate as the only required check, is the one the audit accepts
(TestAdopt_Positive_SoloPathFilteredRulesetIsMergeable in internal/adopt/ruleset_solo_test.go).
Merge queue
A branch protected by a GitHub merge queue starts a run for a merge group, not a pull request, and
a required check reports for a group only when its workflow triggers on merge_group. A ruleset
that requires a context of any other workflow leaves every queued group waiting until the queue's
check timeout, and the queue then removes it. Declare the queue in .standards.yaml:
overrides:
branch_protection:
merge_queue: true
codeql_default_setup: true # only when CodeQL default setup scans the repository
Both keys are repository-only: a catalog archetype or facet that sets either fails to decode
(ArchetypeControls.validate, TestMergeQueueIsRepositoryOnly). With merge_queue: true:
sync --remotewrites a second Praetor-managed ruleset, namedpraetor-main-protection merge queue(forge.MergeQueueRulesetName), that includes the default branch alone and carries themerge_queuerule alone, with the seven parameters GitHub requires at GitHub's documented defaults. The merge method isSQUASHwhile the policy enforces linear history andMERGEotherwise (TestRenderMergeQueueRuleset). The main ruleset (praetor-main-protection,.github/rulesets/main.json) keeps its includes, the default branch andlts-*, and carries nomerge_queuerule (TestMergeQueueRendersOnlyMergeGroupContexts). There is no committed file for the queue ruleset.- The required status checks come only from workflows that declare a
merge_grouptrigger (forge.ForMergeQueue). A workflow that would otherwise require a check but lacks the trigger is left out.adopt,flavor applyandsync(with or without--remote) print a warning per omitted workflow with its path and contexts (forge.QueueOmissions,TestQueueOmissions,TestSync_Remote_DeclaredMergeQueueGetsItsOwnRuleset). Addmerge_group:to the workflow to require it again. A workflow that requires no pull request check is no finding. - Praetor's locked
praetor-docs.ymlandpraetor-api.ymldeclaremerge_group(ghworkflow.HostedGateOn), so theirDocumentation GovernanceandGo API Compatibilitycontexts stay required. An unedited earlier copy refreshes without--forcebecause its text is recorded as a prior (TestPriorDigestsReproduceintools/markdownlint/assets_test.go,TestPriorDigestsintools/apicompat/assets_test.go). - CodeQL default setup never runs for a merge group and has no workflow file, so no check context
can require it. With
codeql_default_setup: truethe ruleset gates pull requests with acode_scanningrule for CodeQL instead (TestMergeQueueRendersCodeScanningForDefaultSetup). A CodeQL workflow of your own that triggers onmerge_groupis an ordinary required check.
The audit fails a stored ruleset that requires a context whose workflow lacks merge_group while
the policy declares a queue, and a live branch that has
an active merge_queue rule and requires such a context
(TestAuditBranchProtection_MergeQueue_Negative, TestLiveMergeQueueFindings). The failure names
the workflow path and the missing trigger. Without a queue the rendering is the previous ruleset
byte for byte (TestNoMergeQueueRendersTheOldRuleset).
The declared merge_queue: true key is the only switch that selects the merge_group checks, in
sync --remote, plan --remote and the audit alike, so plan --remote previews exactly what
sync --remote writes. A merge queue configured only on GitHub does not switch it: the audit
fails a live branch with an active merge_queue rule while the policy declares none, names the
branch and says to declare merge_queue: true and run sync --remote
(TestAuditLiveBranchProtection_MergeQueueWiring,
TestSync_Remote_UndeclaredLiveMergeQueueRulesetIsReportedNotWritten). The merge into a live ruleset (each ruleset by its name) keeps
the operator's merge_queue parameters (timeout, entry counts, grouping, merge method) and every
tool a live code_scanning rule lists, adding only what the live rule lacks
(TestMergeRulesetKeepsLiveMergeQueueParameters, TestMergeRulesetUnionsLiveCodeScanningTools).
It also keeps every live required check, so a check an earlier sync --remote wrote for a
workflow without merge_group stays required: with a declared queue sync --remote reads the
ruleset back, prints a [WARN] naming that workflow instead of reporting the ruleset as
synchronized, and leaves the removal to you
(TestSync_Remote_DeclaredMergeQueueWarnsAboutAStaleCheck). It does drop CodeQL default setup
contexts (CodeQL, CodeQL / Analyze (...), Analyze (...)) from the required checks under
codeql_default_setup: true (TestMergeQueueDropsCodeQLDefaultSetupContexts).
The queue parameters have no policy key: change them on GitHub.
To move a repository that already has a committed ruleset onto a queue, declare merge_queue: true
and run praetorctl adopt or praetorctl flavor apply. The unedited queue-less ruleset is
recognised as Praetor's and refreshed without --force
(TestAdopt_Positive_DeclaringAMergeQueueRefreshesTheUneditedRuleset); a ruleset with a value
edited is kept and warned about, and --force replaces it. praetorctl sync writes the ruleset
only when it is absent and otherwise reports drift, so it does not perform this refresh.
Declaring merge_queue drops the scaffolded CI contexts of the flavor templates (Go, Rust, Node,
JVM, Flutter) from the required checks until their workflows trigger on merge_group: the
templates do not declare it yet, so add merge_group: to the emitted workflow yourself. An edited
workflow is not refreshed by adoption. Removing merge_queue: true refreshes the unedited queue
rendering back to the queue-less ruleset without --force.
GitHub documents that "a merge queue cannot be enabled with branch protection rules that use
wildcard characters (*) in the branch name pattern"
(managing a merge queue).
That is why the queue is not in the main ruleset, whose includes hold lts-*: its own ruleset
targets the default branch alone, so the main ruleset and the lts-* coverage stay as they are.
sync --remote, plan --remote and the audit read and write both rulesets by name, and the union
merge of live includes applies per ruleset, so Praetor never adds a wildcard to the queue ruleset.
A wildcard an operator adds to it stays, and plan --remote, sync --remote and the audit report
it as drift in Merge queue ruleset refs (TestAuditLiveBranchProtection_MergeQueueWiring,
TestPlanRemote_PreviewsWhatSyncWritesForTheQueueRuleset). Removing merge_queue: true does not
delete the queue ruleset: sync --remote and plan --remote print a [WARN] that it still
exists, and the audit fails while it applies, so delete it on GitHub by hand
(TestSync_Remote_UndeclaredLiveMergeQueueRulesetIsReportedNotWritten).
Refreshing a ruleset Praetor rendered earlier
The ruleset is rendered from the effective policy and the workflows present, so it goes stale when
either changes. flavor apply before adoption renders it under the built-in policy; the adoption
that follows pins an archetype policy and adds the documentation gate.
Each run reads the repository's policy and workflows before it writes anything
(forge.ReadRulesetBaseline). The ruleset rendered from that state is the one sync and the audit
accepted before the run. A ruleset that is exactly that rendering, line endings aside, is
refreshed without --force (forge.PriorRulesetDigests, looked up through
util.LookupCanonicalText). Nothing is read back from the file itself.
| Ruleset on disk | Without --force |
Test |
|---|---|---|
| the current rendering, LF or CRLF | verified, left as is | TestApplyFlavor_Boundary_CurrentRenderingIsUnchangedInEitherLineEnding |
| the rendering current before this run, LF or CRLF | refreshed, in its own line endings | TestAdopt_Positive_RefreshesTheRulesetFlavorApplyWrote, TestAdopt_Positive_FlavorApplyAfterAdoptionRefreshesTheRuleset |
| a rendering with one value edited: review count, signature rule, status check | kept and reported | TestAdopt_Negative_ValueEditAfterFlavorApplyIsKept |
| anything else: another name, layout, a final newline | kept and reported | TestAdopt_Negative_EditedRenderingIsKept |
--force replaces a kept ruleset only while the policy enforces linear history or signed
commits: only then does the audit compare the file (rulesetRequired in
internal/adopt/ruleset_audit.go). Under a policy requiring neither, --force keeps it too
(TestReconcileBranchRuleset_Boundary_ForceReplacesOnlyWhilePolicyRequiresIt).
A ruleset rendered for a policy the repository no longer declares is kept too, for example after
.standards.yaml overrides change. praetorctl sync then reports it as drift. Pass --force to
adoption or flavor apply, or delete the file and run sync, which writes the missing ruleset.
Keep a hand-managed ruleset with adoption.decline: [branch-ruleset].
Protected default branch
The ruleset protects the repository's default branch and every lts-* branch
(forge.RepositoryRulesetRefs); a declared merge queue lives in a second ruleset on the default
branch alone (see Merge queue). Adoption, flavor apply, sync (local and --remote) and the
audit resolve that branch the same way (forge.RepositoryDefaultBranch); the first source that
names one wins:
| Source | Written by | Applies |
|---|---|---|
repository.default_branch in .standards.yaml |
the operator, or the command that creates the manifest (below) | whenever it is set |
refs/remotes/origin/HEAD in the checkout |
git clone, git remote set-head origin --auto |
no declaration |
main |
built in | neither |
Resolving the branch asks nothing of the forge, so adopt --dry-run and the audit's ruleset
check stay offline. The audit's live Actions checks do read the
forge, but only when the origin remote names the repository on github.com and a token is
found; praetorctl audit --offline skips them, as the generated pre-commit hook does. A CI
checkout usually has no refs/remotes/origin/HEAD and would resolve main, so a repository
whose default branch is not main declares repository.default_branch; CI then audits the
ruleset a local run writes. The manifest writers record it for you (forge.DefaultBranchToDeclare): adoption,
praetorctl init and harvester onboarding write the checkout's origin HEAD into the
.standards.yaml they create when it is not main
(TestAdopt_Positive_MasterRepositoryRulesetProtectsMaster, TestInit_3D_DefaultBranch,
TestEnsureOnboardingManifest_Positive_DeclaresAMasterOriginHead). Adoption never rewrites an
existing manifest: when one declares no branch and the origin HEAD is not main, it warns, names
every file it renders for the branch and the line to add
(TestAdopt_Negative_UndeclaredBranchInAnExistingManifestIsWarned). The hosted gate workflows
render for the branch too (When the workflow runs),
so a manifest that declines branch-ruleset is still warned while a hosted gate is enabled
(TestAdoptWarnsAnUndeclaredBranchTheHostedGatesRenderFor in
internal/adopt/workflow_branch_test.go).
A declaration outside config.ValidBranchName (1 to 128 letters, digits, ., _, / or -,
no ..) fails the manifest load. An origin HEAD outside it fails the resolution instead of
falling back to main (TestRepositoryDefaultBranch_Negative_UnusableSourcesAreErrors), and
fails adoption, init and onboarding before they write a manifest.
The ruleset name praetor-main-protection and the path .github/rulesets/main.json are the same
for every default branch, because a live ruleset is matched by them.
Earlier Praetor versions rendered main whatever the default branch was. In a repository whose
default branch is something else, that file is still Praetor's unedited rendering: adoption and
flavor apply refresh it to the default branch without --force (forge.PriorRulesetDigests,
TestAdopt_Positive_RefreshesTheEarlierMainRulesetOfAMasterRepository). Until then sync and the
audit report it as drift.
Large repositories
Adoption discovers verification inputs (Makefiles, manifests, scripts) through a bounded
walk of the target: 65536 directory entries, 128 files and 32 levels of depth by default
(util.DefaultDiscoveryEntries in internal/util/discovery_bounds.go). The entry count
covers every directory the walk enters. Version-control, dependency, build-output and agent
scratch directories are skipped by name (skipVerificationDirectory in
internal/adopt/verification_inputs.go); generated output under any other name, such as a
built documentation site, counts. A repository above those bounds fails with an error that
names the flag and the manifest key raising the bound:
verification discovery exceeds 65536 entries; raise max_entries with --verification-max-entries, up to 200000, or declare verification.max_entries in .standards.yaml so every run, the audit's hooks and CI included, reads it
Raise a bound explicitly instead of trimming the tree. Declare it in .standards.yaml when the
repository is large, so every run that walks it reads the same bound. The hooks and CI jobs
adoption writes run praetorctl audit with no flag, and its Paperclip gate walks the tree too:
The strict manifest decoder refuses an unknown key, a non-integer value and a value outside
1..its ceiling, naming the key (internal/config/verification.go,
TestVerificationPolicy_Negative). A flag overrides the declared bound it names for its own
run; every other bound keeps the declared value or the default
(adopt.ResolveVerificationLimits in internal/adopt/verification_limits.go):
standardsctl adopt --dry-run --path /path/to/large-repo \
--lock-source-root=/path/to/praetor --verification-max-entries=131072
--verification-max-entries also raises the editor language scan, which reads the
same tree for the IDE step
(editor capabilities).
--verification-max-files and --verification-max-depth raise the other two bounds.
Each value is validated against its ceiling (200000 entries, 512 files, 64 levels;
internal/util/discovery_bounds.go). A value past its ceiling is refused, and an unset flag
keeps the declared bound or the default. A bound already at its ceiling reports that instead
of naming the flag. The flags apply to single-repository adoption; batch --all-missing takes
none, so it walks each repository under its declared bounds or the defaults.
praetorctl paperclip harness detects the repository's languages with the same walk and
takes the same three flags, and so does praetorctl audit, whose Paperclip gate compares the
harness with the synthesis for those languages. Both read the declared bounds first:
praetorctl paperclip harness --path /path/to/large-repo --verification-max-entries=131072
praetorctl audit --config=/path/to/large-repo/.standards.yaml --verification-max-entries=131072
Tests: internal/adopt/large_repo_bounds_test.go,
internal/adopt/verification_manifest_test.go,
TestPaperclipHarness_VerificationLimitFlags in cmd/standardsctl/adopt_limits_test.go and
TestAuditPaperclip_VerificationBoundFromManifest in
cmd/standardsctl/audit_paperclip_test.go.
What Adoption Scaffolds Automatically
Adoption is a chain of steps, run in this order (adoptSteps in internal/adopt/adopt.go).
Each row is one step, named as adoption.decline and the adoption report name it, with the
files it writes in a repository that declares the default facets. Which of them are mandatory
and which a repository may decline is in
adoption verification. A step writes only what its inputs call
for: the API gate needs a tracked go.mod, the REUSE gate a REUSE.toml or .reuse/dep5, and a
flavor only where one matches. praetorctl adopt --dry-run --lock-source-root=/path/to/praetor
prints the exact list for your repository, about a hundred files for a Go module, before it
writes any (TestAdoptionScaffoldTable_MatchesTheStepChain in
internal/adopt/scaffold_table_test.go keeps this table equal to the chain).
| Step | What it writes |
|---|---|
manifest |
.standards.yaml: profile, facets, repository identity, register sources |
git-ignore |
.gitignore: the managed block for private working directories and gate worktrees |
lockfile |
.standards.lock: profiles and facets pinned to content digests of the source bundle |
policy-catalog |
.config/archetypes/: the pinned profile and facet texts audit reads |
baseline |
.standards-baseline.json: the recorded legacy debt |
agent-harness |
AGENTS.md; CLAUDE.md, .cursor/rules/hiss-invariants.mdc, .github/copilot-instructions.md, .windsurfrules, .gemini/GEMINI.md, .codex/rules.md; the register skills in .agents/skills/ and their .claude/skills/ copies |
dev-container |
.devcontainer/ bundle (devcontainer.json, Dockerfile.praetor, source companions); the .gitattributes managed block |
documentation-gate |
tools/markdownlint/, tools/figures/, .github/workflows/praetor-docs.yml (facet docs:seo-portal) |
api-compatibility-gate |
tools/apicompat/gate/, .github/workflows/praetor-api.yml (facet api:public-contract, tracked go.mod) |
makefile |
Makefile with the verify-all target |
editors |
.editorconfig, .vscode/, .idea/, .zed/, .helix/, .fleet/, .nvim.lua, lua/, .dir-locals.el, standards.sublime-project, .clang-tidy where the profile calls for it |
formatter-ignore |
.prettierignore entries for managed artifacts, where a formatter is configured |
renovate-ignore |
the managed-file ignore rule in an existing Renovate configuration; none is created |
actionlint-labels |
.github/actionlint.yaml runner labels |
contributing |
CONTRIBUTING.md |
pull-request-template |
.github/pull_request_template.md |
security-policy |
SECURITY.md |
adr |
docs/adr/README.md and docs/adr/0000-template.md |
asdecided |
.standards/cache/decisions/ and project MCP files for Claude, Gemini, OpenCode and Continue when ADR records exist |
readme |
the governance block in an existing README.md |
reuse-gate |
.github/workflows/reuse.yml, where the root carries REUSE.toml or .reuse/dep5 |
working-dir-and-flavor |
the private .workingdir/ ledger, the detected flavor's templates (flavors), and .gosec.json whenever git tracks a go.mod |
branch-ruleset |
.github/rulesets/main.json, the branch ruleset with the required status checks |
labels |
.config/labels.yaml, the label taxonomy |
paperclip |
.paperclip/harness.json and .paperclip/rules.md |
agent-definitions |
.agents/agents/repo-auditor.md and repo-gatekeeper.md, projected to .claude/agents/, .github/agents/, .gemini/agents/ and .codex/agents/ |
git-hooks |
lefthook.yml, .config/lefthook/, .config/agent/checkpoint.json, .config/agent/hooks/block_evasion.py, the installed .git/hooks/pre-commit |
agent-hooks |
the hook settings of .claude/settings.json, .gemini/settings.json and .codex/hooks.json for the clients agent_clients selects |
The asdecided step and its export and pin-check commands are described in
Decision recall.
The groups with behavior of their own:
.standards.yaml: Declarative repository manifest containing profile, facets, tool versions, and policy locks..standards.lock: Cryptographic SemVer lockfile binding your repo to exact governance standard releases..standards-baseline.json: Technical debt ratcheting baseline. A first adoption records the existing infractions (e.g. legacy loop bounds, unwrapped errors) so legacy code compiles while new code is strictly gated. A re-adoption keeps the file; see The baseline on a re-adoption.AGENTS.md+ 6 Vendor Targets: Canonical agent operating harness transpiled toCLAUDE.md,.cursor/rules/*.mdc,.github/copilot-instructions.md,.windsurfrules,.gemini/GEMINI.mdand.codex/rules.md.agent_clientsin.standards.yamllimits these to the clients the repository uses (agent client selection)..devcontainer/devcontainer.json: Multi-architecture container configuration pinned to verified base images. Adoption also pins the bundle's Dockerfile to LF: the managed block at the end of.gitattributesopens with.devcontainer/Dockerfile.praetor text eol=lf, so a Windows checkout withcore.autocrlf=trueverifies the same bytes. The rule names that one file, so the repository's own files in.devcontainer/keep their attributes. Every other line of an existing.gitattributesis kept, and a rule of the repository's own inside.devcontainer/that matchesDockerfile.praetorand contradictstext eol=lfstops adoption before its first write (checkout line endings).- Multi-IDE Configs: Workspace settings for every supported editor, or only the ones
editorsin.standards.yamlnames (editor selection). - Makefile & LeftHook: Automated pre-commit hooks and standard verification targets (
make verify-all). - Documentation gate (the
docs:seo-portalfacet, in the default facet set): the locked Markdown gate undertools/markdownlint/, the interactive figure engine undertools/figures/,docs-lintanddocs-figurestargets onverify-all, the figure engine's rules in the managed block at the end of.gitattributes, and.github/workflows/praetor-docs.ymlwith its required Documentation Governance context (documentation governance, figures). The figure checks skip, saying why, until the repository adds its first spec underdocs/figures/. Adoption stops, even with--force, when a file the repository already had at one of the engine's paths undertools/figures/differs from it; move that file aside and rerun. Remove the facet to opt out. - Go API compatibility gate (the
api:public-contractfacet, in the default facet set): the gate programtools/apicompat/gate/main.goand.github/workflows/praetor-api.ymlwith its required Go API Compatibility context, which compares the exported API of every Go module with the pull request's base or the newest root release tag (Go API compatibility gate). Adoption writes it only where git tracks ago.mod, at the root or nested; a repository declaring the facet without one gets no gate and no required context, and adoption and audit say that no API compatibility checker runs for its languages. Adoption stops, even with--force, when a file the repository already had at either path differs from it. Remove the facet to opt out. - actionlint runner labels: when a workflow adoption writes, the documentation gate's, the
Go API compatibility gate's or a flavor's CI workflow, runs on a runner label actionlint does
not know, adoption declares it under
self-hosted-runner.labelsin.github/actionlint.yaml(or an existing.github/actionlint.yml), so a repository that lints its workflows with actionlint accepts them. It only adds labels and leaves a file it cannot patch safely untouched, with a warning naming the labels (actionlint runner labels,internal/adopt/actionlint.go). Declineactionlint-labelsto opt out. - REUSE gate: when the repository root carries
REUSE.tomlor.reuse/dep5, the two files REUSE Specification 3.3 reads for the whole tree (aLICENSES/directory alone declares nothing: it may hold third-party notices, #1244), adoption writes.github/workflows/reuse.yml, whose REUSE lint job runsfsfe/reuse-action@v6within a 10-minute timeout in the shared hosted gate shape (internal/ghworkflow/hostedgate.go: pull request activity and a push to the default branch the branch ruleset protects, a first step that fails a draft run by design, and a not-draft condition on every later step). That branch isrepository.default_branch, else the origin HEAD, elsemain, and adds areuse-lintpre-commit job tolefthook.ymlthat runsreuse lintwith reuse 6.x, skips where reuse is not installed, and fails on another major. The action tag is the one pin both read (supplychain.ReuseActionVersionininternal/supplychain/reuse_lint.go, also the tagpraetorctl bumpexpects). A root with neither file gets neither; one whoseLICENSES/and file headers label it the REUSE way without either file gets a warning, from adoption and from every audit, that namesREUSE.toml(supplychain.ReuseHeadersOnlyNotice). When the root loses its declaration, adoption removes its uneditedreuse.yml, so the ruleset stops requiring a check that cannot pass, and migrateslefthook.ymlto the matching rendering; it does the same in reverse when the root gains them. An uneditedreuse.ymlrendered for another default branch is refreshed. An editedreuse.ymlis kept,--forceincluded, with a warning when the root no longer declares REUSE (internal/adopt/reuse_gate_test.go). Declinereuse-gateto drop both: no workflow and noreuse-lintjob is rendered, a declined step removes an uneditedreuse.ymlan earlier adoption wrote, andlefthook.ymlmigrates to the rendering without the job (internal/adopt/reuse_decline_test.go).
Organisation .github repositories
In GitHub organisations, a repository named .github holds default community health files
(such as CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, SUPPORT.md, pull_request_template.md,
and issue templates). GitHub serves these files as fallback defaults for every repository in the
organisation that does not define its own copy.
When adopting a repository named .github (matched case-insensitively against repository.name in
.standards.yaml, or the repository directory name), Praetor treats organisation community health
files as declined by default:
contributing(CONTRIBUTING.md)pull-request-template(.github/pull_request_template.md)security-policy(SECURITY.md)
The adoption report explicitly names each implicit decline with the reason:
organisation community health files.
Explicit declarations in adoption.decline continue to take effect. There is no opt-in mechanism to
scaffold or overwrite these files in an organisation .github repository, because the organisation
maintains its own authoritative copies. Existing community health files in such repositories are never
modified, even when passing --force (TestAdopt_DotGitHub_ExistingContributing_ByteIdenticalUnderForce
in internal/adopt/dotgithub_health_files_test.go). Furthermore, praetorctl audit skips comparing
these files against Praetor scaffolds in a .github repository, allowing audits to pass without scaffold
copies (internal/adopt/declined.go, cmd/standardsctl/audit_dotgithub_health_test.go).
The audit engine pin on a re-adoption
A re-adoption writes the managed files of the engine that runs it. A repository whose CI runs
the audit engine it pins in tools/go/go.mod (a tool github.com/cordanaLLM/praetor/cmd/standardsctl
directive and a require of the engine module) keeps that pin: adoption does not edit the tools
module or its go.sum. When the pin names another build than the running engine, adoption warns
with the file, the pinned version, the running engine and the command that moves the pin in the
same change, go get -modfile=tools/go/go.mod github.com/cordanaLLM/praetor@<engine>. Without it,
CI's audit with the old engine refuses the refreshed files as differing from its locked assets
(reportStaleEnginePin in internal/adopt/engine_pin.go, TestReportStaleEnginePin_Positive_OlderPinIsReported).
A pin or an engine that identifies no build is not compared.
Exceptions on a re-adoption
A re-adoption keeps the manifest it finds, but it records the dated exceptions a first adoption
records, in the same exceptions list. When the release workflows fall short of the supply chain
the policy declares and the manifest holds no HISS-11 entry, as a manifest an older engine wrote
does not, the run adds the entry a first adoption adds and warns that it did. An entry the
manifest already declares, renewed or narrowed by hand, stands. The declared-linters entries for
declared linters no workflow runs are recorded the same way (syncDeclaredLintersExceptions and
withSupplyChainException in internal/adopt, TestAdopt_Readoption_RecordsTheSupplyChainGap).
The baseline on a re-adoption
Adoption records .standards-baseline.json once. A repository that already has the file keeps it
on every later run, --force included, so a re-adoption never turns a violation the baseline does
not record into accepted debt (reconcileBaseline in internal/adopt/adopt.go).
| Baseline file | Flags | What the baseline step does |
|---|---|---|
| absent | default (--record-baseline=true) |
scans and records the first baseline; a --dry-run scans and writes nothing |
| absent | --record-baseline=false |
writes nothing and warns that no baseline was recorded |
| present | default | keeps the file byte for byte, rescans, and reports the verdict praetorctl baseline --verify gives |
| present | --record-baseline=false |
keeps the file and skips the scan |
| present | --rerecord-baseline |
replaces the file with the rescan under the rules of praetorctl baseline --record |
The kept-baseline verdict is one line under the debt summary; the MCP standards_adopt tool prints
the same line (adopt.BaselineRatchet.Line). praetorctl adopt prints text only: Go code that
calls adopt.Adopt reads the verdict from the BaselineRatchet field of the AdoptReport it
returns (Passed, Recorded, Active, Unbaselined). A baseline entry that matches nothing in
the tree is reported as a warning and leaves the verdict a pass.
When the repository carries a finding the baseline does not record, or more findings than it records, the verdict is a rejection. Adoption still reconciles the files it owns and adds a warning that lists the findings, but the rejected baseline is pending, as a Verification Gate short of ready is (adoption verification): an applied run ends without the success line and names the verdict and what resolves it.
Repository onboarded: <owner>/<name>; not ready yet: Debt Baseline. See the warnings above.
Debt Baseline: Baseline kept, not re-recorded; HISS-13 ratchet rejects: 1 active infractions against 0 recorded, 1 not in the baseline
Resolve: fix the findings, or accept them deliberately with 'praetorctl adopt --rerecord-baseline --allow-increase --reason=<why>' or 'praetorctl baseline --record --allow-increase --reason=<why>'; until then praetorctl audit rejects the repository
The run exits 0, as every run without an error does, a pending Verification Gate included:
praetorctl audit is the command that fails on the debt. The praetor-adopt action carries the
same closing lines in its report output and in the job summary and keeps that exit status, so
its step passes; adopt.yml runs the audit as the next step, which fails the job before anything
is committed (PendingPillars and PendingBaseline in internal/adopt/report.go,
printAppliedOutcome in cmd/standardsctl/adopt.go). A dry run closes with the plan line and
shows the verdict under the debt summary. --all-missing prints one summary per repository
with its warnings and no closing line.
--rerecord-baseline is the explicit route to a new baseline. A rescan that finds more infractions
than the file records is refused, the file is kept and the error lists the added findings, unless
--allow-increase --reason=<why> accepts the increase; the reason is stored in the file as
increase_rationale. A rescan that finds the same debt keeps the file byte for byte, and one
without a resolved repository identity keeps the repository the file records. --allow-increase
and --reason without --rerecord-baseline, and --rerecord-baseline with
--record-baseline=false, are refused before anything is written (adopt.ErrBaselineOptions).
The MCP tool has no re-record: accepting debt is a command an operator runs. A baseline file that
cannot be parsed fails the step on every path; restore it from version control, or delete it to
record a first baseline.
Tests: TestAdopt_Negative_ReAdoptionDoesNotAbsorbNewDebt, TestAdopt_RerecordBaseline_3D and
TestAdopt_Boundary_BaselineDryRunWritesNothing in internal/adopt/baseline_readopt_test.go,
TestReconcileBaseline_KeepsUnchangedBaseline_3D in internal/adopt/baseline_identity_test.go,
TestAdopt_RejectedKeptBaselineIsPending_3D and TestPendingPillars_Baseline_3D in
internal/adopt/baseline_pending_test.go, TestAdoptBaselineFlags_3D,
TestAdoptRejectedKeptBaseline_3D and TestAdoptAllMissing_PassesTheRerecordFlags in
cmd/standardsctl/adopt_baseline_flags_test.go, and
TestPraetorAdoptAction_Boundary_PendingBaselineReachesReportAndSummary in
internal/forge/adopt_action_test.go.
What a forced re-adoption changes
praetorctl adopt --force (MCP: "force": true) refreshes a governed repository; it does not reset
it to the scaffold. It overwrites a file only when praetorctl audit compares that file's bytes, so
the audit fails until the file holds them, and merges or keeps every other file. adopt --help
states this contract (adopt.ForceContract in internal/adopt/force_contract.go,
TestAdoptHelpStatesTheForceContract in cmd/standardsctl/adopt_force_contract_test.go), and the
standards_adopt schema restates each clause for agents
(TestCreateAdoptTool_ForceStatesTheContract in
cmd/standards-mcp/tools_adoption_force_test.go). --force rebuilds the lock, so it needs
--lock-source-root (MCP: source_root), a dry run included
(TestAdoptForceNeedsLockSourceDryRunIncluded). Preview a forced run with --dry-run first.
Every message that tells you to re-run adopt with --force prints the whole command,
praetorctl adopt --force --lock-source-root=<praetor checkout>, with the run's own lock source in
place of the placeholder when it was given one (adopt.ForceCommand, TestForceRemedies in
internal/adopt/force_contract_test.go).
Three rules hold on every run, --force or not:
- An absent file is created. A file that already holds what adoption writes, line endings aside, is
verified. An unedited text an earlier release wrote is refreshed without
--force(refreshPriorScaffoldininternal/adopt/scaffold.go). - Any other overwrite is listed under
Files Replacedwith actionreplace, never as created; an editor JSON merge is listed asmerge. Both carry the line delta and a backup under.workingdir/adopt-backups/<UTC stamp>/<path>when git ignores that path, and a dry run lists them as planned (replaceExistingininternal/adopt/replace.go; the replaced files item below has the details). - Adoption removes no file of the repository's own. A
<file>.bakan earlier release left beside a hook file is reported and kept (warnLegacyHookBackupininternal/adopt/replace.go).
| Class | Files | Without --force |
With --force |
|---|---|---|---|
Audit-locked, rebuilt under --force |
.standards.lock and the texts it pins under .config/archetypes/ |
a valid lock is kept, and one that pins only unmodified earlier catalog texts is re-pinned to --lock-source-root when no value changes; an invalid lock fails the run |
rebuilt from --lock-source-root, an invalid lock included (TestReconcileLockfile_Positive_ForcedRebuildOverEditedLockReplacesWithBackup) |
documentation gate files under tools/markdownlint/ and tools/figures/, and .github/workflows/praetor-docs.yml |
a drifted file is kept with the warning preserved, not verified (praetorctl adopt --force --lock-source-root=<praetor checkout> regenerates it) |
replaced; a file under tools/figures/ that predates adoption still stops the run (reconcileManagedFamily in internal/adopt/managed_family.go) |
|
.github/rulesets/main.json |
kept with a warning; the rendering current before the run is refreshed | replaced while the policy enforces linear history or signed commits, kept under any other policy (TestReconcileBranchRuleset_Boundary_ForceReplacesOnlyWhilePolicyRequiresIt) |
|
the .devcontainer/ bundle |
an existing devcontainer.json keeps the bundle as it is, with a warning |
regenerated; each image the bundle records is kept or refreshed, and reported (prepareAdoptDevContainer in internal/adopt/devcontainer.go, TestReconcileDevContainer_Positive_ForcedEditIsReplacedWithBackup) |
|
the documentation gate block in the Makefile, the managed block at the end of .gitattributes |
an edited block fails the run | the block is restored (TestReconcileDocumentationMakefile_Boundary_DeltaListsOnlyInBlockLines, TestReconcileMakefile_Positive_ForceRestoresEditedBlockOnAppendPath, TestReconcileGitAttributes_Positive_EditedBlockReplacedUnderForce) |
|
| Audit-locked, recompiled every run | vendor context files such as CLAUDE.md, persona copies under .claude/agents/, register skill copies under .claude/skills/, plugin copies |
compiled from AGENTS.md and the canonical personas and skills; before a vendor context file is recompiled, the text it holds that AGENTS.md lacks is folded into AGENTS.md below its instructions (a file a person wrote whole, an edited projection only its added lines), so the new projection carries it; the old file is backed up |
the same (TestAdopt_Positive_HandEditedVendorFileOnPlainRunReplacedWithBackup, TestFoldAuthoredVendorContext_Positive_KeepsAuthoredText) |
| Managed block, every run | the .gitignore and .prettierignore tail blocks, the README governance block, the Renovate rule for the managed files, the text register block in AGENTS.md |
Praetor's block or rule is written; every line outside it is kept | the same |
| Merged | agent hook settings: .claude/settings.json, .gemini/settings.json, .codex/hooks.json |
missing Praetor handlers are merged in; every other entry is kept | the same (TestReconcileAgentHooks_Positive_MergesPreservingForeignContent) |
editor JSON: .vscode/*.json, .zed/*.json, .fleet/*.json, standards.sublime-project |
kept; the warning names the missing managed values | merged, every repository key kept; a file adoption cannot merge is kept with a warning (TestAdopt_Positive_ForceMergesEditorJSONKeepingAdopterKeys) |
|
the AGENTS.md harness |
kept; only its text register block is spliced from the manifest | regenerated, keeping the preamble, invariant rows under the repository's own IDs and the instructions below the harness; an edited generated line is a replace (TestAdopt_AgentsMD_ForceKeepsRepositoryAdditions) |
|
.paperclip/harness.json |
a harness the repository edited is kept; a platform naming another repository is a warning |
only that platform is reset to this repository's <owner>/<name>; .paperclip/rules.md stays as it is, a deleted one included (TestAdoptForcePatchesOnlyHarnessPlatform) |
|
.standards.yaml |
profiles, facets and every other declaration kept (praetorctl profile set changes them); register.sources added, or re-bound only to a harness the run writes |
the same | |
| Generated, not audit-verified | the anti-evasion interceptor, the checkpoint scripts and policy, the personas .agents/agents/repo-auditor.md and repo-gatekeeper.md, the register skills and their upstream LICENSE files under .agents/skills/ (.agents/skills/<name>/SKILL.md and LICENSE), the label taxonomy, CONTRIBUTING.md, the pull request template, SECURITY.md, editor files that are not JSON |
an edited file is kept with a warning that counts the lines regenerating it would change | the same; delete the file and re-run adopt to regenerate it (TestAdopt_Negative_EditedEvasionHookKeptUnderForce) |
lefthook.yml |
a current or earlier Praetor rendering is written, migrated or verified; any other configuration is kept and not activated | the same, and a CRLF checkout of the current rendering is rewritten with LF bytes (TestAdopt_Negative_ForeignLefthookKeptWithAndWithoutForce) |
|
the pre-commit hook written when lefthook cannot install |
a hook Praetor did not write is kept | the same (TestAdopt_Hooks_ForeignPreCommitKeptWithAndWithoutForce) |
|
| Written only when absent | flavor templates such as rustfmt.toml, developer-owned editor files such as .nvim.lua, the ADR directory |
an existing file is kept | the same |
A caller that ran adopt --force to reset a repository to the scaffold gets this refresh instead:
delete the files to regenerate, then run adopt. scripts/adopt_repos.sh runs
adopt --force --lock-source-root=<its checkout> for every target (scripts/test_adopt_repos.py),
and the praetor-adopt action passes the checkout it builds standardsctl from
(TestPraetorAdoptAction_ForcedRunCarriesTheLockSource in internal/forge/adopt_action_test.go).
Loaded remotely, the action has no .git, so a forced run first checks out praetor with git
(Inputs, the binary, and the report output).
What Adoption Reads Before It Writes
- Repository identity.
repository.ownerandrepository.namecome from theoriginremote only (util.ResolveRemoteIdentity). The checkout path is never read as identity, because its parent directory names where the checkout sits, not who owns the repository (TestAdoptionManifest_CheckoutLayoutIsNotIdentity). The remote must be a network remote naming a host and<owner>/<repo>(util.ReadOriginRemote); a local path orfile://origin names where a copy sits and counts as no remote (TestReadOriginRemote_Negative_LocalRemoteIsNotIdentity). Without such a remote, both stay empty, the report warnsrepository identity unresolved, the checkpoint lifecycle is not installed, and the README badge block is skipped. The Paperclip harnessplatformandrules.mdheading name the identity.standards.yamldeclares, else the origin remote's; with neither, adoption writes no harness, the report recordsPaperclip harness not written, and an existing harness stays as it is.praetorctl paperclip harnessfails the same way instead of writing a guessedcordanaLLM/<dir>(TestSynthesizeHarness_Negative_NoIdentityIsAnError). NocordanaLLM/<name>default and no<parent>/<name>guess reaches any adopted file; a flavor body that names the repository, such as the scaffolded.gitleaks.toml, names the checkout directory alone (TestAdopt_NoRemoteWritesNoGuessedPlatform,TestApplyFlavor_Negative_CheckoutLayoutIsNotOwner). A remote read git does not answer, such as a cancelled run, fails adoption instead of counting as no identity.repository.visibilityis always left unset because adoption cannot observe it offline (TestAdoptionManifest_UnresolvedIdentityStaysEmpty). - Recovering an unresolved identity. Until both fields are set,
praetorctl auditfails withManifest repository owner and name must not be empty(cmd/standardsctl/audit.go), so the audit pre-commit hook blocks commits. Adoption never rewrites an existing manifest, so setrepository.ownerandrepository.namein.standards.yamlby hand. Add theoriginremote too and re-runpraetorctl adopt: the re-run installs the checkpoint policy and evaluator, which need that remote, writes the Paperclip harness, and reconciles the README block from the fields you set. A re-run that finds the remote while the manifest still names no identity installs the checkpoint lifecycle and the harness but leaves the manifest and the README as they are (TestAdopt_RerunCompletesOnceIdentityIsSet). - Hosting forge.
repository.forgein.standards.yamlnames the forge that hosts the repository:github,forgejoorgitlab; the strict manifest decoder refuses any other value with the key named (internal/config/forge.go). It selects the push rows of the Paperclip harness: only Forgejo's harness carries the AGitrefs/forpush. Without the key, a repository whoseoriginremote namesgithub.comis GitHub. Any other host, or no network remote, names no forge kind, since Forgejo and GitLab instances run under any host name, so adoption writes no Paperclip harness and its report names the key,praetorctl paperclip harnessrefuses, andpraetorctl auditfails until the key is declared (TestForge_Boundary_DefaultOnlyForGitHubHost,TestAdoptHarness_Negative_UndeclaredForgeWritesNoHarness,TestAuditPaperclip_Boundary_OwnedAbsentRulesAndUndeclaredForge).
- Profile. The profile an existing
.standards.yamldeclares outranks--profile, which outranks file markers. A conflicting--profileis reported as ignored (TestAdopt_DeclaredProfileGovernsAdoption). Adoption never rewrites a declared profile;praetorctl profile setdoes (Changing the profile, facets or catalog). - Declared HISS exceptions. A repository whose own standard allows a construct a HISS
directive bans declares that exception under
hiss.exceptionsin.standards.yaml, naming the repository document that records it. The one exception isc_goto_cleanup: a C or C++gotothat jumps forward to the one cleanup label of its function.
hiss:
exceptions:
c_goto_cleanup: docs/adr/0003-cleanup-goto.md
c_goto_cleanup_labels: [unwind] # optional, beside cleanup, out, err and fail
The exception holds only while that document is a regular file in the repository. The audit's
native HISS-01 scan then reports every goto except one that meets all five conditions
(internal/hiss/cleanup_goto.go):
- Forward. The
gotoline comes before its label. - Same function. The label is in the function body that holds the
goto. - Single level. That function defines exactly one label, so it has one cleanup exit.
- Body level. The label sits directly in the function body, outside every nested block, so the jump leaves blocks and never enters one.
- Name. The label is
cleanup,out,errorfail, or is listed inc_goto_cleanup_labels: C identifiers of at most 64 bytes, at most 8 of them, declared only besidec_goto_cleanup.
A backward goto, a jump to a label inside a nested block, a second label, another name and
a computed goto all stay findings (TestCleanupGoto_Negative_OtherGotosStillFail). The scan
finds a goto anywhere in a statement, if (rc) goto out; included, but not in a string or a
comment (TestCleanupGoto_Negative_OneLineGotoIsAGoto); it reads a label only where it opens a
line. In a macro body it reads a goto only where a line of the body starts with it. praetorctl audit, the
gate's HISS stage, praetorctl baseline, the MCP audit, public-repository verification and
adoption's legacy-debt scan all read the declaration through
config.Manifest.CleanupGotoException, so they judge the same tree the same way
(TestCleanupGotoExceptionReachesTheAudit, TestRunHissStage_CleanupGotoException). HISS-01
in the AGENTS.md and Paperclip harnesses states that rule in place of the C/C++ zero-goto
clause, rendered from the scan's own text (hiss.CleanupGotoRule), and keeps Go's own ban.
Without the declaration every goto is a finding and the ban stays. A declaration whose
document is missing keeps both and warns, naming the document, in the adopt report,
praetorctl paperclip harness, praetorctl audit, praetorctl baseline, the gate stage and
the MCP audit (TestAdoptHonoursDocumentedCleanupGotoException,
TestPaperclipHarness_WarnsOnUndocumentedException). The document path must be a clean
repository-relative path of at most 256 bytes, and an exception key praetor does not know
fails the manifest (TestHISSExceptions_Negative_BadDeclarationsFail,
TestHISSExceptions_Labels). The section is repository-only: no profile or fleet layer
declares an exception, and the effective policy does not change with it
(internal/config/hiss_exceptions.go).
- Existing files. An existing manifest must parse, or adoption fails and leaves it
unchanged. Every other existing scaffold is compared with what adoption would write:
a match is reported as verified, a difference as differs from the scaffold with a
warning, and the file is kept (TestScaffoldFile_ReportsDriftInsteadOfVerified). --force never
overwrites an editor file: it merges the managed values into a JSON one, keeping every adopter
key, and keeps any other differing file with a warning
(editor capabilities).
- What --force overwrites (every file class:
what a forced re-adoption changes). Only a file the audit compares byte for byte, so that the audit
fails until it holds the scaffold (scaffold.auditLocked in internal/adopt/scaffold.go):
the documentation gate's and the Go API compatibility gate's managed files and workflows, and
the branch protection ruleset while the policy requires one. Every other generated file is not audit-verified and is kept under
--force too, with the note differs from the scaffold adoption writes (-N/+M lines); not
audit-verified; kept and a warning: the agent anti-evasion interceptor, the checkpoint
scripts, the canonical personas .agents/agents/repo-auditor.md and repo-gatekeeper.md,
the label taxonomy and the checkpoint policy (TestAdopt_Negative_EditedEvasionHookKeptUnderForce,
TestAdopt_Negative_EditedPersonaKeptUnderForce,
TestAdopt_Boundary_ForceKeepsDriftedCheckpointScriptLifecycleUnavailable). So is a
pre-commit hook praetor did not write (git hooks).
To regenerate one of them, delete it and re-run adopt. So is a lefthook.yml that is neither a
current nor an earlier Praetor rendering: it is kept and not activated, and the skip names the
generated jobs it lacks and the jobs it adds
(the lefthook.yml adoption writes).
- Replaced files. When --force overwrites a drifted scaffold, the report lists it under
Files Replaced with action replace, never as created. The entry carries a line delta
(-removed/+added lines and the first three removed lines; a replace that only reorders
lines reads -0/+0 lines, N moved with the first three moved lines, so a reordered table
names the rows that moved: util.LineDeltaOf, tests in internal/util/line_delta_test.go)
and where the prior bytes went:
.workingdir/adopt-backups/<UTC stamp>/<path>, written only when git check-ignore
confirms that path is ignored. The git-ignore step, which writes the managed
.workingdir/ rule, runs right after the manifest step and before every step that can
replace a file, so a first adoption keeps a backup of every file it replaces, editor merges
included, and a dry run plans the same backups without writing the rule or a copy
(adoptSteps in internal/adopt/adopt.go, backupIgnored in internal/adopt/replace.go,
tests in internal/adopt/first_adoption_backup_test.go). Without that confirmation, for
example when adoption.decline names git-ignore and the repository's own rules do not
ignore .workingdir/, the file is replaced without a copy, its entry says no backup, and
one warning per run gives the reason. A file that differs only in its line endings is
verified, not replaced (TestScaffoldFile_Positive_ForceReplacesWithBackupAndDelta,
TestScaffoldFile_Boundary_DryRunPlansReplaceAndDeltaTruncates,
TestScaffoldFile_Boundary_CRLFOnlyDifferenceIsNotReplaced). Under --force, a backup
root that is a symlink or sits behind one fails the run before the first write
(preflightForceBackupRoot in internal/adopt/replace.go,
TestAdopt_Negative_ForceRefusesSymlinkedBackupRootBeforeAnyWrite). Agent hook merges keep
their copy in the same place (agent hooks). A backup written before
the private .workingdir/ exists creates it owner-only (0700), as the ledger setup does
(TestBackupExisting_Positive_CreatesPrivateWorkingDir). The standards_adopt MCP result
lists the same entries under Replaced Files (Planned Replacements in a dry run), and
neither report repeats a replaced file among the reconciled ones
(AdoptReport.ReconciledNotReplaced in internal/adopt/scaffold.go,
TestServer_Positive_AdoptForceReportsReplacedFile). The --all-missing summary counts
replaced files on their own line.
- Audit-locked files. Files whose exact bytes audit checks are rewritten, and an overwrite
of edited bytes is a replace with its delta and backup, never a create. Under --force that
covers .standards.lock, a pinned file under .config/archetypes/, a DevContainer bundle
file, a documentation family file such as tools/markdownlint/markdownlint-cli2.yaml, an
edited documentation gate block in the Makefile, whose delta lists only the lines inside
the block, and an edited managed attribute block at the end of .gitattributes; without
--force either edited block fails the run, the attribute block before the first write
(preflightManagedAttributes in internal/adopt/gitattributes.go). A disable of
docs:seo-portal refuses to touch an edited Makefile block, and refuses an edited attribute
block where the disable would remove it, that is, where adoption.decline lists
dev-container; otherwise the attribute block stays for its DevContainer rule and follows
the rule above. On every run, --force or not, it covers a vendor context file such as
CLAUDE.md and a persona copy such as .claude/agents/praetor-auditor.md that holds a hand
edit (recordProjections in internal/adopt/vendor_targets.go,
internal/adopt/persona_copies.go). A file that already holds its bytes is verified. Earlier
Praetor texts are refreshed, not replaced: a vendor file that is the projection of
AGENTS.md as the run found it or as the HEAD commit holds it (an AGENTS.md edited
after the last compile-context; without a commit only the first counts), a persona copy
that is the copy of its canonical persona as the run found it, Praetor's own
unedited DevContainer placeholder, a catalog
text with a layout-only successor, an earlier text of a documentation family file, the
documentation gate block an earlier Praetor wrote, and a managed attribute block holding
the rules of an earlier release or of another facet and decline state, such as the block
without the DevContainer rule (tests in
internal/adopt/locked_replace_test.go, internal/adopt/vendor_targets_test.go,
internal/adopt/vendor_head_projection_test.go,
internal/adopt/documentation_makefile_refresh_test.go,
internal/adopt/gitattributes_edit_test.go and
internal/adopt/gitattributes_devcontainer_test.go). Without --force, a symlinked backup root
fails the run before its first write when a vendor file or a persona copy holds a hand edit
(preflightVendorBackupRoot in internal/adopt/vendor_targets.go,
preflightPersonaBackupRoot in internal/adopt/persona_copies.go,
TestAdopt_Negative_PlainRunRefusesSymlinkedBackupRootForVendorEdit,
TestAdopt_Boundary_PlainRunRefusesSymlinkedBackupRootForPersonaCopyEdit). A re-run lists
an existing persona copy and the pre-commit hook adoption installed as reconciled, never as
created (internal/adopt/rerun_report_test.go).
- AGENTS.md harness under --force. An existing harness is kept without --force,
apart from its text register block, which is spliced from the manifest as compile-context
splices it (keepAgentHarness in internal/adopt/harness.go,
TestAdoptKeptHarnessSplicesRegisterBlock in internal/adopt/harness_register_test.go). A
splice that changes the file is reported as a replace with its line delta and backup, like a
forced refresh. A block with no end marker, or a backup root adoption refuses, fails adoption
before its first write (TestAdoptKeptHarnessSpliceRefusedBeforeAnyWrite).
With it, the harness is regenerated and what the repository added around it stays:
- the preamble: every line above the harness start, for example an SPDX header. The
harness starts at the first # ... Agent Operating Harness title, together with a
<!-- markdownlint-disable ... --> line directly above it. A harness whose title was
renamed is found by its ## Core Directives & Invariants heading and starts at the
nearest H1 above that heading, or at the top of the file when no H1 precedes it, so the
renamed title and its intro are regenerated rather than kept above a second copy
(harnessStart in internal/adopt/harness.go,
TestAdopt_AgentsMD_ForceReplacesRenamedHarnessTitle);
- invariant rows under an ID of the repository's own, such as **ACME-01**, appended
after the catalog rows in their original order and byte for byte, escaped pipes
included. hisscatalog.InvariantTableRows reads both tables through
SplitInvariantRow, the splitter ParseGatedInvariants uses. A HISS-<n> row
belongs to the catalog, so it is regenerated;
- the instructions below the harness boundary: the end marker, the footer of an older
harness, or a --- line, searched only below the harness start.
Every other harness line is regenerated. An edited one, such as reworded HISS-02 text, is
reported as replace with its line delta and backup, as above. The text register block
comes from the manifest, as compile-context renders it. The file keeps its CRLF
convention, and a second --force run leaves it byte-identical. A file whose harness has no
boundary is left untouched and the run records an error. Prose that only mentions the
harness is not a harness: the harness is merged above it and every line is kept
(TestAdopt_AgentsMD_ForceKeepsRepositoryAdditions,
TestAdopt_AgentsMD_ForceKeepsCRLFPreamble,
TestAdopt_AgentsMD_ForceRefusesUnknownBoundary,
TestAdopt_AgentsMD_ForceKeepsProseNamingTheHarness in internal/adopt/adopt_test.go).
- Text register policy. Every run renders the harness's text register block from the
manifest, --force or not. A policy the renderer rejects, such as a register.tasks
entry that is not a target_tasks label (text register), fails
adoption before its first write, a dry run included; a manifest that declines
agent-harness is not checked (preflightAgentHarness in internal/adopt/adopt.go,
tests in internal/adopt/register_preflight_test.go).
- Register skills. With --lock-source-root, the agent-harness step installs the skills
the text register block names, social-text and caveman, and adhd-format, which
social-text inherits from, into .agents/skills/, each with the upstream LICENSE its
declared origin requires (.agents/skills/<name>/LICENSE). Shipped skills carry an inline
credit and name no repository path an adopter lacks; adoption checks that on the source bundle
(compiler.CheckShippedSkillReferences) and never on the copy the repository already holds.
A declined agent-harness step, or a run without --lock-source-root, installs none, and
neither the block nor the Paperclip harness then names one. A declined agent-definitions
step installs none either while agent_clients selects Claude Code, since no step would
write the .claude/skills/ copies. A symlinked
.claude/skills fails adoption before its first write
(register skills,
internal/adopt/register_skills.go).
- Agent context verification. The agent-definitions step projects the canonical personas
and skills through the writer compile-context uses (compiler.CompileAgentSurfaces): the
persona copy in every persona directory agent_clients selects, the copy of each register
skill the repository carries in .claude/skills/ when Claude Code is selected, and, when
.agents/plugins/praetor/plugin.json exists, the plugin persona and skill copies. A
hand-edited plugin copy is replaced with its line delta and backup like any persona copy, and
a symlinked plugin directory fails adoption before its first write. Every canonical persona
must declare a model tier (model: flash or model: pro), projected per-client or stripped
with a documented reason. After the last step,
adoption runs the check compile-context --verify runs (compiler.VerifyCompiledContext,
verifyAgentContext in internal/adopt/context_verify.go). The check reads the whole
repository, not only the files adoption wrote. Each rejection is an error on the report,
led by "compile-context --verify rejects the repository's agent context after adoption", and
on the step that writes what it rejects: agent-definitions for a persona or skill copy,
git-ignore for the evidence ignore rule, agent-harness for AGENTS.md and the vendor
files. The run is then incomplete and praetorctl adopt exits non-zero; a caveman finding on
text adoption keeps as written stays a warning. A dry run, and a manifest that declines
agent-harness or agent-definitions, skip the check (tests in
internal/adopt/plugin_projection_test.go and internal/adopt/context_verify_test.go).
Earlier releases exited 0 in these cases, which now fail the run:
- A file in a persona directory that projects no canonical persona. compile-context
--verify treats every .md file in .claude/agents, .codex/agents, .github/agents,
.gemini/agents and .agents/plugins/praetor/agents as compiled output, so a subagent
written there by hand fails the run. Move it into .agents/agents, from where adoption
projects it to every selected client, or remove it
(TestAdopt_Negative_ClientPersonaDirFileWithoutCanonicalPersona). Adoption never rewrites
or removes such a file.
- A manifest that declines git-ignore while Git does not ignore .workingdir/evidence/.
Add .workingdir/ to the repository's own .gitignore
(TestAdopt_DeclinedGitIgnoreNeedsTheOperatorWorkingDirRule).
- Any other failure compile-context --verify names: fix what it names, or run
praetorctl compile-context.
- Earlier Praetor output. The manifest, lock, label taxonomy, pinned catalog, flavor
YAML (.clang-format and .clang-tidy included) and the docs:seo-portal documentation
gate's YAML that adoption writes pass yamllint --strict with its default rules
(make hooks-lint, git hooks). YAML written by other commands is not
covered yet: .needs.yaml, FRAMEWORK_DEMAND.yaml, changelog.d fragments and
.github/FUNDING.yml still fail yamllint --strict. Files an earlier release wrote
before that layout, and nobody edited since, are refreshed on a plain re-run with every value
unchanged: a manifest that is exactly yaml.Marshal of what it declares
(TestAdoptMigratesAnEarlierManifestRendering), the earlier label taxonomy
(TestReconcileLabels_Positive_RefreshesPriorTaxonomy), and a lock that pins only earlier
catalog texts, which is re-pinned to --lock-source-root while the pinned catalog files are
replaced (TestAdoptRepinsAnUnmodifiedEarlierCatalog). The catalog re-pin happens only when
every source file decodes to exactly the values of the earlier text it replaces; a source
that changes even one value keeps failing plain adoption
(TestAdoptDoesNotRepinAnEarlierCatalogToChangedValues). The failure names each declared
text whose values the source changes and the narrow re-pin, praetorctl profile set
(TestExistingLockMismatchNamesTheValueChanges in internal/adopt/lock_remedy_test.go). The files --force no longer
overwrites are refreshed the same way when they hold a text an earlier release wrote: the agent
anti-evasion interceptor, the checkpoint scripts, which go to the --lock-source-root bundle,
and the two canonical personas, which go through the root-pinned writer compile-context uses
(TestAdopt_Positive_PriorEvasionHookRefreshedOnPlainRun,
TestReconcileCheckpointBundle_Positive_PriorScriptRefreshedOnPlainRun,
TestAdopt_Positive_PriorPersonaRefreshedOnPlainRun). Their sets hold the current text too,
and a test fails until a changed text is added, so the release that changes one still
refreshes the copy adopters hold (priorPersonaDigests in internal/adopt/ruleset.go,
TestPriorPersonaDigests_Boundary_CurrentTextsRecorded; the hook files are covered in
git hooks). An earlier text is recognised in
either consistent line-ending style, so a CRLF checkout of the manifest, label taxonomy or a
persona (core.autocrlf on Windows) is refreshed too and keeps CRLF
(TestAdoptMigratesACRLFEarlierManifestInItsOwnStyle,
TestReconcileLabels_Positive_RefreshesCRLFPriorInItsOwnStyle,
TestReconcileAgentDefinitions_Boundary_CRLFPriorKeepsCRLFAndSymlinkNotWrittenThrough). The catalog
differs in one respect. Lock verification judges a uniformly CRLF checkout of a pinned file
as its LF text, so it passes without an eol=lf rule, while a file with mixed line endings is
hashed byte for byte (fileDigest in internal/config/lockdigest.go,
TestCheckoutLineEndings_Positive_CRLFCheckoutAuditsAsLFClone in
cmd/standardsctl/checkout_line_endings_test.go). The adoption refresh still compares catalog
files byte for byte (mayReplaceCatalogText in internal/adopt/policy_catalog.go), so a CRLF
copy is left as it is rather than refreshed; keep .config/archetypes at eol=lf in
.gitattributes when adoption should refresh it. An edited copy of any of them, or one with
mixed line endings, is left as it is and keeps the contract above
(TestAdoptDoesNotRepinAnEditedOrForeignCatalog).
- Files the repository ignores. Once every step has run, adoption asks git whether the
repository's own ignore rules exclude any file it created, verified, merged or replaced
(reportIgnoredWrites in internal/adopt/ignored_writes.go). An ignored file that git does
not track is still written, so the local checkout works, but no commit carries it, so a
clean checkout lacks it. Each one is reported on the step that wrote it, naming the rule
and the negation that re-includes it, such as
tools/figures/player/loader.js remains committable under a conventional dist/ rule; adoption does not add
re-include rules to an adopter's ignore file.
The negation names the shallowest ignored parent directory, because git never looks inside an
ignored directory. The finding is an error, except for editor files, which audit never reads:
those are a warning, and the IDE Ecosystem pillar shows it. The private ledger paths the
managed block ignores on purpose and the installed Git hooks are not checked. Neither is an
entry whose file is not on disk after the run: without a Prettier configuration the
formatter-ignore step records .prettierignore but writes nothing, so a rule that ignores
that name is no finding. A dry run checks the files it plans to create instead
(writtenPath). When git cannot answer, the report warns that the check was skipped
(TestReportIgnoredWrites_Positive_FindingsLandOnTheWritingStep,
TestReportIgnoredWrites_Negative_PrivateHookRemovedAndSkippedPathsAreNotChecked,
TestReportIgnoredWrites_Boundary_OnlyFilesOnDiskOrPlannedAreChecked,
TestAdopt_Negative_UnwrittenPrettierIgnoreIsNotReported,
TestAdopt_Negative_DeclinedGitIgnoreProposesTheNegation).
Praetor writes its pinned catalog, label taxonomy, checkpoint policy and hook scripts under
.config/, the name Kconfig gives its build configuration file. Where a Kconfig-style rule (a
bare .config, /.config or .*) hides that directory, the git-ignore step adds the
anchored, directory-only negation !/.config/ to the managed block and the .gitignore
entry says so. Kconfig .config files stay ignored at the root and at every depth, a
.config/ directory below the root (a vendored tool's) stays ignored, and a later run keeps
the negation. A repository that ignores the directory itself with .config/ gets no
negation, only the errors. With git-ignore declined, each error proposes !/.config/, and a
dry run plans the negation without writing it (kconfigConfigRule,
TestAdopt_Positive_KconfigRuleGetsTheDirectoryNegation,
TestAdopt_Boundary_DryRunAndRootConfigFile). The negation also re-includes any file
already below .config/ that adoption does not write, such as a tool's credentials, so the
next git add -A would commit it. The git-ignore step warns and names each such file,
leaving out tracked files and, in a real run, files another rule still ignores; a dry run
cannot see those rules yet and says so. Ignore each named file by path above the managed
block, or move it (reportReincludedConfigFiles,
TestAdopt_Boundary_NegationNamesTheForeignFilesItReincludes). A .config file at the repository root
cannot share its name with that directory: adoption stops before any write and names the
collision (preflightConfigRoot). Moving Praetor's files out of .config/ is planned
separately. praetorctl audit fails a documentation gate file that git ignores and does not
track, so a local audit gives the verdict a clean checkout gets
(auditDocumentationFilesCommitted in cmd/standardsctl/audit_documentation.go,
TestAuditDocumentationGate_Negative_IgnoredUntrackedFileFails).
Changing the profile, facets or catalog
praetorctl profile set changes what an adopted repository declares, or re-pins it to a newer
Praetor catalog, without adopt --force:
# Move to another profile; preview first, then apply
praetorctl profile set os-image --lock-source-root=/path/to/praetor --dry-run
praetorctl profile set os-image --lock-source-root=/path/to/praetor
# Replace the declared facets; --facets= declares none
praetorctl profile set --facets=security:high --lock-source-root=/path/to/praetor
# Keep the declaration and take up a catalog whose values changed
praetorctl profile set --lock-source-root=/path/to/praetor
It writes three things and nothing else (adopt.SetProfile in internal/adopt/profile_set.go):
| File | Change |
|---|---|
.standards.yaml |
The profiles and facets lists, rewritten only when they change. Every other line, comment and key keeps its text; a comment on the list's key line is kept. A <profile> replaces the whole profiles list; an omitted --facets keeps the declared facets. |
.standards.lock |
Rebuilt from --lock-source-root for the new declaration. |
.config/archetypes |
The texts the new lock pins, written or replaced. A text the declaration no longer names is kept; delete it yourself when nothing reads it. |
Every other file stays as it is (TestSetProfile_Positive_LeavesAnAdoptedTreeAlone). Every file
is read, built and checked before the first one is written, so a refusal leaves the repository as
it was (TestSetProfile_Negative_RefusalsWriteNothing). Each write is bound to the bytes read
first, so a file edited in between fails the run instead of losing the edit. A replaced lock or
catalog text is backed up under .workingdir/adopt-backups/ and listed with its line delta, as
adoption lists a replaced file. --dry-run writes nothing and prints each changed file as a diff,
or its content when it would be created (TestSetProfile_Boundary_EmptyFacetsAndDryRun). A real
run reads the lock and the policy back as the audit's manifest and lock gates do: the lock must
verify against the vendored catalog without the source bundle (TestProfileSetEndToEnd in
cmd/standardsctl/profile_test.go).
Files derived from the declaration
Adoption also renders files from the declared profiles and facets, and profile set leaves them
as they are. After a profile or facet change they can fail praetorctl audit. The DevContainer is
synthesized from the declaration (devcontainer.SynthesizeWithFeatures), turning
docs:seo-portal on or off adds or retires the documentation assets, the README block and the
documentation context in the ruleset, and turning api:public-contract on or off adds or retires
the Go API compatibility gate and its context in a repository
whose go.mod git tracks. So after
writing, or in a dry run against the planned declaration, profile set runs the five audit gates
that check those files: the README block, the documentation gate, the API compatibility gate, the
DevContainer and the branch protection ruleset (declarationGates in
cmd/standardsctl/profile.go). Each prints its verdict
as praetorctl audit prints it. When one fails, profile set still exits 0, since it wrote what it
was asked to, and prints the refresh:
praetorctl adopt --force --dry-run --lock-source-root=/path/to/praetor # preview
praetorctl adopt --force --lock-source-root=/path/to/praetor
adopt --force rewrites every audit-locked file that drifted, not only the ones these gates
check, so read its preview first. Plain adopt does not refresh them: it keeps an existing
DevContainer and refuses to retire the documentation or API compatibility context without
--force.
TestProfileSetReportsDerivedDrift_3D in cmd/standardsctl/profile_test.go covers the report,
the refresh and a re-run that passes every gate.
The other audit gates do not run. The invariant scan reads its limits from the effective policy
(EffectivePolicy.HISSScanOptions in internal/config/hiss_exceptions.go), so a profile with a
lower max_func_loc can report debt the baseline does not hold. The Paperclip harness states the
profile's HISS-04 limits and is not among the five gates either, so a profile change that moves
those limits leaves praetorctl audit failing with Paperclip harness out of date while
profile set names nothing to refresh. Run a plain praetorctl adopt --lock-source-root=...
afterwards: it refreshes an unmodified earlier harness without --force. Run praetorctl audit
for the full verdict.
--lock-source-root is required. A profile or facet the source bundle does not define fails
before anything is written. The error names the bundle and its catalog version, such as
lock source /path/to/praetor, catalog v0.0.0+catalog.3536de06c71a: profile "os-image": ...,
so a bundle older than the archetype shows up as the stale part
(TestBuildLockfileMissingIDNamesTheSourceCatalog in internal/config/lockbuild_test.go).
🤖 AI Agent Adoption via MCP (standards_adopt)
If you are using Claude Code, Cursor, Gemini CLI, Antigravity IDE, or any MCP-compatible coding agent, you can onboard any repository by asking:
"Adopt this repository into Praetor governance."
Under the hood, the agent executes the standards_adopt tool:
{
"name": "standards_adopt",
"arguments": {
"path": ".",
"dry_run": false,
"force": false,
"record_baseline": true,
"source_root": "/path/to/praetor"
}
}
"force": true is the refresh described in
What a forced re-adoption changes and needs source_root.
"record_baseline": true records a first baseline and keeps an existing one
(The baseline on a re-adoption).
The tool returns a detailed summary of created, reconciled and replaced files, detected archetypes, and recorded legacy debt.
âš¡ GitHub Action Bot & PR Automation
Add Praetor fast adoption to your GitHub repository using our composite action:
name: Praetor Adoption Bot
on:
issue_comment:
types: [created]
permissions: {}
jobs:
adopt:
if: >-
github.event.issue.pull_request &&
contains(github.event.comment.body, '/adopt') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-26.04
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
steps:
- id: pr-head
timeout-minutes: 2
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
run: |
sha="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .head.sha)"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "no head for #$PR_NUMBER: '$sha'" >&2; exit 1; }
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
with:
ref: ${{ steps.pr-head.outputs.sha }}
persist-credentials: false
- uses: cordanaLLM/praetor/.github/actions/praetor-adopt@main
with:
mode: adopt
force: true
cache: false
A /adopt comment on a pull request, written by the repository's owner, a member of its
organization or a collaborator, runs the adoption against that pull request's head and leaves the
result in the job summary. It commits nothing. Four parts of the example each close a hole:
- The
author_associationconjunct.issue_commentruns the default branch's workflow with the base repository's token for any account that can comment.OWNER,MEMBERandCOLLABORATORare the associations the repository granted; the other values GitHub defines (CONTRIBUTOR,FIRST_TIMER,FIRST_TIME_CONTRIBUTOR,MANNEQUIN,NONE) are within any account's reach. - The head lookup. An
issue_commentpayload carries the issue, not the pull request, so a checkout without areftakes the default branch and the adoption never sees the pull request. The step asks the pulls API for the head by number and fails rather than publishing anything that is not a commit id. - Read scopes and
persist-credentials: false. The checked-out tree is the pull request's content; the job holds no write scope and leaves no credential in the checkout. cache: false.actions/setup-gorestores and saves the Go caches unless itscacheinput is false, and anissue_commentrun saves into the default branch's cache scope, which every branch restores from. The job saves nothing there.
force: true makes the run the refresh described in
What a forced re-adoption changes, which rebuilds
.standards.lock from a git checkout of praetor. The runner unpacks a remote action without its
.git, so the action's build step runs git init, a one-commit git fetch of
cordanaLLM/praetor at main (the action's own repository and ref, read from
github.action_repository and github.action_ref) and a detached checkout. These land in a fresh
praetor-source.* directory under RUNNER_TEMP. It builds standardsctl from that checkout and
passes the checkout as --lock-source-root. The fetch is anonymous, from GITHUB_SERVER_URL, so
the runner needs to reach it. A fetch that fails, or a checkout that is not praetor, stops the
step before anything is built. With a branch ref such as @main, the checkout is that branch's
commit when the step runs. A tag or commit pins it.
standardsctl itself still comes from the praetor ref the uses: line pins, not from the pull
request (see below). That is why the example accepts a head pushed to a fork: the adoption reads
the fork's files but never builds or executes them. A job that builds or runs the pull request's
code has to refuse fork heads, as praetor's own adopt-comment job does.
Praetor's own adoption workflow
The hardened example uses .github/workflows/adopt.yml for dispatch and
.github/workflows/adopt-comment.yml for pull request comments, with one job per file:
| Job | Trigger | Token | What it does |
|---|---|---|---|
adopt |
workflow_dispatch |
contents: write, handed only to the commit step |
adopts target_path (default .), runs the HISS-13 debt ratchet on it, then commits with a DCO sign-off and pushes; with dry_run set it passes dry-run to the action, which plans the adoption without writing a file, and skips the ratchet and the commit; keeps its own Go build cache through .github/actions/go-cache (job: adopt) |
adopt-comment |
/adopt or /dogfood comment from a trusted commenter |
contents: read, pull-requests: read |
resolves the pull request head, refuses it unless it lives in this repository, checks it out without credentials, and runs the adoption and ratchet, or the dogfood benchmark, against it; restores and saves no cache |
Inside praetor the action builds the checked-out tree's own cmd/standardsctl, and the job loads
./.github/actions/praetor-adopt from that tree too, so the comment job builds and runs the pull
request's code. That is the point of /dogfood, and read scopes alone do not contain it: the job
runs in the default branch's context, and code in it can save Actions cache entries into the
default branch's scope, which the adopt job and every other branch restore. Three things keep
it contained:
- Only a trusted commenter can start the job.
- The head lookup refuses a head whose repository is not this one, which covers forks and deleted forks. Pushing a branch here takes write access, and an account with write access can start the job by commenting anyway, so a push that lands between the comment and the lookup admits no one the gate keeps out. The head is read when the job starts, so that later push is what runs; the repository and the commit come from one API answer, so the check covers it.
- Both uses of the action pass
cache: "false", and the job has no cache step, so nothing it builds is saved for a later run.
The adopt job sets Go up itself with cache: false before the action does. That keeps it
inside AuditGoBuildCaches (internal/forge/go_cache_checks.go), which reads the setup-go
steps of workflow jobs and not those inside an action.
Two checks keep the split honest. AuditPullRequestPermissions in
internal/forge/workflow_permissions.go reports any job that pull_request,
pull_request_target or an ungated issue_comment can start while it holds a write scope, and
TestAuditPullRequestPermissions_Guard_ThisRepositoryIsDisciplined runs it over this repository.
internal/forge/adopt_workflow_split_test.go pins the separate files and rejects a comment
workflow that also declares workflow_dispatch. internal/forge/adopt_workflow_test.go pins
the rest: only the dispatch job writes and pushes,
every checkout drops its credential, the comment job checks out the head it resolved,
target_path reaches both the adoption and the ratchet through env, dry_run reaches the
adoption and keeps the ratchet and the push from running, only the dispatch job keeps
a Go cache and stays visible to AuditGoBuildCaches, every job and the head lookup run under an
explicit timeout-minutes, and the head lookup's own shell body is executed against a stub gh,
which is how a fork head is shown to be refused.
Inputs, the binary, and the report output
| Input | Reaches | Effect |
|---|---|---|
path |
PRAETOR_PATH |
--path=<value>, and the --source/--target-dir of the compile-context --verify that follows an adopt run that is not a dry run; an empty value is refused before anything runs |
mode |
PRAETOR_MODE |
selects the subcommand, adopt or dogfood; any other value is refused |
dry-run |
PRAETOR_DRY_RUN |
--dry-run=<value>; in adopt mode true plans without writing a file and skips the compile-context --verify, which would otherwise check a tree the run did not touch and fail on a repository with no AGENTS.md yet (TestPraetorAdoptAction_Boundary_DryRunSkipsCompileContext); in dogfood mode it changes nothing, because dogfood applies adoptions only to --targets repositories (testTargetAdoptions in internal/dogfood/dogfood.go) and the action passes none, so the host is audited either way |
force |
PRAETOR_FORCE |
--force=<value>, adopt only: the refresh in What a forced re-adoption changes. --force rebuilds .standards.lock and needs a lock source, so with force: true the action also passes --lock-source-root=<checkout>, the praetor checkout the build step compiled standardsctl from, and refuses the run when the build step published none (TestPraetorAdoptAction_ForcedRunCarriesTheLockSource). The rebuild inventories that checkout with git ls-files. When the action's own tree has no .git, as when loaded remotely, a forced adopt run first checks out the action's repository at the action's ref with git under RUNNER_TEMP and builds from it. A tree that already is a git checkout is used as it is (internal/forge/adopt_action_source_test.go) |
record-baseline |
PRAETOR_RECORD_BASELINE |
--record-baseline=<value>, adopt only: true records a first baseline and keeps an existing one, reporting the ratchet verdict on it; false skips the scan. The action has no input that re-records an existing baseline (The baseline on a re-adoption) |
go-version |
actions/setup-go |
the toolchain the step compiles standardsctl with; it never reaches standardsctl, and it has to satisfy the go directive of praetor's go.mod |
cache |
actions/setup-go |
its cache input, default true as in setup-go itself; set false in a job a pull request or its comment starts, and in a job that keeps its own Go cache (TestPraetorAdoptAction_Positive_CacheInputReachesSetupGo) |
All five runtime inputs reach the run step as environment variables rather than as expressions
spliced into its script, so a value carrying a shell metacharacter or a newline is data rather
than script. path and the three booleans are then passed to standardsctl as single arguments;
mode is not passed at all, it picks the subcommand. mode and the booleans are validated
first: mode: Dogfod is a failure, not a silent adopt run. Each boolean is passed as
--flag=<value> rather than added when it is true, because dogfood --dry-run and
adopt --record-baseline default to true in standardsctl — an omitted flag would be an opt-in,
so record-baseline: "false" would have had no effect.
The standardsctl that runs is the one the action's own ref carries: the step builds
cmd/standardsctl out of the praetor checkout that GITHUB_ACTION_PATH points into, or, on a
forced adopt run of an action tree with no .git, out of a git checkout of the same repository
and ref (the force row above), so
praetor-adopt@<tag> and @main differ, and @latest builds the commit this repository's moving
latest tag points at (releasing). standardsctl itself is never
installed from the module proxy.
Outside praetor's own repository, reference the action as
cordanaLLM/praetor/.github/actions/praetor-adopt@<ref>. The directory three levels above the
action has to declare
module github.com/cordanaLLM/praetor and hold cmd/standardsctl, and the step fails before
anything runs when it does not:
uses: ./.github/actions/praetor-adoptin an adopter repository — that directory is the adopter's own workspace.- A copy of the action kept in another repository, such as
acme/ci/...@main— the ref GitHub resolved names a commit ofacme/ci, not of praetor. Installing praetor under that name would run a branch tip for@main, the newestv1.x.xtag for@v1, and the newest release for@latest(Go modules reference, version queries), none of which the caller pinned.
The error names the uses: form to switch to.
The binary output is the absolute path of the standardsctl the build step compiled. A later
step of the same job that has to run praetor again, such as adopt.yml's debt ratchet, runs that
binary instead of compiling a second one.
The report output carries the combined output of the run. The step writes it — and, when the
runner provides GITHUB_STEP_SUMMARY, appends it to the job summary — before re-raising the
command's exit status, and it does so for a refused input as well as for a failed run. Read it
from the job summary after a failure: whether a composite action's declared output still reaches
the caller once one of its steps has exited nonzero is not something GitHub documents. The append
itself is executed by the tests below; the survival of the output is what stays unasserted.
A step that passed is not always a repository that is ready. An adopt run that leaves a pillar
pending, the Verification Gate or a kept baseline the rescan rejects, exits 0 and ends its report
with not ready yet: <pillars> in place of the success line
(The baseline on a re-adoption). The action adds nothing to
that verdict and takes nothing from it: read the report's closing lines, or run praetorctl audit
with the binary output as the next step, as adopt.yml does.
- uses: cordanaLLM/praetor/.github/actions/praetor-adopt@main
id: praetor
with:
mode: dogfood
dry-run: "true"
- run: echo "$REPORT" >> "$GITHUB_STEP_SUMMARY"
env:
REPORT: ${{ steps.praetor.outputs.report }}
Everything above is pinned by internal/forge/adopt_action_test.go, which executes the action's own
shell bodies against a stub binary and reads the input defaults out of action.yml, and by
internal/forge/adopt_action_source_test.go, which runs the build step's checkout with the real
git against a repository served over file://.
Migration: explicit sources for missing lockfiles
Live adoption no longer creates the old placeholder lock. Pass
--lock-source-root=/path/to/praetor (MCP: source_root) when a target has no
valid lock. The source must have a valid manifest/lock and every selected local
archetype source. Digests come from actual source bytes. The MCP source path obeys
server root confinement.
A source root is a Praetor Git checkout or a source bundle. A source that holds go.mod also
feeds the DevContainer bootstrap, which lists its build inputs with git ls-files, so it must
sit in a Git checkout. Adoption refuses one that does not before its first step writes, naming
the source, the flag (MCP: source_root) and the cause, so a git archive export never leaves a
half-adopted repository (preflightDevContainerSource in internal/adopt/devcontainer.go,
TestAdoptRefusesUncapturableSourceBeforeWriting). A bundle without go.mod needs no Git; the
DevContainer then records its bootstrap as unavailable. profile set builds no bootstrap and
accepts either form. A failing inventory carries git's own error rather than a cause Praetor
guessed (TestRunSourceGitCarriesGitStderr_3D in internal/devcontainer).
Lock version: the source catalog's identity
A built lock's pinned_version, and every entry's version, name the source catalog
rather than copying the source lock's own pinned_version:
The hex digits start the aggregate digest over every archetype and facet under the
source's .config/archetypes, computed as a lock's top-level digest is, but over the
whole catalog instead of the target's selection. The source lock is still validated
first; its declared version is not trusted because nothing moves it when the catalog
changes. So:
- re-pinning against changed catalog content writes a new version, even when the target's own digests are unchanged;
- the same catalog content writes the same version, wherever the source lives;
- a source whose
.config/archetypesdefines nothing fails lock generation instead of writing a version it cannot back.
internal/config/lockbuild_test.go pins all three. praetorctl init renders its
unreleased build versions (v0.0.0+<revision>) through the same
config.UnreleasedLockVersion in internal/config/lockbuild.go. The revision is the VCS
stamp of a checkout build or, for go install .../cmd/standardsctl@<commit>, the commit
that build's pseudo-version ends with; go install ...@<tag> pins the tag itself
(buildid.Identify in internal/buildid/buildid.go resolves the identity, tested in
internal/buildid/buildid_test.go; lockPin in cmd/standardsctl/buildidentity.go turns it
into the pin, tested in cmd/standardsctl/buildidentity_test.go). An existing lock is
not rewritten for its version alone: validation compares digests, not versions, so the
catalog version arrives with the next rebuild (praetorctl profile set, --force, or a layout-only re-pin).
An existing valid target lock is preserved. An invalid lock fails adoption unless both
--force and an explicit source permit rebuilding it; praetorctl profile set rebuilds the
lock alone from an explicit source. A dry run without a source
reports lock generation as skipped; it cannot promise a complete adoption. Live
errors retain the partial report, since earlier scaffolding may already exist.
The same source option applies to adopt --all-missing; integrations invoking
adoption must supply it or arrange an already valid target lock.
Migration: the Go API compatibility gate
Only a Go repository that declares api:public-contract, one whose go.mod git tracks at the
root or nested, fails praetorctl audit until praetorctl adopt writes
tools/apicompat/gate/main.go and .github/workflows/praetor-api.yml; its rendered ruleset then
requires Go API Compatibility. A repository declaring the facet without a tracked go.mod
needs no change: adoption writes nothing for the facet, and audit prints an [INFO] line saying
that no API compatibility checker runs for its languages. A repository pinning the earlier
facets/api-public.yaml re-pins it with praetorctl adopt --force. Remove the facet to opt out
(Go API compatibility gate).
Migration: --force keeps an operator-owned Paperclip harness
praetorctl adopt --force no longer regenerates an operator-owned .paperclip/harness.json or
recreates a deleted .paperclip/rules.md (#502). It keeps the harness, resets only a platform
that names another repository to this repository's, and fails on a harness that does not
validate, as a plain run does. To regenerate the harness, delete .paperclip/harness.json (and rules.md) and rerun
praetorctl adopt.
Adoption does not re-bind register.sources to a harness you edited, under --force either.
A declared contract that no longer matches stops the run before its first write with
existing register.sources fails its configured gate. The error names every value that
differs, extracted from the files as they stand, and two remedies:
- Keep the edit: set
expected,not_applicableandsha256underregister.sourcesin.standards.yamlto the extracted values the error reports, then rerunpraetorctl adopt --force --lock-source-root=<praetor checkout>.praetorctl caveman check --configured-sources --root=.reports the same values once every input is staged withgit add; it refuses untracked inputs. - Drop the edit: delete
.paperclip/harness.jsonand rerunpraetorctl adopt. This works only when adoption writes a harness: withadoption.decline: [paperclip]or an unresolved repository identity it writes none, so restore the bound bytes withgit checkoutinstead, as the error then says. A harness adoption writes where none existed is Praetor output. When everyregister.sourcesinput selects.paperclip/harness.json, as the rows adoption declares do, adoption binds the pins to the harness it writes and reportsRe-bound register.sources to the Paperclip harness this run writes where none existed. That includes pins an earlier release bound to the harness it wrote. A contract that also selects another file keeps its gate, because its one digest cannot tell that file's drift from the new harness. The error then reports values that cover the harness the run would write: set them and rerun.
Tests: TestAdoptEditedHarnessFailsBeforeWritingWithRemedy in internal/adopt/adopt_test.go,
TestAdoptDeletedHarnessRebindsPinsAcrossReleases and
TestAdoptAbsentHarnessKeepsGateOfMixedContract in
internal/adopt/absent_harness_rebind_test.go, and
TestAdoptForceEditedHarnessNeedsRecomputedPins in
cmd/standardsctl/audit_paperclip_force_test.go. The harness rules in full:
text register.
Migration: repository.forge and the audited Paperclip harness
The Paperclip harness prescribes the push protocol of the forge repository.forge declares
(#321). A GitHub or GitLab harness carries push_format, the review-branch push alone, in place of
agit_push_format; a consumer of .paperclip/harness.json there reads the new member. A
repository whose origin remote is not on github.com declares the key and reruns adoption:
praetorctl adopt then refreshes a harness an earlier release wrote, with or without --force.
The audit no longer accepts that harness as it stands: it fails on unmodified earlier output,
with praetorctl adopt as the remedy, and on a rules.md that is not the rendering of
harness.json, with praetorctl paperclip harness or deleting rules.md as the remedy. An
edited harness.json stays operator-owned and passes
(what the generated harness claims).
The comparison detects the repository's languages with adoption's bounded walk. A repository
that needs --verification-max-* to adopt declares the bound once in .standards.yaml, so the
audit in the hooks and CI jobs, which pass no flag, reads it too
(large repositories):
Lock verification outcomes
Every command that reads .standards.lock uses one validator,
ValidateLockfileWithOptions in internal/config/lock.go. It recomputes each
declared profile and facet digest from a catalog. The catalog is the .config/archetypes
directory under --catalog-root (MCP: catalog_root), or under the repository when
no catalog root is given. The effective policy gate resolves the same catalog, so
both gates hash the same bytes.
| Outcome | Meaning |
|---|---|
| verified | Every declared entry's catalog file hashed to its pin. |
| unverifiable | Pins and the aggregate digest are valid, but the catalog has no .config/archetypes directory. |
| invalid | A version, pin, digest or catalog defect. Invalid locks are errors, never a status. |
A catalog that exists must define every declared id. An archetype whose id: field
was changed, or whose file was deleted, fails with ErrLockSourceMissing instead of
skipping the digest comparison.
Each command handles unverifiable as follows:
praetorctl auditand the MCPstandards_audittool fail withErrLockUnverifiable.praetorctl syncprints[UNVERIFIED]and exits incomplete;--catalog-rootselects a catalog. The effective policy cannot resolve either, so sync neither checks nor writes.github/rulesets/main.jsonand counts that one cause once.praetorctl planand the MCPstandards_plantool fail to resolve the policy and print none;--catalog-root(MCP:catalog_root) selects a catalog.praetorctl adoptrecords the outcome with a warning;--lock-source-rootverifies against that bundle.praetorctl harvest onboardcompletes withlock_verified: falseandlock_status: unverifiable.- Lock generation refuses a source bundle without a catalog, or with one that defines no archetypes.
Generated locks omit generated_at; a lock that sets it still validates. The
behavior is pinned by internal/config/lock_test.go,
cmd/standardsctl/lockdigest_test.go, cmd/standardsctl/sync_validation_test.go,
internal/adopt/lock_test.go and internal/harvester/onboard_safety_test.go.