diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index bfb8ef02..ed9ac5c0 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -16,8 +16,12 @@ jobs: steps: + # Full history, since the dead-path prose rule keys on deletion history. + # A shallow clone holds none, and the rule stands down there rather than pass blind. - name: Checkout code step uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # Doc linters run as pinned action wrappers. # The editorconfig-checker action is install-only, so it runs via Docker instead. @@ -113,7 +117,7 @@ jobs: # Every default prose rule is clean tree-wide, so all but one block a change that adds a finding. # `charset-unknown` is the exception and reports in the step below, for the reason given there. - name: Check prose step - run: python3 scripts/prose_lint.py . --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path + run: python3 scripts/prose_lint.py . --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path # Warn-only, and visible rather than absent: an unrun check is one nobody acts on. # A finding here names a character no tier covers, and classifying it is a fleet-law edit rather than a prose fix. diff --git a/AUDIT.md b/AUDIT.md index 4b1ffa7a..34b8f2b5 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -14,7 +14,7 @@ The verdict vocabulary is [`WORKFLOW.md`][workflow]'s: **operational / not opera This audit is not occasional. Run it whenever you **create, adopt, or materially change** a fleet repo, and on demand for any known repo: - **Onboarding a repo is complete only when it either passes this audit** (operational on every applicable check) **or carries a committed `reports//audit.md` plus a tracking issue** enumerating every residual delta. A repo that is partially set up but never audited is itself a **defect**, the exact state this process prevents. The create-to-conformance counterpart is [`STANDUP.md`][standup]. Because both read the same manifests, a repo stood up by that file passes this audit by construction. -- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. +- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. A carried `intent` unit gets one advisory beyond presence, a last-modified comparison: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, at **drift**, a hint rather than proof, since a copy touched without reconciling reads current and content is never judged. **Verify the host before running any hub tool.** The tools carry version floors, and a host below one answers `--version`, looks healthy, and produces a wrong answer, so a clean audit run from a broken host is a clean-looking result rather than a result. diff --git a/RESYNC.md b/RESYNC.md index 63fcd3d0..74b3f297 100644 --- a/RESYNC.md +++ b/RESYNC.md @@ -73,14 +73,14 @@ The detector is derived rather than listed: the hub's git-tracked paths minus th - **Only a `retire` disposition in [`spec/divergences.json`][divergences] authorizes a deletion.** It records that the file is the hub's content with nothing per-repository in it, and what to reach instead. - **An untriaged hit is read before it is touched.** A repository's own content at a path the hub also uses matches this check while carrying nothing of the hub's. The first fleet-wide run found two: a KiCad tooling document at `scripts/README.md`, and per-repository formatting hooks at `.husky/pre-commit`, each of which shares the path and none of the content. Deleting either would have destroyed work the hub never owned. - **An `accepted` disposition closes the hit permanently**, whether it is a path collision or a file every repository legitimately owns, such as `LICENSE` and `TODO.md`. -- **Sweep every inbound reference to the path, and count that as part of the deletion.** This governs any deletion rather than only a `hub-only:` finding. The three bullets above turn on a disposition and so belong to this detector, where the sweep turns on nothing: it applies the same way to a file the repository owns and removes for reasons of its own, which is where the fleet first hit it. Deleting the file is one edit and finishing the job usually takes several more, so grep the path tree-wide before deleting and read every hit. **Then read the files whose job is to say what the repo holds, since a grep for the path finds uses of the file and misses descriptions of it.** A layout section calling a deleted script "the apply script" names no path, so it survives every search for one and goes stale silently. This is measured rather than predicted: a resync deleted a carried script, swept its path across the tree, merged, and left `GOVERNANCE.md` "Repository Layout" still describing the directory as holding it. Three shapes appear among the hits and they are fixed differently. A link whose target has an equivalent elsewhere is **re-pointed** at it, the hub's copy being that equivalent for a hub-hosted file, since a pointer that resolves nowhere teaches a reader that a pointer in carried text is decorative. A **runnable command** citing the deleted path is rewritten to the invocation that still works, and this is the one that costs a reader real time, because a documented command fails where a dead link merely disappoints. A mention with no equivalent anywhere is **removed** rather than re-pointed, and removing a reference-style link removes its definition with it, per [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style], since an orphaned definition fails the no-unused-defs rule and is a gate failure rather than untidiness. +- **Sweep every inbound reference to the path, and count that as part of the deletion.** This governs any deletion rather than only a `hub-only:` finding. The three bullets above turn on a disposition and so belong to this detector, where the sweep turns on nothing: it applies the same way to a file the repository owns and removes for reasons of its own, which is where the fleet first hit it. Deleting the file is one edit and finishing the job usually takes several more, so grep the path tree-wide before deleting and read every hit. **Then read the files whose job is to say what the repo holds, since a grep for the path finds uses of the file and misses descriptions of it.** A layout section calling a deleted script "the apply script" names no path, so it survives every search for one and goes stale silently. The named-path half of this class is mechanized: `scripts/prose_lint.py --check dead-path` reports a Markdown mention of a path git once tracked and the tree no longer holds, continuously rather than only at deletion time, so the manual read here is what covers the description that names no path, which no pattern reaches. This is measured rather than predicted: a resync deleted a carried script, swept its path across the tree, merged, and left `GOVERNANCE.md` "Repository Layout" still describing the directory as holding it. Three shapes appear among the hits and they are fixed differently. A link whose target has an equivalent elsewhere is **re-pointed** at it, the hub's copy being that equivalent for a hub-hosted file, since a pointer that resolves nowhere teaches a reader that a pointer in carried text is decorative. A **runnable command** citing the deleted path is rewritten to the invocation that still works, and this is the one that costs a reader real time, because a documented command fails where a dead link merely disappoints. A mention with no equivalent anywhere is **removed** rather than re-pointed, and removing a reference-style link removes its definition with it, per [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style], since an orphaned definition fails the no-unused-defs rule and is a gate failure rather than untidiness. - **The sweep is measured, not hypothetical, and the second case is the one that proves it is not hub-only.** One repository carries an inline link to `configure.sh` in its `repo-config/README.md`, alongside four commands invoking `./repo-config/configure.sh`, so the retirement of that one file makes five lines of a carried document wrong, two of them commands a reader would run. Another repository's nested `AGENTS.md` was deleted for reasons entirely its own, with no disposition and no hub equivalent, and it took three edits across two files: removing the inline link alone tripped `MD053` on the definition it orphaned, so the delete-the-file-only path failed CI rather than merely breaking prose. ## 5. What a Resync Cannot Detect -State this rather than letting a clean run imply more than it earned. A carried file at `intent` fidelity is checked for **presence and nothing else**, per [`spec/fidelity-model.md`][fidelity-model], so a hub revision inside one produces no finding anywhere. That covers `CODESTYLE.md`, `WORKFLOW.md`, the carried `AUDIT.md`, `.github/copilot-instructions.md`, `.editorconfig`, `.gitattributes`, `cspell.json` and `version.json`. +State this rather than letting a clean run imply more than it earned. A carried file at `intent` fidelity is checked for **presence plus a staleness advisory**, per [`spec/fidelity-model.md`][fidelity-model]: its content is never judged, and a copy whose hub canonical changed after the copy's own last commit is reported as possibly trailing, at drift. That covers `CODESTYLE.md`, `WORKFLOW.md`, the carried `AUDIT.md`, `.github/copilot-instructions.md`, `.editorconfig`, `.gitattributes`, `cspell.json` and `version.json`. -So a repository can be clean on every mechanized check and still carry an `intent` file many hub revisions old. Two things follow. Read the hub's own history for those files when a resync is meant to be thorough, rather than trusting the finding list to raise them. And treat `spec/fidelity_honesty.py`'s promotion candidates as the structural fix: an `intent` unit that is content-identical fleet-wide can become `verbatim` and gain drift detection for free, which is the class that hid the `configure.sh` drift for as long as it did. +The advisory is a hint rather than proof, since a copy touched after the hub change without actually reconciling still reads current. Two things follow. Read the hub's own history for those files when a resync is meant to be thorough, rather than trusting the advisory to raise them. And treat `spec/fidelity_honesty.py`'s promotion candidates as the structural fix: an `intent` unit that is content-identical fleet-wide can become `verbatim` and gain drift detection for free, which is the class that hid the `configure.sh` drift for as long as it did. The other half is section 4 of [`AUDIT.md`][audit]: no check belonging to a project type in [`spec/project-types.json`][project-types] is mechanized at all. A clean tool run is evidence for the deterministic subset, no evidence for a type's checks, and partial evidence across the cross-cutting dimensions. diff --git a/STANDUP.md b/STANDUP.md index 1232e1f3..66f343f6 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -89,7 +89,7 @@ The rename runs unconditionally rather than behind a test of `init.defaultBranch **Committing onto `develop` and squashing afterwards does not work**, because `non_fast_forward` is set on both `develop` payloads and rewriting that history is exactly what the rule rejects. This is not hypothetical, since a repo stood up that way was correctly blocked at the point the history needed rewriting, with the standup already written into the branch it had to be lifted off. -**The protection is uneven, so on an operational repo this instruction is the only thing holding the line.** A release repo's `repo-config/develop.json` carries a `pull_request` rule that blocks a direct commit outright, while `repo-config/operational/develop.json` carries three rules, `deletion`, `non_fast_forward` and `required_signatures`, and none of them stops one. A conformant operational repo therefore accepts the commit that this step exists to prevent, and reports nothing wrong afterwards. +**The protection is uneven, so on an operational repo this instruction is the only thing holding the line.** A release repo's `repo-config/develop.json` carries a `pull_request` rule that blocks a direct commit outright, while `repo-config/operational/develop.json` carries three rules, `deletion`, `non_fast_forward` and `required_signatures`, and none of them stops one. A conformant operational repo therefore accepts the commit that this step exists to prevent, and reports nothing wrong afterwards. That is a recorded disposition rather than an oversight, `accepted` in the [`docs/fleet-map.md`][fleet-map] register (G7): the allowance is the operational model's foundation, a standup runs on a feature branch either way, and a ruleset tightened for standup alone would leave a window where the live ruleset contradicts the registry's model, so this instruction stays the enforcement. **On a public repo the squash is the one chance to leave the exploratory history out.** Standup is where a wrong secret value, a throwaway credential, and a run of noise commits accumulate, and a squashed feature branch publishes the result rather than the route to it. @@ -206,6 +206,7 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and [codestyle]: ./CODESTYLE.md [content-import]: ./docs/content-import.md [files]: ./spec/files.json +[fleet-map]: ./docs/fleet-map.md [governance]: ./GOVERNANCE.md [governance-git-and-commit-rules]: ./GOVERNANCE.md#git-and-commit-rules [governance-repository-boundaries-and-write-safety]: ./GOVERNANCE.md#repository-boundaries-and-write-safety diff --git a/docs/fleet-map.md b/docs/fleet-map.md index 3f08f9e8..0429eb8e 100644 --- a/docs/fleet-map.md +++ b/docs/fleet-map.md @@ -108,14 +108,12 @@ flowchart TD ask["bring this repo back into conformance"] --> resync["RESYNC from a hub checkout"] resync --> audit["AUDIT end to end, read-only"] audit --> apply["apply in order: rules, deletions, re-vendors, workflows, config"] - apply -.->|"G4: deleted paths live on in prose"| apply - apply -.->|"G5: intent-fidelity drift is invisible"| apply audit --> hostcheck["host gate on the way in"] hostcheck --> remedy["a failed floor prints its install command"] apply --> reaudit["re-audit"] --> report["committed report"] ``` -Owned by [`RESYNC.md`][resync] and [`AUDIT.md`][audit], packaged as the `resync-a-repo` skill with the `carried-instruction-file-guard` and `copilot-instructions-keeper` skills firing inside it. Gaps on this path: G4 (deletion sweeps miss prose), G5 (intent-fidelity drift detection). G3, the audit-to-install bridge, is closed and its row records the resolution. +Owned by [`RESYNC.md`][resync] and [`AUDIT.md`][audit], packaged as the `resync-a-repo` skill with the `carried-instruction-file-guard` and `copilot-instructions-keeper` skills firing inside it. No open gaps sit on this path: G3, the audit-to-install bridge, G4, the prose-deletion sweep, and G5, the intent-staleness advisory, are closed and their rows record the resolutions. ### Daily Development in a Conformant Repository @@ -129,10 +127,9 @@ flowchart TD gates --> pr["pull request"] pr --> review["Copilot loop via pr_review.py"] review --> merge["merge per registry workflowModel"] - merge -.->|"G7: operational PR-only is prose-enforced"| merge ``` -Owned by the per-language sections of [`CODESTYLE.md`][codestyle] and the conduct skills. Gaps on this path: G7 (the operational develop ruleset blocks no direct commit). G6, the unwired staleness check, is closed and its row records the resolution. +Owned by the per-language sections of [`CODESTYLE.md`][codestyle] and the conduct skills. No open gaps sit on this path: G6, the unwired staleness check, and G7, the operational direct-commit allowance, are closed and their rows record the resolutions. ### Hub-Side Operations @@ -173,14 +170,14 @@ Four wiring points close the model, and each is in place: | G1 | Skills install is absent from the cold-start flow | script + doc | closed | | G2 | Host-tools repo overlay is silently skippable | script + doc | closed | | G3 | A failed tool floor names no install remedy | spec + script | closed | -| G4 | Deletion sweeps miss prose describing the deleted path | doc | P3 | -| G5 | Intent-fidelity carried files have no drift detection | spec + decision | P3 | +| G4 | Deletion sweeps miss prose describing the deleted path | doc | closed | +| G5 | Intent-fidelity carried files have no drift detection | spec + decision | closed | | G6 | Session entry never checks skill staleness | doc + skill | closed | -| G7 | Operational develop PR-only rule is prose-enforced | decision | P3 | +| G7 | Operational develop PR-only rule is prose-enforced | decision | closed | | G8 | Generated plugin can ship stale with no CI gate | CI | closed | | G9 | WORKFLOW.md and AUDIT.md have no skill coverage | skill | closed | | G10 | The skill lifecycle itself has no skill | skill | closed | -| G11 | Peer messaging is live but undeclared | doc | P0 | +| G11 | Peer messaging is live but undeclared | doc | closed | | G12 | General conduct rules have no skill | skill | closed | Each gap's handoff below states who detects it, what closes it, and the test that proves it closed. The handoff sentence is the contract the closing pull request implements. @@ -212,34 +209,28 @@ flowchart LR - **Closing test** - [`scripts/test_bootstrap.py`][test-bootstrap] asserts the mapping stays total per platform, with the one recorded not-applicable exception, and that a remedy handing back into an installer names a tool that installer manages. [`spec/validate.py`][validate] and the schema require a remedy on every hub floor. A repository overlay may still add a floor without one, in which case the failure degrades to the `INSTALL FROM:` source line. -### G4: Deletion Sweeps Miss Prose +### G4: Deletion Sweeps Miss Prose (Closed) - **Gap** - A resync that deletes a carried file greps for the path and finds code uses, not prose describing the file without naming its path. A measured incident left a layout section describing a deleted script. -- **Checked** - [`RESYNC.md`][resync] section 4 documents the incident and prescribes the manual remedy. -- **Handoff** - When a resync deletes a file, the session reads the files whose job is to describe what the repo holds (the layout and operations sections) before shipping, per the RESYNC section 4 step. -- **Closed when** - Either the manual step is judged sufficient and this row closes as `accepted`, or a lint that flags a stale description ships and the row names it. -- **Target** - Decision first, optional [`scripts/prose_lint.py`][prose-lint] check second. +- **Resolution** - Split by what a pattern can reach. The named-path half is mechanized: the `dead-path` rule in [`scripts/prose_lint.py`][prose-lint] reports a Markdown mention (a backtick span, an inline link target, or a reference definition) of a path git once tracked and the tree no longer holds. Keying on deletion history is what scopes it: a proposed file a backlog names, another repository's layout, and a ref like `origin/develop` each have no history here and stay silent, and a manifest-declared carried path is exempt since the hub's own instance retires to a catalog snippet while docs keep naming the carried file. The rule runs in the default set and in CI, where the checkout fetches full history because the rule stands down loudly in a shallow clone rather than pass blind. The name-shaped half, the description that names no path, is `accepted` as manual: no pattern reaches it, the same judgment the home-path rule records, so the [`RESYNC.md`][resync] section 4 read of the layout and operations sections stands and now names the lint beside it. +- **Closing test** - `TestDeadPath` in `scripts/test_prose_lint.py`, including the shallow stand-down and the tree-clean assertion. The rule's first tree-wide run caught a real instance, [`docs/host-setup.md`][host-setup-doc] describing bind-mounts in the deleted `.devcontainer/` definitions, fixed by re-pointing at the catalog snippets. -### G5: Intent-Fidelity Drift Is Invisible +### G5: Intent-Fidelity Drift Is Invisible (Closed) -- **Gap** - A carried file at `intent` fidelity is presence-checked only, so it can trail the hub by many revisions while the audit reads clean. This class hid real drift before. -- **Checked** - [`RESYNC.md`][resync] section 5 names the class, and [`spec/fidelity_honesty.py`][fidelity-model] finds promotion candidates but runs only when the owner runs it. -- **Handoff** - When the audit reports on a repo, it also reports the hub revision each intent-fidelity unit was last reconciled against, advisory rather than failing, so staleness is at least visible. -- **Closed when** - Either the advisory surfaces in the audit report, or the row closes as `accepted` with the honest statement standing in [`AUDIT.md`][audit] and [`spec/fidelity-model.md`][fidelity-model]. -- **Target** - Decision, then candidate advisory in `spec/audit.py`. +- **Gap** - A carried file at `intent` fidelity was presence-checked only, so it could trail the hub by many revisions while the audit read clean. This class hid real drift before. +- **Resolution** - The advisory ships in `spec/audit.py`: for each applicable intent unit the audit compares when the hub canonical and the repo copy each last changed, and a canonical changing later reports the copy as possibly trailing, at DRIFT and never failing. A copy content-identical to the canonical is skipped, being both current and the promotion candidate `spec/fidelity_honesty.py` exists to find. Measured on its first run: a fleet repo's carried `WORKFLOW.md`, `CODESTYLE.md`, and `.editorconfig` each reported as trailing hub changes made weeks earlier, findings no prior check produced. +- **Decisions** - The handoff imagined reporting the hub revision each unit was last reconciled against, and no reconciliation record exists anywhere, so the last-modified comparison is the implementable proxy and ships instead of inventing a per-repo stamp. The advisory therefore says possibly: a copy touched after the hub change without actually reconciling reads current, a blind spot stated in [`RESYNC.md`][resync] section 5, [`AUDIT.md`][audit], and [`spec/fidelity-model.md`][fidelity-model] rather than papered over. ### G6: Session Entry Never Checks Skill Staleness (Closed) - **Gap** - A machine with stale or missing skills behaves like a machine that never installed them, and nothing at session entry said so. The symptom is a rule that keeps needing to be restated. - **Resolution** - The cadence is stated in both places the row asked for. [`docs/host-setup.md`][host-setup-doc] "Fleet Skills Install" directs a re-run of the installer when `--report` exits non-zero and after any hub merge touching `.agents/skills/`, and the `fleet-conformance-check` skill carries the same cadence in its own "Refresh cadence" section, routing the restated-rule symptom to the report it already runs. No new tooling, by design: the trigger is suspicion, and session entry stays uninstrumented until the fleet has evidence the manual cadence fails. -### G7: Operational Develop PR-Only Is Prose-Enforced +### G7: Operational Develop PR-Only Is Prose-Enforced (Closed) - **Gap** - [`repo-config/operational/develop.json`][repo-config-readme] carries deletion, non-fast-forward, and signature rules only, so nothing blocks a direct commit that skips the feature-branch instruction during a standup. -- **Checked** - The ruleset payload, and [`WORKFLOW.md`][workflow] "Branch Model", which documents the direct-commit allowance as deliberate for the operational model. -- **Handoff** - This is a decision row: the allowance is the model's foundation, so the candidate outcomes are `accepted` (the standup instruction stays prose-enforced) or a standup-time-only tightening. Nothing detects the violation mechanically today. -- **Closed when** - The maintainer records the disposition, mirroring the [`spec/divergences.json`][divergences] vocabulary. -- **Target** - A disposition, not necessarily code. +- **Resolution** - Closed `accepted`, mirroring the [`spec/divergences.json`][divergences] vocabulary, with the disposition recorded where the exposure is described: [`STANDUP.md`][standup] section 0B states it beside its uneven-protection warning. The allowance is the operational model's foundation per [`WORKFLOW.md`][workflow] "Branch Model", and a standup runs on a feature branch either way, so the exposed interval is the tail of a procedure the instruction already governs. +- **Decisions** - The considered alternative, applying the release `develop` payload during standup and swapping to the operational payload after the closing audit, was declined: it adds a swap step that can be forgotten and a window where the live ruleset contradicts the registry's declared model, buying mechanical enforcement only for an interval the instruction already covers. A forgotten swap would surface as a ruleset defect on the next audit, which is real, and so would the contradiction it spends the interim in. ### G8: Generated Plugin Can Ship Stale (Closed) @@ -258,13 +249,10 @@ flowchart LR - **Gap** - Authoring, changing, and retiring a skill was governed by scripts and scattered prose, so the agent most likely to get it wrong (one editing a skill) had no skill watching it. - **Resolution** - The `skill-lifecycle` skill packages the pipeline (source-versus-generated split, `build_dist.py` regenerate and `--check`, installer and stamp semantics, the doc-packaging pattern, trigger-description conventions), and [`.agents/skills/README.md`][skills-readme] defers to it for procedure, which is this row's closing test. It was authored first in phase 2 so the other three skills follow its procedure. -### G11: Peer Messaging Is Live but Undeclared +### G11: Peer Messaging Is Live but Undeclared (Closed) -- **Gap** - Agent-to-agent messaging works and has produced real findings, and no committed doc states its rules, so each session rediscovers the capability without its boundaries. -- **Checked** - [`TODO.md`][todo] "Peer Messaging Between Agents as a Declared Method", with the live exchange anchored there at `develop` `3855dbb` on 2026-08-10. -- **Handoff** - [`docs/peer-messaging.md`][peer-messaging] declares the method and its safety rules, hub-only, and the TODO item's open location question resolves to it. -- **Closed when** - This pull request merges. This row closes in P0. -- **Target** - [`docs/peer-messaging.md`][peer-messaging], shipped beside this doc. +- **Gap** - Agent-to-agent messaging works and has produced real findings, and no committed doc stated its rules, so each session rediscovered the capability without its boundaries. +- **Resolution** - [`docs/peer-messaging.md`][peer-messaging] declares the method and its safety rules, hub-only, shipped beside this doc in the P0 pull request, and the [`TODO.md`][todo] "Peer Messaging Between Agents as a Declared Method" item resolves to it. The row's phase cell trailed its own closure until the P3 register pass, which is the maintenance rule doing its job. ### G12: General Conduct Rules Have No Skill (Closed) @@ -348,9 +336,9 @@ Design-doc first: this doc merges, then each unchecked item becomes an issue lin ### P3: Audit-Depth Decisions -- [ ] G4 disposition: manual step sufficient, or a lint ships -- [ ] G5 disposition: advisory staleness in the audit report, or accepted -- [ ] G7 disposition: operational standup enforcement, or accepted +- [x] G4 disposition: the `dead-path` lint ships for the named-path half, the manual read stands for the name-shaped half +- [x] G5 disposition: the last-modified advisory ships in `spec/audit.py` +- [x] G7 disposition: `accepted`, recorded in [`STANDUP.md`][standup] section 0B ### P4: Steady State diff --git a/docs/host-setup.md b/docs/host-setup.md index b59a75c0..d80d4468 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -6,7 +6,7 @@ Supported hosts: - **Linux** - both the devcontainer flow and the host-install flow. - **macOS** - both the devcontainer flow and the host-install flow. -- **Windows** - the devcontainer flow requires **WSL2**, and native Windows (PowerShell + winget) is supported only for the host-install flow described in `README.md`. The bind-mounts in `.devcontainer/dotnet/devcontainer.json` and `.devcontainer/python/devcontainer.json` rely on POSIX paths and only work from Linux/macOS/WSL2. +- **Windows** - the devcontainer flow requires **WSL2**, and native Windows (PowerShell + winget) is supported only for the host-install flow described in `README.md`. The bind-mounts in the `catalog/snippets/devcontainer/` definitions rely on POSIX paths and only work from Linux/macOS/WSL2. > **Shell assumptions in this doc**: every command snippet below assumes a **POSIX shell** (bash/zsh) and POSIX path conventions (`~/.ssh/...`, `mkdir -p`, `$(...)` command substitution), except where a block is marked `powershell`. Such a block is the **Windows-native** form of the step it sits in, meant to run in PowerShell rather than translated. On Windows, run the POSIX snippets from **WSL2** or **Git Bash**, since they will not work as-is in PowerShell or `cmd.exe`. The git config and `gh` commands are portable, and only the file and path manipulation differs by shell. diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index feeedd7c..2a95c0e6 100644 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -13,13 +13,16 @@ sentence-split A sentence must not wrap across lines (one sentence per line). spelling No British spelling, the repo-wide convention being US English. home-path No absolute home path naming a real account, per the representative-data rule. + dead-path No mention of a path git once tracked and the tree no longer holds. Exit 1 if any violation is found. Read-only, never edits. """ from __future__ import annotations import argparse +import functools import io +import json import re import subprocess import sys @@ -40,9 +43,11 @@ 'sentence-split': 'a sentence wrapping across lines', 'spelling': 'a British spelling where the repo convention is US English', 'home-path': 'an absolute home path naming a real account', + 'dead-path': 'a mention of a path git once tracked and the tree no longer holds', } DEFAULT_RULES = frozenset({'charset', 'charset-unknown', 'semicolon', 'dash', 'dupword', - 'spelling', 'comment-wrap', 'comment-case', 'home-path'}) + 'spelling', 'comment-wrap', 'comment-case', 'home-path', + 'dead-path'}) # Trees this repo generates rather than authors, skipped when a wider scan expands into them. # The gate then measures hand-written prose. @@ -249,6 +254,116 @@ def home_path_findings(lineno: int, line: str) -> list[tuple[int, str, str]]: return out +# The named-path half of the stale-description class, and only that half (RESYNC.md section 4). +# The measured incident named no path at all, and no pattern reaches a description without one. +# A backtick span, an inline link target, and a reference definition each assert a path. +INLINE_SPAN = re.compile(r'`([^`\n]+)`') +LINK_TARGET = re.compile(r'\]\(([^)\s]+)\)') +REF_DEF = re.compile(r'^\s*\[[^\]]+\]:\s+(\S+)') +# A character that marks a token as a placeholder, a glob, an expansion, or a scheme. +# The colon covers every URL scheme, a drive letter, and an image tag in one stroke. +PATH_FOREIGN = frozenset('<>{}$*?"\'\\:!|,;') + + +def path_candidate(token: str, in_span: bool = True) -> str | None: + """The relative path a token asserts, or None when it asserts none. + + A backtick span holds prose as often as a path, so it qualifies only when it is shaped + like a file: a single word carrying a separator and a suffix, which is what tells + `spec/audit.py` from a ref like `origin/develop` and from a bare directory pattern like + `references/`, a shape docs use for any repository's layout rather than this one's. A + link target or a reference definition is a path by construction, so only a foreign + character disqualifies it there. + """ + token = token.split('#', 1)[0] + if not token or any(c.isspace() for c in token) or PATH_FOREIGN & set(token): + return None + if token.startswith(('/', '~', '-', '#')): + return None + if in_span: + if '/' not in token: + return None + last = token.rsplit('/', 1)[-1] + if '.' not in last or not last.strip('.'): + return None + return token.removeprefix('./') + + +@functools.cache +def carried_paths(root: str) -> frozenset[str]: + """Paths the manifest declares as carried, exempt because docs name them as fleet layout. + + The hub's own instance of a carried file retires to a catalog snippet, so its history + reads as a deletion while every mention legitimately describes the file a repository + carries. Whether a repository actually carries one is the audit's finding, not prose's. + """ + try: + data = json.loads((Path(root) / 'spec' / 'files.json').read_text(encoding='utf-8')) + except (OSError, ValueError): + return frozenset() + return frozenset(e['path'] for e in data.get('baseline', []) + if isinstance(e, dict) and isinstance(e.get('path'), str)) + + +@functools.cache +def once_tracked(root: str, rel_path: str) -> bool: + """Whether git at `root` ever recorded `rel_path`, the deletion signature this rule keys on.""" + try: + r = subprocess.run(['git', '-C', root, 'log', '-1', '--format=%H', '--', rel_path], + capture_output=True, text=True) + except (OSError, ValueError): + return False + return r.returncode == 0 and bool(r.stdout.strip()) + + +def dead_path_findings(root: Path, base: Path, lineno: int, + line: str) -> list[tuple[int, str, str]]: + """Named paths on this line that git once tracked and the tree no longer holds. + + Requiring a history is what scopes this to the deletion-sweep shape, a file removed with + its describing prose left standing. A path another repository holds, a proposed file a + backlog names, and a layout pattern each have no history here, so none is reported. + """ + m = REF_DEF.match(line) + if m: + # A definition line holds one target and no prose, so nothing else on it is read. + tokens = [(m.group(1), False)] + else: + tokens = [(s.group(1), True) for s in INLINE_SPAN.finditer(line)] + tokens += [(t.group(1), False) for t in LINK_TARGET.finditer(strip_inline_code(line))] + out = [] + for token, in_span in tokens: + rel_path = path_candidate(token, in_span) + if rel_path is None: + continue + # A mention is anchored where it resolves, the file's own directory or the root. + if (root / rel_path).exists() or (base / rel_path).exists(): + continue + for anchor in {root, base}: + try: + tracked_rel = (anchor / rel_path).resolve().relative_to(root.resolve()) + except ValueError: + continue + if str(tracked_rel) in carried_paths(str(root)): + continue + if once_tracked(str(root), str(tracked_rel)): + out.append((lineno, 'dead-path', + f'path {token!r} is deleted from this tree -> re-point, rewrite, ' + 'or remove the stale mention')) + break + return out + + +def shallow_checkout(root: Path) -> bool: + """Whether the checkout at `root` is shallow, which holds no deletion history to key on.""" + try: + r = subprocess.run(['git', '-C', str(root), 'rev-parse', '--is-shallow-repository'], + capture_output=True, text=True) + except (OSError, ValueError): + return False + return r.returncode == 0 and r.stdout.strip() == 'true' + + def operational_checkout(root: Path) -> bool: """Whether this checkout is an operational repository, read from what it carries. @@ -1077,13 +1192,22 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ return out -def check_file(path: Path, rules: set[str]) -> list[tuple[int, str, str]]: +def check_file(path: Path, rules: set[str], + root: Path | None = None) -> list[tuple[int, str, str]]: out: list[tuple[int, str, str]] = [] try: raw = path.read_bytes().decode('utf-8') except (UnicodeDecodeError, OSError): return out lines = raw.split('\n') + # Only Markdown describes what the repo holds, so only Markdown is judged for dead paths. + # The file's own directory anchors a relative link, the repository root anchors the rest. + # No git means no deletion history to key on, so the rule stands down rather than guess. + dead_root: Path | None = None + if 'dead-path' in rules and path.suffix == '.md': + found = str(root) if root else repo_root(path) + if found: + dead_root = Path(found) if {'comment-wrap', 'comment-case'} & rules: out.extend(f for f in comment_wrap_findings(path, raw, lines) if f[1] in rules) # Outside Markdown the prose lives in the comments, and both rules judge prose, not code. @@ -1112,6 +1236,9 @@ def check_file(path: Path, rules: set[str]) -> list[tuple[int, str, str]]: if 'charset' in rules or 'charset-unknown' in rules: out.extend(f for f in charset_findings(i, line) if f[1] in rules) + if dead_root is not None: + out.extend(dead_path_findings(dead_root, path.resolve().parent, i, line)) + txt = strip_inline_code(line) prose = strip_quoted(txt) if path.suffix == '.md' else txt @@ -1226,6 +1353,12 @@ def main(argv: list[str] | None = None) -> int: print('note: home-path is not checked in an operational repository, where an absolute ' 'path is the operator instruction rather than observed data.', file=sys.stderr) + # Announced for the same reason the skip above is, a silent stand-down reads as a pass. + if 'dead-path' in rules and git_roots and shallow_checkout(scan_root): + rules.discard('dead-path') + print('note: dead-path is not checked in a shallow clone, which holds no deletion ' + 'history to key on. Fetch the full history to run it.', file=sys.stderr) + # Every input to a verdict is read from the repository scanned rather than from the process. # That covers the rule set above, the file set, the diff, and the keys joining the last two. # An earlier guard refused a scan of one repository while the process stood in another. @@ -1283,7 +1416,7 @@ def main(argv: list[str] | None = None) -> int: byfile: dict[str, int] = {} for f in files: allowed = scope.get(keys[f]) if scope is not None else None - for ln, kind, msg in check_file(f, rules): + for ln, kind, msg in check_file(f, rules, scan_root): if allowed is not None and ln not in allowed: continue total += 1 diff --git a/scripts/test_prose_lint.py b/scripts/test_prose_lint.py index 542b5a37..462b3315 100644 --- a/scripts/test_prose_lint.py +++ b/scripts/test_prose_lint.py @@ -2325,6 +2325,135 @@ def test_the_reported_scope_counts_only_what_the_verdict_covered(self) -> None: self.assertIn('1 of 2 file(s) read, 2 changed line(s)', self.err.getvalue()) +class TestDeadPath(unittest.TestCase): + """The named-path half of the stale-description class (RESYNC.md section 4). + + Every fixture builds a real repository, because the rule keys on git history: a path is + reported only when git once tracked it and the tree no longer holds it, which is the + deletion-sweep shape. The measured incident named no path at all, and that half stays a + manual read, so nothing here asserts coverage the rule does not have. + """ + + def setUp(self) -> None: + self.tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) + + def git(self, root: Path, *args: str) -> None: + # Signing is disabled explicitly, since a host that signs by default cannot commit here. + subprocess.run(['git', '-C', str(root), '-c', 'user.email=gate@example.invalid', + '-c', 'user.name=gate test', '-c', 'commit.gpgsign=false', *args], + check=True, capture_output=True) + + def repo(self) -> Path: + """A repository that tracked `scripts/gone.py` once and then deleted it.""" + root = self.tmp / 'repo' + (root / 'scripts').mkdir(parents=True) + (root / 'scripts' / 'gone.py').write_text('print()\n', encoding='utf-8') + (root / 'scripts' / 'kept.py').write_text('print()\n', encoding='utf-8') + self.git(root, 'init', '-q') + self.git(root, 'add', '-A') + self.git(root, 'commit', '-qm', 'base') + self.git(root, 'rm', '-q', 'scripts/gone.py') + self.git(root, 'commit', '-qm', 'delete') + return root + + def kinds(self, root: Path, text: str, rel: str = 'DOC.md') -> list[str]: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding='utf-8') + return [kind for _, kind, _ in prose_lint.check_file(path, {'dead-path'}, root)] + + def test_a_deleted_path_in_a_span_is_flagged(self) -> None: + root = self.repo() + self.assertIn('dead-path', self.kinds(root, 'Run `scripts/gone.py` to apply.\n')) + + def test_a_living_path_is_not_a_finding(self) -> None: + root = self.repo() + self.assertEqual([], self.kinds(root, 'Run `scripts/kept.py` to apply.\n')) + + def test_a_path_with_no_history_here_is_not_a_finding(self) -> None: + """A proposal in a backlog and another repository's layout both name unborn paths.""" + root = self.repo() + self.assertEqual([], self.kinds(root, 'Ship `scripts/future.py` next.\n')) + + def test_a_reference_definition_to_a_deleted_target_is_flagged(self) -> None: + root = self.repo() + self.assertIn('dead-path', self.kinds(root, '[gone]: ./scripts/gone.py\n')) + self.assertEqual([], self.kinds(root, '[kept]: ./scripts/kept.py\n')) + + def test_an_inline_link_to_a_deleted_target_is_flagged(self) -> None: + root = self.repo() + self.assertIn('dead-path', self.kinds(root, 'See [the script](scripts/gone.py).\n')) + + def test_a_relative_mention_anchors_on_the_files_own_directory(self) -> None: + root = self.repo() + self.assertIn('dead-path', + self.kinds(root, 'See [it](../scripts/gone.py).\n', rel='docs/D.md')) + self.assertEqual([], + self.kinds(root, 'See [it](../scripts/kept.py).\n', rel='docs/D.md')) + + def test_a_ref_or_directory_shaped_span_is_not_a_candidate(self) -> None: + """`origin/develop` styles a ref and `scripts/` a layout, and neither asserts a file.""" + root = self.repo() + for text in ('Fetched from `origin/develop` here.\n', + 'The `scripts/` tree holds the tools.\n', + 'A run like `./scripts/...` is elided.\n'): + with self.subTest(text=text.strip()): + self.assertEqual([], self.kinds(root, text)) + + def test_a_fenced_block_is_skipped(self) -> None: + """A fence quotes a transcript or an example, which may legitimately show any path.""" + root = self.repo() + self.assertEqual([], self.kinds(root, '```sh\npython3 scripts/gone.py\n```\n')) + + def test_a_manifest_declared_carried_path_is_exempt(self) -> None: + """The hub's instance of a carried file retires to a snippet, and docs still name it.""" + root = self.repo() + (root / 'spec').mkdir() + (root / 'spec' / 'files.json').write_text( + json.dumps({'baseline': [{'path': 'scripts/gone.py'}]}), encoding='utf-8') + prose_lint.carried_paths.cache_clear() + self.addCleanup(prose_lint.carried_paths.cache_clear) + self.assertEqual([], self.kinds(root, 'Run `scripts/gone.py` to apply.\n')) + + def test_without_git_the_rule_stands_down(self) -> None: + """No history means no deletion signature, so nothing is reported rather than guessed.""" + bare = self.tmp / 'bare' + bare.mkdir() + path = bare / 'DOC.md' + path.write_text('Run `scripts/gone.py` to apply.\n', encoding='utf-8') + self.assertEqual([], [k for _, k, _ in prose_lint.check_file(path, {'dead-path'})]) + + def test_a_shallow_clone_stands_down_loudly(self) -> None: + """A shallow clone holds no deletion history, so a run there says so and reports nothing. + + Without the announcement a shallow CI checkout would pass every mention forever, which + is the silent-stop failure the sweep floor exists to prevent. + """ + root = self.repo() + (root / 'DOC.md').write_text('Run `scripts/gone.py` to apply.\n', encoding='utf-8') + self.git(root, 'add', '-A') + self.git(root, 'commit', '-qm', 'doc') + shallow = self.tmp / 'shallow' + subprocess.run(['git', 'clone', '-q', '--depth', '1', f'file://{root}', str(shallow)], + check=True, capture_output=True) + err = io.StringIO() + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(err), \ + contextlib.chdir(shallow): + rc = prose_lint.main(['.', '--check', 'dead-path']) + self.assertEqual(0, rc) + self.assertIn('shallow', err.getvalue()) + + def test_this_repository_is_clean_of_dead_paths(self) -> None: + """The gate ships clean on its own tree, so every finding after this is a regression.""" + md = [p for p in REPO.rglob('*.md') + if not prose_lint.GENERATED_ROOTS.intersection(p.relative_to(REPO).parts) + and 'reports' not in p.relative_to(REPO).parts] + hits = [(prose_lint.rel(p), ln, msg) for p in md + for ln, kind, msg in prose_lint.check_file(p, {'dead-path'}, REPO) + if kind == 'dead-path'] + self.assertEqual([], hits) + + class TestHarness(unittest.TestCase): def test_this_module_collects_a_plausible_number_of_cases(self) -> None: """A module whose cases fail to load still reports OK, which is a pass proving nothing.""" diff --git a/spec/audit.py b/spec/audit.py index 2e09af31..2dca9ed6 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -6,8 +6,9 @@ names (spec/secrets.json; values are never read), baseline/per-type file presence and per-scope Markdown section presence on the ground-truth branch (spec/files.json, spec/scope-model.md), hub-hosted files a repo carries and should delete (git-tracked here and undeclared in the manifest, -triaged by spec/divergences.json), and branch-model facts (main/develop existence, develop behind -main). Owner-initiated: run it when +triaged by spec/divergences.json), intent-staleness advisories (a carried intent file whose hub +canonical changed after the copy last did), and branch-model facts (main/develop existence, develop +behind main). Owner-initiated: run it when onboarding a repo, when drift is suspected, or before fleet-wide changes. Read-only - it never modifies a target. @@ -1092,6 +1093,56 @@ def git_file_history(rel_path): return out +@functools.cache +def hub_last_change(rel_path): + """The hub checkout's last commit touching rel_path, as (iso_date, short_sha), or None if untracked. + + Cached because one canonical's date is compared against every audited repo's copy. + """ + r = subprocess.run(["git", "log", "-1", "--format=%cI %h", "--", rel_path], cwd=ROOT, + capture_output=True, text=True) + if r.returncode != 0 or not r.stdout.strip(): + return None + date, sha = r.stdout.strip().split(" ", 1) + return date, sha + + +def check_intent_staleness(slug, ground, path, canonical_rel, down_text): + """The intent-staleness advisory: a last-modified comparison, since intent has no content check. + + An intent unit is judged by meaning, so the audit asserts presence and nothing about content + (spec/fidelity-model.md), which is how a copy trailed the hub by many revisions while every + check read clean. No reconciliation record exists anywhere, so the implementable proxy is + when each side last changed: the hub canonical changing after the repo's copy marks the copy + as possibly trailing. Advisory only, DRIFT and never a failure, and honest about its blind + spot: a copy touched after the hub change without actually reconciling reads current. + + A copy content-identical to the canonical cannot trail it, so that case is skipped however + old the copy's last commit is. It is also the promotion candidate spec/fidelity_honesty.py + exists to find, where the structural fix is verbatim fidelity rather than a better advisory. + """ + hub_change = hub_last_change(canonical_rel) + if hub_change is None: + return [] + if down_text is not None: + try: + canon_text = (ROOT / canonical_rel).read_text(encoding="utf-8", errors="replace") + except OSError: + canon_text = None + if canon_text is not None and content_hash(down_text) == content_hash(canon_text): + return [] + commits = gh(f"repos/{slug}/commits?path={path}&sha={ground}&per_page=1") + if not commits: + return [] + repo_date = commits[0]["commit"]["committer"]["date"] + hub_date, hub_sha = hub_change + if datetime.fromisoformat(hub_date) <= datetime.fromisoformat(repo_date): + return [] + return [("DRIFT", f"intent: {path} last changed {repo_date} on {ground}, and the hub canonical " + f"changed later at {hub_date} ({hub_sha}) - the copy possibly trails the hub, " + f"verify intent per AUDIT.md section 7")] + + def check_verbatim(label, down_text, canonical_rel, extract=None): """Compare a downstream copy against the hub's canonical (a region if `extract` is given), EOL-normalized, and classify a mismatch as stale or modified via the canonical's git history. All findings are DRIFT: a @@ -1307,7 +1358,7 @@ def audit_repo(entry, spec, branch=None): path_order.append(path) wanted_sections[path].update(required_sections(item, sel)) verbatim_secs[path].update(verbatim_sections(item, sel)) - if item.get("fidelity") in ("interface", "verbatim"): + if item.get("fidelity") in ("interface", "verbatim", "intent"): check_item[path] = item for path in path_order: content = gh(f"repos/{slug}/contents/{path}?ref={ground}", ok404=True) @@ -1343,6 +1394,10 @@ def audit_repo(entry, spec, branch=None): findings.append(("DRIFT", f"verbatim: could not read {path} content on {ground} to compare (no inline content returned); verify by hand")) else: findings.extend(check_verbatim(path, text, item.get("reference") or path)) + # Intent staleness: the one advisory an intent unit gets, a last-modified comparison. + # The hub's own copies are the canonicals, so the hub itself has nothing to trail. + elif item is not None and fid == "intent" and entry.get("name") != HUB_NAME: + findings.extend(check_intent_staleness(slug, ground, path, item.get("reference") or path, text)) # Heading-based presence is only meaningful for Markdown. # A "section" named on a non-md file, a tasks.json task group being one, is an intent marker judged per AUDIT.md rather than a heading grep. needed = wanted_sections[path] diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md index bd56a25a..e07e0956 100644 --- a/spec/fidelity-model.md +++ b/spec/fidelity-model.md @@ -11,7 +11,7 @@ Carried content is a class with virtual functions. The **fixed** part is the int Each [`spec/files.json`][files] entry declares one `fidelity`, defaulting to `presence`. - **presence** - the unit exists (a file, or a Markdown section heading). The audit's baseline check. -- **intent** - carried faithfully but judged by meaning, not bytes. A downstream copy legitimately differs (a governed divergence or a paraphrase), and equivalence is a human call via `intentRef`. The audit asserts nothing beyond presence. +- **intent** - carried faithfully but judged by meaning, not bytes. A downstream copy legitimately differs (a governed divergence or a paraphrase), and equivalence is a human call via `intentRef`. The audit asserts presence, plus a last-modified staleness advisory at drift: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, a hint rather than proof, and content is never judged. - **verbatim** - byte-identical to the hub's canonical after line-ending, action-pin, and job-needs normalization. The audit content-hashes the downstream copy against canonical. It applies to a whole file, a workflow job region (a job selected by key), or a Markdown section region (a `## heading` block selected by name). The section granularity lets one file be **intent overall while a few of its sections are verbatim**. A universal rule block stays byte-identical fleet-wide even though the rest of the document is a repo-adapted paraphrase, so a stale section or a missing rule is caught while its heading still passes the presence check. - **interface** - an overridable body that must honor a named contract. The audit checks the contract by name and wiring, never the body.