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-ps1-tests.yml b/.github/workflows/deploy-ps1-tests.yml index 88d1e36..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 ./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)." @@ -110,7 +122,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..c1aabde 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -57,6 +57,68 @@ 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 + + - 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: @@ -81,19 +143,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/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..360d430 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,55 @@ +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: + # 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 + 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..bb1622a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,181 @@ +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')" + # 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)" + + # 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" + 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 + 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" + + # 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" + + - 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..2d4ebb4 --- /dev/null +++ b/.github/workflows/version-gate.yml @@ -0,0 +1,170 @@ +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 + # 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 + + - name: Reject hand-edits to generated files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + fail=0 + + # 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 -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 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/.sha256 is published by the release workflow. Do not add or edit per-release 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: 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: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + 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 "$PR_TITLE" --latest-tag "$prev_tag")" + { + 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." + 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 "" + 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" + echo "prev_tag=$prev_tag" >> "$GITHUB_OUTPUT" + + - 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 + fi + echo "OK: $current -> $NEXT" 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 diff --git a/AGENTS.md b/AGENTS.md index 5d36863..d5c71d1 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. @@ -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 @@ -107,13 +119,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 +138,41 @@ 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. + +--- + +### Versioning and releases + +**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: + +| 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. 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. 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. --- @@ -146,7 +186,11 @@ 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` | +| 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. @@ -155,7 +199,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,16 +218,20 @@ 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 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/.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. ## Decision Checklist @@ -192,8 +240,12 @@ 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 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/.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 new file mode 100644 index 0000000..1d728cd --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,104 @@ +# 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. + +--- + +## 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 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 1.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. 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 + +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 `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`. +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`. + +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 +you actually deployed. 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 ee2b0fa..bc5b466 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). @@ -79,12 +79,14 @@ 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 .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/) @@ -114,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) @@ -133,12 +139,26 @@ 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 + update.sh / update.ps1 Staleness check and in-place update + 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 (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) + +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 +183,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 @@ -176,6 +196,152 @@ 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, 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/.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 + +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 --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 +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**: @@ -186,7 +352,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/core/.context/index.md b/core/.context/index.md index 2b35f97..619ad1d 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) @@ -51,17 +63,22 @@ Combine multiple matches when a task spans domains. | 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 | --- @@ -79,7 +96,7 @@ Combine multiple matches when a task spans domains. | 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/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..e3361bc 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,23 @@ 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 --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. + --- ## Mandated Standards @@ -162,8 +185,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/unversioned.sha256 b/scripts/baselines/unversioned.sha256 new file mode 100644 index 0000000..1985945 --- /dev/null +++ b/scripts/baselines/unversioned.sha256 @@ -0,0 +1,78 @@ +conventions/code.md 306b9916bd0ebc87c418b4ded90a56c600708b0144a37de4aaca53484e19fb25 +conventions/communication.md 508a9981cc3830e9992eb6edb30473c06ed149bcc63bab63fb8683e277d6ee03 +conventions/workflow.md 6cc7f88c4dde53b9a713f9257060497abdf3e343c6e09b515b0cdb38464eb8cd +index.md 9081522a512ac330d633c4cedd3f07edb792928b35ad8640b0b853c27fbb1789 +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/ci/next-version.sh b/scripts/ci/next-version.sh new file mode 100755 index 0000000..05aea09 --- /dev/null +++ b/scripts/ci/next-version.sh @@ -0,0 +1,129 @@ +#!/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 ] [--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 +# 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="" +BODY="" +BODY_FILE="" +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 ;; + --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; } + +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 + +# --- 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 +# 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" +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 + +ac_semver_bump "$CURRENT" "$BUMP" diff --git a/scripts/ci/write-baseline.sh b/scripts/ci/write-baseline.sh new file mode 100755 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 100755 index 0000000..efe84d5 --- /dev/null +++ b/scripts/ci/write-changelog.sh @@ -0,0 +1,114 @@ +#!/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 + +# 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' + +# 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 + +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" diff --git a/deploy.Tests.ps1 b/scripts/deploy.Tests.ps1 similarity index 55% rename from deploy.Tests.ps1 rename to scripts/deploy.Tests.ps1 index 3a968f6..b19adfa 100644 --- a/deploy.Tests.ps1 +++ b/scripts/deploy.Tests.ps1 @@ -32,11 +32,18 @@ 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 | 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 } } @@ -192,3 +199,138 @@ 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' + } +} + +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/deploy.ps1 b/scripts/deploy.ps1 similarity index 72% rename from deploy.ps1 rename to scripts/deploy.ps1 index a9309cb..d90f27c 100644 --- a/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, @@ -547,46 +597,81 @@ 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 + +. (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 $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') + +# 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-AcContextExcludingOverrides -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 +695,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) { @@ -630,6 +715,63 @@ 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-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')" +$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 + } +} +# 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-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-SingleFile -Source $libSrc -Destination (Join-Path $binLibDst $libFile) + } +} + +Set-Content -LiteralPath (Join-Path $script:Target '.context/VERSION') -Value $DeployVersion -Encoding UTF8 + +Write-Host " Writing manifest -> $(Join-Path $script:Target '.context/manifest.json')" +# 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:" $step = 1 @@ -651,6 +793,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/deploy.sh b/scripts/deploy.sh similarity index 63% rename from deploy.sh rename to scripts/deploy.sh index e4a9aee..f41919f 100755 --- a/deploy.sh +++ b/scripts/deploy.sh @@ -165,6 +165,174 @@ 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 +# 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. + +# 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() { + 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 ac_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 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' "$pin" + printf ' "checkFrequency": "%s",\n' "$freq" + 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[@]} @@ -533,45 +701,57 @@ 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)" + +# 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 "$SCRIPT_DIR/core/AGENTS.md" "$TARGET/AGENTS.md" -copy_dir_contents "$SCRIPT_DIR/core/.context" "$TARGET/.context" +deploy_agents_md "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" "$DEPLOY_VERSION" +# 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..." - 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 +764,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 +794,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" \ @@ -634,6 +814,33 @@ 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" +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/" +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 + copy_file "$SOURCE_ROOT/scripts/$tool" "$TARGET/.context/bin/$tool" + fi +done +mkdir -p "$TARGET/.context/bin/lib" +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" + +echo " Writing manifest → $TARGET/.context/manifest.json" +write_manifest "$TARGET" "$DEPLOY_VERSION" "${ENABLED_AGENTS[*]}" + echo "" echo "Done. Next steps:" step=1 @@ -653,6 +860,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.ps1 b/scripts/lib/common.ps1 new file mode 100644 index 0000000..ac1361a --- /dev/null +++ b/scripts/lib/common.ps1 @@ -0,0 +1,394 @@ +# 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() +} + +# 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 { + 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 + + # -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/')) -and + ($generated -notcontains $relNorm) + } | + 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', + # 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)) { + 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 = $(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) + 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/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..0d135a0 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,280 @@ +#!/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 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 \ + ! -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 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 + { + 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 +} + +# 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.ps1 b/scripts/migrate.ps1 new file mode 100644 index 0000000..a1e982e --- /dev/null +++ b/scripts/migrate.ps1 @@ -0,0 +1,355 @@ +# migrate.ps1 - upgrade an unversioned agentic-context deployment to the override model. +# +# 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 +# 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 = 'unversioned', + [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 unversioned 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] +$nonMd = New-Object System.Collections.Generic.List[string] +$totalCount = 0 + +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('\', '/') + $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 { + # Not in the baseline at all: a file the consumer added themselves. + $added.Add($rel) + } + } +} + +# 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) { + 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 ($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 +} + +# --- 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 +} + +# 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 ..." +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 } + 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 new file mode 100755 index 0000000..32449f4 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,357 @@ +#!/bin/bash +# migrate.sh — upgrade an unversioned agentic-context deployment to the override model. +# +# 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 +# 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. + +# 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)" + +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="unversioned" +BASELINE="" + +usage() { + cat <] [--baseline ] [target-repo] + +Upgrade an unversioned deployment to the override model. + + --apply Write changes. Without it, reports what would happen and exits. + --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. + +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 unversioned 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)" +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"/}" + 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 + 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 ] && [ "$nonmd_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 + 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 +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" + +# 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%%:*}" + 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" +[ -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. +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/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 75% rename from tests/test-deploy.ps1 rename to scripts/tests/test-deploy.ps1 index f325c50..0fba0af 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 @@ -246,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 new file mode 100755 index 0000000..4f20420 --- /dev/null +++ b/scripts/tests/test-deploy.sh @@ -0,0 +1,834 @@ +#!/usr/bin/env bash +# Test suite for deploy.sh — verifies setup/ playbook deployment and regressions. +# +# Usage: +# bash scripts/tests/test-deploy.sh +# +# Exit codes: +# 0 All tests passed +# 1 One or more tests failed + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && 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 +# of git index permissions. In that case, skip non-executable assertions +# because cp preserves the (always-executable) source permissions. +PERMS_SUPPORTED=true +if [ -x "$REPO_DIR/README.md" ]; then + PERMS_SUPPORTED=false +fi + +# Portable checksum and executable-file listing. GNU coreutils provides +# 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 + # 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 ! -name 'manifest.json' -exec shasum -a 256 {} + | sort; } +else + echo "ERROR: neither sha256sum nor shasum is available" >&2 + exit 1 +fi + +# -exec test -x is portable; GNU -perm /111 and BSD -perm +111 are not interchangeable. +list_executables() { find "$1" -type f -exec test -x {} \; -print | sort; } + +PASSED=0 +FAILED=0 + +pass() { + echo " PASS: $1" + PASSED=$((PASSED + 1)) +} + +fail() { + echo " FAIL: $1" + FAILED=$((FAILED + 1)) +} + +assert_file_exists() { + local label="$1" + local path="$2" + if [ -f "$path" ]; then + pass "$label exists" + else + fail "$label does not exist: $path" + fi +} + +assert_file_not_exists() { + local label="$1" + local path="$2" + if [ ! -f "$path" ]; then + pass "$label does not exist (expected)" + else + fail "$label unexpectedly exists: $path" + fi +} + +assert_dir_not_exists() { + local label="$1" + local path="$2" + if [ ! -d "$path" ]; then + pass "$label directory does not exist (expected)" + else + fail "$label directory unexpectedly exists: $path" + fi +} + +assert_executable() { + local label="$1" + local path="$2" + if [ -x "$path" ]; then + pass "$label is executable" + else + fail "$label is not executable: $path" + fi +} + +assert_not_executable() { + local label="$1" + local path="$2" + if [ "$PERMS_SUPPORTED" = false ]; then + echo " SKIP: $label non-executable check (filesystem does not differentiate permissions)" + return 0 + fi + if [ ! -x "$path" ]; then + pass "$label is not executable (expected)" + else + fail "$label is unexpectedly executable: $path" + fi +} + +assert_contains() { + local label="$1" + local path="$2" + local expected="$3" + if grep -qF "$expected" "$path" 2>/dev/null; then + pass "$label contains '$expected'" + else + fail "$label does not contain '$expected'" + fi +} + +assert_not_contains() { + local label="$1" + local path="$2" + local unexpected="$3" + if ! grep -qF "$unexpected" "$path" 2>/dev/null; then + pass "$label does not contain '$unexpected'" + else + fail "$label unexpectedly contains '$unexpected'" + fi +} + +assert_files_identical() { + local label="$1" + local file_a="$2" + local file_b="$3" + if diff -q "$file_a" "$file_b" >/dev/null 2>&1; then + pass "$label files are identical" + else + fail "$label files differ" + diff "$file_a" "$file_b" || true + fi +} + +# ═══════════════════════════════════════════════════════════════════════ +# TC1: Fresh deploy — all agents +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC1: Fresh deploy — all agents ===" +TC1_DIR=$(mktemp -d) +"$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" +assert_file_exists "discover-local-otel-stack.md" "$TC1_DIR/.context/playbooks/setup/discover-local-otel-stack.md" +assert_file_exists "use-local-otel-stack.md" "$TC1_DIR/.context/playbooks/setup/use-local-otel-stack.md" +assert_file_not_exists "instrument-dotnet-otel.md (migrated to standard)" "$TC1_DIR/.context/playbooks/setup/instrument-dotnet-otel.md" + +echo " --- OTel standards ---" +assert_file_exists "opentelemetry.md" "$TC1_DIR/.context/standards/opentelemetry.md" +assert_file_exists "opentelemetry-dotnet.md" "$TC1_DIR/.context/standards/opentelemetry-dotnet.md" + +echo " --- Debugging standard and playbooks ---" +assert_file_exists "debugging.md" "$TC1_DIR/.context/standards/debugging.md" +assert_file_exists "debug/scientific-debugging.md" "$TC1_DIR/.context/playbooks/debug/scientific-debugging.md" +assert_file_exists "plan/research.md" "$TC1_DIR/.context/playbooks/plan/research.md" + +echo " --- Companion scripts (executable) ---" +assert_file_exists "start-local-otel-stack.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/start-local-otel-stack.sh" +assert_executable "start-local-otel-stack.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/start-local-otel-stack.sh" +assert_file_exists "test-local-otel-stack.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/test-local-otel-stack.sh" +assert_executable "test-local-otel-stack.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/test-local-otel-stack.sh" +assert_file_exists "validate-config.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" +assert_executable "validate-config.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" + +echo " --- Non-executable files ---" +assert_file_exists "Start-LocalOtelStack.ps1" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/Start-LocalOtelStack.ps1" +assert_file_exists "versions.env" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/versions.env" +assert_not_executable "versions.env" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/versions.env" + +echo " --- Claude thin wrappers ---" +assert_file_exists "claude/setup-create-local-otel-stack" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" +assert_file_exists "claude/setup-discover-local-otel-stack" "$TC1_DIR/.claude/skills/setup-discover-local-otel-stack/SKILL.md" +assert_file_exists "claude/setup-use-local-otel-stack" "$TC1_DIR/.claude/skills/setup-use-local-otel-stack/SKILL.md" +assert_file_not_exists "claude/setup-instrument-dotnet-otel (removed — migrated to standard)" "$TC1_DIR/.claude/skills/setup-instrument-dotnet-otel/SKILL.md" +assert_file_exists "claude/debug-scientific-debugging" "$TC1_DIR/.claude/skills/debug-scientific-debugging/SKILL.md" +assert_file_exists "claude/plan-research" "$TC1_DIR/.claude/skills/plan-research/SKILL.md" +assert_contains "claude debug wrapper unrestricted bash" "$TC1_DIR/.claude/skills/debug-scientific-debugging/SKILL.md" "Bash," +assert_contains "claude debug wrapper has playbook path" "$TC1_DIR/.claude/skills/debug-scientific-debugging/SKILL.md" ".context/playbooks/debug/scientific-debugging.md" + +echo " --- Copilot thin wrappers ---" +assert_file_exists "copilot/setup-create-local-otel-stack" "$TC1_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" +assert_file_exists "copilot/setup-discover-local-otel-stack" "$TC1_DIR/.github/skills/setup-discover-local-otel-stack/SKILL.md" +assert_file_exists "copilot/setup-use-local-otel-stack" "$TC1_DIR/.github/skills/setup-use-local-otel-stack/SKILL.md" +assert_file_not_exists "copilot/setup-instrument-dotnet-otel (removed — migrated to standard)" "$TC1_DIR/.github/skills/setup-instrument-dotnet-otel/SKILL.md" +assert_file_exists "copilot/debug-scientific-debugging" "$TC1_DIR/.github/skills/debug-scientific-debugging/SKILL.md" +assert_file_exists "copilot/plan-research" "$TC1_DIR/.github/skills/plan-research/SKILL.md" +assert_not_contains "copilot debug wrapper no allowed-tools" "$TC1_DIR/.github/skills/debug-scientific-debugging/SKILL.md" "allowed-tools:" + +echo " --- Wrapper content checks ---" +assert_contains "claude wrapper allowed-tools" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" "allowed-tools:" +assert_not_contains "claude wrapper no git-only bash" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" "Bash(git *)" +assert_contains "claude wrapper unrestricted bash" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" "Bash," +assert_contains "claude wrapper has description" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" 'description: "Create and start a local OpenTelemetry' +assert_contains "claude wrapper has playbook path" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" ".context/playbooks/setup/create-local-otel-stack.md" + +echo " --- Copilot wrappers omit allowed-tools ---" +assert_not_contains "copilot wrapper no allowed-tools" "$TC1_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" "allowed-tools:" +assert_contains "copilot wrapper has description" "$TC1_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" 'description: "Create and start a local OpenTelemetry' +assert_contains "copilot wrapper has playbook path" "$TC1_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" ".context/playbooks/setup/create-local-otel-stack.md" + +echo " --- Safety and provenance ---" +assert_contains "local-dev-only warning" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack.md" "Local development and testing only" +assert_contains "provenance comment" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack.md" "Ported from devopsin" + +echo " --- Index routing ---" +assert_contains "index has setup playbooks" "$TC1_DIR/.context/index.md" "playbooks/setup/" + +echo " --- Negative: monolithic skill not ported ---" +assert_file_not_exists "local-otel-stack.md" "$TC1_DIR/.context/playbooks/setup/local-otel-stack.md" + +rm -rf "$TC1_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC2: Agent-scoped deploy — Claude only +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC2: Agent-scoped deploy — Claude only ===" +TC2_DIR=$(mktemp -d) +"$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" + +rm -rf "$TC2_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC3: Agent-scoped deploy — Copilot only +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC3: Agent-scoped deploy — Copilot only ===" +TC3_DIR=$(mktemp -d) +"$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" + +rm -rf "$TC3_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC4: No regressions — existing thin-wrapper generation +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC4: No regressions — existing thin wrappers ===" +TC4_DIR=$(mktemp -d) +"$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" +assert_file_exists "plan-adr" "$TC4_DIR/.claude/skills/plan-adr/SKILL.md" +assert_file_exists "refactor-safe-refactor" "$TC4_DIR/.claude/skills/safe-refactor/SKILL.md" + +echo " --- Regression content check ---" +assert_files_identical "assess-observability fixture" \ + "$TC4_DIR/.claude/skills/assess-observability/SKILL.md" \ + "$SCRIPT_DIR/fixtures/assess-observability-skill.md" + +rm -rf "$TC4_DIR" + +# ═══════════════════════════════════════════════════════════════════════ +# TC5: Idempotency +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC5: Idempotency ===" +TC5_DIR=$(mktemp -d) +TC5_CHECKSUMS1=$(mktemp) +TC5_CHECKSUMS2=$(mktemp) +TC5_PERMS1=$(mktemp) +TC5_PERMS2=$(mktemp) + +"$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" + +"$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" + +if diff -q "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" >/dev/null 2>&1; then + pass "File checksums identical across both runs" +else + fail "File checksums differ between runs" + diff "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" || true +fi + +if diff -q "$TC5_PERMS1" "$TC5_PERMS2" >/dev/null 2>&1; then + pass "Executable permissions identical across both runs" +else + fail "Executable permissions differ between runs" + diff "$TC5_PERMS1" "$TC5_PERMS2" || true +fi + +# 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 +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC6: validate-config passes ===" +TC6_DIR=$(mktemp -d) +"$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" +else + fail "Deployed validate-config.sh exited non-zero" +fi + +if "$REPO_DIR/playbooks/setup/create-local-otel-stack/validate-config.sh" >/dev/null 2>&1; then + pass "Source validate-config.sh exits 0" +else + fail "Source validate-config.sh exited non-zero" +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 + # --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 --latest-tag v1.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" + +# 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" + +# ═══════════════════════════════════════════════════════════════════════ +# 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 +# ═══════════════════════════════════════════════════════════════════════ +# ═══════════════════════════════════════════════════════════════════════ +# 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" + +# ═══════════════════════════════════════════════════════════════════════ +# 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" + +# ═══════════════════════════════════════════════════════════════════════ +# 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" + +# ═══════════════════════════════════════════════════════════════════════ +# 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" +echo " Failed: $FAILED" + +if [ "$FAILED" -gt 0 ]; then + echo "" + echo "TEST SUITE FAILED" + exit 1 +else + echo "" + echo "TEST SUITE PASSED" + exit 0 +fi diff --git a/scripts/update.ps1 b/scripts/update.ps1 new file mode 100644 index 0000000..dca9456 --- /dev/null +++ b/scripts/update.ps1 @@ -0,0 +1,319 @@ +# 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 { + # 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 + exit 0 +} + +# --- apply ----------------------------------------------------------------- + +if (-not $pinOk) { + 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 +} + +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') } + ) + + # 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 + } + } + + # 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 } + New-Item -ItemType Directory -Path $dest -Force | Out-Null + Copy-Item -Path (Join-Path $area.From '*') -Destination $dest -Recurse -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' + $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 -Pin $Pin + + 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 new file mode 100755 index 0000000..7527095 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,411 @@ +#!/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 rel_escaped + ac_hash_context_tree "$CONTEXT_DIR" | while IFS= read -r line; do + rel="${line%% *}" + 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. "|" 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" + 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 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):" + 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 + # 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 + 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 $AC_WEB_BASE/$SOURCE_REPO/blob/v$LATEST/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. +# 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 +# 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%%:*}" + from="${pair#*:}" + 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 + +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." +for tool in update.sh update.ps1 migrate.sh migrate.ps1; do + if [ -f "$SRC/scripts/$tool" ]; then + cp "$SRC/scripts/$tool" "$CONTEXT_DIR/bin/$tool" || partial_apply "could not write bin/$tool." + fi +done +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. +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. +AGENTS_FILE="$TARGET_ROOT/AGENTS.md" +if [ -f "$AGENTS_FILE" ] && [ -f "$SRC/core/AGENTS.md" ]; then + # Both markers are required. Guarding on the begin marker alone would let the + # rewrite below swallow every line from it to EOF whenever the end marker is + # missing - destroying the consumer configuration this block exists to + # protect. deploy.sh and update.ps1 both require the pair; so must this. + if ac_has_managed_block "$AGENTS_FILE"; then + block="$WORK_DIR/block.md" + awk -v b='' ' + 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 well-formed managed block (needs both the begin and end marker) — 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')" +# 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" + +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' "$pin" + 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 diff --git a/tests/test-deploy.sh b/tests/test-deploy.sh deleted file mode 100644 index c6b009a..0000000 --- a/tests/test-deploy.sh +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env bash -# Test suite for deploy.sh — verifies setup/ playbook deployment and regressions. -# -# Usage: -# ./tests/test-deploy.sh -# -# Exit codes: -# 0 All tests passed -# 1 One or more tests failed - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" - -# Detect whether the source filesystem supports permission differentiation. -# On WSL2-mounted Windows filesystems, all files are rwxrwxrwx regardless -# of git index permissions. In that case, skip non-executable assertions -# because cp preserves the (always-executable) source permissions. -PERMS_SUPPORTED=true -if [ -x "$REPO_DIR/README.md" ]; then - PERMS_SUPPORTED=false -fi - -# Portable checksum and executable-file listing. GNU coreutils provides -# 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; } -elif command -v shasum >/dev/null 2>&1; then - checksum_tree() { find "$1" -type f -exec shasum -a 256 {} + | sort; } -else - echo "ERROR: neither sha256sum nor shasum is available" >&2 - exit 1 -fi - -# -exec test -x is portable; GNU -perm /111 and BSD -perm +111 are not interchangeable. -list_executables() { find "$1" -type f -exec test -x {} \; -print | sort; } - -PASSED=0 -FAILED=0 - -pass() { - echo " PASS: $1" - PASSED=$((PASSED + 1)) -} - -fail() { - echo " FAIL: $1" - FAILED=$((FAILED + 1)) -} - -assert_file_exists() { - local label="$1" - local path="$2" - if [ -f "$path" ]; then - pass "$label exists" - else - fail "$label does not exist: $path" - fi -} - -assert_file_not_exists() { - local label="$1" - local path="$2" - if [ ! -f "$path" ]; then - pass "$label does not exist (expected)" - else - fail "$label unexpectedly exists: $path" - fi -} - -assert_dir_not_exists() { - local label="$1" - local path="$2" - if [ ! -d "$path" ]; then - pass "$label directory does not exist (expected)" - else - fail "$label directory unexpectedly exists: $path" - fi -} - -assert_executable() { - local label="$1" - local path="$2" - if [ -x "$path" ]; then - pass "$label is executable" - else - fail "$label is not executable: $path" - fi -} - -assert_not_executable() { - local label="$1" - local path="$2" - if [ "$PERMS_SUPPORTED" = false ]; then - echo " SKIP: $label non-executable check (filesystem does not differentiate permissions)" - return 0 - fi - if [ ! -x "$path" ]; then - pass "$label is not executable (expected)" - else - fail "$label is unexpectedly executable: $path" - fi -} - -assert_contains() { - local label="$1" - local path="$2" - local expected="$3" - if grep -qF "$expected" "$path" 2>/dev/null; then - pass "$label contains '$expected'" - else - fail "$label does not contain '$expected'" - fi -} - -assert_not_contains() { - local label="$1" - local path="$2" - local unexpected="$3" - if ! grep -qF "$unexpected" "$path" 2>/dev/null; then - pass "$label does not contain '$unexpected'" - else - fail "$label unexpectedly contains '$unexpected'" - fi -} - -assert_files_identical() { - local label="$1" - local file_a="$2" - local file_b="$3" - if diff -q "$file_a" "$file_b" >/dev/null 2>&1; then - pass "$label files are identical" - else - fail "$label files differ" - diff "$file_a" "$file_b" || true - fi -} - -# ═══════════════════════════════════════════════════════════════════════ -# TC1: Fresh deploy — all agents -# ═══════════════════════════════════════════════════════════════════════ -echo "" -echo "=== TC1: Fresh deploy — all agents ===" -TC1_DIR=$(mktemp -d) -"$REPO_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" -assert_file_exists "discover-local-otel-stack.md" "$TC1_DIR/.context/playbooks/setup/discover-local-otel-stack.md" -assert_file_exists "use-local-otel-stack.md" "$TC1_DIR/.context/playbooks/setup/use-local-otel-stack.md" -assert_file_not_exists "instrument-dotnet-otel.md (migrated to standard)" "$TC1_DIR/.context/playbooks/setup/instrument-dotnet-otel.md" - -echo " --- OTel standards ---" -assert_file_exists "opentelemetry.md" "$TC1_DIR/.context/standards/opentelemetry.md" -assert_file_exists "opentelemetry-dotnet.md" "$TC1_DIR/.context/standards/opentelemetry-dotnet.md" - -echo " --- Debugging standard and playbooks ---" -assert_file_exists "debugging.md" "$TC1_DIR/.context/standards/debugging.md" -assert_file_exists "debug/scientific-debugging.md" "$TC1_DIR/.context/playbooks/debug/scientific-debugging.md" -assert_file_exists "plan/research.md" "$TC1_DIR/.context/playbooks/plan/research.md" - -echo " --- Companion scripts (executable) ---" -assert_file_exists "start-local-otel-stack.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/start-local-otel-stack.sh" -assert_executable "start-local-otel-stack.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/start-local-otel-stack.sh" -assert_file_exists "test-local-otel-stack.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/test-local-otel-stack.sh" -assert_executable "test-local-otel-stack.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/test-local-otel-stack.sh" -assert_file_exists "validate-config.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" -assert_executable "validate-config.sh" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" - -echo " --- Non-executable files ---" -assert_file_exists "Start-LocalOtelStack.ps1" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/Start-LocalOtelStack.ps1" -assert_file_exists "versions.env" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/versions.env" -assert_not_executable "versions.env" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack/versions.env" - -echo " --- Claude thin wrappers ---" -assert_file_exists "claude/setup-create-local-otel-stack" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" -assert_file_exists "claude/setup-discover-local-otel-stack" "$TC1_DIR/.claude/skills/setup-discover-local-otel-stack/SKILL.md" -assert_file_exists "claude/setup-use-local-otel-stack" "$TC1_DIR/.claude/skills/setup-use-local-otel-stack/SKILL.md" -assert_file_not_exists "claude/setup-instrument-dotnet-otel (removed — migrated to standard)" "$TC1_DIR/.claude/skills/setup-instrument-dotnet-otel/SKILL.md" -assert_file_exists "claude/debug-scientific-debugging" "$TC1_DIR/.claude/skills/debug-scientific-debugging/SKILL.md" -assert_file_exists "claude/plan-research" "$TC1_DIR/.claude/skills/plan-research/SKILL.md" -assert_contains "claude debug wrapper unrestricted bash" "$TC1_DIR/.claude/skills/debug-scientific-debugging/SKILL.md" "Bash," -assert_contains "claude debug wrapper has playbook path" "$TC1_DIR/.claude/skills/debug-scientific-debugging/SKILL.md" ".context/playbooks/debug/scientific-debugging.md" - -echo " --- Copilot thin wrappers ---" -assert_file_exists "copilot/setup-create-local-otel-stack" "$TC1_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" -assert_file_exists "copilot/setup-discover-local-otel-stack" "$TC1_DIR/.github/skills/setup-discover-local-otel-stack/SKILL.md" -assert_file_exists "copilot/setup-use-local-otel-stack" "$TC1_DIR/.github/skills/setup-use-local-otel-stack/SKILL.md" -assert_file_not_exists "copilot/setup-instrument-dotnet-otel (removed — migrated to standard)" "$TC1_DIR/.github/skills/setup-instrument-dotnet-otel/SKILL.md" -assert_file_exists "copilot/debug-scientific-debugging" "$TC1_DIR/.github/skills/debug-scientific-debugging/SKILL.md" -assert_file_exists "copilot/plan-research" "$TC1_DIR/.github/skills/plan-research/SKILL.md" -assert_not_contains "copilot debug wrapper no allowed-tools" "$TC1_DIR/.github/skills/debug-scientific-debugging/SKILL.md" "allowed-tools:" - -echo " --- Wrapper content checks ---" -assert_contains "claude wrapper allowed-tools" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" "allowed-tools:" -assert_not_contains "claude wrapper no git-only bash" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" "Bash(git *)" -assert_contains "claude wrapper unrestricted bash" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" "Bash," -assert_contains "claude wrapper has description" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" 'description: "Create and start a local OpenTelemetry' -assert_contains "claude wrapper has playbook path" "$TC1_DIR/.claude/skills/setup-create-local-otel-stack/SKILL.md" ".context/playbooks/setup/create-local-otel-stack.md" - -echo " --- Copilot wrappers omit allowed-tools ---" -assert_not_contains "copilot wrapper no allowed-tools" "$TC1_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" "allowed-tools:" -assert_contains "copilot wrapper has description" "$TC1_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" 'description: "Create and start a local OpenTelemetry' -assert_contains "copilot wrapper has playbook path" "$TC1_DIR/.github/skills/setup-create-local-otel-stack/SKILL.md" ".context/playbooks/setup/create-local-otel-stack.md" - -echo " --- Safety and provenance ---" -assert_contains "local-dev-only warning" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack.md" "Local development and testing only" -assert_contains "provenance comment" "$TC1_DIR/.context/playbooks/setup/create-local-otel-stack.md" "Ported from devopsin" - -echo " --- Index routing ---" -assert_contains "index has setup playbooks" "$TC1_DIR/.context/index.md" "playbooks/setup/" - -echo " --- Negative: monolithic skill not ported ---" -assert_file_not_exists "local-otel-stack.md" "$TC1_DIR/.context/playbooks/setup/local-otel-stack.md" - -rm -rf "$TC1_DIR" - -# ═══════════════════════════════════════════════════════════════════════ -# TC2: Agent-scoped deploy — Claude only -# ═══════════════════════════════════════════════════════════════════════ -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 - -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" - -rm -rf "$TC2_DIR" - -# ═══════════════════════════════════════════════════════════════════════ -# TC3: Agent-scoped deploy — Copilot only -# ═══════════════════════════════════════════════════════════════════════ -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 - -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" - -rm -rf "$TC3_DIR" - -# ═══════════════════════════════════════════════════════════════════════ -# TC4: No regressions — existing thin-wrapper generation -# ═══════════════════════════════════════════════════════════════════════ -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 - -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" -assert_file_exists "plan-adr" "$TC4_DIR/.claude/skills/plan-adr/SKILL.md" -assert_file_exists "refactor-safe-refactor" "$TC4_DIR/.claude/skills/safe-refactor/SKILL.md" - -echo " --- Regression content check ---" -assert_files_identical "assess-observability fixture" \ - "$TC4_DIR/.claude/skills/assess-observability/SKILL.md" \ - "$SCRIPT_DIR/fixtures/assess-observability-skill.md" - -rm -rf "$TC4_DIR" - -# ═══════════════════════════════════════════════════════════════════════ -# TC5: Idempotency -# ═══════════════════════════════════════════════════════════════════════ -echo "" -echo "=== TC5: Idempotency ===" -TC5_DIR=$(mktemp -d) -TC5_CHECKSUMS1=$(mktemp) -TC5_CHECKSUMS2=$(mktemp) -TC5_PERMS1=$(mktemp) -TC5_PERMS2=$(mktemp) - -"$REPO_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 -checksum_tree "$TC5_DIR" > "$TC5_CHECKSUMS2" -list_executables "$TC5_DIR" > "$TC5_PERMS2" - -if diff -q "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" >/dev/null 2>&1; then - pass "File checksums identical across both runs" -else - fail "File checksums differ between runs" - diff "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" || true -fi - -if diff -q "$TC5_PERMS1" "$TC5_PERMS2" >/dev/null 2>&1; then - pass "Executable permissions identical across both runs" -else - fail "Executable permissions differ between runs" - diff "$TC5_PERMS1" "$TC5_PERMS2" || true -fi - -rm -rf "$TC5_DIR" "$TC5_CHECKSUMS1" "$TC5_CHECKSUMS2" "$TC5_PERMS1" "$TC5_PERMS2" - -# ═══════════════════════════════════════════════════════════════════════ -# TC6: validate-config passes — deployed copy -# ═══════════════════════════════════════════════════════════════════════ -echo "" -echo "=== TC6: validate-config passes ===" -TC6_DIR=$(mktemp -d) -"$REPO_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" -else - fail "Deployed validate-config.sh exited non-zero" -fi - -if "$REPO_DIR/playbooks/setup/create-local-otel-stack/validate-config.sh" >/dev/null 2>&1; then - pass "Source validate-config.sh exits 0" -else - fail "Source validate-config.sh exited non-zero" -fi - -rm -rf "$TC6_DIR" - -# ═══════════════════════════════════════════════════════════════════════ -# Summary -# ═══════════════════════════════════════════════════════════════════════ -echo "" -echo "=== Results ===" -echo " Passed: $PASSED" -echo " Failed: $FAILED" - -if [ "$FAILED" -gt 0 ]; then - echo "" - echo "TEST SUITE FAILED" - exit 1 -else - echo "" - echo "TEST SUITE PASSED" - exit 0 -fi