') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Carry the Host Contract and Declare the jq Floor by ptr727 · Pull Request #652 · ptr727/ProjectTemplate · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion AUDIT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,14 @@ This audit is not occasional. Run it whenever you **create, adopt, or materially
- **Onboarding a repo is complete only when it either passes this audit** (operational on every applicable check) **or carries a committed `reports/<repo>/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.

**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.

```shell
python3 scripts/host_gate.py --repo <path-to-target-checkout> # run from a hub checkout, floors from spec/host-tools.json
```

Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory. Omitting it does not read the target's declaration at all, so every floor that repo adds goes unapplied, and the run reports nothing about the omission. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks.

## 1. Scope and Ground-Truth Branch

Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. Read `develop` only to detect divergence. A stale or diverged `develop` (behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat a `develop`-only file as present if it is absent on `main`.
Expand DownExpand Up@@ -84,7 +92,7 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions:
# Rules sort on each rule's whole content, matching the key normalize_ruleset in audit.py sorts by.
# Sorting on .type alone leaves two rules of one type in input order, so a reordered pair would read as drift.
# canon sorts keys at every depth before serializing, because the committed payload is written key-sorted and the API returns its own order, so a bare tojson gives the same rule two different sort keys.
# It recurses rather than calling walk/1, which arrived in jq 1.6, and no declared floor puts a host above that.
# It recurses rather than calling walk/1, which the declared floor does make available, because the recursion costs nothing and compiles below the floor as well.
# A host on jq 1.5 would not degrade on walk, it would fail to compile the filter and report drift on every ruleset it never compared, which is what repo-config/configure.sh defines its own recursion to avoid.
canon='def canon: . as $in | if type == "object" then reduce (keys_unsorted|sort)[] as $k ({}; . + { ($k): ($in[$k]|canon) }) elif type == "array" then map(canon) else . end;'
norm="$canon"'{name,target,enforcement,conditions,rules} | .rules|=sort_by(canon|tojson)'
Expand DownExpand Up@@ -174,6 +182,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta
[governance-branching-model]: ./GOVERNANCE.md#branching-model
[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling
[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette
[host-setup]: ./docs/host-setup.md
[project-types]: ./spec/project-types.json
[readme-sections]: ./spec/readme-sections.json
[readme-structure]: ./spec/readme-structure.md
Expand Down
2 changes: 2 additions & 0 deletions RESYNC.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ Three states look similar from inside a repository and take different procedures

**The distinction is measured, not assumed.** `spec/audit.py <Repo>` reports an absent carried file as a `LETTER` and a present-but-stale one as a `DRIFT`, so the finding kinds themselves say which procedure the repository is owed. A run that is mostly letters is a repository that needs [`STANDUP.md`][standup].

**A few letters are not that, and the ordinary cause is the baseline having grown.** When the manifest gains an entry, every repository in the fleet reports that one file absent on its next audit, all at once and through no fault of its own. That is a file to carry inside a resync, at the point in section 3 where its class belongs, rather than evidence the repository was never stood up. Read the letters before routing on their count: a handful naming files the hub added recently is a resync, and a baseline's worth naming the instruction set is a standup.

## 1. Reach the Hub, and Verify the Host

**Fetch a hub checkout of your own immediately before reading it.** A clone is whatever it last fetched rather than the branch it names, so a stale one answers confidently instead of failing, and a resync driven from a stale hub converges a repository onto a revision that is already history. Read `main`, the promoted and gated state, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. Work only in that checkout rather than in one that another task is using.
Expand Down
24 changes: 23 additions & 1 deletion STANDUP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,22 @@ if [ "$(git config --global --get gpg.format)" = ssh ]; then ssh-add -L; else gp

`--global` rather than the effective config, because the effective value depends on where the command runs: inside any existing repository a repo-local override wins, so a bare `git config --get user.email` there reports that repository's identity and hides the host setting this step exists to check. The two scopes together are what make the result sound, since this block proves the host is right and the block below proves nothing shadows it.

**Verify the host's tools in the same step, since identity is only half of what a standup needs from a machine.** The tools carry version floors, and a host below one does not fail cleanly: it answers `--version`, looks healthy, and produces a wrong answer, which is how both host defects this fleet has hit arrived.

```shell
python3 scripts/host_gate.py # from a hub checkout, against the fleet floors in spec/host-tools.json
```

**No `--repo` here, and that is the one place in these procedures where it is omitted deliberately.** The flag points the gate at a repo's own `host-tools.json` so its floors are layered over the fleet ones, and at this step there is no repo to point it at: the target does not exist yet, since this section runs before the `git init` in section 0B, and the file itself arrives with the baseline in section 2. So this run checks the fleet floors, which is all that is knowable now.

**Re-run it with `--repo` once section 2 has carried the file**, because a bare run does not read the target's declaration at all, so any floor that repo adds goes unapplied and the run cannot tell you it was skipped:

```shell
python3 scripts/host_gate.py --repo <path-to-target-checkout> # after section 2, so the repo's own floors count
```

A finding at either point is a **host** misconfiguration to fix on the machine or surface to the maintainer, never something to patch per repo, and [`docs/host-setup.md`][host-setup] is the contract it checks.

The agent check branches rather than listing both forms, because they are alternatives and running the wrong one fails on a correctly configured host: an SSH host need not have `gpg` installed at all. Signing is **SSH or GPG**, so judge the format and its agent together rather than requiring `ssh`: what matters is that the configured format has a matching agent holding the key, which is the check [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] prescribes. Any of these wrong or absent is a **host** misconfiguration to surface to the maintainer ([`docs/host-setup.md`][host-setup] is the setup procedure), not something to patch per repo. Patching it locally hides a broken host that then produces wrong identities in every other repo on that machine.

After `git init` and before the first commit, confirm the repo added no override of its own. This one needs a repository, since `--local` fails outside one. Read it here and run it in section 0B, which places it between the init and the first commit, so nothing here is a prompt to init early:
Expand DownExpand Up@@ -104,7 +120,7 @@ Capture the source, verify the capture **against the source**, and hold the veri

## 2. Carry the Baseline Files

Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **selector set**, **adapted, not cloned**. The selector set is the repo's `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`, so filtering on type alone silently drops the entries a non-type selector carries ([`spec/scope-model.md`][scope-model] defines the four namespaces and how they resolve). The prose files (`CODESTYLE.md`, `README.md`, and the like) describe the repo's own toolchain, so adapt them to reality rather than propagating template specifics verbatim (see the "Adapt before propagating" callout in [`CODESTYLE.md`][codestyle], since a verbatim copy that misdescribes the repo is rejected in review). The baseline covers `WORKFLOW.md`, `version.json`, the two rulesets, `.github/dependabot.yml`, `.editorconfig`, `.gitattributes`, the linter configs, and the per-type files (`.vscode/tasks.json` from the language's snippet, `codecov.yml`, `.dockerignore`, `Docker/README.md`). **Every repo carries `repo-config/main.json`**, and only the `develop` payload varies by workflow model: `repo-config/develop.json` for a release repo, `repo-config/operational/develop.json` for an operational one.
Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **selector set**, **adapted, not cloned**. The selector set is the repo's `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`, so filtering on type alone silently drops the entries a non-type selector carries ([`spec/scope-model.md`][scope-model] defines the four namespaces and how they resolve). The prose files (`CODESTYLE.md`, `README.md`, and the like) describe the repo's own toolchain, so adapt them to reality rather than propagating template specifics verbatim (see the "Adapt before propagating" callout in [`CODESTYLE.md`][codestyle], since a verbatim copy that misdescribes the repo is rejected in review). The baseline covers `WORKFLOW.md`, `version.json`, the two rulesets, `.github/dependabot.yml`, `.editorconfig`, `.gitattributes`, `host-tools.json`, the linter configs, and the per-type files (`.vscode/tasks.json` from the language's snippet, `codecov.yml`, `.dockerignore`, `Docker/README.md`). **Every repo carries `repo-config/main.json`**, and only the `develop` payload varies by workflow model: `repo-config/develop.json` for a release repo, `repo-config/operational/develop.json` for an operational one.

**`version.json` is a file to carry and a floor to choose.** [`WORKFLOW.md`][workflow] D3.3 makes its `version` field the repo's own major.minor floor, with NBGV appending the git height as the patch, so the number carried in with the file is a claim about a release history the new repo does not have. Set it deliberately, at standup, before the first release:

Expand All@@ -115,6 +131,10 @@ Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's

**This decision is effectively one-way, which is why it belongs here.** Once a repo publishes against a floor, lowering it regresses the released version order, so a floor that was never chosen is kept rather than corrected. Inherited floors are the observed failure, not a hypothetical one: four operational config repos run on a floor none of them picked and have released against it.

**`host-tools.json` is carried at the repo's root, and it is not the fleet declaration.** [`spec/host-tools.json`][host-tools] states what every repo's procedures need and is the hub's to change. The carried root file states what this repo needs **beyond** that, so it is where a tool only this repo uses, or a floor only this repo requires, is declared. [`scripts/host_gate.py`][host-gate] layers the root file over the fleet one, tighten-only: an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, since that retires a fleet check from inside the repo it protects. A rejected relaxation is reported rather than dropped. A repo with nothing to add carries the stub with an empty `tools` list, the same footing as `OPERATIONS.md`, so the declaration is somewhere a reader finds rather than somewhere they must know to look.

**The carried copy drops the `$schema` pointer, and that is not an oversight to correct.** The schemas are hub-only and no selector carries one, so a relative pointer copied downstream resolves to a path that repo does not have, and a schema-aware editor then reports the file invalid for a reason the repo cannot fix. This is settled fleet practice rather than a new rule: the hub's `spec/secrets.json` carries `./secrets.schema.json` and every downstream copy of that file omits the key. Copy the structure and leave the pointer behind.

**Repo-specific content has a declared destination, not a judgment call.** The baseline is what a repo *carries*. Anything the repo knows that the fleet does not needs somewhere to live, and improvising a location per repo is what the destinations in [`spec/section-model.md`][section-model] exist to prevent. Four topical docs take it, chosen by what the content **is**:

- [`CODESTYLE.md`][codestyle]: the repo's language and formatting conventions beyond the carried rules.
Expand DownExpand Up@@ -187,7 +207,9 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and
[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
[host-gate]: ./scripts/host_gate.py
[host-setup]: ./docs/host-setup.md
[host-tools]: ./spec/host-tools.json
[matrix]: ./reports/conformance-matrix.md
[project-types]: ./spec/project-types.json
[readme-structure]: ./spec/readme-structure.md
Expand Down
2 changes: 2 additions & 0 deletions docs/host-setup.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,7 @@ This section is the **contract**: which tools a host needs and which repo proced
| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none |
| `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured |
| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target |
| `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.7**, target |
| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | none |
| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | none |

Expand DownExpand Up@@ -241,6 +242,7 @@ A host that fails any row is not ready for the procedure that row names, and the
<!-- Repo -->

[agent-safety]: ../host-setup/agent-safety/README.md
[audit]: ../AUDIT.md
[devcontainer]: ./devcontainer.md
[governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules
[host-gate]: ../scripts/host_gate.py
Expand Down
5 changes: 5 additions & 0 deletions host-tools.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
{
"$schema": "./spec/host-tools-local.schema.json",
"note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file, do not hold the same thing, and do not share a schema: an overlay allows an empty tools list and a partial entry, and the fleet declaration requires at least one entry and every field of each. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look. A repository copying this file leaves the $schema pointer behind, because the schemas are hub-only and no selector carries one, so a relative pointer resolves to a path that repository does not have and a schema-aware editor reports the file invalid for a reason nobody there can fix. The carried spec/secrets.json already works that way, holding the pointer here and omitting it in every downstream copy.",
"tools": []
}
2 changes: 1 addition & 1 deletion repo-config/configure.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -239,7 +239,7 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol
# Dropping that would turn array order into false drift.
# A scalar array sorts directly, and required_status_checks sorts by context, its identifying field.
local ptypes norm
# The walk/1 builtin arrived in jq 1.6, so it is defined here rather than called.
# The walk/1 builtin arrived in jq 1.6, which the declared floor makes available, and it is defined here rather than called so the filter also compiles below the floor.
# A host on jq 1.5 would otherwise not degrade, it would fail to compile the filter.
# The check_ruleset function would then report drift on every parameterized rule it never actually compared.
# That is the inverse of the false clean this comparison was written to close, so the portable definition is worth its length.
Expand Down
Loading