From a1f1f81ea1ee9e8bfb842b2eb26fbdfb4c7d655f Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 14:04:21 +0100 Subject: [PATCH 01/25] refactor(scripts): move deploy scripts and tests into scripts/ Relocates deploy.sh, deploy.ps1, deploy.Tests.ps1 and tests/ into a scripts/ directory to keep the repository root clean, and adds VERSION as the canonical version marker. Content paths are resolved via a new SOURCE_ROOT/SourceRoot pointing at the repository root, since the scripts now live one level down. Test harnesses gain a separate SCRIPTS_DIR/ScriptsDir so REPO_DIR keeps referring to the repository root for content lookups. BREAKING CHANGE: deploy.sh and deploy.ps1 must now be invoked as ./scripts/deploy.sh and ./scripts/deploy.ps1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deploy-ps1-tests.yml | 6 +-- .github/workflows/deploy-sh-tests.yml | 6 +-- AGENTS.md | 38 ++++++++--------- README.md | 31 ++++++++++---- VERSION | 1 + deploy.Tests.ps1 => scripts/deploy.Tests.ps1 | 4 +- deploy.ps1 => scripts/deploy.ps1 | 25 +++++------ deploy.sh => scripts/deploy.sh | 42 ++++++++++--------- .../fixtures/assess-observability-skill.md | 0 {tests => scripts/tests}/test-deploy.ps1 | 13 +++--- {tests => scripts/tests}/test-deploy.sh | 17 ++++---- 11 files changed, 101 insertions(+), 82 deletions(-) create mode 100644 VERSION rename deploy.Tests.ps1 => scripts/deploy.Tests.ps1 (98%) rename deploy.ps1 => scripts/deploy.ps1 (93%) rename deploy.sh => scripts/deploy.sh (91%) rename {tests => scripts/tests}/fixtures/assess-observability-skill.md (100%) rename {tests => scripts/tests}/test-deploy.ps1 (96%) rename {tests => scripts/tests}/test-deploy.sh (96%) diff --git a/.github/workflows/deploy-ps1-tests.yml b/.github/workflows/deploy-ps1-tests.yml index 88d1e36..af4d573 100644 --- a/.github/workflows/deploy-ps1-tests.yml +++ b/.github/workflows/deploy-ps1-tests.yml @@ -56,7 +56,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path ./deploy.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 + $results = Invoke-ScriptAnalyzer -Path ./scripts/deploy.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 if ($results) { $results | Format-Table -AutoSize | Out-String | Write-Host throw "PSScriptAnalyzer reported $($results.Count) compatibility finding(s)." @@ -110,7 +110,7 @@ jobs: } - name: Run Pester unit tests - run: Invoke-Pester -Path ./deploy.Tests.ps1 -CI + run: Invoke-Pester -Path ./scripts/deploy.Tests.ps1 -CI - name: Run end-to-end deploy tests - run: pwsh ./tests/test-deploy.ps1 + run: pwsh ./scripts/tests/test-deploy.ps1 diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml index e4e7558..da2eb8c 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -81,19 +81,19 @@ jobs: echo "/bin/bash: $(/bin/bash --version | head -1)" - name: Run end-to-end deploy tests - run: bash tests/test-deploy.sh + run: bash scripts/tests/test-deploy.sh # macOS ships bash 3.2 as /bin/bash and deploy.sh declares #!/bin/bash, # so consumers on macOS run it under 3.2. Assert that explicitly instead of # relying on whichever bash happens to be first on PATH. - name: Run end-to-end deploy tests under /bin/bash if: runner.os == 'macOS' - run: /bin/bash tests/test-deploy.sh + run: /bin/bash scripts/tests/test-deploy.sh - name: Verify deploy.sh runs from an arbitrary working directory run: | target="$(mktemp -d)" cd "$(mktemp -d)" - "$GITHUB_WORKSPACE/deploy.sh" --agents all --overwrite "$target" + "$GITHUB_WORKSPACE/scripts/deploy.sh" --agents all --overwrite "$target" test -f "$target/AGENTS.md" test -f "$target/.context/index.md" diff --git a/AGENTS.md b/AGENTS.md index 5d36863..0ed9ef6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -Maintainer-facing guide for working in **this** repository — the source of standards, playbooks, and per-agent configuration templates that `deploy.sh` and `deploy.ps1` distribute into target repositories. +Maintainer-facing guide for working in **this** repository — the source of standards, playbooks, and per-agent configuration templates that `scripts/deploy.sh` and `scripts/deploy.ps1` distribute into target repositories. This file is for contributors editing the templates here. It is **not** the `AGENTS.md` that gets shipped to consumers — that template lives at `core/AGENTS.md` and is copied into each target repo by the deploy scripts (the consumer fills in the `[CONFIGURE]` sections; the scripts do not template anything themselves). @@ -13,9 +13,9 @@ A single-source-of-truth library of: - **Standards** (`standards/`) — per-concern prescriptive rules (security, testing, performance, .NET, React, etc.). - **Playbooks** (`playbooks/`) — step-by-step procedures for assessments, reviews, planning, and refactoring. - **Core configuration** (`core/`) — the lean, always-in-context files (`AGENTS.md`, `CLAUDE.md`, `.context/index.md`, conventions, per-agent redirects) that every target repo receives. -- **Deploy scripts** (`deploy.sh`, `deploy.ps1`) — the writers that assemble the above into a target repository's layout for the selected agents. +- **Deploy scripts** (`scripts/deploy.sh`, `scripts/deploy.ps1`) — the writers that assemble the above into a target repository's layout for the selected agents. -Consumers of this library run `deploy.sh` (or `deploy.ps1`) against their own repository. They never edit content here. +Consumers of this library run `scripts/deploy.sh` (or `scripts/deploy.ps1`) against their own repository. They never edit content here. --- @@ -38,7 +38,7 @@ If you need to reference the same rule from two playbooks, link to the standard | Concern | Where | What lives here | | --- | --- | --- | | **Authoring** | `standards/`, `playbooks/`, `core/` | The prose. Edit these. | -| **Distribution** | `deploy.sh`, `deploy.ps1` | The writers. They translate this repo's structure into the target's layout, selecting per-agent files based on `--agents`. | +| **Distribution** | `scripts/deploy.sh`, `scripts/deploy.ps1` | The writers. They translate this repo's structure into the target's layout, selecting per-agent files based on `--agents`. | Authoring changes touch markdown only. Distribution changes touch the scripts only. Do not mix. @@ -47,7 +47,7 @@ Authoring changes touch markdown only. Distribution changes touch the scripts on The point of supporting multiple agents (Claude Code, Cursor, Windsurf, Devin, Copilot) is **not** to write multiple copies of our standards. It is to write a small adapter file that each agent reads and then sends the agent to `AGENTS.md` + `.context/index.md`. - `core/.cursor/rules/standards.mdc`, `core/.windsurfrules`, `core/.devin/devin.json`, `core/.github/copilot-instructions.md`, `core/CLAUDE.md` — all are redirects. None contains a standard. -- For Claude Code and Copilot, `deploy.sh` additionally generates skill wrappers from playbook frontmatter — wrappers, not copies. +- For Claude Code and Copilot, `scripts/deploy.sh` additionally generates skill wrappers from playbook frontmatter — wrappers, not copies. If you find yourself writing prose in a per-agent file, stop and put it in `core/AGENTS.md` or a standard instead. @@ -107,13 +107,13 @@ The directory layout in this repo is **not** the layout in target repos. The dep 3. Use semantic, non-numbered headings (`## Role`, `## Phase 1: Discovery`, ...). 4. Add a keyword route in `core/.context/index.md`. -5. Do not generate skill wrappers by hand. `deploy.sh` does that from the frontmatter. +5. Do not generate skill wrappers by hand. `scripts/deploy.sh` does that from the frontmatter. ### Adding support for a new agent 1. Add the agent's redirect file under `core/` (e.g. `core/.newagent/config.yaml`). 2. The redirect must point the agent to `AGENTS.md` and `.context/index.md` — never duplicate standards. -3. Extend `deploy.sh` and `deploy.ps1`: agent flag parsing, interactive menu entry, copy step. +3. Extend `scripts/deploy.sh` and `scripts/deploy.ps1`: agent flag parsing, interactive menu entry, copy step. 4. Update the README's "Supported Agents" table. ### Editing standards or playbooks @@ -126,13 +126,13 @@ The directory layout in this repo is **not** the layout in target repos. The dep ### Editing deploy scripts -- `deploy.sh` (bash) and `deploy.ps1` (PowerShell) must stay behaviour-equivalent. A change to one usually needs the matching change in the other. +- `scripts/deploy.sh` (bash) and `scripts/deploy.ps1` (PowerShell) must stay behaviour-equivalent. A change to one usually needs the matching change in the other. - Both must honour the overwrite guard (`--overwrite` / `--no-overwrite`, interactive prompt otherwise). - The interactive `--agents` menu must work on macOS, Linux, and Windows PowerShell. -- **`deploy.sh` must be portable to macOS and Linux.** macOS ships bash 3.2 as `/bin/bash` and BSD userland, so bash 4+ syntax (`declare -A`, `mapfile`, `${var,,}`) and GNU-only utilities are forbidden. Use portable equivalents: `shasum -a 256` or a `sha256sum` fallback rather than assuming `sha256sum`; `find … -exec test -x {} \; -print` rather than `find -perm /111`; `sed -i.bak` (then delete the backup) rather than bare `sed -i`. The same rule applies to every `.sh` file in `playbooks/`. -- **`deploy.ps1` must run on Windows PowerShell 5.1 and PowerShell 7+.** 5.1 is the oldest supported baseline and is where the historic parse and `Add-Type` bugs surfaced. PowerShell 7-only syntax (ternaries, `??`, `-Parallel`) is forbidden. `PSScriptAnalyzerSettings.psd1` encodes this and is enforced in CI. -- **CI enforces both.** `.github/workflows/deploy-sh-tests.yml` and `.github/workflows/deploy-ps1-tests.yml` run on every pull request from any branch (and on every commit pushed to an open pull request), plus every push to `main`, with **no path filters** — these scripts are load-bearing for every consumer, so they are never allowed to go untested. `deploy.sh` is tested on Ubuntu and macOS (including explicitly under `/bin/bash` 3.2) and linted with `shellcheck --severity=warning`; `deploy.ps1` is tested on Windows PowerShell 5.1, and on PowerShell 7 across Windows, Linux and macOS. Do not add path filters to these workflows and do not restrict the `pull_request` trigger to specific branches. -- **Never run `deploy.sh` or `deploy.ps1` against this repository.** This repo is the source library, not a deploy target. Running the scripts here writes `/AGENTS.md`, `/CLAUDE.md`, `/.context/`, `/.cursor/`, `/.devin/`, `/.windsurfrules`, `/.github/copilot-instructions.md`, `/.claude/`, and `/.github/skills/` at the repo root — the `.gitignore` keeps those out of commits, but they overlay tracked source paths (`core/AGENTS.md` is the tracked source; `/AGENTS.md` is the tracked maintainer guide) and create confusing untracked state. To test a deploy-script change, run it against an empty scratch directory (`mkdir /tmp/agentic-context-test && ./deploy.sh /tmp/agentic-context-test`) or another repo entirely. +- **`scripts/deploy.sh` must be portable to macOS and Linux.** macOS ships bash 3.2 as `/bin/bash` and BSD userland, so bash 4+ syntax (`declare -A`, `mapfile`, `${var,,}`) and GNU-only utilities are forbidden. Use portable equivalents: `shasum -a 256` or a `sha256sum` fallback rather than assuming `sha256sum`; `find … -exec test -x {} \; -print` rather than `find -perm /111`; `sed -i.bak` (then delete the backup) rather than bare `sed -i`. The same rule applies to every `.sh` file in `playbooks/`. +- **`scripts/deploy.ps1` must run on Windows PowerShell 5.1 and PowerShell 7+.** 5.1 is the oldest supported baseline and is where the historic parse and `Add-Type` bugs surfaced. PowerShell 7-only syntax (ternaries, `??`, `-Parallel`) is forbidden. `PSScriptAnalyzerSettings.psd1` encodes this and is enforced in CI. +- **CI enforces both.** `.github/workflows/deploy-sh-tests.yml` and `.github/workflows/deploy-ps1-tests.yml` run on every pull request from any branch (and on every commit pushed to an open pull request), plus every push to `main`, with **no path filters** — these scripts are load-bearing for every consumer, so they are never allowed to go untested. `scripts/deploy.sh` is tested on Ubuntu and macOS (including explicitly under `/bin/bash` 3.2) and linted with `shellcheck --severity=warning`; `scripts/deploy.ps1` is tested on Windows PowerShell 5.1, and on PowerShell 7 across Windows, Linux and macOS. Do not add path filters to these workflows and do not restrict the `pull_request` trigger to specific branches. +- **Never run `scripts/deploy.sh` or `scripts/deploy.ps1` against this repository.** This repo is the source library, not a deploy target. Running the scripts here writes `/AGENTS.md`, `/CLAUDE.md`, `/.context/`, `/.cursor/`, `/.devin/`, `/.windsurfrules`, `/.github/copilot-instructions.md`, `/.claude/`, and `/.github/skills/` at the repo root — the `.gitignore` keeps those out of commits, but they overlay tracked source paths (`core/AGENTS.md` is the tracked source; `/AGENTS.md` is the tracked maintainer guide) and create confusing untracked state. To test a deploy-script change, run it against an empty scratch directory (`mkdir /tmp/agentic-context-test && ./scripts/deploy.sh /tmp/agentic-context-test`) or another repo entirely. --- @@ -146,7 +146,7 @@ The directory layout in this repo is **not** the layout in target repos. The dep | The lean per-project config every target repo gets | `core/AGENTS.md` | | A pointer for a specific agent to find AGENTS.md | `core/` | | A keyword route to discover a standard or playbook | `core/.context/index.md` | -| Anything about how files are written to target repos | `deploy.sh` and `deploy.ps1` | +| Anything about how files are written to target repos | `scripts/deploy.sh` and `scripts/deploy.ps1` | If a change does not fit any row above, it probably does not belong in this repo. @@ -155,7 +155,7 @@ If a change does not fit any row above, it probably does not belong in this repo ## What Does Not Belong Here - **Engagement artefacts.** Assessment reports, pen-test write-ups, and review outputs produced *by* using these playbooks against a target codebase. Keep them locally under `/engagements/` (git-ignored), alongside any `reviews/` directories (also ignored at any depth). -- **Deploy outputs.** Anything written to the repo root by `deploy.sh` (e.g. a generated `/AGENTS.md` template copy, `/.context/`, `/.cursor/`, etc.) when this repo is used as its own deploy target for local testing. The `.gitignore` anchors these with leading slashes so they cannot be accidentally committed; `core/` is the tracked source and is never affected. +- **Deploy outputs.** Anything written to the repo root by `scripts/deploy.sh` (e.g. a generated `/AGENTS.md` template copy, `/.context/`, `/.cursor/`, etc.) when this repo is used as its own deploy target for local testing. The `.gitignore` anchors these with leading slashes so they cannot be accidentally committed; `core/` is the tracked source and is never affected. - **Per-agent duplicates of standards.** See Core Design Principle 3. - **Local editor or agent settings** beyond what every contributor needs. `/.claude/`, `/.cursor/`, `/.devin/` at the repo root, and `.vscode/` at any depth, are ignored. @@ -174,15 +174,15 @@ Additional rules that apply specifically to maintainers of this template repo: ## Non-Negotiables -- **Never run `deploy.sh` or `deploy.ps1` against this repository.** Test deploy-script changes in a scratch directory or against another repo. See [Editing deploy scripts](#editing-deploy-scripts) for the reasoning. +- **Never run `scripts/deploy.sh` or `scripts/deploy.ps1` against this repository.** Test deploy-script changes in a scratch directory or against another repo. See [Editing deploy scripts](#editing-deploy-scripts) for the reasoning. - Never duplicate a standard across files. One canonical home per rule. - Never write a standard inside a per-agent redirect file. - Never paste playbook text into a skill wrapper — regenerate from frontmatter. - Keep authoring (markdown) and distribution (deploy scripts) separate in every change. - Never commit engagement artefacts or root-level deploy outputs. -- `deploy.sh` and `deploy.ps1` must remain behaviour-equivalent. -- `deploy.sh` must run on macOS bash 3.2 with BSD userland as well as on Linux with GNU userland. No bash 4+ syntax, no GNU-only utilities. -- `deploy.ps1` must run on Windows PowerShell 5.1 as well as PowerShell 7+. No PowerShell 7-only syntax. +- `scripts/deploy.sh` and `scripts/deploy.ps1` must remain behaviour-equivalent. +- `scripts/deploy.sh` must run on macOS bash 3.2 with BSD userland as well as on Linux with GNU userland. No bash 4+ syntax, no GNU-only utilities. +- `scripts/deploy.ps1` must run on Windows PowerShell 5.1 as well as PowerShell 7+. No PowerShell 7-only syntax. - The deploy-script workflows must run on every pull request from any branch, with no path filters. Never narrow their triggers. ## Decision Checklist @@ -192,7 +192,7 @@ Before opening a PR, confirm: - [ ] No existing file already covers this content (would be duplication). - [ ] Prose lives in `standards/`, `playbooks/`, or `core/.context/` — not in a per-agent file. - [ ] If a new standard or playbook: added to `core/.context/index.md` and (for standards) the table in `core/AGENTS.md`. -- [ ] If a deploy script change: both `deploy.sh` and `deploy.ps1` updated, and tested against a scratch directory — never against this repo. +- [ ] If a deploy script change: both `scripts/deploy.sh` and `scripts/deploy.ps1` updated, and tested against a scratch directory — never against this repo. - [ ] If a deploy script change: `shellcheck --severity=warning` is clean, and no bash 4+ syntax, GNU-only utilities, or PowerShell 7-only syntax was introduced. - [ ] If a new agent: redirect file added under `core/`, both deploy scripts updated, README table updated. - [ ] British English, kebab-case, prescriptive language. diff --git a/README.md b/README.md index ee2b0fa..955d48a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The `.context/index.md` file is a keyword-to-file routing table. Every agent — This is the cross-agent mechanism: any LLM-based agent can read a markdown table and match keywords. No proprietary skill system required. -For **Claude Code** and **GitHub Copilot** specifically, `deploy.sh` generates thin skill wrappers (`.claude/skills/` and `.github/skills/`) for the selected agents. The playbook is the single source of truth; the skill wrapper is a disposable adapter. +For **Claude Code** and **GitHub Copilot** specifically, `scripts/deploy.sh` generates thin skill wrappers (`.claude/skills/` and `.github/skills/`) for the selected agents. The playbook is the single source of truth; the skill wrapper is a disposable adapter. ### Example @@ -46,23 +46,23 @@ User says: "refactor the authentication module" ## Quick Start ```bash -./deploy.sh --agents all /path/to/target-repo +./scripts/deploy.sh --agents all /path/to/target-repo ``` Examples: ```bash # Claude only -./deploy.sh --agents claude /path/to/target-repo +./scripts/deploy.sh --agents claude /path/to/target-repo # Multiple agents -./deploy.sh --agents claude copilot cursor /path/to/target-repo +./scripts/deploy.sh --agents claude copilot cursor /path/to/target-repo # Deploy to current directory -./deploy.sh --agents all +./scripts/deploy.sh --agents all # Omit --agents to use the interactive arrow-key selector -./deploy.sh /path/to/target-repo +./scripts/deploy.sh /path/to/target-repo ``` Then fill in all `[CONFIGURE]` sections in `AGENTS.md` and any selected agent-specific files (for example `CLAUDE.md` when Claude is selected). @@ -84,7 +84,7 @@ core/ Tier 1 — always in context (→ target .devin/devin.json Devin config + index pointer .github/copilot-instructions.md Copilot redirect → AGENTS.md .claude/settings.json Claude Code permissions + hooks template - (skill wrappers are generated by deploy.sh + (skill wrappers are generated by scripts/deploy.sh for selected Claude/Copilot agents) standards/ Tier 3 — reference (→ target .context/standards/) @@ -139,6 +139,19 @@ playbooks/ Tier 2 — on demand (→ target .contex discover-local-otel-stack.md use-local-otel-stack.md instrument-dotnet-otel.md + +scripts/ Distribution tooling (not deployed to targets) + deploy.sh / deploy.ps1 First-time install into a target repository + update.sh / update.ps1 Staleness check and in-place update + migrate.sh / migrate.ps1 One-off upgrade for pre-2.0 deployments + lib/ Shared helpers (SemVer, manifest, hashing) + baselines/.sha256 Per-release hashes of deployable files + deploy.Tests.ps1 Pester unit tests + tests/ End-to-end test suites (bash and PowerShell) + +VERSION Canonical SemVer — written by CI, never by hand +CHANGELOG.md Generated from commit history on release +MIGRATIONS.md What consumers must do on each MAJOR bump ``` > Scripts in `playbooks/setup/create-local-otel-stack/` are deployed to @@ -163,7 +176,7 @@ keywords: [assess security, security audit, threat model] ... ``` -The `keywords` field feeds the context index. The `description` field is used by `deploy.sh` to generate Claude Code and GitHub Copilot skill wrappers. The content is plain markdown that any agent can read and follow. +The `keywords` field feeds the context index. The `description` field is used by `scripts/deploy.sh` to generate Claude Code and GitHub Copilot skill wrappers. The content is plain markdown that any agent can read and follow. ## Conventions @@ -186,7 +199,7 @@ Standards are maintained in **one place only**: | Per-concern detail | `standards/{concern}.md` | | On-demand context routing | `core/.context/index.md` | | Playbook procedures | `playbooks/{category}/{concern}.md` | -| Skill wrappers (Claude + Copilot) | Generated by `deploy.sh` — do not edit directly | +| Skill wrappers (Claude + Copilot) | Generated by `scripts/deploy.sh` — do not edit directly | ## Editing Guidelines diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 similarity index 98% rename from deploy.Tests.ps1 rename to scripts/deploy.Tests.ps1 index 3a968f6..65d3d90 100644 --- a/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -32,10 +32,10 @@ Describe 'deploy.ps1 (PowerShell version/platform compatibility)' { # isn't installed locally; run `Install-Module PSScriptAnalyzer -Scope CurrentUser` to enable # it. This is a static check - it does not catch semantic/runtime issues like Add-Type # resetting [Environment]::CurrentDirectory, which is why the compatibility test above and - # TC5 in tests/test-deploy.ps1 exist separately. + # TC5 in scripts/tests/test-deploy.ps1 exist separately. It 'has no PSScriptAnalyzer compatibility findings for Windows PowerShell 5.1 / PowerShell 7.0' -Skip:(-not $script:PSScriptAnalyzerAvailable) { Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path (Join-Path $PSScriptRoot 'deploy.ps1') -Settings (Join-Path $PSScriptRoot 'PSScriptAnalyzerSettings.psd1') + $results = Invoke-ScriptAnalyzer -Path (Join-Path $PSScriptRoot 'deploy.ps1') -Settings (Join-Path (Split-Path -Parent $PSScriptRoot) 'PSScriptAnalyzerSettings.psd1') $results | Should -BeNullOrEmpty } } diff --git a/deploy.ps1 b/scripts/deploy.ps1 similarity index 93% rename from deploy.ps1 rename to scripts/deploy.ps1 index a9309cb..396f1c8 100644 --- a/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -547,46 +547,47 @@ if (-not (Test-Path $script:Target -PathType Container)) { } } -$ScriptDir = $PSScriptRoot +# Content lives one level up: this script sits in scripts/, sources are at the repo root. +$SourceRoot = Split-Path -Parent $PSScriptRoot Write-Host "Deploying agent-contexts to $($script:Target)" Write-Host " Selected agents: $($script:EnabledAgents -join ', ')" Write-Host " Copying shared context files..." -Copy-SingleFile -Source (Join-Path $ScriptDir 'core/AGENTS.md') -Destination (Join-Path $script:Target 'AGENTS.md') -Copy-DirectoryContents -Source (Join-Path $ScriptDir 'core/.context') -Destination (Join-Path $script:Target '.context') +Copy-SingleFile -Source (Join-Path $SourceRoot 'core/AGENTS.md') -Destination (Join-Path $script:Target 'AGENTS.md') +Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context') -Destination (Join-Path $script:Target '.context') if (Test-AgentEnabled 'claude') { Write-Host " Copying Claude Code files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/CLAUDE.md') -Destination (Join-Path $script:Target 'CLAUDE.md') - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.claude/settings.json') -Destination (Join-Path $script:Target '.claude/settings.json') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/CLAUDE.md') -Destination (Join-Path $script:Target 'CLAUDE.md') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.claude/settings.json') -Destination (Join-Path $script:Target '.claude/settings.json') } if (Test-AgentEnabled 'copilot') { Write-Host " Copying GitHub Copilot files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.github/copilot-instructions.md') -Destination (Join-Path $script:Target '.github/copilot-instructions.md') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.github/copilot-instructions.md') -Destination (Join-Path $script:Target '.github/copilot-instructions.md') } if (Test-AgentEnabled 'cursor') { Write-Host " Copying Cursor files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.cursor/rules/standards.mdc') -Destination (Join-Path $script:Target '.cursor/rules/standards.mdc') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.cursor/rules/standards.mdc') -Destination (Join-Path $script:Target '.cursor/rules/standards.mdc') } if (Test-AgentEnabled 'devin') { Write-Host " Copying Devin files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.devin/devin.json') -Destination (Join-Path $script:Target '.devin/devin.json') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.devin/devin.json') -Destination (Join-Path $script:Target '.devin/devin.json') } if (Test-AgentEnabled 'windsurf') { Write-Host " Copying Windsurf files..." - Copy-SingleFile -Source (Join-Path $ScriptDir 'core/.windsurfrules') -Destination (Join-Path $script:Target '.windsurfrules') + Copy-SingleFile -Source (Join-Path $SourceRoot 'core/.windsurfrules') -Destination (Join-Path $script:Target '.windsurfrules') } Write-Host " Copying standards\ -> $($script:Target)\.context\standards\" -Copy-DirectoryContents -Source (Join-Path $ScriptDir 'standards') -Destination (Join-Path $script:Target '.context/standards') +Copy-DirectoryContents -Source (Join-Path $SourceRoot 'standards') -Destination (Join-Path $script:Target '.context/standards') Write-Host " Copying playbooks\ -> $($script:Target)\.context\playbooks\" -Copy-DirectoryContents -Source (Join-Path $ScriptDir 'playbooks') -Destination (Join-Path $script:Target '.context/playbooks') +Copy-DirectoryContents -Source (Join-Path $SourceRoot 'playbooks') -Destination (Join-Path $script:Target '.context/playbooks') if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { Write-Host " Generating skill wrappers from playbooks..." @@ -610,7 +611,7 @@ if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { ) foreach ($category in $playbookCategories) { - $dir = Join-Path $ScriptDir "playbooks/$($category.Dir)" + $dir = Join-Path $SourceRoot "playbooks/$($category.Dir)" if (Test-Path $dir) { $playbooks = Get-ChildItem -Path $dir -Filter '*.md' -File -ErrorAction SilentlyContinue foreach ($playbook in $playbooks) { diff --git a/deploy.sh b/scripts/deploy.sh similarity index 91% rename from deploy.sh rename to scripts/deploy.sh index e4a9aee..51641a1 100755 --- a/deploy.sh +++ b/scripts/deploy.sh @@ -533,45 +533,47 @@ if [[ ! -d "$TARGET" ]]; then fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Content lives one level up: this script sits in scripts/, sources are at the repo root. +SOURCE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" echo "Deploying agent-contexts to $TARGET" echo " Selected agents: $(join_by ', ' "${ENABLED_AGENTS[@]}")" echo " Copying shared context files..." -copy_file "$SCRIPT_DIR/core/AGENTS.md" "$TARGET/AGENTS.md" -copy_dir_contents "$SCRIPT_DIR/core/.context" "$TARGET/.context" +copy_file "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" +copy_dir_contents "$SOURCE_ROOT/core/.context" "$TARGET/.context" if agent_enabled claude; then echo " Copying Claude Code files..." - copy_file "$SCRIPT_DIR/core/CLAUDE.md" "$TARGET/CLAUDE.md" - copy_file "$SCRIPT_DIR/core/.claude/settings.json" "$TARGET/.claude/settings.json" + copy_file "$SOURCE_ROOT/core/CLAUDE.md" "$TARGET/CLAUDE.md" + copy_file "$SOURCE_ROOT/core/.claude/settings.json" "$TARGET/.claude/settings.json" fi if agent_enabled copilot; then echo " Copying GitHub Copilot files..." - copy_file "$SCRIPT_DIR/core/.github/copilot-instructions.md" "$TARGET/.github/copilot-instructions.md" + copy_file "$SOURCE_ROOT/core/.github/copilot-instructions.md" "$TARGET/.github/copilot-instructions.md" fi if agent_enabled cursor; then echo " Copying Cursor files..." - copy_file "$SCRIPT_DIR/core/.cursor/rules/standards.mdc" "$TARGET/.cursor/rules/standards.mdc" + copy_file "$SOURCE_ROOT/core/.cursor/rules/standards.mdc" "$TARGET/.cursor/rules/standards.mdc" fi if agent_enabled devin; then echo " Copying Devin files..." - copy_file "$SCRIPT_DIR/core/.devin/devin.json" "$TARGET/.devin/devin.json" + copy_file "$SOURCE_ROOT/core/.devin/devin.json" "$TARGET/.devin/devin.json" fi if agent_enabled windsurf; then echo " Copying Windsurf files..." - copy_file "$SCRIPT_DIR/core/.windsurfrules" "$TARGET/.windsurfrules" + copy_file "$SOURCE_ROOT/core/.windsurfrules" "$TARGET/.windsurfrules" fi echo " Copying standards/ → $TARGET/.context/standards/" -copy_dir_contents "$SCRIPT_DIR/standards" "$TARGET/.context/standards" +copy_dir_contents "$SOURCE_ROOT/standards" "$TARGET/.context/standards" echo " Copying playbooks/ → $TARGET/.context/playbooks/" -copy_dir_contents "$SCRIPT_DIR/playbooks" "$TARGET/.context/playbooks" +copy_dir_contents "$SOURCE_ROOT/playbooks" "$TARGET/.context/playbooks" if agent_enabled claude || agent_enabled copilot; then echo " Generating skill wrappers from playbooks..." @@ -584,29 +586,29 @@ if agent_enabled claude || agent_enabled copilot; then mkdir -p "$TARGET/.github/skills" fi - for playbook in "$SCRIPT_DIR"/playbooks/assess/*.md; do + for playbook in "$SOURCE_ROOT"/playbooks/assess/*.md; do filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "assess/$filename" done - for playbook in "$SCRIPT_DIR"/playbooks/review/*.md; do + for playbook in "$SOURCE_ROOT"/playbooks/review/*.md; do filename=$(basename "$playbook") # Review playbooks get read-only tools for Claude Code generate_skills_for_selected_agents "$playbook" "review/$filename" "Read, Grep, Glob, Bash(git *)" done - for playbook in "$SCRIPT_DIR"/playbooks/plan/*.md; do + for playbook in "$SOURCE_ROOT"/playbooks/plan/*.md; do filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "plan/$filename" done - for playbook in "$SCRIPT_DIR"/playbooks/refactor/*.md; do + for playbook in "$SOURCE_ROOT"/playbooks/refactor/*.md; do filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "refactor/$filename" done - if [[ -d "$SCRIPT_DIR/playbooks/debug" ]]; then - for playbook in "$SCRIPT_DIR"/playbooks/debug/*.md; do + if [[ -d "$SOURCE_ROOT/playbooks/debug" ]]; then + for playbook in "$SOURCE_ROOT"/playbooks/debug/*.md; do [[ -f "$playbook" ]] || continue filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "debug/$filename" \ @@ -614,16 +616,16 @@ if agent_enabled claude || agent_enabled copilot; then done fi - if [[ -d "$SCRIPT_DIR/playbooks/docs" ]]; then - for playbook in "$SCRIPT_DIR"/playbooks/docs/*.md; do + if [[ -d "$SOURCE_ROOT/playbooks/docs" ]]; then + for playbook in "$SOURCE_ROOT"/playbooks/docs/*.md; do [[ -f "$playbook" ]] || continue filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "docs/$filename" done fi - if [[ -d "$SCRIPT_DIR/playbooks/setup" ]]; then - for playbook in "$SCRIPT_DIR"/playbooks/setup/*.md; do + if [[ -d "$SOURCE_ROOT/playbooks/setup" ]]; then + for playbook in "$SOURCE_ROOT"/playbooks/setup/*.md; do [[ -f "$playbook" ]] || continue filename=$(basename "$playbook") generate_skills_for_selected_agents "$playbook" "setup/$filename" \ diff --git a/tests/fixtures/assess-observability-skill.md b/scripts/tests/fixtures/assess-observability-skill.md similarity index 100% rename from tests/fixtures/assess-observability-skill.md rename to scripts/tests/fixtures/assess-observability-skill.md diff --git a/tests/test-deploy.ps1 b/scripts/tests/test-deploy.ps1 similarity index 96% rename from tests/test-deploy.ps1 rename to scripts/tests/test-deploy.ps1 index f325c50..58c258a 100644 --- a/tests/test-deploy.ps1 +++ b/scripts/tests/test-deploy.ps1 @@ -11,7 +11,8 @@ $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $PSCommandPath -$RepoDir = Split-Path -Parent $ScriptDir +$RepoDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$ScriptsDir = Split-Path -Parent $ScriptDir $script:Passed = 0 $script:Failed = 0 @@ -109,7 +110,7 @@ Write-Host "" Write-Host "=== TC1: Fresh deploy — all agents ===" $tc1Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc1-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc1Dir -Force | Out-Null -& "$RepoDir/deploy.ps1" -Agents all -Overwrite -Target $tc1Dir *>$null +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc1Dir *>$null Write-Host " --- Playbook files ---" Assert-FileExists "create-local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack.md" @@ -179,7 +180,7 @@ Write-Host "" Write-Host "=== TC2: Agent-scoped deploy — Claude only ===" $tc2Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc2-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc2Dir -Force | Out-Null -& "$RepoDir/deploy.ps1" -Agents claude -Overwrite -Target $tc2Dir *>$null +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc2Dir *>$null Assert-FileExists "claude wrapper present" "$tc2Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" Assert-DirNotExists "copilot dir absent" "$tc2Dir/.github/skills/setup-create-local-otel-stack" @@ -193,7 +194,7 @@ Write-Host "" Write-Host "=== TC3: Agent-scoped deploy — Copilot only ===" $tc3Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc3-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc3Dir -Force | Out-Null -& "$RepoDir/deploy.ps1" -Agents copilot -Overwrite -Target $tc3Dir *>$null +& "$ScriptsDir/deploy.ps1" -Agents copilot -Overwrite -Target $tc3Dir *>$null Assert-FileExists "copilot wrapper present" "$tc3Dir/.github/skills/setup-create-local-otel-stack/SKILL.md" Assert-DirNotExists "claude dir absent" "$tc3Dir/.claude/skills/setup-create-local-otel-stack" @@ -207,7 +208,7 @@ Write-Host "" Write-Host "=== TC4: No regressions — existing thin wrappers ===" $tc4Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc4-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc4Dir -Force | Out-Null -& "$RepoDir/deploy.ps1" -Agents claude -Overwrite -Target $tc4Dir *>$null +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc4Dir *>$null Assert-FileExists "assess-observability" "$tc4Dir/.claude/skills/assess-observability/SKILL.md" @@ -235,7 +236,7 @@ $originalCurrentDirectory = [Environment]::CurrentDirectory try { Set-Location $tc5Launch [Environment]::CurrentDirectory = $tc5Corrupt - & "$RepoDir/deploy.ps1" -Agents claude -Overwrite -Target "../reltarget" *>$null + & "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target "../reltarget" *>$null } finally { Set-Location $originalCwd [Environment]::CurrentDirectory = $originalCurrentDirectory diff --git a/tests/test-deploy.sh b/scripts/tests/test-deploy.sh similarity index 96% rename from tests/test-deploy.sh rename to scripts/tests/test-deploy.sh index c6b009a..d487b2a 100644 --- a/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -11,7 +11,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +SCRIPTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # Detect whether the source filesystem supports permission differentiation. # On WSL2-mounted Windows filesystems, all files are rwxrwxrwx regardless @@ -144,7 +145,7 @@ assert_files_identical() { echo "" echo "=== TC1: Fresh deploy — all agents ===" TC1_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents all --overwrite "$TC1_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC1_DIR" >/dev/null 2>&1 echo " --- Playbook files ---" assert_file_exists "create-local-otel-stack.md" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack.md" @@ -223,7 +224,7 @@ rm -rf "$TC1_DIR" echo "" echo "=== TC2: Agent-scoped deploy — Claude only ===" TC2_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents claude --overwrite "$TC2_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC2_DIR" >/dev/null 2>&1 assert_file_exists "claude wrapper present" "$TC2_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" assert_dir_not_exists "copilot dir absent" "$TC2_DIR/.github/skills/setup-create-local-otel-stack" @@ -236,7 +237,7 @@ rm -rf "$TC2_DIR" echo "" echo "=== TC3: Agent-scoped deploy — Copilot only ===" TC3_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents copilot --overwrite "$TC3_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents copilot --overwrite "$TC3_DIR" >/dev/null 2>&1 assert_file_exists "copilot wrapper present" "$TC3_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" assert_dir_not_exists "claude dir absent" "$TC3_DIR/.claude/skills/setup-create-local-otel-stack" @@ -249,7 +250,7 @@ rm -rf "$TC3_DIR" echo "" echo "=== TC4: No regressions — existing thin wrappers ===" TC4_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents claude --overwrite "$TC4_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC4_DIR" >/dev/null 2>&1 assert_file_exists "assess-observability" "$TC4_DIR/.claude/skills/assess-observability/SKILL.md" assert_file_exists "review-security" "$TC4_DIR/.claude/skills/review-security/SKILL.md" @@ -274,11 +275,11 @@ TC5_CHECKSUMS2=$(mktemp) TC5_PERMS1=$(mktemp) TC5_PERMS2=$(mktemp) -"$REPO_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 checksum_tree "$TC5_DIR" > "$TC5_CHECKSUMS1" list_executables "$TC5_DIR" > "$TC5_PERMS1" -"$REPO_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 checksum_tree "$TC5_DIR" > "$TC5_CHECKSUMS2" list_executables "$TC5_DIR" > "$TC5_PERMS2" @@ -304,7 +305,7 @@ rm -rf "$TC5_DIR" "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" "$TC5_PERMS1" "$TC5_PERMS2 echo "" echo "=== TC6: validate-config passes ===" TC6_DIR=$(mktemp -d) -"$REPO_DIR/deploy.sh" --agents all --overwrite "$TC6_DIR" >/dev/null 2>&1 +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC6_DIR" >/dev/null 2>&1 if "$TC6_DIR/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" >/dev/null 2>&1; then pass "Deployed validate-config.sh exits 0" From c73e9a5de9ca746eeae3c61600d33e0bed290c52 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:09:33 +0100 Subject: [PATCH 02/25] feat(overrides): add immutable base with consumer-owned override layer Introduces the override architecture that makes divergence structurally impossible instead of merely detectable. Base content under .context/{standards,playbooks,conventions} is now framework-owned and replaced wholesale on update. Consumers place changes in .context/overrides/ mirroring the base path, with frontmatter declaring mode: replace (ignore base) or mode: extend (layer over base). The resolution rule is stated in .context/index.md and the AGENTS.md managed block, costing roughly three lines of always-in-context budget. AGENTS.md gains agentic-context:begin/end markers. Only that block is rewritten on deploy or update, so [CONFIGURE] sections and any other consumer content survive untouched. Adds scripts/lib/common.sh with SemVer comparison (numeric, so 1.10.0 correctly exceeds 1.9.0), pin matching, fail-open network helpers and manifest hashing; scripts/update.sh for staleness checks and in-place updates; scripts/migrate.sh to upgrade pre-2.0 deployments by promoting existing divergence into overrides automatically; and the 1.0.0 baseline hashes that make that promotion possible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/.context/index.md | 12 + core/.context/overrides/README.md | 134 +++++++ core/.context/overrides/conventions/.gitkeep | 0 core/.context/overrides/playbooks/.gitkeep | 0 core/.context/overrides/standards/.gitkeep | 0 core/AGENTS.md | 37 ++ scripts/baselines/1.0.0.sha256 | 78 +++++ scripts/deploy.sh | 161 ++++++++- scripts/lib/common.sh | 224 ++++++++++++ scripts/migrate.sh | 293 ++++++++++++++++ scripts/update.sh | 345 +++++++++++++++++++ 11 files changed, 1283 insertions(+), 1 deletion(-) create mode 100644 core/.context/overrides/README.md create mode 100644 core/.context/overrides/conventions/.gitkeep create mode 100644 core/.context/overrides/playbooks/.gitkeep create mode 100644 core/.context/overrides/standards/.gitkeep create mode 100644 scripts/baselines/1.0.0.sha256 create mode 100644 scripts/lib/common.sh create mode 100644 scripts/migrate.sh create mode 100644 scripts/update.sh diff --git a/core/.context/index.md b/core/.context/index.md index 2b35f97..684262a 100644 --- a/core/.context/index.md +++ b/core/.context/index.md @@ -4,6 +4,18 @@ Before starting any task, scan this index for matching keywords. Load the referenced files into your context before proceeding. Combine multiple matches when a task spans domains. +## Override resolution — apply to every file below + +Before loading any `.context/**` file, check whether `.context/overrides/` exists. If it does, that override takes precedence: + +- Override frontmatter `mode: replace` (or no `mode`) — load **only** the override; ignore the base file. +- Override frontmatter `mode: extend` — load the base file first, then apply the override on top. Where they conflict, the override wins. + +Base files under `.context/standards/`, `.context/playbooks/` and `.context/conventions/` are +managed by the framework and are replaced wholesale on update. Never edit them; put changes in +`.context/overrides/` instead. See `.context/overrides/README.md`. + --- ## Standards (reference — load when working in the domain) diff --git a/core/.context/overrides/README.md b/core/.context/overrides/README.md new file mode 100644 index 0000000..b6b6845 --- /dev/null +++ b/core/.context/overrides/README.md @@ -0,0 +1,134 @@ +# Overrides + +This directory is **yours**. The framework never reads, writes or deletes anything in it. + +Everything else under `.context/` is **base content**: it belongs to the framework, and +`update.sh` replaces it wholesale every time you take a new version. If you edit a base file +directly, your change is silently reverted on the next update. + +Put your changes here instead. They survive every update, forever. + +--- + +## How resolution works + +Mirror the path of the file you want to change, relative to `.context/`. + +| Base file | Your override | +| --- | --- | +| `.context/standards/security.md` | `.context/overrides/standards/security.md` | +| `.context/playbooks/review/code-quality.md` | `.context/overrides/playbooks/review/code-quality.md` | +| `.context/conventions/code.md` | `.context/overrides/conventions/code.md` | + +When an agent is about to load a base file, it checks for the override first. If one exists, +the override takes precedence. + +--- + +## The two modes + +Every override declares what it does in YAML frontmatter. + +### `mode: replace` — throw the base away + +Use when the framework's version is wrong for you and you want your own rules entirely. + +```markdown +--- +overrides: standards/testing.md +mode: replace +--- + +# Testing Standard + +We use a different model from the framework default. This file is the whole truth; +the base standard is ignored. + +## Non-Negotiables + +- Minimum coverage is 75%, measured on the service layer only. +``` + +### `mode: extend` — keep the base, layer on top + +Use when the framework is broadly right and you want to tighten, relax or add to it. This is +the common case, and it is the one that ages best — you keep inheriting upstream improvements. + +```markdown +--- +overrides: standards/testing.md +mode: extend +--- + +## Coverage + +Coverage minimum is **95%**, not the framework default of 90%. + +## Additional Requirement + +Every bug fix must ship with a regression test that fails without the fix. +``` + +The base standard loads first, then this file. Where the two conflict, this file wins. +Everything the base says that this file does not contradict still applies. + +**Prefer `extend`.** A `replace` override is a permanent fork of that file: you stop receiving +upstream improvements to it entirely. + +--- + +## Frontmatter reference + +| Field | Required | Values | Meaning | +| --- | --- | --- | --- | +| `overrides` | Yes | Path relative to `.context/` | The base file this override applies to | +| `mode` | No | `replace` (default) or `extend` | Whether the base is discarded or layered under | + +`overrides` must match the file's own location. `.context/overrides/standards/security.md` +must declare `overrides: standards/security.md`. `update.sh` warns when they disagree, +because a mismatch usually means a file was copied and the frontmatter was not updated. + +--- + +## Adding something entirely new + +An override does not have to correspond to a base file. To add a standard or playbook the +framework does not ship, put it here with no `overrides` field: + +```markdown +--- +name: standards-billing-domain +description: "Rules specific to our billing domain." +keywords: [billing, invoice, dunning, payment retry] +--- +``` + +Then add a keyword route for it in your `AGENTS.md`, **outside** the managed block, so agents +can discover it. Do not add it to `.context/index.md` — that file is base content and your +edit would be reverted on the next update. + +--- + +## What happens on update + +`update.sh` will: + +- Replace every base file under `.context/standards/`, `.context/playbooks/` and + `.context/conventions/` with the new version. +- **Never touch this directory.** +- Report any base file you had edited directly, and restore it — with a pointer to the + override path you should have used. +- Report standards and playbooks that were **added** or **removed** upstream. A removed base + file whose override still exists is flagged: your override now points at nothing, so either + delete it or convert it into a standalone addition. + +--- + +## Checking your overrides + +```bash +.context/bin/update.sh --check +``` + +Reports the current version, whether a newer one is available, and any override whose +`overrides:` target no longer exists. diff --git a/core/.context/overrides/conventions/.gitkeep b/core/.context/overrides/conventions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/.context/overrides/playbooks/.gitkeep b/core/.context/overrides/playbooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/.context/overrides/standards/.gitkeep b/core/.context/overrides/standards/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core/AGENTS.md b/core/AGENTS.md index 9226551..6469892 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -98,6 +98,12 @@ Infrastructure (Database / External APIs / Messaging) --- + + + ## Context System This repository uses on-demand context loading. Before starting any task, read `.context/index.md` and load files matching the current task's domain. @@ -108,6 +114,21 @@ Available context types: - **Playbooks** in `.context/playbooks/` — step-by-step procedures for assessments, reviews, plans, and refactoring - **Conventions** in `.context/conventions/` — workflow, communication, and coding style guidance +### Override Resolution + +Before loading any `.context/**` file, check whether `.context/overrides/` exists. If it does, that override takes precedence: + +- `mode: replace` (or no `mode`) — load **only** the override; ignore the base file. +- `mode: extend` — load the base file first, then apply the override on top. Where they conflict, the override wins. + +Base files under `.context/standards/`, `.context/playbooks/` and `.context/conventions/` are framework-managed and are replaced on update. Never edit them; put changes in `.context/overrides/`. See `.context/overrides/README.md`. + +### Framework Updates + +Check frequency: **weekly** + +At session start, if `.context/.last-update-check` is missing or older than the frequency above, run `.context/bin/update.sh --check` once. Report at most one line. **Never block the session.** If the check fails for any reason, continue silently. + --- ## Mandated Standards @@ -162,8 +183,24 @@ The following standards are non-negotiable. Do not weaken them. Detailed guidanc | OpenTelemetry | Cross-language SDK patterns, OTLP protocol, backends | `.context/standards/opentelemetry.md` | | OpenTelemetry .NET | .NET instrumentation, pitfalls, testing patterns | `.context/standards/opentelemetry-dotnet.md` | + + --- ## Project-Specific Rules [CONFIGURE] + +--- + +## Additional Context [CONFIGURE] + + + diff --git a/scripts/baselines/1.0.0.sha256 b/scripts/baselines/1.0.0.sha256 new file mode 100644 index 0000000..6098fe5 --- /dev/null +++ b/scripts/baselines/1.0.0.sha256 @@ -0,0 +1,78 @@ +conventions/code.md 306b9916bd0ebc87c418b4ded90a56c600708b0144a37de4aaca53484e19fb25 +conventions/communication.md 508a9981cc3830e9992eb6edb30473c06ed149bcc63bab63fb8683e277d6ee03 +conventions/workflow.md 6cc7f88c4dde53b9a713f9257060497abdf3e343c6e09b515b0cdb38464eb8cd +index.md 32a985ba27f992afdbe28da8f415c4e391ca7cd4ca87922e2ede8325ea4524bf +playbooks/assess/accessibility.md b9349df70e40518df65ef528f7366d288ffb5183fa2ef95f0641feb02ba6363c +playbooks/assess/api-design.md ffb618ba0ce0e6909d89e18f1fceef214e1df5eac4619593091b415aa4318763 +playbooks/assess/architecture.md e8b1de975d950f1d85a5ee7380e5a2385809711bc71824886c87c3399f0fa305 +playbooks/assess/aws-well-architected.md 962ca955caf258746d4b50b72ac6d48390a330f524a8d2e2065d1f185adb8d2d +playbooks/assess/azure-well-architected.md 05ccf31371bd07c918a2a2ff255e37a14acdcbf584927663f2dc03d0dc7fa66a +playbooks/assess/ci-cd.md 339e25cb98ebd5d2c3d9092d67f8a66d28388b4b92849c3c082f7d7f5b7e6f91 +playbooks/assess/code-quality.md 8695ebbdf1be36e82e704e32dd3655e894873686801861b704b9f36b0494eab0 +playbooks/assess/cost-optimisation.md 04dda1b90eb7db6726cdfb6fa0eb2a3ae29f897488a47502887654c3a7e56931 +playbooks/assess/domain-security.md 3b7ab6ddfee426b9c956c948f54890cdf124a31b09c7bb4ead4e96f49547504b +playbooks/assess/full.md d560735f7deab0b9f377ce33458590c8d6bd76da81c5e91b5ed55ef5da2ec3ac +playbooks/assess/gdpr.md c3a0727188cecae887be68adae8ba98eb1f2c598e06d4db6c439479191c60258 +playbooks/assess/iac.md 9977d505a067d47ad8b40a6e14fd501cec1b9082d432f51dd76d93b174e07711 +playbooks/assess/observability.md 04e05fbfbbc762871faab52de3ba85acc8711b7ddd1adfdf22edd35047b95cf3 +playbooks/assess/operational-excellence.md 6e1c03adbdf22a7a6dc6cc809736fe97c71d0043cd8e83834fa102fc9ab0f6a6 +playbooks/assess/pci-dss.md dbf890ac9882322d6a3019465f7e2b0a1d9f030410f97b7583f77a8d3b190446 +playbooks/assess/pen-test.md d2897e4e7d8c229228085ec9cabd86a390a00867a0ef55bd7057114e6a1698f0 +playbooks/assess/performance.md b7e080aea72e168ebc3aedb7dc97cf410cd98f102ded3dda0df86017a61d9f23 +playbooks/assess/resilience.md 666e2ab2db632f4525a69fd767870bf30f7759e30e4960383cbf28d3d83e48fc +playbooks/assess/security.md 5788e854bc32e1a1acdca278a579616c3e6f0e329b0a78280fcdc848895f7d38 +playbooks/assess/tech-debt.md bd7f183b5f13556d01852be2ecd29f168725e5da8cb3fb93d5b5defec415fe89 +playbooks/assess/testing.md 3e37c8ab2ac430184b0a80510ab9228862e9bcf0a3811d14bb64082296cf2cb7 +playbooks/debug/scientific-debugging.md 1a52e094b429b34074428d3d215feffbdf5a44d6cb66d8d64cdbbea8208531eb +playbooks/docs/gitbook.md 5951d16a59db69e693e07845f11129bb122a13bebbaa3ab177a733365d619f06 +playbooks/plan/adr.md 1015979a43df16e3f469b4f4f5fb8afe15df300d29cda400f05fc977f4ea7c7a +playbooks/plan/design-doc.md 938a2ea9ee36e10e0caf70ef51149ef2c44d45dbe4f94cecf457e4c536dfb5ea +playbooks/plan/research.md 8b0c774783cb369bafd09e37b4be641224ee836caf564f14ab7f3536f5e73347 +playbooks/plan/risk-assessment.md e68d1dbbcecf3ac31f0842c2ea37f28e176d9edb72bffea0c7c95d36d221711e +playbooks/plan/spike.md 29b9f19ff3a030eb7bdcf67b9903fb919282cd78997ca2103c4729328fc2cef4 +playbooks/refactor/dependency-upgrade.md b8bdf61de51e5f98fea320971c356a9f8bd4e73706ba3f87ee6e98dffd85e86a +playbooks/refactor/extract-module.md c3d67a579216457e471263d09e6783bf93e1c9e701682fa72a015402469764d3 +playbooks/refactor/safe-refactor.md c767d5bd189d3868163dcdaa786371e4b88e246a3ecd0a64aaba09f10d185416 +playbooks/review/accessibility.md 21e31f3116b8690373dae060d850f776f05154bee0c5f038ba4f0c2f16f14ed1 +playbooks/review/api-design.md 113721e0474bb09580fd11dc4b96edccf9b9916330dd794988e2aa10a6a57ee9 +playbooks/review/architecture.md 8deb5eb26771519a7f0612d2eaedb0a78f12b1560f10e18c377b80b5fef05c7c +playbooks/review/code-quality.md aedfb78f9c96d827096f8714a4e2f872423aacd9c1eb54ca6e8cabec1a850988 +playbooks/review/compliance.md f7a6c4d568b70ecc133ba7bd7747139a715cc19a0b06b5821b7e7e11f54c46bf +playbooks/review/cost-optimisation.md 69a8bd5c8bc5ec3372c5a7ba39e076c40c3729a1b645f75f33c6e1a12dd448bc +playbooks/review/iac.md 8449c682b18e38040cfa42cb721c532556c0b190878691fe6dc5581a327e3070 +playbooks/review/observability.md 4796c378b6067fd8192a10b5ff21aaf04375c9d08acde77e64f6f733e0f767b2 +playbooks/review/performance.md 36a17da7faaeaf6848ed24d1528f4ad98369d2a3cb40479477fafc16099032c7 +playbooks/review/security.md 5a209101a4c54199e370c54079464843f4ca480597e42080d4e61eb6dc2baf37 +playbooks/review/testing.md eae8dec22058ccf5db9771ea461f1dd404a549ce072f61ad725174e78cb882b4 +playbooks/setup/create-local-otel-stack.md d085a19dd7d426a89fa7900bbaadfa581aafd69c6117f7b35312a6d9f8d43049 +playbooks/setup/discover-local-otel-stack.md b226d151297ad068c6195d9dc52ef001c605219f0e6593aa71f8656649bbb9f4 +playbooks/setup/use-local-otel-stack.md 75f5ffbbf83f87495e00d8994b7de79409434b4f1e656734bffa00cdb3a3a2d3 +standards/accessibility.md 222c94e68e30f73081913e46eeb2d423eee8306b7753899729ba83f9560ffc75 +standards/ado-pipelines.md ecfaf7686ec26502e945864d3babed3da156c955e937a845c9802d78f24d5b34 +standards/api-design.md 93fbe27830ce29ddc0510088a178e9a27cfa56b1e697a88b9a93adad48b0658b +standards/architecture.md a1a1ff3f370f56c4ce82519bb137389985d666646c9aa252bc644233d1067762 +standards/aws-well-architected.md 3ef134cee4986cb4ef85cdd113663966d85917bf7e7b602b3b79ef43e87aa7e6 +standards/azure-well-architected.md 6b18818e64c4b17fbf5e348f1ba78ff6e0bd78c3e77560b5b1e11018d309c30f +standards/ci-cd.md 98a2e7e7424e8a3739372f9982a5d9643567c329e21af899824db1a2d859a977 +standards/code-quality.md 93d89564bc31ee8c795156ef0d0f64daf0ef651f3dda891bb81c64921d4b63f7 +standards/cost-optimisation.md 73c60ce6e398a703ebf035d960ccc17ddf05cac3d26ab6a1f9bb2a2a3581eb0e +standards/debugging.md af61c44c446009d72adfadf0c996e3161ca5c533e85e0bfd5a64aa6fd9a6b0ae +standards/docker.md af46fa7c7a95a313082587015f72f44421300502ef91e0f97f7df7f5282b846c +standards/dotnet.md ae7aecfe9167bf442e318e8336384326917f6823095bf88d6dfe250764cd8095 +standards/gdpr.md 6fdd28381e69ee097839b1f2eeeceead1a17b98e9e5b906727a6702022b58cc8 +standards/iac.md 42e3f14947e6e666efcac2a90da19308828c87b0d0f2ffb374e7205320a2f73e +standards/mssql.md 2d66ad11557e317a1bf52163c01900a3ff0f6f71655c1289efb7cf806f1de25b +standards/observability.md 715b0e76ad1a42049d83c7d5afb694e1cd52d0289c0341cd97c32646fe816a1d +standards/opentelemetry-dotnet.md 9c7fb12d69c14d4baeeb2fc0e9d580db148751905061c51fa3e5bad4c124cd87 +standards/opentelemetry.md aa46ebaa4ab8ecab0fb1e1d9edb50a3303b37bb46e738c90d6631275946e754d +standards/operational-excellence.md b3ab56143a895b8b38ca549a7899555e1013a7486b0a428fcd33409bf0959e8c +standards/pci-dss.md 1ed1186eca0f2009d43e2aade351a932f4ba35b69b9153a39d38986107a6b504 +standards/performance.md b3f6963bf3a21ca3a23a5cb9b4678d1eeeb96316452305649c4fe95ca250bbd6 +standards/playwright.md 435c1630df617db05a217ee9d2cf81736db011fa982bab9f2f6d810b54cf5861 +standards/powershell.md 4644e1ef2f28360566f5beebde38ca23ea7e611e5e3d3cfa7f55cb59467a5f05 +standards/react.md f4cdad1447ef376de05583d69cbaed1f83fc2e0371b8476d34efd29c373df57a +standards/resilience.md 4e6550f5ffca539c78cc392ed9094096d97c3a73f8382cd38bff8000d6aaad1e +standards/security.md d43c02e6e464c2703354bac503b931856073a0ba2d82d6f3419882e8aa1a00a0 +standards/tech-debt.md 5fd717bd084d6453b8616a14dd1ff7a6ccb73f88e65a33a210d3680e1352a427 +standards/terraform.md 0a1ef3e7f39800aa348e947c6ae40c775f84ed9ced2d71a46bddbeb7fc240c26 +standards/testing.md 1dfbcc360460498eaab906b98f145489bd5d5f572c8dfaa73fa81b6bcd42c9de diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 51641a1..70bf3d4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -165,6 +165,125 @@ copy_dir_contents() { done < <(find "$src" -type f -print0 | sort -z) } +# --- managed block --------------------------------------------------------- +# +# The consumer owns AGENTS.md: it carries their [CONFIGURE] sections. The +# framework owns only the region between the begin/end markers. Rewriting just +# that region is the only safe way to refresh a file we do not own. + +AC_BEGIN_MARKER='' + +# Return 0 when the file contains a well-formed managed block. +has_managed_block() { + local file="$1" + [ -f "$file" ] || return 1 + grep -q "^$AC_BEGIN_MARKER" "$file" 2>/dev/null || return 1 + grep -qF "$AC_END_MARKER" "$file" 2>/dev/null || return 1 + return 0 +} + +# Print the managed block (markers included) from the source template. +extract_managed_block() { + local file="$1" + awk -v b="$AC_BEGIN_MARKER" -v e="$AC_END_MARKER" ' + index($0, b) == 1 { inblock = 1 } + inblock { print } + index($0, e) == 1 { inblock = 0 } + ' "$file" +} + +# Replace the managed block in $dst with the one from $src, preserving +# everything outside the markers byte for byte. +replace_managed_block() { + local src="$1" dst="$2" version="$3" + local block_file tmp_file + + block_file="$(mktemp)" + tmp_file="$(mktemp)" + + extract_managed_block "$src" \ + | sed "1s|^$AC_BEGIN_MARKER.*|$AC_BEGIN_MARKER $version -->|" > "$block_file" + + awk -v b="$AC_BEGIN_MARKER" -v e="$AC_END_MARKER" -v blockfile="$block_file" ' + index($0, b) == 1 { + inblock = 1 + while ((getline line < blockfile) > 0) { print line } + close(blockfile) + next + } + index($0, e) == 1 && inblock { inblock = 0; next } + !inblock { print } + ' "$dst" > "$tmp_file" + + cat "$tmp_file" > "$dst" + rm -f "$block_file" "$tmp_file" +} + +# Deploy AGENTS.md safely. +# - absent -> copy the template whole +# - has managed block -> rewrite only that block, keep the rest untouched +# - no managed block -> defer to the normal overwrite prompt +deploy_agents_md() { + local src="$1" dst="$2" version="$3" + + if [ ! -f "$dst" ]; then + mkdir -p "$(dirname "$dst")" + sed "s|^$AC_BEGIN_MARKER.*|$AC_BEGIN_MARKER $version -->|" "$src" > "$dst" + return 0 + fi + + if has_managed_block "$dst"; then + replace_managed_block "$src" "$dst" "$version" + echo " AGENTS.md: refreshed managed block (your content preserved)" + return 0 + fi + + copy_file "$src" "$dst" +} + +# --- manifest -------------------------------------------------------------- + +# Write .context/manifest.json describing what was deployed, so update and +# migrate can tell pristine base files from consumer-edited ones. +write_manifest() { + local target="$1" version="$2" agents="$3" + local manifest="$target/.context/manifest.json" + local ctx="$target/.context" + local now first=1 + + now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + + mkdir -p "$ctx" + { + printf '{\n' + printf ' "schema": 1,\n' + printf ' "version": "%s",\n' "$version" + printf ' "source": "%s",\n' "$AC_SOURCE_REPO" + printf ' "pin": "%s",\n' "$(ac_semver_major "$version").x" + printf ' "checkFrequency": "weekly",\n' + printf ' "deployedAt": "%s",\n' "$now" + printf ' "agents": [' + for agent in $agents; do + if [ $first -eq 1 ]; then first=0; else printf ','; fi + printf '"%s"' "$agent" + done + printf '],\n' + printf ' "files": {\n' + + first=1 + ac_hash_context_tree "$ctx" | while IFS= read -r line; do + rel="${line%% *}" + hash="${line##* }" + if [ $first -eq 1 ]; then first=0; else printf ',\n'; fi + printf ' "%s": "%s"' "$rel" "$hash" + done + + printf '\n }\n' + printf '}\n' + } > "$manifest" +} + interactive_select_agents() { local options=(all "${VALID_AGENTS[@]}" "clear and exit") local options_count=${#options[@]} @@ -536,11 +655,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Content lives one level up: this script sits in scripts/, sources are at the repo root. SOURCE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# shellcheck source=scripts/lib/common.sh +. "$SCRIPT_DIR/lib/common.sh" + +DEPLOY_VERSION="$(ac_semver_normalise "$(cat "$SOURCE_ROOT/VERSION" 2>/dev/null || echo '0.0.0')")" +ac_semver_is_valid "$DEPLOY_VERSION" || DEPLOY_VERSION="0.0.0" + echo "Deploying agent-contexts to $TARGET" +echo " Version: $DEPLOY_VERSION" echo " Selected agents: $(join_by ', ' "${ENABLED_AGENTS[@]}")" echo " Copying shared context files..." -copy_file "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" +deploy_agents_md "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" "$DEPLOY_VERSION" copy_dir_contents "$SOURCE_ROOT/core/.context" "$TARGET/.context" if agent_enabled claude; then @@ -636,6 +762,28 @@ else echo " Skipping skill wrapper generation (no selected agent uses skills)." fi +# Consumer-owned override tree. Created empty; never touched again by update. +echo " Creating override layer → $TARGET/.context/overrides/" +mkdir -p "$TARGET/.context/overrides" +copy_dir_contents "$SOURCE_ROOT/core/.context/overrides" "$TARGET/.context/overrides" + +# Update tooling, shipped into the target so it can maintain itself. +echo " Installing update tooling → $TARGET/.context/bin/" +mkdir -p "$TARGET/.context/bin" +for tool in update.sh update.ps1 migrate.sh migrate.ps1; do + if [[ -f "$SOURCE_ROOT/scripts/$tool" ]]; then + cp "$SOURCE_ROOT/scripts/$tool" "$TARGET/.context/bin/$tool" + fi +done +mkdir -p "$TARGET/.context/bin/lib" +cp "$SOURCE_ROOT/scripts/lib/common.sh" "$TARGET/.context/bin/lib/common.sh" +chmod +x "$TARGET/.context/bin"/*.sh 2>/dev/null || true + +printf '%s\n' "$DEPLOY_VERSION" > "$TARGET/.context/VERSION" + +echo " Writing manifest → $TARGET/.context/manifest.json" +write_manifest "$TARGET" "$DEPLOY_VERSION" "${ENABLED_AGENTS[*]}" + echo "" echo "Done. Next steps:" step=1 @@ -655,6 +803,17 @@ if agent_enabled copilot; then next_step "Review $TARGET/.github/copilot-instructions.md" fi +next_step "Never edit .context/standards|playbooks|conventions directly — use .context/overrides/ (see .context/overrides/README.md)" + +# Report staleness on every deploy. Fails open and never blocks. +if latest="$(ac_fetch_latest_version "$AC_SOURCE_REPO" 2>/dev/null)" && [[ -n "$latest" ]]; then + if ac_semver_gt "$latest" "$DEPLOY_VERSION"; then + echo "" + echo " Note: you deployed $DEPLOY_VERSION but $latest is available upstream." + echo " Pull the latest agentic-context and re-run this script." + fi +fi + if [[ ${#SKIPPED_FILES[@]} -gt 0 ]]; then echo "" echo "Skipped files (not overwritten — manual merge may be required):" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..7fa9691 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# scripts/lib/common.sh — shared helpers for deploy, update and migrate. +# +# Portability: must run under macOS bash 3.2 with BSD userland as well as Linux +# with GNU userland. No bash 4+ syntax (no declare -A, no mapfile, no ${var,,}), +# no GNU-only utilities. +# +# Source this file; do not execute it. + +AC_SOURCE_REPO="${AC_SOURCE_REPO:-ldastey-dev/agentic-context}" +AC_RAW_BASE="${AC_RAW_BASE:-https://raw.githubusercontent.com}" +AC_WEB_BASE="${AC_WEB_BASE:-https://github.com}" + +# --- hashing --------------------------------------------------------------- + +# Print the sha256 of a file as a bare hex digest. +# macOS has shasum but not always sha256sum; Linux usually has both. +ac_sha256() { + local file="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + else + echo "ERROR: neither shasum nor sha256sum is available" >&2 + return 1 + fi +} + +# --- semver ---------------------------------------------------------------- + +# Strip a leading "v" and any surrounding whitespace. +ac_semver_normalise() { + printf '%s' "$1" | tr -d '[:space:]' | sed 's/^[vV]//' +} + +# Return 0 when the string is a bare X.Y.Z of non-negative integers. +ac_semver_is_valid() { + local v + v="$(ac_semver_normalise "$1")" + case "$v" in + ''|*[!0-9.]*) return 1 ;; + esac + # Exactly three dot-separated numeric components. + echo "$v" | grep -q '^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$' +} + +ac_semver_major() { ac_semver_normalise "$1" | cut -d. -f1; } +ac_semver_minor() { ac_semver_normalise "$1" | cut -d. -f2; } +ac_semver_patch() { ac_semver_normalise "$1" | cut -d. -f3; } + +# ac_semver_gt A B -> 0 (true) when A is strictly greater than B. +# +# Compares numerically component by component. sort -V is deliberately avoided: +# BSD sort on older macOS lacks -V, and its ordering of equal values would make +# "strictly greater" ambiguous. +ac_semver_gt() { + local a b a1 a2 a3 b1 b2 b3 + a="$(ac_semver_normalise "$1")" + b="$(ac_semver_normalise "$2")" + + ac_semver_is_valid "$a" || return 1 + ac_semver_is_valid "$b" || return 1 + + a1="$(ac_semver_major "$a")"; a2="$(ac_semver_minor "$a")"; a3="$(ac_semver_patch "$a")" + b1="$(ac_semver_major "$b")"; b2="$(ac_semver_minor "$b")"; b3="$(ac_semver_patch "$b")" + + if [ "$a1" -gt "$b1" ]; then return 0; fi + if [ "$a1" -lt "$b1" ]; then return 1; fi + if [ "$a2" -gt "$b2" ]; then return 0; fi + if [ "$a2" -lt "$b2" ]; then return 1; fi + if [ "$a3" -gt "$b3" ]; then return 0; fi + return 1 +} + +# ac_semver_bump +ac_semver_bump() { + local cur="$1" kind="$2" x y z + cur="$(ac_semver_normalise "$cur")" + ac_semver_is_valid "$cur" || { echo "ERROR: invalid version '$1'" >&2; return 1; } + x="$(ac_semver_major "$cur")"; y="$(ac_semver_minor "$cur")"; z="$(ac_semver_patch "$cur")" + case "$kind" in + major) x=$((x + 1)); y=0; z=0 ;; + minor) y=$((y + 1)); z=0 ;; + patch) z=$((z + 1)) ;; + *) echo "ERROR: invalid bump kind '$kind'" >&2; return 1 ;; + esac + printf '%s.%s.%s' "$x" "$y" "$z" +} + +# Return 0 when satisfies . Pin forms: "" or "*" (any), +# "2.x" / "2" (major line), or an exact "2.3.1". +ac_semver_satisfies_pin() { + local version="$1" pin="$2" + version="$(ac_semver_normalise "$version")" + pin="$(printf '%s' "$pin" | tr -d '[:space:]' | sed 's/^[vV]//')" + + if [ -z "$pin" ] || [ "$pin" = "*" ]; then + return 0 + fi + + case "$pin" in + *.x|*.X) + [ "$(ac_semver_major "$version")" = "${pin%.[xX]}" ] && return 0 + return 1 + ;; + *.*.*) + [ "$version" = "$pin" ] && return 0 + return 1 + ;; + *) + [ "$(ac_semver_major "$version")" = "$pin" ] && return 0 + return 1 + ;; + esac +} + +# --- network --------------------------------------------------------------- +# +# Every network helper FAILS OPEN: on any error it prints nothing and returns +# non-zero. Callers must treat "no answer" as "up to date" and never block. + +AC_CURL_TIMEOUT="${AC_CURL_TIMEOUT:-5}" + +ac_have_curl() { command -v curl >/dev/null 2>&1; } + +# Fetch the canonical VERSION from the default branch. Cheapest transport: +# ~6 bytes, CDN-cached, unauthenticated, no rate limit. +ac_fetch_latest_version_raw() { + local repo="${1:-$AC_SOURCE_REPO}" branch="${2:-main}" out + ac_have_curl || return 1 + out="$(curl -fsS --max-time "$AC_CURL_TIMEOUT" \ + "$AC_RAW_BASE/$repo/$branch/VERSION" 2>/dev/null)" || return 1 + out="$(ac_semver_normalise "$out")" + ac_semver_is_valid "$out" || return 1 + printf '%s' "$out" +} + +# Fallback: resolve the newest release from the /releases/latest 302 redirect. +# Unauthenticated and not subject to the REST API's 60/hr per-IP limit. +ac_fetch_latest_version_release() { + local repo="${1:-$AC_SOURCE_REPO}" loc out + ac_have_curl || return 1 + loc="$(curl -fsSI --max-time "$AC_CURL_TIMEOUT" \ + "$AC_WEB_BASE/$repo/releases/latest" 2>/dev/null \ + | tr -d '\r' | awk 'tolower($1) == "location:" { print $2 }' | tail -1)" || return 1 + [ -n "$loc" ] || return 1 + out="$(ac_semver_normalise "${loc##*/}")" + ac_semver_is_valid "$out" || return 1 + printf '%s' "$out" +} + +# Try the cheap transport, then the fallback. Prints nothing on total failure. +ac_fetch_latest_version() { + local repo="${1:-$AC_SOURCE_REPO}" v + if v="$(ac_fetch_latest_version_raw "$repo")"; then + printf '%s' "$v" + return 0 + fi + if v="$(ac_fetch_latest_version_release "$repo")"; then + printf '%s' "$v" + return 0 + fi + return 1 +} + +# --- manifest -------------------------------------------------------------- +# +# The manifest is JSON so PowerShell can use ConvertFrom-Json natively and bash +# needs only grep/sed. It is written by us and read by us, so a full parser is +# unnecessary; these helpers read one scalar key at a time. + +# ac_manifest_get +ac_manifest_get() { + local file="$1" key="$2" + [ -f "$file" ] || return 1 + sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -1 +} + +# Hash every deployable file in a deployed .context tree, printing +# " " lines sorted by path. +# +# Relative paths are relative to the .context directory. Overrides and the +# bin/ directory are excluded: overrides belong to the consumer, and bin/ is +# refreshed like any other base file but is not part of the content baseline. +ac_hash_context_tree() { + local ctx="$1" f rel + [ -d "$ctx" ] || return 1 + find "$ctx" -type f -name '*.md' \ + ! -path "$ctx/overrides/*" \ + ! -path "$ctx/bin/*" \ + 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + rel="${f#"$ctx"/}" + printf '%s %s\n' "$rel" "$(ac_sha256 "$f")" + done +} + +# Hash the deployable source files in this repository, printing the same +# " " shape so the two can be diffed directly. +ac_hash_source_tree() { + local root="$1" f rel + [ -d "$root" ] || return 1 + { + find "$root/standards" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + printf 'standards/%s %s\n' "${f#"$root"/standards/}" "$(ac_sha256 "$f")" + done + find "$root/playbooks" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + printf 'playbooks/%s %s\n' "${f#"$root"/playbooks/}" "$(ac_sha256 "$f")" + done + find "$root/core/.context/conventions" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + printf 'conventions/%s %s\n' "${f#"$root"/core/.context/conventions/}" "$(ac_sha256 "$f")" + done + if [ -f "$root/core/.context/index.md" ]; then + printf 'index.md %s\n' "$(ac_sha256 "$root/core/.context/index.md")" + fi + } | LC_ALL=C sort +} + +# Look up one path's expected hash in a baseline file. +ac_baseline_lookup() { + local baseline="$1" path="$2" + [ -f "$baseline" ] || return 1 + awk -v p="$path" '$1 == p { print $2; found = 1; exit } END { exit !found }' "$baseline" +} diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100644 index 0000000..33cb5e6 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,293 @@ +#!/bin/bash +# migrate.sh — upgrade a pre-2.0 agentic-context deployment to the override model. +# +# Pre-2.0 deployments have no .context/manifest.json and no .context/overrides/. +# Consumers may have edited standards and playbooks directly. This script finds +# those edits by comparing against a published baseline for the version they are +# on, and promotes each edited file into .context/overrides/ so their intent is +# preserved before base content is restored. +# +# Safe by default: dry-run unless --apply is given, and refuses to run on a +# dirty git tree so every change is reviewable. +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/lib/common.sh" ]; then + # shellcheck source=scripts/lib/common.sh + . "$SCRIPT_DIR/lib/common.sh" +else + echo "ERROR: cannot locate lib/common.sh next to migrate.sh" >&2 + exit 1 +fi + +APPLY=0 +TARGET="" +FROM_VERSION="1.0.0" +BASELINE="" + +usage() { + cat <] [--baseline ] [target-repo] + +Upgrade a pre-2.0 deployment to the override model. + + --apply Write changes. Without it, reports what would happen and exits. + --from Version the target was deployed from. Default: 1.0.0 + --baseline Baseline hash file. Default: scripts/baselines/.sha256 + target-repo Repository to migrate. Default: current directory. + +What it does: + 1. Refuses to run on a dirty git tree. + 2. Hashes .context/{standards,playbooks,conventions} against the baseline. + 3. Moves every file that differs into .context/overrides/, adding frontmatter. + 4. Restores base files to the framework version. + 5. Prepends the AGENTS.md managed block, leaving your content intact below. + 6. Writes .context/manifest.json. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --apply) APPLY=1 ;; + --from) shift; FROM_VERSION="${1:-}" ;; + --baseline) shift; BASELINE="${1:-}" ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + *) TARGET="$1" ;; + esac + shift +done + +[ -n "$TARGET" ] || TARGET="$(pwd)" +TARGET="$(cd "$TARGET" 2>/dev/null && pwd)" || { echo "ERROR: no such directory." >&2; exit 1; } + +CONTEXT_DIR="$TARGET/.context" +SOURCE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +[ -n "$BASELINE" ] || BASELINE="$SOURCE_ROOT/scripts/baselines/$FROM_VERSION.sha256" + +# --- preconditions --------------------------------------------------------- + +if [ ! -d "$CONTEXT_DIR" ]; then + echo "ERROR: $TARGET has no .context/ directory — nothing to migrate." >&2 + exit 1 +fi + +if [ -f "$CONTEXT_DIR/manifest.json" ]; then + existing="$(ac_manifest_get "$CONTEXT_DIR/manifest.json" version)" + echo "This deployment already has a manifest (version ${existing:-unknown})." + echo "Migration is only for pre-2.0 deployments. Use update.sh --apply instead." + exit 0 +fi + +if [ ! -f "$BASELINE" ]; then + echo "ERROR: baseline not found: $BASELINE" >&2 + echo " Pass --baseline explicitly, or --from with a published version." >&2 + exit 1 +fi + +# A dirty tree makes the migration unreviewable, so refuse outright. +if command -v git >/dev/null 2>&1 && git -C "$TARGET" rev-parse --git-dir >/dev/null 2>&1; then + if [ -n "$(git -C "$TARGET" status --porcelain 2>/dev/null)" ]; then + echo "ERROR: $TARGET has uncommitted changes." >&2 + echo " Commit or stash first so this migration can be reviewed as a diff." >&2 + exit 1 + fi +else + echo "WARNING: $TARGET is not a git repository. Changes will not be reviewable." >&2 + if [ "$APPLY" -eq 1 ]; then + printf "Continue anyway? [y/N] " + read -r reply + case "$reply" in [yY]*) ;; *) echo "Aborted."; exit 1 ;; esac + fi +fi + +if [ "$APPLY" -eq 1 ]; then + echo "Migrating $TARGET (from $FROM_VERSION)" +else + echo "DRY RUN — no files will be written. Re-run with --apply to commit." + echo "Analysing $TARGET (from $FROM_VERSION)" +fi +echo "" + +# --- classify -------------------------------------------------------------- + +DIVERGED_LIST="$(mktemp)" +MISSING_LIST="$(mktemp)" +cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST"; } +trap cleanup EXIT INT TERM + +for area in standards playbooks conventions; do + [ -d "$CONTEXT_DIR/$area" ] || continue + find "$CONTEXT_DIR/$area" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + rel="$area/${f#"$CONTEXT_DIR"/"$area"/}" + actual="$(ac_sha256 "$f")" + if expected="$(ac_baseline_lookup "$BASELINE" "$rel")"; then + if [ "$expected" != "$actual" ]; then + printf '%s\n' "$rel" >> "$DIVERGED_LIST" + fi + else + # Not in the baseline at all: a file the consumer added themselves. + printf '%s\n' "$rel" >> "$MISSING_LIST" + fi + done +done + +diverged_count=$(wc -l < "$DIVERGED_LIST" | tr -d ' ') +added_count=$(wc -l < "$MISSING_LIST" | tr -d ' ') + +if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ]; then + echo "No local modifications detected — this deployment is pristine." +else + if [ "$diverged_count" -gt 0 ]; then + echo "Modified framework files ($diverged_count) — will become overrides:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel -> .context/overrides/$rel" + done < "$DIVERGED_LIST" + echo "" + fi + if [ "$added_count" -gt 0 ]; then + echo "Files you added ($added_count) — will move to overrides as standalone additions:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel -> .context/overrides/$rel" + done < "$MISSING_LIST" + echo "" + fi +fi + +if [ "$APPLY" -eq 0 ]; then + echo "Nothing written. Re-run with --apply to perform the migration." + exit 0 +fi + +# --- apply ----------------------------------------------------------------- + +mkdir -p "$CONTEXT_DIR/overrides" + +promote() { + local rel="$1" mode="$2" + local src="$CONTEXT_DIR/$rel" + local dst="$CONTEXT_DIR/overrides/$rel" + [ -f "$src" ] || return 0 + + mkdir -p "$(dirname "$dst")" + + if [ "$mode" = "replace" ]; then + { + printf -- '---\n' + printf 'overrides: %s\n' "$rel" + printf 'mode: replace\n' + printf -- '---\n\n' + printf '\n\n' + cat "$src" + } > "$dst" + else + cat "$src" > "$dst" + fi +} + +while IFS= read -r rel; do + [ -n "$rel" ] || continue + promote "$rel" replace +done < "$DIVERGED_LIST" + +while IFS= read -r rel; do + [ -n "$rel" ] || continue + promote "$rel" standalone + rm -f "$CONTEXT_DIR/$rel" +done < "$MISSING_LIST" + +echo "Restoring base content from $SOURCE_ROOT ..." +for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks" "conventions:$SOURCE_ROOT/core/.context/conventions"; do + name="${pair%%:*}" + from="${pair#*:}" + [ -d "$from" ] || continue + rm -rf "${CONTEXT_DIR:?}/$name" + mkdir -p "$CONTEXT_DIR/$name" + (cd "$from" && tar cf - .) | (cd "$CONTEXT_DIR/$name" && tar xf -) +done + +[ -f "$SOURCE_ROOT/core/.context/index.md" ] && cp "$SOURCE_ROOT/core/.context/index.md" "$CONTEXT_DIR/index.md" +[ -f "$SOURCE_ROOT/core/.context/overrides/README.md" ] && cp "$SOURCE_ROOT/core/.context/overrides/README.md" "$CONTEXT_DIR/overrides/README.md" + +mkdir -p "$CONTEXT_DIR/bin/lib" +for tool in update.sh update.ps1 migrate.sh migrate.ps1; do + [ -f "$SOURCE_ROOT/scripts/$tool" ] && cp "$SOURCE_ROOT/scripts/$tool" "$CONTEXT_DIR/bin/$tool" +done +cp "$SOURCE_ROOT/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true + +# AGENTS.md: prepend the managed block, leave everything the consumer has intact. +# Identifying which of their existing regions were framework-authored is guesswork, +# so this deliberately does not try — a human reviews one file instead. +NEW_VERSION="$(ac_semver_normalise "$(cat "$SOURCE_ROOT/VERSION" 2>/dev/null || echo '0.0.0')")" +AGENTS_FILE="$TARGET/AGENTS.md" + +if [ -f "$SOURCE_ROOT/core/AGENTS.md" ]; then + if [ ! -f "$AGENTS_FILE" ]; then + sed "s|^|" \ + "$SOURCE_ROOT/core/AGENTS.md" > "$AGENTS_FILE" + elif grep -q '^' ' + index($0, b) == 1 { inblock = 1 } + inblock { print } + index($0, e) == 1 { inblock = 0 } + ' "$SOURCE_ROOT/core/AGENTS.md" \ + | sed "1s|^|" + printf '\n---\n\n' + printf '\n\n' + cat "$AGENTS_FILE" + } > "$tmp" + cat "$tmp" > "$AGENTS_FILE" + rm -f "$tmp" + echo " AGENTS.md: managed block prepended; your original content kept below for review." + fi +fi + +# Write the manifest last, so its hashes reflect the final state. +now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +{ + printf '{\n' + printf ' "schema": 1,\n' + printf ' "version": "%s",\n' "$NEW_VERSION" + printf ' "source": "%s",\n' "$AC_SOURCE_REPO" + printf ' "pin": "%s",\n' "$(ac_semver_major "$NEW_VERSION").x" + printf ' "checkFrequency": "weekly",\n' + printf ' "deployedAt": "%s",\n' "$now" + printf ' "agents": [],\n' + printf ' "files": {\n' + first=1 + ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do + rel="${line%% *}" + hash="${line##* }" + if [ $first -eq 1 ]; then first=0; else printf ',\n'; fi + printf ' "%s": "%s"' "$rel" "$hash" + done + printf '\n }\n' + printf '}\n' +} > "$CONTEXT_DIR/manifest.json" + +printf '%s\n' "$NEW_VERSION" > "$CONTEXT_DIR/VERSION" + +echo "" +echo "Migration complete. Now on $NEW_VERSION." +echo "" +echo "Review before committing:" +echo " git -C $TARGET diff --stat" +echo " $TARGET/AGENTS.md — remove sections duplicated by the managed block" +echo " $TARGET/.context/overrides/ — convert 'mode: replace' to 'mode: extend' where you can" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100644 index 0000000..65e93f5 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,345 @@ +#!/bin/bash +# update.sh — check for and apply agentic-context updates. +# +# Deployed into target repositories as .context/bin/update.sh, and also usable +# from the library itself as scripts/update.sh. +# +# Usage: +# .context/bin/update.sh --check Report status only. Never writes. Never blocks. +# .context/bin/update.sh --apply Fetch the latest version and update base content. +# .context/bin/update.sh --status Show local state without any network call. +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# The library lives at scripts/lib/common.sh; the deployed copy at .context/bin/lib/common.sh. +if [ -f "$SCRIPT_DIR/lib/common.sh" ]; then + # shellcheck source=scripts/lib/common.sh + . "$SCRIPT_DIR/lib/common.sh" +else + echo "ERROR: cannot locate lib/common.sh next to update.sh" >&2 + exit 1 +fi + +MODE="check" +FORCE=0 +QUIET=0 + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac + shift +done + +# --- locate the deployed .context directory -------------------------------- + +find_context_dir() { + # Deployed layout: .context/bin/update.sh -> .context + if [ -f "$SCRIPT_DIR/../manifest.json" ]; then + (cd "$SCRIPT_DIR/.." && pwd) + return 0 + fi + # Walk up from the current directory looking for a deployed .context. + local dir + dir="$(pwd)" + while [ "$dir" != "/" ]; do + if [ -f "$dir/.context/manifest.json" ]; then + printf '%s' "$dir/.context" + return 0 + fi + dir="$(dirname "$dir")" + done + return 1 +} + +CONTEXT_DIR="$(find_context_dir)" || { + echo "ERROR: no deployed .context/manifest.json found." >&2 + echo " Run this from a repository where agentic-context is deployed." >&2 + exit 1 +} +TARGET_ROOT="$(cd "$CONTEXT_DIR/.." && pwd)" +MANIFEST="$CONTEXT_DIR/manifest.json" +STAMP="$CONTEXT_DIR/.last-update-check" + +LOCAL_VERSION="$(ac_manifest_get "$MANIFEST" version)" +[ -n "$LOCAL_VERSION" ] || LOCAL_VERSION="0.0.0" +SOURCE_REPO="$(ac_manifest_get "$MANIFEST" source)" +[ -n "$SOURCE_REPO" ] || SOURCE_REPO="$AC_SOURCE_REPO" +PIN="$(ac_manifest_get "$MANIFEST" pin)" + +# --- divergence and override reporting ------------------------------------- + +# List base files whose current hash differs from the manifest record. +list_diverged() { + local rel recorded actual + ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do + rel="${line%% *}" + actual="${line##* }" + recorded="$(sed -n 's|.*"'"$rel"'"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*|\1|p' "$MANIFEST" | head -1)" + if [ -n "$recorded" ] && [ "$recorded" != "$actual" ]; then + printf '%s\n' "$rel" + fi + done +} + +# List overrides whose declared target no longer exists in the base tree. +list_orphan_overrides() { + local f rel target + [ -d "$CONTEXT_DIR/overrides" ] || return 0 + find "$CONTEXT_DIR/overrides" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + rel="${f#"$CONTEXT_DIR"/overrides/}" + [ "$rel" = "README.md" ] && continue + target="$(sed -n 's/^overrides:[[:space:]]*//p' "$f" | head -1 | tr -d '[:space:]')" + [ -n "$target" ] || continue + if [ ! -f "$CONTEXT_DIR/$target" ]; then + printf '%s -> %s\n' "$rel" "$target" + fi + done +} + +touch_stamp() { date -u '+%Y-%m-%dT%H:%M:%SZ' > "$STAMP" 2>/dev/null || true; } + +# --- modes ----------------------------------------------------------------- + +report_local_state() { + local diverged orphans + diverged="$(list_diverged)" + orphans="$(list_orphan_overrides)" + + if [ -n "$diverged" ]; then + echo "" + echo "Locally modified base files (these will be restored on update):" + printf '%s\n' "$diverged" | while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " - $rel" + echo " move your change to .context/overrides/$rel" + done + fi + + if [ -n "$orphans" ]; then + echo "" + echo "Overrides pointing at files that no longer exist:" + printf '%s\n' "$orphans" | while IFS= read -r line; do + [ -n "$line" ] || continue + echo " - $line" + done + fi +} + +if [ "$MODE" = "status" ]; then + echo "agentic-context $LOCAL_VERSION (source: $SOURCE_REPO, pin: ${PIN:-none})" + report_local_state + exit 0 +fi + +# Both --check and --apply need the upstream version. Fail open. +LATEST="$(ac_fetch_latest_version "$SOURCE_REPO" 2>/dev/null)" || LATEST="" + +if [ -z "$LATEST" ]; then + touch_stamp + [ "$QUIET" -eq 1 ] && exit 0 + echo "agentic-context $LOCAL_VERSION — update check unavailable (offline or unreachable)." + exit 0 +fi + +touch_stamp + +if ! ac_semver_gt "$LATEST" "$LOCAL_VERSION"; then + [ "$QUIET" -eq 1 ] && exit 0 + echo "agentic-context $LOCAL_VERSION is up to date." + report_local_state + exit 0 +fi + +# An update exists. Honour the pin unless forced. +PIN_OK=0 +if [ "$FORCE" -eq 1 ] || ac_semver_satisfies_pin "$LATEST" "$PIN"; then + PIN_OK=1 +fi + +if [ "$MODE" = "check" ]; then + if [ "$PIN_OK" -eq 1 ]; then + echo "agentic-context update available: $LOCAL_VERSION -> $LATEST (run .context/bin/update.sh --apply)" + else + echo "agentic-context $LATEST is available but outside your pin '$PIN'. See MIGRATIONS.md, then use --force." + fi + [ "$QUIET" -eq 1 ] && exit 0 + report_local_state + exit 0 +fi + +# --- apply ----------------------------------------------------------------- + +if [ "$PIN_OK" -ne 1 ]; then + echo "Refusing to update: $LATEST is outside the pin '$PIN'." >&2 + echo "This is a major upgrade. Read MIGRATIONS.md, then re-run with --force." >&2 + exit 1 +fi + +command -v curl >/dev/null 2>&1 || { echo "ERROR: curl is required to apply updates." >&2; exit 1; } +command -v tar >/dev/null 2>&1 || { echo "ERROR: tar is required to apply updates." >&2; exit 1; } + +echo "Updating agentic-context $LOCAL_VERSION -> $LATEST" + +WORK_DIR="$(mktemp -d)" +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT INT TERM + +TARBALL="$WORK_DIR/src.tar.gz" +if ! curl -fsSL --max-time 60 \ + "$AC_WEB_BASE/$SOURCE_REPO/archive/refs/tags/v$LATEST.tar.gz" -o "$TARBALL"; then + echo "ERROR: could not download v$LATEST." >&2 + exit 1 +fi + +tar -xzf "$TARBALL" -C "$WORK_DIR" || { echo "ERROR: could not extract archive." >&2; exit 1; } + +SRC="$(find "$WORK_DIR" -maxdepth 1 -type d -name '*-*' | head -1)" +[ -d "$SRC" ] || { echo "ERROR: unexpected archive layout." >&2; exit 1; } + +# Record divergence before we overwrite anything, so we can report it after. +DIVERGED="$(list_diverged)" + +# Detect removals: base files present locally but absent upstream. +REMOVED="" +for rel in $(ac_hash_context_tree "$CONTEXT_DIR" | sed 's/ .*//'); do + case "$rel" in + standards/*) [ -f "$SRC/${rel}" ] || REMOVED="$REMOVED $rel" ;; + playbooks/*) [ -f "$SRC/${rel}" ] || REMOVED="$REMOVED $rel" ;; + conventions/*) [ -f "$SRC/core/.context/${rel}" ] || REMOVED="$REMOVED $rel" ;; + esac +done + +# Replace base trees wholesale. Overrides are untouched by construction. +for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$SRC/core/.context/conventions"; do + name="${pair%%:*}" + from="${pair#*:}" + [ -d "$from" ] || continue + rm -rf "${CONTEXT_DIR:?}/$name" + mkdir -p "$CONTEXT_DIR/$name" + (cd "$from" && tar cf - .) | (cd "$CONTEXT_DIR/$name" && tar xf -) +done + +[ -f "$SRC/core/.context/index.md" ] && cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" + +# Refresh the update tooling itself, so a fixed updater reaches consumers. +mkdir -p "$CONTEXT_DIR/bin/lib" +for tool in update.sh update.ps1 migrate.sh migrate.ps1; do + [ -f "$SRC/scripts/$tool" ] && cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" +done +[ -f "$SRC/scripts/lib/common.sh" ] && cp "$SRC/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true + +# Refresh only the managed block in AGENTS.md. +AGENTS_FILE="$TARGET_ROOT/AGENTS.md" +if [ -f "$AGENTS_FILE" ] && [ -f "$SRC/core/AGENTS.md" ]; then + if grep -q '^' ' + index($0, b) == 1 { inblock = 1 } + inblock { print } + index($0, e) == 1 { inblock = 0 } + ' "$SRC/core/AGENTS.md" \ + | sed "1s|^|" > "$block" + + awk -v b='' -v blockfile="$block" ' + index($0, b) == 1 { + inblock = 1 + while ((getline line < blockfile) > 0) { print line } + close(blockfile) + next + } + index($0, e) == 1 && inblock { inblock = 0; next } + !inblock { print } + ' "$AGENTS_FILE" > "$WORK_DIR/agents.md" + cat "$WORK_DIR/agents.md" > "$AGENTS_FILE" + echo " AGENTS.md: managed block refreshed; your content preserved." + else + echo " AGENTS.md: no managed block found — left untouched. Merge manually if required." + fi +fi + +# Rewrite the manifest with the new version and fresh hashes. +now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +agents_json="$(sed -n 's/.*"agents"[[:space:]]*:[[:space:]]*\(\[[^]]*\]\).*/\1/p' "$MANIFEST" | head -1)" +[ -n "$agents_json" ] || agents_json='[]' +freq="$(ac_manifest_get "$MANIFEST" checkFrequency)" +[ -n "$freq" ] || freq="weekly" + +{ + printf '{\n' + printf ' "schema": 1,\n' + printf ' "version": "%s",\n' "$LATEST" + printf ' "source": "%s",\n' "$SOURCE_REPO" + printf ' "pin": "%s",\n' "$(ac_semver_major "$LATEST").x" + printf ' "checkFrequency": "%s",\n' "$freq" + printf ' "deployedAt": "%s",\n' "$now" + printf ' "agents": %s,\n' "$agents_json" + printf ' "files": {\n' + first=1 + ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do + rel="${line%% *}" + hash="${line##* }" + if [ $first -eq 1 ]; then first=0; else printf ',\n'; fi + printf ' "%s": "%s"' "$rel" "$hash" + done + printf '\n }\n' + printf '}\n' +} > "$MANIFEST" + +printf '%s\n' "$LATEST" > "$CONTEXT_DIR/VERSION" + +echo "" +echo "Updated to $LATEST." + +if [ -n "$DIVERGED" ]; then + echo "" + echo "The following base files had local edits. They have been restored to the" + echo "framework version. Re-apply your changes as overrides:" + printf '%s\n' "$DIVERGED" | while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " - $rel -> .context/overrides/$rel" + done +fi + +if [ -n "$REMOVED" ]; then + echo "" + echo "Removed upstream (no longer part of the framework):" + for rel in $REMOVED; do + echo " - $rel" + done +fi + +orphans="$(list_orphan_overrides)" +if [ -n "$orphans" ]; then + echo "" + echo "Overrides now pointing at files that no longer exist:" + printf '%s\n' "$orphans" | while IFS= read -r line; do + [ -n "$line" ] || continue + echo " - $line" + done +fi From 2a965a7d11c0c72920ad5605b29c8cbdc8e7b1d6 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:34:00 +0100 Subject: [PATCH 03/25] feat(update): add update and migrate tooling for deployed repos Adds update.sh/update.ps1 for staleness checks against the upstream VERSION and wholesale base refresh, and migrate.sh/migrate.ps1 to promote pre-2.0 local edits into the override layer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.Tests.ps1 | 11 +- scripts/deploy.ps1 | 81 ++++++++- scripts/deploy.sh | 1 + scripts/lib/common.ps1 | 360 +++++++++++++++++++++++++++++++++++++++ scripts/migrate.ps1 | 293 +++++++++++++++++++++++++++++++ scripts/migrate.sh | 5 +- scripts/update.ps1 | 297 ++++++++++++++++++++++++++++++++ scripts/update.sh | 8 +- 8 files changed, 1050 insertions(+), 6 deletions(-) create mode 100644 scripts/lib/common.ps1 create mode 100644 scripts/migrate.ps1 create mode 100644 scripts/update.ps1 diff --git a/scripts/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 index 65d3d90..fda55d3 100644 --- a/scripts/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -35,8 +35,15 @@ Describe 'deploy.ps1 (PowerShell version/platform compatibility)' { # TC5 in scripts/tests/test-deploy.ps1 exist separately. It 'has no PSScriptAnalyzer compatibility findings for Windows PowerShell 5.1 / PowerShell 7.0' -Skip:(-not $script:PSScriptAnalyzerAvailable) { Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path (Join-Path $PSScriptRoot 'deploy.ps1') -Settings (Join-Path (Split-Path -Parent $PSScriptRoot) 'PSScriptAnalyzerSettings.psd1') - $results | Should -BeNullOrEmpty + $settings = Join-Path (Split-Path -Parent $PSScriptRoot) 'PSScriptAnalyzerSettings.psd1' + $targets = @('deploy.ps1', 'update.ps1', 'migrate.ps1', 'lib/common.ps1') + $findings = @() + foreach ($target in $targets) { + $path = Join-Path $PSScriptRoot $target + if (-not (Test-Path -LiteralPath $path)) { continue } + $findings += Invoke-ScriptAnalyzer -Path $path -Settings $settings + } + $findings | Should -BeNullOrEmpty } } diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 396f1c8..b709f8a 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -550,11 +550,45 @@ if (-not (Test-Path $script:Target -PathType Container)) { # Content lives one level up: this script sits in scripts/, sources are at the repo root. $SourceRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'lib/common.ps1') + +$DeployVersion = '0.0.0' +$versionFile = Join-Path $SourceRoot 'VERSION' +if (Test-Path -LiteralPath $versionFile) { + $candidate = ConvertTo-AcSemVer ((Get-Content -LiteralPath $versionFile -Raw)) + if (Test-AcSemVer $candidate) { $DeployVersion = $candidate } +} + Write-Host "Deploying agent-contexts to $($script:Target)" +Write-Host " Version: $DeployVersion" Write-Host " Selected agents: $($script:EnabledAgents -join ', ')" Write-Host " Copying shared context files..." -Copy-SingleFile -Source (Join-Path $SourceRoot 'core/AGENTS.md') -Destination (Join-Path $script:Target 'AGENTS.md') + +# AGENTS.md belongs to the consumer: it carries their [CONFIGURE] sections. +# Only the managed block is ours, so refresh just that when it is present. +$agentsSrc = Join-Path $SourceRoot 'core/AGENTS.md' +$agentsDst = Join-Path $script:Target 'AGENTS.md' +if (-not (Test-Path -LiteralPath $agentsDst)) { + $parent = Split-Path -Parent $agentsDst + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + $seeded = Get-Content -LiteralPath $agentsSrc | ForEach-Object { + if ($_.StartsWith('" + } else { + $_ + } + } + Set-Content -LiteralPath $agentsDst -Value $seeded -Encoding UTF8 +} elseif (Test-AcManagedBlock -Path $agentsDst) { + Update-AcManagedBlock -Source $agentsSrc -Destination $agentsDst -Version $DeployVersion + Write-Host " AGENTS.md: refreshed managed block (your content preserved)" +} else { + Copy-SingleFile -Source $agentsSrc -Destination $agentsDst +} + Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context') -Destination (Join-Path $script:Target '.context') if (Test-AgentEnabled 'claude') { @@ -631,6 +665,41 @@ if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { Write-Host " Skipping skill wrapper generation (no selected agent uses skills)." } +# Consumer-owned override tree. Created here; never touched again by update. +Write-Host " Creating override layer -> $(Join-Path $script:Target '.context/overrides')" +$overrideDst = Join-Path $script:Target '.context/overrides' +if (-not (Test-Path -LiteralPath $overrideDst)) { + New-Item -ItemType Directory -Path $overrideDst -Force | Out-Null +} +Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context/overrides') -Destination $overrideDst + +# Update tooling, shipped into the target so it can maintain itself. +Write-Host " Installing update tooling -> $(Join-Path $script:Target '.context/bin')" +$binDst = Join-Path $script:Target '.context/bin' +$binLibDst = Join-Path $binDst 'lib' +foreach ($dir in @($binDst, $binLibDst)) { + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } +} +foreach ($tool in @('update.sh', 'update.ps1', 'migrate.sh', 'migrate.ps1')) { + $toolSrc = Join-Path $SourceRoot "scripts/$tool" + if (Test-Path -LiteralPath $toolSrc) { + Copy-Item -LiteralPath $toolSrc -Destination (Join-Path $binDst $tool) -Force + } +} +foreach ($libFile in @('common.sh', 'common.ps1')) { + $libSrc = Join-Path $SourceRoot "scripts/lib/$libFile" + if (Test-Path -LiteralPath $libSrc) { + Copy-Item -LiteralPath $libSrc -Destination (Join-Path $binLibDst $libFile) -Force + } +} + +Set-Content -LiteralPath (Join-Path $script:Target '.context/VERSION') -Value $DeployVersion -Encoding UTF8 + +Write-Host " Writing manifest -> $(Join-Path $script:Target '.context/manifest.json')" +Write-AcManifest -ContextDir (Join-Path $script:Target '.context') -Version $DeployVersion -Agents $script:EnabledAgents + Write-Host "" Write-Host "Done. Next steps:" $step = 1 @@ -652,6 +721,16 @@ if (Test-AgentEnabled 'copilot') { Show-NextStep "Review $($script:Target)\.github\copilot-instructions.md" } +Show-NextStep "Never edit .context/standards|playbooks|conventions directly - use .context/overrides/ (see .context/overrides/README.md)" + +# Report staleness on every deploy. Fails open and never blocks. +$latest = Get-AcLatestVersion +if ($latest -and (Test-AcSemVerGreater $latest $DeployVersion)) { + Write-Host "" + Write-Host " Note: you deployed $DeployVersion but $latest is available upstream." + Write-Host " Pull the latest agentic-context and re-run this script." +} + if ($script:SkippedFiles.Count -gt 0) { Write-Host "" Write-Host "Skipped files (not overwritten - manual merge may be required):" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 70bf3d4..ff97c3e 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -777,6 +777,7 @@ for tool in update.sh update.ps1 migrate.sh migrate.ps1; do done mkdir -p "$TARGET/.context/bin/lib" cp "$SOURCE_ROOT/scripts/lib/common.sh" "$TARGET/.context/bin/lib/common.sh" +[[ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ]] && cp "$SOURCE_ROOT/scripts/lib/common.ps1" "$TARGET/.context/bin/lib/common.ps1" chmod +x "$TARGET/.context/bin"/*.sh 2>/dev/null || true printf '%s\n' "$DEPLOY_VERSION" > "$TARGET/.context/VERSION" diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 new file mode 100644 index 0000000..406d5c2 --- /dev/null +++ b/scripts/lib/common.ps1 @@ -0,0 +1,360 @@ +# scripts/lib/common.ps1 — shared helpers for deploy, update and migrate. +# +# Portability: must run on Windows PowerShell 5.1 as well as PowerShell 7+. +# No PowerShell 7-only syntax (no ternaries, no ??, no -Parallel). +# +# Dot-source this file; do not execute it. + +$script:AcSourceRepo = 'ldastey-dev/agentic-context' +$script:AcRawBase = 'https://raw.githubusercontent.com' +$script:AcWebBase = 'https://github.com' +$script:AcTimeoutSec = 5 + +# --- hashing --------------------------------------------------------------- + +function Get-AcFileHash { + param([Parameter(Mandatory)][string]$Path) + $hash = Get-FileHash -Path $Path -Algorithm SHA256 + return $hash.Hash.ToLowerInvariant() +} + +# --- semver ---------------------------------------------------------------- + +function ConvertTo-AcSemVer { + param([string]$Version) + if ($null -eq $Version) { return '' } + return ($Version -replace '\s', '') -replace '^[vV]', '' +} + +function Test-AcSemVer { + param([string]$Version) + $v = ConvertTo-AcSemVer $Version + return ($v -match '^\d+\.\d+\.\d+$') +} + +function Get-AcSemVerPart { + param([string]$Version, [ValidateSet('Major', 'Minor', 'Patch')][string]$Part) + $v = ConvertTo-AcSemVer $Version + $bits = $v.Split('.') + switch ($Part) { + 'Major' { return [int]$bits[0] } + 'Minor' { return [int]$bits[1] } + 'Patch' { return [int]$bits[2] } + } +} + +# Returns $true when A is strictly greater than B. Compares numerically, so +# 1.10.0 correctly exceeds 1.9.0 where a string comparison would not. +function Test-AcSemVerGreater { + param([string]$A, [string]$B) + if (-not (Test-AcSemVer $A)) { return $false } + if (-not (Test-AcSemVer $B)) { return $false } + + $av = ConvertTo-AcSemVer $A + $bv = ConvertTo-AcSemVer $B + $ap = $av.Split('.') + $bp = $bv.Split('.') + + for ($i = 0; $i -lt 3; $i++) { + $x = [int]$ap[$i] + $y = [int]$bp[$i] + if ($x -gt $y) { return $true } + if ($x -lt $y) { return $false } + } + return $false +} + +function Step-AcSemVer { + param( + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][ValidateSet('major', 'minor', 'patch')][string]$Kind + ) + if (-not (Test-AcSemVer $Version)) { throw "Invalid version '$Version'" } + $x = Get-AcSemVerPart $Version 'Major' + $y = Get-AcSemVerPart $Version 'Minor' + $z = Get-AcSemVerPart $Version 'Patch' + + switch ($Kind) { + 'major' { $x++; $y = 0; $z = 0 } + 'minor' { $y++; $z = 0 } + 'patch' { $z++ } + } + return "$x.$y.$z" +} + +# Pin forms: '' or '*' (any), '2.x' / '2' (major line), or exact '2.3.1'. +function Test-AcPinSatisfied { + param([string]$Version, [string]$Pin) + + $v = ConvertTo-AcSemVer $Version + $p = ConvertTo-AcSemVer $Pin + + if ([string]::IsNullOrEmpty($p) -or $p -eq '*') { return $true } + + if ($p -match '^(\d+)\.[xX]$') { + return ((Get-AcSemVerPart $v 'Major') -eq [int]$Matches[1]) + } + if ($p -match '^\d+\.\d+\.\d+$') { + return ($v -eq $p) + } + if ($p -match '^\d+$') { + return ((Get-AcSemVerPart $v 'Major') -eq [int]$p) + } + return $false +} + +# --- network --------------------------------------------------------------- +# +# Every network helper FAILS OPEN: on any error it returns $null and never +# throws. Callers must treat $null as "up to date" and never block. + +function Get-AcLatestVersionRaw { + param([string]$Repo = $script:AcSourceRepo, [string]$Branch = 'main') + try { + $url = "$script:AcRawBase/$Repo/$Branch/VERSION" + $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec $script:AcTimeoutSec -ErrorAction Stop + $v = ConvertTo-AcSemVer ([string]$resp.Content) + if (Test-AcSemVer $v) { return $v } + return $null + } catch { + return $null + } +} + +function Get-AcLatestVersionRelease { + param([string]$Repo = $script:AcSourceRepo) + # Invoke-WebRequest handles redirects inconsistently across versions: + # Windows PowerShell 5.1 returns the 302 response when redirection is + # disabled, whereas PowerShell 7 raises. HttpWebRequest behaves identically + # on both, so read the Location header directly. + try { + $url = "$script:AcWebBase/$Repo/releases/latest" + $req = [System.Net.HttpWebRequest]::Create($url) + $req.AllowAutoRedirect = $false + $req.Method = 'HEAD' + $req.Timeout = $script:AcTimeoutSec * 1000 + $req.UserAgent = 'agentic-context-update' + + $loc = $null + try { + $resp = $req.GetResponse() + $loc = $resp.Headers['Location'] + $resp.Close() + } catch [System.Net.WebException] { + $wr = $_.Exception.Response + if ($wr) { + $loc = $wr.Headers['Location'] + $wr.Close() + } + } + + if ([string]::IsNullOrEmpty($loc)) { return $null } + $tag = $loc.Substring($loc.LastIndexOf('/') + 1) + $v = ConvertTo-AcSemVer $tag + if (Test-AcSemVer $v) { return $v } + return $null + } catch { + return $null + } +} + +function Get-AcLatestVersion { + param([string]$Repo = $script:AcSourceRepo) + $v = Get-AcLatestVersionRaw -Repo $Repo + if ($v) { return $v } + return Get-AcLatestVersionRelease -Repo $Repo +} + +# --- manifest -------------------------------------------------------------- + +function Get-AcManifest { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + try { + return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json) + } catch { + return $null + } +} + +# Hash every base .md file in a deployed .context tree. +# Returns an ordered hashtable of relative path -> sha256. +# Overrides and bin/ are excluded: overrides belong to the consumer, and bin/ +# is refreshed like any other base file but is not part of the content baseline. +function Get-AcContextHashes { + param([Parameter(Mandatory)][string]$ContextDir) + + $result = [ordered]@{} + if (-not (Test-Path -LiteralPath $ContextDir)) { return $result } + + $full = (Resolve-Path -LiteralPath $ContextDir).Path + $sep = [System.IO.Path]::DirectorySeparatorChar + + $files = Get-ChildItem -LiteralPath $full -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + Where-Object { + $rel = $_.FullName.Substring($full.Length).TrimStart($sep) + $relNorm = $rel.Replace('\', '/') + (-not $relNorm.StartsWith('overrides/')) -and (-not $relNorm.StartsWith('bin/')) + } | + Sort-Object { $_.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') } + + foreach ($f in $files) { + $rel = $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') + $result[$rel] = Get-AcFileHash -Path $f.FullName + } + return $result +} + +# Write .context/manifest.json describing a deployment. +function Write-AcManifest { + param( + [Parameter(Mandatory)][string]$ContextDir, + [Parameter(Mandatory)][string]$Version, + [string[]]$Agents = @(), + [string]$SourceRepo = $script:AcSourceRepo, + [string]$CheckFrequency = 'weekly' + ) + + if (-not (Test-Path -LiteralPath $ContextDir)) { + New-Item -ItemType Directory -Path $ContextDir -Force | Out-Null + } + + $hashes = Get-AcContextHashes -ContextDir $ContextDir + $files = [ordered]@{} + foreach ($k in $hashes.Keys) { $files[$k] = $hashes[$k] } + + $manifest = [ordered]@{ + schema = 1 + version = $Version + source = $SourceRepo + pin = "$(Get-AcSemVerPart $Version 'Major').x" + checkFrequency = $CheckFrequency + deployedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + agents = @($Agents) + files = $files + } + + $json = $manifest | ConvertTo-Json -Depth 5 + Set-Content -LiteralPath (Join-Path $ContextDir 'manifest.json') -Value $json -Encoding UTF8 +} + +# --- managed block --------------------------------------------------------- + +$script:AcBeginMarker = '' + +function Test-AcManagedBlock { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $lines = Get-Content -LiteralPath $Path + $hasBegin = $false + $hasEnd = $false + foreach ($line in $lines) { + if ($line.StartsWith($script:AcBeginMarker)) { $hasBegin = $true } + if ($line.StartsWith($script:AcEndMarker)) { $hasEnd = $true } + } + return ($hasBegin -and $hasEnd) +} + +function Get-AcManagedBlock { + param([Parameter(Mandatory)][string]$Path, [string]$Version) + + $out = New-Object System.Collections.Generic.List[string] + $inBlock = $false + foreach ($line in (Get-Content -LiteralPath $Path)) { + if ($line.StartsWith($script:AcBeginMarker)) { + $inBlock = $true + if ($Version) { + $out.Add("$script:AcBeginMarker $Version -->") + } else { + $out.Add($line) + } + continue + } + if ($line.StartsWith($script:AcEndMarker)) { + if ($inBlock) { $out.Add($line) } + $inBlock = $false + continue + } + if ($inBlock) { $out.Add($line) } + } + return $out +} + +# Replace the managed block in $Destination with the one from $Source, +# preserving everything outside the markers exactly. +function Update-AcManagedBlock { + param( + [Parameter(Mandatory)][string]$Source, + [Parameter(Mandatory)][string]$Destination, + [Parameter(Mandatory)][string]$Version + ) + + $block = Get-AcManagedBlock -Path $Source -Version $Version + $out = New-Object System.Collections.Generic.List[string] + $inBlock = $false + + foreach ($line in (Get-Content -LiteralPath $Destination)) { + if ($line.StartsWith($script:AcBeginMarker)) { + $inBlock = $true + foreach ($b in $block) { $out.Add($b) } + continue + } + if ($line.StartsWith($script:AcEndMarker)) { + if ($inBlock) { $inBlock = $false; continue } + } + if (-not $inBlock) { $out.Add($line) } + } + + Set-Content -LiteralPath $Destination -Value $out -Encoding UTF8 +} + +# List base files whose current hash differs from the manifest record. +function Get-AcDivergedFiles { + param( + [Parameter(Mandatory)][string]$ContextDir, + [Parameter(Mandatory)][string]$ManifestPath + ) + + $result = New-Object System.Collections.Generic.List[string] + $manifest = Get-AcManifest -Path $ManifestPath + if (-not $manifest -or -not $manifest.files) { return $result } + + $current = Get-AcContextHashes -ContextDir $ContextDir + foreach ($rel in $current.Keys) { + $recorded = $manifest.files.$rel + if ($recorded -and $recorded -ne $current[$rel]) { + $result.Add($rel) + } + } + return $result +} + +# List overrides whose declared target no longer exists in the base tree. +function Get-AcOrphanOverrides { + param([Parameter(Mandatory)][string]$ContextDir) + + $result = New-Object System.Collections.Generic.List[string] + $overrideDir = Join-Path $ContextDir 'overrides' + if (-not (Test-Path -LiteralPath $overrideDir)) { return $result } + + $files = Get-ChildItem -LiteralPath $overrideDir -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue + foreach ($f in $files) { + if ($f.Name -eq 'README.md') { continue } + $target = $null + foreach ($line in (Get-Content -LiteralPath $f.FullName)) { + if ($line -match '^overrides:\s*(.+)$') { + $target = $Matches[1].Trim() + break + } + } + if (-not $target) { continue } + if (-not (Test-Path -LiteralPath (Join-Path $ContextDir $target))) { + $sep = [System.IO.Path]::DirectorySeparatorChar + $full = (Resolve-Path -LiteralPath $overrideDir).Path + $rel = $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') + $result.Add("$rel -> $target") + } + } + return $result +} diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 new file mode 100644 index 0000000..ae67715 --- /dev/null +++ b/scripts/migrate.ps1 @@ -0,0 +1,293 @@ +# migrate.ps1 - upgrade a pre-2.0 agentic-context deployment to the override model. +# +# Pre-2.0 deployments have no .context/manifest.json and no .context/overrides/. +# Consumers may have edited standards and playbooks directly. This script finds +# those edits by comparing against a published baseline for the version they are +# on, and promotes each edited file into .context/overrides/ so their intent is +# preserved before base content is restored. +# +# Safe by default: dry-run unless -Apply is given, and refuses to run on a dirty +# git tree so every change is reviewable. +# +# Portability: Windows PowerShell 5.1 and PowerShell 7+. + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$Target = '', + [switch]$Apply, + [string]$From = '1.0.0', + [string]$Baseline = '' +) + +$ErrorActionPreference = 'Stop' + +$libPath = Join-Path $PSScriptRoot 'lib/common.ps1' +if (-not (Test-Path -LiteralPath $libPath)) { + Write-Error "Cannot locate lib/common.ps1 next to migrate.ps1" + exit 1 +} +. $libPath + +if ([string]::IsNullOrEmpty($Target)) { $Target = (Get-Location).Path } +if (-not (Test-Path -LiteralPath $Target)) { + Write-Error "No such directory: $Target" + exit 1 +} +$Target = (Resolve-Path -LiteralPath $Target).Path + +$ContextDir = Join-Path $Target '.context' +$SourceRoot = Split-Path -Parent $PSScriptRoot + +if ([string]::IsNullOrEmpty($Baseline)) { + $Baseline = Join-Path $SourceRoot "scripts/baselines/$From.sha256" +} + +# --- preconditions --------------------------------------------------------- + +if (-not (Test-Path -LiteralPath $ContextDir)) { + Write-Error "$Target has no .context/ directory - nothing to migrate." + exit 1 +} + +$manifestPath = Join-Path $ContextDir 'manifest.json' +if (Test-Path -LiteralPath $manifestPath) { + $existing = Get-AcManifest -Path $manifestPath + $ver = 'unknown' + if ($existing -and $existing.version) { $ver = $existing.version } + Write-Host "This deployment already has a manifest (version $ver)." + Write-Host "Migration is only for pre-2.0 deployments. Use update.ps1 -Apply instead." + exit 0 +} + +if (-not (Test-Path -LiteralPath $Baseline)) { + Write-Error "Baseline not found: $Baseline. Pass -Baseline explicitly, or -From with a published version." + exit 1 +} + +# A dirty tree makes the migration unreviewable, so refuse outright. +$isGit = $false +try { + $null = & git -C $Target rev-parse --git-dir 2>$null + if ($LASTEXITCODE -eq 0) { $isGit = $true } +} catch { + $isGit = $false +} + +if ($isGit) { + $dirty = & git -C $Target status --porcelain 2>$null + if ($dirty) { + Write-Error "$Target has uncommitted changes. Commit or stash first so this migration can be reviewed as a diff." + exit 1 + } +} else { + Write-Warning "$Target is not a git repository. Changes will not be reviewable." + if ($Apply) { + $reply = Read-Host "Continue anyway? [y/N]" + if ($reply -notmatch '^[yY]') { + Write-Host "Aborted." + exit 1 + } + } +} + +if ($Apply) { + Write-Host "Migrating $Target (from $From)" +} else { + Write-Host "DRY RUN - no files will be written. Re-run with -Apply to commit." + Write-Host "Analysing $Target (from $From)" +} +Write-Host "" + +# --- classify -------------------------------------------------------------- + +$baselineMap = @{} +foreach ($line in (Get-Content -LiteralPath $Baseline)) { + if ($line -match '^(\S+)\s+([a-f0-9]+)$') { + $baselineMap[$Matches[1]] = $Matches[2] + } +} + +$diverged = New-Object System.Collections.Generic.List[string] +$added = New-Object System.Collections.Generic.List[string] + +foreach ($area in @('standards', 'playbooks', 'conventions')) { + $areaDir = Join-Path $ContextDir $area + if (-not (Test-Path -LiteralPath $areaDir)) { continue } + + $full = (Resolve-Path -LiteralPath $areaDir).Path + $sep = [System.IO.Path]::DirectorySeparatorChar + $files = Get-ChildItem -LiteralPath $full -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + Sort-Object FullName + + foreach ($f in $files) { + $rel = $area + '/' + $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') + $actual = Get-AcFileHash -Path $f.FullName + if ($baselineMap.ContainsKey($rel)) { + if ($baselineMap[$rel] -ne $actual) { $diverged.Add($rel) } + } else { + # Not in the baseline at all: a file the consumer added themselves. + $added.Add($rel) + } + } +} + +if ($diverged.Count -eq 0 -and $added.Count -eq 0) { + Write-Host "No local modifications detected - this deployment is pristine." +} else { + if ($diverged.Count -gt 0) { + Write-Host "Modified framework files ($($diverged.Count)) - will become overrides:" + foreach ($rel in $diverged) { Write-Host " $rel -> .context/overrides/$rel" } + Write-Host "" + } + if ($added.Count -gt 0) { + Write-Host "Files you added ($($added.Count)) - will move to overrides as standalone additions:" + foreach ($rel in $added) { Write-Host " $rel -> .context/overrides/$rel" } + Write-Host "" + } +} + +if (-not $Apply) { + Write-Host "Nothing written. Re-run with -Apply to perform the migration." + exit 0 +} + +# --- apply ----------------------------------------------------------------- + +$overrideRoot = Join-Path $ContextDir 'overrides' +if (-not (Test-Path -LiteralPath $overrideRoot)) { + New-Item -ItemType Directory -Path $overrideRoot -Force | Out-Null +} + +function Move-AcToOverride { + param([string]$Rel, [string]$Mode) + + $src = Join-Path $ContextDir $Rel + $dst = Join-Path $overrideRoot $Rel + if (-not (Test-Path -LiteralPath $src)) { return } + + $parent = Split-Path -Parent $dst + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + + if ($Mode -eq 'replace') { + $header = @( + '---', + "overrides: $Rel", + 'mode: replace', + '---', + '', + '', + '' + ) + $body = Get-Content -LiteralPath $src + Set-Content -LiteralPath $dst -Value (@($header) + @($body)) -Encoding UTF8 + } else { + Copy-Item -LiteralPath $src -Destination $dst -Force + } +} + +foreach ($rel in $diverged) { Move-AcToOverride -Rel $rel -Mode 'replace' } +foreach ($rel in $added) { + Move-AcToOverride -Rel $rel -Mode 'standalone' + Remove-Item -LiteralPath (Join-Path $ContextDir $rel) -Force -ErrorAction SilentlyContinue +} + +Write-Host "Restoring base content from $SourceRoot ..." +$areas = @( + @{ Name = 'standards'; From = (Join-Path $SourceRoot 'standards') }, + @{ Name = 'playbooks'; From = (Join-Path $SourceRoot 'playbooks') }, + @{ Name = 'conventions'; From = (Join-Path $SourceRoot 'core/.context/conventions') } +) +foreach ($area in $areas) { + if (-not (Test-Path -LiteralPath $area.From)) { continue } + $dest = Join-Path $ContextDir $area.Name + if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } + New-Item -ItemType Directory -Path $dest -Force | Out-Null + Copy-Item -Path (Join-Path $area.From '*') -Destination $dest -Recurse -Force +} + +$srcIndex = Join-Path $SourceRoot 'core/.context/index.md' +if (Test-Path -LiteralPath $srcIndex) { + Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force +} +$srcOverrideReadme = Join-Path $SourceRoot 'core/.context/overrides/README.md' +if (Test-Path -LiteralPath $srcOverrideReadme) { + Copy-Item -LiteralPath $srcOverrideReadme -Destination (Join-Path $overrideRoot 'README.md') -Force +} + +$binDir = Join-Path $ContextDir 'bin' +$binLib = Join-Path $binDir 'lib' +foreach ($d in @($binDir, $binLib)) { + if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } +} +foreach ($tool in @('update.sh', 'update.ps1', 'migrate.sh', 'migrate.ps1')) { + $toolSrc = Join-Path $SourceRoot "scripts/$tool" + if (Test-Path -LiteralPath $toolSrc) { + Copy-Item -LiteralPath $toolSrc -Destination (Join-Path $binDir $tool) -Force + } +} +foreach ($libFile in @('common.sh', 'common.ps1')) { + $libSrc = Join-Path $SourceRoot "scripts/lib/$libFile" + if (Test-Path -LiteralPath $libSrc) { + Copy-Item -LiteralPath $libSrc -Destination (Join-Path $binLib $libFile) -Force + } +} + +# AGENTS.md: prepend the managed block, leave everything the consumer has intact. +# Identifying which of their existing regions were framework-authored is guesswork, +# so this deliberately does not try - a human reviews one file instead. +$NewVersion = '0.0.0' +$versionFile = Join-Path $SourceRoot 'VERSION' +if (Test-Path -LiteralPath $versionFile) { + $candidate = ConvertTo-AcSemVer ((Get-Content -LiteralPath $versionFile -Raw)) + if (Test-AcSemVer $candidate) { $NewVersion = $candidate } +} + +$agentsFile = Join-Path $Target 'AGENTS.md' +$agentsSrc = Join-Path $SourceRoot 'core/AGENTS.md' + +if (Test-Path -LiteralPath $agentsSrc) { + if (-not (Test-Path -LiteralPath $agentsFile)) { + $seeded = Get-Content -LiteralPath $agentsSrc | ForEach-Object { + if ($_.StartsWith('" + } else { + $_ + } + } + Set-Content -LiteralPath $agentsFile -Value $seeded -Encoding UTF8 + } elseif (Test-AcManagedBlock -Path $agentsFile) { + Write-Host " AGENTS.md already has a managed block - left as is." + } else { + $block = Get-AcManagedBlock -Path $agentsSrc -Version $NewVersion + $notice = @( + '', + '---', + '', + '', + '' + ) + $original = Get-Content -LiteralPath $agentsFile + Set-Content -LiteralPath $agentsFile -Value (@($block) + $notice + @($original)) -Encoding UTF8 + Write-Host " AGENTS.md: managed block prepended; your original content kept below for review." + } +} + +# Write the manifest last, so its hashes reflect the final state. +Write-AcManifest -ContextDir $ContextDir -Version $NewVersion -Agents @() +Set-Content -LiteralPath (Join-Path $ContextDir 'VERSION') -Value $NewVersion -Encoding UTF8 + +Write-Host "" +Write-Host "Migration complete. Now on $NewVersion." +Write-Host "" +Write-Host "Review before committing:" +Write-Host " git -C $Target diff --stat" +Write-Host " $Target/AGENTS.md - remove sections duplicated by the managed block" +Write-Host " $Target/.context/overrides/ - convert 'mode: replace' to 'mode: extend' where you can" diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 33cb5e6..dd85667 100644 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -184,7 +184,7 @@ promote() { printf 'overrides: %s\n' "$rel" printf 'mode: replace\n' printf -- '---\n\n' - printf '\n\n' @@ -224,6 +224,7 @@ for tool in update.sh update.ps1 migrate.sh migrate.ps1; do [ -f "$SOURCE_ROOT/scripts/$tool" ] && cp "$SOURCE_ROOT/scripts/$tool" "$CONTEXT_DIR/bin/$tool" done cp "$SOURCE_ROOT/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +[ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ] && cp "$SOURCE_ROOT/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true # AGENTS.md: prepend the managed block, leave everything the consumer has intact. @@ -248,7 +249,7 @@ if [ -f "$SOURCE_ROOT/core/AGENTS.md" ]; then ' "$SOURCE_ROOT/core/AGENTS.md" \ | sed "1s|^|" printf '\n---\n\n' - printf '\n\n' cat "$AGENTS_FILE" diff --git a/scripts/update.ps1 b/scripts/update.ps1 new file mode 100644 index 0000000..eebc4f5 --- /dev/null +++ b/scripts/update.ps1 @@ -0,0 +1,297 @@ +# update.ps1 - check for and apply agentic-context updates. +# +# Deployed into target repositories as .context/bin/update.ps1, and also usable +# from the library itself as scripts/update.ps1. +# +# Usage: +# .context\bin\update.ps1 -Check Report status only. Never writes. Never blocks. +# .context\bin\update.ps1 -Apply Fetch the latest version and update base content. +# .context\bin\update.ps1 -Status Show local state without any network call. +# +# Portability: Windows PowerShell 5.1 and PowerShell 7+. + +[CmdletBinding()] +param( + [switch]$Check, + [switch]$Apply, + [switch]$Status, + [switch]$Force, + [switch]$Quiet +) + +$ErrorActionPreference = 'Stop' + +$libPath = Join-Path $PSScriptRoot 'lib/common.ps1' +if (-not (Test-Path -LiteralPath $libPath)) { + Write-Error "Cannot locate lib/common.ps1 next to update.ps1" + exit 1 +} +. $libPath + +$mode = 'check' +if ($Status) { $mode = 'status' } +if ($Apply) { $mode = 'apply' } +if ($Check) { $mode = 'check' } + +# --- locate the deployed .context directory -------------------------------- + +function Find-AcContextDir { + $candidate = Join-Path (Split-Path -Parent $PSScriptRoot) 'manifest.json' + if (Test-Path -LiteralPath $candidate) { + return (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path + } + + $dir = (Get-Location).Path + while ($dir) { + $probe = Join-Path $dir '.context/manifest.json' + if (Test-Path -LiteralPath $probe) { + return (Resolve-Path -LiteralPath (Join-Path $dir '.context')).Path + } + $parent = Split-Path -Parent $dir + if ($parent -eq $dir) { break } + $dir = $parent + } + return $null +} + +$ContextDir = Find-AcContextDir +if (-not $ContextDir) { + Write-Error "No deployed .context/manifest.json found. Run this from a repository where agentic-context is deployed." + exit 1 +} + +$TargetRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $ContextDir)).Path +$ManifestPath = Join-Path $ContextDir 'manifest.json' +$StampPath = Join-Path $ContextDir '.last-update-check' + +$manifest = Get-AcManifest -Path $ManifestPath +$LocalVersion = '0.0.0' +$SourceRepo = $script:AcSourceRepo +$Pin = '' +$Freq = 'weekly' + +if ($manifest) { + if ($manifest.version) { $LocalVersion = [string]$manifest.version } + if ($manifest.source) { $SourceRepo = [string]$manifest.source } + if ($manifest.pin) { $Pin = [string]$manifest.pin } + if ($manifest.checkFrequency) { $Freq = [string]$manifest.checkFrequency } +} + +function Update-AcStamp { + try { + Set-Content -LiteralPath $StampPath -Value ((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) -Encoding UTF8 + } catch { + # A stamp we cannot write must never stop the caller. + } +} + +function Show-AcLocalState { + $overridesDir = Join-Path $ContextDir 'overrides' + if (Test-Path -LiteralPath $overridesDir) { + $overrideFiles = @(Get-ChildItem -LiteralPath $overridesDir -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ne 'README.md' }) + if ($overrideFiles.Count -gt 0) { + Write-Host (" {0} override file(s) active." -f $overrideFiles.Count) + } + } + + $diverged = Get-AcDivergedFiles -ContextDir $ContextDir -ManifestPath $ManifestPath + if ($diverged.Count -gt 0) { + Write-Host "" + Write-Host "Locally modified base files (these will be restored on update):" + foreach ($rel in $diverged) { + Write-Host " - $rel" + Write-Host " move your change to .context/overrides/$rel" + } + } + + $orphans = Get-AcOrphanOverrides -ContextDir $ContextDir + if ($orphans.Count -gt 0) { + Write-Host "" + Write-Host "Overrides pointing at files that no longer exist:" + foreach ($o in $orphans) { Write-Host " - $o" } + } +} + +if ($mode -eq 'status') { + $pinText = $Pin + if ([string]::IsNullOrEmpty($pinText)) { $pinText = 'none' } + Write-Host "agentic-context $LocalVersion (source: $SourceRepo, pin: $pinText)" + Show-AcLocalState + exit 0 +} + +# Both check and apply need the upstream version. Fail open. +$Latest = Get-AcLatestVersion -Repo $SourceRepo + +if (-not $Latest) { + Update-AcStamp + if ($Quiet) { exit 0 } + Write-Host "agentic-context $LocalVersion - update check unavailable (offline or unreachable)." + exit 0 +} + +Update-AcStamp + +if (-not (Test-AcSemVerGreater $Latest $LocalVersion)) { + if ($Quiet) { exit 0 } + Write-Host "agentic-context $LocalVersion is up to date." + Show-AcLocalState + exit 0 +} + +$pinOk = $false +if ($Force -or (Test-AcPinSatisfied -Version $Latest -Pin $Pin)) { $pinOk = $true } + +if ($mode -eq 'check') { + if ($pinOk) { + Write-Host "agentic-context update available: $LocalVersion -> $Latest (run .context/bin/update.ps1 -Apply)" + } else { + Write-Host "agentic-context $Latest is available but outside your pin '$Pin'. See MIGRATIONS.md, then use -Force." + } + if ($Quiet) { exit 0 } + Show-AcLocalState + exit 0 +} + +# --- apply ----------------------------------------------------------------- + +if (-not $pinOk) { + Write-Error "Refusing to update: $Latest is outside the pin '$Pin'. This is a major upgrade. Read MIGRATIONS.md, then re-run with -Force." + exit 1 +} + +Write-Host "Updating agentic-context $LocalVersion -> $Latest" + +$WorkDir = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-update-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null + +try { + $tarball = Join-Path $WorkDir 'src.tar.gz' + $url = "$script:AcWebBase/$SourceRepo/archive/refs/tags/v$Latest.tar.gz" + try { + Invoke-WebRequest -Uri $url -OutFile $tarball -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + } catch { + Write-Error "Could not download v$Latest." + exit 1 + } + + # tar is present on Windows 10 1803+, and on macOS and Linux. + & tar -xzf $tarball -C $WorkDir + if ($LASTEXITCODE -ne 0) { + Write-Error "Could not extract archive." + exit 1 + } + + $src = Get-ChildItem -LiteralPath $WorkDir -Directory | Select-Object -First 1 + if (-not $src) { + Write-Error "Unexpected archive layout." + exit 1 + } + $srcPath = $src.FullName + + # Record divergence before overwriting, so it can be reported afterwards. + $diverged = Get-AcDivergedFiles -ContextDir $ContextDir -ManifestPath $ManifestPath + + # Detect removals: base files present locally but absent upstream. + $removed = New-Object System.Collections.Generic.List[string] + $currentHashes = Get-AcContextHashes -ContextDir $ContextDir + foreach ($rel in $currentHashes.Keys) { + $probe = $null + if ($rel.StartsWith('standards/') -or $rel.StartsWith('playbooks/')) { + $probe = Join-Path $srcPath $rel + } elseif ($rel.StartsWith('conventions/')) { + $probe = Join-Path $srcPath "core/.context/$rel" + } + if ($probe -and -not (Test-Path -LiteralPath $probe)) { + $removed.Add($rel) + } + } + + # Replace base trees wholesale. Overrides are untouched by construction. + $areas = @( + @{ Name = 'standards'; From = (Join-Path $srcPath 'standards') }, + @{ Name = 'playbooks'; From = (Join-Path $srcPath 'playbooks') }, + @{ Name = 'conventions'; From = (Join-Path $srcPath 'core/.context/conventions') } + ) + foreach ($area in $areas) { + if (-not (Test-Path -LiteralPath $area.From)) { continue } + $dest = Join-Path $ContextDir $area.Name + if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } + New-Item -ItemType Directory -Path $dest -Force | Out-Null + Copy-Item -Path (Join-Path $area.From '*') -Destination $dest -Recurse -Force + } + + $srcIndex = Join-Path $srcPath 'core/.context/index.md' + if (Test-Path -LiteralPath $srcIndex) { + Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force + } + + # Refresh the update tooling itself, so a fixed updater reaches consumers. + $binDir = Join-Path $ContextDir 'bin' + $binLib = Join-Path $binDir 'lib' + foreach ($d in @($binDir, $binLib)) { + if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } + } + foreach ($tool in @('update.sh', 'update.ps1', 'migrate.sh', 'migrate.ps1')) { + $toolSrc = Join-Path $srcPath "scripts/$tool" + if (Test-Path -LiteralPath $toolSrc) { + Copy-Item -LiteralPath $toolSrc -Destination (Join-Path $binDir $tool) -Force + } + } + foreach ($libFile in @('common.sh', 'common.ps1')) { + $libSrc = Join-Path $srcPath "scripts/lib/$libFile" + if (Test-Path -LiteralPath $libSrc) { + Copy-Item -LiteralPath $libSrc -Destination (Join-Path $binLib $libFile) -Force + } + } + + # Refresh only the managed block in AGENTS.md. + $agentsFile = Join-Path $TargetRoot 'AGENTS.md' + $agentsSrc = Join-Path $srcPath 'core/AGENTS.md' + if ((Test-Path -LiteralPath $agentsFile) -and (Test-Path -LiteralPath $agentsSrc)) { + if (Test-AcManagedBlock -Path $agentsFile) { + Update-AcManagedBlock -Source $agentsSrc -Destination $agentsFile -Version $Latest + Write-Host " AGENTS.md: managed block refreshed; your content preserved." + } else { + Write-Host " AGENTS.md: no managed block found - left untouched. Merge manually if required." + } + } + + $agents = @() + if ($manifest -and $manifest.agents) { $agents = @($manifest.agents) } + + Write-AcManifest -ContextDir $ContextDir -Version $Latest -Agents $agents ` + -SourceRepo $SourceRepo -CheckFrequency $Freq + + Set-Content -LiteralPath (Join-Path $ContextDir 'VERSION') -Value $Latest -Encoding UTF8 + + Write-Host "" + Write-Host "Updated to $Latest." + + if ($diverged.Count -gt 0) { + Write-Host "" + Write-Host "The following base files had local edits. They have been restored to the" + Write-Host "framework version. Re-apply your changes as overrides:" + foreach ($rel in $diverged) { + Write-Host " - $rel -> .context/overrides/$rel" + } + } + + if ($removed.Count -gt 0) { + Write-Host "" + Write-Host "Removed upstream (no longer part of the framework):" + foreach ($rel in $removed) { Write-Host " - $rel" } + } + + $orphans = Get-AcOrphanOverrides -ContextDir $ContextDir + if ($orphans.Count -gt 0) { + Write-Host "" + Write-Host "Overrides now pointing at files that no longer exist:" + foreach ($o in $orphans) { Write-Host " - $o" } + } +} finally { + if (Test-Path -LiteralPath $WorkDir) { + Remove-Item -LiteralPath $WorkDir -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/scripts/update.sh b/scripts/update.sh index 65e93f5..0dc233f 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -126,10 +126,16 @@ touch_stamp() { date -u '+%Y-%m-%dT%H:%M:%SZ' > "$STAMP" 2>/dev/null || true; } # --- modes ----------------------------------------------------------------- report_local_state() { - local diverged orphans + local diverged orphans override_count diverged="$(list_diverged)" orphans="$(list_orphan_overrides)" + override_count=0 + if [ -d "$CONTEXT_DIR/overrides" ]; then + override_count="$(find "$CONTEXT_DIR/overrides" -type f -name '*.md' ! -name 'README.md' 2>/dev/null | wc -l | tr -d ' ')" + fi + [ "$override_count" -gt 0 ] && echo " $override_count override file(s) active." + if [ -n "$diverged" ]; then echo "" echo "Locally modified base files (these will be restored on update):" From 8c0eca0884ab36a5dc99d6a2f77ba0f7c2264224 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:37:44 +0100 Subject: [PATCH 04/25] ci(release): add version gate, PR title check and release automation Adds three workflows and the shared bump computation they both use, so the version shown on a pull request is the version its merge cuts. VERSION, CHANGELOG.md and scripts/baselines/ become CI-owned and hand-edits are rejected. Corrects the 1.0.0 baseline to hash main rather than this branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-title.yml | 44 ++++++++++ .github/workflows/release.yml | 133 +++++++++++++++++++++++++++++ .github/workflows/version-gate.yml | 103 ++++++++++++++++++++++ scripts/baselines/1.0.0.sha256 | 2 +- scripts/ci/next-version.sh | 92 ++++++++++++++++++++ scripts/ci/write-baseline.sh | 30 +++++++ scripts/ci/write-changelog.sh | 104 ++++++++++++++++++++++ 7 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/version-gate.yml create mode 100644 scripts/ci/next-version.sh create mode 100644 scripts/ci/write-baseline.sh create mode 100644 scripts/ci/write-changelog.sh diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..ce5db03 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,44 @@ +name: PR title + +# The release job derives the version bump size from the merge commit subject, +# which is the pull request title when a PR is squashed. If the title is not a +# valid Conventional Commit the bump cannot be derived, so the title is checked +# on every pull request rather than discovered at merge time. +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + pull-requests: read + +jobs: + lint: + name: Conventional Commit title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + requireScope: false + # A single-line subject keeps the squashed commit parseable. + subjectPattern: ^(?![A-Z])(?!.*\.$).+$ + subjectPatternError: | + The subject "{subject}" is invalid: it must start with a lower-case + letter and must not end with a full stop. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b926362 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Release + +# Cuts a release when deployable content lands on main. This is the only writer +# of VERSION, CHANGELOG.md, tags and scripts/baselines/, which is what lets a +# deployed consumer trust a single 6-byte fetch of VERSION to detect staleness. +on: + push: + branches: [main] + +# Releases must queue, never cancel. Cancelling an in-flight release could leave +# a tag pointing at a commit whose VERSION was never pushed. +concurrency: + group: release-main + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + name: Cut release + runs-on: ubuntu-latest + # Loop guard, belt and braces: the release commit is authored by the Actions + # bot and carries [skip ci]. Either condition alone would do; both together + # mean a change to one convention cannot cause an infinite release loop. + if: >- + github.actor != 'github-actions[bot]' && + !contains(github.event.head_commit.message, '[skip ci]') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Persist the token so the later push and tag reuse it. + persist-credentials: true + + - name: Resolve changed files for this push + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + run: | + set -euo pipefail + # github.event.before is all-zeroes for the first push to a new branch + # and can point at a commit that no longer exists after a force-push. + # Fall back to the first-parent diff, which is the squashed merge. + if [ -z "${BEFORE//0/}" ] || ! git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then + git show --name-only --pretty=format: "$AFTER" > /tmp/changed-files.txt + else + git diff --name-only "$BEFORE" "$AFTER" > /tmp/changed-files.txt + fi + grep -v '^$' /tmp/changed-files.txt > /tmp/changed-files.tmp || true + mv /tmp/changed-files.tmp /tmp/changed-files.txt + echo "Changed files:" + sed 's/^/ /' /tmp/changed-files.txt + + - name: Compute next version + id: next + run: | + set -euo pipefail + subject="$(git log -1 --pretty=format:'%s')" + current="$(tr -d ' \t\r\n' < VERSION)" + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject")" + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "next=$next" >> "$GITHUB_OUTPUT" + if [ "$next" = "none" ]; then + echo "No deployable content changed. Skipping release." >> "$GITHUB_STEP_SUMMARY" + else + echo "Releasing $current -> $next" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Write VERSION, CHANGELOG and baseline + if: steps.next.outputs.next != 'none' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" + printf '%s\n' "$NEXT" > VERSION + # The baseline must hash the released content, so write it after + # VERSION but from the same working tree that is about to be tagged. + scripts/ci/write-baseline.sh "$NEXT" + scripts/ci/write-changelog.sh "$NEXT" "$prev_tag" + + - name: Commit, then tag that commit + if: steps.next.outputs.next != 'none' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add VERSION CHANGELOG.md "scripts/baselines/$NEXT.sha256" + git commit -m "chore(release): $NEXT [skip ci]" + + # Push the commit before tagging it. Tagging first would produce a tag + # whose tree still holds the previous VERSION, so a consumer resolving + # the tag would download content that disagrees with the version they + # were told to expect. + # + # Another release may have landed while this job queued, so rebase and + # retry rather than failing the release outright. + attempt=1 + until git push origin HEAD:main; do + if [ "$attempt" -ge 3 ]; then + echo "::error::Could not push the release commit after $attempt attempts." + exit 1 + fi + echo "Push rejected; rebasing onto the latest main (attempt $attempt)." + git fetch origin main + git rebase origin/main + attempt=$((attempt + 1)) + done + + git tag -a "v$NEXT" -m "v$NEXT" HEAD + git push origin "v$NEXT" + + - name: Publish GitHub release + if: steps.next.outputs.next != 'none' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + # Extract just this version's section from the changelog for the notes. + awk -v ver="## $NEXT " ' + index($0, ver) == 1 { inside = 1; next } + /^## / && inside { exit } + inside { print } + ' CHANGELOG.md > /tmp/notes.md + [ -s /tmp/notes.md ] || echo "See CHANGELOG.md." > /tmp/notes.md + gh release create "v$NEXT" \ + --title "v$NEXT" \ + --notes-file /tmp/notes.md \ + "scripts/baselines/$NEXT.sha256#baseline-$NEXT.sha256" diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml new file mode 100644 index 0000000..d9d4239 --- /dev/null +++ b/.github/workflows/version-gate.yml @@ -0,0 +1,103 @@ +name: Version gate + +# VERSION is owned by the release job, not by contributors. This gate enforces +# that ownership in both directions: it rejects hand-edits to the generated +# files, and it tells the reviewer exactly which version the merge will cut. +# +# No path filters: the gate must run on every pull request so it is always +# available as a required status check. +on: + pull_request: + +concurrency: + group: version-gate-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + gate: + name: Version bump + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The gate diffs against the base branch, so the merge-base must be + # reachable. A shallow clone would not contain it. + fetch-depth: 0 + + - name: Resolve changed files + id: changed + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + git diff --name-only "$BASE_SHA" "$HEAD_SHA" > /tmp/changed-files.txt + echo "Changed files:" + sed 's/^/ /' /tmp/changed-files.txt + + - name: Reject hand-edits to generated files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + fail=0 + + if grep -qx 'VERSION' /tmp/changed-files.txt; then + echo "::error file=VERSION::VERSION is written by the release workflow on merge to main. Revert your change to it - the version for this pull request is derived from its title." + fail=1 + fi + + if grep -q '^CHANGELOG\.md$' /tmp/changed-files.txt; then + echo "::error file=CHANGELOG.md::CHANGELOG.md is generated by the release workflow. Revert your change to it - it is written from commit messages on merge." + fail=1 + fi + + # Baselines are published per release so migrate can tell a pristine + # file from an edited one. A hand-written baseline would silently + # corrupt every future migration, so only the release job may add one. + if grep -q '^scripts/baselines/' /tmp/changed-files.txt; then + echo "::error::scripts/baselines/ is published by the release workflow. Do not add or edit baseline files by hand." + fail=1 + fi + + exit "$fail" + + - name: Compute the version this merge will cut + id: next + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + current="$(tr -d ' \t\r\n' < VERSION)" + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$PR_TITLE")" + { + echo "### Version gate" + echo "" + if [ "$next" = "none" ]; then + echo "No deployable content changed. **No release will be cut** and \`VERSION\` stays at \`$current\`." + echo "" + echo "Deployable paths are \`core/\`, \`standards/\`, \`playbooks/\`, and the deploy, update, migrate and shared library scripts. Documentation, workflows and tests do not reach a consumer repository, so they do not earn a version." + else + echo "Merging this pull request will release **\`$next\`** (currently \`$current\`)." + echo "" + echo "The bump size comes from the pull request title, with a patch floor: \`feat\` gives a minor, a \`!\` marker or \`BREAKING CHANGE\` gives a major, anything else gives a patch." + fi + } >> "$GITHUB_STEP_SUMMARY" + echo "next=$next" >> "$GITHUB_OUTPUT" + + - name: Verify the computed version is a real increment + if: steps.next.outputs.next != 'none' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + . scripts/lib/common.sh + current="$(tr -d ' \t\r\n' < VERSION)" + if ! ac_semver_gt "$NEXT" "$current"; then + echo "::error::Computed version '$NEXT' is not greater than the current '$current'. This is a bug in scripts/ci/next-version.sh." + exit 1 + fi + echo "OK: $current -> $NEXT" diff --git a/scripts/baselines/1.0.0.sha256 b/scripts/baselines/1.0.0.sha256 index 6098fe5..1985945 100644 --- a/scripts/baselines/1.0.0.sha256 +++ b/scripts/baselines/1.0.0.sha256 @@ -1,7 +1,7 @@ conventions/code.md 306b9916bd0ebc87c418b4ded90a56c600708b0144a37de4aaca53484e19fb25 conventions/communication.md 508a9981cc3830e9992eb6edb30473c06ed149bcc63bab63fb8683e277d6ee03 conventions/workflow.md 6cc7f88c4dde53b9a713f9257060497abdf3e343c6e09b515b0cdb38464eb8cd -index.md 32a985ba27f992afdbe28da8f415c4e391ca7cd4ca87922e2ede8325ea4524bf +index.md 9081522a512ac330d633c4cedd3f07edb792928b35ad8640b0b853c27fbb1789 playbooks/assess/accessibility.md b9349df70e40518df65ef528f7366d288ffb5183fa2ef95f0641feb02ba6363c playbooks/assess/api-design.md ffb618ba0ce0e6909d89e18f1fceef214e1df5eac4619593091b415aa4318763 playbooks/assess/architecture.md e8b1de975d950f1d85a5ee7380e5a2385809711bc71824886c87c3399f0fa305 diff --git a/scripts/ci/next-version.sh b/scripts/ci/next-version.sh new file mode 100644 index 0000000..9f5bbca --- /dev/null +++ b/scripts/ci/next-version.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# next-version.sh - single source of truth for "what version does this merge produce?". +# +# Both the version gate (on a pull request) and the release job (on push to main) +# call this, so the version shown on the PR is the version that is actually cut. +# +# Usage: +# next-version.sh --changed-files --subject [--current ] +# +# Reads the current version from ./VERSION unless --current is given. +# --changed-files points at a newline-delimited list of paths changed by the merge. +# +# Prints one of: +# when the change touches deployable content +# none when it does not, so no release is cut +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/lib/common.sh +. "$REPO_ROOT/scripts/lib/common.sh" + +CHANGED_FILES="" +SUBJECT="" +CURRENT="" + +while [ $# -gt 0 ]; do + case "$1" in + --changed-files) CHANGED_FILES="${2:-}"; shift 2 ;; + --subject) SUBJECT="${2:-}"; shift 2 ;; + --current) CURRENT="${2:-}"; shift 2 ;; + *) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;; + esac +done + +[ -n "$CHANGED_FILES" ] || { echo "ERROR: --changed-files is required." >&2; exit 2; } +[ -f "$CHANGED_FILES" ] || { echo "ERROR: no such file: $CHANGED_FILES" >&2; exit 2; } + +if [ -z "$CURRENT" ]; then + CURRENT="$(tr -d ' \t\r\n' < "$REPO_ROOT/VERSION")" +fi +ac_semver_is_valid "$CURRENT" || { echo "ERROR: VERSION is not valid SemVer: '$CURRENT'" >&2; exit 1; } + +# --- is this change deployable? -------------------------------------------- +# +# Only content that lands in a consumer repository can make a deployment stale, +# so only that content earns a version. Workflows, tests, the README and the +# maintainer AGENTS.md never reach a consumer and never cut a release. +is_deployable() { + case "$1" in + core/*|standards/*|playbooks/*) return 0 ;; + scripts/deploy.sh|scripts/deploy.ps1) return 0 ;; + scripts/update.sh|scripts/update.ps1) return 0 ;; + scripts/migrate.sh|scripts/migrate.ps1) return 0 ;; + scripts/lib/common.sh|scripts/lib/common.ps1) return 0 ;; + *) return 1 ;; + esac +} + +DEPLOYABLE=0 +while IFS= read -r path; do + [ -n "$path" ] || continue + if is_deployable "$path"; then DEPLOYABLE=1; break; fi +done < "$CHANGED_FILES" + +if [ "$DEPLOYABLE" -eq 0 ]; then + echo "none" + exit 0 +fi + +# --- how big a bump? ------------------------------------------------------- +# +# The Conventional Commit type sets the size, never whether a bump happens: a +# deployable change is a release regardless of how its author labelled it. An +# unrecognised type therefore falls through to the patch floor rather than +# blocking the release. +BUMP="patch" +case "$SUBJECT" in + feat*) BUMP="minor" ;; +esac +case "$SUBJECT" in + # "type!: subject" and "type(scope)!: subject" both mark a breaking change. + *"!:"*) BUMP="major" ;; +esac +if printf '%s' "$SUBJECT" | grep -q 'BREAKING[ -]CHANGE'; then + BUMP="major" +fi + +ac_semver_bump "$CURRENT" "$BUMP" diff --git a/scripts/ci/write-baseline.sh b/scripts/ci/write-baseline.sh new file mode 100644 index 0000000..0362f7d --- /dev/null +++ b/scripts/ci/write-baseline.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# write-baseline.sh - publish the content baseline for a release. +# +# migrate.sh compares a consumer's deployed files against the baseline for the +# version they are on to tell a pristine file from an edited one. Without a +# baseline for a version, nobody deployed on that version can ever migrate, so +# the release job writes one for every release. +# +# Usage: write-baseline.sh +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/lib/common.sh +. "$REPO_ROOT/scripts/lib/common.sh" + +VERSION="${1:-}" +[ -n "$VERSION" ] || { echo "ERROR: usage: write-baseline.sh " >&2; exit 2; } +ac_semver_is_valid "$VERSION" || { echo "ERROR: not valid SemVer: '$VERSION'" >&2; exit 1; } + +OUT="$REPO_ROOT/scripts/baselines/$VERSION.sha256" +mkdir -p "$REPO_ROOT/scripts/baselines" +ac_hash_source_tree "$REPO_ROOT" > "$OUT" + +count="$(grep -c . "$OUT" || true)" +[ "$count" -gt 0 ] || { echo "ERROR: baseline for $VERSION is empty - refusing to publish." >&2; rm -f "$OUT"; exit 1; } +echo "Wrote $OUT ($count entries)" diff --git a/scripts/ci/write-changelog.sh b/scripts/ci/write-changelog.sh new file mode 100644 index 0000000..79c710d --- /dev/null +++ b/scripts/ci/write-changelog.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# write-changelog.sh - prepend a release section to CHANGELOG.md. +# +# Deliberately not git-cliff or a third-party action: the release job holds +# contents:write, so every dependency it pulls is a supply-chain risk, and the +# grouping we need is a dozen lines of shell. Fewer moving parts also means the +# release cannot fail because an action changed its interface. +# +# Usage: write-changelog.sh [] +# +# With no previous tag, every commit reachable from HEAD is included. +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +VERSION="${1:-}" +PREV_TAG="${2:-}" +[ -n "$VERSION" ] || { echo "ERROR: usage: write-changelog.sh [previous-tag]" >&2; exit 2; } + +CHANGELOG="$REPO_ROOT/CHANGELOG.md" +TODAY="$(date -u '+%Y-%m-%d')" + +if [ -n "$PREV_TAG" ]; then + RANGE="$PREV_TAG..HEAD" +else + RANGE="HEAD" +fi + +RAW="$(mktemp)" +SECTION="$(mktemp)" +trap 'rm -f "$RAW" "$SECTION"' EXIT + +git -C "$REPO_ROOT" log --no-merges --pretty=format:'%s' "$RANGE" > "$RAW" + +# Emit one heading per Conventional Commit type, in a fixed order so the file +# reads consistently release to release. Types with no commits are omitted. +emit_group() { + local pattern="$1" heading="$2" matched + matched="$(grep -E "$pattern" "$RAW" || true)" + [ -n "$matched" ] || return 0 + echo "### $heading" >> "$SECTION" + echo "" >> "$SECTION" + printf '%s\n' "$matched" | sed -E 's/^[a-z]+(\([^)]*\))?!?: */- /' >> "$SECTION" + echo "" >> "$SECTION" +} + +{ + echo "## $VERSION - $TODAY" + echo "" +} > "$SECTION" + +# Breaking changes lead: they are the reason a consumer would read this at all. +BREAKING="$(grep -E '^[a-z]+(\([^)]*\))?!:' "$RAW" || true)" +if [ -n "$BREAKING" ]; then + echo "### Breaking changes" >> "$SECTION" + echo "" >> "$SECTION" + printf '%s\n' "$BREAKING" | sed -E 's/^[a-z]+(\([^)]*\))?!: */- /' >> "$SECTION" + echo "" >> "$SECTION" +fi + +emit_group '^feat(\([^)]*\))?!?: ' 'Features' +emit_group '^fix(\([^)]*\))?!?: ' 'Fixes' +emit_group '^(docs|refactor|perf|style)(\([^)]*\))?!?: ' 'Other changes' + +if [ ! -s "$RAW" ]; then + echo "- No user-facing changes recorded." >> "$SECTION" + echo "" >> "$SECTION" +fi + +if [ ! -f "$CHANGELOG" ]; then + { + echo "# Changelog" + echo "" + echo "Every release of this library. Versions follow [Semantic Versioning](https://semver.org):" + echo "a major means a consumer must act, a minor adds content, a patch corrects it." + echo "" + echo "This file is generated by the release workflow. Do not edit it by hand." + echo "" + } > "$CHANGELOG" +fi + +# Insert the new section directly after the preamble, so newest is first. +NEW="$(mktemp)" +awk -v section_file="$SECTION" ' + BEGIN { inserted = 0 } + # The first "## " line is the previous release; insert above it. + /^## / && !inserted { + while ((getline line < section_file) > 0) print line + inserted = 1 + } + { print } + END { + if (!inserted) { + while ((getline line < section_file) > 0) print line + } + } +' "$CHANGELOG" > "$NEW" +mv "$NEW" "$CHANGELOG" + +echo "Prepended $VERSION to CHANGELOG.md" From 6bcd4cb67a5489081660ec70d429ff7b2af8cc53 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:46:21 +0100 Subject: [PATCH 05/25] docs(versioning): document the override model, versioning and update flow Adds MIGRATIONS.md for the 1.x to 2.0.0 upgrade, README sections covering customisation, versioning and staying current, and maintainer guidance on deployable paths and CI-owned files. Extends both suites to cover the manifest, override layer, managed block and version computation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deploy-ps1-tests.yml | 14 ++- AGENTS.md | 50 ++++++++- MIGRATIONS.md | 90 +++++++++++++++ README.md | 93 ++++++++++++++++ scripts/tests/test-deploy.ps1 | 62 +++++++++++ scripts/tests/test-deploy.sh | 146 ++++++++++++++++++++++++- 6 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 MIGRATIONS.md diff --git a/.github/workflows/deploy-ps1-tests.yml b/.github/workflows/deploy-ps1-tests.yml index af4d573..edd600d 100644 --- a/.github/workflows/deploy-ps1-tests.yml +++ b/.github/workflows/deploy-ps1-tests.yml @@ -56,7 +56,19 @@ jobs: run: | $ErrorActionPreference = 'Stop' Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path ./scripts/deploy.ps1 -Settings ./PSScriptAnalyzerSettings.psd1 + # Every PowerShell file that ships to a consumer must clear the 5.1 + # baseline, not just deploy.ps1 - update and migrate run on their + # machines too. + $targets = @( + './scripts/deploy.ps1', + './scripts/update.ps1', + './scripts/migrate.ps1', + './scripts/lib/common.ps1' + ) + $results = @() + foreach ($target in $targets) { + $results += Invoke-ScriptAnalyzer -Path $target -Settings ./PSScriptAnalyzerSettings.psd1 + } if ($results) { $results | Format-Table -AutoSize | Out-String | Write-Host throw "PSScriptAnalyzer reported $($results.Count) compatibility finding(s)." diff --git a/AGENTS.md b/AGENTS.md index 0ed9ef6..edbaf16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,14 @@ When adding a new standard or playbook, the new entry in `core/.context/index.md Standards use "must", "never", "always". They are not suggestions. Every standard ends with a `## Non-Negotiables` and a `## Decision Checklist` so a reader can act without rereading the body. +### 6. The base is disposable; only the override layer is owned + +Everything the deploy scripts write into a target's `.context/` base — standards, playbooks, conventions, `index.md` — is **replaced wholesale on every update**. This is deliberate. It is what removes merging from the update path entirely: there is no three-way merge, no conflict, and no drift to reconcile. + +Consumers customise through `.context/overrides/`, which updates never touch, and through the region of their `AGENTS.md` outside the managed block. + +The consequence for maintainers: **nothing generated into the base may carry consumer state.** If you add a file to the base that a consumer would reasonably want to edit, you have created a file that updates will silently destroy. Either it belongs in the override layer, or it must be generated from the manifest. + --- ## Source → Target Layout @@ -71,15 +79,19 @@ The directory layout in this repo is **not** the layout in target repos. The dep | Source here | Target repo path | | --- | --- | -| `core/AGENTS.md` | `/AGENTS.md` | +| `core/AGENTS.md` | `/AGENTS.md` (managed block only after first deploy) | | `core/CLAUDE.md` | `/CLAUDE.md` | | `core/.context/index.md` | `/.context/index.md` | | `core/.context/conventions/*` | `/.context/conventions/*` | +| `core/.context/overrides/*` | `/.context/overrides/*` (scaffold only — never overwritten) | | `standards/*.md` | `/.context/standards/*.md` | | `playbooks/**/*.md` | `/.context/playbooks/**/*.md` | +| `scripts/{update,migrate}.{sh,ps1}`, `scripts/lib/*` | `/.context/bin/` | | `core/.cursor/`, `.devin/`, `.windsurfrules`, `.github/copilot-instructions.md` | mirrored to target (only when the agent is selected) | | Skill wrappers generated from `playbooks/**/*.md` frontmatter | `/.claude/skills/` and `/.github/skills/` (Claude/Copilot only) | +Generated into the target, with no source file here: `/.context/manifest.json` and `/.context/VERSION`. + --- ## Working in This Repository @@ -136,6 +148,30 @@ The directory layout in this repo is **not** the layout in target repos. The dep --- +### Versioning and releases + +**Never edit `VERSION`, `CHANGELOG.md`, or `scripts/baselines/` by hand.** All three are written by `.github/workflows/release.yml` when a change lands on `main`, and `.github/workflows/version-gate.yml` rejects any pull request that touches them. A hand-written baseline is the most damaging of the three: `migrate.sh` uses it to tell a pristine file from a consumer's edit, so a wrong baseline silently corrupts every future migration. + +A release is cut only when **deployable content** changes. Deployable means content that reaches a consumer repository: + +| Deployable — cuts a release | Not deployable — cuts nothing | +| --- | --- | +| `core/**`, `standards/**`, `playbooks/**` | `README.md`, this file, `MIGRATIONS.md` | +| `scripts/deploy.{sh,ps1}` | `.github/workflows/**` | +| `scripts/update.{sh,ps1}` | `scripts/tests/**`, `scripts/deploy.Tests.ps1` | +| `scripts/migrate.{sh,ps1}` | `scripts/ci/**` | +| `scripts/lib/common.{sh,ps1}` | `.gitignore`, editor config | + +This list lives in exactly one place — `is_deployable` in `scripts/ci/next-version.sh` — and both the gate and the release job call that script, so the version reported on a pull request is always the version its merge cuts. If you add a new file that ships to consumers, add it there or it will never trigger a release and every deployment will silently miss it. + +The pull request title sets the **size** of the bump, never whether one happens: `feat` gives a minor, `!` or `BREAKING CHANGE` gives a major, anything else falls to the patch floor. An unrecognised type must never block a release — a deployable change is a release regardless of how its author labelled it. + +Ordering inside the release job is load-bearing. The commit must be pushed **before** the tag is created, because tagging first produces a tag whose tree still holds the previous `VERSION` — a consumer resolving that tag would download content that contradicts the version it was told to expect. The job also runs under `concurrency: cancel-in-progress: false` so releases queue rather than cancel, and guards against re-triggering itself with both an actor check and `[skip ci]`. + +Any change that requires a consumer to act needs a section in `MIGRATIONS.md` and a matching baseline, or adopters on the previous version cannot upgrade. + +--- + ## What Belongs Where | If you are adding... | Put it in... | @@ -147,6 +183,10 @@ The directory layout in this repo is **not** the layout in target repos. The dep | A pointer for a specific agent to find AGENTS.md | `core/` | | A keyword route to discover a standard or playbook | `core/.context/index.md` | | Anything about how files are written to target repos | `scripts/deploy.sh` and `scripts/deploy.ps1` | +| Anything about how a deployed repo detects or applies updates | `scripts/update.sh` and `scripts/update.ps1` | +| Logic shared by the deploy, update and migrate scripts | `scripts/lib/common.sh` and `scripts/lib/common.ps1` | +| Anything about how a release is computed or published | `scripts/ci/*` and `.github/workflows/release.yml` | +| What a consumer must do to upgrade across a MAJOR | `MIGRATIONS.md` | If a change does not fit any row above, it probably does not belong in this repo. @@ -183,7 +223,11 @@ Additional rules that apply specifically to maintainers of this template repo: - `scripts/deploy.sh` and `scripts/deploy.ps1` must remain behaviour-equivalent. - `scripts/deploy.sh` must run on macOS bash 3.2 with BSD userland as well as on Linux with GNU userland. No bash 4+ syntax, no GNU-only utilities. - `scripts/deploy.ps1` must run on Windows PowerShell 5.1 as well as PowerShell 7+. No PowerShell 7-only syntax. +- The same portability and behaviour-equivalence rules apply in full to `scripts/update.*`, `scripts/migrate.*` and `scripts/lib/common.*`. They ship to consumers and run on their machines, not ours. - The deploy-script workflows must run on every pull request from any branch, with no path filters. Never narrow their triggers. +- Never edit `VERSION`, `CHANGELOG.md` or `scripts/baselines/` by hand. CI owns all three. +- Never add a file to a consumer's base layer that a consumer would want to edit. It belongs in the override layer, or it will be destroyed on the next update. +- Never let an unrecognised commit type block a release. The type sets the size of a bump, never whether one happens. ## Decision Checklist @@ -194,6 +238,10 @@ Before opening a PR, confirm: - [ ] If a new standard or playbook: added to `core/.context/index.md` and (for standards) the table in `core/AGENTS.md`. - [ ] If a deploy script change: both `scripts/deploy.sh` and `scripts/deploy.ps1` updated, and tested against a scratch directory — never against this repo. - [ ] If a deploy script change: `shellcheck --severity=warning` is clean, and no bash 4+ syntax, GNU-only utilities, or PowerShell 7-only syntax was introduced. +- [ ] If an update or migrate script change: the bash and PowerShell versions were run against the same fixture and their output trees diffed for parity. +- [ ] If a new file now ships to consumers: `is_deployable` in `scripts/ci/next-version.sh` was updated, or it will never trigger a release. +- [ ] If consumers must act to upgrade: `MIGRATIONS.md` has a section for it. +- [ ] No hand-edits to `VERSION`, `CHANGELOG.md` or `scripts/baselines/`. - [ ] If a new agent: redirect file added under `core/`, both deploy scripts updated, README table updated. - [ ] British English, kebab-case, prescriptive language. - [ ] No engagement artefacts or generated deploy outputs in the diff. diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..5e7c8c1 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,90 @@ +# Migrations + +Upgrade notes for consumers of this library. Only versions that require you to +act appear here; ordinary releases are picked up by `update.sh` with no manual +step. + +--- + +## 1.x to 2.0.0 — the override model + +### What changed + +Before 2.0.0, deploying was a one-way copy. Anything you edited afterwards was +yours, but the next deploy would either overwrite it or refuse to touch it, and +nothing could tell the difference between a file you had deliberately customised +and one you had never touched. In practice a deployment drifted from the library +and could never be safely refreshed. + +From 2.0.0 the deployed tree is split in two: + +| Layer | Path | Who owns it | +| --- | --- | --- | +| Base | `.context/standards/`, `.context/playbooks/`, `.context/conventions/`, `.context/index.md` | The library. Replaced wholesale on every update. | +| Overrides | `.context/overrides/` | You. Never touched by an update. | + +Because the base is disposable, updating is a delete-and-recopy with no merge +and no conflicts. Because overrides sit outside it, your customisations survive +untouched. Divergence becomes structurally impossible rather than something you +have to detect and reconcile. + +Your `AGENTS.md` gains a managed block delimited by +`` and ``. Only that +block is rewritten on update; everything above and below it is yours. + +### Migrating + +Run the migration from the root of the repository that has the deployment: + +```bash +# Dry run first. Nothing is written. +/path/to/agentic-context/scripts/migrate.sh . + +# When the report looks right: +/path/to/agentic-context/scripts/migrate.sh . --apply +``` + +```powershell +# Windows PowerShell 5.1 or PowerShell 7+ +& C:\path\to\agentic-context\scripts\migrate.ps1 . -Apply +``` + +The migration: + +1. Compares every deployed file against the published baseline for the version + you are on, so it can tell a file you edited from one you never touched. +2. Promotes each edited file into `.context/overrides/`, preserving your content + and marking it `mode: replace`. +3. Restores the base to pristine library content. +4. Moves files you added yourself into the override layer, where updates cannot + remove them. +5. Writes `.context/manifest.json` and installs `.context/bin/` tooling. +6. Prepends the managed block to `AGENTS.md`, leaving your existing content + below it untouched. + +It refuses to run on a dirty git tree, so every change it makes is reviewable +with `git diff` before you commit. + +### After migrating + +Two follow-ups are worth doing by hand — the migration cannot make these +judgements for you: + +- **Trim `AGENTS.md`.** Content that the managed block now supplies may still be + duplicated below it. Delete the duplicates. +- **Convert `mode: replace` to `mode: extend` where you can.** The migration is + conservative and marks every promoted file `replace`, which pins the whole file + and means you stop receiving library improvements to it. If your change was an + addition rather than a contradiction, `extend` keeps the library version and + appends yours. See `.context/overrides/README.md`. + +If you deployed on a version with no published baseline, pass `--from` to name +the closest released version, or `--baseline` to point at a baseline file +directly. Without a baseline the migration cannot distinguish your edits from +library content and will not run. + +### If you never edited anything + +Migration is still worth running: it installs the manifest and update tooling +that let the deployment stay current. It will report no promotions and simply +convert the layout. diff --git a/README.md b/README.md index 955d48a..82c388f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ core/ Tier 1 — always in context (→ target code.md Naming, patterns, imports, core principles workflow.md Workflow orchestration, task management communication.md Writing standards, communication style + overrides/ Consumer-owned layer — never overwritten by an update + README.md How to write an override (extend vs replace) .windsurfrules Windsurf redirect → AGENTS.md .cursor/rules/standards.mdc Cursor redirect → AGENTS.md + index .devin/devin.json Devin config + index pointer @@ -145,6 +147,7 @@ scripts/ Distribution tooling (not deployed to ta update.sh / update.ps1 Staleness check and in-place update migrate.sh / migrate.ps1 One-off upgrade for pre-2.0 deployments lib/ Shared helpers (SemVer, manifest, hashing) + ci/ Release automation helpers (version, changelog, baseline) baselines/.sha256 Per-release hashes of deployable files deploy.Tests.ps1 Pester unit tests tests/ End-to-end test suites (bash and PowerShell) @@ -189,6 +192,96 @@ The `keywords` field feeds the context index. The `description` field is used by - SOLID principles always use **full names** (never SRP, OCP, etc.) - All instructions prescriptive: "must", "never", "always" +## Customising a Deployment + +A deployed repository is split into two layers, and the split is what makes +updates safe: + +| Layer | Path | Owner | +| ----- | ---- | ----- | +| Base | `.context/standards/`, `.context/playbooks/`, `.context/conventions/`, `.context/index.md` | This library. Replaced wholesale on every update. | +| Overrides | `.context/overrides/` | You. Never touched by an update. | +| Managed block | The delimited region of your `AGENTS.md` | This library. Everything outside it is yours. | + +**Never edit the base.** Anything you change there is restored on the next +update. To customise, put a file at the mirrored path under +`.context/overrides/`: + +``` +.context/standards/testing.md <- base, do not edit +.context/overrides/standards/testing.md <- your version, wins +``` + +Each override declares how it combines with the base: + +- **`mode: extend`** — the library file is loaded, then yours is appended. Use + this when you are adding a rule. You keep receiving library improvements. +- **`mode: replace`** — yours is loaded instead. Use this only when you need to + contradict the library, because you stop receiving improvements to that file. + +Prefer `extend`. Full detail is in `.context/overrides/README.md` in your +deployment. + +Because overrides live outside the base, an update never has to merge anything: +the base is deleted and recopied, and your layer is left alone. There are no +conflicts to resolve. + +## Versioning + +This library is versioned with [Semantic Versioning](https://semver.org). The +current version is in `VERSION` at the repository root, and every release is +tagged `vX.Y.Z` with notes in `CHANGELOG.md`. + +Versions are cut automatically by CI, never by hand: + +- A release happens only when **deployable content** changes — `core/`, + `standards/`, `playbooks/`, and the deploy, update, migrate and shared library + scripts. Changes to the README, workflows or tests do not produce a release, + because they never reach a consumer. +- The **size** of the bump comes from the pull request title, with a patch + floor: `feat` gives a minor, a `!` marker or `BREAKING CHANGE` gives a major, + anything else gives a patch. +- `VERSION`, `CHANGELOG.md` and `scripts/baselines/` are written by the release + workflow. Pull requests that edit them by hand are rejected by the version + gate. + +For maintainers, the version a pull request will cut is reported in its checks +before merge. + +## Staying Current + +A deployment goes stale the moment this library moves on. Each deployment +carries `.context/manifest.json` recording the version it is on, and +`.context/bin/` with the tooling to check and apply updates. + +```bash +.context/bin/update.sh --status # local state only, no network +.context/bin/update.sh --check # is there a newer version? +.context/bin/update.sh --apply # refresh the base to the latest +``` + +```powershell +.context/bin/update.ps1 -Status +.context/bin/update.ps1 -Check +.context/bin/update.ps1 -Apply +``` + +The check costs a single fetch of a six-byte `VERSION` file from a CDN-cached +URL — no authentication, no rate limit, and nothing meaningful added to an agent +session. It **fails open**: if the network is unavailable the check reports so +and exits zero, so it can never block your work. + +`--apply` deletes and recopies the base, rewrites only the managed block of +`AGENTS.md`, and leaves `.context/overrides/` untouched. It reports any base +file you had edited and tells you where to move the change. + +The deployed `AGENTS.md` carries an update checkpoint so an agent prompts you on +a schedule you choose — weekly by default. Change the frequency there, or pin to +a major line in the manifest to refuse automatic majors. + +Existing deployments made before the override model must be migrated once. See +[MIGRATIONS.md](MIGRATIONS.md). + ## Updating Standards Standards are maintained in **one place only**: diff --git a/scripts/tests/test-deploy.ps1 b/scripts/tests/test-deploy.ps1 index 58c258a..0fba0af 100644 --- a/scripts/tests/test-deploy.ps1 +++ b/scripts/tests/test-deploy.ps1 @@ -247,6 +247,68 @@ Assert-FileNotExists "AGENTS.md NOT deployed relative to corrupted CurrentDirect Remove-Item -Recurse -Force $tc5Base +# ═══════════════════════════════════════════════════════════════════════ +# TC6: manifest and override layer are deployed +# ═══════════════════════════════════════════════════════════════════════ +Write-Host "" +Write-Host "=== TC6: manifest and override layer ===" +$tc6Dir = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-tc6-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tc6Dir -Force | Out-Null +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc6Dir *>$null + +Assert-FileExists "manifest.json" "$tc6Dir/.context/manifest.json" + +$tc6Version = (Get-Content (Join-Path $RepoDir 'VERSION') -Raw).Trim() +Assert-Contains "manifest records the current version" "$tc6Dir/.context/manifest.json" "`"version`": `"$tc6Version`"" + +Assert-FileExists "override layer README" "$tc6Dir/.context/overrides/README.md" + +foreach ($tc6Tool in @('update.sh', 'update.ps1', 'lib/common.sh', 'lib/common.ps1')) { + Assert-FileExists "bin/$tc6Tool" "$tc6Dir/.context/bin/$tc6Tool" +} + +# The managed block is what lets an update rewrite framework content without +# touching the consumer's own AGENTS.md prose. +Assert-Contains "AGENTS.md managed block start" "$tc6Dir/AGENTS.md" "agentic-context:begin" +Assert-Contains "AGENTS.md managed block end" "$tc6Dir/AGENTS.md" "agentic-context:end" + +# -Status must work without network access and must not fail on a clean tree. +$tc6Status = & pwsh -NoProfile -File "$tc6Dir/.context/bin/update.ps1" -Status 2>&1 | Out-String +if ($tc6Status -match [regex]::Escape("agentic-context $tc6Version")) { + Pass "update.ps1 -Status reports the deployed version" +} else { + Fail "update.ps1 -Status did not report the deployed version" +} + +Remove-Item -Recurse -Force $tc6Dir + +# ═══════════════════════════════════════════════════════════════════════ +# TC7: consumer edits outside the managed block survive a redeploy +# ═══════════════════════════════════════════════════════════════════════ +Write-Host "" +Write-Host "=== TC7: consumer AGENTS.md content survives redeploy ===" +$tc7Dir = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-tc7-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $tc7Dir -Force | Out-Null +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc7Dir *>$null + +$tc7Agents = Join-Path $tc7Dir 'AGENTS.md' +$tc7Body = Get-Content $tc7Agents -Raw +Set-Content -Path $tc7Agents -Value ("Sentinel-above-block`n`n" + $tc7Body + "`n## Our own section`nSentinel-below-block`n") -NoNewline + +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc7Dir *>$null + +Assert-Contains "content above the managed block survived redeploy" $tc7Agents "Sentinel-above-block" +Assert-Contains "content below the managed block survived redeploy" $tc7Agents "Sentinel-below-block" + +# An override the consumer wrote must never be overwritten by a redeploy. +$tc7Override = Join-Path $tc7Dir '.context/overrides/standards' +New-Item -ItemType Directory -Path $tc7Override -Force | Out-Null +Set-Content -Path (Join-Path $tc7Override 'testing.md') -Value "Sentinel-override" +& "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc7Dir *>$null +Assert-Contains "consumer override survived redeploy" (Join-Path $tc7Override 'testing.md') "Sentinel-override" + +Remove-Item -Recurse -Force $tc7Dir + # ═══════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════ diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index d487b2a..1562b46 100644 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -27,9 +27,12 @@ fi # sha256sum and GNU find accepts -perm /111; macOS ships BSD equivalents that # reject both. Resolve each once so the suite runs identically on Linux and macOS. if command -v sha256sum >/dev/null 2>&1; then - checksum_tree() { find "$1" -type f -exec sha256sum {} + | sort; } + # manifest.json is excluded: it records the deployment timestamp, so it is + # expected to differ between runs. TC5 asserts its stability separately, + # comparing every field except deployedAt. + checksum_tree() { find "$1" -type f ! -name 'manifest.json' -exec sha256sum {} + | sort; } elif command -v shasum >/dev/null 2>&1; then - checksum_tree() { find "$1" -type f -exec shasum -a 256 {} + | sort; } + checksum_tree() { find "$1" -type f ! -name 'manifest.json' -exec shasum -a 256 {} + | sort; } else echo "ERROR: neither sha256sum nor shasum is available" >&2 exit 1 @@ -297,7 +300,22 @@ else diff "$TC5_PERMS1" "$TC5_PERMS2" || true fi -rm -rf "$TC5_DIR" "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" "$TC5_PERMS1" "$TC5_PERMS2" +# The manifest is excluded from the byte comparison above because it carries a +# timestamp. Every other field must still be identical across runs, or a +# redeploy is silently changing what the update tooling believes is installed. +TC5_MAN1=$(mktemp) +TC5_MAN2=$(mktemp) +grep -v '"deployedAt"' "$TC5_DIR/.context/manifest.json" > "$TC5_MAN2" +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC5_DIR" >/dev/null 2>&1 +grep -v '"deployedAt"' "$TC5_DIR/.context/manifest.json" > "$TC5_MAN1" +if diff -q "$TC5_MAN1" "$TC5_MAN2" >/dev/null 2>&1; then + pass "Manifest identical across runs apart from deployedAt" +else + fail "Manifest differs across runs beyond deployedAt" + diff "$TC5_MAN2" "$TC5_MAN1" || true +fi + +rm -rf "$TC5_DIR" "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" "$TC5_PERMS1" "$TC5_PERMS2" "$TC5_MAN1" "$TC5_MAN2" # ═══════════════════════════════════════════════════════════════════════ # TC6: validate-config passes — deployed copy @@ -321,6 +339,128 @@ fi rm -rf "$TC6_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# TC7: manifest and override layer are deployed +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC7: manifest and override layer ===" +TC7_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC7_DIR" >/dev/null 2>&1 + +if [ -f "$TC7_DIR/.context/manifest.json" ]; then + pass "manifest.json written" +else + fail "manifest.json missing" +fi + +TC7_VER=$(tr -d ' \t\r\n' < "$REPO_DIR/VERSION") +if grep -q "\"version\": \"$TC7_VER\"" "$TC7_DIR/.context/manifest.json"; then + pass "manifest records the current version ($TC7_VER)" +else + fail "manifest version does not match VERSION" +fi + +if [ -d "$TC7_DIR/.context/overrides" ] && [ -f "$TC7_DIR/.context/overrides/README.md" ]; then + pass "override layer scaffolded" +else + fail "override layer missing" +fi + +for TC7_TOOL in update.sh update.ps1 lib/common.sh lib/common.ps1; do + if [ -f "$TC7_DIR/.context/bin/$TC7_TOOL" ]; then + pass "bin/$TC7_TOOL deployed" + else + fail "bin/$TC7_TOOL missing" + fi +done + +# The managed block is what lets an update rewrite framework content without +# touching the consumer's own AGENTS.md prose. +if grep -q 'agentic-context:begin' "$TC7_DIR/AGENTS.md" && grep -q 'agentic-context:end' "$TC7_DIR/AGENTS.md"; then + pass "AGENTS.md carries the managed block markers" +else + fail "AGENTS.md is missing the managed block markers" +fi + +# --status must work without network access and must not fail on a clean tree. +if (cd "$TC7_DIR" && bash .context/bin/update.sh --status 2>&1 | grep -q "agentic-context $TC7_VER"); then + pass "update.sh --status reports the deployed version" +else + fail "update.sh --status did not report the deployed version" +fi + +rm -rf "$TC7_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC8: consumer edits outside the managed block survive a redeploy +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC8: consumer AGENTS.md content survives redeploy ===" +TC8_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC8_DIR" >/dev/null 2>&1 + +printf '\n## Our own section\nSentinel-below-block\n' >> "$TC8_DIR/AGENTS.md" +TC8_TMP=$(mktemp) +{ printf 'Sentinel-above-block\n\n'; cat "$TC8_DIR/AGENTS.md"; } > "$TC8_TMP" +mv "$TC8_TMP" "$TC8_DIR/AGENTS.md" + +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC8_DIR" >/dev/null 2>&1 + +if grep -q 'Sentinel-above-block' "$TC8_DIR/AGENTS.md"; then + pass "content above the managed block survived redeploy" +else + fail "content above the managed block was lost on redeploy" +fi + +if grep -q 'Sentinel-below-block' "$TC8_DIR/AGENTS.md"; then + pass "content below the managed block survived redeploy" +else + fail "content below the managed block was lost on redeploy" +fi + +# An override the consumer wrote must never be overwritten by a redeploy. +mkdir -p "$TC8_DIR/.context/overrides/standards" +printf 'Sentinel-override\n' > "$TC8_DIR/.context/overrides/standards/testing.md" +"$SCRIPTS_DIR/deploy.sh" --agents all --overwrite "$TC8_DIR" >/dev/null 2>&1 +if grep -q 'Sentinel-override' "$TC8_DIR/.context/overrides/standards/testing.md"; then + pass "consumer override survived redeploy" +else + fail "consumer override was overwritten by redeploy" +fi + +rm -rf "$TC8_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC9: release version computation +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC9: next-version computation ===" +TC9_LIST=$(mktemp) + +check_next() { + # check_next + local got + printf '%s\n' "$1" > "$TC9_LIST" + got=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "$2" --current 1.2.3) + if [ "$got" = "$3" ]; then + pass "next-version: $1 + '$2' -> $3" + else + fail "next-version: $1 + '$2' gave '$got', expected '$3'" + fi +} + +check_next "README.md" "feat: x" "none" +check_next ".github/workflows/x.yml" "feat: x" "none" +check_next "scripts/tests/test-deploy.sh" "fix: x" "none" +check_next "standards/testing.md" "docs: x" "1.2.4" +check_next "core/AGENTS.md" "feat: x" "1.3.0" +check_next "playbooks/assess/a.md" "feat!: x" "2.0.0" +check_next "scripts/lib/common.ps1" "fix(deploy)!: x" "2.0.0" +# An unrecognised type must still release, at the patch floor. +check_next "standards/testing.md" "wibble: x" "1.2.4" + +rm -f "$TC9_LIST" + # ═══════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════ From 641fb441e506f748ee0742e64ee824c10c46c3fc Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 16:51:20 +0100 Subject: [PATCH 06/25] fix(ci): allow bootstrap of CI-owned files and restore executable bits The version gate rejected the very commit that introduces VERSION and the first baseline. It now rejects only changes to files that already exist on the base, so creation is permitted once and every later edit is not. Restores the executable bit lost when the scripts moved into scripts/, and checks it in CI so the failure is not an opaque exit 126. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/deploy-sh-tests.yml | 22 ++++++++++++++++++++++ .github/workflows/version-gate.yml | 25 ++++++++++++++++++++----- scripts/ci/next-version.sh | 0 scripts/ci/write-baseline.sh | 0 scripts/ci/write-changelog.sh | 0 scripts/migrate.sh | 0 scripts/tests/test-deploy.sh | 0 scripts/update.sh | 0 8 files changed, 42 insertions(+), 5 deletions(-) mode change 100644 => 100755 scripts/ci/next-version.sh mode change 100644 => 100755 scripts/ci/write-baseline.sh mode change 100644 => 100755 scripts/ci/write-changelog.sh mode change 100644 => 100755 scripts/migrate.sh mode change 100644 => 100755 scripts/tests/test-deploy.sh mode change 100644 => 100755 scripts/update.sh diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml index da2eb8c..0d7860b 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -57,6 +57,28 @@ jobs: done < <(git ls-files '*.sh') exit $status + - name: Executable bit on entry-point scripts + run: | + # A lost executable bit surfaces as an opaque exit 126 in the test job, + # so check it here where the cause is obvious. Only scripts that are + # invoked directly need it; lib/common.sh is sourced, not run. + status=0 + for script in \ + scripts/deploy.sh \ + scripts/update.sh \ + scripts/migrate.sh \ + scripts/tests/test-deploy.sh \ + scripts/ci/next-version.sh \ + scripts/ci/write-baseline.sh \ + scripts/ci/write-changelog.sh; do + mode="$(git ls-files -s "$script" | cut -d' ' -f1)" + if [ "$mode" != "100755" ]; then + echo "::error file=$script::$script is mode $mode; it must be 100755. Run: git update-index --chmod=+x $script" + status=1 + fi + done + exit $status + test: name: ${{ matrix.name }} strategy: diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index d9d4239..098b4b5 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -45,24 +45,39 @@ jobs: set -euo pipefail fail=0 - if grep -qx 'VERSION' /tmp/changed-files.txt; then + # These files are CI-owned once they exist. Creating them is a + # legitimate one-time bootstrap - the pull request that introduces + # versioning has to add them, and no release workflow exists yet to + # do it. Only a change to a file that already exists on the base is a + # hand-edit of generated content. + exists_on_base() { + git cat-file -e "$BASE_SHA:$1" 2>/dev/null + } + + if grep -qx 'VERSION' /tmp/changed-files.txt && exists_on_base VERSION; then echo "::error file=VERSION::VERSION is written by the release workflow on merge to main. Revert your change to it - the version for this pull request is derived from its title." fail=1 fi - if grep -q '^CHANGELOG\.md$' /tmp/changed-files.txt; then + if grep -qx 'CHANGELOG.md' /tmp/changed-files.txt && exists_on_base CHANGELOG.md; then echo "::error file=CHANGELOG.md::CHANGELOG.md is generated by the release workflow. Revert your change to it - it is written from commit messages on merge." fail=1 fi # Baselines are published per release so migrate can tell a pristine # file from an edited one. A hand-written baseline would silently - # corrupt every future migration, so only the release job may add one. + # corrupt every future migration, so once any baseline exists only + # the release job may add or change one. if grep -q '^scripts/baselines/' /tmp/changed-files.txt; then - echo "::error::scripts/baselines/ is published by the release workflow. Do not add or edit baseline files by hand." - fail=1 + if git ls-tree -r --name-only "$BASE_SHA" -- scripts/baselines | grep -q .; then + echo "::error::scripts/baselines/ is published by the release workflow. Do not add or edit baseline files by hand." + fail=1 + fi fi + if [ "$fail" -eq 0 ]; then + echo "OK: no hand-edits to CI-owned files." + fi exit "$fail" - name: Compute the version this merge will cut diff --git a/scripts/ci/next-version.sh b/scripts/ci/next-version.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/write-baseline.sh b/scripts/ci/write-baseline.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/write-changelog.sh b/scripts/ci/write-changelog.sh old mode 100644 new mode 100755 diff --git a/scripts/migrate.sh b/scripts/migrate.sh old mode 100644 new mode 100755 diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh old mode 100644 new mode 100755 diff --git a/scripts/update.sh b/scripts/update.sh old mode 100644 new mode 100755 From ec6e6a09560d714dac5bd859a11efa4cfaf4e3d6 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 17:53:12 +0100 Subject: [PATCH 07/25] fix(release): publish 1.0.0 as the initial drop instead of bumping past it With no release tag there is nothing to bump from, so the version already in VERSION is published as-is and tagged. Bumping here would have skipped 1.0.0 entirely and made the first tag disagree with the repository's own VERSION. Both the gate and the release job now pass the latest tag explicitly, the release job tolerates having nothing to commit on an initial release, and both refuse to move a tag that already exists. Reframes migration as unversioned to 1.0.0 rather than a 2.0.0 upgrade. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 69 ++++++++++++++++++++---------- .github/workflows/version-gate.yml | 25 ++++++++++- MIGRATIONS.md | 26 +++++++---- README.md | 2 +- scripts/ci/next-version.sh | 23 +++++++++- scripts/migrate.ps1 | 8 ++-- scripts/migrate.sh | 10 ++--- scripts/tests/test-deploy.sh | 23 +++++++++- 8 files changed, 141 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b926362..86d1598 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,27 +58,44 @@ jobs: set -euo pipefail subject="$(git log -1 --pretty=format:'%s')" current="$(tr -d ' \t\r\n' < VERSION)" - next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject")" + prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject" --latest-tag "$prev_tag")" echo "current=$current" >> "$GITHUB_OUTPUT" echo "next=$next" >> "$GITHUB_OUTPUT" + echo "prev_tag=$prev_tag" >> "$GITHUB_OUTPUT" if [ "$next" = "none" ]; then echo "No deployable content changed. Skipping release." >> "$GITHUB_STEP_SUMMARY" + elif [ -z "$prev_tag" ]; then + echo "Initial release: publishing $next as-is." >> "$GITHUB_STEP_SUMMARY" else echo "Releasing $current -> $next" >> "$GITHUB_STEP_SUMMARY" fi + - name: Fail if the release tag already exists + if: steps.next.outputs.next != 'none' + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + set -euo pipefail + # Re-releasing an existing version would move a tag consumers have + # already pinned to, so stop rather than overwrite. + if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then + echo "::error::Tag v$NEXT already exists. Refusing to re-release it." + exit 1 + fi + - name: Write VERSION, CHANGELOG and baseline if: steps.next.outputs.next != 'none' env: NEXT: ${{ steps.next.outputs.next }} + PREV_TAG: ${{ steps.next.outputs.prev_tag }} run: | set -euo pipefail - prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" printf '%s\n' "$NEXT" > VERSION # The baseline must hash the released content, so write it after # VERSION but from the same working tree that is about to be tagged. scripts/ci/write-baseline.sh "$NEXT" - scripts/ci/write-changelog.sh "$NEXT" "$prev_tag" + scripts/ci/write-changelog.sh "$NEXT" "$PREV_TAG" - name: Commit, then tag that commit if: steps.next.outputs.next != 'none' @@ -89,26 +106,34 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add VERSION CHANGELOG.md "scripts/baselines/$NEXT.sha256" - git commit -m "chore(release): $NEXT [skip ci]" - # Push the commit before tagging it. Tagging first would produce a tag - # whose tree still holds the previous VERSION, so a consumer resolving - # the tag would download content that disagrees with the version they - # were told to expect. - # - # Another release may have landed while this job queued, so rebase and - # retry rather than failing the release outright. - attempt=1 - until git push origin HEAD:main; do - if [ "$attempt" -ge 3 ]; then - echo "::error::Could not push the release commit after $attempt attempts." - exit 1 - fi - echo "Push rejected; rebasing onto the latest main (attempt $attempt)." - git fetch origin main - git rebase origin/main - attempt=$((attempt + 1)) - done + # On an initial release VERSION and the baseline are already correct + # in the tree, so there may be nothing to commit. That is not a + # failure - the tag still has to be created against this commit. + if git diff --cached --quiet; then + echo "Nothing to commit; tagging the current commit." + else + git commit -m "chore(release): $NEXT [skip ci]" + + # Push the commit before tagging it. Tagging first would produce a + # tag whose tree still holds the previous VERSION, so a consumer + # resolving the tag would download content that disagrees with the + # version they were told to expect. + # + # Another release may have landed while this job queued, so rebase + # and retry rather than failing the release outright. + attempt=1 + until git push origin HEAD:main; do + if [ "$attempt" -ge 3 ]; then + echo "::error::Could not push the release commit after $attempt attempts." + exit 1 + fi + echo "Push rejected; rebasing onto the latest main (attempt $attempt)." + git fetch origin main + git rebase origin/main + attempt=$((attempt + 1)) + done + fi git tag -a "v$NEXT" -m "v$NEXT" HEAD git push origin "v$NEXT" diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 098b4b5..7009b58 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -87,7 +87,8 @@ jobs: run: | set -euo pipefail current="$(tr -d ' \t\r\n' < VERSION)" - next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$PR_TITLE")" + prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$PR_TITLE" --latest-tag "$prev_tag")" { echo "### Version gate" echo "" @@ -95,6 +96,10 @@ jobs: echo "No deployable content changed. **No release will be cut** and \`VERSION\` stays at \`$current\`." echo "" echo "Deployable paths are \`core/\`, \`standards/\`, \`playbooks/\`, and the deploy, update, migrate and shared library scripts. Documentation, workflows and tests do not reach a consumer repository, so they do not earn a version." + elif [ -z "$prev_tag" ]; then + echo "This is the **initial release**. Merging will publish \`$next\` as-is and tag it \`v$next\`." + echo "" + echo "There is no earlier tag to bump from, so the version in \`VERSION\` is published unchanged rather than skipping straight past it." else echo "Merging this pull request will release **\`$next\`** (currently \`$current\`)." echo "" @@ -102,15 +107,31 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" echo "next=$next" >> "$GITHUB_OUTPUT" + echo "prev_tag=$prev_tag" >> "$GITHUB_OUTPUT" - - name: Verify the computed version is a real increment + - name: Verify the computed version is releasable if: steps.next.outputs.next != 'none' env: NEXT: ${{ steps.next.outputs.next }} + PREV_TAG: ${{ steps.next.outputs.prev_tag }} run: | set -euo pipefail . scripts/lib/common.sh current="$(tr -d ' \t\r\n' < VERSION)" + + # Whatever version is about to be cut, its tag must not already exist: + # re-releasing would move a tag consumers have pinned to. + if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then + echo "::error::Tag v$NEXT already exists. This merge would try to re-release it." + exit 1 + fi + + if [ -z "$PREV_TAG" ]; then + # Initial release: nothing to be greater than. + echo "OK: initial release of $NEXT" + exit 0 + fi + if ! ac_semver_gt "$NEXT" "$current"; then echo "::error::Computed version '$NEXT' is not greater than the current '$current'. This is a bug in scripts/ci/next-version.sh." exit 1 diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 5e7c8c1..da3b39a 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -6,17 +6,22 @@ step. --- -## 1.x to 2.0.0 — the override model +## Unversioned to 1.0.0 — the override model + +If you deployed this library before it carried a `VERSION`, your deployment has +no `.context/manifest.json` and no `.context/overrides/`. This is a one-off +migration; every release after 1.0.0 is picked up by `update.sh` with no manual +step. ### What changed -Before 2.0.0, deploying was a one-way copy. Anything you edited afterwards was +Before 1.0.0, deploying was a one-way copy. Anything you edited afterwards was yours, but the next deploy would either overwrite it or refuse to touch it, and nothing could tell the difference between a file you had deliberately customised and one you had never touched. In practice a deployment drifted from the library and could never be safely refreshed. -From 2.0.0 the deployed tree is split in two: +From 1.0.0 the deployed tree is split in two: | Layer | Path | Who owns it | | --- | --- | --- | @@ -51,8 +56,8 @@ Run the migration from the root of the repository that has the deployment: The migration: -1. Compares every deployed file against the published baseline for the version - you are on, so it can tell a file you edited from one you never touched. +1. Compares every deployed file against the published `1.0.0` baseline, so it + can tell a file you edited from one you never touched. 2. Promotes each edited file into `.context/overrides/`, preserving your content and marking it `mode: replace`. 3. Restores the base to pristine library content. @@ -78,10 +83,13 @@ judgements for you: addition rather than a contradiction, `extend` keeps the library version and appends yours. See `.context/overrides/README.md`. -If you deployed on a version with no published baseline, pass `--from` to name -the closest released version, or `--baseline` to point at a baseline file -directly. Without a baseline the migration cannot distinguish your edits from -library content and will not run. +The migration compares against the `1.0.0` baseline by default, which hashes the +content this library shipped at the point versioning was introduced. If you +deployed from an older commit than that, some files will be reported as edited +when you never touched them — promote only the ones you recognise, or pass +`--baseline` to point at a baseline file you generated yourself from the commit +you actually deployed. Without a baseline the migration cannot distinguish your +edits from library content and will not run. ### If you never edited anything diff --git a/README.md b/README.md index 82c388f..5869379 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ playbooks/ Tier 2 — on demand (→ target .contex scripts/ Distribution tooling (not deployed to targets) deploy.sh / deploy.ps1 First-time install into a target repository update.sh / update.ps1 Staleness check and in-place update - migrate.sh / migrate.ps1 One-off upgrade for pre-2.0 deployments + migrate.sh / migrate.ps1 One-off upgrade for unversioned deployments lib/ Shared helpers (SemVer, manifest, hashing) ci/ Release automation helpers (version, changelog, baseline) baselines/.sha256 Per-release hashes of deployable files diff --git a/scripts/ci/next-version.sh b/scripts/ci/next-version.sh index 9f5bbca..b194782 100755 --- a/scripts/ci/next-version.sh +++ b/scripts/ci/next-version.sh @@ -5,10 +5,13 @@ # call this, so the version shown on the PR is the version that is actually cut. # # Usage: -# next-version.sh --changed-files --subject [--current ] +# next-version.sh --changed-files --subject +# [--current ] [--latest-tag ] # # Reads the current version from ./VERSION unless --current is given. # --changed-files points at a newline-delimited list of paths changed by the merge. +# --latest-tag names the most recent release tag; pass an empty string to state +# that none exists. When omitted it is derived from git. # # Prints one of: # when the change touches deployable content @@ -26,12 +29,15 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" CHANGED_FILES="" SUBJECT="" CURRENT="" +LATEST_TAG="" +LATEST_TAG_SET=0 while [ $# -gt 0 ]; do case "$1" in --changed-files) CHANGED_FILES="${2:-}"; shift 2 ;; --subject) SUBJECT="${2:-}"; shift 2 ;; --current) CURRENT="${2:-}"; shift 2 ;; + --latest-tag) LATEST_TAG="${2:-}"; LATEST_TAG_SET=1; shift 2 ;; *) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;; esac done @@ -71,6 +77,21 @@ if [ "$DEPLOYABLE" -eq 0 ]; then exit 0 fi +# --- the initial drop ------------------------------------------------------ +# +# With no release tag there is nothing to bump from: the version already in +# VERSION is the first release, published as-is. Bumping here would silently +# skip 1.0.0 and make the first tag disagree with everything the repository +# says its version is. +if [ "$LATEST_TAG_SET" -eq 0 ]; then + LATEST_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-v:refname 2>/dev/null | head -n 1 || true)" +fi + +if [ -z "$LATEST_TAG" ]; then + printf '%s\n' "$CURRENT" + exit 0 +fi + # --- how big a bump? ------------------------------------------------------- # # The Conventional Commit type sets the size, never whether a bump happens: a diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 index ae67715..6596c36 100644 --- a/scripts/migrate.ps1 +++ b/scripts/migrate.ps1 @@ -1,6 +1,6 @@ -# migrate.ps1 - upgrade a pre-2.0 agentic-context deployment to the override model. +# migrate.ps1 - upgrade an unversioned agentic-context deployment to the override model. # -# Pre-2.0 deployments have no .context/manifest.json and no .context/overrides/. +# Unversioned deployments have no .context/manifest.json and no .context/overrides/. # Consumers may have edited standards and playbooks directly. This script finds # those edits by comparing against a published baseline for the version they are # on, and promotes each edited file into .context/overrides/ so their intent is @@ -56,7 +56,7 @@ if (Test-Path -LiteralPath $manifestPath) { $ver = 'unknown' if ($existing -and $existing.version) { $ver = $existing.version } Write-Host "This deployment already has a manifest (version $ver)." - Write-Host "Migration is only for pre-2.0 deployments. Use update.ps1 -Apply instead." + Write-Host "Migration is only for unversioned deployments. Use update.ps1 -Apply instead." exit 0 } @@ -178,7 +178,7 @@ function Move-AcToOverride { 'mode: replace', '---', '', - '', diff --git a/scripts/migrate.sh b/scripts/migrate.sh index dd85667..2cca835 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -1,7 +1,7 @@ #!/bin/bash -# migrate.sh — upgrade a pre-2.0 agentic-context deployment to the override model. +# migrate.sh — upgrade an unversioned agentic-context deployment to the override model. # -# Pre-2.0 deployments have no .context/manifest.json and no .context/overrides/. +# Unversioned deployments have no .context/manifest.json and no .context/overrides/. # Consumers may have edited standards and playbooks directly. This script finds # those edits by comparing against a published baseline for the version they are # on, and promotes each edited file into .context/overrides/ so their intent is @@ -33,7 +33,7 @@ usage() { cat <] [--baseline ] [target-repo] -Upgrade a pre-2.0 deployment to the override model. +Upgrade an unversioned deployment to the override model. --apply Write changes. Without it, reports what would happen and exits. --from Version the target was deployed from. Default: 1.0.0 @@ -80,7 +80,7 @@ fi if [ -f "$CONTEXT_DIR/manifest.json" ]; then existing="$(ac_manifest_get "$CONTEXT_DIR/manifest.json" version)" echo "This deployment already has a manifest (version ${existing:-unknown})." - echo "Migration is only for pre-2.0 deployments. Use update.sh --apply instead." + echo "Migration is only for unversioned deployments. Use update.sh --apply instead." exit 0 fi @@ -184,7 +184,7 @@ promote() { printf 'overrides: %s\n' "$rel" printf 'mode: replace\n' printf -- '---\n\n' - printf '\n\n' diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 1562b46..a524e2c 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -439,9 +439,11 @@ TC9_LIST=$(mktemp) check_next() { # check_next + # --latest-tag is passed explicitly so the result does not depend on which + # tags happen to exist in the checkout running the tests. local got printf '%s\n' "$1" > "$TC9_LIST" - got=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "$2" --current 1.2.3) + got=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "$2" --current 1.2.3 --latest-tag v1.2.3) if [ "$got" = "$3" ]; then pass "next-version: $1 + '$2' -> $3" else @@ -459,6 +461,25 @@ check_next "scripts/lib/common.ps1" "fix(deploy)!: x" "2.0.0" # An unrecognised type must still release, at the patch floor. check_next "standards/testing.md" "wibble: x" "1.2.4" +# The initial drop: with no release tag there is nothing to bump from, so the +# version already in VERSION is published as-is rather than skipping 1.0.0. +printf '%s\n' "core/AGENTS.md" > "$TC9_LIST" +TC9_INITIAL=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "feat!: x" --current 1.0.0 --latest-tag "") +if [ "$TC9_INITIAL" = "1.0.0" ]; then + pass "next-version: no tag yet -> publishes 1.0.0 unchanged" +else + fail "next-version: no tag yet gave '$TC9_INITIAL', expected '1.0.0'" +fi + +# Non-deployable changes must still cut nothing, even with no tag. +printf '%s\n' "README.md" > "$TC9_LIST" +TC9_INITIAL_NONE=$("$SCRIPTS_DIR/ci/next-version.sh" --changed-files "$TC9_LIST" --subject "feat: x" --current 1.0.0 --latest-tag "") +if [ "$TC9_INITIAL_NONE" = "none" ]; then + pass "next-version: no tag yet + non-deployable change -> none" +else + fail "next-version: no tag yet + non-deployable gave '$TC9_INITIAL_NONE', expected 'none'" +fi + rm -f "$TC9_LIST" # ═══════════════════════════════════════════════════════════════════════ From 7a09baf32253c5721b678eeea03c9028ffa59995 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 18:03:15 +0100 Subject: [PATCH 08/25] docs(readme): explain what each version bump means for a deployment Documents MAJOR, MINOR and PATCH in terms of their effect on a consumer's deployment rather than the scale of the change, since that is the decision a reader actually faces: whether an update can be applied unattended or needs reading first. Adds the pinning defaults and how a version is derived. Removes the bump derivation from the maintainer guide, which now links to the README rather than restating it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 8 ++++-- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 69 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index edbaf16..6f293a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,11 +164,15 @@ A release is cut only when **deployable content** changes. Deployable means cont This list lives in exactly one place — `is_deployable` in `scripts/ci/next-version.sh` — and both the gate and the release job call that script, so the version reported on a pull request is always the version its merge cuts. If you add a new file that ships to consumers, add it there or it will never trigger a release and every deployment will silently miss it. -The pull request title sets the **size** of the bump, never whether one happens: `feat` gives a minor, `!` or `BREAKING CHANGE` gives a major, anything else falls to the patch floor. An unrecognised type must never block a release — a deployable change is a release regardless of how its author labelled it. +The pull request title sets the **size** of the bump, never whether one happens. The derivation and what each number means to a consumer are in the [Versioning section of the README](README.md#versioning) — do not restate them here. The maintainer-facing rule is the one the table cannot express: an unrecognised type must never block a release. A deployable change is a release regardless of how its author labelled it, so mislabelling may understate a version but must never lose one. + +Choose the bump by its effect on a consumer's deployment, not by how much prose changed. Renaming or removing a deployed path is a major even if it is a one-line change, because an override pointing at the old path is orphaned by it. Rewriting a standard in full is a patch if every path survives. + +The first release is a special case: with no tag to bump from, the version already in `VERSION` is published as-is rather than bumped past. `next-version.sh` handles this when `--latest-tag` is empty, and both the gate and the release job pass it explicitly so they cannot disagree. Ordering inside the release job is load-bearing. The commit must be pushed **before** the tag is created, because tagging first produces a tag whose tree still holds the previous `VERSION` — a consumer resolving that tag would download content that contradicts the version it was told to expect. The job also runs under `concurrency: cancel-in-progress: false` so releases queue rather than cancel, and guards against re-triggering itself with both an actor check and `[skip ci]`. -Any change that requires a consumer to act needs a section in `MIGRATIONS.md` and a matching baseline, or adopters on the previous version cannot upgrade. +Any change that requires a consumer to act needs a section in `MIGRATIONS.md` and a matching baseline, or adopters on the previous version cannot upgrade. Because consumers are pinned to their major line by default, a major is never applied silently — which is what makes it safe to cut one when a deployment genuinely needs attention, rather than contorting a change to avoid it. --- diff --git a/README.md b/README.md index 5869379..9d65044 100644 --- a/README.md +++ b/README.md @@ -229,24 +229,69 @@ conflicts to resolve. ## Versioning This library is versioned with [Semantic Versioning](https://semver.org). The -current version is in `VERSION` at the repository root, and every release is -tagged `vX.Y.Z` with notes in `CHANGELOG.md`. - -Versions are cut automatically by CI, never by hand: - -- A release happens only when **deployable content** changes — `core/`, - `standards/`, `playbooks/`, and the deploy, update, migrate and shared library - scripts. Changes to the README, workflows or tests do not produce a release, - because they never reach a consumer. -- The **size** of the bump comes from the pull request title, with a patch - floor: `feat` gives a minor, a `!` marker or `BREAKING CHANGE` gives a major, - anything else gives a patch. -- `VERSION`, `CHANGELOG.md` and `scripts/baselines/` are written by the release - workflow. Pull requests that edit them by hand are rejected by the version - gate. - -For maintainers, the version a pull request will cut is reported in its checks -before merge. +current version is in `VERSION` at the repository root, every release is tagged +`vX.Y.Z` with notes in `CHANGELOG.md`, and each deployment records the version +it is on in `.context/manifest.json`. + +The version describes **the library as a whole**, not individual files. A single +number is what makes the staleness check cheap: comparing your manifest against +one six-byte file is enough to know whether you are behind. + +### What each number means + +Versions here describe the effect on **your deployment**, not the scale of the +writing. A rewritten standard is still a patch if your overrides keep working; a +single renamed file is a major if they do not. + +| Bump | Example: `1.4.2` → | What changed | What you must do | +| ---- | ------------------ | ------------ | ---------------- | +| **PATCH** | `1.4.3` | Existing content corrected or clarified. No file added, renamed or removed. | Nothing. Apply it whenever. | +| **MINOR** | `1.5.0` | New standards, playbooks or conventions added, and new routes in `index.md`. Existing paths unchanged. | Nothing. Your overrides still resolve; you simply gain content. | +| **MAJOR** | `2.0.0` | A deployed path was renamed or removed, the manifest schema changed, the managed block markers changed, or override resolution changed. | Read [MIGRATIONS.md](MIGRATIONS.md) before applying. An override may now point at a file that no longer exists. | + +The distinction that matters is **major versus everything else**. Patches and +minors are safe to apply unattended: the base is replaced, your override layer +is untouched, and every override still resolves to a real file. A major is the +only case where an update can leave an override orphaned, which is why it is the +only case that requires you to read anything. + +`update.sh --status` lists any override whose target no longer exists, so you +can confirm a major landed cleanly. + +### Pinning + +New deployments are pinned to their major line — `"pin": "1.x"` in the manifest. +Patches and minors apply automatically; a major is reported but never applied +until you act. Widen it to `"*"` to accept anything, narrow it to an exact +version to freeze entirely, or pass `--force` to override the pin once. + +This default means a major can never surprise you, which is what allows majors +to be used honestly rather than avoided. + +### How a version is decided + +Versions are cut automatically by CI, never by hand. + +**Whether** a release happens depends only on what changed. Deployable content — +`core/`, `standards/`, `playbooks/`, and the deploy, update, migrate and shared +library scripts — cuts a release. The README, workflows and tests do not, because +they never reach a consumer and so cannot make a deployment stale. + +**How large** the bump is comes from the pull request title, with a patch floor: + +| Pull request title | Bump | +| ------------------ | ---- | +| `feat!: ...`, or any title containing `BREAKING CHANGE` | major | +| `feat: ...` | minor | +| `fix: ...`, `docs: ...`, or anything else | patch | + +The title sets the size of the bump, never whether one happens: a deployable +change is released regardless of how it was labelled, so mislabelling can +understate a release but can never lose it. + +`VERSION`, `CHANGELOG.md` and `scripts/baselines/` are written by the release +workflow. Pull requests that edit them by hand are rejected by the version gate, +which also reports the exact version a merge will cut before it is merged. ## Staying Current From f41aa5a0d21be238d1659ca527e5c91da74e94e1 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 18:27:54 +0100 Subject: [PATCH 09/25] chore: ignore Node heap dump reports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 39e7d61..011a5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ # Pester test output (Invoke-Pester -CI) testResults.xml + +# Node/V8 diagnostic reports (heap dumps from an agent or tooling crash). +# Written to the working directory as report....json. +report.*.json From 732fa7f3785747230853e807f3498d2298b5f6d3 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 19:24:41 +0100 Subject: [PATCH 10/25] fix(content): repair broken index routes and separate migration baseline Three keyword routes in core/.context/index.md pointed at playbooks that do not exist, so an agent following them resolved nothing: assess/compliance.md -> split into assess/gdpr.md and assess/pci-dss.md assess/test-coverage.md -> renamed to assess/testing.md review/test-coverage.md -> renamed to review/testing.md Four assess playbooks (ci-cd, cost-optimisation, operational-excellence and resilience) had no route at all and were undiscoverable. All 29 standards and 45 playbooks are now routed, with no route pointing at a missing file. Separate the two distinct meanings of a baseline. scripts/baselines/ .sha256 records what a tagged release shipped and is written by CI. The migration baseline records the pre-versioning content that unversioned adopters actually deployed, and is now scripts/baselines/unversioned.sha256. Previously both were the same file, so the release job would have regenerated 1.0.0.sha256 from the release tree. That is harmless today because migrate compares only standards, playbooks and conventions, none of which differ on this branch. It stops being harmless as soon as a release also edits a standard or playbook: every such file would be reported as a consumer edit and promoted to a mode: replace override, freezing consumers on stale content. write-baseline.sh accepts only valid SemVer, so CI can never overwrite the unversioned baseline. Correct the README Repository Structure block, which had drifted: three missing standards, and stale counts and file lists for assess, review and setup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 6 ++--- MIGRATIONS.md | 9 ++++--- README.md | 24 +++++++++++-------- core/.context/index.md | 11 ++++++--- .../{1.0.0.sha256 => unversioned.sha256} | 0 scripts/migrate.ps1 | 2 +- scripts/migrate.sh | 5 ++-- 7 files changed, 35 insertions(+), 22 deletions(-) rename scripts/baselines/{1.0.0.sha256 => unversioned.sha256} (100%) diff --git a/AGENTS.md b/AGENTS.md index 6f293a7..d5c71d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ Generated into the target, with no source file here: `/.context/manifest.j ### Versioning and releases -**Never edit `VERSION`, `CHANGELOG.md`, or `scripts/baselines/` by hand.** All three are written by `.github/workflows/release.yml` when a change lands on `main`, and `.github/workflows/version-gate.yml` rejects any pull request that touches them. A hand-written baseline is the most damaging of the three: `migrate.sh` uses it to tell a pristine file from a consumer's edit, so a wrong baseline silently corrupts every future migration. +**Never edit `VERSION`, `CHANGELOG.md`, or `scripts/baselines/.sha256` by hand.** All three are written by `.github/workflows/release.yml` when a change lands on `main`, and `.github/workflows/version-gate.yml` rejects any pull request that touches them. A hand-written baseline is the most damaging of the three: `migrate.sh` uses it to tell a pristine file from a consumer's edit, so a wrong baseline silently corrupts every future migration. A release is cut only when **deployable content** changes. Deployable means content that reaches a consumer repository: @@ -229,7 +229,7 @@ Additional rules that apply specifically to maintainers of this template repo: - `scripts/deploy.ps1` must run on Windows PowerShell 5.1 as well as PowerShell 7+. No PowerShell 7-only syntax. - The same portability and behaviour-equivalence rules apply in full to `scripts/update.*`, `scripts/migrate.*` and `scripts/lib/common.*`. They ship to consumers and run on their machines, not ours. - The deploy-script workflows must run on every pull request from any branch, with no path filters. Never narrow their triggers. -- Never edit `VERSION`, `CHANGELOG.md` or `scripts/baselines/` by hand. CI owns all three. +- Never edit `VERSION`, `CHANGELOG.md` or `scripts/baselines/.sha256` by hand. CI owns all three. `scripts/baselines/unversioned.sha256` is the one exception: it records the pre-versioning content for `migrate`, is committed by hand, and CI can never overwrite it because `write-baseline.sh` accepts only valid SemVer. - Never add a file to a consumer's base layer that a consumer would want to edit. It belongs in the override layer, or it will be destroyed on the next update. - Never let an unrecognised commit type block a release. The type sets the size of a bump, never whether one happens. @@ -245,7 +245,7 @@ Before opening a PR, confirm: - [ ] If an update or migrate script change: the bash and PowerShell versions were run against the same fixture and their output trees diffed for parity. - [ ] If a new file now ships to consumers: `is_deployable` in `scripts/ci/next-version.sh` was updated, or it will never trigger a release. - [ ] If consumers must act to upgrade: `MIGRATIONS.md` has a section for it. -- [ ] No hand-edits to `VERSION`, `CHANGELOG.md` or `scripts/baselines/`. +- [ ] No hand-edits to `VERSION`, `CHANGELOG.md` or `scripts/baselines/.sha256`. - [ ] If a new agent: redirect file added under `core/`, both deploy scripts updated, README table updated. - [ ] British English, kebab-case, prescriptive language. - [ ] No engagement artefacts or generated deploy outputs in the diff. diff --git a/MIGRATIONS.md b/MIGRATIONS.md index da3b39a..67f474e 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -56,7 +56,7 @@ Run the migration from the root of the repository that has the deployment: The migration: -1. Compares every deployed file against the published `1.0.0` baseline, so it +1. Compares every deployed file against the `unversioned` baseline, so it can tell a file you edited from one you never touched. 2. Promotes each edited file into `.context/overrides/`, preserving your content and marking it `mode: replace`. @@ -83,8 +83,11 @@ judgements for you: addition rather than a contradiction, `extend` keeps the library version and appends yours. See `.context/overrides/README.md`. -The migration compares against the `1.0.0` baseline by default, which hashes the -content this library shipped at the point versioning was introduced. If you +The migration compares against the `unversioned` baseline by default, which +hashes the content this library shipped immediately before versioning was +introduced - that is, what you deployed. It is deliberately distinct from the +per-release `.sha256` baselines, which record what each tagged release +shipped. If you deployed from an older commit than that, some files will be reported as edited when you never touched them — promote only the ones you recognise, or pass `--baseline` to point at a baseline file you generated yourself from the commit diff --git a/README.md b/README.md index 9d65044..68514d3 100644 --- a/README.md +++ b/README.md @@ -116,17 +116,21 @@ standards/ Tier 3 — reference (→ target .contex terraform.md HCL file layout, modules, tflint, Terratest ado-pipelines.md YAML triggers, templates, environments, approvals docker.md Multi-stage builds, layer optimisation, scanning + opentelemetry.md OpenTelemetry semantic conventions and SDK usage + opentelemetry-dotnet.md OpenTelemetry instrumentation for .NET + playwright.md End-to-end browser testing with Playwright playbooks/ Tier 2 — on demand (→ target .context/playbooks/) - assess/ Structured codebase-level assessments (14) + assess/ Structured codebase-level assessments (21) accessibility.md, api-design.md, architecture.md, aws-well-architected.md, - azure-well-architected.md, code-quality.md, compliance.md, full.md, - iac.md, observability.md, performance.md, security.md, tech-debt.md, - test-coverage.md - review/ PR-level and change-level reviews (10) + azure-well-architected.md, ci-cd.md, code-quality.md, cost-optimisation.md, + domain-security.md, full.md, gdpr.md, iac.md, observability.md, + operational-excellence.md, pci-dss.md, pen-test.md, performance.md, + resilience.md, security.md, tech-debt.md, testing.md + review/ PR-level and change-level reviews (11) accessibility.md, api-design.md, architecture.md, code-quality.md, - compliance.md, iac.md, observability.md, performance.md, - security.md, test-coverage.md + compliance.md, cost-optimisation.md, iac.md, observability.md, + performance.md, security.md, testing.md plan/ Design and decision documents (5) adr.md, design-doc.md, research.md, risk-assessment.md, spike.md refactor/ Structured code change procedures (3) @@ -135,12 +139,11 @@ playbooks/ Tier 2 — on demand (→ target .contex scientific-debugging.md docs/ Developer-facing documentation generation (1) gitbook.md - setup/ Setup and tooling playbooks (4) + setup/ Setup and tooling playbooks (3) create-local-otel-stack.md create-local-otel-stack/ (companion scripts and configs — deployed alongside the playbook) discover-local-otel-stack.md use-local-otel-stack.md - instrument-dotnet-otel.md scripts/ Distribution tooling (not deployed to targets) deploy.sh / deploy.ps1 First-time install into a target repository @@ -148,7 +151,8 @@ scripts/ Distribution tooling (not deployed to ta migrate.sh / migrate.ps1 One-off upgrade for unversioned deployments lib/ Shared helpers (SemVer, manifest, hashing) ci/ Release automation helpers (version, changelog, baseline) - baselines/.sha256 Per-release hashes of deployable files + baselines/.sha256 Per-release hashes of deployable files (written by CI) + baselines/unversioned.sha256 Hashes of the pre-versioning content, for migrate deploy.Tests.ps1 Pester unit tests tests/ End-to-end test suites (bash and PowerShell) diff --git a/core/.context/index.md b/core/.context/index.md index 684262a..619ad1d 100644 --- a/core/.context/index.md +++ b/core/.context/index.md @@ -63,17 +63,22 @@ managed by the framework and are replaced wholesale on update. Never edit them; | assess architecture, architecture review, well-architected | `.context/playbooks/assess/architecture.md` | Well-Architected Framework assessment | | assess AWS, AWS well-architected, AWS cloud review | `.context/playbooks/assess/aws-well-architected.md` | AWS Well-Architected Framework assessment (6 pillars) | | assess Azure, Azure well-architected, Azure cloud review | `.context/playbooks/assess/azure-well-architected.md` | Azure Well-Architected Framework assessment (5 pillars) | +| assess ci-cd, pipeline audit, build assessment, deployment review | `.context/playbooks/assess/ci-cd.md` | CI/CD pipeline and delivery assessment | | assess code quality, SOLID assessment, clean code audit | `.context/playbooks/assess/code-quality.md` | SOLID and Clean Code assessment | -| assess compliance, GDPR audit, PCI audit, regulatory | `.context/playbooks/assess/compliance.md` | GDPR and PCI-DSS compliance assessment | +| assess compliance, GDPR audit, regulatory | `.context/playbooks/assess/gdpr.md` | GDPR data protection assessment | +| assess PCI audit, card data, PCI-DSS | `.context/playbooks/assess/pci-dss.md` | PCI-DSS payment card data assessment | | full assessment, comprehensive assessment, health check | `.context/playbooks/assess/full.md` | Single-pass assessment across all domains | +| assess cost, finops audit, cost review, resource optimisation | `.context/playbooks/assess/cost-optimisation.md` | Cloud cost and resource efficiency assessment | | assess IaC, infrastructure assessment, terraform audit | `.context/playbooks/assess/iac.md` | Infrastructure as Code maturity assessment | | assess observability, monitoring audit, logging assessment | `.context/playbooks/assess/observability.md` | Observability maturity assessment | +| assess operations, operational audit, ops review, platform assessment | `.context/playbooks/assess/operational-excellence.md` | Operational excellence and platform maturity assessment | | assess performance, performance audit, scalability review | `.context/playbooks/assess/performance.md` | Performance and resilience assessment | +| assess resilience, fault tolerance audit, reliability review, chaos readiness | `.context/playbooks/assess/resilience.md` | Fault tolerance, reliability and chaos readiness assessment | | assess security, security audit, threat model, owasp top 10 | `.context/playbooks/assess/security.md` | OWASP Top 10 security assessment (lighter-touch triage; for a full CREST engagement see `pen-test.md` below) | | assess domain security, DNS audit, subdomain takeover, security headers, HSTS, CSP, WAF, Cloudflare | `.context/playbooks/assess/domain-security.md` | External domain and attack-surface security assessment | | crest pen test, penetration test, red team, ethical hacking, bug bounty, exploit chain | `.context/playbooks/assess/pen-test.md` | CREST-aligned penetration test across web, API, infra, cloud, identity, mobile, wireless, social-policy with authorisation gating and non-destructive PoC | | assess tech debt, debt inventory, code health | `.context/playbooks/assess/tech-debt.md` | Technical debt identification and prioritisation | -| assess test coverage, test audit, testing assessment | `.context/playbooks/assess/test-coverage.md` | Testing strategy and coverage assessment | +| assess test coverage, test audit, testing assessment | `.context/playbooks/assess/testing.md` | Testing strategy and coverage assessment | --- @@ -91,7 +96,7 @@ managed by the framework and are replaced wholesale on update. Never edit them; | review cost, FinOps audit, cost review, resource optimisation | `.context/playbooks/review/cost-optimisation.md` | API economy, dependencies, observability spend | | review performance, performance review | `.context/playbooks/review/performance.md` | Query efficiency, caching, resource disposal | | review security, security review | `.context/playbooks/review/security.md` | OWASP, secrets, injection vectors | -| review test coverage, test review | `.context/playbooks/review/test-coverage.md` | Test quality, coverage gaps, coupling | +| review test coverage, test review | `.context/playbooks/review/testing.md` | Test quality, coverage gaps, coupling | --- diff --git a/scripts/baselines/1.0.0.sha256 b/scripts/baselines/unversioned.sha256 similarity index 100% rename from scripts/baselines/1.0.0.sha256 rename to scripts/baselines/unversioned.sha256 diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 index 6596c36..7a9006e 100644 --- a/scripts/migrate.ps1 +++ b/scripts/migrate.ps1 @@ -16,7 +16,7 @@ param( [Parameter(Position = 0)] [string]$Target = '', [switch]$Apply, - [string]$From = '1.0.0', + [string]$From = 'unversioned', [string]$Baseline = '' ) diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 2cca835..067cc22 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -26,7 +26,7 @@ fi APPLY=0 TARGET="" -FROM_VERSION="1.0.0" +FROM_VERSION="unversioned" BASELINE="" usage() { @@ -36,7 +36,8 @@ Usage: migrate.sh [--apply] [--from ] [--baseline ] [target-repo] Upgrade an unversioned deployment to the override model. --apply Write changes. Without it, reports what would happen and exits. - --from Version the target was deployed from. Default: 1.0.0 + --from Content the target was deployed from. Default: unversioned + (the pre-versioning content). Otherwise a published version. --baseline Baseline hash file. Default: scripts/baselines/.sha256 target-repo Repository to migrate. Default: current directory. From 8317ba4448068b9198df18fbb2a17fc5f5285f15 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 19:36:48 +0100 Subject: [PATCH 11/25] docs(readme): correct baseline ownership claim The README stated that all of scripts/baselines/ is written by the release workflow. That stopped being true when the migration baseline was separated from the per-release baselines: scripts/baselines/unversioned.sha256 records the pre-versioning content for migrate.sh and is committed by hand, and CI cannot write it because write-baseline.sh accepts only valid SemVer. AGENTS.md was corrected at the time; the README was missed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 68514d3..f5d412b 100644 --- a/README.md +++ b/README.md @@ -293,9 +293,12 @@ The title sets the size of the bump, never whether one happens: a deployable change is released regardless of how it was labelled, so mislabelling can understate a release but can never lose it. -`VERSION`, `CHANGELOG.md` and `scripts/baselines/` are written by the release -workflow. Pull requests that edit them by hand are rejected by the version gate, -which also reports the exact version a merge will cut before it is merged. +`VERSION`, `CHANGELOG.md` and `scripts/baselines/.sha256` are written by +the release workflow. Pull requests that edit them by hand are rejected by the +version gate, which also reports the exact version a merge will cut before it is +merged. The one baseline CI does not own is +`scripts/baselines/unversioned.sha256`, which records the pre-versioning content +for `migrate.sh` and is committed by hand. ## Staying Current From 4124f1161ee7d19f60bcd89f882ab71cd1a4927a Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 20:48:59 +0100 Subject: [PATCH 12/25] fix(update,ci): close data-loss and version-derivation defects from review Actions the review comments on pull request #31. Data loss and correctness in consumer repositories: - update.sh guarded the managed block on the begin marker alone, so a file with a begin marker and no end marker had everything from that marker to EOF replaced - destroying the consumer configuration the block exists to protect. deploy.sh and update.ps1 both required the pair, so this was also a parity break. The markers and the well-formed test now live in scripts/lib/common.sh and are shared, so the two cannot drift again. - migrate.sh compared byte hashes against an LF baseline, so a Windows checkout with core.autocrlf=true saw every file as edited and promoted all of them to "mode: replace" overrides, silently converting a pristine deployment into a permanent fork. Hashing is now LF-normalised, and an all-files-differ result is refused outright as a systemic mismatch. - migrate.sh restored each area with rm -rf but only classified *.md, so a consumer's non-markdown file was destroyed without ever being offered as an override. Such files are now preserved; framework companions are restored. The migration also states its destructive behaviour before it runs. - update.sh refreshed common.sh but not common.ps1, so any deployment last updated from bash shipped a stale PowerShell library to its Windows users forever. - Both updaters reset "pin" to the new version's major line on every apply, discarding a deliberate consumer choice. The pin is now preserved. - update.sh deleted each area before writing its replacement without checking any result, so a failed extract left a half-deleted .context/ and exited zero while PowerShell stopped cleanly. The payload is now validated before anything is removed, and every step is checked. - migrate.sh runs under set -e; it has no fail-open requirement. Version derivation: - The breaking-change match was unanchored, so "fix: cleanup foo!: bar" cut a major and orphaned every pinned consumer. "feat*" likewise matched "feature flags:". Both are now anchored to the leading type token. - BREAKING CHANGE was only ever read from the subject, but Conventional Commits puts it in the footer, so a breaking change shipped as a patch. The release job now passes the full message, and the gate rejects a description declaring a breaking change when the title omits "!". - The release job warns when the merge subject is not a Conventional Commit rather than silently falling back to a patch, since the bump depends on squash-merge being enabled. CI: - The version gate uses a three-dot diff, so commits landing on main while a pull request is open are no longer attributed to it. - The gate allows scripts/baselines/unversioned.sha256, which AGENTS.md documents as hand-committed and which migrate correctness depends on. - A new check asserts that everything the deploy scripts ship is matched by is_deployable, and that non-deployable paths are not. - write-changelog.sh no longer lists breaking commits twice, and groups build/ci/chore/test/revert so a deployable script change cannot produce an empty release section. - The third-party action is pinned to a commit SHA, with Dependabot added to keep it current. Tests: - 11 new assertions covering the managed-block matrix, migrate promotion and restore, non-markdown preservation, CRLF handling and the backstop. Each was confirmed to fail when its defect is reintroduced. 92 pass, up from 81. Not actioned: the report that find -maxdepth is GNU-only. FreeBSD and macOS both document -maxdepth, so update.sh is already portable there. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 15 +++ .github/workflows/deploy-sh-tests.yml | 40 +++++++ .github/workflows/pr-title.yml | 13 ++- .github/workflows/release.yml | 25 ++++- .github/workflows/version-gate.yml | 43 ++++++-- MIGRATIONS.md | 9 +- scripts/ci/next-version.sh | 32 ++++-- scripts/ci/write-changelog.sh | 18 +++- scripts/deploy.sh | 15 +-- scripts/lib/common.ps1 | 9 +- scripts/lib/common.sh | 39 +++++++ scripts/migrate.sh | 70 +++++++++++- scripts/tests/test-deploy.sh | 148 ++++++++++++++++++++++++++ scripts/update.ps1 | 8 +- scripts/update.sh | 59 ++++++++-- 15 files changed, 489 insertions(+), 54 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b1232c9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 + +# Third-party actions are pinned to full commit SHAs so a retargeted tag cannot +# execute in our workflows. Dependabot is what keeps those pins current, so the +# hardening does not decay into running abandoned versions. +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + commit-message: + # Workflow files are not deployable, so this never cuts a release. + prefix: ci + labels: + - dependencies diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml index 0d7860b..c1aabde 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -79,6 +79,46 @@ jobs: done exit $status + - name: Deployable paths cover everything that ships + run: | + # is_deployable in next-version.sh decides whether a change cuts a + # release. If a file reaches consumers but is not listed there, the + # change silently never releases and the omission is only discovered + # when a consumer notices missing content. A rule this load-bearing + # must not depend on remembering a checklist item. + set -uo pipefail + status=0 + # Everything the deploy scripts copy into a target repository. + shipped="core standards playbooks + scripts/deploy.sh scripts/deploy.ps1 + scripts/update.sh scripts/update.ps1 + scripts/migrate.sh scripts/migrate.ps1 + scripts/lib/common.sh scripts/lib/common.ps1" + for path in $shipped; do + if [ -d "$path" ]; then + probe="$path/__probe__.md" + else + probe="$path" + fi + printf '%s\n' "$probe" > /tmp/probe.txt + result="$(scripts/ci/next-version.sh --changed-files /tmp/probe.txt --subject 'fix: probe' --latest-tag v1.0.0)" + if [ "$result" = "none" ]; then + echo "::error file=scripts/ci/next-version.sh::'$probe' is deployed to consumers but is_deployable does not match it, so changing it would never cut a release." + status=1 + fi + done + # And the inverse: non-deployable paths must not trigger releases. + for path in README.md AGENTS.md .github/workflows/release.yml scripts/ci/next-version.sh scripts/tests/test-deploy.sh; do + printf '%s\n' "$path" > /tmp/probe.txt + result="$(scripts/ci/next-version.sh --changed-files /tmp/probe.txt --subject 'fix: probe' --latest-tag v1.0.0)" + if [ "$result" != "none" ]; then + echo "::error file=scripts/ci/next-version.sh::'$path' never reaches a consumer but is_deployable matches it, so documentation and CI changes would cut releases." + status=1 + fi + done + [ "$status" -eq 0 ] && echo "OK: deployable paths and is_deployable agree." + exit $status + test: name: ${{ matrix.name }} strategy: diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index ce5db03..360d430 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -20,10 +20,21 @@ jobs: name: Conventional Commit title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + # Pinned to a full commit SHA, not a mutable tag: a compromised or + # retargeted "v5" tag would otherwise execute here. Dependabot raises the + # upgrade, so pinning costs nothing in currency. + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: + # This list is PR hygiene, not the bump decision. It keeps changelog + # groups meaningful by rejecting invented types before merge. + # + # next-version.sh deliberately does NOT share this list: it applies a + # patch floor to any unrecognised type, so a deployable change that + # reaches main by some route this lint never saw - a direct push, or + # a non-squash merge - still cuts a release instead of being lost. + # The lint prevents unknown types; the floor makes them harmless. types: | feat fix diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86d1598..bb1622a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,9 +57,32 @@ jobs: run: | set -euo pipefail subject="$(git log -1 --pretty=format:'%s')" + # Pass the whole message, not just the subject: Conventional Commits + # puts BREAKING CHANGE in the footer, so a subject-only read ships a + # breaking change as a patch to consumers pinned to a major line. + git log -1 --pretty=format:'%B' > /tmp/commit-body.txt current="$(tr -d ' \t\r\n' < VERSION)" prev_tag="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)" - next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject" --latest-tag "$prev_tag")" + + # The bump size is read from the merge subject, which equals the PR + # title only under squash-merge. Under a merge commit the subject is + # "Merge pull request #..." and under rebase-merge it is the last + # commit's subject, so pr-title.yml would be validating a string that + # never reaches this job. Warn loudly rather than silently defaulting + # to a patch, which would understate a feat or a breaking change. + if ! printf '%s' "$subject" | grep -Eq '^[a-zA-Z]+(\([^)]*\))?!?:'; then + echo "::warning::Merge subject '$subject' is not a Conventional Commit. \ + The repository must use squash-merge so the PR title becomes the merge subject; \ + falling back to the patch floor." + { + echo "> **Merge subject is not a Conventional Commit.**" + echo "> \`$subject\`" + echo ">" + echo "> Bump size fell back to the patch floor. Enable squash-merge only." + } >> "$GITHUB_STEP_SUMMARY" + fi + + next="$(scripts/ci/next-version.sh --changed-files /tmp/changed-files.txt --subject "$subject" --body-file /tmp/commit-body.txt --latest-tag "$prev_tag")" echo "current=$current" >> "$GITHUB_OUTPUT" echo "next=$next" >> "$GITHUB_OUTPUT" echo "prev_tag=$prev_tag" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml index 7009b58..2d4ebb4 100644 --- a/.github/workflows/version-gate.yml +++ b/.github/workflows/version-gate.yml @@ -34,7 +34,12 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail - git diff --name-only "$BASE_SHA" "$HEAD_SHA" > /tmp/changed-files.txt + # Three-dot: compare against the merge base, not the tip of main. + # Two-dot would attribute commits that landed on main while this pull + # request was open - a release commit bumping VERSION, say - to this + # pull request, producing a false "hand-edit to a CI-owned file" + # rejection and a wrong changed-file set for the version computation. + git diff --name-only "$BASE_SHA...$HEAD_SHA" > /tmp/changed-files.txt echo "Changed files:" sed 's/^/ /' /tmp/changed-files.txt @@ -65,12 +70,18 @@ jobs: fi # Baselines are published per release so migrate can tell a pristine - # file from an edited one. A hand-written baseline would silently - # corrupt every future migration, so once any baseline exists only - # the release job may add or change one. - if grep -q '^scripts/baselines/' /tmp/changed-files.txt; then + # file from an edited one. A hand-written per-release baseline would + # silently corrupt every future migration, so once any baseline + # exists only the release job may add or change one. + # + # scripts/baselines/unversioned.sha256 is the documented exception: + # it records the pre-versioning content that adopters actually + # deployed, CI can never write it (write-baseline.sh takes only valid + # SemVer), and migrate correctness depends on it being correctable. + if grep '^scripts/baselines/' /tmp/changed-files.txt \ + | grep -qv '^scripts/baselines/unversioned\.sha256$'; then if git ls-tree -r --name-only "$BASE_SHA" -- scripts/baselines | grep -q .; then - echo "::error::scripts/baselines/ is published by the release workflow. Do not add or edit baseline files by hand." + echo "::error::scripts/baselines/.sha256 is published by the release workflow. Do not add or edit per-release baseline files by hand." fail=1 fi fi @@ -80,6 +91,26 @@ jobs: fi exit "$fail" + - name: Require the breaking-change marker in the title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -euo pipefail + # The release job reads BREAKING CHANGE from the full commit message, + # but this gate only ever sees the pull request title. A body that + # declares a breaking change while the title omits "!" would compute + # one version here and a different one on merge, and would ship a + # breaking change to consumers who pinned a major line precisely so + # that cannot happen. Require the marker in the title instead. + if printf '%s' "$PR_BODY" | grep -Eq '(^|[^[:alnum:]])BREAKING[ -]CHANGE'; then + if ! printf '%s' "$PR_TITLE" | grep -Eq '^[a-zA-Z]+(\([^)]*\))?!:'; then + echo "::error::The description declares a BREAKING CHANGE but the title has no '!' marker. Retitle as 'type!: ...' so the release cuts a major." + exit 1 + fi + fi + echo "OK: breaking-change marking is consistent." + - name: Compute the version this merge will cut id: next env: diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 67f474e..1d728cd 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -33,9 +33,12 @@ and no conflicts. Because overrides sit outside it, your customisations survive untouched. Divergence becomes structurally impossible rather than something you have to detect and reconcile. -Your `AGENTS.md` gains a managed block delimited by -`` and ``. Only that -block is rewritten on update; everything above and below it is yours. +Your `AGENTS.md` gains a managed block. The start marker carries the version it +was written from, so it looks like ``, and +the block ends with ``. Both markers are required: +tooling that finds a begin marker without a matching end marker treats the file +as unmanaged and leaves it alone rather than guessing where the block stops. +Only that block is rewritten on update; everything above and below it is yours. ### Migrating diff --git a/scripts/ci/next-version.sh b/scripts/ci/next-version.sh index b194782..05aea09 100755 --- a/scripts/ci/next-version.sh +++ b/scripts/ci/next-version.sh @@ -28,6 +28,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" CHANGED_FILES="" SUBJECT="" +BODY="" +BODY_FILE="" CURRENT="" LATEST_TAG="" LATEST_TAG_SET=0 @@ -36,12 +38,19 @@ while [ $# -gt 0 ]; do case "$1" in --changed-files) CHANGED_FILES="${2:-}"; shift 2 ;; --subject) SUBJECT="${2:-}"; shift 2 ;; + --body) BODY="${2:-}"; shift 2 ;; + --body-file) BODY_FILE="${2:-}"; shift 2 ;; --current) CURRENT="${2:-}"; shift 2 ;; --latest-tag) LATEST_TAG="${2:-}"; LATEST_TAG_SET=1; shift 2 ;; *) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;; esac done +if [ -n "$BODY_FILE" ]; then + [ -f "$BODY_FILE" ] || { echo "ERROR: no such file: $BODY_FILE" >&2; exit 2; } + BODY="$(cat "$BODY_FILE")" +fi + [ -n "$CHANGED_FILES" ] || { echo "ERROR: --changed-files is required." >&2; exit 2; } [ -f "$CHANGED_FILES" ] || { echo "ERROR: no such file: $CHANGED_FILES" >&2; exit 2; } @@ -98,15 +107,22 @@ fi # deployable change is a release regardless of how its author labelled it. An # unrecognised type therefore falls through to the patch floor rather than # blocking the release. +# Every pattern below is anchored to the leading type token. An unanchored +# match reads incidental prose as intent: "fix: cleanup foo!: bar" is a patch, +# not a major, and "feature flags: add toggle" is not a feat. BUMP="patch" -case "$SUBJECT" in - feat*) BUMP="minor" ;; -esac -case "$SUBJECT" in - # "type!: subject" and "type(scope)!: subject" both mark a breaking change. - *"!:"*) BUMP="major" ;; -esac -if printf '%s' "$SUBJECT" | grep -q 'BREAKING[ -]CHANGE'; then +if printf '%s' "$SUBJECT" | grep -Eq '^feat(\([^)]*\))?!?:'; then + BUMP="minor" +fi +# "type!: subject" and "type(scope)!: subject" both mark a breaking change. +if printf '%s' "$SUBJECT" | grep -Eq '^[a-zA-Z]+(\([^)]*\))?!:'; then + BUMP="major" +fi +# A BREAKING CHANGE footer is authoritative wherever it appears in the message. +# The release job passes the full commit body precisely so this can be seen; +# the gate only ever has the title, which is why it separately rejects a body +# that declares a breaking change without the "!" marker in the title. +if printf '%s' "$BODY" | grep -Eq '(^|[^[:alnum:]])BREAKING[ -]CHANGE'; then BUMP="major" fi diff --git a/scripts/ci/write-changelog.sh b/scripts/ci/write-changelog.sh index 79c710d..efe84d5 100755 --- a/scripts/ci/write-changelog.sh +++ b/scripts/ci/write-changelog.sh @@ -62,11 +62,21 @@ if [ -n "$BREAKING" ]; then echo "" >> "$SECTION" fi -emit_group '^feat(\([^)]*\))?!?: ' 'Features' -emit_group '^fix(\([^)]*\))?!?: ' 'Fixes' -emit_group '^(docs|refactor|perf|style)(\([^)]*\))?!?: ' 'Other changes' +# These patterns deliberately exclude "!": a breaking commit is already listed +# under "Breaking changes" above, and matching it again here would print it +# twice in the same release section. +emit_group '^feat(\([^)]*\))?: ' 'Features' +emit_group '^fix(\([^)]*\))?: ' 'Fixes' +emit_group '^(docs|refactor|perf|style)(\([^)]*\))?: ' 'Other changes' +# build/ci/chore/test/revert are grouped rather than dropped. Editing a deploy +# script under "chore:" is deployable and cuts a release, so without this the +# release notes for a real change to shipped tooling would be empty. +emit_group '^(build|ci|chore|test|revert)(\([^)]*\))?: ' 'Maintenance' -if [ ! -s "$RAW" ]; then +# Fall back when nothing was written, not merely when there were no commits at +# all: a release whose commits all fell outside every group above would +# otherwise publish a heading with no content under it. +if [ "$(wc -l < "$SECTION" | tr -d ' ')" -le 2 ]; then echo "- No user-facing changes recorded." >> "$SECTION" echo "" >> "$SECTION" fi diff --git a/scripts/deploy.sh b/scripts/deploy.sh index ff97c3e..c0d5852 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -171,17 +171,8 @@ copy_dir_contents() { # framework owns only the region between the begin/end markers. Rewriting just # that region is the only safe way to refresh a file we do not own. -AC_BEGIN_MARKER='' - -# Return 0 when the file contains a well-formed managed block. -has_managed_block() { - local file="$1" - [ -f "$file" ] || return 1 - grep -q "^$AC_BEGIN_MARKER" "$file" 2>/dev/null || return 1 - grep -qF "$AC_END_MARKER" "$file" 2>/dev/null || return 1 - return 0 -} +# The markers and the well-formed-block test live in scripts/lib/common.sh so +# deploy and update cannot disagree about what counts as a managed block. # Print the managed block (markers included) from the source template. extract_managed_block() { @@ -233,7 +224,7 @@ deploy_agents_md() { return 0 fi - if has_managed_block "$dst"; then + if ac_has_managed_block "$dst"; then replace_managed_block "$src" "$dst" "$version" echo " AGENTS.md: refreshed managed block (your content preserved)" return 0 diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 index 406d5c2..66e7200 100644 --- a/scripts/lib/common.ps1 +++ b/scripts/lib/common.ps1 @@ -212,7 +212,12 @@ function Write-AcManifest { [Parameter(Mandatory)][string]$Version, [string[]]$Agents = @(), [string]$SourceRepo = $script:AcSourceRepo, - [string]$CheckFrequency = 'weekly' + [string]$CheckFrequency = 'weekly', + # The pin is consumer configuration. Callers updating an existing + # deployment must pass the pin already recorded there, or a deliberate + # choice ("*" to accept majors, or an exact version to freeze) is + # silently reset to the new version's major line on every apply. + [string]$Pin = '' ) if (-not (Test-Path -LiteralPath $ContextDir)) { @@ -227,7 +232,7 @@ function Write-AcManifest { schema = 1 version = $Version source = $SourceRepo - pin = "$(Get-AcSemVerPart $Version 'Major').x" + pin = $(if ([string]::IsNullOrWhiteSpace($Pin)) { "$(Get-AcSemVerPart $Version 'Major').x" } else { $Pin }) checkFrequency = $CheckFrequency deployedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') agents = @($Agents) diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 7fa9691..a67eae1 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -216,9 +216,48 @@ ac_hash_source_tree() { } | LC_ALL=C sort } +# Hash a text file with line endings normalised to LF. +# +# Baselines are generated on LF checkouts but compared against a consumer's +# working tree. A Windows checkout with core.autocrlf=true has CRLF in every +# file, so a byte hash would report every framework file as modified when the +# consumer has changed nothing. Only used for cross-machine comparison; the +# manifest keeps byte-exact hashes, which are always written and read on the +# same machine. +ac_sha256_lf() { + local file="$1" + if command -v shasum >/dev/null 2>&1; then + tr -d '\r' < "$file" | shasum -a 256 | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + tr -d '\r' < "$file" | sha256sum | awk '{print $1}' + else + echo "ERROR: neither shasum nor sha256sum is available" >&2 + return 1 + fi +} + # Look up one path's expected hash in a baseline file. ac_baseline_lookup() { local baseline="$1" path="$2" [ -f "$baseline" ] || return 1 awk -v p="$path" '$1 == p { print $2; found = 1; exit } END { exit !found }' "$baseline" } + +# --- managed block --------------------------------------------------------- +# +# The consumer owns AGENTS.md; the framework owns only the region between these +# markers. Both markers are required. A begin marker with no end marker is +# malformed, and treating it as a block would swallow every line to EOF - which +# in AGENTS.md is the consumer's own configuration. +AC_BEGIN_MARKER='' + +# Return 0 only when the file contains a well-formed (begin AND end) block. +# Mirrors Test-AcManagedBlock in common.ps1; the two must stay equivalent. +ac_has_managed_block() { + local file="$1" + [ -f "$file" ] || return 1 + grep -q "^$AC_BEGIN_MARKER" "$file" 2>/dev/null || return 1 + grep -qF "$AC_END_MARKER" "$file" 2>/dev/null || return 1 + return 0 +} diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 067cc22..32449f4 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -12,7 +12,11 @@ # # Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. -set -uo pipefail +# Exit on first error. This script rewrites files in a repository it did not +# create, so a failed copy must stop the run rather than let it complete with a +# partially migrated tree. Unlike update.sh there is no network call here, so +# there is nothing that needs to fail open. +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -119,14 +123,18 @@ echo "" DIVERGED_LIST="$(mktemp)" MISSING_LIST="$(mktemp)" -cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST"; } +TOTAL_LIST="$(mktemp)" +NONMD_LIST="$(mktemp)" +cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST" "$TOTAL_LIST" "$NONMD_LIST"; } trap cleanup EXIT INT TERM for area in standards playbooks conventions; do [ -d "$CONTEXT_DIR/$area" ] || continue find "$CONTEXT_DIR/$area" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do rel="$area/${f#"$CONTEXT_DIR"/"$area"/}" - actual="$(ac_sha256 "$f")" + printf '%s\n' "$rel" >> "$TOTAL_LIST" + # LF-normalised: a CRLF checkout must not make every file look edited. + actual="$(ac_sha256_lf "$f")" if expected="$(ac_baseline_lookup "$BASELINE" "$rel")"; then if [ "$expected" != "$actual" ]; then printf '%s\n' "$rel" >> "$DIVERGED_LIST" @@ -138,10 +146,40 @@ for area in standards playbooks conventions; do done done +# Non-markdown files. The baseline only covers .md, but the restore below +# replaces each area wholesale, so anything else here is destroyed unless it is +# recognised. A file that also exists in the source tree is a framework +# companion (playbooks/setup ships shell scripts) and is restored intact; one +# that does not is the consumer's own and must be preserved as an override. +for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks" "conventions:$SOURCE_ROOT/core/.context/conventions"; do + area="${pair%%:*}" + from="${pair#*:}" + [ -d "$CONTEXT_DIR/$area" ] || continue + find "$CONTEXT_DIR/$area" -type f ! -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + rel="${f#"$CONTEXT_DIR"/"$area"/}" + [ -f "$from/$rel" ] || printf '%s/%s\n' "$area" "$rel" >> "$NONMD_LIST" + done +done + diverged_count=$(wc -l < "$DIVERGED_LIST" | tr -d ' ') added_count=$(wc -l < "$MISSING_LIST" | tr -d ' ') +nonmd_count=$(wc -l < "$NONMD_LIST" | tr -d ' ') +total_count=$(wc -l < "$TOTAL_LIST" | tr -d ' ') + +# Backstop. Every single file differing is not a real editing pattern - it is +# the signature of a systemic mismatch (wrong baseline, or an encoding or +# line-ending transform). Promoting them all would turn a pristine deployment +# into a total fork, pinning every file with "mode: replace" so no upstream +# improvement ever reaches it again. Refuse rather than do that silently. +if [ "$total_count" -gt 1 ] && [ "$diverged_count" -eq "$total_count" ]; then + echo "ERROR: every one of the $total_count base files differs from the baseline." >&2 + echo " That is a systemic mismatch, not consumer edits - check the baseline" >&2 + echo " version (--from) and that the checkout has not rewritten line endings." >&2 + echo " Refusing to promote every file into overrides." >&2 + exit 1 +fi -if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ]; then +if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ] && [ "$nonmd_count" -eq 0 ]; then echo "No local modifications detected — this deployment is pristine." else if [ "$diverged_count" -gt 0 ]; then @@ -160,8 +198,22 @@ else done < "$MISSING_LIST" echo "" fi + if [ "$nonmd_count" -gt 0 ]; then + echo "Non-markdown files you added ($nonmd_count) — will move to overrides:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel -> .context/overrides/$rel" + done < "$NONMD_LIST" + echo "" + fi fi +# State the destructive behaviour before it happens, not after. +echo "This migration replaces .context/{standards,playbooks,conventions} wholesale." +echo "Anything listed above is preserved under .context/overrides/. A base file you" +echo "deleted is restored, because the base is owned by the library, not by you." +echo "" + if [ "$APPLY" -eq 0 ]; then echo "Nothing written. Re-run with --apply to perform the migration." exit 0 @@ -207,6 +259,16 @@ while IFS= read -r rel; do rm -f "$CONTEXT_DIR/$rel" done < "$MISSING_LIST" +# Consumer-owned non-markdown files: copied verbatim, with no frontmatter - +# they are not markdown, so a YAML header would corrupt them. +while IFS= read -r rel; do + [ -n "$rel" ] || continue + dst="$CONTEXT_DIR/overrides/$rel" + mkdir -p "$(dirname "$dst")" + cat "$CONTEXT_DIR/$rel" > "$dst" + rm -f "$CONTEXT_DIR/$rel" +done < "$NONMD_LIST" + echo "Restoring base content from $SOURCE_ROOT ..." for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks" "conventions:$SOURCE_ROOT/core/.context/conventions"; do name="${pair%%:*}" diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index a524e2c..33cc83a 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -482,6 +482,154 @@ fi rm -f "$TC9_LIST" +# ═══════════════════════════════════════════════════════════════════════ +# TC10: managed block safety +# +# These cover the destructive rewrite of a file the framework does not own. +# A begin marker with no end marker must NOT be treated as a block: the +# rewrite would otherwise swallow every line to EOF, destroying the consumer +# configuration the block exists to protect. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC10: managed block detection ===" + +# shellcheck source=scripts/lib/common.sh +. "$SCRIPTS_DIR/lib/common.sh" + +TC10_DIR=$(mktemp -d) + +printf '\nF\n\n\n## MINE\nkeep\n' > "$TC10_DIR/well-formed.md" +printf '\nF\n\n## MINE\nkeep\n' > "$TC10_DIR/no-end.md" +printf '## MINE\nkeep\n' > "$TC10_DIR/no-block.md" +printf '\r\nF\r\n\r\n' > "$TC10_DIR/crlf.md" + +if ac_has_managed_block "$TC10_DIR/well-formed.md"; then + pass "managed block: well-formed file is recognised" +else + fail "managed block: well-formed file was not recognised" +fi + +if ac_has_managed_block "$TC10_DIR/no-end.md"; then + fail "managed block: begin-without-end was treated as a block (would truncate consumer content)" +else + pass "managed block: begin-without-end is rejected" +fi + +if ac_has_managed_block "$TC10_DIR/no-block.md"; then + fail "managed block: a file with no markers was treated as a block" +else + pass "managed block: file with no markers is rejected" +fi + +if ac_has_managed_block "$TC10_DIR/missing-entirely.md"; then + fail "managed block: a missing file was treated as a block" +else + pass "managed block: missing file is rejected" +fi + +rm -rf "$TC10_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC11: migrate promotes divergence and restores the base +# +# migrate rewrites a repository it did not create, so its behaviour is +# asserted rather than assumed: edits become overrides, consumer additions +# survive, and base content is restored pristine. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC11: migrate ===" + +TC11_DIR=$(mktemp -d) +( + cd "$TC11_DIR" && git init -q . && git config user.email t@t && git config user.name t +) +mkdir -p "$TC11_DIR/.context/standards" "$TC11_DIR/.context/conventions" +cp "$REPO_DIR/standards/security.md" "$TC11_DIR/.context/standards/" +cp "$REPO_DIR/core/.context/conventions/code.md" "$TC11_DIR/.context/conventions/" +echo "MY LOCAL EDIT" >> "$TC11_DIR/.context/standards/security.md" +echo "mine" > "$TC11_DIR/.context/standards/my-own.md" +printf '{"k":1}\n' > "$TC11_DIR/.context/standards/fixture.json" +(cd "$TC11_DIR" && git add -A >/dev/null 2>&1 && git commit -qm init >/dev/null 2>&1) + +TC11_OUT="$("$SCRIPTS_DIR/migrate.sh" --apply "$TC11_DIR" 2>&1)" || true + +if [ -f "$TC11_DIR/.context/overrides/standards/security.md" ] \ + && grep -q 'MY LOCAL EDIT' "$TC11_DIR/.context/overrides/standards/security.md"; then + pass "migrate: edited base file promoted to overrides with content intact" +else + fail "migrate: edited base file was not promoted (output: $TC11_OUT)" +fi + +if grep -q 'mode: replace' "$TC11_DIR/.context/overrides/standards/security.md" 2>/dev/null; then + pass "migrate: promoted override carries mode: replace frontmatter" +else + fail "migrate: promoted override is missing frontmatter" +fi + +if [ -f "$TC11_DIR/.context/overrides/standards/my-own.md" ]; then + pass "migrate: consumer-added markdown preserved" +else + fail "migrate: consumer-added markdown was lost" +fi + +# Regression: the restore wipes each area wholesale, so a non-markdown file the +# consumer added must be moved out first or it is destroyed silently. +if [ -f "$TC11_DIR/.context/overrides/standards/fixture.json" ]; then + pass "migrate: consumer-added non-markdown preserved" +else + fail "migrate: consumer-added non-markdown was destroyed by the restore" +fi + +if [ -f "$TC11_DIR/.context/standards/security.md" ] \ + && ! grep -q 'MY LOCAL EDIT' "$TC11_DIR/.context/standards/security.md"; then + pass "migrate: base file restored pristine" +else + fail "migrate: base file was not restored pristine" +fi + +rm -rf "$TC11_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC12: migrate refuses a systemic mismatch +# +# A CRLF checkout makes every file hash differently. Classifying all of them +# as consumer edits would silently convert a pristine deployment into a total +# fork, so line endings are normalised and an all-files-differ result is +# refused outright. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC12: migrate line endings ===" + +TC12_DIR=$(mktemp -d) +( + cd "$TC12_DIR" && git init -q . && git config user.email t@t && git config user.name t +) +mkdir -p "$TC12_DIR/.context/standards" +for f in security.md testing.md code-quality.md; do + sed 's/$/\r/' "$REPO_DIR/standards/$f" > "$TC12_DIR/.context/standards/$f" +done +(cd "$TC12_DIR" && git add -A >/dev/null 2>&1 && git commit -qm init >/dev/null 2>&1) + +TC12_OUT="$("$SCRIPTS_DIR/migrate.sh" "$TC12_DIR" 2>&1)" || true +if printf '%s' "$TC12_OUT" | grep -q 'pristine'; then + pass "migrate: CRLF checkout is not misread as wholesale divergence" +else + fail "migrate: CRLF checkout reported as diverged (output: $TC12_OUT)" +fi + +# And the backstop itself: a genuinely wrong baseline must refuse, not promote. +TC12_BAD="$(mktemp)" +printf 'standards/security.md %s\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$TC12_BAD" +printf 'standards/testing.md %s\n' "0000000000000000000000000000000000000000000000000000000000000000" >> "$TC12_BAD" +printf 'standards/code-quality.md %s\n' "0000000000000000000000000000000000000000000000000000000000000000" >> "$TC12_BAD" +if "$SCRIPTS_DIR/migrate.sh" --baseline "$TC12_BAD" "$TC12_DIR" >/dev/null 2>&1; then + fail "migrate: a baseline matching nothing was accepted (would fork every file)" +else + pass "migrate: refuses when every file differs from the baseline" +fi +rm -f "$TC12_BAD" +rm -rf "$TC12_DIR" + # ═══════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════ diff --git a/scripts/update.ps1 b/scripts/update.ps1 index eebc4f5..c487856 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -147,7 +147,9 @@ if ($mode -eq 'check') { if ($pinOk) { Write-Host "agentic-context update available: $LocalVersion -> $Latest (run .context/bin/update.ps1 -Apply)" } else { - Write-Host "agentic-context $Latest is available but outside your pin '$Pin'. See MIGRATIONS.md, then use -Force." + # Link, do not name the file: MIGRATIONS.md is not deployed into + # consumer repositories, so naming it points at something they lack. + Write-Host "agentic-context $Latest is available but outside your pin '$Pin'. See $script:AcWebBase/$SourceRepo/blob/v$Latest/MIGRATIONS.md, then use -Force." } if ($Quiet) { exit 0 } Show-AcLocalState @@ -157,7 +159,7 @@ if ($mode -eq 'check') { # --- apply ----------------------------------------------------------------- if (-not $pinOk) { - Write-Error "Refusing to update: $Latest is outside the pin '$Pin'. This is a major upgrade. Read MIGRATIONS.md, then re-run with -Force." + Write-Error "Refusing to update: $Latest is outside the pin '$Pin'. This is a major upgrade. Read $script:AcWebBase/$SourceRepo/blob/v$Latest/MIGRATIONS.md, then re-run with -Force." exit 1 } @@ -262,7 +264,7 @@ try { if ($manifest -and $manifest.agents) { $agents = @($manifest.agents) } Write-AcManifest -ContextDir $ContextDir -Version $Latest -Agents $agents ` - -SourceRepo $SourceRepo -CheckFrequency $Freq + -SourceRepo $SourceRepo -CheckFrequency $Freq -Pin $Pin Set-Content -LiteralPath (Join-Path $ContextDir 'VERSION') -Value $Latest -Encoding UTF8 diff --git a/scripts/update.sh b/scripts/update.sh index 0dc233f..f93214e 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -191,7 +191,9 @@ if [ "$MODE" = "check" ]; then if [ "$PIN_OK" -eq 1 ]; then echo "agentic-context update available: $LOCAL_VERSION -> $LATEST (run .context/bin/update.sh --apply)" else - echo "agentic-context $LATEST is available but outside your pin '$PIN'. See MIGRATIONS.md, then use --force." + # Link, do not name the file: MIGRATIONS.md is not deployed into consumer + # repositories, so "see MIGRATIONS.md" points at something they do not have. + echo "agentic-context $LATEST is available but outside your pin '$PIN'. See $AC_WEB_BASE/$SOURCE_REPO/blob/v$LATEST/MIGRATIONS.md, then use --force." fi [ "$QUIET" -eq 1 ] && exit 0 report_local_state @@ -202,7 +204,7 @@ fi if [ "$PIN_OK" -ne 1 ]; then echo "Refusing to update: $LATEST is outside the pin '$PIN'." >&2 - echo "This is a major upgrade. Read MIGRATIONS.md, then re-run with --force." >&2 + echo "This is a major upgrade. Read $AC_WEB_BASE/$SOURCE_REPO/blob/v$LATEST/MIGRATIONS.md, then re-run with --force." >&2 exit 1 fi @@ -241,16 +243,38 @@ for rel in $(ac_hash_context_tree "$CONTEXT_DIR" | sed 's/ .*//'); do done # Replace base trees wholesale. Overrides are untouched by construction. +# This is the destructive step: each area is deleted before its replacement is +# written. The script deliberately does not run under "set -e" (check and status +# must fail open), so every operation here is checked explicitly. Without this a +# failed extract would leave a half-deleted .context/ and still exit zero, while +# the same failure on PowerShell stops cleanly - a parity break at the one point +# where it does real damage. +partial_apply() { + echo "ERROR: $1" >&2 + echo " .context/ may be partially updated. Restore with:" >&2 + echo " git -C \"$TARGET_ROOT\" checkout -- .context" >&2 + exit 1 +} + +# Validate the whole payload before deleting anything. +for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$SRC/core/.context/conventions"; do + from="${pair#*:}" + [ -d "$from" ] || partial_apply "downloaded archive is missing ${pair%%:*}/ - nothing was changed." +done + for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$SRC/core/.context/conventions"; do name="${pair%%:*}" from="${pair#*:}" - [ -d "$from" ] || continue - rm -rf "${CONTEXT_DIR:?}/$name" - mkdir -p "$CONTEXT_DIR/$name" - (cd "$from" && tar cf - .) | (cd "$CONTEXT_DIR/$name" && tar xf -) + rm -rf "${CONTEXT_DIR:?}/$name" || partial_apply "could not remove $name/." + mkdir -p "$CONTEXT_DIR/$name" || partial_apply "could not create $name/." + if ! (cd "$from" && tar cf - .) | (cd "$CONTEXT_DIR/$name" && tar xf -); then + partial_apply "could not write $name/." + fi done -[ -f "$SRC/core/.context/index.md" ] && cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" +if [ -f "$SRC/core/.context/index.md" ]; then + cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" || partial_apply "could not write index.md." +fi # Refresh the update tooling itself, so a fixed updater reaches consumers. mkdir -p "$CONTEXT_DIR/bin/lib" @@ -258,12 +282,21 @@ for tool in update.sh update.ps1 migrate.sh migrate.ps1; do [ -f "$SRC/scripts/$tool" ] && cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" done [ -f "$SRC/scripts/lib/common.sh" ] && cp "$SRC/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +# Both libraries, not just this platform's. A deployment updated from bash - a +# Linux CI runner, or one bash user on a mixed team - would otherwise pair the +# freshly downloaded update.ps1 with a stale common.ps1 forever, so a bug fixed +# in the PowerShell library could never reach that repo's Windows users. +[ -f "$SRC/scripts/lib/common.ps1" ] && cp "$SRC/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true # Refresh only the managed block in AGENTS.md. AGENTS_FILE="$TARGET_ROOT/AGENTS.md" if [ -f "$AGENTS_FILE" ] && [ -f "$SRC/core/AGENTS.md" ]; then - if grep -q '^' ' index($0, b) == 1 { inblock = 1 } @@ -285,7 +318,7 @@ if [ -f "$AGENTS_FILE" ] && [ -f "$SRC/core/AGENTS.md" ]; then cat "$WORK_DIR/agents.md" > "$AGENTS_FILE" echo " AGENTS.md: managed block refreshed; your content preserved." else - echo " AGENTS.md: no managed block found — left untouched. Merge manually if required." + echo " AGENTS.md: no well-formed managed block (needs both the begin and end marker) — left untouched. Merge manually if required." fi fi @@ -296,12 +329,18 @@ agents_json="$(sed -n 's/.*"agents"[[:space:]]*:[[:space:]]*\(\[[^]]*\]\).*/\1/p freq="$(ac_manifest_get "$MANIFEST" checkFrequency)" [ -n "$freq" ] || freq="weekly" +pin="$(ac_manifest_get "$MANIFEST" pin)" +# The pin is consumer configuration, not derived state. Recomputing it from the +# new version would silently undo a deliberate choice - "*" to accept majors, or +# an exact version to freeze - and change how every future check behaves. +[ -n "$pin" ] || pin="$(ac_semver_major "$LATEST").x" + { printf '{\n' printf ' "schema": 1,\n' printf ' "version": "%s",\n' "$LATEST" printf ' "source": "%s",\n' "$SOURCE_REPO" - printf ' "pin": "%s",\n' "$(ac_semver_major "$LATEST").x" + printf ' "pin": "%s",\n' "$pin" printf ' "checkFrequency": "%s",\n' "$freq" printf ' "deployedAt": "%s",\n' "$now" printf ' "agents": %s,\n' "$agents_json" From 189368bfd1d7d56458e7aed1a4a69f768ce2c46f Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 21:14:16 +0100 Subject: [PATCH 13/25] fix(powershell): restore parity with the bash migrate and update fixes The previous commit fixed three defects in the bash tooling but left the PowerShell twins untouched, so Windows consumers kept every one of them. Behaviour equivalence between the pairs is a non-negotiable in AGENTS.md. - migrate.ps1 hashed bytes against an LF baseline, so a CRLF checkout saw every file as edited and promoted the whole deployment into "mode: replace" overrides - a silent permanent fork that never receives another upstream improvement. Hashing is now LF-normalised via a new Get-AcFileHashLf, which mirrors ac_sha256_lf and is verified to produce a byte-identical digest. - migrate.ps1 restored each area with a recursive delete but classified only *.md, so a consumer's non-markdown file under standards, playbooks or conventions was destroyed without ever being offered as an override. Such files are now preserved; files that also exist in the source tree are framework companions and are restored intact. - migrate.ps1 gained the all-files-differ backstop and the pre-run statement of destructive behaviour. - update.ps1 skipped a missing payload area and carried on, which could leave old base content in place while the manifest and VERSION still advanced, so the deployment reported a version it did not contain. The payload is now validated before anything is deleted. Both migrate implementations were run against the same fixtures and agree: an edited base file is promoted with frontmatter, consumer markdown and non-markdown survive, the base is restored pristine, a CRLF tree is correctly reported as pristine rather than diverged, and a genuinely all-edited tree is refused by the backstop in both. Adds 8 Pester assertions covering the hash equivalence and the migrate preservation matrix, since the absence of PowerShell coverage for these paths is why the defects were not caught. 28 pass, up from 20. The non-markdown assertion was confirmed to fail when the defect is reintroduced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.Tests.ps1 | 92 ++++++++++++++++++++++++++++++++++++++++ scripts/lib/common.ps1 | 21 +++++++++ scripts/migrate.ps1 | 78 ++++++++++++++++++++++++++++++---- scripts/update.ps1 | 13 +++++- 4 files changed, 195 insertions(+), 9 deletions(-) diff --git a/scripts/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 index fda55d3..b4239a0 100644 --- a/scripts/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -199,3 +199,95 @@ Describe 'Copy-SingleFile' { $firstBytes[0] | Should -Not -Be 0xEF } } + +Describe 'Get-AcFileHashLf' { + BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) 'scripts/lib/common.ps1') + $script:LfTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-lf-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:LfTmp -Force | Out-Null + } + AfterAll { + if (Test-Path -LiteralPath $script:LfTmp) { Remove-Item -LiteralPath $script:LfTmp -Recurse -Force } + } + + # Baselines are generated on LF checkouts. Hashing a CRLF working tree + # byte-for-byte reports every file as edited, which made migrate promote a + # pristine deployment wholesale into "mode: replace" overrides - a silent + # permanent fork. The LF and CRLF forms of the same content must agree. + It 'returns the same hash for LF and CRLF forms of identical content' { + $lf = Join-Path $script:LfTmp 'lf.md' + $crlf = Join-Path $script:LfTmp 'crlf.md' + [System.IO.File]::WriteAllText($lf, "line one`nline two`n") + [System.IO.File]::WriteAllText($crlf, "line one`r`nline two`r`n") + + Get-AcFileHashLf -Path $crlf | Should -Be (Get-AcFileHashLf -Path $lf) + } + + # The byte-exact hash must still distinguish them, because manifest hashes + # rely on it for local change detection. + It 'differs from the byte-exact hash for CRLF content' { + $crlf = Join-Path $script:LfTmp 'crlf2.md' + [System.IO.File]::WriteAllText($crlf, "line one`r`nline two`r`n") + + Get-AcFileHashLf -Path $crlf | Should -Not -Be (Get-AcFileHash -Path $crlf) + } + + It 'still detects genuinely different content' { + $a = Join-Path $script:LfTmp 'a.md' + $b = Join-Path $script:LfTmp 'b.md' + [System.IO.File]::WriteAllText($a, "alpha`n") + [System.IO.File]::WriteAllText($b, "beta`n") + + Get-AcFileHashLf -Path $a | Should -Not -Be (Get-AcFileHashLf -Path $b) + } +} + +Describe 'migrate.ps1 (consumer content preservation)' { + BeforeAll { + $script:RepoRoot = Split-Path -Parent $PSScriptRoot + $script:MigTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-mig-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $script:MigTmp '.context/standards') -Force | Out-Null + + Copy-Item -LiteralPath (Join-Path $script:RepoRoot 'standards/security.md') ` + -Destination (Join-Path $script:MigTmp '.context/standards/security.md') + Add-Content -LiteralPath (Join-Path $script:MigTmp '.context/standards/security.md') -Value 'MY LOCAL EDIT' + Set-Content -LiteralPath (Join-Path $script:MigTmp '.context/standards/my-own.md') -Value 'mine' + Set-Content -LiteralPath (Join-Path $script:MigTmp '.context/standards/fixture.json') -Value '{"k":1}' + + & pwsh -NoProfile -File (Join-Path $script:RepoRoot 'scripts/migrate.ps1') ` + -Target $script:MigTmp -Apply 2>&1 | Out-Null + } + AfterAll { + if (Test-Path -LiteralPath $script:MigTmp) { Remove-Item -LiteralPath $script:MigTmp -Recurse -Force } + } + + It 'promotes an edited base file into overrides with its content intact' { + $o = Join-Path $script:MigTmp '.context/overrides/standards/security.md' + Test-Path -LiteralPath $o | Should -BeTrue + (Get-Content -LiteralPath $o -Raw) | Should -Match 'MY LOCAL EDIT' + } + + It 'marks a promoted override with mode: replace' { + (Get-Content -LiteralPath (Join-Path $script:MigTmp '.context/overrides/standards/security.md') -Raw) | + Should -Match 'mode: replace' + } + + It 'preserves a consumer-added markdown file' { + Test-Path -LiteralPath (Join-Path $script:MigTmp '.context/overrides/standards/my-own.md') | + Should -BeTrue + } + + # The restore deletes each area wholesale, so a non-markdown file the + # consumer added is destroyed unless it is classified and moved out first. + # The baseline only covers *.md, so this needed handling separately. + It 'preserves a consumer-added non-markdown file' { + Test-Path -LiteralPath (Join-Path $script:MigTmp '.context/overrides/standards/fixture.json') | + Should -BeTrue + } + + It 'restores the base file pristine' { + $b = Join-Path $script:MigTmp '.context/standards/security.md' + Test-Path -LiteralPath $b | Should -BeTrue + (Get-Content -LiteralPath $b -Raw) | Should -Not -Match 'MY LOCAL EDIT' + } +} diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 index 66e7200..2594bcd 100644 --- a/scripts/lib/common.ps1 +++ b/scripts/lib/common.ps1 @@ -18,6 +18,27 @@ function Get-AcFileHash { return $hash.Hash.ToLowerInvariant() } +# Hash with CR stripped, matching ac_sha256_lf in common.sh. Baselines are +# generated on LF checkouts, so comparing a CRLF working tree byte-for-byte +# reports every file as edited. Only cross-machine baseline comparisons use +# this; manifest hashes stay byte-exact because they are written and read on +# the same machine. +function Get-AcFileHashLf { + param([Parameter(Mandatory)][string]$Path) + $bytes = [System.IO.File]::ReadAllBytes($Path) + $out = New-Object System.Collections.Generic.List[byte] + foreach ($b in $bytes) { + if ($b -ne 13) { $out.Add($b) } + } + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($out.ToArray()) + } finally { + $sha.Dispose() + } + return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() +} + # --- semver ---------------------------------------------------------------- function ConvertTo-AcSemVer { diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 index 7a9006e..a1e982e 100644 --- a/scripts/migrate.ps1 +++ b/scripts/migrate.ps1 @@ -110,6 +110,8 @@ foreach ($line in (Get-Content -LiteralPath $Baseline)) { $diverged = New-Object System.Collections.Generic.List[string] $added = New-Object System.Collections.Generic.List[string] +$nonMd = New-Object System.Collections.Generic.List[string] +$totalCount = 0 foreach ($area in @('standards', 'playbooks', 'conventions')) { $areaDir = Join-Path $ContextDir $area @@ -122,7 +124,9 @@ foreach ($area in @('standards', 'playbooks', 'conventions')) { foreach ($f in $files) { $rel = $area + '/' + $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') - $actual = Get-AcFileHash -Path $f.FullName + $totalCount++ + # LF-normalised: a CRLF checkout must not make every file look edited. + $actual = Get-AcFileHashLf -Path $f.FullName if ($baselineMap.ContainsKey($rel)) { if ($baselineMap[$rel] -ne $actual) { $diverged.Add($rel) } } else { @@ -132,7 +136,47 @@ foreach ($area in @('standards', 'playbooks', 'conventions')) { } } -if ($diverged.Count -eq 0 -and $added.Count -eq 0) { +# Non-markdown files. The baseline only covers .md, but the restore below +# replaces each area wholesale, so anything else here is destroyed unless it is +# recognised. A file that also exists in the source tree is a framework +# companion (playbooks/setup ships shell scripts) and is restored intact; one +# that does not is the consumer's own and must be preserved as an override. +$sourceAreas = @( + @{ Name = 'standards'; From = (Join-Path $SourceRoot 'standards') }, + @{ Name = 'playbooks'; From = (Join-Path $SourceRoot 'playbooks') }, + @{ Name = 'conventions'; From = (Join-Path $SourceRoot 'core/.context/conventions') } +) +foreach ($area in $sourceAreas) { + $areaDir = Join-Path $ContextDir $area.Name + if (-not (Test-Path -LiteralPath $areaDir)) { continue } + + $full = (Resolve-Path -LiteralPath $areaDir).Path + $sep = [System.IO.Path]::DirectorySeparatorChar + $files = Get-ChildItem -LiteralPath $full -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -ne '.md' } | Sort-Object FullName + + foreach ($f in $files) { + $rel = $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') + if (-not (Test-Path -LiteralPath (Join-Path $area.From $rel))) { + $nonMd.Add($area.Name + '/' + $rel) + } + } +} + +# Backstop. Every single file differing is not a real editing pattern - it is +# the signature of a systemic mismatch (wrong baseline, or an encoding or +# line-ending transform). Promoting them all would turn a pristine deployment +# into a total fork, pinning every file with "mode: replace" so no upstream +# improvement ever reaches it again. Refuse rather than do that silently. +if ($totalCount -gt 1 -and $diverged.Count -eq $totalCount) { + Write-Error ("Every one of the $totalCount base files differs from the baseline. " + + "That is a systemic mismatch, not consumer edits - check the baseline version " + + "(-From) and that the checkout has not rewritten line endings. " + + "Refusing to promote every file into overrides.") + exit 1 +} + +if ($diverged.Count -eq 0 -and $added.Count -eq 0 -and $nonMd.Count -eq 0) { Write-Host "No local modifications detected - this deployment is pristine." } else { if ($diverged.Count -gt 0) { @@ -145,8 +189,19 @@ if ($diverged.Count -eq 0 -and $added.Count -eq 0) { foreach ($rel in $added) { Write-Host " $rel -> .context/overrides/$rel" } Write-Host "" } + if ($nonMd.Count -gt 0) { + Write-Host "Non-markdown files you added ($($nonMd.Count)) - will move to overrides:" + foreach ($rel in $nonMd) { Write-Host " $rel -> .context/overrides/$rel" } + Write-Host "" + } } +# State the destructive behaviour before it happens, not after. +Write-Host "This migration replaces .context/{standards,playbooks,conventions} wholesale." +Write-Host "Anything listed above is preserved under .context/overrides/. A base file you" +Write-Host "deleted is restored, because the base is owned by the library, not by you." +Write-Host "" + if (-not $Apply) { Write-Host "Nothing written. Re-run with -Apply to perform the migration." exit 0 @@ -197,13 +252,20 @@ foreach ($rel in $added) { Remove-Item -LiteralPath (Join-Path $ContextDir $rel) -Force -ErrorAction SilentlyContinue } +# Consumer-owned non-markdown files: copied verbatim, with no frontmatter - +# they are not markdown, so a YAML header would corrupt them. +foreach ($rel in $nonMd) { + $dst = Join-Path $overrideRoot $rel + $parent = Split-Path -Parent $dst + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + Copy-Item -LiteralPath (Join-Path $ContextDir $rel) -Destination $dst -Force + Remove-Item -LiteralPath (Join-Path $ContextDir $rel) -Force -ErrorAction SilentlyContinue +} + Write-Host "Restoring base content from $SourceRoot ..." -$areas = @( - @{ Name = 'standards'; From = (Join-Path $SourceRoot 'standards') }, - @{ Name = 'playbooks'; From = (Join-Path $SourceRoot 'playbooks') }, - @{ Name = 'conventions'; From = (Join-Path $SourceRoot 'core/.context/conventions') } -) -foreach ($area in $areas) { +foreach ($area in $sourceAreas) { if (-not (Test-Path -LiteralPath $area.From)) { continue } $dest = Join-Path $ContextDir $area.Name if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } diff --git a/scripts/update.ps1 b/scripts/update.ps1 index c487856..d67261b 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -216,8 +216,19 @@ try { @{ Name = 'playbooks'; From = (Join-Path $srcPath 'playbooks') }, @{ Name = 'conventions'; From = (Join-Path $srcPath 'core/.context/conventions') } ) + + # Validate the whole payload before deleting anything. Skipping a missing + # area would leave the previous content in place while the manifest and + # VERSION still advance, so the deployment would report a version it does + # not actually contain. + foreach ($area in $areas) { + if (-not (Test-Path -LiteralPath $area.From)) { + Write-Error ("Downloaded archive is missing $($area.Name)/ - nothing was changed.") + exit 1 + } + } + foreach ($area in $areas) { - if (-not (Test-Path -LiteralPath $area.From)) { continue } $dest = Join-Path $ContextDir $area.Name if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } New-Item -ItemType Directory -Path $dest -Force | Out-Null From cad8b6eb47ae341511c705ea7cf9f42b9259a001 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 21:39:24 +0100 Subject: [PATCH 14/25] fix(deploy,update): protect the override layer and fix cross-platform manifest parsing Actions the suppressed findings from the latest automated review. Each was reproduced against a scratch deployment before being fixed. The override layer was not actually consumer-owned: - deploy.sh and deploy.ps1 copied the override scaffolding with the normal overwrite rules, so redeploying with --overwrite destroyed a consumer's own .context/overrides/README.md - the file where they document why their overrides exist. The comment above the call claimed the tree was "never touched again", and it was not. Scaffolding is now seeded only where absent, ignoring --overwrite entirely, and the override subtree is excluded from the bulk core/.context copy that also reached it. Base content is still refreshed, so the exemption is scoped to overrides/ alone. This mattered more than the small blast radius suggests: the base is disposable only because the framework never writes to overrides. An update path that can overwrite consumer files there breaks the guarantee the whole design rests on. - deploy.ps1 used Get-ChildItem without -Force, which skips dotfiles, so the .gitkeep files under the override scaffolding were silently never deployed and the subdirectories never created. deploy.sh created all three. Windows consumers therefore had no scaffolding at all. Cross-platform manifest parsing in update.sh: - The agents array was extracted with a single-line pattern, but ConvertTo-Json in deploy.ps1 writes arrays across multiple lines. Running update.sh --apply against a repository deployed with deploy.ps1 matched nothing and silently reset the recorded agent list to empty, which then changes what a later deploy writes. Reproduced with a real PowerShell-written manifest; the file is now collapsed before matching so both styles parse identically. - list_diverged interpolated the file path into a sed pattern unescaped. Every ".md" contains a regex metacharacter, so the pattern could match more than the literal key and report a false divergence. The key is now escaped. Not actioned: the report that Invoke-WebRequest -UseBasicParsing is unavailable on PowerShell 7. It is still present and functional - verified on 7.6.3, where the parameter exists and a live request succeeds. It is a deprecated no-op retained for backwards compatibility, which is exactly what a script supporting both 5.1 and 7+ needs. Adds TC13 to the bash suite and four Pester assertions covering the override ownership contract in both implementations, including the dotfile scaffolding and the requirement that base content is still refreshed. 96 bash assertions, up from 92; 32 Pester, up from 28. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.Tests.ps1 | 43 +++++++++++++++++++++++++++ scripts/deploy.ps1 | 56 ++++++++++++++++++++++++++++++++++-- scripts/deploy.sh | 53 ++++++++++++++++++++++++++++++++-- scripts/tests/test-deploy.sh | 50 ++++++++++++++++++++++++++++++++ scripts/update.sh | 14 +++++++-- 5 files changed, 208 insertions(+), 8 deletions(-) diff --git a/scripts/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 index b4239a0..b19adfa 100644 --- a/scripts/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -291,3 +291,46 @@ Describe 'migrate.ps1 (consumer content preservation)' { (Get-Content -LiteralPath $b -Raw) | Should -Not -Match 'MY LOCAL EDIT' } } + +Describe 'deploy.ps1 (override layer ownership)' { + BeforeAll { + $script:OvRepo = Split-Path -Parent $PSScriptRoot + $script:OvTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-ov-" + [guid]::NewGuid()) + $deploy = Join-Path $script:OvRepo 'scripts/deploy.ps1' + New-Item -ItemType Directory -Path $script:OvTmp -Force | Out-Null + + & pwsh -NoProfile -File $deploy -Target $script:OvTmp -Agents claude -Overwrite 2>&1 | Out-Null + Set-Content -LiteralPath (Join-Path $script:OvTmp '.context/overrides/README.md') -Value 'MY OWN OVERRIDE NOTES' + Set-Content -LiteralPath (Join-Path $script:OvTmp '.context/overrides/standards/security.md') -Value 'my custom rule' + Set-Content -LiteralPath (Join-Path $script:OvTmp '.context/standards/security.md') -Value 'tampered' + & pwsh -NoProfile -File $deploy -Target $script:OvTmp -Agents claude -Overwrite 2>&1 | Out-Null + } + AfterAll { + if (Test-Path -LiteralPath $script:OvTmp) { Remove-Item -LiteralPath $script:OvTmp -Recurse -Force } + } + + # The override tree is consumer-owned; the base is disposable only because + # the framework never writes there. Scaffolding was previously copied with + # the normal overwrite rules, which destroyed a consumer's own README. + It 'preserves a consumer-edited overrides README under -Overwrite' { + (Get-Content -LiteralPath (Join-Path $script:OvTmp '.context/overrides/README.md') -Raw) | + Should -Match 'MY OWN OVERRIDE NOTES' + } + + It 'preserves a consumer override file under -Overwrite' { + (Get-Content -LiteralPath (Join-Path $script:OvTmp '.context/overrides/standards/security.md') -Raw) | + Should -Match 'my custom rule' + } + + # Get-ChildItem skips dotfiles without -Force, so the .gitkeep scaffolding + # was silently never deployed, diverging from deploy.sh. + It 'seeds the override scaffolding including dotfiles' { + Test-Path -LiteralPath (Join-Path $script:OvTmp '.context/overrides/playbooks/.gitkeep') | + Should -BeTrue + } + + It 'still refreshes base content under -Overwrite' { + (Get-Content -LiteralPath (Join-Path $script:OvTmp '.context/standards/security.md') -Raw) | + Should -Not -Match '^tampered' + } +} diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index b709f8a..7f2d0ba 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -204,7 +204,10 @@ function Copy-DirectoryContents { if (-not (Test-Path $Destination)) { New-Item -ItemType Directory -Path $Destination -Force | Out-Null } - $sourceFiles = Get-ChildItem -Path $Source -Recurse -File + # -Force is required or dotfiles are skipped: the override scaffolding + # ships .gitkeep files, and without this the subdirectories are never + # created, diverging from deploy.sh. + $sourceFiles = Get-ChildItem -Path $Source -Recurse -File -Force foreach ($file in $sourceFiles) { $relativePath = $file.FullName.Substring($Source.TrimEnd('/\').Length + 1) $destPath = Join-Path $Destination $relativePath @@ -212,6 +215,53 @@ function Copy-DirectoryContents { } } +# Seed files only where absent, ignoring -Overwrite entirely. +# +# The override tree is consumer-owned: the whole architecture depends on the +# framework never writing there, because that is what makes the base +# disposable. Copy-SingleFile honours -Overwrite, so using it for the override +# scaffolding would let a redeploy destroy a consumer's own README - the very +# file where they document why their overrides exist. +function Copy-AcSeedContents { + param([string]$Source, [string]$Destination) + if (-not (Test-Path $Destination)) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + } + # -Force is required or dotfiles are skipped: the override scaffolding + # ships .gitkeep files, and without this the subdirectories are never + # created, diverging from deploy.sh. + $sourceFiles = Get-ChildItem -Path $Source -Recurse -File -Force + foreach ($file in $sourceFiles) { + $relativePath = $file.FullName.Substring($Source.TrimEnd('/\').Length + 1) + $destPath = Join-Path $Destination $relativePath + if (Test-Path -LiteralPath $destPath) { continue } + $parent = Split-Path -Parent $destPath + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + Copy-Item -LiteralPath $file.FullName -Destination $destPath -Force + } +} + +# Copy core/.context but skip the override subtree, which is seeded separately +# and must never be overwritten. +function Copy-AcContextExcludingOverrides { + param([string]$Source, [string]$Destination) + if (-not (Test-Path $Destination)) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + } + # -Force is required or dotfiles are skipped: the override scaffolding + # ships .gitkeep files, and without this the subdirectories are never + # created, diverging from deploy.sh. + $sourceFiles = Get-ChildItem -Path $Source -Recurse -File -Force + foreach ($file in $sourceFiles) { + $relativePath = $file.FullName.Substring($Source.TrimEnd('/\').Length + 1) + if ($relativePath.Replace('\', '/') -like 'overrides/*') { continue } + $destPath = Join-Path $Destination $relativePath + Copy-SingleFile -Source $file.FullName -Destination $destPath + } +} + function Enable-VirtualTerminal { # Returns $true if ANSI escape sequences are usable on stdout. On non-Windows # hosts this is always true; on Windows it requires ENABLE_VIRTUAL_TERMINAL_PROCESSING, @@ -589,7 +639,7 @@ if (-not (Test-Path -LiteralPath $agentsDst)) { Copy-SingleFile -Source $agentsSrc -Destination $agentsDst } -Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context') -Destination (Join-Path $script:Target '.context') +Copy-AcContextExcludingOverrides -Source (Join-Path $SourceRoot 'core/.context') -Destination (Join-Path $script:Target '.context') if (Test-AgentEnabled 'claude') { Write-Host " Copying Claude Code files..." @@ -671,7 +721,7 @@ $overrideDst = Join-Path $script:Target '.context/overrides' if (-not (Test-Path -LiteralPath $overrideDst)) { New-Item -ItemType Directory -Path $overrideDst -Force | Out-Null } -Copy-DirectoryContents -Source (Join-Path $SourceRoot 'core/.context/overrides') -Destination $overrideDst +Copy-AcSeedContents -Source (Join-Path $SourceRoot 'core/.context/overrides') -Destination $overrideDst # Update tooling, shipped into the target so it can maintain itself. Write-Host " Installing update tooling -> $(Join-Path $script:Target '.context/bin')" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c0d5852..c930851 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -165,6 +165,52 @@ copy_dir_contents() { done < <(find "$src" -type f -print0 | sort -z) } +# Copy core/.context but skip the override subtree, which is seeded separately +# and must never be overwritten. +copy_dir_contents_excluding_overrides() { + local src="$1" + local dst="$2" + local rel_path + + while IFS= read -r -d '' file; do + rel_path="${file#"$src"/}" + case "$rel_path" in + overrides/*) continue ;; + esac + copy_file "$file" "$dst/$rel_path" + done < <(find "$src" -type f -print0 | sort -z) +} + +# Seed a file only when it is absent, ignoring --overwrite entirely. +# +# The override tree is consumer-owned: the whole architecture depends on the +# framework never writing there, because that is what makes the base +# disposable. copy_file honours --overwrite, so using it for the override +# scaffolding would let a redeploy destroy a consumer's own README - the very +# file where they document why their overrides exist. +seed_file_if_absent() { + local src="$1" + local dst="$2" + + if [[ -e "$dst" ]]; then + return 0 + fi + + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" +} + +seed_dir_if_absent() { + local src="$1" + local dst="$2" + local rel_path + + while IFS= read -r -d '' file; do + rel_path="${file#"$src"/}" + seed_file_if_absent "$file" "$dst/$rel_path" + done < <(find "$src" -type f -print0 | sort -z) +} + # --- managed block --------------------------------------------------------- # # The consumer owns AGENTS.md: it carries their [CONFIGURE] sections. The @@ -658,7 +704,10 @@ echo " Selected agents: $(join_by ', ' "${ENABLED_AGENTS[@]}")" echo " Copying shared context files..." deploy_agents_md "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" "$DEPLOY_VERSION" -copy_dir_contents "$SOURCE_ROOT/core/.context" "$TARGET/.context" +# The override subtree is deliberately excluded here and seeded later with +# seed_dir_if_absent, so that --overwrite can never rewrite consumer-owned +# files under .context/overrides/. +copy_dir_contents_excluding_overrides "$SOURCE_ROOT/core/.context" "$TARGET/.context" if agent_enabled claude; then echo " Copying Claude Code files..." @@ -756,7 +805,7 @@ fi # Consumer-owned override tree. Created empty; never touched again by update. echo " Creating override layer → $TARGET/.context/overrides/" mkdir -p "$TARGET/.context/overrides" -copy_dir_contents "$SOURCE_ROOT/core/.context/overrides" "$TARGET/.context/overrides" +seed_dir_if_absent "$SOURCE_ROOT/core/.context/overrides" "$TARGET/.context/overrides" # Update tooling, shipped into the target so it can maintain itself. echo " Installing update tooling → $TARGET/.context/bin/" diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 33cc83a..842f79d 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -633,6 +633,56 @@ rm -rf "$TC12_DIR" # ═══════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════ +# ═══════════════════════════════════════════════════════════════════════ +# TC13: the override layer is consumer-owned and survives --overwrite +# +# The whole architecture depends on the framework never writing under +# .context/overrides/ - that is what makes the base disposable. Scaffolding +# was previously copied with the normal overwrite rules, so a redeploy with +# --overwrite destroyed a consumer's own overrides README. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC13: override layer ownership ===" + +TC13_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC13_DIR" >/dev/null 2>&1 + +printf 'MY OWN OVERRIDE NOTES\n' > "$TC13_DIR/.context/overrides/README.md" +printf 'my custom rule\n' > "$TC13_DIR/.context/overrides/standards/security.md" + +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC13_DIR" >/dev/null 2>&1 + +if grep -q 'MY OWN OVERRIDE NOTES' "$TC13_DIR/.context/overrides/README.md" 2>/dev/null; then + pass "deploy: --overwrite preserves a consumer-edited overrides README" +else + fail "deploy: --overwrite destroyed the consumer-owned overrides README" +fi + +if grep -q 'my custom rule' "$TC13_DIR/.context/overrides/standards/security.md" 2>/dev/null; then + pass "deploy: --overwrite preserves a consumer override file" +else + fail "deploy: --overwrite destroyed a consumer override file" +fi + +# The scaffolding must still be seeded on a fresh deployment. +if [ -f "$TC13_DIR/.context/overrides/playbooks/.gitkeep" ]; then + pass "deploy: override scaffolding seeded" +else + fail "deploy: override scaffolding missing" +fi + +# Base content must still be refreshed by --overwrite; the exemption is +# scoped to overrides/ only. +printf 'tampered\n' > "$TC13_DIR/.context/standards/security.md" +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC13_DIR" >/dev/null 2>&1 +if ! grep -qx 'tampered' "$TC13_DIR/.context/standards/security.md" 2>/dev/null; then + pass "deploy: --overwrite still refreshes base content" +else + fail "deploy: --overwrite no longer refreshes base content" +fi + +rm -rf "$TC13_DIR" + echo "" echo "=== Results ===" echo " Passed: $PASSED" diff --git a/scripts/update.sh b/scripts/update.sh index f93214e..d4f8ad1 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -95,11 +95,15 @@ PIN="$(ac_manifest_get "$MANIFEST" pin)" # List base files whose current hash differs from the manifest record. list_diverged() { - local rel recorded actual + local rel recorded actual rel_escaped ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do rel="${line%% *}" actual="${line##* }" - recorded="$(sed -n 's|.*"'"$rel"'"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*|\1|p' "$MANIFEST" | head -1)" + # The key is interpolated into a sed pattern, so regex metacharacters in + # the path (every ".md" contains one) must be escaped or they match more + # than the literal name and can report a false divergence. + rel_escaped="$(printf '%s' "$rel" | sed 's/[][\\.*^$/]/\\&/g')" + recorded="$(sed -n 's|.*"'"$rel_escaped"'"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*|\1|p' "$MANIFEST" | head -1)" if [ -n "$recorded" ] && [ "$recorded" != "$actual" ]; then printf '%s\n' "$rel" fi @@ -324,7 +328,11 @@ fi # Rewrite the manifest with the new version and fresh hashes. now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" -agents_json="$(sed -n 's/.*"agents"[[:space:]]*:[[:space:]]*\(\[[^]]*\]\).*/\1/p' "$MANIFEST" | head -1)" +# ConvertTo-Json in deploy.ps1 writes arrays across multiple lines, so a +# single-line pattern silently finds nothing and resets the agent list to +# empty - which then changes what a later deploy writes. Collapse the file to +# one line first so both manifest styles parse identically. +agents_json="$(tr -d '\n' < "$MANIFEST" | sed -n 's/.*"agents"[[:space:]]*:[[:space:]]*\(\[[^]]*\]\).*/\1/p' | tr -s ' ')" [ -n "$agents_json" ] || agents_json='[]' freq="$(ac_manifest_get "$MANIFEST" checkFrequency)" [ -n "$freq" ] || freq="weekly" From 60dff5d26d48cac73dbb0b32f05c89dff6f0e111 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 21:56:32 +0100 Subject: [PATCH 15/25] fix(deploy): apply the overwrite guard to the shipped update tooling Actions the two suppressed findings from the latest automated review. deploy.sh installed .context/bin/* with a raw cp, and deploy.ps1 with Copy-Item -Force, so both bypassed the overwrite guard that every other base file goes through. --no-overwrite and -NoOverwrite were silently ignored for the tooling, which made the flag misleading: the deployment reported files as skipped while rewriting them. Both are now routed through copy_file and Copy-SingleFile. Refreshing a stale updater unconditionally remains correct, but that belongs to update.sh and update.ps1, where replacing the base is the declared intent - not to a deploy the consumer explicitly asked not to overwrite anything. Verified in both implementations: a fresh deployment still installs the tooling, --no-overwrite now preserves an edited bin file, --overwrite still refreshes it, and the executable bit survives. Adds TC14 covering all four of those properties. 100 bash assertions, up from 96. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.ps1 | 9 ++++++-- scripts/deploy.sh | 10 ++++++--- scripts/tests/test-deploy.sh | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 7f2d0ba..2cda11b 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -732,16 +732,21 @@ foreach ($dir in @($binDst, $binLibDst)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } +# Routed through Copy-SingleFile so -NoOverwrite means what it says. Every +# other base file honours the guard, and silently rewriting the tooling +# regardless would make the flag misleading. Refreshing a stale updater +# unconditionally is update.ps1's job, where replacing the base is the +# declared intent. foreach ($tool in @('update.sh', 'update.ps1', 'migrate.sh', 'migrate.ps1')) { $toolSrc = Join-Path $SourceRoot "scripts/$tool" if (Test-Path -LiteralPath $toolSrc) { - Copy-Item -LiteralPath $toolSrc -Destination (Join-Path $binDst $tool) -Force + Copy-SingleFile -Source $toolSrc -Destination (Join-Path $binDst $tool) } } foreach ($libFile in @('common.sh', 'common.ps1')) { $libSrc = Join-Path $SourceRoot "scripts/lib/$libFile" if (Test-Path -LiteralPath $libSrc) { - Copy-Item -LiteralPath $libSrc -Destination (Join-Path $binLibDst $libFile) -Force + Copy-SingleFile -Source $libSrc -Destination (Join-Path $binLibDst $libFile) } } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index c930851..44f1180 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -810,14 +810,18 @@ seed_dir_if_absent "$SOURCE_ROOT/core/.context/overrides" "$TARGET/.context/over # Update tooling, shipped into the target so it can maintain itself. echo " Installing update tooling → $TARGET/.context/bin/" mkdir -p "$TARGET/.context/bin" +# Routed through copy_file so --no-overwrite means what it says. Every other +# base file honours the guard, and silently rewriting the tooling regardless +# would make the flag misleading. Refreshing a stale updater unconditionally +# is update.sh's job, where replacing the base is the declared intent. for tool in update.sh update.ps1 migrate.sh migrate.ps1; do if [[ -f "$SOURCE_ROOT/scripts/$tool" ]]; then - cp "$SOURCE_ROOT/scripts/$tool" "$TARGET/.context/bin/$tool" + copy_file "$SOURCE_ROOT/scripts/$tool" "$TARGET/.context/bin/$tool" fi done mkdir -p "$TARGET/.context/bin/lib" -cp "$SOURCE_ROOT/scripts/lib/common.sh" "$TARGET/.context/bin/lib/common.sh" -[[ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ]] && cp "$SOURCE_ROOT/scripts/lib/common.ps1" "$TARGET/.context/bin/lib/common.ps1" +copy_file "$SOURCE_ROOT/scripts/lib/common.sh" "$TARGET/.context/bin/lib/common.sh" +[[ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ]] && copy_file "$SOURCE_ROOT/scripts/lib/common.ps1" "$TARGET/.context/bin/lib/common.ps1" chmod +x "$TARGET/.context/bin"/*.sh 2>/dev/null || true printf '%s\n' "$DEPLOY_VERSION" > "$TARGET/.context/VERSION" diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 842f79d..07fcc86 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -683,6 +683,48 @@ fi rm -rf "$TC13_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# TC14: --no-overwrite applies to the shipped tooling too +# +# .context/bin/* was installed with a raw cp that bypassed the overwrite +# guard, so --no-overwrite silently rewrote it anyway and the flag lied. +# Refreshing a stale updater unconditionally is update.sh's job. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC14: overwrite guard covers .context/bin ===" + +TC14_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC14_DIR" >/dev/null 2>&1 + +if [ -f "$TC14_DIR/.context/bin/update.sh" ] && [ -f "$TC14_DIR/.context/bin/lib/common.sh" ]; then + pass "deploy: update tooling installed on a fresh deployment" +else + fail "deploy: update tooling missing from a fresh deployment" +fi + +printf 'LOCAL EDIT\n' > "$TC14_DIR/.context/bin/update.sh" +"$SCRIPTS_DIR/deploy.sh" --agents claude --no-overwrite "$TC14_DIR" >/dev/null 2>&1 +if grep -q 'LOCAL EDIT' "$TC14_DIR/.context/bin/update.sh"; then + pass "deploy: --no-overwrite is honoured for .context/bin" +else + fail "deploy: --no-overwrite was ignored for .context/bin" +fi + +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC14_DIR" >/dev/null 2>&1 +if ! grep -q 'LOCAL EDIT' "$TC14_DIR/.context/bin/update.sh"; then + pass "deploy: --overwrite still refreshes .context/bin" +else + fail "deploy: --overwrite no longer refreshes .context/bin" +fi + +if [ -x "$TC14_DIR/.context/bin/update.sh" ]; then + pass "deploy: shipped tooling remains executable" +else + fail "deploy: shipped tooling lost its executable bit" +fi + +rm -rf "$TC14_DIR" + echo "" echo "=== Results ===" echo " Passed: $PASSED" From 8a75dabbca5ed3d54f8c2787ecfd3a7b57d663ee Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 22:16:37 +0100 Subject: [PATCH 16/25] fix(update): track non-markdown base files and check the tooling copies Actions the two suppressed findings from the latest automated review. update.sh left the bin tool and library copies unchecked in the apply path, which directly contradicted the comment above them stating that every operation there is checked. Because the script deliberately does not run under set -e, a failed copy left .context/bin partially updated while the run continued, rewrote manifest.json and VERSION, and reported success. All of them now route through partial_apply like the rest of the apply path. The manifest hashed only *.md while describing itself as covering every deployable file. deploy ships non-markdown companions under playbooks/ - compose files, collector configs, env files and shell scripts - and update replaces each area wholesale, so a consumer edit to one was destroyed with no divergence report. That is exactly the outcome the override layer exists to prevent, and it was invisible precisely because those files were untracked. Hashing now covers all base content in both languages, excluding overrides/ and bin/ as before, and additionally manifest.json, VERSION and .last-update-check, which are generated state rather than content - manifest.json cannot hash itself. Nine previously untracked files are now covered, and the bash and PowerShell implementations were confirmed to produce identical output over all 87 entries. Editing a shipped docker-compose.yaml is now reported by update --status with the override path to move it to, where previously it was silently overwritten. ac_hash_source_tree is left markdown-only and its comment corrected to say so. It backs the published baselines, migrate classifies non-markdown companions by source presence rather than by hash, and the frozen unversioned baseline is markdown-only, so widening it would make the baselines disagree. Adds TC15 covering the tracking, the exclusions and the divergence report. 105 bash assertions, up from 100. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/common.ps1 | 12 +++++++-- scripts/lib/common.sh | 23 +++++++++++++--- scripts/tests/test-deploy.sh | 52 ++++++++++++++++++++++++++++++++++++ scripts/update.sh | 14 +++++++--- 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 index 2594bcd..ac1361a 100644 --- a/scripts/lib/common.ps1 +++ b/scripts/lib/common.ps1 @@ -211,11 +211,19 @@ function Get-AcContextHashes { $full = (Resolve-Path -LiteralPath $ContextDir).Path $sep = [System.IO.Path]::DirectorySeparatorChar - $files = Get-ChildItem -LiteralPath $full -Recurse -File -Filter '*.md' -ErrorAction SilentlyContinue | + # -Force so dotfiles are included, and no *.md filter: deploy ships + # non-markdown companions under playbooks/, and update replaces each area + # wholesale, so hashing only markdown meant a consumer edit to one of those + # was destroyed with no divergence report. manifest.json, VERSION and + # .last-update-check are generated state, not content. + $generated = @('manifest.json', 'VERSION', '.last-update-check') + $files = Get-ChildItem -LiteralPath $full -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $rel = $_.FullName.Substring($full.Length).TrimStart($sep) $relNorm = $rel.Replace('\', '/') - (-not $relNorm.StartsWith('overrides/')) -and (-not $relNorm.StartsWith('bin/')) + (-not $relNorm.StartsWith('overrides/')) -and + (-not $relNorm.StartsWith('bin/')) -and + ($generated -notcontains $relNorm) } | Sort-Object { $_.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') } diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index a67eae1..0d135a0 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -177,26 +177,43 @@ ac_manifest_get() { sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -1 } -# Hash every deployable file in a deployed .context tree, printing +# Hash every base file in a deployed .context tree, printing # " " lines sorted by path. # # Relative paths are relative to the .context directory. Overrides and the # bin/ directory are excluded: overrides belong to the consumer, and bin/ is # refreshed like any other base file but is not part of the content baseline. +# manifest.json, VERSION and .last-update-check are generated state, not +# content - manifest.json in particular cannot hash itself. +# +# This deliberately covers more than markdown. deploy ships non-markdown +# companions under playbooks/ (compose files, env files, shell scripts), and +# update replaces each area wholesale. Hashing only *.md meant a consumer edit +# to one of those was destroyed with no divergence report, which is precisely +# the outcome the override layer exists to prevent. ac_hash_context_tree() { local ctx="$1" f rel [ -d "$ctx" ] || return 1 - find "$ctx" -type f -name '*.md' \ + find "$ctx" -type f \ ! -path "$ctx/overrides/*" \ ! -path "$ctx/bin/*" \ + ! -name 'manifest.json' \ + ! -name 'VERSION' \ + ! -name '.last-update-check' \ 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do rel="${f#"$ctx"/}" printf '%s %s\n' "$rel" "$(ac_sha256 "$f")" done } -# Hash the deployable source files in this repository, printing the same +# Hash the markdown base files in this repository, printing the same # " " shape so the two can be diffed directly. +# +# Markdown only, deliberately: this backs the published baselines, which +# migrate uses to tell a pristine context file from an edited one. Non-markdown +# companions are classified by migrate on whether they exist in the source +# tree, not by hash, and the frozen unversioned baseline is markdown-only, so +# widening this would make the baselines disagree with each other. ac_hash_source_tree() { local root="$1" f rel [ -d "$root" ] || return 1 diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 07fcc86..b196cf6 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -725,6 +725,58 @@ fi rm -rf "$TC14_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# TC15: non-markdown base files are tracked and their edits reported +# +# The manifest hashed only *.md, but deploy ships non-markdown companions +# under playbooks/ and update replaces each area wholesale. An edit to one +# was therefore destroyed with no divergence report - the exact outcome the +# override layer exists to prevent. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC15: non-markdown base file tracking ===" + +TC15_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC15_DIR" >/dev/null 2>&1 + +TC15_NONMD="playbooks/setup/create-local-otel-stack/docker-compose.yaml" +if [ -f "$TC15_DIR/.context/$TC15_NONMD" ]; then + pass "deploy: non-markdown companion shipped" +else + fail "deploy: non-markdown companion missing - fixture is stale" +fi + +if grep -q "$TC15_NONMD" "$TC15_DIR/.context/manifest.json"; then + pass "manifest: non-markdown base file is tracked" +else + fail "manifest: non-markdown base file is not tracked" +fi + +# Generated state must never be hashed; manifest.json cannot hash itself. +if ! grep -q '"manifest.json"' "$TC15_DIR/.context/manifest.json" \ + && ! grep -q '"VERSION"' "$TC15_DIR/.context/manifest.json"; then + pass "manifest: generated state excluded from hashes" +else + fail "manifest: generated state was hashed" +fi + +if ! grep -q '"overrides/' "$TC15_DIR/.context/manifest.json" \ + && ! grep -q '"bin/' "$TC15_DIR/.context/manifest.json"; then + pass "manifest: overrides and bin excluded from hashes" +else + fail "manifest: overrides or bin were hashed" +fi + +printf 'tampered\n' >> "$TC15_DIR/.context/$TC15_NONMD" +TC15_OUT="$(cd "$TC15_DIR" && bash .context/bin/update.sh --status 2>&1)" || true +if printf '%s' "$TC15_OUT" | grep -q "$TC15_NONMD"; then + pass "update: an edited non-markdown base file is reported as diverged" +else + fail "update: an edited non-markdown base file went unreported" +fi + +rm -rf "$TC15_DIR" + echo "" echo "=== Results ===" echo " Passed: $PASSED" diff --git a/scripts/update.sh b/scripts/update.sh index d4f8ad1..79bceb1 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -281,16 +281,22 @@ if [ -f "$SRC/core/.context/index.md" ]; then fi # Refresh the update tooling itself, so a fixed updater reaches consumers. -mkdir -p "$CONTEXT_DIR/bin/lib" +mkdir -p "$CONTEXT_DIR/bin/lib" || partial_apply "could not create .context/bin/lib." for tool in update.sh update.ps1 migrate.sh migrate.ps1; do - [ -f "$SRC/scripts/$tool" ] && cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" + if [ -f "$SRC/scripts/$tool" ]; then + cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" || partial_apply "could not write bin/$tool." + fi done -[ -f "$SRC/scripts/lib/common.sh" ] && cp "$SRC/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" +if [ -f "$SRC/scripts/lib/common.sh" ]; then + cp "$SRC/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" || partial_apply "could not write bin/lib/common.sh." +fi # Both libraries, not just this platform's. A deployment updated from bash - a # Linux CI runner, or one bash user on a mixed team - would otherwise pair the # freshly downloaded update.ps1 with a stale common.ps1 forever, so a bug fixed # in the PowerShell library could never reach that repo's Windows users. -[ -f "$SRC/scripts/lib/common.ps1" ] && cp "$SRC/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" +if [ -f "$SRC/scripts/lib/common.ps1" ]; then + cp "$SRC/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" || partial_apply "could not write bin/lib/common.ps1." +fi chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true # Refresh only the managed block in AGENTS.md. From e4430d366601e6fd613c66e1d0a75649bed70618 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 22:34:02 +0100 Subject: [PATCH 17/25] fix(deploy): preserve consumer manifest configuration on redeploy Actions the three suppressed findings from the latest automated review. pin and checkFrequency are consumer configuration, not derived state, but both deploy scripts rewrote them unconditionally. Redeploying over an existing deployment therefore reset a pin of "*" back to the current major line, or an exact pin used to freeze a repository, and restored a check frequency the consumer had deliberately slowed or disabled. Because the pin is what decides whether a major upgrade is offered at all, this silently changed update behaviour on a command the consumer would reasonably expect to be idempotent. This is the same defect already fixed in update.sh and update.ps1 in an earlier commit; deploy was missed, so the two disagreed about who owns those fields. Both now read an existing manifest and preserve both values, falling back to the derived defaults only when there is no manifest or it cannot be parsed. Verified in both implementations: a fresh deployment still gets "1.x" and "weekly", and a redeploy over a manifest edited to "*" and "monthly" keeps both. Also corrects the usage comment in the test suite, which still pointed at the pre-move ./tests/test-deploy.sh path, and the matching reference in deploy.sh. The suite must be invoked as bash scripts/tests/test-deploy.sh. Adds TC16 covering the defaults and both preserved fields. 108 bash assertions, up from 105. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/deploy.ps1 | 19 +++++++++++++++- scripts/deploy.sh | 18 ++++++++++++--- scripts/tests/test-deploy.sh | 43 +++++++++++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 2cda11b..d90f27c 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -753,7 +753,24 @@ foreach ($libFile in @('common.sh', 'common.ps1')) { Set-Content -LiteralPath (Join-Path $script:Target '.context/VERSION') -Value $DeployVersion -Encoding UTF8 Write-Host " Writing manifest -> $(Join-Path $script:Target '.context/manifest.json')" -Write-AcManifest -ContextDir (Join-Path $script:Target '.context') -Version $DeployVersion -Agents $script:EnabledAgents +# pin and checkFrequency are consumer configuration, not derived state. +# Redeploying over an existing deployment must not silently undo a deliberate +# choice - "*" to accept majors, an exact version to freeze, or a check +# frequency other than weekly. update.sh and update.ps1 already preserve both; +# deploy has to agree or a redeploy quietly resets them. +$manifestPath = Join-Path $script:Target '.context/manifest.json' +$existingPin = '' +$existingFreq = 'weekly' +if (Test-Path -LiteralPath $manifestPath) { + try { + $existing = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($existing.pin) { $existingPin = [string]$existing.pin } + if ($existing.checkFrequency) { $existingFreq = [string]$existing.checkFrequency } + } catch { + Write-Host " manifest.json is unreadable - rewriting with defaults." + } +} +Write-AcManifest -ContextDir (Join-Path $script:Target '.context') -Version $DeployVersion -Agents $script:EnabledAgents -Pin $existingPin -CheckFrequency $existingFreq Write-Host "" Write-Host "Done. Next steps:" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 44f1180..f41919f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -287,18 +287,30 @@ write_manifest() { local target="$1" version="$2" agents="$3" local manifest="$target/.context/manifest.json" local ctx="$target/.context" - local now first=1 + local now first=1 pin freq now="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + # pin and checkFrequency are consumer configuration, not derived state. + # Redeploying over an existing deployment must not silently undo a + # deliberate choice - "*" to accept majors, an exact version to freeze, or a + # check frequency other than weekly. update.sh and update.ps1 already + # preserve both; deploy has to agree or a redeploy quietly resets them. + if [ -f "$manifest" ]; then + pin="$(ac_manifest_get "$manifest" pin)" + freq="$(ac_manifest_get "$manifest" checkFrequency)" + fi + [ -n "${pin:-}" ] || pin="$(ac_semver_major "$version").x" + [ -n "${freq:-}" ] || freq="weekly" + mkdir -p "$ctx" { printf '{\n' printf ' "schema": 1,\n' printf ' "version": "%s",\n' "$version" printf ' "source": "%s",\n' "$AC_SOURCE_REPO" - printf ' "pin": "%s",\n' "$(ac_semver_major "$version").x" - printf ' "checkFrequency": "weekly",\n' + printf ' "pin": "%s",\n' "$pin" + printf ' "checkFrequency": "%s",\n' "$freq" printf ' "deployedAt": "%s",\n' "$now" printf ' "agents": [' for agent in $agents; do diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index b196cf6..4f20420 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -2,7 +2,7 @@ # Test suite for deploy.sh — verifies setup/ playbook deployment and regressions. # # Usage: -# ./tests/test-deploy.sh +# bash scripts/tests/test-deploy.sh # # Exit codes: # 0 All tests passed @@ -777,6 +777,47 @@ fi rm -rf "$TC15_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# TC16: redeploy preserves consumer manifest configuration +# +# pin and checkFrequency are consumer choices, not derived state. update +# preserves both; deploy rewrote them on every run, so a redeploy silently +# reset a pin of "*" back to the current major line. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC16: manifest configuration is consumer-owned ===" + +TC16_DIR=$(mktemp -d) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC16_DIR" >/dev/null 2>&1 + +if grep -q '"pin": "1.x"' "$TC16_DIR/.context/manifest.json" \ + && grep -q '"checkFrequency": "weekly"' "$TC16_DIR/.context/manifest.json"; then + pass "deploy: fresh deployment gets default pin and check frequency" +else + fail "deploy: fresh deployment has unexpected pin or check frequency" +fi + +# Simulate a consumer widening the pin and slowing the check. +sed 's/"pin": "1.x"/"pin": "*"/; s/"checkFrequency": "weekly"/"checkFrequency": "monthly"/' \ + "$TC16_DIR/.context/manifest.json" > "$TC16_DIR/.context/manifest.json.tmp" +mv "$TC16_DIR/.context/manifest.json.tmp" "$TC16_DIR/.context/manifest.json" + +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC16_DIR" >/dev/null 2>&1 + +if grep -q '"pin": "\*"' "$TC16_DIR/.context/manifest.json"; then + pass "deploy: redeploy preserves a consumer-set pin" +else + fail "deploy: redeploy reset the consumer-set pin" +fi + +if grep -q '"checkFrequency": "monthly"' "$TC16_DIR/.context/manifest.json"; then + pass "deploy: redeploy preserves a consumer-set check frequency" +else + fail "deploy: redeploy reset the consumer-set check frequency" +fi + +rm -rf "$TC16_DIR" + echo "" echo "=== Results ===" echo " Passed: $PASSED" From 59353d38f44d41c616a3f2926f4c979fc726351c Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 22:49:39 +0100 Subject: [PATCH 18/25] docs(core): use --quiet for the session-start update check Actions the remaining suppressed finding from the latest automated review. The managed block told agents to run "update.sh --check" while also requiring "Report at most one line" and a cheap session-start check. Those conflict: without --quiet, the up-to-date path prints a line and then calls report_local_state, which hashes the whole context tree and prints a further line for every locally edited or orphaned file. That cost has just grown. Expanding the manifest to track non-markdown base files took the scan from 78 files to 87, so the guidance was pulling in the opposite direction to the design goal it sits under. --quiet is built for exactly this: it exits before the local-state scan, stays silent when the check is offline or already current, and still prints the one-line notice when an update is available - which is the only case the agent needs to surface. Verified the offline path is silent under --quiet and prints a line without it, and that both implementations gate at the same three points. The manual invocations in README.md and the overrides README keep plain --check, where the local-state report is the reason a person runs it. README now documents --quiet, the trade-off between the two, and which is intended for whom. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 14 +++++++++++--- core/AGENTS.md | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f5d412b..bc5b466 100644 --- a/README.md +++ b/README.md @@ -307,17 +307,25 @@ carries `.context/manifest.json` recording the version it is on, and `.context/bin/` with the tooling to check and apply updates. ```bash -.context/bin/update.sh --status # local state only, no network -.context/bin/update.sh --check # is there a newer version? -.context/bin/update.sh --apply # refresh the base to the latest +.context/bin/update.sh --status # local state only, no network +.context/bin/update.sh --check # is there a newer version? +.context/bin/update.sh --check --quiet # as above, but silent unless action is needed +.context/bin/update.sh --apply # refresh the base to the latest ``` ```powershell .context/bin/update.ps1 -Status .context/bin/update.ps1 -Check +.context/bin/update.ps1 -Check -Quiet .context/bin/update.ps1 -Apply ``` +`--check` also reports which base files have been edited locally, which means +hashing the context tree. `--quiet` skips that scan and prints only when an +update is actually available, so it costs a single HTTP request and nothing +else. That is why it is what agents are told to run at session start, and why +`--check` on its own is the better command to run yourself. + The check costs a single fetch of a six-byte `VERSION` file from a CDN-cached URL — no authentication, no rate limit, and nothing meaningful added to an agent session. It **fails open**: if the network is unavailable the check reports so diff --git a/core/AGENTS.md b/core/AGENTS.md index 6469892..e3361bc 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -127,7 +127,9 @@ Base files under `.context/standards/`, `.context/playbooks/` and `.context/conv Check frequency: **weekly** -At session start, if `.context/.last-update-check` is missing or older than the frequency above, run `.context/bin/update.sh --check` once. Report at most one line. **Never block the session.** If the check fails for any reason, continue silently. +At session start, if `.context/.last-update-check` is missing or older than the frequency above, run `.context/bin/update.sh --check --quiet` once. Report at most one line. **Never block the session.** If the check fails for any reason, continue silently. + +`--quiet` is what keeps this cheap: it prints a line only when an update is actually available, and otherwise exits without hashing the context tree. Run it without `--quiet` yourself, at any time, to also see which base files have been edited locally. --- From 5e98b5dad880e229279a7b5e12d6efc6303b3e4e Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 23:03:24 +0100 Subject: [PATCH 19/25] fix(update): escape the substitution delimiter in manifest key lookup The character class escaped "/", which is neither a regex metacharacter nor the delimiter of the substitution it feeds, so the backslash it produced was undefined behaviour under POSIX. It also omitted "|", which is the actual delimiter, so a path containing one could terminate the pattern early. Escape "|" and drop "/". Verified that "a.b.md" still matches its manifest entry and that "indexXmd" still does not match "index.md". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/update.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/update.sh b/scripts/update.sh index 79bceb1..f530e8c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -101,8 +101,11 @@ list_diverged() { actual="${line##* }" # The key is interpolated into a sed pattern, so regex metacharacters in # the path (every ".md" contains one) must be escaped or they match more - # than the literal name and can report a false divergence. - rel_escaped="$(printf '%s' "$rel" | sed 's/[][\\.*^$/]/\\&/g')" + # than the literal name and can report a false divergence. "|" is escaped + # because it is the delimiter of the substitution below; "/" is not, since + # it is neither a metacharacter nor the delimiter, and a backslash before + # an ordinary character is undefined behaviour in POSIX. + rel_escaped="$(printf '%s' "$rel" | sed 's/[][\\.*^$|]/\\&/g')" recorded="$(sed -n 's|.*"'"$rel_escaped"'"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*|\1|p' "$MANIFEST" | head -1)" if [ -n "$recorded" ] && [ "$recorded" != "$actual" ]; then printf '%s\n' "$rel" From a5aa682ffd2d892272403afd57d1cf8cb6f84da2 Mon Sep 17 00:00:00 2001 From: ldastey-dev Date: Sun, 6 Sep 2026 23:07:45 +0100 Subject: [PATCH 20/25] fix(update): treat index.md as mandatory payload content The apply path copied index.md only when it was present in the downloaded archive. A payload missing it left the previous index.md in place while every other area was replaced wholesale, so a deployment could carry a stale routing table indefinitely while the manifest and VERSION advanced. index.md is the routing table; without it no standard or playbook is discoverable, so it is mandatory base content. Validate it alongside the other areas before anything is deleted, and copy it unconditionally. Deleting it when absent would be worse than keeping it, so refusing the payload is the correct outcome. Applied to both implementations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/update.ps1 | 17 +++++++++++++---- scripts/update.sh | 10 +++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/update.ps1 b/scripts/update.ps1 index d67261b..dca9456 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -228,6 +228,18 @@ try { } } + # index.md is the routing table - without it no standard or playbook is + # discoverable, so it is mandatory base content, not an optional extra. It + # is validated here rather than skipped at the copy: skipping left the + # previous index.md behind while every other area was replaced, so a stale + # routing table could survive an update indefinitely. Deleting it instead + # would be worse. + $srcIndex = Join-Path $srcPath 'core/.context/index.md' + if (-not (Test-Path -LiteralPath $srcIndex)) { + Write-Error 'Downloaded archive is missing core/.context/index.md - nothing was changed.' + exit 1 + } + foreach ($area in $areas) { $dest = Join-Path $ContextDir $area.Name if (Test-Path -LiteralPath $dest) { Remove-Item -LiteralPath $dest -Recurse -Force } @@ -235,10 +247,7 @@ try { Copy-Item -Path (Join-Path $area.From '*') -Destination $dest -Recurse -Force } - $srcIndex = Join-Path $srcPath 'core/.context/index.md' - if (Test-Path -LiteralPath $srcIndex) { - Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force - } + Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force # Refresh the update tooling itself, so a fixed updater reaches consumers. $binDir = Join-Path $ContextDir 'bin' diff --git a/scripts/update.sh b/scripts/update.sh index f530e8c..7527095 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -268,6 +268,12 @@ for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$ from="${pair#*:}" [ -d "$from" ] || partial_apply "downloaded archive is missing ${pair%%:*}/ - nothing was changed." done +# index.md is the routing table - without it no standard or playbook is +# discoverable, so it is mandatory base content, not an optional extra. It is +# validated here rather than skipped at the copy: skipping left the previous +# index.md behind while every other area was replaced, so a stale routing table +# could survive an update indefinitely. Deleting it instead would be worse. +[ -f "$SRC/core/.context/index.md" ] || partial_apply "downloaded archive is missing core/.context/index.md - nothing was changed." for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$SRC/core/.context/conventions"; do name="${pair%%:*}" @@ -279,9 +285,7 @@ for pair in "standards:$SRC/standards" "playbooks:$SRC/playbooks" "conventions:$ fi done -if [ -f "$SRC/core/.context/index.md" ]; then - cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" || partial_apply "could not write index.md." -fi +cp "$SRC/core/.context/index.md" "$CONTEXT_DIR/index.md" || partial_apply "could not write index.md." # Refresh the update tooling itself, so a fixed updater reaches consumers. mkdir -p "$CONTEXT_DIR/bin/lib" || partial_apply "could not create .context/bin/lib." From e6101544f5f7218bb44086b833b9aaa42fd6c199 Mon Sep 17 00:00:00 2001 From: Leigh Dastey Date: Mon, 5 Oct 2026 09:23:37 +0100 Subject: [PATCH 21/25] fix(migrate): recognise every pre-versioning revision and convert AGENTS.md in place The unversioned baseline hashed a single snapshot of main, but adopters deployed from whichever commit was current. Every file the library had improved since an adopter deployed was promoted to a "mode: replace" override - pinning it so it never updated again - and files the library had since removed were kept as if the consumer had written them. - Regenerate unversioned.sha256 from the full history with the new scripts/ci/write-unversioned-baseline.sh; a path may now list several known revisions, and ac_baseline_match / Get-AcBaselineStatus treat a match against any of them as pristine. - Remove unedited files the library no longer ships instead of keeping them as consumer additions. - Keep an edited index.md as an extend override rather than overwriting the consumer's routes. - Replace the framework region of AGENTS.md (## Context System up to ## Project-Specific Rules) in place with the managed block when it matches a shipped revision, instead of prepending a second copy of every framework section above the consumer's title. Falls back to prepending when the region was edited. - Infer the manifest agents from the deployed files instead of writing []. - Ship .context/.gitignore so the per-session .last-update-check stamp does not dirty consumer trees. Applied to both implementations. --- core/.context/.gitignore | 2 + deploy.sh | 6 + scripts/baselines/unversioned.sha256 | 121 ++++++++++++++++ scripts/ci/write-unversioned-baseline.sh | 73 ++++++++++ scripts/lib/common.sh | 49 ++++++- scripts/migrate.ps1 | 176 ++++++++++++++++++----- scripts/migrate.sh | 161 +++++++++++++++++---- scripts/update.sh | 3 + 8 files changed, 525 insertions(+), 66 deletions(-) create mode 100644 core/.context/.gitignore create mode 100755 deploy.sh create mode 100755 scripts/ci/write-unversioned-baseline.sh diff --git a/core/.context/.gitignore b/core/.context/.gitignore new file mode 100644 index 0000000..c15fe86 --- /dev/null +++ b/core/.context/.gitignore @@ -0,0 +1,2 @@ +# Per-machine state written by .context/bin/update.sh. Not shared. +.last-update-check diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..c0dff0e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# deploy.sh — compatibility shim. The deploy script lives in scripts/deploy.sh. +# +# Kept so existing instructions and automation that call ./deploy.sh from the +# repository root keep working. New usage should call scripts/deploy.sh. +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/scripts/deploy.sh" "$@" diff --git a/scripts/baselines/unversioned.sha256 b/scripts/baselines/unversioned.sha256 index 1985945..fdadb1b 100644 --- a/scripts/baselines/unversioned.sha256 +++ b/scripts/baselines/unversioned.sha256 @@ -1,55 +1,166 @@ +AGENTS.md#framework-region 05600dce653e392038ef8823c932d55a48b111c4263df26cf9d801c329234d32 +AGENTS.md#framework-region 07bbe58e3542c325262a9ac50ff00ca3899d94cf7722a493146b886f4bcd54d4 +AGENTS.md#framework-region 46b92d8fea75280193df0cf1029b88299e38d32e5abf8af87d3dc5de34b6adf2 +AGENTS.md#framework-region 53f8ade9a7c77cf5ac6fc8bea4512d10565f167c2c4cabbe8f2b04173e887b30 +AGENTS.md#framework-region 7663f9f9c489952973e7d39f93e014184d1bfa9bed784f29f9afdb75c827694b +AGENTS.md#framework-region 7779f356611e756661d865729ffe0ddecf66d22f09966842f98db3599ed151b3 +AGENTS.md#framework-region 9893b3923cfc8c11687edbe70c15374db07862a3356bd17fd98e8ee7ad433c9a +AGENTS.md#framework-region c732e50bdc2b7b5f78c5f19e7864f76bb47292f3013a8c91113e3d4cf3376f85 +AGENTS.md#framework-region cd76c473a538a085f81e2d9a17e167a21c4c7eba89e706ca900951c2cced0231 +AGENTS.md#framework-region d7daa9652eb58f52fb580806e6317e47957d76c3c7af65da61d2ae81750b5ca1 +AGENTS.md#framework-region e69045a9d0f7694ed765b5580f3484e90d4bd6234652549eb9b43ddab09e6c41 +AGENTS.md#framework-region f50bb86d40b7dcd9d3875cb07919387c627f10d17d151c47575da6c52ce6d014 +AGENTS.md#framework-region fc77b0ed91e5c876a4705b09433d4f66dd36cf63fbea6569eae6f8545abd2e2b conventions/code.md 306b9916bd0ebc87c418b4ded90a56c600708b0144a37de4aaca53484e19fb25 +conventions/code.md f826aebb6d455271b43fbff19da7492bbc79f58aa1900c6ad0bbbb1a7097652e conventions/communication.md 508a9981cc3830e9992eb6edb30473c06ed149bcc63bab63fb8683e277d6ee03 conventions/workflow.md 6cc7f88c4dde53b9a713f9257060497abdf3e343c6e09b515b0cdb38464eb8cd +index.md 0e73d3f1d262aeff028504ae61c9c0c0f681700ba723bbb195cf846b31f0724c +index.md 0fb681aa5c4ea4133e517f40d3b4fc2ba170a707e5fd86472afc38b701dedf33 +index.md 12ee0884d0b4d1fa6e2580e60635e80079f36c60b45af4ead9af1b53f82ef87c +index.md 32a985ba27f992afdbe28da8f415c4e391ca7cd4ca87922e2ede8325ea4524bf +index.md 4ccbb01ce1418141e75632f318714cc2ddd49887f928049e4688802ce4b5bdc2 +index.md 50e5866d2ca9d9fbc5e93d772ea627a4f7bdd81688675dfa9ba27250b2be617c +index.md 51595dafbc732653bfe5a00246cde38c68db0f7b4d5742f26a7432668a22534d +index.md 51da2033ae4e64f0feac6a7235717326294c188bcdef75e5eaabcd8627a54640 +index.md 68427df841c6c117bc6e98596c0e29d12617b54797bdd5c2a52cec36190c07ba +index.md 69780811b571c4b135b86aa6658ad0ca17ba5d08d224373b5c4e2ea9d5b04e64 +index.md 83a9ae0afbd6373ea138a1aab50d0755dffc97d588aa6b8eda8ff1ac5b55e491 +index.md 8d4b82ca8c247b8991761907aab15c629aae44db4e3358d72610456a3ac28664 index.md 9081522a512ac330d633c4cedd3f07edb792928b35ad8640b0b853c27fbb1789 +index.md b4cbd980c42e8fc5692a167a90fe332b8fc0a2b94f0dc3d97f86205e0ba2e81b +index.md e05cb576dfbad33a6dbf85526583d2d3abd6e594e8e9c8aa163b8399c93a594a +index.md e4be7c56a5573793e2d7dc8cad56c206f9d19b286a7e3be2e09ff3b79b9642c7 +index.md e4bef4de208914c563a7a63801206366a41ed0ccdfd52c6eaae5dfc64a7af18a +index.md ef432a1a9c6b9eaba3deb9a659ad3d5d1da45345806628fe080cc43eda356be0 +index.md fea00911919a950ca9de2ea79a308aeb54bbf0647619f85da6a74a079fce3b29 playbooks/assess/accessibility.md b9349df70e40518df65ef528f7366d288ffb5183fa2ef95f0641feb02ba6363c +playbooks/assess/accessibility.md e3acdf8af25c5ca317f4126925f535840ebe1deb670865e9b2f77cf468fc34a4 +playbooks/assess/api-design.md b51892ede7e00ef2e7d12f672c4c0da68589638538ed68c0581fff640ed43c7a playbooks/assess/api-design.md ffb618ba0ce0e6909d89e18f1fceef214e1df5eac4619593091b415aa4318763 +playbooks/assess/architecture.md ba1dafe9eaa44b761e80636ad6254c0b44a10c9b036da4a9f90a6fb2dd5c5c37 playbooks/assess/architecture.md e8b1de975d950f1d85a5ee7380e5a2385809711bc71824886c87c3399f0fa305 playbooks/assess/aws-well-architected.md 962ca955caf258746d4b50b72ac6d48390a330f524a8d2e2065d1f185adb8d2d playbooks/assess/azure-well-architected.md 05ccf31371bd07c918a2a2ff255e37a14acdcbf584927663f2dc03d0dc7fa66a playbooks/assess/ci-cd.md 339e25cb98ebd5d2c3d9092d67f8a66d28388b4b92849c3c082f7d7f5b7e6f91 +playbooks/assess/ci-cd.md 5e177aa43610aabe12515c956147d8f575657c3956aabc95941cf77cf190782a +playbooks/assess/code-quality.md 5d2d7aedf26c7a5774a415af3baa6c789420091ec563c336d1f381a7e9392537 playbooks/assess/code-quality.md 8695ebbdf1be36e82e704e32dd3655e894873686801861b704b9f36b0494eab0 +playbooks/assess/compliance.md 38cfe4a0e0a056e0b55d2db8a5ddc6cf2f5e00d33c6f610ccc69e12ca4355bce playbooks/assess/cost-optimisation.md 04dda1b90eb7db6726cdfb6fa0eb2a3ae29f897488a47502887654c3a7e56931 +playbooks/assess/cost-optimisation.md 74848df4b3cf1288ecb4a634bfd7215e335df9c2c18fe7bb951f48b97cc25199 playbooks/assess/domain-security.md 3b7ab6ddfee426b9c956c948f54890cdf124a31b09c7bb4ead4e96f49547504b +playbooks/assess/full.md 815a857b5da895f56fd1522905c3925f38923d04d1598134fd6aa0a71f0fa4c7 playbooks/assess/full.md d560735f7deab0b9f377ce33458590c8d6bd76da81c5e91b5ed55ef5da2ec3ac +playbooks/assess/gdpr.md 054ae601ba346497c60599addc636029c723dd2651e3c9d8e1061cc99b44352f +playbooks/assess/gdpr.md 7b838bb450cb6d39764053ee3deac503796251226096c7b96627033747963ec0 +playbooks/assess/gdpr.md bc18db84c7917d039f08bafb95071ab5231bfb263f2d408088e5093529bf7d12 playbooks/assess/gdpr.md c3a0727188cecae887be68adae8ba98eb1f2c598e06d4db6c439479191c60258 +playbooks/assess/gdpr.md ca5c89169bbb885827e13a2114a1c59a19a40596c8045d94e143614a34f87b19 +playbooks/assess/gdpr.md cae1da5f6aea9f01dea8402f2e38bd02e0aa9b26d141fa308890f5c9c15d498e +playbooks/assess/iac.md 8b98da9a863d2891e1ce283672de264d4406424883c3763713142e92a676e50e playbooks/assess/iac.md 9977d505a067d47ad8b40a6e14fd501cec1b9082d432f51dd76d93b174e07711 playbooks/assess/observability.md 04e05fbfbbc762871faab52de3ba85acc8711b7ddd1adfdf22edd35047b95cf3 +playbooks/assess/observability.md a795c4afdcfa4be78afdefdc068cf7fcc2bdd01268080cdf14629c9f7a0c4913 +playbooks/assess/operational-excellence.md 1d3a5189f588a40f0f38b2a3faa08738edf36574957c43720fe0ee152fb9cec9 playbooks/assess/operational-excellence.md 6e1c03adbdf22a7a6dc6cc809736fe97c71d0043cd8e83834fa102fc9ab0f6a6 playbooks/assess/pci-dss.md dbf890ac9882322d6a3019465f7e2b0a1d9f030410f97b7583f77a8d3b190446 playbooks/assess/pen-test.md d2897e4e7d8c229228085ec9cabd86a390a00867a0ef55bd7057114e6a1698f0 +playbooks/assess/performance.md 93b145c2cc987d854459279ad85268c36245ddc54b16bf0d20f8aaa2f2da296e playbooks/assess/performance.md b7e080aea72e168ebc3aedb7dc97cf410cd98f102ded3dda0df86017a61d9f23 playbooks/assess/resilience.md 666e2ab2db632f4525a69fd767870bf30f7759e30e4960383cbf28d3d83e48fc +playbooks/assess/resilience.md 6f87548dda8d68a1ced72092e73370ad3b2e4e747ab308b9855ce6eb0fdac32a playbooks/assess/security.md 5788e854bc32e1a1acdca278a579616c3e6f0e329b0a78280fcdc848895f7d38 +playbooks/assess/security.md ac6b64e59649f94ab806aa8a070c6a63e53e1140fa998c22b741b64c1eb019cb +playbooks/assess/tech-debt.md 09b149e01126b9c97ff678659257fda9f10c97b43fb0ebe00ca23ed75e917827 playbooks/assess/tech-debt.md bd7f183b5f13556d01852be2ecd29f168725e5da8cb3fb93d5b5defec415fe89 +playbooks/assess/test-coverage.md 19b9e4d79a07871b52c862a24054c0b16838f43c9e236cf68f41fbe7e4918086 playbooks/assess/testing.md 3e37c8ab2ac430184b0a80510ab9228862e9bcf0a3811d14bb64082296cf2cb7 +playbooks/assessment/api-design.md abd926b550ba8345ae47ee701e44c9e4d15d3e7e505eb1345432987e952f2765 +playbooks/assessment/architecture.md 8ec79eced66864307217476091e17b28e2a4dd70104fa0bdc118e26d7c5932ff +playbooks/assessment/code-quality.md de4aa9d6d02df42e5d5c9f52088f564ded0b11954c9a93d6c75d68ddef574125 +playbooks/assessment/compliance.md 4c43c3a081edb1fa1702872a0e5c097ae41d958c901c269ade63cce076c5a50e +playbooks/assessment/full.md 5ba4e34f6618fb08c7fb8b10537865b4dcb72635dd84d72af422ea69d39f10b5 +playbooks/assessment/iac-maturity.md 7cab087e44b32e8d775f6a7bbf5f61c300cd673bd0a36dc1c3bc75314abd7b9b +playbooks/assessment/observability.md 4f370943cf6c9d4477e439f4dd703c3858c3e4806bd458972c744589a67e6735 +playbooks/assessment/performance-resilience.md 1c09b3e02b7b9af225b740b42719577469b8cb1da1c4d195acccb19bda3453dc +playbooks/assessment/security.md 1a18d9fb3e210e97386f731b21685dcd077c9c16d778a465dc3d432b2f688b86 +playbooks/assessment/technical-debt.md 5e25ee63dbf53968924cc677ac5a465243c2dc1b23f726916351a2217679e58e +playbooks/assessment/test-coverage.md 4e42612fed1d82dd9bf4284fc87df1ac11b4718f3dd78b75eab8a8f6567ded49 +playbooks/assessment/test-coverage.md 623fd6328e1a665079efc848a05917333e2375072ffc662c33404771626c6da2 playbooks/debug/scientific-debugging.md 1a52e094b429b34074428d3d215feffbdf5a44d6cb66d8d64cdbbea8208531eb playbooks/docs/gitbook.md 5951d16a59db69e693e07845f11129bb122a13bebbaa3ab177a733365d619f06 +playbooks/docs/gitbook.md ae4d294347fef67bbda7736d465b794af08cb7a7e83d7d16603b542bb94611e5 playbooks/plan/adr.md 1015979a43df16e3f469b4f4f5fb8afe15df300d29cda400f05fc977f4ea7c7a playbooks/plan/design-doc.md 938a2ea9ee36e10e0caf70ef51149ef2c44d45dbe4f94cecf457e4c536dfb5ea playbooks/plan/research.md 8b0c774783cb369bafd09e37b4be641224ee836caf564f14ab7f3536f5e73347 playbooks/plan/risk-assessment.md e68d1dbbcecf3ac31f0842c2ea37f28e176d9edb72bffea0c7c95d36d221711e playbooks/plan/spike.md 29b9f19ff3a030eb7bdcf67b9903fb919282cd78997ca2103c4729328fc2cef4 +playbooks/planning/01-technical-design-doc.md ee5534cb69babdd8b465c81b2b90ad94860d93fd15cce3d0f0f0fc18e43ef545 +playbooks/planning/02-architecture-decision-record.md 72b18e3f3972949afe579a941695cc854be4e9520174faf358a1e623662480ec +playbooks/planning/03-risk-assessment.md d749f4847143c9ca98ff5f661a73c4985f14bd4c1d23f680e6d0d518042e6cb1 +playbooks/planning/04-spike-research.md 51e22c7ed0897e65a275677d92eeb81c4702655deed7de1113de29910f4da47d +playbooks/planning/adr.md 72b18e3f3972949afe579a941695cc854be4e9520174faf358a1e623662480ec +playbooks/planning/design-doc.md ee5534cb69babdd8b465c81b2b90ad94860d93fd15cce3d0f0f0fc18e43ef545 +playbooks/planning/risk-assessment.md d749f4847143c9ca98ff5f661a73c4985f14bd4c1d23f680e6d0d518042e6cb1 +playbooks/planning/spike.md 51e22c7ed0897e65a275677d92eeb81c4702655deed7de1113de29910f4da47d playbooks/refactor/dependency-upgrade.md b8bdf61de51e5f98fea320971c356a9f8bd4e73706ba3f87ee6e98dffd85e86a playbooks/refactor/extract-module.md c3d67a579216457e471263d09e6783bf93e1c9e701682fa72a015402469764d3 playbooks/refactor/safe-refactor.md c767d5bd189d3868163dcdaa786371e4b88e246a3ecd0a64aaba09f10d185416 +playbooks/refactoring/01-safe-refactoring-runbook.md 02a3cf6e7cc2deb63b5b116334efd4e0afdf497833eead6c1e5d2abedad037a7 +playbooks/refactoring/02-extract-module.md 5e702feb94a39fa0a068c67f98be921e925443392a77258a3d4ad4363d85fa7f +playbooks/refactoring/03-dependency-upgrade.md 0f7f4e133970c9ce0499b211ed8876c084c90afaed17217134cbb951b6b64a0e +playbooks/refactoring/04-technical-debt-reduction.md 5e25ee63dbf53968924cc677ac5a465243c2dc1b23f726916351a2217679e58e +playbooks/repository-review/00-full-assessment.md 720f06d6bf0ccb5db2f2511d1a1c3dab327a4189a827ca5c649e0789f264058d +playbooks/repository-review/00-full-assessment.md c3214365365087c8fa5289bebec17258b72fa08e36971840555d84754ea6cf49 +playbooks/repository-review/01-architectural-assessment.md 8ec79eced66864307217476091e17b28e2a4dd70104fa0bdc118e26d7c5932ff +playbooks/repository-review/02-security-assessment.md 1a18d9fb3e210e97386f731b21685dcd077c9c16d778a465dc3d432b2f688b86 +playbooks/repository-review/03-test-coverage-assessment.md 4e42612fed1d82dd9bf4284fc87df1ac11b4718f3dd78b75eab8a8f6567ded49 +playbooks/repository-review/04-solid-clean-code-assessment.md de4aa9d6d02df42e5d5c9f52088f564ded0b11954c9a93d6c75d68ddef574125 +playbooks/repository-review/05-iac-maturity-assessment.md 7cab087e44b32e8d775f6a7bbf5f61c300cd673bd0a36dc1c3bc75314abd7b9b +playbooks/repository-review/06-performance-resilience-assessment.md 1c09b3e02b7b9af225b740b42719577469b8cb1da1c4d195acccb19bda3453dc +playbooks/repository-review/07-observability-assessment.md 4f370943cf6c9d4477e439f4dd703c3858c3e4806bd458972c744589a67e6735 +playbooks/repository-review/08-api-assessment.md abd926b550ba8345ae47ee701e44c9e4d15d3e7e505eb1345432987e952f2765 playbooks/review/accessibility.md 21e31f3116b8690373dae060d850f776f05154bee0c5f038ba4f0c2f16f14ed1 playbooks/review/api-design.md 113721e0474bb09580fd11dc4b96edccf9b9916330dd794988e2aa10a6a57ee9 +playbooks/review/api-design.md 74f207da849e6cbaf823873f79b8c8cef91c427061d29ba7c04e265f6c144c03 +playbooks/review/architecture.md 3b1474280f7ca9e4f441a18fb88afca7edcd23fc9a0b7cd45083f3130ddad3fd playbooks/review/architecture.md 8deb5eb26771519a7f0612d2eaedb0a78f12b1560f10e18c377b80b5fef05c7c +playbooks/review/architecture.md ede077ffe43c748e84b4489c226b34ae4ed063352f24c8dd9d92762a3021532e +playbooks/review/code-quality.md 023a1304d119007b40da67aa601c443576fe1e66b70bcad904ca9263d6e48d6c playbooks/review/code-quality.md aedfb78f9c96d827096f8714a4e2f872423aacd9c1eb54ca6e8cabec1a850988 +playbooks/review/compliance.md 19924a2ef3bcb2678f399bf44fead68531d76c1881b1e3ca26c35608ec15699f playbooks/review/compliance.md f7a6c4d568b70ecc133ba7bd7747139a715cc19a0b06b5821b7e7e11f54c46bf playbooks/review/cost-optimisation.md 69a8bd5c8bc5ec3372c5a7ba39e076c40c3729a1b645f75f33c6e1a12dd448bc +playbooks/review/iac-maturity.md 681b2fa60f704f736ed733f8b38c6e6eb376673e578a43c6eafb59f59554fe41 +playbooks/review/iac.md 6ed7e2805b95cc0af76c743b6d120463a8f43030434e07aacdd4eee8cc57cc75 playbooks/review/iac.md 8449c682b18e38040cfa42cb721c532556c0b190878691fe6dc5581a327e3070 playbooks/review/observability.md 4796c378b6067fd8192a10b5ff21aaf04375c9d08acde77e64f6f733e0f767b2 +playbooks/review/observability.md 6f6434b2b253bb7de6c35a0921776a55284012b1ec60732122ac30f60802995a +playbooks/review/performance-resilience.md fbf6552960a807e9eaffb31095bd6e62e4dc52e8fe88095718552d3086b6d84c playbooks/review/performance.md 36a17da7faaeaf6848ed24d1528f4ad98369d2a3cb40479477fafc16099032c7 +playbooks/review/security.md 29ebbbdb9b63748c77fe606a5282a8a1ed18f2c8983287abc0ccfba35880cdc1 playbooks/review/security.md 5a209101a4c54199e370c54079464843f4ca480597e42080d4e61eb6dc2baf37 +playbooks/review/test-coverage.md 29c9c9d0be655ac7b3bbca8036b04b6d932713152ee9b6fb69afc51d0ba89dcf +playbooks/review/test-coverage.md c0455e559ba52b38162cc85cbd516ae925b643947e75faea14e0db2b502b594e playbooks/review/testing.md eae8dec22058ccf5db9771ea461f1dd404a549ce072f61ad725174e78cb882b4 +playbooks/setup/create-local-otel-stack.md 085154d6d924bbde6b1d2c622703ab9a08ac53f357d1c2ebeafe02107b85bfb5 playbooks/setup/create-local-otel-stack.md d085a19dd7d426a89fa7900bbaadfa581aafd69c6117f7b35312a6d9f8d43049 playbooks/setup/discover-local-otel-stack.md b226d151297ad068c6195d9dc52ef001c605219f0e6593aa71f8656649bbb9f4 +playbooks/setup/discover-local-otel-stack.md fca3574c6a107094485297dab48f974af5999471431e9b41df3af229909542bd +playbooks/setup/instrument-dotnet-otel.md 35fdadbc0c089e91c2ece381a80ae59694f77b4c7f6335cb4bb73ea607887aae +playbooks/setup/use-local-otel-stack.md 180df32e273585ddd9b3e7466e19516f7dcd1f4c69193623b0ba980b2c69222f playbooks/setup/use-local-otel-stack.md 75f5ffbbf83f87495e00d8994b7de79409434b4f1e656734bffa00cdb3a3a2d3 standards/accessibility.md 222c94e68e30f73081913e46eeb2d423eee8306b7753899729ba83f9560ffc75 +standards/ado-pipelines.md 3f75ef9a3042b1cfd45db92c30b0c9a38e82324f41dd6c9b1e0402203960e583 +standards/ado-pipelines.md 6240d322009b1a192c899054aa0860e26f27f69dc7cda811b3c179f46e9ac8a2 +standards/ado-pipelines.md 9185125bdf2c3faf787d39be3fbde78e46cb155ed4d6fb7f694e3692e4e02728 +standards/ado-pipelines.md 9a59b388de3c205949367e6a9d3be298383c3926059b0479665db425b2558fef +standards/ado-pipelines.md aeea8574e26c474bdab2b672a99541a32f5ddefb78257df7e2fd27742d112f2b +standards/ado-pipelines.md b98717360705ada4f0bb32df3ec5530e0c30c9b9ecfaca2364625ffde26092f9 standards/ado-pipelines.md ecfaf7686ec26502e945864d3babed3da156c955e937a845c9802d78f24d5b34 standards/api-design.md 93fbe27830ce29ddc0510088a178e9a27cfa56b1e697a88b9a93adad48b0658b +standards/api-design.md e9be58dfe6eec16eb0591936c6fa664dca30f4b2929e3319b6297a3e7543302d standards/architecture.md a1a1ff3f370f56c4ce82519bb137389985d666646c9aa252bc644233d1067762 standards/aws-well-architected.md 3ef134cee4986cb4ef85cdd113663966d85917bf7e7b602b3b79ef43e87aa7e6 standards/azure-well-architected.md 6b18818e64c4b17fbf5e348f1ba78ff6e0bd78c3e77560b5b1e11018d309c30f @@ -59,7 +170,14 @@ standards/cost-optimisation.md 73c60ce6e398a703ebf035d960ccc17ddf05cac3d26ab6a1 standards/debugging.md af61c44c446009d72adfadf0c996e3161ca5c533e85e0bfd5a64aa6fd9a6b0ae standards/docker.md af46fa7c7a95a313082587015f72f44421300502ef91e0f97f7df7f5282b846c standards/dotnet.md ae7aecfe9167bf442e318e8336384326917f6823095bf88d6dfe250764cd8095 +standards/gdpr.md 03d8dc1c18738e5cbab1123e7a4b9f18aeb984b50e03984ef324fb518bdd7f0b +standards/gdpr.md 2c8284037e8abd07c460f9f5fde2a316e9f25e732d6183a12124ded1f9f012ca +standards/gdpr.md 46401eab094e5b5641533bde3b8851edeb1bd2fc800400c00386b6c09fae06a5 standards/gdpr.md 6fdd28381e69ee097839b1f2eeeceead1a17b98e9e5b906727a6702022b58cc8 +standards/gdpr.md 70a3778dbb45bb577c5dff4d9e1de7cb61c51471ce7a8933e8befb6cd8477e03 +standards/gdpr.md 8d712e6f9389aee5888cd4e0e5bbbc196e8468ad23981900309d19954bfd7b2c +standards/gdpr.md 926ad95210832a6f63080649bdba59e2e15d4b9fdc1bda8bde68b9d6d8d6d3f4 +standards/gdpr.md ccc21f190f192c3367c786445d0905486c36d519bc1f2b7a67424413ba36720f standards/iac.md 42e3f14947e6e666efcac2a90da19308828c87b0d0f2ffb374e7205320a2f73e standards/mssql.md 2d66ad11557e317a1bf52163c01900a3ff0f6f71655c1289efb7cf806f1de25b standards/observability.md 715b0e76ad1a42049d83c7d5afb694e1cd52d0289c0341cd97c32646fe816a1d @@ -69,6 +187,9 @@ standards/operational-excellence.md b3ab56143a895b8b38ca549a7899555e1013a7486b0 standards/pci-dss.md 1ed1186eca0f2009d43e2aade351a932f4ba35b69b9153a39d38986107a6b504 standards/performance.md b3f6963bf3a21ca3a23a5cb9b4678d1eeeb96316452305649c4fe95ca250bbd6 standards/playwright.md 435c1630df617db05a217ee9d2cf81736db011fa982bab9f2f6d810b54cf5861 +standards/playwright.md b1ff16bf0b9ae452a872cf48e7da27d1cbf40547ee651cb93bc7be12e4eb2cc7 +standards/playwright.md c34a078d1ac15fc5ac83d0a91a4c701cc3514d27d933edf16cb3be752c385490 +standards/playwright.md c8a74de8b9c7363016425844ff561888d1a3f49155167f7b178c75aca0d7b38d standards/powershell.md 4644e1ef2f28360566f5beebde38ca23ea7e611e5e3d3cfa7f55cb59467a5f05 standards/react.md f4cdad1447ef376de05583d69cbaed1f83fc2e0371b8476d34efd29c373df57a standards/resilience.md 4e6550f5ffca539c78cc392ed9094096d97c3a73f8382cd38bff8000d6aaad1e diff --git a/scripts/ci/write-unversioned-baseline.sh b/scripts/ci/write-unversioned-baseline.sh new file mode 100755 index 0000000..30c833b --- /dev/null +++ b/scripts/ci/write-unversioned-baseline.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# write-unversioned-baseline.sh - regenerate scripts/baselines/unversioned.sha256. +# +# Unversioned adopters deployed from whichever commit of main was current at the +# time, not from one fixed snapshot. A baseline built from a single commit +# reports every file the library has changed since an adopter deployed as a +# consumer edit, and migrate then pins it as a "mode: replace" override - so the +# adopter silently stops receiving improvements to it. It also mistakes a file +# the library has since removed for one the consumer added. +# +# This records every revision each path has had across the history of , so +# a file matching any of them is recognised as pristine. Hashes are taken with +# line endings normalised to LF, matching ac_sha256_lf in migrate. +# +# It also records every revision of the framework-authored region of the +# AGENTS.md template (see ac_agents_region), so migrate can swap that region for +# the managed block in place instead of leaving the consumer with two copies. +# +# Usage: write-unversioned-baseline.sh [rev] (default: HEAD) +# +# This file is committed by hand. It is not CI-owned and is never rewritten by a +# release. +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/lib/common.sh +. "$REPO_ROOT/scripts/lib/common.sh" + +REV="${1:-HEAD}" +OUT="$REPO_ROOT/scripts/baselines/unversioned.sha256" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT INT TERM + +blob_sha256_lf() { + git -C "$REPO_ROOT" cat-file blob "$1" > "$WORK/blob" + ac_sha256_lf "$WORK/blob" +} + +# Map a source path to the path it is deployed at under .context/. +target_path() { + case "$1" in + standards/*.md|playbooks/*.md) printf '%s' "$1" ;; + core/.context/conventions/*.md) printf 'conventions/%s' "${1#core/.context/conventions/}" ;; + core/.context/index.md) printf 'index.md' ;; + *) return 1 ;; + esac +} + +# Unique (path, blob) pairs across history; each blob is hashed once. +git -C "$REPO_ROOT" rev-list "$REV" | while IFS= read -r commit; do + git -C "$REPO_ROOT" ls-tree -r "$commit" -- standards playbooks core/.context/conventions core/.context/index.md core/AGENTS.md +done | awk '{ print $3 "\t" $4 }' | LC_ALL=C sort -u > "$WORK/pairs" + +: > "$WORK/out" +while IFS="$(printf '\t')" read -r blob path; do + if [ "$path" = "core/AGENTS.md" ]; then + git -C "$REPO_ROOT" cat-file blob "$blob" > "$WORK/agents" + if ac_agents_region "$WORK/agents" > "$WORK/region"; then + printf '%s %s\n' "$AC_AGENTS_REGION_KEY" "$(ac_sha256 "$WORK/region")" >> "$WORK/out" + fi + continue + fi + rel="$(target_path "$path")" || continue + printf '%s %s\n' "$rel" "$(blob_sha256_lf "$blob")" >> "$WORK/out" +done < "$WORK/pairs" + +LC_ALL=C sort -u "$WORK/out" > "$OUT" +echo "Wrote $OUT ($(grep -c . "$OUT") entries, $(cut -d' ' -f1 "$OUT" | sort -u | grep -c .) paths)" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 0d135a0..c1c0cd5 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -253,11 +253,50 @@ ac_sha256_lf() { fi } -# Look up one path's expected hash in a baseline file. -ac_baseline_lookup() { - local baseline="$1" path="$2" - [ -f "$baseline" ] || return 1 - awk -v p="$path" '$1 == p { print $2; found = 1; exit } END { exit !found }' "$baseline" +# Classify one file against a baseline. +# +# A baseline may list a path more than once: the unversioned baseline records +# every revision the library ever shipped for a path, because unversioned +# adopters deployed from whichever commit was current at the time. A file is +# pristine if it matches ANY recorded revision. +# +# ac_baseline_match +# 0 - the hash is a known revision of this path (pristine) +# 1 - the path is known but the hash is not (edited) +# 2 - the path is not in the baseline at all (consumer-added) +ac_baseline_match() { + local baseline="$1" path="$2" hash="$3" + [ -f "$baseline" ] || return 2 + awk -v p="$path" -v h="$hash" ' + $1 == p { known = 1; if ($2 == h) { hit = 1; exit } } + END { if (hit) exit 0; if (known) exit 1; exit 2 } + ' "$baseline" +} + +# --- AGENTS.md framework region ---------------------------------------------- +# +# Before 1.0.0 the template had no markers, so the framework-authored sections +# of a consumer's AGENTS.md cannot be located by marker. They can be located by +# heading: the region runs from "## Context System" up to (not including) +# "## Project-Specific Rules". migrate hashes that region and, when it matches a +# revision the library shipped, replaces it in place with the managed block, so +# the consumer is not left with two copies of every framework section. +# +# The key is not a .context path, so it can never collide with a file entry. +# shellcheck disable=SC2034 # read by migrate.sh and ci/write-unversioned-baseline.sh +AC_AGENTS_REGION_KEY='AGENTS.md#framework-region' + +# Print the region, CR-stripped, or return 1 when either heading is missing. +# Mirrors Get-AcAgentsRegion in common.ps1; the two must stay equivalent. +ac_agents_region() { + local file="$1" + [ -f "$file" ] || return 1 + tr -d '\r' < "$file" | awk ' + /^## Context System[[:space:]]*$/ && !started { started = 1 } + /^## Project-Specific Rules/ && started { ended = 1; exit } + started { print } + END { exit !(started && ended) } + ' } # --- managed block --------------------------------------------------------- diff --git a/scripts/migrate.ps1 b/scripts/migrate.ps1 index a1e982e..0fa7a69 100644 --- a/scripts/migrate.ps1 +++ b/scripts/migrate.ps1 @@ -101,18 +101,23 @@ Write-Host "" # --- classify -------------------------------------------------------------- -$baselineMap = @{} -foreach ($line in (Get-Content -LiteralPath $Baseline)) { - if ($line -match '^(\S+)\s+([a-f0-9]+)$') { - $baselineMap[$Matches[1]] = $Matches[2] - } -} +$baselineMap = Read-AcBaseline -Path $Baseline $diverged = New-Object System.Collections.Generic.List[string] $added = New-Object System.Collections.Generic.List[string] $nonMd = New-Object System.Collections.Generic.List[string] +$retired = New-Object System.Collections.Generic.List[string] $totalCount = 0 +# Where a deployed base path comes from in this checkout. A path with no source +# file is one the library no longer ships. +function Get-AcSourcePath { + param([string]$Rel) + if ($Rel.StartsWith('conventions/')) { return (Join-Path $SourceRoot "core/.context/$Rel") } + if ($Rel -eq 'index.md') { return (Join-Path $SourceRoot 'core/.context/index.md') } + return (Join-Path $SourceRoot $Rel) +} + foreach ($area in @('standards', 'playbooks', 'conventions')) { $areaDir = Join-Path $ContextDir $area if (-not (Test-Path -LiteralPath $areaDir)) { continue } @@ -126,16 +131,31 @@ foreach ($area in @('standards', 'playbooks', 'conventions')) { $rel = $area + '/' + $f.FullName.Substring($full.Length).TrimStart($sep).Replace('\', '/') $totalCount++ # LF-normalised: a CRLF checkout must not make every file look edited. - $actual = Get-AcFileHashLf -Path $f.FullName - if ($baselineMap.ContainsKey($rel)) { - if ($baselineMap[$rel] -ne $actual) { $diverged.Add($rel) } + $status = Get-AcBaselineStatus -Baseline $baselineMap -RelPath $rel -Hash (Get-AcFileHashLf -Path $f.FullName) + $shipped = Test-Path -LiteralPath (Get-AcSourcePath $rel) + if ($status -eq 'pristine') { + # Unedited; removed if the library has since stopped shipping it. + if (-not $shipped) { $retired.Add($rel) } + } elseif ($status -eq 'edited' -and $shipped) { + $diverged.Add($rel) } else { - # Not in the baseline at all: a file the consumer added themselves. + # Not in the baseline (a file the consumer added), or an edited copy + # of a file the library no longer ships - there is no base left for an + # override to replace, so it is kept as a standalone file. $added.Add($rel) } } } +# index.md is base content too. Consumers commonly add their own routes to it, +# and the restore below overwrites it, so an edited copy must be kept. +$indexEdited = $false +$indexPath = Join-Path $ContextDir 'index.md' +if (Test-Path -LiteralPath $indexPath) { + $indexStatus = Get-AcBaselineStatus -Baseline $baselineMap -RelPath 'index.md' -Hash (Get-AcFileHashLf -Path $indexPath) + if ($indexStatus -ne 'pristine') { $indexEdited = $true } +} + # Non-markdown files. The baseline only covers .md, but the restore below # replaces each area wholesale, so anything else here is destroyed unless it is # recognised. A file that also exists in the source tree is a framework @@ -176,7 +196,7 @@ if ($totalCount -gt 1 -and $diverged.Count -eq $totalCount) { exit 1 } -if ($diverged.Count -eq 0 -and $added.Count -eq 0 -and $nonMd.Count -eq 0) { +if ($diverged.Count -eq 0 -and $added.Count -eq 0 -and $nonMd.Count -eq 0 -and -not $indexEdited) { Write-Host "No local modifications detected - this deployment is pristine." } else { if ($diverged.Count -gt 0) { @@ -185,7 +205,8 @@ if ($diverged.Count -eq 0 -and $added.Count -eq 0 -and $nonMd.Count -eq 0) { Write-Host "" } if ($added.Count -gt 0) { - Write-Host "Files you added ($($added.Count)) - will move to overrides as standalone additions:" + Write-Host "Files you added, or edited files the library no longer ships ($($added.Count))" + Write-Host "- will move to overrides as standalone additions:" foreach ($rel in $added) { Write-Host " $rel -> .context/overrides/$rel" } Write-Host "" } @@ -196,6 +217,19 @@ if ($diverged.Count -eq 0 -and $added.Count -eq 0 -and $nonMd.Count -eq 0) { } } +if ($indexEdited) { + Write-Host "index.md has local edits - your copy will be kept as .context/overrides/index.md" + Write-Host " (mode: extend, so routes the library adds still reach you). Trim it to just" + Write-Host " your own routes, or move them to the Additional Context table in AGENTS.md." + Write-Host "" +} + +if ($retired.Count -gt 0) { + Write-Host "Framework files the library no longer ships ($($retired.Count)) - unedited, will be removed:" + foreach ($rel in $retired) { Write-Host " $rel" } + Write-Host "" +} + # State the destructive behaviour before it happens, not after. Write-Host "This migration replaces .context/{standards,playbooks,conventions} wholesale." Write-Host "Anything listed above is preserved under .context/overrides/. A base file you" @@ -239,8 +273,8 @@ function Move-AcToOverride { ' so you continue to inherit upstream improvements. -->', '' ) - $body = Get-Content -LiteralPath $src - Set-Content -LiteralPath $dst -Value (@($header) + @($body)) -Encoding UTF8 + $body = @(Read-AcTextLines -Path $src) + Write-AcTextFile -Path $dst -Lines (@($header) + @($body)) } else { Copy-Item -LiteralPath $src -Destination $dst -Force } @@ -264,6 +298,21 @@ foreach ($rel in $nonMd) { Remove-Item -LiteralPath (Join-Path $ContextDir $rel) -Force -ErrorAction SilentlyContinue } +if ($indexEdited) { + $indexHeader = @( + '---', + 'overrides: index.md', + 'mode: extend', + '---', + '', + '', + '' + ) + Write-AcTextFile -Path (Join-Path $overrideRoot 'index.md') -Lines (@($indexHeader) + @(Read-AcTextLines -Path $indexPath)) +} + Write-Host "Restoring base content from $SourceRoot ..." foreach ($area in $sourceAreas) { if (-not (Test-Path -LiteralPath $area.From)) { continue } @@ -277,6 +326,10 @@ $srcIndex = Join-Path $SourceRoot 'core/.context/index.md' if (Test-Path -LiteralPath $srcIndex) { Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force } +$srcGitignore = Join-Path $SourceRoot 'core/.context/.gitignore' +if (Test-Path -LiteralPath $srcGitignore) { + Copy-Item -LiteralPath $srcGitignore -Destination (Join-Path $ContextDir '.gitignore') -Force +} $srcOverrideReadme = Join-Path $SourceRoot 'core/.context/overrides/README.md' if (Test-Path -LiteralPath $srcOverrideReadme) { Copy-Item -LiteralPath $srcOverrideReadme -Destination (Join-Path $overrideRoot 'README.md') -Force @@ -300,56 +353,105 @@ foreach ($libFile in @('common.sh', 'common.ps1')) { } } -# AGENTS.md: prepend the managed block, leave everything the consumer has intact. -# Identifying which of their existing regions were framework-authored is guesswork, -# so this deliberately does not try - a human reviews one file instead. +# AGENTS.md. When the framework-authored region (## Context System up to +# ## Project-Specific Rules) is exactly as some release of the template shipped +# it, swap it for the managed block in place: the consumer's title and every +# [CONFIGURE] section stay where they are, and nothing is duplicated. When the +# region was edited, or cannot be found, which parts are framework-authored is +# guesswork - so the block is prepended and a human reviews one file instead. $NewVersion = '0.0.0' $versionFile = Join-Path $SourceRoot 'VERSION' if (Test-Path -LiteralPath $versionFile) { - $candidate = ConvertTo-AcSemVer ((Get-Content -LiteralPath $versionFile -Raw)) + $candidate = ConvertTo-AcSemVer ((Read-AcTextLines -Path $versionFile) -join '') if (Test-AcSemVer $candidate) { $NewVersion = $candidate } } $agentsFile = Join-Path $Target 'AGENTS.md' $agentsSrc = Join-Path $SourceRoot 'core/AGENTS.md' +$agentsPrepended = $false if (Test-Path -LiteralPath $agentsSrc) { + $block = Get-AcManagedBlock -Path $agentsSrc -Version $NewVersion if (-not (Test-Path -LiteralPath $agentsFile)) { - $seeded = Get-Content -LiteralPath $agentsSrc | ForEach-Object { + $seeded = Read-AcTextLines -Path $agentsSrc | ForEach-Object { if ($_.StartsWith('" } else { $_ } } - Set-Content -LiteralPath $agentsFile -Value $seeded -Encoding UTF8 + Write-AcTextFile -Path $agentsFile -Lines $seeded } elseif (Test-AcManagedBlock -Path $agentsFile) { Write-Host " AGENTS.md already has a managed block - left as is." } else { - $block = Get-AcManagedBlock -Path $agentsSrc -Version $NewVersion - $notice = @( - '', - '---', - '', - '', - '' - ) - $original = Get-Content -LiteralPath $agentsFile - Set-Content -LiteralPath $agentsFile -Value (@($block) + $notice + @($original)) -Encoding UTF8 - Write-Host " AGENTS.md: managed block prepended; your original content kept below for review." + $region = Get-AcAgentsRegion -Path $agentsFile + $regionPristine = $false + if ($null -ne $region) { + $regionStatus = Get-AcBaselineStatus -Baseline $baselineMap -RelPath $script:AcAgentsRegionKey -Hash (Get-AcStringHash $region) + $regionPristine = ($regionStatus -eq 'pristine') + } + # Line endings are preserved: a CRLF file stays CRLF, so the diff shows + # only the region that changed rather than every line. + $crlf = Test-AcCrlf -Path $agentsFile + $original = @(Read-AcTextLines -Path $agentsFile) + + if ($regionPristine) { + $out = New-Object System.Collections.Generic.List[string] + $state = 0 + foreach ($line in $original) { + if ($state -eq 0 -and $line -match '^## Context System\s*$') { + $state = 1 + foreach ($b in $block) { $out.Add($b) } + $out.Add('') + $out.Add('---') + $out.Add('') + continue + } + if ($state -eq 1 -and $line.StartsWith('## Project-Specific Rules')) { $state = 2 } + if ($state -ne 1) { $out.Add($line) } + } + Write-AcTextFile -Path $agentsFile -Lines $out -Crlf:$crlf + Write-Host " AGENTS.md: framework sections replaced in place by the managed block; your sections untouched." + } else { + $notice = @( + '', + '---', + '', + '', + '' + ) + Write-AcTextFile -Path $agentsFile -Lines (@($block) + $notice + @($original)) -Crlf:$crlf + $agentsPrepended = $true + Write-Host " AGENTS.md: your framework sections were edited, so the managed block was" + Write-Host " prepended and your original content kept below it for review." + } } } +# Record which agents this deployment serves, inferred from the files deploy +# writes for each. An empty list would make the manifest claim no agents, and a +# later deploy or update would have nothing to go on. +$agents = New-Object System.Collections.Generic.List[string] +if ((Test-Path -LiteralPath (Join-Path $Target 'CLAUDE.md')) -or (Test-Path -LiteralPath (Join-Path $Target '.claude/skills'))) { $agents.Add('claude') } +if ((Test-Path -LiteralPath (Join-Path $Target '.github/copilot-instructions.md')) -or (Test-Path -LiteralPath (Join-Path $Target '.github/skills'))) { $agents.Add('copilot') } +if (Test-Path -LiteralPath (Join-Path $Target '.cursor/rules/standards.mdc')) { $agents.Add('cursor') } +if (Test-Path -LiteralPath (Join-Path $Target '.devin/devin.json')) { $agents.Add('devin') } +if (Test-Path -LiteralPath (Join-Path $Target '.windsurfrules')) { $agents.Add('windsurf') } + # Write the manifest last, so its hashes reflect the final state. -Write-AcManifest -ContextDir $ContextDir -Version $NewVersion -Agents @() -Set-Content -LiteralPath (Join-Path $ContextDir 'VERSION') -Value $NewVersion -Encoding UTF8 +Write-AcManifest -ContextDir $ContextDir -Version $NewVersion -Agents $agents.ToArray() +Write-AcTextFile -Path (Join-Path $ContextDir 'VERSION') -Lines @($NewVersion) Write-Host "" Write-Host "Migration complete. Now on $NewVersion." Write-Host "" Write-Host "Review before committing:" Write-Host " git -C $Target diff --stat" -Write-Host " $Target/AGENTS.md - remove sections duplicated by the managed block" -Write-Host " $Target/.context/overrides/ - convert 'mode: replace' to 'mode: extend' where you can" +if ($agentsPrepended) { + Write-Host " $Target/AGENTS.md - remove sections duplicated by the managed block" +} +if ($diverged.Count -gt 0) { + Write-Host " $Target/.context/overrides/ - convert 'mode: replace' to 'mode: extend' where you can" +} diff --git a/scripts/migrate.sh b/scripts/migrate.sh index 32449f4..2f53582 100755 --- a/scripts/migrate.sh +++ b/scripts/migrate.sh @@ -125,9 +125,20 @@ DIVERGED_LIST="$(mktemp)" MISSING_LIST="$(mktemp)" TOTAL_LIST="$(mktemp)" NONMD_LIST="$(mktemp)" -cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST" "$TOTAL_LIST" "$NONMD_LIST"; } +RETIRED_LIST="$(mktemp)" +cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST" "$TOTAL_LIST" "$NONMD_LIST" "$RETIRED_LIST"; } trap cleanup EXIT INT TERM +# Where a deployed base path comes from in this checkout. A path with no source +# file is one the library no longer ships. +source_path_for() { + case "$1" in + conventions/*) printf '%s' "$SOURCE_ROOT/core/.context/$1" ;; + index.md) printf '%s' "$SOURCE_ROOT/core/.context/index.md" ;; + *) printf '%s' "$SOURCE_ROOT/$1" ;; + esac +} + for area in standards playbooks conventions; do [ -d "$CONTEXT_DIR/$area" ] || continue find "$CONTEXT_DIR/$area" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do @@ -135,17 +146,31 @@ for area in standards playbooks conventions; do printf '%s\n' "$rel" >> "$TOTAL_LIST" # LF-normalised: a CRLF checkout must not make every file look edited. actual="$(ac_sha256_lf "$f")" - if expected="$(ac_baseline_lookup "$BASELINE" "$rel")"; then - if [ "$expected" != "$actual" ]; then - printf '%s\n' "$rel" >> "$DIVERGED_LIST" - fi - else - # Not in the baseline at all: a file the consumer added themselves. - printf '%s\n' "$rel" >> "$MISSING_LIST" - fi + status=0 + ac_baseline_match "$BASELINE" "$rel" "$actual" || status=$? + shipped=1 + [ -f "$(source_path_for "$rel")" ] || shipped=0 + case "$status:$shipped" in + 0:1) ;; # pristine, still shipped + 0:0) printf '%s\n' "$rel" >> "$RETIRED_LIST" ;; # pristine, since removed upstream + 1:1) printf '%s\n' "$rel" >> "$DIVERGED_LIST" ;; # edited framework file + # Edited, but the library no longer ships it: there is no base left for an + # override to replace, so keep it as a standalone file. + 1:0) printf '%s\n' "$rel" >> "$MISSING_LIST" ;; + *) printf '%s\n' "$rel" >> "$MISSING_LIST" ;; # not in the baseline: consumer-added + esac done done +# index.md is base content too. Consumers commonly add their own routes to it, +# and the restore below overwrites it, so an edited copy must be kept. +INDEX_EDITED=0 +if [ -f "$CONTEXT_DIR/index.md" ]; then + status=0 + ac_baseline_match "$BASELINE" index.md "$(ac_sha256_lf "$CONTEXT_DIR/index.md")" || status=$? + [ "$status" -eq 0 ] || INDEX_EDITED=1 +fi + # Non-markdown files. The baseline only covers .md, but the restore below # replaces each area wholesale, so anything else here is destroyed unless it is # recognised. A file that also exists in the source tree is a framework @@ -179,7 +204,9 @@ if [ "$total_count" -gt 1 ] && [ "$diverged_count" -eq "$total_count" ]; then exit 1 fi -if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ] && [ "$nonmd_count" -eq 0 ]; then +retired_count=$(wc -l < "$RETIRED_LIST" | tr -d ' ') + +if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ] && [ "$nonmd_count" -eq 0 ] && [ "$INDEX_EDITED" -eq 0 ]; then echo "No local modifications detected — this deployment is pristine." else if [ "$diverged_count" -gt 0 ]; then @@ -191,7 +218,8 @@ else echo "" fi if [ "$added_count" -gt 0 ]; then - echo "Files you added ($added_count) — will move to overrides as standalone additions:" + echo "Files you added, or edited files the library no longer ships ($added_count)" + echo "— will move to overrides as standalone additions:" while IFS= read -r rel; do [ -n "$rel" ] || continue echo " $rel -> .context/overrides/$rel" @@ -208,6 +236,22 @@ else fi fi +if [ "$INDEX_EDITED" -eq 1 ]; then + echo "index.md has local edits — your copy will be kept as .context/overrides/index.md" + echo " (mode: extend, so routes the library adds still reach you). Trim it to just" + echo " your own routes, or move them to the Additional Context table in AGENTS.md." + echo "" +fi + +if [ "$retired_count" -gt 0 ]; then + echo "Framework files the library no longer ships ($retired_count) — unedited, will be removed:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel" + done < "$RETIRED_LIST" + echo "" +fi + # State the destructive behaviour before it happens, not after. echo "This migration replaces .context/{standards,playbooks,conventions} wholesale." echo "Anything listed above is preserved under .context/overrides/. A base file you" @@ -269,6 +313,19 @@ while IFS= read -r rel; do rm -f "$CONTEXT_DIR/$rel" done < "$NONMD_LIST" +if [ "$INDEX_EDITED" -eq 1 ]; then + { + printf -- '---\n' + printf 'overrides: index.md\n' + printf 'mode: extend\n' + printf -- '---\n\n' + printf '\n\n' + cat "$CONTEXT_DIR/index.md" + } > "$CONTEXT_DIR/overrides/index.md" +fi + echo "Restoring base content from $SOURCE_ROOT ..." for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks" "conventions:$SOURCE_ROOT/core/.context/conventions"; do name="${pair%%:*}" @@ -280,6 +337,7 @@ for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks done [ -f "$SOURCE_ROOT/core/.context/index.md" ] && cp "$SOURCE_ROOT/core/.context/index.md" "$CONTEXT_DIR/index.md" +[ -f "$SOURCE_ROOT/core/.context/.gitignore" ] && cp "$SOURCE_ROOT/core/.context/.gitignore" "$CONTEXT_DIR/.gitignore" [ -f "$SOURCE_ROOT/core/.context/overrides/README.md" ] && cp "$SOURCE_ROOT/core/.context/overrides/README.md" "$CONTEXT_DIR/overrides/README.md" mkdir -p "$CONTEXT_DIR/bin/lib" @@ -290,27 +348,64 @@ cp "$SOURCE_ROOT/scripts/lib/common.sh" "$CONTEXT_DIR/bin/lib/common.sh" [ -f "$SOURCE_ROOT/scripts/lib/common.ps1" ] && cp "$SOURCE_ROOT/scripts/lib/common.ps1" "$CONTEXT_DIR/bin/lib/common.ps1" chmod +x "$CONTEXT_DIR/bin"/*.sh 2>/dev/null || true -# AGENTS.md: prepend the managed block, leave everything the consumer has intact. -# Identifying which of their existing regions were framework-authored is guesswork, -# so this deliberately does not try — a human reviews one file instead. +# AGENTS.md. When the framework-authored region (## Context System up to +# ## Project-Specific Rules) is exactly as some release of the template shipped +# it, swap it for the managed block in place: the consumer's title and every +# [CONFIGURE] section stay where they are, and nothing is duplicated. When the +# region was edited, or cannot be found, which parts are framework-authored is +# guesswork - so the block is prepended and a human reviews one file instead. NEW_VERSION="$(ac_semver_normalise "$(cat "$SOURCE_ROOT/VERSION" 2>/dev/null || echo '0.0.0')")" AGENTS_FILE="$TARGET/AGENTS.md" +AGENTS_PREPENDED=0 if [ -f "$SOURCE_ROOT/core/AGENTS.md" ]; then + block="$(mktemp)" + awk -v b='' ' + index($0, b) == 1 { inblock = 1 } + inblock { print } + index($0, e) == 1 { inblock = 0 } + ' "$SOURCE_ROOT/core/AGENTS.md" \ + | sed "1s|^|" > "$block" + + region_pristine=1 + if [ -f "$AGENTS_FILE" ] && ac_agents_region "$AGENTS_FILE" > "$block.region"; then + ac_baseline_match "$BASELINE" "$AC_AGENTS_REGION_KEY" "$(ac_sha256 "$block.region")" || region_pristine=0 + else + region_pristine=0 + fi + if [ ! -f "$AGENTS_FILE" ]; then sed "s|^|" \ "$SOURCE_ROOT/core/AGENTS.md" > "$AGENTS_FILE" elif grep -q '^' ' - index($0, b) == 1 { inblock = 1 } - inblock { print } - index($0, e) == 1 { inblock = 0 } - ' "$SOURCE_ROOT/core/AGENTS.md" \ - | sed "1s|^|" + cat "$block" printf '\n---\n\n' printf '" } else { $_ } } - Set-Content -LiteralPath $agentsDst -Value $seeded -Encoding UTF8 + Write-AcTextFile -Path $agentsDst -Lines $seeded } elseif (Test-AcManagedBlock -Path $agentsDst) { Update-AcManagedBlock -Source $agentsSrc -Destination $agentsDst -Version $DeployVersion Write-Host " AGENTS.md: refreshed managed block (your content preserved)" @@ -711,6 +793,9 @@ if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { } } } + + if (Test-AgentEnabled 'claude') { Remove-StaleSkillWrappers -SkillsDir (Join-Path $script:Target '.claude/skills') } + if (Test-AgentEnabled 'copilot') { Remove-StaleSkillWrappers -SkillsDir (Join-Path $script:Target '.github/skills') } } else { Write-Host " Skipping skill wrapper generation (no selected agent uses skills)." } @@ -750,7 +835,7 @@ foreach ($libFile in @('common.sh', 'common.ps1')) { } } -Set-Content -LiteralPath (Join-Path $script:Target '.context/VERSION') -Value $DeployVersion -Encoding UTF8 +Write-AcTextFile -Path (Join-Path $script:Target '.context/VERSION') -Lines @($DeployVersion) Write-Host " Writing manifest -> $(Join-Path $script:Target '.context/manifest.json')" # pin and checkFrequency are consumer configuration, not derived state. @@ -763,7 +848,7 @@ $existingPin = '' $existingFreq = 'weekly' if (Test-Path -LiteralPath $manifestPath) { try { - $existing = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + $existing = Get-AcManifest -Path $manifestPath if ($existing.pin) { $existingPin = [string]$existing.pin } if ($existing.checkFrequency) { $existingFreq = [string]$existing.checkFrequency } } catch { diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f41919f..0b2608a 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -146,6 +146,12 @@ confirm_overwrite() { copy_file() { local src="$1" local dst="$2" + # Identical content needs no decision. Without this, redeploying the same + # version prompts once for every file in the library, and a non-interactive + # redeploy lists them all as "skipped" when nothing would have changed. + if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then + return 0 + fi if ! confirm_overwrite "$dst"; then return 0 fi @@ -548,33 +554,48 @@ generate_skill() { local skill_dir="$target_dir/$name" local skill_file="$skill_dir/SKILL.md" + local content + + # Built with printf rather than a heredoc inside $(...): bash 3.2, which is + # /bin/bash on macOS, mis-parses heredocs in command substitutions. + content="$(printf -- '---\nname: %s\ndescription: "%s"\n' "$name" "$description")" + if [[ -n "$allowed_tools" ]]; then + content="$content"$'\n'"$(printf 'allowed-tools: "%s"' "$allowed_tools")" + fi + content="$content"$'\n'"---"$'\n\n'"Read and follow \`.context/playbooks/$rel_path\` in full." + + # Same rule as copy_file: an unchanged wrapper needs no prompt. + if [[ -f "$skill_file" ]] && [[ "$(cat "$skill_file")" == "$content" ]]; then + return + fi if ! confirm_overwrite "$skill_file"; then return fi mkdir -p "$skill_dir" + printf '%s\n' "$content" > "$skill_file" +} - if [[ -n "$allowed_tools" ]]; then - cat > "$skill_file" << SKILL_EOF ---- -name: $name -description: "$description" -allowed-tools: "$allowed_tools" ---- - -Read and follow \`.context/playbooks/$rel_path\` in full. -SKILL_EOF - else - cat > "$skill_file" << SKILL_EOF ---- -name: $name -description: "$description" ---- - -Read and follow \`.context/playbooks/$rel_path\` in full. -SKILL_EOF - fi +# Remove generated wrappers whose playbook the library no longer ships. +# +# A wrapper is only a pointer, so once its playbook is renamed or removed it +# advertises a skill that cannot load. Only files that are exactly what +# generate_skill writes - at most seven lines, ending in the "Read and follow" +# pointer - are touched; anything a consumer wrote themselves is left alone. +prune_stale_skills() { + local dir="$1" f target + [[ -d "$dir" ]] || return 0 + for f in "$dir"/*/SKILL.md; do + [[ -f "$f" ]] || continue + [[ "$(wc -l < "$f" | tr -d ' ')" -le 7 ]] || continue + target="$(sed -n 's/^Read and follow `\.context\/playbooks\/\(.*\)` in full\.$/\1/p' "$f" | head -1)" + [[ -n "$target" ]] || continue + [[ -f "$TARGET/.context/playbooks/$target" ]] && continue + rm -f "$f" + rmdir "$(dirname "$f")" 2>/dev/null || true + echo " Removed stale skill wrapper $(basename "$(dirname "$f")") (playbook $target no longer ships)" + done } generate_skills_for_selected_agents() { @@ -714,6 +735,24 @@ echo "Deploying agent-contexts to $TARGET" echo " Version: $DEPLOY_VERSION" echo " Selected agents: $(join_by ', ' "${ENABLED_AGENTS[@]}")" +# A deployment made before versioning has a .context/ but no manifest. Copying +# over it would stamp a manifest claiming the new version onto whatever base +# files the overwrite guard skipped, leave AGENTS.md without a managed block, +# and make migrate refuse to run afterwards because a manifest now exists. +# Upgrade it first: migrate preserves every local edit as an override, and +# refuses to run on a dirty git tree so the result is reviewable as a diff. +if [[ -d "$TARGET/.context" && ! -f "$TARGET/.context/manifest.json" ]] && + [[ -f "$TARGET/.context/index.md" || -d "$TARGET/.context/standards" || -d "$TARGET/.context/playbooks" ]]; then + echo " Existing deployment from before versioning detected — upgrading it first." + echo "" + if ! "$SCRIPT_DIR/migrate.sh" --apply "$TARGET"; then + echo "" >&2 + echo "Error: could not upgrade the existing deployment; nothing else was changed." >&2 + exit 1 + fi + echo "" +fi + echo " Copying shared context files..." deploy_agents_md "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" "$DEPLOY_VERSION" # The override subtree is deliberately excluded here and seeded later with @@ -810,6 +849,9 @@ if agent_enabled claude || agent_enabled copilot; then "Read, Grep, Glob, Bash, Write, Edit, Agent" done fi + + if agent_enabled claude; then prune_stale_skills "$TARGET/.claude/skills"; fi + if agent_enabled copilot; then prune_stale_skills "$TARGET/.github/skills"; fi else echo " Skipping skill wrapper generation (no selected agent uses skills)." fi From a6968cd10886cd08bf80c2b77f02cf11753372bb Mon Sep 17 00:00:00 2001 From: Leigh Dastey Date: Mon, 5 Oct 2026 09:23:40 +0100 Subject: [PATCH 23/25] fix(powershell): read and write text as UTF-8 without a BOM On Windows PowerShell 5.1, Get-Content decodes BOM-less UTF-8 as ANSI and Set-Content -Encoding UTF8 writes a BOM. Migrating or updating a deployment therefore turned every non-ASCII character in AGENTS.md - including the consumer's own text - into mojibake, compounding on each update, and the BOM hid a line-1 managed-block marker from the bash tooling so a later update.sh left the block unmanaged for good. Route all text I/O through Read-AcTextLines / Write-AcTextFile, which use explicit UTF-8 with no BOM and preserve CRLF where the file already used it. Also honour the AC_SOURCE_REPO / AC_RAW_BASE / AC_WEB_BASE overrides that common.sh already supports, and decode a byte[] VERSION response so the update check works against servers that do not label it as text. --- scripts/lib/common.ps1 | 150 ++++++++++++++++++++++++++++++++++++++--- scripts/update.ps1 | 8 ++- 2 files changed, 147 insertions(+), 11 deletions(-) diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 index ac1361a..ba928b7 100644 --- a/scripts/lib/common.ps1 +++ b/scripts/lib/common.ps1 @@ -1,13 +1,18 @@ -# scripts/lib/common.ps1 — shared helpers for deploy, update and migrate. +# scripts/lib/common.ps1 - shared helpers for deploy, update and migrate. # # Portability: must run on Windows PowerShell 5.1 as well as PowerShell 7+. # No PowerShell 7-only syntax (no ternaries, no ??, no -Parallel). # # Dot-source this file; do not execute it. +# Overridable from the environment, exactly as in common.sh, so a mirror or a +# test fixture can be pointed at without editing the deployed tooling. $script:AcSourceRepo = 'ldastey-dev/agentic-context' +if ($env:AC_SOURCE_REPO) { $script:AcSourceRepo = $env:AC_SOURCE_REPO } $script:AcRawBase = 'https://raw.githubusercontent.com' +if ($env:AC_RAW_BASE) { $script:AcRawBase = $env:AC_RAW_BASE } $script:AcWebBase = 'https://github.com' +if ($env:AC_WEB_BASE) { $script:AcWebBase = $env:AC_WEB_BASE } $script:AcTimeoutSec = 5 # --- hashing --------------------------------------------------------------- @@ -39,6 +44,70 @@ function Get-AcFileHashLf { return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() } +# Hash a string's UTF-8 bytes (no BOM), matching ac_sha256 over a file with the +# same content. +function Get-AcStringHash { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) + $bytes = (New-Object System.Text.UTF8Encoding($false)).GetBytes($Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($bytes) + } finally { + $sha.Dispose() + } + return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() +} + +# --- text I/O ---------------------------------------------------------------- +# +# Every text read and write goes through these. On Windows PowerShell 5.1, +# Get-Content without -Encoding decodes a BOM-less UTF-8 file as the ANSI code +# page, and Set-Content -Encoding UTF8 writes a BOM. Together they turn every +# em dash in a consumer's AGENTS.md into mojibake on each rewrite, and the BOM +# hides a managed-block marker on line 1 from the bash tooling - so a Windows +# migration followed by a bash update leaves the block unmanaged for good. + +function ConvertTo-AcFullPath { + param([Parameter(Mandatory)][string]$Path) + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) +} + +# Read a text file as UTF-8 (a BOM, if present, is dropped) and return its +# lines without terminators. +function Read-AcTextLines { + param([Parameter(Mandatory)][string]$Path) + $text = [System.IO.File]::ReadAllText((ConvertTo-AcFullPath $Path), [System.Text.Encoding]::UTF8) + if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } + if ($text.Length -eq 0) { return } + $lines = $text -split "\r?\n" + if ($text.EndsWith("`n")) { $lines = $lines[0..($lines.Count - 2)] } + # Unrolled on purpose so the result pipes line by line; wrap the call in + # @() where an array is required. + return $lines +} + +# True when a file uses CRLF line endings, so a rewrite can keep them. +function Test-AcCrlf { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $text = [System.IO.File]::ReadAllText((ConvertTo-AcFullPath $Path), [System.Text.Encoding]::UTF8) + return $text.Contains("`r`n") +} + +# Write lines as UTF-8 without a BOM, LF-terminated unless -Crlf is given. +function Write-AcTextFile { + param( + [Parameter(Mandatory)][string]$Path, + [AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines = @(), + [switch]$Crlf + ) + $nl = "`n" + if ($Crlf) { $nl = "`r`n" } + $text = '' + if (@($Lines).Count -gt 0) { $text = (@($Lines) -join $nl) + $nl } + [System.IO.File]::WriteAllText((ConvertTo-AcFullPath $Path), $text, (New-Object System.Text.UTF8Encoding($false))) +} + # --- semver ---------------------------------------------------------------- function ConvertTo-AcSemVer { @@ -134,7 +203,12 @@ function Get-AcLatestVersionRaw { try { $url = "$script:AcRawBase/$Repo/$Branch/VERSION" $resp = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec $script:AcTimeoutSec -ErrorAction Stop - $v = ConvertTo-AcSemVer ([string]$resp.Content) + # A server that does not label VERSION as text (a mirror, or a plain + # file server) yields byte[] here, which [string] would render as a + # list of numbers and the check would silently never succeed. + $content = $resp.Content + if ($content -is [byte[]]) { $content = [System.Text.Encoding]::UTF8.GetString($content) } + $v = ConvertTo-AcSemVer ([string]$content) if (Test-AcSemVer $v) { return $v } return $null } catch { @@ -192,7 +266,7 @@ function Get-AcManifest { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $null } try { - return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json) + return ((Read-AcTextLines -Path $Path) -join "`n" | ConvertFrom-Json) } catch { return $null } @@ -269,7 +343,7 @@ function Write-AcManifest { } $json = $manifest | ConvertTo-Json -Depth 5 - Set-Content -LiteralPath (Join-Path $ContextDir 'manifest.json') -Value $json -Encoding UTF8 + Write-AcTextFile -Path (Join-Path $ContextDir 'manifest.json') -Lines ($json -split "\r?\n") } # --- managed block --------------------------------------------------------- @@ -280,7 +354,7 @@ $script:AcEndMarker = '' function Test-AcManagedBlock { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $false } - $lines = Get-Content -LiteralPath $Path + $lines = @(Read-AcTextLines -Path $Path) $hasBegin = $false $hasEnd = $false foreach ($line in $lines) { @@ -295,7 +369,7 @@ function Get-AcManagedBlock { $out = New-Object System.Collections.Generic.List[string] $inBlock = $false - foreach ($line in (Get-Content -LiteralPath $Path)) { + foreach ($line in (Read-AcTextLines -Path $Path)) { if ($line.StartsWith($script:AcBeginMarker)) { $inBlock = $true if ($Version) { @@ -328,7 +402,8 @@ function Update-AcManagedBlock { $out = New-Object System.Collections.Generic.List[string] $inBlock = $false - foreach ($line in (Get-Content -LiteralPath $Destination)) { + $crlf = Test-AcCrlf -Path $Destination + foreach ($line in (Read-AcTextLines -Path $Destination)) { if ($line.StartsWith($script:AcBeginMarker)) { $inBlock = $true foreach ($b in $block) { $out.Add($b) } @@ -340,7 +415,7 @@ function Update-AcManagedBlock { if (-not $inBlock) { $out.Add($line) } } - Set-Content -LiteralPath $Destination -Value $out -Encoding UTF8 + Write-AcTextFile -Path $Destination -Lines $out -Crlf:$crlf } # List base files whose current hash differs from the manifest record. @@ -376,7 +451,7 @@ function Get-AcOrphanOverrides { foreach ($f in $files) { if ($f.Name -eq 'README.md') { continue } $target = $null - foreach ($line in (Get-Content -LiteralPath $f.FullName)) { + foreach ($line in (Read-AcTextLines -Path $f.FullName)) { if ($line -match '^overrides:\s*(.+)$') { $target = $Matches[1].Trim() break @@ -392,3 +467,60 @@ function Get-AcOrphanOverrides { } return $result } + +# --- migration baseline ------------------------------------------------------ + +# Load a baseline into path -> list of known hashes. A path may appear more than +# once: the unversioned baseline records every revision the library shipped, +# because unversioned adopters deployed from whichever commit was current. +function Read-AcBaseline { + param([Parameter(Mandatory)][string]$Path) + $map = @{} + foreach ($line in (Read-AcTextLines -Path $Path)) { + if ($line -match '^(\S+)\s+([a-f0-9]+)$') { + if (-not $map.ContainsKey($Matches[1])) { + $map[$Matches[1]] = New-Object System.Collections.Generic.List[string] + } + $map[$Matches[1]].Add($Matches[2]) + } + } + return $map +} + +# Mirrors ac_baseline_match in common.sh: 'pristine' when the hash is a known +# revision of the path, 'edited' when the path is known but the hash is not, +# 'unknown' when the path is not in the baseline at all. +function Get-AcBaselineStatus { + param( + [Parameter(Mandatory)][hashtable]$Baseline, + [Parameter(Mandatory)][string]$RelPath, + [Parameter(Mandatory)][string]$Hash + ) + if (-not $Baseline.ContainsKey($RelPath)) { return 'unknown' } + if ($Baseline[$RelPath].Contains($Hash)) { return 'pristine' } + return 'edited' +} + +# --- AGENTS.md framework region ---------------------------------------------- +# +# Mirrors ac_agents_region in common.sh; the two must stay equivalent. Before +# 1.0.0 the template had no markers, so the framework-authored sections are +# located by heading: from "## Context System" up to, not including, +# "## Project-Specific Rules". +$script:AcAgentsRegionKey = 'AGENTS.md#framework-region' + +# Return the region as LF-joined text (each line LF-terminated), or $null when +# either heading is missing. +function Get-AcAgentsRegion { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $sb = New-Object System.Text.StringBuilder + $started = $false + foreach ($line in (Read-AcTextLines -Path $Path)) { + $l = $line.Replace("`r", '') + if (-not $started -and $l -match '^## Context System\s*$') { $started = $true } + if ($started -and $l.StartsWith('## Project-Specific Rules')) { return $sb.ToString() } + if ($started) { [void]$sb.Append($l).Append("`n") } + } + return $null +} diff --git a/scripts/update.ps1 b/scripts/update.ps1 index dca9456..0fe1ce4 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -79,7 +79,7 @@ if ($manifest) { function Update-AcStamp { try { - Set-Content -LiteralPath $StampPath -Value ((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) -Encoding UTF8 + Write-AcTextFile -Path $StampPath -Lines @((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) } catch { # A stamp we cannot write must never stop the caller. } @@ -248,6 +248,10 @@ try { } Copy-Item -LiteralPath $srcIndex -Destination (Join-Path $ContextDir 'index.md') -Force + $srcGitignore = Join-Path $srcPath 'core/.context/.gitignore' + if (Test-Path -LiteralPath $srcGitignore) { + Copy-Item -LiteralPath $srcGitignore -Destination (Join-Path $ContextDir '.gitignore') -Force + } # Refresh the update tooling itself, so a fixed updater reaches consumers. $binDir = Join-Path $ContextDir 'bin' @@ -286,7 +290,7 @@ try { Write-AcManifest -ContextDir $ContextDir -Version $Latest -Agents $agents ` -SourceRepo $SourceRepo -CheckFrequency $Freq -Pin $Pin - Set-Content -LiteralPath (Join-Path $ContextDir 'VERSION') -Value $Latest -Encoding UTF8 + Write-AcTextFile -Path (Join-Path $ContextDir 'VERSION') -Lines @($Latest) Write-Host "" Write-Host "Updated to $Latest." From 6e516b0b2010769d9e7754964494838d4725204e Mon Sep 17 00:00:00 2001 From: Leigh Dastey Date: Mon, 5 Oct 2026 09:23:43 +0100 Subject: [PATCH 24/25] test(upgrade): cover historical migration and redeploy; run e2e on real 5.1 Add bash and PowerShell end-to-end cases for migrating content from an earlier revision, retired files, edited index.md, in-place AGENTS.md conversion, BOM and encoding safety, agent inference, and re-running deploy over an unversioned deployment. The Windows PowerShell 5.1 job ran the end-to-end suite via pwsh, so it actually tested PowerShell 7 and never saw the 5.1 encoding defects. Run it under the matrix shell instead, make the suite ASCII so 5.1 can parse it, call update.ps1 in-process, and parse the manifest rather than string-match 5.1's ConvertTo-Json spacing. Add the new entry points to the executable-bit check. --- .github/workflows/deploy-ps1-tests.yml | 5 +- .github/workflows/deploy-sh-tests.yml | 4 +- scripts/tests/test-deploy.ps1 | 260 ++++++++++++++++++++++--- scripts/tests/test-deploy.sh | 169 ++++++++++++++++ 4 files changed, 407 insertions(+), 31 deletions(-) diff --git a/.github/workflows/deploy-ps1-tests.yml b/.github/workflows/deploy-ps1-tests.yml index edd600d..73984e7 100644 --- a/.github/workflows/deploy-ps1-tests.yml +++ b/.github/workflows/deploy-ps1-tests.yml @@ -125,4 +125,7 @@ jobs: run: Invoke-Pester -Path ./scripts/deploy.Tests.ps1 -CI - name: Run end-to-end deploy tests - run: pwsh ./scripts/tests/test-deploy.ps1 + # Run under the matrix shell, not pwsh: on the 5.1 leg, pwsh would + # silently test PowerShell 7 instead, which is how 5.1-only encoding + # defects (BOM writes, ANSI reads) reached the scripts unnoticed. + run: ./scripts/tests/test-deploy.ps1 diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml index c1aabde..bf11dc5 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -64,13 +64,15 @@ jobs: # invoked directly need it; lib/common.sh is sourced, not run. status=0 for script in \ + deploy.sh \ scripts/deploy.sh \ scripts/update.sh \ scripts/migrate.sh \ scripts/tests/test-deploy.sh \ scripts/ci/next-version.sh \ scripts/ci/write-baseline.sh \ - scripts/ci/write-changelog.sh; do + scripts/ci/write-changelog.sh \ + scripts/ci/write-unversioned-baseline.sh; do mode="$(git ls-files -s "$script" | cut -d' ' -f1)" if [ "$mode" != "100755" ]; then echo "::error file=$script::$script is mode $mode; it must be 100755. Run: git update-index --chmod=+x $script" diff --git a/scripts/tests/test-deploy.ps1 b/scripts/tests/test-deploy.ps1 index 0fba0af..80d231f 100644 --- a/scripts/tests/test-deploy.ps1 +++ b/scripts/tests/test-deploy.ps1 @@ -1,5 +1,5 @@ #!/usr/bin/env pwsh -# Test suite for deploy.ps1 — verifies setup/ playbook deployment and regressions. +# Test suite for deploy.ps1 - verifies setup/ playbook deployment and regressions. # # Usage: # pwsh ./tests/test-deploy.ps1 @@ -103,11 +103,11 @@ function Assert-NotContains { } } -# ═══════════════════════════════════════════════════════════════════════ -# TC1: Fresh deploy — all agents -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= +# TC1: Fresh deploy - all agents +# ======================================================================= Write-Host "" -Write-Host "=== TC1: Fresh deploy — all agents ===" +Write-Host "=== TC1: Fresh deploy - all agents ===" $tc1Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc1-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc1Dir -Force | Out-Null & "$ScriptsDir/deploy.ps1" -Agents all -Overwrite -Target $tc1Dir *>$null @@ -143,7 +143,7 @@ Write-Host " --- Claude thin wrappers ---" Assert-FileExists "claude/setup-create-local-otel-stack" "$tc1Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" Assert-FileExists "claude/setup-discover-local-otel-stack" "$tc1Dir/.claude/skills/setup-discover-local-otel-stack/SKILL.md" Assert-FileExists "claude/setup-use-local-otel-stack" "$tc1Dir/.claude/skills/setup-use-local-otel-stack/SKILL.md" -Assert-FileNotExists "claude/setup-instrument-dotnet-otel (removed — migrated to standard)" "$tc1Dir/.claude/skills/setup-instrument-dotnet-otel/SKILL.md" +Assert-FileNotExists "claude/setup-instrument-dotnet-otel (removed - migrated to standard)" "$tc1Dir/.claude/skills/setup-instrument-dotnet-otel/SKILL.md" Assert-FileExists "claude/debug-scientific-debugging" "$tc1Dir/.claude/skills/debug-scientific-debugging/SKILL.md" Assert-FileExists "claude/plan-research" "$tc1Dir/.claude/skills/plan-research/SKILL.md" Assert-Contains "claude debug wrapper has playbook path" "$tc1Dir/.claude/skills/debug-scientific-debugging/SKILL.md" ".context/playbooks/debug/scientific-debugging.md" @@ -152,7 +152,7 @@ Write-Host " --- Copilot thin wrappers ---" Assert-FileExists "copilot/setup-create-local-otel-stack" "$tc1Dir/.github/skills/setup-create-local-otel-stack/SKILL.md" Assert-FileExists "copilot/setup-discover-local-otel-stack" "$tc1Dir/.github/skills/setup-discover-local-otel-stack/SKILL.md" Assert-FileExists "copilot/setup-use-local-otel-stack" "$tc1Dir/.github/skills/setup-use-local-otel-stack/SKILL.md" -Assert-FileNotExists "copilot/setup-instrument-dotnet-otel (removed — migrated to standard)" "$tc1Dir/.github/skills/setup-instrument-dotnet-otel/SKILL.md" +Assert-FileNotExists "copilot/setup-instrument-dotnet-otel (removed - migrated to standard)" "$tc1Dir/.github/skills/setup-instrument-dotnet-otel/SKILL.md" Assert-FileExists "copilot/debug-scientific-debugging" "$tc1Dir/.github/skills/debug-scientific-debugging/SKILL.md" Assert-FileExists "copilot/plan-research" "$tc1Dir/.github/skills/plan-research/SKILL.md" Assert-NotContains "copilot debug wrapper no allowed-tools" "$tc1Dir/.github/skills/debug-scientific-debugging/SKILL.md" "allowed-tools:" @@ -173,11 +173,11 @@ Assert-FileNotExists "local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/loc Remove-Item -Recurse -Force $tc1Dir -# ═══════════════════════════════════════════════════════════════════════ -# TC2: Agent-scoped deploy — Claude only -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= +# TC2: Agent-scoped deploy - Claude only +# ======================================================================= Write-Host "" -Write-Host "=== TC2: Agent-scoped deploy — Claude only ===" +Write-Host "=== TC2: Agent-scoped deploy - Claude only ===" $tc2Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc2-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc2Dir -Force | Out-Null & "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc2Dir *>$null @@ -187,11 +187,11 @@ Assert-DirNotExists "copilot dir absent" "$tc2Dir/.github/skills/setup-create-lo Remove-Item -Recurse -Force $tc2Dir -# ═══════════════════════════════════════════════════════════════════════ -# TC3: Agent-scoped deploy — Copilot only -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= +# TC3: Agent-scoped deploy - Copilot only +# ======================================================================= Write-Host "" -Write-Host "=== TC3: Agent-scoped deploy — Copilot only ===" +Write-Host "=== TC3: Agent-scoped deploy - Copilot only ===" $tc3Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc3-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc3Dir -Force | Out-Null & "$ScriptsDir/deploy.ps1" -Agents copilot -Overwrite -Target $tc3Dir *>$null @@ -201,11 +201,11 @@ Assert-DirNotExists "claude dir absent" "$tc3Dir/.claude/skills/setup-create-loc Remove-Item -Recurse -Force $tc3Dir -# ═══════════════════════════════════════════════════════════════════════ -# TC4: No regressions — existing thin wrappers -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= +# TC4: No regressions - existing thin wrappers +# ======================================================================= Write-Host "" -Write-Host "=== TC4: No regressions — existing thin wrappers ===" +Write-Host "=== TC4: No regressions - existing thin wrappers ===" $tc4Dir = Join-Path ([System.IO.Path]::GetTempPath()) "tc4-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tc4Dir -Force | Out-Null & "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc4Dir *>$null @@ -214,9 +214,9 @@ Assert-FileExists "assess-observability" "$tc4Dir/.claude/skills/assess-observab Remove-Item -Recurse -Force $tc4Dir -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= # TC5: Relative -TargetRepo survives [Environment]::CurrentDirectory corruption -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= # Add-Type (used by Enable-VirtualTerminal for the interactive agent menu) resets # [Environment]::CurrentDirectory as a side effect on Windows. This reproduces that # corruption directly (no real interactive console needed) and proves a relative @@ -247,9 +247,9 @@ Assert-FileNotExists "AGENTS.md NOT deployed relative to corrupted CurrentDirect Remove-Item -Recurse -Force $tc5Base -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= # TC6: manifest and override layer are deployed -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= Write-Host "" Write-Host "=== TC6: manifest and override layer ===" $tc6Dir = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-tc6-" + [System.Guid]::NewGuid().ToString("N")) @@ -259,7 +259,14 @@ New-Item -ItemType Directory -Path $tc6Dir -Force | Out-Null Assert-FileExists "manifest.json" "$tc6Dir/.context/manifest.json" $tc6Version = (Get-Content (Join-Path $RepoDir 'VERSION') -Raw).Trim() -Assert-Contains "manifest records the current version" "$tc6Dir/.context/manifest.json" "`"version`": `"$tc6Version`"" +# Parsed rather than string-matched: Windows PowerShell 5.1's ConvertTo-Json +# pads the colon with two spaces, so a literal match fails there. +$tc6Manifest = Get-Content (Join-Path $tc6Dir '.context/manifest.json') -Raw | ConvertFrom-Json +if ($tc6Manifest.version -eq $tc6Version) { + Pass "manifest records the current version" +} else { + Fail "manifest records '$($tc6Manifest.version)', expected '$tc6Version'" +} Assert-FileExists "override layer README" "$tc6Dir/.context/overrides/README.md" @@ -273,7 +280,9 @@ Assert-Contains "AGENTS.md managed block start" "$tc6Dir/AGENTS.md" "agentic-con Assert-Contains "AGENTS.md managed block end" "$tc6Dir/AGENTS.md" "agentic-context:end" # -Status must work without network access and must not fail on a clean tree. -$tc6Status = & pwsh -NoProfile -File "$tc6Dir/.context/bin/update.ps1" -Status 2>&1 | Out-String +# In-process rather than via pwsh, so the Windows PowerShell 5.1 job really +# exercises update.ps1 on 5.1. +$tc6Status = & "$tc6Dir/.context/bin/update.ps1" -Status *>&1 | Out-String if ($tc6Status -match [regex]::Escape("agentic-context $tc6Version")) { Pass "update.ps1 -Status reports the deployed version" } else { @@ -282,9 +291,9 @@ if ($tc6Status -match [regex]::Escape("agentic-context $tc6Version")) { Remove-Item -Recurse -Force $tc6Dir -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= # TC7: consumer edits outside the managed block survive a redeploy -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= Write-Host "" Write-Host "=== TC7: consumer AGENTS.md content survives redeploy ===" $tc7Dir = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-tc7-" + [System.Guid]::NewGuid().ToString("N")) @@ -309,9 +318,202 @@ Assert-Contains "consumer override survived redeploy" (Join-Path $tc7Override 't Remove-Item -Recurse -Force $tc7Dir -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= +# Helpers for the upgrade-path cases: turn a fresh deployment into what a +# pre-versioning deploy left - no manifest, tooling or override layer, and an +# AGENTS.md without markers. Mirrors make_unversioned in test-deploy.sh. +# ======================================================================= +function ConvertTo-Unversioned { + param([string]$Dir) + foreach ($p in @('.context/manifest.json', '.context/VERSION', '.context/bin', '.context/overrides', '.context/.gitignore')) { + $full = Join-Path $Dir $p + if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force } + } + $agents = Join-Path $Dir 'AGENTS.md' + $out = New-Object System.Collections.Generic.List[string] + $skip = $false + foreach ($line in [System.IO.File]::ReadAllLines($agents, [System.Text.Encoding]::UTF8)) { + if ($line.StartsWith('')) { continue } + if ($line.StartsWith('\s*$') { $skip = $false }; continue } + $out.Add($line) + } + [System.IO.File]::WriteAllText($agents, (($out -join "`n") + "`n"), (New-Object System.Text.UTF8Encoding($false))) +} + +function New-GitDir { + param([string]$Prefix) + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ($Prefix + [System.Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + & git -C $dir init -q 2>$null + & git -C $dir config user.email t@t + & git -C $dir config user.name t + # Git for Windows defaults to core.autocrlf=true, which prints a warning per + # file on add; Windows PowerShell 5.1 stalls redirecting that much native + # stderr. The fixtures are LF and the line-ending cases are tested directly. + & git -C $dir config core.autocrlf false + & git -C $dir config core.safecrlf false + return $dir +} + +function Save-GitState { + param([string]$Dir) + & git -C $Dir add -A 2>$null + & git -C $Dir commit -qm init 2>$null | Out-Null +} + +function Get-TestSha256 { + param([string]$Path) + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +. (Join-Path $ScriptsDir 'lib/common.ps1') + +# ======================================================================= +# TC8: migrate recognises content from any earlier revision +# Mirrors TC17 in test-deploy.sh. +# ======================================================================= +Write-Host "" +Write-Host "=== TC8: migrate across historical revisions ===" +$tc8Dir = New-GitDir 'ac-tc8-' +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc8Dir *>$null +ConvertTo-Unversioned -Dir $tc8Dir + +$tc8Baseline = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-tc8-baseline-" + [System.Guid]::NewGuid().ToString("N")) +Copy-Item -LiteralPath (Join-Path $ScriptsDir 'baselines/unversioned.sha256') -Destination $tc8Baseline +$tc8Testing = Join-Path $tc8Dir '.context/standards/testing.md' +[System.IO.File]::WriteAllText($tc8Testing, "older revision`n") +$tc8Retired = Join-Path $tc8Dir '.context/playbooks/setup/retired.md' +[System.IO.File]::WriteAllText($tc8Retired, "retired playbook`n") +$tc8Region = Get-AcAgentsRegion -Path (Join-Path $tc8Dir 'AGENTS.md') +$tc8Extra = @( + "standards/testing.md $(Get-TestSha256 $tc8Testing)", + "playbooks/setup/retired.md $(Get-TestSha256 $tc8Retired)", + "$($script:AcAgentsRegionKey) $(Get-AcStringHash $tc8Region)" +) +[System.IO.File]::AppendAllText($tc8Baseline, (($tc8Extra -join "`n") + "`n")) +# A consumer route added to the index, and non-ASCII text that must survive. +$tc8Index = Join-Path $tc8Dir '.context/index.md' +$tc8Dash = [string][char]0x2014 +[System.IO.File]::AppendAllText($tc8Index, "| billing | .context/overrides/standards/billing.md | ours $tc8Dash mine |`n") +$tc8AgentsPath = Join-Path $tc8Dir 'AGENTS.md' +$tc8DashesBefore = ([regex]::Matches([System.IO.File]::ReadAllText($tc8AgentsPath, [System.Text.Encoding]::UTF8), [string][char]0x2014)).Count +Save-GitState -Dir $tc8Dir + +& "$ScriptsDir/migrate.ps1" -Target $tc8Dir -Apply -Baseline $tc8Baseline *>$null + +$tc8RepoTesting = Join-Path $RepoDir 'standards/testing.md' +if (-not (Test-Path -LiteralPath (Join-Path $tc8Dir '.context/overrides/standards/testing.md')) -and + ((Get-TestSha256 $tc8Testing) -eq (Get-TestSha256 $tc8RepoTesting))) { + Pass "migrate: an earlier revision is pristine, restored and not promoted" +} else { + Fail "migrate: an earlier revision was treated as a consumer edit" +} + +if (-not (Test-Path -LiteralPath $tc8Retired) -and + -not (Test-Path -LiteralPath (Join-Path $tc8Dir '.context/overrides/playbooks/setup/retired.md'))) { + Pass "migrate: an unedited file the library no longer ships is removed" +} else { + Fail "migrate: a retired framework file was kept as if the consumer wrote it" +} + +Assert-Contains "edited index.md kept as an extend override" (Join-Path $tc8Dir '.context/overrides/index.md') "mode: extend" + +$tc8Agents = [System.IO.File]::ReadAllText($tc8AgentsPath, [System.Text.Encoding]::UTF8) +$tc8Lines = $tc8Agents -split "`n" +if (([regex]::Matches($tc8Agents, '(?m)^## Context System')).Count -eq 1 -and + $tc8Lines[0] -eq '# AGENTS.md' -and + $tc8Agents.Contains('')) { + Pass "migrate: AGENTS.md framework sections replaced in place, not duplicated" +} else { + Fail "migrate: AGENTS.md was not converted in place" +} + +# Windows PowerShell 5.1 regressions: Set-Content -Encoding UTF8 wrote a BOM, +# and Get-Content decoded UTF-8 as ANSI, turning every em dash into mojibake. +$tc8Bytes = [System.IO.File]::ReadAllBytes($tc8AgentsPath) +if ($tc8Bytes.Length -ge 3 -and $tc8Bytes[0] -eq 0xEF -and $tc8Bytes[1] -eq 0xBB -and $tc8Bytes[2] -eq 0xBF) { + Fail "migrate: AGENTS.md was written with a BOM" +} else { + Pass "migrate: AGENTS.md written without a BOM" +} +$tc8DashesAfter = ([regex]::Matches($tc8Agents, [string][char]0x2014)).Count +if ($tc8DashesAfter -ge $tc8DashesBefore -and -not $tc8Agents.Contains([string][char]0x00E2)) { + Pass "migrate: non-ASCII text in AGENTS.md survives intact" +} else { + Fail "migrate: non-ASCII text in AGENTS.md was corrupted" +} + +$tc8Manifest = Get-AcManifest -Path (Join-Path $tc8Dir '.context/manifest.json') +if ($tc8Manifest -and @($tc8Manifest.agents) -contains 'claude') { + Pass "migrate: manifest records the agents inferred from the deployment" +} else { + Fail "migrate: manifest agents were not inferred" +} + +Assert-Contains "update-check stamp git-ignored" (Join-Path $tc8Dir '.context/.gitignore') "last-update-check" + +Remove-Item -Recurse -Force $tc8Dir +Remove-Item -Force $tc8Baseline + +# ======================================================================= +# TC9: re-running deploy over a pre-versioning deployment upgrades it +# Mirrors TC18 in test-deploy.sh. +# ======================================================================= +Write-Host "" +Write-Host "=== TC9: deploy over an unversioned deployment ===" +$tc9Dir = New-GitDir 'ac-tc9-' +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc9Dir *>$null +ConvertTo-Unversioned -Dir $tc9Dir +[System.IO.File]::AppendAllText((Join-Path $tc9Dir '.context/standards/security.md'), "MY LOCAL EDIT`n") +$tc9Stale = Join-Path $tc9Dir '.claude/skills/old-gone' +New-Item -ItemType Directory -Path $tc9Stale -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $tc9Stale 'SKILL.md'), "---`nname: old-gone`ndescription: `"x`"`n---`n`nRead and follow ``.context/playbooks/assess/old-gone.md`` in full.`n") +$tc9Mine = Join-Path $tc9Dir '.claude/skills/my-skill' +New-Item -ItemType Directory -Path $tc9Mine -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $tc9Mine 'SKILL.md'), "My own skill, which points at .context/playbooks/assess/not-there.md`n") +Save-GitState -Dir $tc9Dir + +[System.IO.File]::WriteAllText((Join-Path $tc9Dir 'dirty.txt'), "dirty`n") +& "$ScriptsDir/deploy.ps1" -Agents claude -NoOverwrite -Target $tc9Dir *>$null +if (-not (Test-Path -LiteralPath (Join-Path $tc9Dir '.context/manifest.json'))) { + Pass "deploy: refuses to upgrade a dirty tree and writes nothing" +} else { + Fail "deploy: upgraded an unversioned deployment on a dirty tree" +} +Remove-Item -LiteralPath (Join-Path $tc9Dir 'dirty.txt') -Force + +$tc9Out = & "$ScriptsDir/deploy.ps1" -Agents claude -NoOverwrite -Target $tc9Dir *>&1 | Out-String + +$tc9Override = Join-Path $tc9Dir '.context/overrides/standards/security.md' +if ((Test-Path -LiteralPath (Join-Path $tc9Dir '.context/manifest.json')) -and + (Test-Path -LiteralPath $tc9Override) -and + ([System.IO.File]::ReadAllText($tc9Override)).Contains('MY LOCAL EDIT') -and + -not ([System.IO.File]::ReadAllText((Join-Path $tc9Dir '.context/standards/security.md'))).Contains('MY LOCAL EDIT')) { + Pass "deploy: unversioned deployment migrated, local edit preserved as an override" +} else { + Fail "deploy: unversioned deployment was not migrated" +} + +Assert-Contains "AGENTS.md gained the managed block" (Join-Path $tc9Dir 'AGENTS.md') "agentic-context:begin" + +if ($tc9Out -notmatch 'Skipped files') { + Pass "deploy: unchanged files are not reported as skipped" +} else { + Fail "deploy: identical files were reported as skipped" +} + +if (-not (Test-Path -LiteralPath $tc9Stale) -and (Test-Path -LiteralPath (Join-Path $tc9Mine 'SKILL.md'))) { + Pass "deploy: stale generated wrapper pruned, consumer skill untouched" +} else { + Fail "deploy: skill wrapper pruning removed the wrong files" +} + +Remove-Item -Recurse -Force $tc9Dir + +# ======================================================================= # Summary -# ═══════════════════════════════════════════════════════════════════════ +# ======================================================================= Write-Host "" Write-Host "=== Results ===" Write-Host " Passed: $($script:Passed)" diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh index 4f20420..7b3ed47 100755 --- a/scripts/tests/test-deploy.sh +++ b/scripts/tests/test-deploy.sh @@ -818,6 +818,175 @@ fi rm -rf "$TC16_DIR" +# ═══════════════════════════════════════════════════════════════════════ +# Helper: turn a fresh deployment into what a pre-versioning deploy left. +# No manifest, no tooling, no override layer, and an AGENTS.md without markers. +# ═══════════════════════════════════════════════════════════════════════ +make_unversioned() { + local dir="$1" tmp + rm -rf "$dir/.context/manifest.json" "$dir/.context/VERSION" "$dir/.context/bin" \ + "$dir/.context/overrides" "$dir/.context/.gitignore" + tmp="$(mktemp)" + awk ' + /^/ { next } + /^[[:space:]]*$/) skip = 0; next } + { print } + ' "$dir/AGENTS.md" > "$tmp" + cat "$tmp" > "$dir/AGENTS.md" + rm -f "$tmp" +} + +# sha256 of a file's contents, portable across macOS and Linux. +sha_file() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1"; else sha256sum "$1"; fi | awk '{print $1}'; } + +# ═══════════════════════════════════════════════════════════════════════ +# TC17: migrate recognises content from any earlier revision +# +# Unversioned adopters deployed from whichever commit was current, so a file +# matching ANY revision the library shipped is pristine. A single-snapshot +# baseline turned every file the library had since improved into a pinned +# "mode: replace" override, and kept files the library had since removed as if +# the consumer had written them. The framework sections of AGENTS.md are +# swapped for the managed block in place rather than duplicated. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC17: migrate across historical revisions ===" + +TC17_DIR=$(mktemp -d) +(cd "$TC17_DIR" && git init -q . && git config user.email t@t && git config user.name t) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC17_DIR" >/dev/null 2>&1 +make_unversioned "$TC17_DIR" + +TC17_BASELINE="$(mktemp)" +cp "$SCRIPTS_DIR/baselines/unversioned.sha256" "$TC17_BASELINE" +# An older revision of a file that still ships. +printf 'older revision\n' > "$TC17_DIR/.context/standards/testing.md" +printf 'standards/testing.md %s\n' "$(sha_file "$TC17_DIR/.context/standards/testing.md")" >> "$TC17_BASELINE" +# A file the library used to ship and no longer does. +printf 'retired playbook\n' > "$TC17_DIR/.context/playbooks/setup/retired.md" +printf 'playbooks/setup/retired.md %s\n' "$(sha_file "$TC17_DIR/.context/playbooks/setup/retired.md")" >> "$TC17_BASELINE" +# The framework region of this AGENTS.md, as an earlier template shipped it. +( + # shellcheck source=scripts/lib/common.sh + . "$SCRIPTS_DIR/lib/common.sh" + region="$(mktemp)" + ac_agents_region "$TC17_DIR/AGENTS.md" > "$region" + printf '%s %s\n' "$AC_AGENTS_REGION_KEY" "$(sha_file "$region")" >> "$TC17_BASELINE" + rm -f "$region" +) +# A consumer route added to the index. +echo "| billing | .context/overrides/standards/billing.md | ours |" >> "$TC17_DIR/.context/index.md" +(cd "$TC17_DIR" && git add -A >/dev/null 2>&1 && git commit -qm init >/dev/null 2>&1) + +TC17_OUT="$("$SCRIPTS_DIR/migrate.sh" --apply --baseline "$TC17_BASELINE" "$TC17_DIR" 2>&1)" || true + +if [ ! -e "$TC17_DIR/.context/overrides/standards/testing.md" ] \ + && cmp -s "$TC17_DIR/.context/standards/testing.md" "$REPO_DIR/standards/testing.md"; then + pass "migrate: an earlier revision is pristine, restored and not promoted" +else + fail "migrate: an earlier revision was treated as a consumer edit (output: $TC17_OUT)" +fi + +if [ ! -e "$TC17_DIR/.context/playbooks/setup/retired.md" ] \ + && [ ! -e "$TC17_DIR/.context/overrides/playbooks/setup/retired.md" ]; then + pass "migrate: an unedited file the library no longer ships is removed" +else + fail "migrate: a retired framework file was kept as if the consumer wrote it" +fi + +if grep -q '^mode: extend' "$TC17_DIR/.context/overrides/index.md" 2>/dev/null \ + && grep -q 'billing' "$TC17_DIR/.context/overrides/index.md"; then + pass "migrate: an edited index.md is kept as an extend override" +else + fail "migrate: consumer routes in index.md were lost" +fi + +if [ "$(grep -c '^## Context System' "$TC17_DIR/AGENTS.md")" = "1" ] \ + && [ "$(head -1 "$TC17_DIR/AGENTS.md")" = "# AGENTS.md" ] \ + && grep -q '^' "$TC17_DIR/AGENTS.md"; then + pass "migrate: AGENTS.md framework sections replaced in place, not duplicated" +else + fail "migrate: AGENTS.md was not converted in place" +fi + +if grep -q '"agents": \["claude"\]' "$TC17_DIR/.context/manifest.json"; then + pass "migrate: manifest records the agents inferred from the deployment" +else + fail "migrate: manifest agents were not inferred" +fi + +if [ -f "$TC17_DIR/.context/.gitignore" ] && grep -q 'last-update-check' "$TC17_DIR/.context/.gitignore"; then + pass "migrate: update-check stamp is git-ignored" +else + fail "migrate: .context/.gitignore was not installed" +fi + +rm -rf "$TC17_DIR" "$TC17_BASELINE" + +# ═══════════════════════════════════════════════════════════════════════ +# TC18: re-running deploy over a pre-versioning deployment upgrades it +# +# Most adopters will simply pull the library and re-run deploy, as they always +# have. Doing so used to stamp a 1.0.0 manifest over stale base files, leave +# AGENTS.md unmanaged, and make migrate refuse to run afterwards. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC18: deploy over an unversioned deployment ===" + +TC18_DIR=$(mktemp -d) +(cd "$TC18_DIR" && git init -q . && git config user.email t@t && git config user.name t) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC18_DIR" >/dev/null 2>&1 +make_unversioned "$TC18_DIR" +echo "MY LOCAL EDIT" >> "$TC18_DIR/.context/standards/security.md" +mkdir -p "$TC18_DIR/.claude/skills/old-gone" +printf -- '---\nname: old-gone\ndescription: "x"\n---\n\nRead and follow `.context/playbooks/assess/old-gone.md` in full.\n' \ + > "$TC18_DIR/.claude/skills/old-gone/SKILL.md" +mkdir -p "$TC18_DIR/.claude/skills/my-skill" +printf 'My own skill, which points at .context/playbooks/assess/not-there.md\n' > "$TC18_DIR/.claude/skills/my-skill/SKILL.md" +(cd "$TC18_DIR" && git add -A >/dev/null 2>&1 && git commit -qm init >/dev/null 2>&1) + +echo "dirty" > "$TC18_DIR/dirty.txt" +if ! "$SCRIPTS_DIR/deploy.sh" --agents claude --no-overwrite "$TC18_DIR" >/dev/null 2>&1 \ + && [ ! -f "$TC18_DIR/.context/manifest.json" ]; then + pass "deploy: refuses to upgrade a dirty tree and writes nothing" +else + fail "deploy: upgraded an unversioned deployment on a dirty tree" +fi +rm -f "$TC18_DIR/dirty.txt" + +TC18_OUT="$("$SCRIPTS_DIR/deploy.sh" --agents claude --no-overwrite "$TC18_DIR" 2>&1)" || true + +if [ -f "$TC18_DIR/.context/manifest.json" ] \ + && grep -q 'MY LOCAL EDIT' "$TC18_DIR/.context/overrides/standards/security.md" 2>/dev/null \ + && ! grep -q 'MY LOCAL EDIT' "$TC18_DIR/.context/standards/security.md"; then + pass "deploy: unversioned deployment migrated, local edit preserved as an override" +else + fail "deploy: unversioned deployment was not migrated (output: $TC18_OUT)" +fi + +if grep -q '^