From d85b3b731695f8bcd072aecec21a6eb5e2491241 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:39:01 +0000 Subject: [PATCH 1/3] Bump the actions-deps group with 5 updates (#555) Bumps the actions-deps group with 5 updates: | Package | From | To | | --- | --- | --- | | [ptr727/ProjectTemplate/.github/workflows/validate-task.yml](https://github.com/ptr727/projecttemplate) | `2.0.1148` | `2.0.1166` | | [ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml](https://github.com/ptr727/projecttemplate) | `2.0.1148` | `2.0.1166` | | [ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml](https://github.com/ptr727/projecttemplate) | `2.0.1148` | `2.0.1166` | | [ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml](https://github.com/ptr727/projecttemplate) | `2.0.1148` | `2.0.1166` | | [ptr727/ProjectTemplate/.github/workflows/build-release-task.yml](https://github.com/ptr727/projecttemplate) | `2.0.1148` | `2.0.1166` | Updates `ptr727/ProjectTemplate/.github/workflows/validate-task.yml` from 2.0.1148 to 2.0.1166 - [Release notes](https://github.com/ptr727/projecttemplate/releases) - [Changelog](https://github.com/ptr727/ProjectTemplate/blob/main/HISTORY.md) - [Commits](https://github.com/ptr727/projecttemplate/compare/f610e4229f2acbb60d69f6d9386b341c81514534...63cf1c9436876d55889260ce43d3900da2fd9b47) Updates `ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml` from 2.0.1148 to 2.0.1166 - [Release notes](https://github.com/ptr727/projecttemplate/releases) - [Changelog](https://github.com/ptr727/ProjectTemplate/blob/main/HISTORY.md) - [Commits](https://github.com/ptr727/projecttemplate/compare/f610e4229f2acbb60d69f6d9386b341c81514534...63cf1c9436876d55889260ce43d3900da2fd9b47) Updates `ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml` from 2.0.1148 to 2.0.1166 - [Release notes](https://github.com/ptr727/projecttemplate/releases) - [Changelog](https://github.com/ptr727/ProjectTemplate/blob/main/HISTORY.md) - [Commits](https://github.com/ptr727/projecttemplate/compare/f610e4229f2acbb60d69f6d9386b341c81514534...63cf1c9436876d55889260ce43d3900da2fd9b47) Updates `ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml` from 2.0.1148 to 2.0.1166 - [Release notes](https://github.com/ptr727/projecttemplate/releases) - [Changelog](https://github.com/ptr727/ProjectTemplate/blob/main/HISTORY.md) - [Commits](https://github.com/ptr727/projecttemplate/compare/f610e4229f2acbb60d69f6d9386b341c81514534...63cf1c9436876d55889260ce43d3900da2fd9b47) Updates `ptr727/ProjectTemplate/.github/workflows/build-release-task.yml` from 2.0.1148 to 2.0.1166 - [Release notes](https://github.com/ptr727/projecttemplate/releases) - [Changelog](https://github.com/ptr727/ProjectTemplate/blob/main/HISTORY.md) - [Commits](https://github.com/ptr727/projecttemplate/compare/f610e4229f2acbb60d69f6d9386b341c81514534...63cf1c9436876d55889260ce43d3900da2fd9b47) --- updated-dependencies: - dependency-name: ptr727/ProjectTemplate/.github/workflows/validate-task.yml dependency-version: 2.0.1166 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-deps - dependency-name: ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml dependency-version: 2.0.1166 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-deps - dependency-name: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml dependency-version: 2.0.1166 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-deps - dependency-name: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml dependency-version: 2.0.1166 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-deps - dependency-name: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml dependency-version: 2.0.1166 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-site.yml | 4 ++-- .github/workflows/merge-bot-pull-request.yml | 2 +- .github/workflows/publish-release.yml | 6 +++--- .github/workflows/test-pull-request.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index abf3c04..e042858 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -40,7 +40,7 @@ jobs: validate: name: Validate sources job needs: [ assert-ref ] - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@f610e4229f2acbb60d69f6d9386b341c81514534 # 2.0.1148 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 permissions: contents: read with: @@ -58,7 +58,7 @@ jobs: deploy: name: Deploy site job needs: [ validate ] - uses: ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml@f610e4229f2acbb60d69f6d9386b341c81514534 # 2.0.1148 + uses: ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 with: environment: ${{ inputs.environment }} permissions: diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 51d3029..f7926be 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -18,7 +18,7 @@ jobs: merge-bot: name: Merge bot pull request job - uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@f610e4229f2acbb60d69f6d9386b341c81514534 # 2.0.1148 + uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 secrets: CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 804a659..2f8dfdc 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,7 +16,7 @@ jobs: # Also replaces this repo's own dispatch-ref assertion: the hub task errors identically on a ref other than main/develop. plan: name: Plan release job - uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@f610e4229f2acbb60d69f6d9386b341c81514534 # 2.0.1148 + uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 with: event_name: ${{ github.event_name }} actor: ${{ github.actor }} @@ -30,7 +30,7 @@ jobs: name: Validate sources job needs: [plan] if: ${{ needs.plan.outputs.publish == 'true' }} - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@f610e4229f2acbb60d69f6d9386b341c81514534 # 2.0.1148 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 permissions: contents: read with: @@ -54,7 +54,7 @@ jobs: # An if: on a job replaces the implicit success() over its needs. # Without the validate arm a failed gate would publish anyway. if: ${{ needs.plan.outputs.publish == 'true' && needs.validate.result == 'success' }} - uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@f610e4229f2acbb60d69f6d9386b341c81514534 # 2.0.1148 + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 permissions: contents: write actions: write diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 8a6f592..f39ba55 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -19,7 +19,7 @@ jobs: validate: name: Validate sources job - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@f610e4229f2acbb60d69f6d9386b341c81514534 # 2.0.1148 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 permissions: contents: read with: From 9ff409a75a43ec27770a9686e44ffe6eabbbd2b8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 14:54:05 -0700 Subject: [PATCH 2/3] Resync the Carried Instruction Set and Skills Tree With Hub f60f57d (#556) * Carry the Stale AGENTS and GOVERNANCE Verbatim Sections From Hub f60f57d Co-Authored-By: Claude Opus 5.5 * Carry the Hub's Env and Secret File Naming Rules Into CODESTYLE Co-Authored-By: Claude Opus 5.5 * Re-vendor Three Stale Skills From Hub f60f57d Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/skills/backlog-burndown/SKILL.md | 32 +++++++++++++++-- .github/skills/local-strict-review/SKILL.md | 27 ++++++++++++++- .github/skills/unattended-handoff/SKILL.md | 38 ++++++++++++++++++--- AGENTS.md | 17 +++++---- CODESTYLE.md | 8 +++-- GOVERNANCE.md | 5 ++- 6 files changed, 109 insertions(+), 18 deletions(-) diff --git a/.github/skills/backlog-burndown/SKILL.md b/.github/skills/backlog-burndown/SKILL.md index ef2073f..1e5dede 100644 --- a/.github/skills/backlog-burndown/SKILL.md +++ b/.github/skills/backlog-burndown/SKILL.md @@ -326,12 +326,13 @@ A worker still live, a promotion fix included, keeps its worktree until the next ### Choosing the Worker's Model Tier -`AGENTS.md` "Delegation" owns the model-tier rule and names the tiers. Read it there. This is only -what it leaves to judgment here. The tier is chosen per group rather than defaulted, since a +`AGENTS.md` "Model Tiers" owns the model-tier rule and names the tiers, carried under "Model Tiers" +below. This is only what it leaves to judgment here. The tier is chosen per group rather than defaulted, since a stronger tier produces better work and takes fewer review rounds to land it. That often costs less than a cheaper worker looping. The tier is `frontier` or `standard`, never `bulk`, since every worker judges its pull request's review findings. It is `highest` only where the maintainer -named it for this run. Groups of these kinds are never tiered +named it for this run and launched the session with it allowed, per "Model Tiers" below. Where a +dispatch at it is refused, ask the maintainer rather than dispatching lower. Groups of these kinds are never tiered below `frontier`: - One touching **carried canonical content**, as `GOVERNANCE.md` "Verification Discipline" bounds @@ -536,6 +537,31 @@ Report at every round boundary and at each ending: what merged to develop, what request carries, what was newly filed, what is stopped and on which question, what was held back and on which blocker, and what the next round would pick. +## Model Tiers + +`AGENTS.md` "Model Tiers" keeps the tier rule, carried here whole as a generated include. A tier +this skill names then resolves to its model even where a carried `AGENTS.md` predates the table. + + + +A rule, a Skill, or a brief names a model tier, never a model. A model release is then an edit to the table below and to the hub's `spec/model-tiers.json`. That file states the same mapping as data, and the hub's spec gate holds the two equal. Reasoning effort is a separate axis stated beside the tier, such as `bulk` at low effort. + +- **`frontier`** is the fleet's default reasoning tier, for orchestrating, rule text, design, spec logic, and adversarial review. It is not the most capable model available, which is `highest`. +- **`standard`** is ordinary implementation and driving a pull request. Judging a review finding is never mechanical, however often it repeats, so it runs at `standard` or above. A `standard` driver's evidence-backed decline of a finding raised on its pull request stands, since that pull request's reviewers and its merge gate check it. +- **`bulk`** is the cheapest model that does the work correctly. It is for high fan-out mechanical work, such as an extraction or a status check. +- **`highest`** is the most capable model available, and only the maintainer selects it. The maintainer chooses it for the session itself, or allows it for the session's subagents at launch. The host's agent-safety kit reads that launch setting, `FLEET_ALLOW_HIGHEST=1` on Claude Code. Naming the tier in a prompt does not set it. A maintainer naming it partway through a session restarts the session with it allowed, and an agent never sets it. Without that setting, the kit refuses a dispatch that names it. A dispatch naming no model runs at its agent definition's model where one is set, and otherwise at the session's own. A fork always runs at the session's model, whatever it names. So a session launched on `highest` names a tier on every dispatch meant to run below it, and uses no fork for such work. A session never reaches for it because a task looks hard. + +| Tool | `highest` | `frontier` | `standard` | `bulk` | +| --- | --- | --- | --- | --- | +| Claude Code | `fable` | `opus` | `sonnet` | `haiku` | + +Each cell is the name that tool's subagent dispatch takes, which follows the latest model of its family. A tool with no row here, or one that cannot name a model per subagent, runs every tier at the session's own model. + +- **A tier resolves only through this table.** A word such as "frontier", "strongest", or "most capable" never resolves to `highest`. The industry's "frontier model" is the opposite of what this tier means here. A session that cannot see this table names no model rather than guessing one, so the subagent runs at the session's own model. +- **An orchestrating seat runs at `frontier` and names a tier on every dispatch.** It defaults a subagent to `frontier`, and picks `standard` or `bulk` deliberately for execution or mechanical work. A dispatch that names no model inherits whatever the session runs on, which is a choice nobody made. + + + ## Mechanics Live Elsewhere - The review loop, the Merge Gate, the finding outcomes, and when a finding is a question: diff --git a/.github/skills/local-strict-review/SKILL.md b/.github/skills/local-strict-review/SKILL.md index fac8dfd..144f2d8 100644 --- a/.github/skills/local-strict-review/SKILL.md +++ b/.github/skills/local-strict-review/SKILL.md @@ -72,7 +72,7 @@ Bounds: read-only. No edit, no stage, no commit, no push, no PR-hosted write of Before dispatching, grep the tree for other statements of each rule the diff adds or changes, and add each file holding one to the `Paths:` floor, so a statement the diff has put in disagreement is read rather than missed. -**Model tier:** `frontier`, the tier `AGENTS.md` "Delegation" names for adversarial review, which this pass is. Where `frontier` is not reachable, run the pass on the strongest tier below it that is, even the one that authored the change. A second, adversarially-prompted look still catches what the authoring pass's own "looks ready" judgment did not. +**Model tier:** `frontier`, the tier "Model Tiers" below names for adversarial review, which this pass is. Where `frontier` is not reachable, run the pass on the strongest tier below it that is, even the one that authored the change. A second, adversarially-prompted look still catches what the authoring pass's own "looks ready" judgment did not. Reachable means the tier this session can name when it dispatches the reviewer, rather than the tier this session is itself running on. A session deliberately tiered down for execution work, a worker dispatched by an orchestrator being the ordinary case, names `frontier` for the reviewer where its harness lets it, since tiering down the author is the reason the reviewer must not follow it down. What a given harness and account actually permit varies, so treat this as the tier to ask for rather than one to assume. Where a dispatch exposes no way to name a tier, take what it gives and run the pass, for the reason the fallback above states. A seat that cannot dispatch a subagent hands the pass to whoever dispatched it. The seat commits, runs `python3 /scripts/local_review.py status --target ` in its worktree, and hands back its worktree path, target, and `contentDigest`. The dispatcher fills the brief's `` slot and runs the reviewer. It then runs the record command from "Recording the Pass" in that worktree, with `--reviewer agent-skill --target --expect-digest `. It returns the findings. Each fix commit repeats this cycle, and the seat pushes only when a record matches its content. The brief dispatching the seat states whether the dispatcher can continue that seat. Where the dispatcher cannot, or under `unattended-handoff`, the seat stops, reports, and does not push. Where nobody dispatched the seat, it reports to the maintainer that it could not run the pass, and does not push. Either way the push waits rather than the pass being quietly skipped. The headless `run --backend` route under "Recording the Pass" is not the substitute: it runs a vendor CLI against its own review, which never carries the brief above, so it satisfies the rule this section states only where that separate route is what a capture point asked for. @@ -176,6 +176,31 @@ In the hub, `.husky/pre-push` checks the receipt at the push itself, so the mome This table is the fleet's one enumeration of these, and every other surface states the principle and routes here rather than listing the shapes. That is deliberate: every review round that added a shape also left a restatement of it somewhere else, and keeping one table is what stops the next round doing the same. +## Model Tiers + +`AGENTS.md` "Model Tiers" keeps the tier rule, carried here whole as a generated include. A tier +this skill names then resolves to its model even where a carried `AGENTS.md` predates the table. + + + +A rule, a Skill, or a brief names a model tier, never a model. A model release is then an edit to the table below and to the hub's `spec/model-tiers.json`. That file states the same mapping as data, and the hub's spec gate holds the two equal. Reasoning effort is a separate axis stated beside the tier, such as `bulk` at low effort. + +- **`frontier`** is the fleet's default reasoning tier, for orchestrating, rule text, design, spec logic, and adversarial review. It is not the most capable model available, which is `highest`. +- **`standard`** is ordinary implementation and driving a pull request. Judging a review finding is never mechanical, however often it repeats, so it runs at `standard` or above. A `standard` driver's evidence-backed decline of a finding raised on its pull request stands, since that pull request's reviewers and its merge gate check it. +- **`bulk`** is the cheapest model that does the work correctly. It is for high fan-out mechanical work, such as an extraction or a status check. +- **`highest`** is the most capable model available, and only the maintainer selects it. The maintainer chooses it for the session itself, or allows it for the session's subagents at launch. The host's agent-safety kit reads that launch setting, `FLEET_ALLOW_HIGHEST=1` on Claude Code. Naming the tier in a prompt does not set it. A maintainer naming it partway through a session restarts the session with it allowed, and an agent never sets it. Without that setting, the kit refuses a dispatch that names it. A dispatch naming no model runs at its agent definition's model where one is set, and otherwise at the session's own. A fork always runs at the session's model, whatever it names. So a session launched on `highest` names a tier on every dispatch meant to run below it, and uses no fork for such work. A session never reaches for it because a task looks hard. + +| Tool | `highest` | `frontier` | `standard` | `bulk` | +| --- | --- | --- | --- | --- | +| Claude Code | `fable` | `opus` | `sonnet` | `haiku` | + +Each cell is the name that tool's subagent dispatch takes, which follows the latest model of its family. A tool with no row here, or one that cannot name a model per subagent, runs every tier at the session's own model. + +- **A tier resolves only through this table.** A word such as "frontier", "strongest", or "most capable" never resolves to `highest`. The industry's "frontier model" is the opposite of what this tier means here. A session that cannot see this table names no model rather than guessing one, so the subagent runs at the session's own model. +- **An orchestrating seat runs at `frontier` and names a tier on every dispatch.** It defaults a subagent to `frontier`, and picks `standard` or `bulk` deliberately for execution or mechanical work. A dispatch that names no model inherits whatever the session runs on, which is a choice nobody made. + + + ## Mechanics Live Elsewhere - Review criteria: `fleet-code-review`. diff --git a/.github/skills/unattended-handoff/SKILL.md b/.github/skills/unattended-handoff/SKILL.md index 42aea3b..218c4d7 100644 --- a/.github/skills/unattended-handoff/SKILL.md +++ b/.github/skills/unattended-handoff/SKILL.md @@ -90,22 +90,25 @@ maintainer starts a second run on a repository only once the first has ended. The orchestrator first resolves `/` from the checkout's `origin`, or takes it from the invocation. Apart from the timer "A Dispatch That Fails" names, that is the one command it runs. -From then on it holds only the scope and any `highest` the invocation named, the round count, and +From then on it holds only the scope and any allowed `highest` the invocation named, the round count, and the dispatched handoff numbers. It also holds each round's one-line outcome and the waits spent on a dispatch being retried. Each round runs: -1. **Dispatch the picker** at `frontier` with the brief below, or at `highest` where the - maintainer's invocation named that tier or its model. Its ranking, decision, and worker-tier +1. **Dispatch the picker** at `frontier` with the brief below. Use `highest` only where the + invocation named that tier or its model and the session was launched with it allowed, per + "Model Tiers" below. A refusal there ends the run the way step 3's does. Its ranking, decision, and worker-tier calls are the ones `backlog-burndown` keeps in its orchestrator, and here no maintainer reviews them. Wait on the dispatch mechanism's own completion signal rather than polling. 2. **Read its one line.** `NONE` or `STOP` ends the run, and so does any reply that is not a picker line below. A handoff number already dispatched in this run ends it too, since a handoff a worker neither closed nor parked means the worker failed in a way this seat must not investigate. -3. **Dispatch the worker** on that handoff and track, at the model `AGENTS.md` "Delegation" names +3. **Dispatch the worker** on that handoff and track, at the model "Model Tiers" below names for the tier in the picker's line. Wait the same way. Dispatch at `highest` instead only where - the maintainer's invocation named that tier or its model. + the invocation named that tier or its model and the session was launched with it allowed, per + "Model Tiers" below. Without that allowance every such dispatch is refused. The run then ends + and reports the refusal rather than continuing at a tier nobody chose. 4. **Record its one line** and start the next round. `STOP` ends the run, and so does a reply that is not one of the lines below, rather than being read further. @@ -180,6 +183,31 @@ Under `main` or `release` every unblocked round promotes, so each promotion ordi fix. A change another session merged to develop meanwhile rides along with it. Naming the scope accepts that. +## Model Tiers + +`AGENTS.md` "Model Tiers" keeps the tier rule, carried here whole as a generated include. A tier +this skill names then resolves to its model even where a carried `AGENTS.md` predates the table. + + + +A rule, a Skill, or a brief names a model tier, never a model. A model release is then an edit to the table below and to the hub's `spec/model-tiers.json`. That file states the same mapping as data, and the hub's spec gate holds the two equal. Reasoning effort is a separate axis stated beside the tier, such as `bulk` at low effort. + +- **`frontier`** is the fleet's default reasoning tier, for orchestrating, rule text, design, spec logic, and adversarial review. It is not the most capable model available, which is `highest`. +- **`standard`** is ordinary implementation and driving a pull request. Judging a review finding is never mechanical, however often it repeats, so it runs at `standard` or above. A `standard` driver's evidence-backed decline of a finding raised on its pull request stands, since that pull request's reviewers and its merge gate check it. +- **`bulk`** is the cheapest model that does the work correctly. It is for high fan-out mechanical work, such as an extraction or a status check. +- **`highest`** is the most capable model available, and only the maintainer selects it. The maintainer chooses it for the session itself, or allows it for the session's subagents at launch. The host's agent-safety kit reads that launch setting, `FLEET_ALLOW_HIGHEST=1` on Claude Code. Naming the tier in a prompt does not set it. A maintainer naming it partway through a session restarts the session with it allowed, and an agent never sets it. Without that setting, the kit refuses a dispatch that names it. A dispatch naming no model runs at its agent definition's model where one is set, and otherwise at the session's own. A fork always runs at the session's model, whatever it names. So a session launched on `highest` names a tier on every dispatch meant to run below it, and uses no fork for such work. A session never reaches for it because a task looks hard. + +| Tool | `highest` | `frontier` | `standard` | `bulk` | +| --- | --- | --- | --- | --- | +| Claude Code | `fable` | `opus` | `sonnet` | `haiku` | + +Each cell is the name that tool's subagent dispatch takes, which follows the latest model of its family. A tool with no row here, or one that cannot name a model per subagent, runs every tier at the session's own model. + +- **A tier resolves only through this table.** A word such as "frontier", "strongest", or "most capable" never resolves to `highest`. The industry's "frontier model" is the opposite of what this tier means here. A session that cannot see this table names no model rather than guessing one, so the subagent runs at the session's own model. +- **An orchestrating seat runs at `frontier` and names a tier on every dispatch.** It defaults a subagent to `frontier`, and picks `standard` or `bulk` deliberately for execution or mechanical work. A dispatch that names no model inherits whatever the session runs on, which is a choice nobody made. + + + ## Auto-Resolvable An issue needs no maintainer decision when every one of these holds. Where any is unclear, it diff --git a/AGENTS.md b/AGENTS.md index 67a6bd5..b78b0db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,20 +54,25 @@ An agent session is billed on the context it carries, not the work it does. Ever - **Report an execution boundary separately from a check finding.** A denied path, network request, or Docker socket says the check did not run. Preserve that failure, then use the executor's approval mechanism for the required rerun. Request the narrowest reusable command prefix the executor supports. Report the rerun's result as the verification evidence. - **Bound every probe, and prove it ended.** A throwaway probe or test harness runs under `timeout`, and one that can recurse or fan out also runs under a task and memory cap. Start it in a process group of its own, as `setsid --wait timeout ` on Linux, and end it by that group with `kill -- -`. Without `--wait`, `setsid` returns at once when its caller leads a process group, and the probe runs on detached. A `pkill -f` pattern that matches nothing exits 1 and kills nothing. Then confirm with `pgrep` that nothing from any earlier run survives, since a later clean run proves nothing about an earlier one. Never suppress a cleanup command's output or exit status. A command the harness moved to the background, where no background job was intended, is stopped and confirmed gone before work continues. -### Delegation +### Model Tiers -A rule, a Skill, or a brief names a model tier, never a model. A model release is then an edit to the table below alone. Reasoning effort is a separate axis stated beside the tier, such as `bulk` at low effort. +A rule, a Skill, or a brief names a model tier, never a model. A model release is then an edit to the table below and to the hub's `spec/model-tiers.json`. That file states the same mapping as data, and the hub's spec gate holds the two equal. Reasoning effort is a separate axis stated beside the tier, such as `bulk` at low effort. -- **`frontier`** is the strongest reasoning the fleet uses by default, for rule text, design, spec logic, and adversarial review. +- **`frontier`** is the fleet's default reasoning tier, for orchestrating, rule text, design, spec logic, and adversarial review. It is not the most capable model available, which is `highest`. - **`standard`** is ordinary implementation and driving a pull request. Judging a review finding is never mechanical, however often it repeats, so it runs at `standard` or above. A `standard` driver's evidence-backed decline of a finding raised on its pull request stands, since that pull request's reviewers and its merge gate check it. - **`bulk`** is the cheapest model that does the work correctly. It is for high fan-out mechanical work, such as an extraction or a status check. -- **`highest`** is the most capable model available. Only the maintainer selects it, naming the tier or the model in the session or the invocation. +- **`highest`** is the most capable model available, and only the maintainer selects it. The maintainer chooses it for the session itself, or allows it for the session's subagents at launch. The host's agent-safety kit reads that launch setting, `FLEET_ALLOW_HIGHEST=1` on Claude Code. Naming the tier in a prompt does not set it. A maintainer naming it partway through a session restarts the session with it allowed, and an agent never sets it. Without that setting, the kit refuses a dispatch that names it. A dispatch naming no model runs at its agent definition's model where one is set, and otherwise at the session's own. A fork always runs at the session's model, whatever it names. So a session launched on `highest` names a tier on every dispatch meant to run below it, and uses no fork for such work. A session never reaches for it because a task looks hard. | Tool | `highest` | `frontier` | `standard` | `bulk` | | --- | --- | --- | --- | --- | -| Claude Code | Fable | Opus | Sonnet | Haiku | +| Claude Code | `fable` | `opus` | `sonnet` | `haiku` | + +Each cell is the name that tool's subagent dispatch takes, which follows the latest model of its family. A tool with no row here, or one that cannot name a model per subagent, runs every tier at the session's own model. -A tool with no row here, or one that cannot name a model per subagent, runs every tier at the session's own model. +- **A tier resolves only through this table.** A word such as "frontier", "strongest", or "most capable" never resolves to `highest`. The industry's "frontier model" is the opposite of what this tier means here. A session that cannot see this table names no model rather than guessing one, so the subagent runs at the session's own model. +- **An orchestrating seat runs at `frontier` and names a tier on every dispatch.** It defaults a subagent to `frontier`, and picks `standard` or `bulk` deliberately for execution or mechanical work. A dispatch that names no model inherits whatever the session runs on, which is a choice nobody made. + +### Delegation - **Delegate exploration, keep judgment.** A subagent starts from an empty context and returns only its conclusion, so a wide search, a multi-file audit, or a "which of these is affected" question costs a fraction of the same work inline. Delegate when the finding compresses to a short answer, and stay inline when the intermediate detail drives the next edit. - **Match the model tier to the judgment, not to the diff size.** Mechanical work (a known-shape edit repeated across files, an extraction, a status check, a lint fix) runs at `bulk`, at the lowest effort that holds. State the tier in the delegation itself rather than accepting the default. A change to a gate, a ruleset, a release condition, or a carried governance section is a design change however small it looks. diff --git a/CODESTYLE.md b/CODESTYLE.md index fbf502a..defa4d7 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -54,12 +54,16 @@ A command-line script is one a person or an agent runs by name. A hook its host | Shell `.sh` and PowerShell `.ps1` | lowercase-hyphenated | The Linux command convention. A cross-platform pair keeps one name, `sync-tools.sh` beside `sync-tools.ps1` | | Windows batch `.cmd` and `.bat` | lowercase-hyphenated | The same convention as shell and PowerShell. A shim that puts a command on `PATH` takes the name of the command it provides, `sync-tools.cmd` launching `sync-tools.ps1` | | PowerShell functions inside a `.ps1` | Verb-Noun with an approved verb (`Get-Verb`) | PSScriptAnalyzer `PSUseApprovedVerbs`. This is a command rule, not a file rule | -| Env and secret files | lowercase snake_case stem | `mail_relay.env`, `db_root_password` | +| Env and secret files, tracked or not | lowercase fields joined by `_`, with no dot but the extension | `mail_relay.env`, `db_root_password`, `app_web-01_prod.env` | | Env variable names and CI secret names | UPPER_SNAKE_CASE | The POSIX and GitHub convention | - **Installed commands.** An executable or launcher on `PATH` or in a system slot (systemd, cron, run-parts, motd) is named as a command, whatever its language. That name is lowercase-hyphenated. It carries no extension unless the platform needs one to run it, such as `.cmd` or `.ps1` on Windows. - **Wrapper exemption.** A thin launcher whose only job is to run a Python tool keeps that tool's stem, so `fetch_report.py`, `fetch_report.sh`, and `fetch_report.ps1` stay grouped together. It covers only a launcher run by its path. A launcher installed onto `PATH` or into a system slot is an installed command instead, such as `fetch-report` rather than `fetch_report`. -- **Externally fixed names are exempt.** That covers Git hook names, `__init__.py`, `conftest.py`, and Pester's `.Tests.ps1` suffix, whose stem mirrors the script under test. It covers a PowerShell data file (`.psd1`) whose name a tool fixes, such as a module manifest named for its module. `PSScriptAnalyzerSettings.psd1` is another, since PSScriptAnalyzer finds it by that name. It also covers any name a platform defines. +- **Env and secret file names reach every file a repository names.** That covers a tracked file or template and a secret file mounted into a container. It also covers a host-side file the repository's docs or scripts name, such as an `ENV_FILE` path or a compose `env_file`. +- **`_` joins the fields of an env or secret file name, and each field keeps its own valid form.** A host name field stays verbatim, hyphens included, so `app_web-01_prod.env` is right and `app_web_01_prod.env` and `app.web-01.prod.env` are not. That field is a short name, the host's first DNS label (`web-01`) or a logical role name (`vps-staging`), and never a fully qualified one. Either way it takes the DNS label form, so its own words join with `-`. In every other field a hyphen never joins words, since that is the job of `_`. A host name label can carry `-` and never `_`, so every `_` is a separator and the name splits back into its fields. The name is lowercase, carries no dot but its extension, and starts and ends with a letter or digit. A tracked template's `.example` suffix is not counted as a dot. +- **A variable name converts separately.** An env variable or CI secret name allows only `A-Z`, `0-9`, and `_`. Where a field becomes one, the hyphen becomes `_` there, as in `APP_WEB_01_PROD_TOKEN`, and never in the file name. +- **Some consumers set limits of their own.** A name used as a Docker secret stays within the 64 characters Docker allows. A name that maps to an env variable or CI secret does not start with a digit, which POSIX and GitHub both forbid. +- **Externally fixed names are exempt.** That covers Git hook names, `__init__.py`, `conftest.py`, and Pester's `.Tests.ps1` suffix, whose stem mirrors the script under test. It covers a PowerShell data file (`.psd1`) whose name a tool fixes, such as a module manifest named for its module. `PSScriptAnalyzerSettings.psd1` is another, since PSScriptAnalyzer finds it by that name. So is an env file a tool reads by a fixed name, such as `.env`, `.env.local`, or `.env.production`. An env or secret file in a `.secrets/` directory is never one, since it is read by a path the repository configures. The exemption also covers any name a platform defines. - **Directories are out of scope.** Departures, stated on purpose so a reader does not "fix" them back: diff --git a/GOVERNANCE.md b/GOVERNANCE.md index bcac9bb..5890958 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -109,7 +109,7 @@ Line-ending governance for an operational repo is in [Line Endings](#line-ending A repo whose own stacks or scripts read local runtime credentials from disk, most commonly an operational repo's Docker Compose stack, keeps them in a dotted `.secrets/` directory at the repo root. This is the repo-scoped counterpart to the host-scoped `~/.secrets/` convention a repo's own `OPERATIONS.md` may document, and it is a different thing from `spec/secrets.json`, the CI/GitHub Actions secret-name registry `spec/audit.py` cross-checks. `spec/secrets.json` governs what a workflow reads from GitHub Actions. This convention governs what a repo's own process reads from its own checkout. - **The directory is named `.secrets/`, dotted, never a bare `secrets/`.** -- **A secret file's stem is lowercase snake_case** (`homeassistant_db_password`, `mail_relay.env`), per [`CODESTYLE.md` "Script and Utility File Names"](./CODESTYLE.md#script-and-utility-file-names). +- **A secret file's name is lowercase fields joined by `_`** (`homeassistant_db_password`, `mail_relay.env`), per [`CODESTYLE.md` "Script and Utility File Names"](./CODESTYLE.md#script-and-utility-file-names). - **A single opaque credential file carries no extension** (`homeassistant_db_password`, not `homeassistant_db_password.txt`), the same reason `README` and `LICENSE` carry none. It is a security property, read but never sourced, not a formatting preference. - **A structured credential keeps its format's extension** (`.json` for structured config). - **The shared env file is named for what it configures**, not a bare `.env` (`docker.env` for a repo whose stacks are Docker Compose), so a second env-shaped file added later stays unambiguous. @@ -123,6 +123,7 @@ A repo whose own stacks or scripts read local runtime credentials from disk, mos A fresh checkout then documents its own required shape without ever exposing a real value. This negation keeps a real secret file out of a **new** commit. It does not remove one already tracked: `.gitignore` has no effect on a path git already follows. A real secret file found tracked is removed from the index (`git rm --cached `) and its credential is rotated, not just added to `.gitignore` going forward. - **`.secrets/README.md` is a catalog**, one row per file naming what it holds and what consumes it, plus a short note on how the directory relates to `~/.secrets/` where the repo also touches that. +- **A repository whose shell scripts read a credential from disk declares the `host-secrets` type, and reads each one through the carried readers.** A credential is a secret file or an env file holding one, in `.secrets/` or in the host-scoped `~/.secrets/`, and this bullet reaches both. A single inline read counts, since it is the read a bare existence check and a sourced file get wrong. A script sources its carried `lib/fleet/sh/secrets.sh`, per "Hub-Hosted Tooling", and checks the file before reading it. A private file takes `require_secret` or `secret_set_verdict`, and one a container reads as a Docker secret takes `secret_verdict` with `shared`. It reads one value from an env file with `read_env_value` rather than sourcing the file into its own shell. ## Hub-Hosted Tooling @@ -130,6 +131,8 @@ The fleet's tooling lives in the hub once and a repository runs it from there ra **What a repository carries and what it reaches is decided by what the content is.** It carries the content it is audited against and the configuration that describes it, meaning its rule text and the files the manifest declares. It reaches machinery whose content is identical in every repository, meaning the prose and repository gates, the review digest, and the configuration script, because a file holding no per-repo content is a copy whose only future is to go stale. A tool named in a carried rule is therefore named as the hub's, since the alternative is a pointer to a path the reader does not have, and a pointer that resolves nowhere teaches the reader that a pointer in carried text is decorative. +**Code a repository's own runtime executes is the exception, and it is carried.** Such a library runs on a host the project owns, often unattended and sometimes as root. Fetched from the hub at run time, it would let a GitHub outage or a bad `main` commit stop a scheduled host job. It would also run code the repository never reviewed against its own secrets. So offline operation and review in the repository that runs it outweigh the staleness of a copy. A gate differs, since "the tool did not run" is an acceptable answer there and CI is the backstop. The hub hosts the library once. A repository whose declared types select it carries it byte-for-byte, as a tree the hub's `spec/files.json` declares. The hub owns the carried directory whole. The repository extends the library in a file of its own outside it that sources it, never by editing the copy. The staleness is made visible rather than accepted. The repository gate compares each carried tree with the hub tree its pinned action holds. It reports a difference as a warning that never fails the gate. A failure would break every downstream pull request on each hub fix. The audit reports a stale copy, and the resync refreshes it. + **Reaching it is a checkout of the hub rather than a copy of one file.** A tool reads the payloads, tables, and sibling modules beside it, so a single file lifted out of the tree runs against whatever the caller happens to have, which is the copy problem again in a shorter loop. Read `main`, the promoted and gated state, and fetch immediately before running, because a clone is whatever it last fetched rather than the branch it names, and a stale clone answers confidently instead of failing. Name the tool by its path in that checkout and name the target explicitly, since a tool that defaults to the current directory or the current repository resolves somewhere either way, and a result computed against the wrong repository is well-formed. Which directory the command runs in is the tool's own contract rather than a rule here, so a gate reading a working tree runs in the repository under test while a tool taking its target as an argument runs anywhere and is given one. What the rule forbids is letting a default decide which repository the answer is about. **A loader is outside this section rather than exempt from it.** The rule above governs a tool that reads hub content, because a tool reads the payloads, tables, and sibling modules beside it. A loader reads none of them: it obtains a tree and hands control to a tool inside that tree, on a host that cannot yet obtain one. The bound is what it may contain rather than who runs it, and it is one line: a loader references no path inside the tree it fetches except the single entry point it hands control to, and everything else it touches is the machine or the network. A loader that grows a second path into that tree has become a tool and is governed above. From b88574397128891040f4f51809c67b10fb864d83 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 27 Oct 2026 15:47:05 -0700 Subject: [PATCH 3/3] Rename the Host Env Files to the Fleet's Underscore-Joined Form (#557) * Rename the host env files to the fleet's underscore-joined form The carried CODESTYLE.md naming rule reaches a host-side file the repository's docs or scripts name, so blog...env becomes blog__.env. make-release.sh's default and every doc naming one of the four files change together. Co-Authored-By: Claude Opus 5.5 * Name the blog_ prefix in the secrets catalog Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .secrets/README.md | 12 ++++++------ .secrets/example.env | 12 ++++++------ ENVIRONMENT.md | 10 +++++----- OPERATIONS.md | 36 ++++++++++++++++++------------------ README.md | 6 +++--- capture/README.md | 4 ++-- deploy/README.md | 22 +++++++++++----------- deploy/make-release.sh | 8 ++++---- 8 files changed, 55 insertions(+), 55 deletions(-) diff --git a/.secrets/README.md b/.secrets/README.md index 7afcf38..4a64633 100644 --- a/.secrets/README.md +++ b/.secrets/README.md @@ -10,18 +10,18 @@ exposing one. The documented local convention is `~/.secrets/`, not this directory. CI supplies the same values directly from the GitHub Environment instead, reading no file here at all. `ENV_FILE= deploy/make-release.sh` resolves a relative `ENV_FILE` against `$HOME/.secrets`, -refuses a traversing one, and defaults to `~/.secrets/blog.local.production.env`. An absolute +refuses a traversing one, and defaults to `~/.secrets/blog_local_production.env`. An absolute `ENV_FILE` is honored as given rather than resolved against `~/.secrets/`, an escape hatch rather than the documented shape. `~/.secrets/` is shared across every repo on the host, so each of this repo's files carries the -`blog.` prefix: +`blog_` prefix: | File | Selects | | --- | --- | -| `~/.secrets/blog.local.production.env` | The default, read when `ENV_FILE` is unset. | -| `~/.secrets/blog.local.staging.env` | The local staging mirror. | -| `~/.secrets/blog.vps.production.env` | The VPS production environment. | -| `~/.secrets/blog.vps.staging.env` | The VPS staging environment. | +| `~/.secrets/blog_local_production.env` | The default, read when `ENV_FILE` is unset. | +| `~/.secrets/blog_local_staging.env` | The local staging mirror. | +| `~/.secrets/blog_vps_production.env` | The VPS production environment. | +| `~/.secrets/blog_vps_staging.env` | The VPS staging environment. | Every value each file holds, and what reads it, is described once in [`ENVIRONMENT.md`](../ENVIRONMENT.md). Start a new one from [`example.env`](./example.env). diff --git a/.secrets/example.env b/.secrets/example.env index 9f770f3..69c1fd2 100644 --- a/.secrets/example.env +++ b/.secrets/example.env @@ -4,7 +4,7 @@ # This file states the shape and a placeholder. Add a value here and describe it there, # or checks/env_docs_gate.py fails. # -# One destination: ~/.secrets/blog...env on a workstation, one file +# One destination: ~/.secrets/blog__.env on a workstation, one file # per environment. The off-host VPS backup pull, and the environment file it reads, are # maintained outside this repository -- see OPERATIONS.md, Working With the VPS. # @@ -15,14 +15,14 @@ # HUGO_BASEURL for Hugo, only where that tool requires its own name. # ============================================================================= -# ~/.secrets/blog...env +# ~/.secrets/blog__.env # ============================================================================= # One file per environment, named for the server it describes and the environment on it, # with both words spelled out, and selected by ENV_FILE: -# blog.local.production.env the default, read when ENV_FILE is unset -# blog.local.staging.env ENV_FILE=blog.local.staging.env deploy/make-release.sh -# blog.vps.production.env ENV_FILE=blog.vps.production.env deploy/make-release.sh -# blog.vps.staging.env ENV_FILE=blog.vps.staging.env deploy/make-release.sh +# blog_local_production.env the default, read when ENV_FILE is unset +# blog_local_staging.env ENV_FILE=blog_local_staging.env deploy/make-release.sh +# blog_vps_production.env ENV_FILE=blog_vps_production.env deploy/make-release.sh +# blog_vps_staging.env ENV_FILE=blog_vps_staging.env deploy/make-release.sh # # Sourced with `set -a`, which overwrites a variable the caller exported first. A named # file that does not exist is a hard failure rather than a fall-through. Real files live diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 85790e3..bd9b1c3 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -8,7 +8,7 @@ Every configuration value this repository reads or writes, described once. [`OPE ## The mechanism -A local run reads one file. `~/.secrets/blog...env` is sourced with `set -a`, selected by `ENV_FILE`, and defaults to `~/.secrets/blog.local.production.env`. The real files live on the host, never in this checkout, and [`.secrets/example.env`](./.secrets/example.env) is the tracked template that documents the shape. +A local run reads one file. `~/.secrets/blog__.env` is sourced with `set -a`, selected by `ENV_FILE`, and defaults to `~/.secrets/blog_local_production.env`. The real files live on the host, never in this checkout, and [`.secrets/example.env`](./.secrets/example.env) is the tracked template that documents the shape. Two consequences of `set -a` are worth stating because both have surprised someone. Sourcing overwrites a variable the caller exported first, so exporting `DEPLOY_ROOT` by hand does not switch environments and only `ENV_FILE` does. And a named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root. @@ -16,7 +16,7 @@ CI reads no file. The deploy workflow resolves the same values from the GitHub E ## Repository environment files -Held in `~/.secrets/blog...env`, one file per environment, on the host rather than in this checkout. Template: [`.secrets/example.env`](./.secrets/example.env). +Held in `~/.secrets/blog__.env`, one file per environment, on the host rather than in this checkout. Template: [`.secrets/example.env`](./.secrets/example.env). | Value | Names | Notes | | --- | --- | --- | @@ -62,9 +62,9 @@ Every variable and secret this repository stores for GitHub Actions, Dependabot, **[`checks/github_env_gate.py`](./checks/github_env_gate.py) compares this table against GitHub.** It reads the names those stores hold, never a value, and leaves the Codespaces secret store unread. It reports a value missing from a store, held on a store the table does not name, or held as the wrong kind. Every environment GitHub has is read, so an environment the table never names must hold nothing. Run it after changing a value on GitHub or a row here. It needs a `gh` login that can administer the repository, since listing environment secrets requires that. A workflow's `GITHUB_TOKEN` cannot, so CI does not run it. -**The family values take the hub's names for a second site.** The hub's deploy task reads `SITE_EXTRA_BASE_URL` and the `SITE_EXTRA_AUTH_TOKEN_ID`/`SITE_EXTRA_AUTH_TOKEN` pair by those names from the environment. `check-live-urls.sh` and the `~/.secrets/blog.*.env` files use the same three names, so nothing translates between them. A deploy therefore checks the family host with its own token. +**The family values take the hub's names for a second site.** The hub's deploy task reads `SITE_EXTRA_BASE_URL` and the `SITE_EXTRA_AUTH_TOKEN_ID`/`SITE_EXTRA_AUTH_TOKEN` pair by those names from the environment. `check-live-urls.sh` and the `~/.secrets/blog_*.env` files use the same three names, so nothing translates between them. A deploy therefore checks the family host with its own token. -**`SITE_BASE_URL` being read twice is the trap worth knowing.** A wrong value bakes the wrong address into every canonical tag and then runs the full URL contract against that same wrong address, so the deploy verifies itself and passes. Its generic name is the hub's own `deploy-site-task.yml` interface, and it is also the one this repository's own scripts and `~/.secrets/blog.*.env` files read: `make-release.sh` bridges it to Hugo's own `HUGO_BASEURL` in one place, since only Hugo requires that name. +**`SITE_BASE_URL` being read twice is the trap worth knowing.** A wrong value bakes the wrong address into every canonical tag and then runs the full URL contract against that same wrong address, so the deploy verifies itself and passes. Its generic name is the hub's own `deploy-site-task.yml` interface, and it is also the one this repository's own scripts and `~/.secrets/blog_*.env` files read: `make-release.sh` bridges it to Hugo's own `HUGO_BASEURL` in one place, since only Hugo requires that name. **A host rebuild regenerates the SSH host keys and the pinned value stops matching**, which fails every deploy closed and blocks the rollback path at the same moment a rebuild makes both matter. Replace `DEPLOY_SSH_KNOWN_HOSTS` on **both** environments before the first deploy after a rebuild. @@ -74,7 +74,7 @@ Set on the command line for one run rather than stored anywhere. | Value | Effect | | --- | --- | -| `ENV_FILE` | which environment file to source. Defaults to `~/.secrets/blog.local.production.env` | +| `ENV_FILE` | which environment file to source. Defaults to `~/.secrets/blog_local_production.env` | | `REQUIRE_BROTLI=1` | fail rather than shipping gzip-only. CI sets it | | `NO_LINK_DEST=1` | full copy instead of hard-linking from the previous release | | `KEEP_RELEASES` | how many releases `make-release.sh` leaves behind | diff --git a/OPERATIONS.md b/OPERATIONS.md index 8d7e301..36c3de6 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -33,9 +33,9 @@ So release to the local mirror and run the live check **before** opening a pull ```sh set -e -set -a; . ~/.secrets/blog.local.production.env; set +a +set -a; . ~/.secrets/blog_local_production.env; set +a RELEASE="$(git rev-parse --short HEAD)" -ENV_FILE=~/.secrets/blog.local.production.env deploy/make-release.sh "" "$RELEASE" +ENV_FILE=~/.secrets/blog_local_production.env deploy/make-release.sh "" "$RELEASE" EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL" ``` @@ -43,9 +43,9 @@ Name the file in both places, even when it is the default, since `make-release.s ```sh set -e -set -a; . ~/.secrets/blog.local.staging.env; set +a +set -a; . ~/.secrets/blog_local_staging.env; set +a RELEASE="$(git rev-parse --short HEAD)" -ENV_FILE=~/.secrets/blog.local.staging.env deploy/make-release.sh "" "$RELEASE" +ENV_FILE=~/.secrets/blog_local_staging.env deploy/make-release.sh "" "$RELEASE" EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL" ``` @@ -67,7 +67,7 @@ EXPECT_RELEASE= checks/check-live-urls.sh "$SITE_BASE_URL" Sourcing the environment file first puts the deploy root and the base URL in the environment, so no literal value is typed. `make-release.sh` then needs no arguments, because its deploy root falls back to `$DEPLOY_ROOT` and its version falls back to a timestamp. It still accepts both arguments, and the deploy workflow passes a bundle path and a version, as [Deploying](#deploying) below shows. Either form works locally, and the argument wins over the environment. -`ENV_FILE` is set as well as sourced, and the redundancy is deliberate. The script sources its own file regardless, so leaving `ENV_FILE` off would build and install against `~/.secrets/blog.local.production.env` while the shell's `$SITE_BASE_URL` still named staging, and the run would check the staging site after publishing to the production root. The script prints the file it read, on every build, for that reason. +`ENV_FILE` is set as well as sourced, and the redundancy is deliberate. The script sources its own file regardless, so leaving `ENV_FILE` off would build and install against `~/.secrets/blog_local_production.env` while the shell's `$SITE_BASE_URL` still named staging, and the run would check the staging site after publishing to the production root. The script prints the file it read, on every build, for that reason. It refuses to install a release that fails the build gate. `check-live-urls.sh` does take a base URL, which is where the sourced `$SITE_BASE_URL` goes. It follows every URL in the contract against the running mirror, checking each redirect's destination rather than trusting its status code. @@ -79,7 +79,7 @@ A documentation-only or workflow-only change does not need this. A change to the ```sh set -e -set -a; . ~/.secrets/blog.local.staging.env; set +a +set -a; . ~/.secrets/blog_local_staging.env; set +a checks/csp_browser_gate.py "$SITE_BASE_URL" ``` @@ -139,7 +139,7 @@ The deploy root and the base URL are the only host-specific values. A local run | Variable | Effect | | --- | --- | -| `ENV_FILE` | Which environment file to source. Defaults to `~/.secrets/blog.local.production.env`. | +| `ENV_FILE` | Which environment file to source. Defaults to `~/.secrets/blog_local_production.env`. | | `DEPLOY_ROOT` | Fallback deploy root. The first argument wins. | | `SITE_BASE_URL` | Overrides the site base URL. | | `REQUIRE_BROTLI=1` | Fails rather than shipping gzip-only. CI sets this. | @@ -181,7 +181,7 @@ The script asserts both halves of that rather than assuming them. It fails when **Every path and hostname on this page is a value in `~/.secrets/`, never a literal to be remembered or asked for.** The convention is the one "Environments" describes and `CAPTURE_ROOT` already follows: a value naming a machine rather than the project lives in the environment file, is sourced with `set -a`, and is read from there rather than searched for. The VPS values are environment-independent, because there is one such host rather than one per environment, so they sit in the default file alongside `CAPTURE_ROOT`. ```sh -set -a; . ~/.secrets/blog.local.production.env; set +a +set -a; . ~/.secrets/blog_local_production.env; set +a ssh "$VPS_SSH_HOST" true && echo reachable ``` @@ -198,7 +198,7 @@ ssh "$VPS_SSH_HOST" true && echo reachable **`LOG_ARCHIVE_ROOT` is spelled the same way on both sides, so there is nothing to reconcile.** The pull writes it and the log review reads it, under the one name. Every value this repository reads or writes is described once, in [`ENVIRONMENT.md`](./ENVIRONMENT.md), and [`checks/env_docs_gate.py`](./checks/env_docs_gate.py) fails if one is declared without a description or described without existing. ```sh -set -a; . ~/.secrets/blog.local.production.env; set +a +set -a; . ~/.secrets/blog_local_production.env; set +a ls -d "$LOG_ARCHIVE_ROOT" ``` @@ -327,7 +327,7 @@ Widen `== 404` to `>= 400` for the whole non-200 sweep the table above describes **The filters live in [`checks/log-review.jq`](./checks/log-review.jq), so every pass runs the same ones.** The file holds the tag, host, status and shape filters, and the referer step above is read from its output. A filter rebuilt each pass drifts, and a drifted filter hides a finding without saying so. Join the archived days with the live log, keep the window since the last pass, and run the filter over it: ```sh -set -a; . ~/.secrets/blog.local.production.env; set +a +set -a; . ~/.secrets/blog_local_production.env; set +a start='2026-10-10T22:39' # where the last pass ended ( set -euo pipefail @@ -432,11 +432,11 @@ The tools this repository's operations reach for, and the behavior of each that Staging keeps Pangolin's authentication on, so an unauthenticated request never reaches the site. `check-live-urls.sh` presents a Pangolin resource access token when both halves of the pair are set, and sends nothing when neither is: ```sh -set -a; . ~/.secrets/blog.vps.staging.env; set +a +set -a; . ~/.secrets/blog_vps_staging.env; set +a checks/check-live-urls.sh "$SITE_BASE_URL" ``` -The gate is the VPS staging environment's, so this is `~/.secrets/blog.vps.staging.env`. The local staging mirror sits behind Traefik on the maintainer's own network and carries neither half of the pair. +The gate is the VPS staging environment's, so this is `~/.secrets/blog_vps_staging.env`. The local staging mirror sits behind Traefik on the maintainer's own network and carries neither half of the pair. | Variable | Header | | --- | --- | @@ -488,16 +488,16 @@ Four environments, in two pairs. Each pair is one publish site and one staging s | Environment | Address | Fronted by | Purpose | | --- | --- | --- | --- | -| Local publish mirror | a private hostname, set in `~/.secrets/blog.local.production.env` | Traefik, on the maintainer's own network | Proves the artifact. The redirect rules, the maps, and the release mechanics. | -| Local staging mirror | a second private hostname, set in `~/.secrets/blog.local.staging.env` | Traefik | Proves that two environments on one host stay independent, before that matters on a server. | -| Staging | `blog.vps.insanegenius.net`, behind the auth gate, set in `~/.secrets/blog.vps.staging.env` | Pangolin | Proves the infrastructure. Routing, TLS, and the deploy path. | -| Production | `blog.insanegenius.com`, set in `~/.secrets/blog.vps.production.env` | Pangolin | The public site, per [Current State](#current-state). | +| Local publish mirror | a private hostname, set in `~/.secrets/blog_local_production.env` | Traefik, on the maintainer's own network | Proves the artifact. The redirect rules, the maps, and the release mechanics. | +| Local staging mirror | a second private hostname, set in `~/.secrets/blog_local_staging.env` | Traefik | Proves that two environments on one host stay independent, before that matters on a server. | +| Staging | `blog.vps.insanegenius.net`, behind the auth gate, set in `~/.secrets/blog_vps_staging.env` | Pangolin | Proves the infrastructure. Routing, TLS, and the deploy path. | +| Production | `blog.insanegenius.com`, set in `~/.secrets/blog_vps_production.env` | Pangolin | The public site, per [Current State](#current-state). | The local mirrors are not staging. They run the same bundle against the same web server, so they catch a broken redirect or a bad permission for free, but they exercise none of the routing, authentication, or certificate machinery that only exists on the VPS. Passing locally says the artifact is right. It says nothing about whether the server in front of it is. **The two words are `production` and `staging`, spelled out, in every position.** No `prod`, no `stage`. The same two name the container, the deploy root, the environment file, the `X-Blog-Env` value, and the GitHub Environment. This is not tidiness: the environment name is a value that gets **compared**, by `EXPECT_SITE_ENV` and by the deploy, so a spelling that differs in one position fails a deploy for a reason that reads like an outage. The local mirrors prefix the same words, `mirror-production` and `mirror-staging`, so a header names exactly one of the four environments in the fleet. -Each environment is one file under `~/.secrets/`, named `blog...env`, selected with `ENV_FILE`, and holding the deploy root, the base URL, and the container name. The name carries both halves because the two pairs differ in server as well as environment, so a file says which machine it describes rather than leaving that to the value inside it, and the four in the table above are the four files. `~/.secrets/blog.local.production.env` is the one read when `ENV_FILE` is unset. Selecting the file is how an environment is chosen: the file is sourced with `set -a`, so it overwrites a `DEPLOY_ROOT` the caller exported and setting that variable by hand does not switch anything. A named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root. +Each environment is one file under `~/.secrets/`, named `blog__.env`, selected with `ENV_FILE`, and holding the deploy root, the base URL, and the container name. The name carries both halves because the two pairs differ in server as well as environment, so a file says which machine it describes rather than leaving that to the value inside it, and the four in the table above are the four files. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. Selecting the file is how an environment is chosen: the file is sourced with `set -a`, so it overwrites a `DEPLOY_ROOT` the caller exported and setting that variable by hand does not switch anything. A named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root. **The staging FQDN sits under the VPS wildcard deliberately.** `blog.vps.insanegenius.net` needs no new certificate and no new DNS record, and it keeps the staging name off the production domain. @@ -572,7 +572,7 @@ The container reads three host paths, and only one of them a release ever writes Because it sits outside the bundle, no release updates it. Install or refresh it explicitly, once per environment, which is the same command against a different sourced file: ```sh -set -a; . ~/.secrets/blog.local.production.env; set +a # or any other ~/.secrets/blog...env +set -a; . ~/.secrets/blog_local_production.env; set +a # or any other ~/.secrets/blog__.env install -m 644 deploy/bootstrap.Caddyfile "$CADDY_APPDATA/config/Caddyfile" docker restart "$CADDY_CONTAINER" # only this file needs one, see below ``` diff --git a/README.md b/README.md index 5759a85..be1386f 100644 --- a/README.md +++ b/README.md @@ -201,13 +201,13 @@ Build a release and verify it against a running server: ```sh set -e -set -a; . ~/.secrets/blog.local.production.env; set +a +set -a; . ~/.secrets/blog_local_production.env; set +a RELEASE="$(git rev-parse --short HEAD)" -ENV_FILE=~/.secrets/blog.local.production.env deploy/make-release.sh "" "$RELEASE" +ENV_FILE=~/.secrets/blog_local_production.env deploy/make-release.sh "" "$RELEASE" EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL" ``` -The deploy root and the base URL come from a file per environment in `~/.secrets/`, named `blog...env`, copied from [example.env][env-example] and selected with `ENV_FILE`. `~/.secrets/blog.local.production.env` is the one read when `ENV_FILE` is unset. The real files live on the host, never in this checkout. +The deploy root and the base URL come from a file per environment in `~/.secrets/`, named `blog__.env`, copied from [example.env][env-example] and selected with `ENV_FILE`. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. The real files live on the host, never in this checkout. ## 3rd Party Tools diff --git a/capture/README.md b/capture/README.md index ff7edb3..bfd48ad 100644 --- a/capture/README.md +++ b/capture/README.md @@ -2,7 +2,7 @@ Everything in this repository is derived. What it was derived *from* is a capture directory that lives outside it, and this directory holds the scripts that read that capture. -The capture's path is `CAPTURE_ROOT`, recorded alongside the other values that name a machine rather than the project. It is environment-independent, so unlike a deploy root it belongs in `~/.secrets/blog.local.production.env` alone rather than in a copy per environment: there is one capture, and four copies of its path is four chances for three of them to be wrong. It is not a git repository, so it has no history to revert to, and it is read-only in normal use. Nothing here writes into it except the steps below that say they do. +The capture's path is `CAPTURE_ROOT`, recorded alongside the other values that name a machine rather than the project. It is environment-independent, so unlike a deploy root it belongs in `~/.secrets/blog_local_production.env` alone rather than in a copy per environment: there is one capture, and four copies of its path is four chances for three of them to be wrong. It is not a git repository, so it has no history to revert to, and it is read-only in normal use. Nothing here writes into it except the steps below that say they do. **None of this runs in CI, and none of it runs on a schedule.** These are provenance tools, run by hand, and their outputs are committed. That is the whole difference between this directory and [`checks/`](../checks/), which holds gates that run on every change. This is also why the validator's `python-directories` input does not declare this directory: it is one-shot migration tooling that has already run, so it owes no test suite, and the validator's warning about its files is expected. @@ -46,7 +46,7 @@ In order. Each Python step is a dry run by default and takes `--apply` to write. Everything here is standard library except `clean_content.py`, which needs PyYAML to read front matter. It names the package if it is missing rather than raising an import error. ```sh -set -a; . ~/.secrets/blog.local.production.env; set +a +set -a; . ~/.secrets/blog_local_production.env; set +a capture/run-wp2hugo.sh # convert, into $CAPTURE_ROOT/converted/ capture/clean_content.py --apply # drop comments, reduce the front matter diff --git a/deploy/README.md b/deploy/README.md index 991a54b..d5439a5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -57,16 +57,16 @@ neither. Seven facts are the whole contract: ```sh set -e -set -a; . ~/.secrets/blog.local.production.env; set +a +set -a; . ~/.secrets/blog_local_production.env; set +a RELEASE="$(git rev-parse --short HEAD)" -ENV_FILE=~/.secrets/blog.local.production.env deploy/make-release.sh "" "$RELEASE" +ENV_FILE=~/.secrets/blog_local_production.env deploy/make-release.sh "" "$RELEASE" EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL" ``` The deploy root and the base URL are the only host-specific values, and they pair per -environment. Copy [`example.env`][example-env] to `~/.secrets/blog.local.production.env`, which is the -file read when `ENV_FILE` is unset, and add `~/.secrets/blog...env` for each further -environment. A single environment therefore needs `~/.secrets/blog.local.production.env` and nothing +environment. Copy [`example.env`][example-env] to `~/.secrets/blog_local_production.env`, which is the +file read when `ENV_FILE` is unset, and add `~/.secrets/blog__.env` for each further +environment. A single environment therefore needs `~/.secrets/blog_local_production.env` and nothing else, since a differently named file is read only when `ENV_FILE` names it. The real files live on the host, in `~/.secrets/`, never in this checkout. CI passes the base URL and a scratch bundle path explicitly instead, then uploads the bundle to the host's deploy root: @@ -77,22 +77,22 @@ SITE_BASE_URL= deploy/make-release.sh **That command-prefix form is CI-only.** A local run whose default environment file exists sources it after the command-prefix assignment and overwrites it, since `set -a` overwrites a value the caller exported first. Locally, select the environment through `ENV_FILE` instead, per the table below. -**One file per environment, named `~/.secrets/blog...env`, selected by `ENV_FILE`.** +**One file per environment, named `~/.secrets/blog__.env`, selected by `ENV_FILE`.** Both halves are spelled out, so a name says which machine it describes as well as which -environment on it, and the four in the fleet read as one set. `~/.secrets/blog.local.production.env` is +environment on it, and the four in the fleet read as one set. `~/.secrets/blog_local_production.env` is the default and is read when `ENV_FILE` is unset, so a single-environment host needs nothing else: ```sh -deploy/make-release.sh # ~/.secrets/blog.local.production.env -ENV_FILE=~/.secrets/blog.local.staging.env deploy/make-release.sh # the staging site on the same host +deploy/make-release.sh # ~/.secrets/blog_local_production.env +ENV_FILE=~/.secrets/blog_local_staging.env deploy/make-release.sh # the staging site on the same host ``` A file whose `DEPLOY_SSH_HOST` is set describes a root on another machine, so this script refuses to create that path here and asks for a local one to assemble a bundle into: ```sh -ENV_FILE=~/.secrets/blog.vps.staging.env deploy/make-release.sh /path/to/bundle +ENV_FILE=~/.secrets/blog_vps_staging.env deploy/make-release.sh /path/to/bundle ``` Selecting the file is the only way to switch environments. The file is sourced with `set -a`, @@ -113,7 +113,7 @@ that reason. | Variable | Effect | | --- | --- | -| `ENV_FILE` | Which environment file to source. Defaults to `~/.secrets/blog.local.production.env`. | +| `ENV_FILE` | Which environment file to source. Defaults to `~/.secrets/blog_local_production.env`. | | `DEPLOY_ROOT` | Fallback deploy root. The first argument wins. | | `SITE_BASE_URL` | Overrides the site base URL. Bridged internally to `HUGO_BASEURL`, the name Hugo maps `HUGO_` onto config natively. | | `REQUIRE_BROTLI=1` | Fails rather than shipping gzip-only. CI sets this. | diff --git a/deploy/make-release.sh b/deploy/make-release.sh index 99d03aa..59e7442 100755 --- a/deploy/make-release.sh +++ b/deploy/make-release.sh @@ -12,7 +12,7 @@ KEEP_RELEASES=10 usage() { echo "usage: $0 [deploy-root] [version]" echo " deploy-root defaults to DEPLOY_ROOT, from the environment or \$ENV_FILE" - echo " ENV_FILE defaults to ~/.secrets/blog.local.production.env, and a relative path resolves against ~/.secrets" + echo " ENV_FILE defaults to ~/.secrets/blog_local_production.env, and a relative path resolves against ~/.secrets" echo " deploy-root is required when the environment file sets DEPLOY_SSH_HOST, since that root is on another host" } @@ -45,9 +45,9 @@ MTIME_MIN=2025.08 # The deploy root and the base URL are the only host-specific values, and they pair per environment. # ENV_FILE selects the environment, because `set -a` overwrites a value the caller exported. # The first argument overrides the root, being read after this. -# Files are named ~/.secrets/blog...env, both words spelled out, so the default names the environment it actually selects rather than being the one file whose name says nothing. -# The leading blog segment disambiguates this repo's files in ~/.secrets, since other repos on the same host also write there. -DEFAULT_ENV_FILE="$HOME/.secrets/blog.local.production.env" +# Files are named ~/.secrets/blog__.env, both words spelled out, so the default names the environment it actually selects rather than being the one file whose name says nothing. +# The leading blog field disambiguates this repo's files in ~/.secrets, since other repos on the same host also write there. +DEFAULT_ENV_FILE="$HOME/.secrets/blog_local_production.env" ENV_FILE="${ENV_FILE:-$DEFAULT_ENV_FILE}" # A relative name resolves against ~/.secrets, so it means the same from any working directory. # Traversal is refused rather than resolved, since a relative name is meant to reach ~/.secrets.