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..73984e7 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,10 @@ 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 under the matrix shell, not pwsh: on the 5.1 leg, pwsh would + # silently test PowerShell 7 instead, which is how 5.1-only encoding + # defects (BOM writes, ANSI reads) reached the scripts unnoticed. + run: ./scripts/tests/test-deploy.ps1 diff --git a/.github/workflows/deploy-sh-tests.yml b/.github/workflows/deploy-sh-tests.yml index e4e7558..bf11dc5 100644 --- a/.github/workflows/deploy-sh-tests.yml +++ b/.github/workflows/deploy-sh-tests.yml @@ -57,6 +57,70 @@ 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 \ + deploy.sh \ + scripts/deploy.sh \ + scripts/update.sh \ + scripts/migrate.sh \ + scripts/tests/test-deploy.sh \ + scripts/ci/next-version.sh \ + scripts/ci/write-baseline.sh \ + scripts/ci/write-changelog.sh \ + scripts/ci/write-unversioned-baseline.sh; do + mode="$(git ls-files -s "$script" | cut -d' ' -f1)" + if [ "$mode" != "100755" ]; then + echo "::error file=$script::$script is mode $mode; it must be 100755. Run: git update-index --chmod=+x $script" + 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 +145,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..5cd98b5 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,44 @@ 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). +- A destination whose content is already identical to the source is skipped silently, never prompted for or reported as skipped. A redeploy of the same version must be quiet. +- Deploying over a pre-versioning deployment (a `.context/` with no `manifest.json`) runs `migrate` first. Never copy over one directly: that stamps a current manifest onto stale base files and leaves `AGENTS.md` without a managed block. +- **PowerShell text I/O goes through `Read-AcTextLines` / `Write-AcTextFile` in `scripts/lib/common.ps1`, never bare `Get-Content` / `Set-Content`.** On Windows PowerShell 5.1, `Get-Content` decodes BOM-less UTF-8 as ANSI and `Set-Content -Encoding UTF8` writes a BOM, which corrupts every non-ASCII character in a consumer's `AGENTS.md` and hides a line-1 marker from the bash tooling. Shipped `.ps1` files must also stay pure ASCII, because 5.1 parses a BOM-less script as ANSI. - 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 +189,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 +202,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 +221,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 every revision of the pre-versioning content for `migrate` (adopters deployed from whichever commit was current, so one snapshot is not enough), is regenerated by hand with `scripts/ci/write-unversioned-baseline.sh`, 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 +243,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..cacfe96 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,125 @@ +# 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 + +Commit or stash any outstanding work, pull the latest library, then re-run +deploy exactly as you did originally: + +```bash +/path/to/agentic-context/scripts/deploy.sh --agents claude copilot /path/to/your-repo +``` + +```powershell +# Windows PowerShell 5.1 or PowerShell 7+ +& C:\path\to\agentic-context\scripts\deploy.ps1 -Agents claude,copilot -TargetRepo C:\path\to\your-repo +``` + +The old root-level `./deploy.sh` and `.\deploy.ps1` still work and forward to +`scripts/`. Deploy detects the pre-versioning layout and runs the migration +first, then refreshes your agent files and skill wrappers as usual. + +To preview the migration without deploying, or to migrate without touching +agent files, run it directly: + +```bash +# Dry run first. Nothing is written. +/path/to/agentic-context/scripts/migrate.sh /path/to/your-repo + +# When the report looks right: +/path/to/agentic-context/scripts/migrate.sh /path/to/your-repo --apply +``` + +```powershell +& C:\path\to\agentic-context\scripts\migrate.ps1 C:\path\to\your-repo -Apply +``` + +The migration: + +1. Compares every deployed file against the `unversioned` baseline, which + records every revision the library shipped before versioning. A file that + matches any of them is recognised as untouched, whichever commit you + deployed from. +2. Promotes each file you edited into `.context/overrides/`, preserving your + content and marking it `mode: replace`. +3. Keeps an edited `index.md` as `.context/overrides/index.md` with + `mode: extend`, so your routes survive and the library's new routes still + reach you. +4. Moves files you added yourself into the override layer, where updates cannot + remove them, and removes unedited files the library no longer ships. +5. Restores the base to pristine library content. +6. Writes `.context/manifest.json`, recording the agents your deployment + serves, and installs `.context/bin/` tooling. +7. Converts `AGENTS.md`. If its framework sections (`## Context System` through + `## Mandated Standards`) are as the library shipped them, they are replaced + in place by the managed block, and your title and `[CONFIGURE]` sections are + untouched. If you edited those sections, the managed block is prepended + instead and your original content is kept below it for you to review. + +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 may be worth doing by hand — the migration cannot make these +judgements for you: + +- **Trim `AGENTS.md`**, only if the migration reported that it prepended the + managed block. Delete the sections below it that the block now supplies. +- **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 `unversioned` baseline is deliberately distinct from the per-release +`.sha256` baselines, which record what each tagged release shipped. If +you deployed from a fork whose content never existed in this repository, pass +`--baseline` to point at a baseline you generated 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..05efdea 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, baselines) + baselines/.sha256 Per-release hashes of deployable files (written by CI) + baselines/unversioned.sha256 Hashes of every pre-versioning revision, 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,154 @@ 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 every pre-versioning +revision for `migrate.sh` and is regenerated by hand with +`scripts/ci/write-unversioned-baseline.sh`. + +## 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 are upgraded once, by +pulling this library and re-running `scripts/deploy.sh` (or `./deploy.sh`, which +still works) exactly as before. See [MIGRATIONS.md](MIGRATIONS.md). + ## Updating Standards Standards are maintained in **one place only**: @@ -186,7 +354,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/.gitignore b/core/.context/.gitignore new file mode 100644 index 0000000..c15fe86 --- /dev/null +++ b/core/.context/.gitignore @@ -0,0 +1,2 @@ +# Per-machine state written by .context/bin/update.sh. Not shared. +.last-update-check diff --git a/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/deploy.ps1 b/deploy.ps1 index a9309cb..b53faf8 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -1,662 +1,6 @@ -<# -.SYNOPSIS - Deploy agent-contexts templates to a target repository. - -.DESCRIPTION - Copies shared engineering standards and agent-specific configuration files - (for Claude Code, GitHub Copilot, Cursor, Devin, and Windsurf) to target - repositories. Generates skill wrapper SKILL.md files from playbooks for - Claude Code and GitHub Copilot. - -.PARAMETER Agents - One or more agents to deploy: claude, copilot, cursor, devin, windsurf, all. - If omitted in an interactive terminal, a selection menu is shown. - -.PARAMETER TargetRepo - Target repository path. Defaults to the current directory. - -.PARAMETER Overwrite - Overwrite all existing files without prompting. - -.PARAMETER NoOverwrite - Skip all existing files without prompting. - -.EXAMPLE - .\deploy.ps1 -Agents claude,copilot - .\deploy.ps1 -Agents claude copilot - .\deploy.ps1 -Agents "claude copilot windsurf" - .\deploy.ps1 -Agents all -TargetRepo C:\repos\my-project - .\deploy.ps1 - .\deploy.ps1 -Agents all -TargetRepo C:\repos\my-project -NoOverwrite -#> -[CmdletBinding()] -param( - [string[]]$Agents, - [string]$TargetRepo, - [switch]$Help, - [switch]$Overwrite, - [switch]$NoOverwrite, - [Parameter(ValueFromRemainingArguments = $true)] - [string[]]$RemainingAgents -) - -$ErrorActionPreference = 'Stop' - -if ($RemainingAgents) { - $Agents = @($Agents) + @($RemainingAgents) -} -if ($Agents) { - $Agents = @($Agents | ForEach-Object { $_ -split '[\s,]+' } | Where-Object { $_ -ne '' }) -} - -$ValidAgents = @('claude', 'copilot', 'cursor', 'devin', 'windsurf') -$script:EnabledAgents = @() -$script:OverwriteMode = "" # "all" | "none" | "" (prompt per-file) -$script:SkippedFiles = @() - -# --------------------------------------------------------------------------- -# Helper functions -# --------------------------------------------------------------------------- - -function Show-Usage { - Write-Host @" -Usage: .\deploy.ps1 -Agents [-TargetRepo ] - -Copy agent-contexts templates to a target repository and generate skill wrappers. -If -TargetRepo is omitted, deploys to the current directory. - -Shared content (always copied): - AGENTS.md -> target repo root - .context\ -> target .context\ (index + conventions) - standards\ -> target .context\standards\ - playbooks\ -> target .context\playbooks\ - -Agent-specific files (copied only for selected agents): - claude -> CLAUDE.md, .claude\settings.json, .claude\skills\ - copilot -> .github\copilot-instructions.md, .github\skills\ - cursor -> .cursor\rules\standards.mdc - devin -> .devin\devin.json - windsurf -> .windsurfrules - all -> all of the above - -Parameters: - -Agents Mandatory in non-interactive mode. Accepts one or more values: - claude copilot cursor devin windsurf all - -TargetRepo Target directory (default: current directory) - -Overwrite Overwrite all existing files without prompting - -NoOverwrite Skip all existing files without prompting - Default: prompt per-file when conflicts are detected - -Help Show this help message and exit -"@ -} - -function Print-Banner { - Write-Host " __" -ForegroundColor Cyan - Write-Host " _(\ |@@|" -ForegroundColor Cyan - Write-Host "(__/\__ \--/ __" -ForegroundColor Cyan - Write-Host " \___|----| | __" -ForegroundColor Cyan - Write-Host " \ }{ /\ )_ / _\" -ForegroundColor Cyan - Write-Host " /\__/\ \__O (__" -ForegroundColor Cyan - Write-Host " (--/\--) \__/" -ForegroundColor Cyan - Write-Host " _)( )(_" -ForegroundColor Cyan - Write-Host " ``---''---``" -ForegroundColor Cyan - Write-Host "A comprehensive list of engineering standards for context engineering with AI Agents" -ForegroundColor Yellow - Write-Host "https://github.com/ldastey-dev/agentic-context" -ForegroundColor Cyan - Write-Host "Written by Leigh Dastey" -ForegroundColor Magenta - Write-Host "" -} - -function Test-AgentEnabled { - param([string]$Agent) - return ($script:EnabledAgents -contains $Agent) -} - -function Confirm-Overwrite { - param([string]$Destination) - - # New files always proceed - if (-not (Test-Path $Destination)) { - return $true - } - - switch ($script:OverwriteMode) { - "all" { return $true } - "none" { - $script:SkippedFiles += $Destination - return $false - } - } - - # Non-interactive -> safe default (skip) - $isInteractive = $false - try { - $isInteractive = [Environment]::UserInteractive -and -not [Console]::IsInputRedirected - } catch { } - - if (-not $isInteractive) { - Write-Host " Skipping existing file (non-interactive): $Destination" - $script:SkippedFiles += $Destination - return $false - } - - while ($true) { - Write-Host " File already exists: $Destination" - $answer = Read-Host " Overwrite? [y]es / [n]o / [N]o to all / [a]ll" - switch ($answer) { - 'y' { return $true } - 'n' { $script:SkippedFiles += $Destination; return $false } - 'N' { $script:OverwriteMode = "none"; $script:SkippedFiles += $Destination; return $false } - 'a' { $script:OverwriteMode = "all"; return $true } - default { Write-Host " Please enter y, n, N, or a." } - } - } -} - -$script:TextExtensions = @('.md', '.json', '.mdc', '.txt', '.yaml', '.yml', '.toml', '.ini') - -function Test-IsUtf8Compatible { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$Path - ) - $resolvedPath = [System.IO.Path]::GetFullPath($Path) - try { - $bom = [byte[]]::new(4) - $stream = [System.IO.File]::OpenRead($resolvedPath) - try { $read = $stream.Read($bom, 0, 4) } - finally { $stream.Close() } - # UTF-32 must be tested before UTF-16 (shares FF FE prefix) - if ($read -ge 4 -and $bom[0] -eq 0xFF -and $bom[1] -eq 0xFE -and $bom[2] -eq 0x00 -and $bom[3] -eq 0x00) { return $false } # UTF-32 LE - if ($read -ge 4 -and $bom[0] -eq 0x00 -and $bom[1] -eq 0x00 -and $bom[2] -eq 0xFE -and $bom[3] -eq 0xFF) { return $false } # UTF-32 BE - if ($read -ge 2 -and $bom[0] -eq 0xFF -and $bom[1] -eq 0xFE) { return $false } # UTF-16 LE - if ($read -ge 2 -and $bom[0] -eq 0xFE -and $bom[1] -eq 0xFF) { return $false } # UTF-16 BE - return $true - } catch { - Write-Warning "Test-IsUtf8Compatible: could not read '$resolvedPath' - $_. Falling back to Copy-Item." - return $false - } -} - -function Copy-SingleFile { - [CmdletBinding()] - param([string]$Source, [string]$Destination) - if (-not (Confirm-Overwrite -Destination $Destination)) { - return - } - $parentDir = Split-Path $Destination -Parent - if (-not (Test-Path $parentDir)) { - New-Item -ItemType Directory -Path $parentDir -Force | Out-Null - } - $ext = [System.IO.Path]::GetExtension($Source).ToLowerInvariant() - if (($ext -in $script:TextExtensions) -and (Test-IsUtf8Compatible -Path $Source)) { - # ANSI/Windows-1252 files without a BOM are indistinguishable from UTF-8 at the header level and will pass - # Test-IsUtf8Compatible; characters above U+007F may be silently replaced. All repo source files are UTF-8. - $content = [System.IO.File]::ReadAllText($Source, [System.Text.Encoding]::UTF8) -replace "`r`n", "`n" -replace "`r", "`n" - [System.IO.File]::WriteAllText($Destination, $content, (New-Object System.Text.UTF8Encoding($false))) - } else { - Copy-Item -Path $Source -Destination $Destination -Force - } -} - -function Copy-DirectoryContents { - param([string]$Source, [string]$Destination) - if (-not (Test-Path $Destination)) { - New-Item -ItemType Directory -Path $Destination -Force | Out-Null - } - $sourceFiles = Get-ChildItem -Path $Source -Recurse -File - foreach ($file in $sourceFiles) { - $relativePath = $file.FullName.Substring($Source.TrimEnd('/\').Length + 1) - $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, - # which we set via kernel32!SetConsoleMode. Returns $false if the API isn't - # available (e.g. constrained language mode) or the call fails - callers should - # refuse to render ANSI in that case rather than print literal escape text. - $onWindows = ($PSVersionTable.PSVersion.Major -le 5) -or $IsWindows - if (-not $onWindows) { return $true } - - try { - if (-not ('AgenticContext.NativeConsole' -as [type])) { - Add-Type -Namespace AgenticContext -Name NativeConsole -MemberDefinition @' -[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] -public static extern System.IntPtr GetStdHandle(int nStdHandle); -[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] -public static extern bool GetConsoleMode(System.IntPtr hConsoleHandle, out uint lpMode); -[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] -public static extern bool SetConsoleMode(System.IntPtr hConsoleHandle, uint dwMode); -'@ - # Add-Type compiles the above via an external process, which resets - # [Environment]::CurrentDirectory (observed jumping to C:\WINDOWS\System32) as a - # side effect. Restore it so relative-path .NET file I/O elsewhere in the script - # keeps resolving against PowerShell's actual working directory. - [System.Environment]::CurrentDirectory = (Get-Location).Path - } - - $STD_OUTPUT_HANDLE = -11 - $ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x4 - - $stdOut = [AgenticContext.NativeConsole]::GetStdHandle($STD_OUTPUT_HANDLE) - $mode = 0 - if ([AgenticContext.NativeConsole]::GetConsoleMode($stdOut, [ref]$mode)) { - return [AgenticContext.NativeConsole]::SetConsoleMode($stdOut, $mode -bor $ENABLE_VIRTUAL_TERMINAL_PROCESSING) - } - return $false - } catch { - return $false - } -} - -function Render-AgentMenu { - param( - [string[]]$Options, - [int[]]$Selected, - [int]$Cursor, - [string]$StatusMessage, - [bool]$FirstRender - ) - - $esc = [char]27 - $linesDrawn = $Options.Count + 1 # menu rows + status row - - if (-not $FirstRender) { - [Console]::Write("$esc[${linesDrawn}A") - } - - for ($i = 0; $i -lt $Options.Count; $i++) { - $pointer = " " - $marker = "[ ]" - if ($i -eq $Cursor) { $pointer = "> " } - if ($Selected[$i] -eq 1) { $marker = "[x]" } - - $line = "$pointer$marker $($Options[$i])" - - if ($i -eq $Cursor) { - [Console]::Write("`r$esc[2K$esc[32m$line$esc[0m`n") - } else { - [Console]::Write("`r$esc[2K$line`n") - } - } - - [Console]::Write("`r$esc[2K$StatusMessage`n") -} - -function Select-AgentsInteractive { - if (-not (Enable-VirtualTerminal)) { - Write-Error "Interactive menu unavailable: virtual-terminal mode could not be enabled. Re-run with -Agents ." - exit 1 - } - - $options = @('all') + $ValidAgents + @('clear and exit') - $optionsCount = $options.Count - $exitIndex = $optionsCount - 1 - $selected = @(0) * $optionsCount - $cursor = 0 - $statusMsg = "" - - Write-Host "Select one or more agents (space to toggle, Up/Down to move, Enter to confirm)." - Write-Host "Select 'all' to deploy every supported agent." - Write-Host "Select 'clear and exit' to clear selection and quit." - Write-Host "" - - $firstRender = $true - - try { - [Console]::CursorVisible = $false - - Render-AgentMenu -Options $options -Selected $selected -Cursor $cursor -StatusMessage $statusMsg -FirstRender $firstRender - $firstRender = $false - - while ($true) { - $keyInfo = [Console]::ReadKey($true) - - switch ($keyInfo.Key) { - 'UpArrow' { - $cursor = ($cursor - 1 + $optionsCount) % $optionsCount - $statusMsg = "" - } - 'DownArrow' { - $cursor = ($cursor + 1) % $optionsCount - $statusMsg = "" - } - 'Spacebar' { - if ($cursor -eq $exitIndex) { - if ($selected[$cursor] -eq 1) { - $selected[$cursor] = 0 - $statusMsg = "" - } else { - for ($i = 0; $i -lt $optionsCount; $i++) { $selected[$i] = 0 } - $selected[$cursor] = 1 - $statusMsg = "Press Enter to clear selections and exit." - } - } elseif ($options[$cursor] -eq 'all') { - if ($selected[$cursor] -eq 1) { - for ($i = 0; $i -lt $exitIndex; $i++) { $selected[$i] = 0 } - } else { - for ($i = 0; $i -lt $exitIndex; $i++) { $selected[$i] = 1 } - } - $selected[$exitIndex] = 0 - $statusMsg = "" - } else { - $selected[$cursor] = if ($selected[$cursor] -eq 1) { 0 } else { 1 } - - $selected[$exitIndex] = 0 - $selected[0] = 1 - for ($i = 1; $i -lt $exitIndex; $i++) { - if ($selected[$i] -eq 0) { - $selected[0] = 0 - break - } - } - $statusMsg = "" - } - } - 'Enter' { - if ($cursor -eq $exitIndex) { - [Console]::CursorVisible = $true - Write-Host "`nSelection cleared. Exiting." - return $null - } - - $chosen = @() - for ($i = 0; $i -lt $optionsCount; $i++) { - if ($i -eq $exitIndex) { continue } - if ($selected[$i] -eq 1) { $chosen += $options[$i] } - } - - if ($chosen.Count -eq 0) { - $statusMsg = "Select at least one option." - } else { - [Console]::CursorVisible = $true - return $chosen - } - } - } - - Render-AgentMenu -Options $options -Selected $selected -Cursor $cursor -StatusMessage $statusMsg -FirstRender $firstRender - } - } finally { - [Console]::CursorVisible = $true - } -} - -function Resolve-SelectedAgents { - param([string[]]$Selected) - - $seen = @{} - $enabled = [System.Collections.Generic.List[string]]::new() - - foreach ($agent in $Selected) { - switch ($agent) { - 'all' { - $script:EnabledAgents = @($ValidAgents) - return - } - { $_ -in $ValidAgents } { - if (-not $seen.ContainsKey($agent)) { - $enabled.Add($agent) - $seen[$agent] = $true - } - } - default { - Write-Error "Unsupported agent '$agent'. Supported agents: all, $($ValidAgents -join ', ')" - exit 1 - } - } - } - - if ($enabled.Count -eq 0) { - Write-Error "At least one agent must be selected." - exit 1 - } - - $script:EnabledAgents = @($enabled) -} - -function New-SkillWrapper { - param( - [string]$PlaybookPath, - [string]$TargetDir, - [string]$RelPath, - [string]$AllowedTools = "" - ) - - $name = "" - $description = "" - - foreach ($line in (Get-Content $PlaybookPath)) { - if ($line -match '^name:\s*(.+)$') { - if (-not $name) { $name = $Matches[1].Trim() } - } - if ($line -match '^description:\s*"?(.+?)"?\s*$') { - if (-not $description) { $description = $Matches[1].Trim().Trim('"') } - } - if ($name -and $description) { break } - } - - if (-not $name -or -not $description) { return } - - $skillDir = Join-Path $TargetDir $name - $skillFile = Join-Path $skillDir "SKILL.md" - - if (-not (Confirm-Overwrite -Destination $skillFile)) { - return - } - - New-Item -ItemType Directory -Path $skillDir -Force | Out-Null - - $bt = '`' - $lines = @("---", "name: $name", "description: `"$description`"") - if ($AllowedTools) { - $lines += "allowed-tools: `"$AllowedTools`"" - } - $lines += @("---", "", "Read and follow ${bt}.context/playbooks/${RelPath}${bt} in full.") - - $content = ($lines -join "`n") + "`n" - [System.IO.File]::WriteAllText($skillFile, $content, (New-Object System.Text.UTF8Encoding($false))) -} - -function New-SkillsForSelectedAgents { - param( - [string]$PlaybookPath, - [string]$RelPath, - [string]$AllowedTools = "Read, Grep, Glob, Bash(git *), Write, Edit, Agent" - ) - - if (Test-AgentEnabled 'claude') { - New-SkillWrapper -PlaybookPath $PlaybookPath -TargetDir (Join-Path $script:Target '.claude/skills') -RelPath $RelPath -AllowedTools $AllowedTools - } - if (Test-AgentEnabled 'copilot') { - New-SkillWrapper -PlaybookPath $PlaybookPath -TargetDir (Join-Path $script:Target '.github/skills') -RelPath $RelPath -AllowedTools "" - } -} - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -# Dot-source guard: allows deploy.Tests.ps1 to import these functions without running a deploy. -if ($MyInvocation.InvocationName -ne '.') { - -if ($Help) { - Show-Usage - return -} - -Print-Banner - -if ($Overwrite -and $NoOverwrite) { - Write-Error "-Overwrite and -NoOverwrite are mutually exclusive." - exit 1 -} -if ($Overwrite) { - $script:OverwriteMode = "all" -} -if ($NoOverwrite) { - $script:OverwriteMode = "none" -} - -if (-not $Agents -or $Agents.Count -eq 0) { - $isInteractive = $false - try { - $isInteractive = [Environment]::UserInteractive ` - -and -not [Console]::IsInputRedirected ` - -and -not [Console]::IsOutputRedirected - } catch { } - - if ($isInteractive) { - $result = Select-AgentsInteractive - if ($null -eq $result) { - exit 0 - } - $Agents = $result - } else { - Write-Error "-Agents is mandatory in non-interactive mode." - Show-Usage - exit 1 - } -} - -Resolve-SelectedAgents -Selected $Agents - -if (-not $TargetRepo) { - $script:Target = (Get-Location).Path -} elseif ([System.IO.Path]::IsPathRooted($TargetRepo)) { - $script:Target = $TargetRepo -} else { - # Resolve relative to PowerShell's working directory rather than letting .NET fall back to - # [Environment]::CurrentDirectory. Add-Type (invoked by Enable-VirtualTerminal for the - # interactive menu) resets that process-wide value on Windows, which would otherwise silently - # redirect every subsequent relative-path file operation to the wrong directory. - $script:Target = [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $TargetRepo)) -} - -if (-not (Test-Path $script:Target -PathType Container)) { - $answer = Read-Host "Directory '$($script:Target)' does not exist. Create it? [y/N]" - if ($answer -match '^[yY]') { - New-Item -ItemType Directory -Path $script:Target -Force | Out-Null - Write-Host "Created '$($script:Target)'" - } else { - Write-Host "Aborted." -ForegroundColor Red - exit 1 - } -} - -$ScriptDir = $PSScriptRoot - -Write-Host "Deploying agent-contexts to $($script:Target)" -Write-Host " Selected agents: $($script:EnabledAgents -join ', ')" - -Write-Host " Copying shared context files..." -Copy-SingleFile -Source (Join-Path $ScriptDir 'core/AGENTS.md') -Destination (Join-Path $script:Target 'AGENTS.md') -Copy-DirectoryContents -Source (Join-Path $ScriptDir 'core/.context') -Destination (Join-Path $script:Target '.context') - -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') -} - -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') -} - -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') -} - -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') -} - -if (Test-AgentEnabled 'windsurf') { - Write-Host " Copying Windsurf files..." - Copy-SingleFile -Source (Join-Path $ScriptDir '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') - -Write-Host " Copying playbooks\ -> $($script:Target)\.context\playbooks\" -Copy-DirectoryContents -Source (Join-Path $ScriptDir 'playbooks') -Destination (Join-Path $script:Target '.context/playbooks') - -if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { - Write-Host " Generating skill wrappers from playbooks..." - if (Test-AgentEnabled 'claude') { - Write-Host " -> .claude\skills\ (Claude Code)" - New-Item -ItemType Directory -Path (Join-Path $script:Target '.claude/skills') -Force | Out-Null - } - if (Test-AgentEnabled 'copilot') { - Write-Host " -> .github\skills\ (GitHub Copilot)" - New-Item -ItemType Directory -Path (Join-Path $script:Target '.github/skills') -Force | Out-Null - } - - $playbookCategories = @( - @{ Dir = 'assess'; Tools = $null } - @{ Dir = 'review'; Tools = 'Read, Grep, Glob, Bash(git *)' } - @{ Dir = 'plan'; Tools = $null } - @{ Dir = 'refactor'; Tools = $null } - @{ Dir = 'debug'; Tools = 'Read, Grep, Glob, Bash, Write, Edit, Agent' } - @{ Dir = 'docs'; Tools = $null } - @{ Dir = 'setup'; Tools = 'Read, Grep, Glob, Bash, Write, Edit, Agent' } - ) - - foreach ($category in $playbookCategories) { - $dir = Join-Path $ScriptDir "playbooks/$($category.Dir)" - if (Test-Path $dir) { - $playbooks = Get-ChildItem -Path $dir -Filter '*.md' -File -ErrorAction SilentlyContinue - foreach ($playbook in $playbooks) { - $relPath = "$($category.Dir)/$($playbook.Name)" - $params = @{ - PlaybookPath = $playbook.FullName - RelPath = $relPath - } - if ($null -ne $category.Tools) { - $params['AllowedTools'] = $category.Tools - } - New-SkillsForSelectedAgents @params - } - } - } -} else { - Write-Host " Skipping skill wrapper generation (no selected agent uses skills)." -} - -Write-Host "" -Write-Host "Done. Next steps:" -$step = 1 - -function Show-NextStep { - param([string]$Message) - Write-Host " $($script:step). $Message" - $script:step++ -} - -Show-NextStep "Fill in [CONFIGURE] sections in $($script:Target)\AGENTS.md" - -if (Test-AgentEnabled 'claude') { - Show-NextStep "Fill in [CONFIGURE] sections in $($script:Target)\CLAUDE.md" - Show-NextStep "Review $($script:Target)\.claude\settings.json and adjust permissions/hooks" -} - -if (Test-AgentEnabled 'copilot') { - Show-NextStep "Review $($script:Target)\.github\copilot-instructions.md" -} - -if ($script:SkippedFiles.Count -gt 0) { - Write-Host "" - Write-Host "Skipped files (not overwritten - manual merge may be required):" - foreach ($f in $script:SkippedFiles) { - Write-Host " - $f" - } -} - -} # end dot-source guard +# deploy.ps1 - compatibility shim. The deploy script lives in scripts/deploy.ps1. +# +# Kept so existing instructions and automation that call .\deploy.ps1 from the +# repository root keep working. New usage should call scripts\deploy.ps1. +& (Join-Path $PSScriptRoot 'scripts/deploy.ps1') @args +exit $LASTEXITCODE diff --git a/deploy.sh b/deploy.sh index e4a9aee..c0dff0e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,662 +1,6 @@ #!/bin/bash -# deploy.sh — copy agent-contexts templates to a target repository +# deploy.sh — compatibility shim. The deploy script lives in scripts/deploy.sh. # -# Usage: -# ./deploy.sh --agents [target-repo] -# -# If --agents is omitted in an interactive terminal, an arrow-key multiselect -# prompt is shown. - -set -euo pipefail - -VALID_AGENTS=(claude copilot cursor devin windsurf) -SELECTED_AGENTS=() -ENABLED_AGENTS=() -TARGET="" -AGENTS_FLAG_PROVIDED=0 -OVERWRITE_MODE="" # "all" | "none" | "" (prompt per-file) -OVERWRITE_FLAG=0 -NO_OVERWRITE_FLAG=0 -SKIPPED_FILES=() - -usage() { - cat < [target-repo] - -Copy agent-contexts templates to a target repository and generate skill wrappers. -If [target-repo] is omitted, deploys to the current directory. - -Shared content (always copied): - AGENTS.md → target repo root - .context/ → target .context/ (index + conventions) - standards/ → target .context/standards/ - playbooks/ → target .context/playbooks/ - -Agent-specific files (copied only for selected agents): - claude → CLAUDE.md, .claude/settings.json, .claude/skills/ - copilot → .github/copilot-instructions.md, .github/skills/ - cursor → .cursor/rules/standards.mdc - devin → .devin/devin.json - windsurf → .windsurfrules - all → all of the above - -Options: - --agents Mandatory in non-interactive mode. Supports one or more values: - claude copilot cursor devin windsurf all - --overwrite Overwrite all existing files without prompting - --no-overwrite Skip all existing files without prompting - Default: prompt per-file when conflicts are detected - -h, --help Show this help message and exit -EOF -} - -print_banner() { - local c1=$'\033[38;5;39m' - local c2=$'\033[38;5;45m' - local c3=$'\033[38;5;51m' - local c4=$'\033[38;5;220m' - local c_link=$'\033[38;5;81m' - local c_name=$'\033[38;5;213m' - local reset=$'\033[0m' - local repo_url="https://github.com/ldastey-dev/agentic-context" - - printf "%b\n" "${c1} __${reset}" - printf "%b\n" "${c2} _(\\\\ |@@|${reset}" - printf "%b\n" "${c3}(__/\\\\__ \\\\--/ __${reset}" - printf "%b\n" "${c1} \\\\___|----| | __${reset}" - printf "%b\n" "${c2} \\\\ }{ /\\\\ )_ / _\\\\${reset}" - printf "%b\n" "${c3} /\\\\__/\\\\ \\\\__O (__${reset}" - printf "%b\n" "${c1} (--/\\\\--) \\\\__/${reset}" - printf "%b\n" "${c2} _)( )(_${reset}" - printf "%b\n" "${c3} \`---''---\`${reset}" - printf "%b\n" "${c4}A comprehensive list of engineering standards for context engineering with AI Agents${reset}" - - if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then - printf "%b\033]8;;%s\a%s\033]8;;\a%b\n" "$c_link" "$repo_url" "$repo_url" "$reset" - else - printf "%b%s%b\n" "$c_link" "$repo_url" "$reset" - fi - - printf "%b%s%b\n\n" "$c_name" "Written by Leigh Dastey" "$reset" -} - -join_by() { - local delimiter="$1" - shift - local first=1 - local value - for value in "$@"; do - if [[ $first -eq 1 ]]; then - printf "%s" "$value" - first=0 - else - printf "%s%s" "$delimiter" "$value" - fi - done -} - -agent_enabled() { - local sought="$1" - local agent - for agent in "${ENABLED_AGENTS[@]}"; do - if [[ "$agent" == "$sought" ]]; then - return 0 - fi - done - return 1 -} - -confirm_overwrite() { - local dst="$1" - - # New files always proceed - if [[ ! -e "$dst" ]]; then - return 0 - fi - - case "$OVERWRITE_MODE" in - all) return 0 ;; - none) - SKIPPED_FILES+=("$dst") - return 1 - ;; - esac - - # Non-interactive without explicit flag → safe default (skip) - if [[ ! -t 0 || ! -t 1 ]]; then - echo " Skipping existing file (non-interactive): $dst" - SKIPPED_FILES+=("$dst") - return 1 - fi - - while true; do - printf " File already exists: %s\n" "$dst" - printf " Overwrite? [y]es / [n]o / [N]o to all / [a]ll: " - read -r answer - case "$answer" in - y) return 0 ;; - n) SKIPPED_FILES+=("$dst"); return 1 ;; - N) OVERWRITE_MODE="none"; SKIPPED_FILES+=("$dst"); return 1 ;; - a) OVERWRITE_MODE="all"; return 0 ;; - *) echo " Please enter y, n, N, or a." ;; - esac - done -} - -copy_file() { - local src="$1" - local dst="$2" - if ! confirm_overwrite "$dst"; then - return 0 - fi - mkdir -p "$(dirname "$dst")" - cp "$src" "$dst" -} - -copy_dir_contents() { - local src="$1" - local dst="$2" - local rel_path file_dst - - while IFS= read -r -d '' file; do - rel_path="${file#"$src"/}" - file_dst="$dst/$rel_path" - copy_file "$file" "$file_dst" - done < <(find "$src" -type f -print0 | sort -z) -} - -interactive_select_agents() { - local options=(all "${VALID_AGENTS[@]}" "clear and exit") - local options_count=${#options[@]} - local exit_index=$((options_count - 1)) - local -a selected=() - local cursor=0 - local key seq1 seq2 i - local colour_green=$'\033[32m' - local colour_reset=$'\033[0m' - local status_msg="" - local first_render=1 - - for ((i=0; i&2 - echo "Supported agents: all, $(join_by ', ' "${VALID_AGENTS[@]}")" >&2 - exit 1 - ;; - esac - done - - if [[ ${#ENABLED_AGENTS[@]} -eq 0 ]]; then - echo "Error: at least one agent must be selected." >&2 - exit 1 - fi -} - -generate_skill() { - local playbook_path="$1" - local target_dir="$2" - local rel_path="$3" - local allowed_tools="${4:-}" - - local name description - name=$(sed -n 's/^name: *//p' "$playbook_path" | head -1) - description=$(sed -n 's/^description: *//p' "$playbook_path" | head -1 | sed 's/^"//;s/"$//') - - if [[ -z "$name" || -z "$description" ]]; then - return - fi - - local skill_dir="$target_dir/$name" - local skill_file="$skill_dir/SKILL.md" - - if ! confirm_overwrite "$skill_file"; then - return - fi - - mkdir -p "$skill_dir" - - if [[ -n "$allowed_tools" ]]; then - cat > "$skill_file" << SKILL_EOF ---- -name: $name -description: "$description" -allowed-tools: "$allowed_tools" ---- - -Read and follow \`.context/playbooks/$rel_path\` in full. -SKILL_EOF - else - cat > "$skill_file" << SKILL_EOF ---- -name: $name -description: "$description" ---- - -Read and follow \`.context/playbooks/$rel_path\` in full. -SKILL_EOF - fi -} - -generate_skills_for_selected_agents() { - local playbook_path="$1" - local rel_path="$2" - local allowed_tools="${3:-Read, Grep, Glob, Bash(git *), Write, Edit, Agent}" - - if agent_enabled claude; then - generate_skill "$playbook_path" "$TARGET/.claude/skills" "$rel_path" "$allowed_tools" - fi - if agent_enabled copilot; then - generate_skill "$playbook_path" "$TARGET/.github/skills" "$rel_path" "" - fi -} - -while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help) - usage - exit 0 - ;; - --agents) - if [[ $AGENTS_FLAG_PROVIDED -eq 1 ]]; then - echo "Error: --agents provided more than once." >&2 - exit 1 - fi - AGENTS_FLAG_PROVIDED=1 - shift - - while [[ $# -gt 0 ]]; do - case "$1" in - all|claude|copilot|cursor|devin|windsurf) - SELECTED_AGENTS+=("$1") - shift - ;; - --*) - break - ;; - *) - break - ;; - esac - done - - if [[ ${#SELECTED_AGENTS[@]} -eq 0 ]]; then - echo "Error: --agents requires at least one value." >&2 - usage >&2 - exit 1 - fi - ;; - --overwrite) - OVERWRITE_MODE="all" - OVERWRITE_FLAG=1 - shift - ;; - --no-overwrite) - OVERWRITE_MODE="none" - NO_OVERWRITE_FLAG=1 - shift - ;; - --*) - echo "Error: unknown option '$1'" >&2 - echo "" >&2 - usage >&2 - exit 1 - ;; - *) - if [[ -z "$TARGET" ]]; then - TARGET="$1" - else - echo "Error: unexpected argument '$1'" >&2 - usage >&2 - exit 1 - fi - shift - ;; - esac -done - -if [[ $OVERWRITE_FLAG -eq 1 && $NO_OVERWRITE_FLAG -eq 1 ]]; then - echo "Error: --overwrite and --no-overwrite are mutually exclusive." >&2 - exit 1 -fi - -print_banner - -if [[ $AGENTS_FLAG_PROVIDED -eq 0 ]]; then - if [[ -t 0 && -t 1 ]]; then - if interactive_select_agents; then - : - else - selector_status=$? - if [[ $selector_status -eq 2 ]]; then - exit 0 - fi - exit "$selector_status" - fi - else - echo "Error: --agents is mandatory in non-interactive mode." >&2 - usage >&2 - exit 1 - fi -fi - -normalise_agents - -if [[ -z "$TARGET" ]]; then - TARGET="." -fi - -if [[ ! -d "$TARGET" ]]; then - printf "Directory '%s' does not exist. Create it? [y/N] " "$TARGET" - read -r answer - case "$answer" in - [yY]|[yY][eE][sS]) - mkdir -p "$TARGET" - echo "Created '$TARGET'" - ;; - *) - echo "Aborted." >&2 - exit 1 - ;; - esac -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -echo "Deploying agent-contexts to $TARGET" -echo " Selected agents: $(join_by ', ' "${ENABLED_AGENTS[@]}")" - -echo " Copying shared context files..." -copy_file "$SCRIPT_DIR/core/AGENTS.md" "$TARGET/AGENTS.md" -copy_dir_contents "$SCRIPT_DIR/core/.context" "$TARGET/.context" - -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" -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" -fi - -if agent_enabled cursor; then - echo " Copying Cursor files..." - copy_file "$SCRIPT_DIR/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" -fi - -if agent_enabled windsurf; then - echo " Copying Windsurf files..." - copy_file "$SCRIPT_DIR/core/.windsurfrules" "$TARGET/.windsurfrules" -fi - -echo " Copying standards/ → $TARGET/.context/standards/" -copy_dir_contents "$SCRIPT_DIR/standards" "$TARGET/.context/standards" - -echo " Copying playbooks/ → $TARGET/.context/playbooks/" -copy_dir_contents "$SCRIPT_DIR/playbooks" "$TARGET/.context/playbooks" - -if agent_enabled claude || agent_enabled copilot; then - echo " Generating skill wrappers from playbooks..." - if agent_enabled claude; then - echo " → .claude/skills/ (Claude Code)" - mkdir -p "$TARGET/.claude/skills" - fi - if agent_enabled copilot; then - echo " → .github/skills/ (GitHub Copilot)" - mkdir -p "$TARGET/.github/skills" - fi - - for playbook in "$SCRIPT_DIR"/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 - 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 - filename=$(basename "$playbook") - generate_skills_for_selected_agents "$playbook" "plan/$filename" - done - - for playbook in "$SCRIPT_DIR"/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 - [[ -f "$playbook" ]] || continue - filename=$(basename "$playbook") - generate_skills_for_selected_agents "$playbook" "debug/$filename" \ - "Read, Grep, Glob, Bash, Write, Edit, Agent" - done - fi - - if [[ -d "$SCRIPT_DIR/playbooks/docs" ]]; then - for playbook in "$SCRIPT_DIR"/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 - [[ -f "$playbook" ]] || continue - filename=$(basename "$playbook") - generate_skills_for_selected_agents "$playbook" "setup/$filename" \ - "Read, Grep, Glob, Bash, Write, Edit, Agent" - done - fi -else - echo " Skipping skill wrapper generation (no selected agent uses skills)." -fi - -echo "" -echo "Done. Next steps:" -step=1 -next_step() { - echo " $step. $1" - step=$((step + 1)) -} - -next_step "Fill in [CONFIGURE] sections in $TARGET/AGENTS.md" - -if agent_enabled claude; then - next_step "Fill in [CONFIGURE] sections in $TARGET/CLAUDE.md" - next_step "Review $TARGET/.claude/settings.json and adjust permissions/hooks" -fi - -if agent_enabled copilot; then - next_step "Review $TARGET/.github/copilot-instructions.md" -fi - -if [[ ${#SKIPPED_FILES[@]} -gt 0 ]]; then - echo "" - echo "Skipped files (not overwritten — manual merge may be required):" - for f in "${SKIPPED_FILES[@]}"; do - echo " - $f" - done -fi +# Kept so existing instructions and automation that call ./deploy.sh from the +# repository root keep working. New usage should call scripts/deploy.sh. +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/scripts/deploy.sh" "$@" diff --git a/scripts/baselines/unversioned.sha256 b/scripts/baselines/unversioned.sha256 new file mode 100644 index 0000000..fdadb1b --- /dev/null +++ b/scripts/baselines/unversioned.sha256 @@ -0,0 +1,199 @@ +AGENTS.md#framework-region 05600dce653e392038ef8823c932d55a48b111c4263df26cf9d801c329234d32 +AGENTS.md#framework-region 07bbe58e3542c325262a9ac50ff00ca3899d94cf7722a493146b886f4bcd54d4 +AGENTS.md#framework-region 46b92d8fea75280193df0cf1029b88299e38d32e5abf8af87d3dc5de34b6adf2 +AGENTS.md#framework-region 53f8ade9a7c77cf5ac6fc8bea4512d10565f167c2c4cabbe8f2b04173e887b30 +AGENTS.md#framework-region 7663f9f9c489952973e7d39f93e014184d1bfa9bed784f29f9afdb75c827694b +AGENTS.md#framework-region 7779f356611e756661d865729ffe0ddecf66d22f09966842f98db3599ed151b3 +AGENTS.md#framework-region 9893b3923cfc8c11687edbe70c15374db07862a3356bd17fd98e8ee7ad433c9a +AGENTS.md#framework-region c732e50bdc2b7b5f78c5f19e7864f76bb47292f3013a8c91113e3d4cf3376f85 +AGENTS.md#framework-region cd76c473a538a085f81e2d9a17e167a21c4c7eba89e706ca900951c2cced0231 +AGENTS.md#framework-region d7daa9652eb58f52fb580806e6317e47957d76c3c7af65da61d2ae81750b5ca1 +AGENTS.md#framework-region e69045a9d0f7694ed765b5580f3484e90d4bd6234652549eb9b43ddab09e6c41 +AGENTS.md#framework-region f50bb86d40b7dcd9d3875cb07919387c627f10d17d151c47575da6c52ce6d014 +AGENTS.md#framework-region fc77b0ed91e5c876a4705b09433d4f66dd36cf63fbea6569eae6f8545abd2e2b +conventions/code.md 306b9916bd0ebc87c418b4ded90a56c600708b0144a37de4aaca53484e19fb25 +conventions/code.md f826aebb6d455271b43fbff19da7492bbc79f58aa1900c6ad0bbbb1a7097652e +conventions/communication.md 508a9981cc3830e9992eb6edb30473c06ed149bcc63bab63fb8683e277d6ee03 +conventions/workflow.md 6cc7f88c4dde53b9a713f9257060497abdf3e343c6e09b515b0cdb38464eb8cd +index.md 0e73d3f1d262aeff028504ae61c9c0c0f681700ba723bbb195cf846b31f0724c +index.md 0fb681aa5c4ea4133e517f40d3b4fc2ba170a707e5fd86472afc38b701dedf33 +index.md 12ee0884d0b4d1fa6e2580e60635e80079f36c60b45af4ead9af1b53f82ef87c +index.md 32a985ba27f992afdbe28da8f415c4e391ca7cd4ca87922e2ede8325ea4524bf +index.md 4ccbb01ce1418141e75632f318714cc2ddd49887f928049e4688802ce4b5bdc2 +index.md 50e5866d2ca9d9fbc5e93d772ea627a4f7bdd81688675dfa9ba27250b2be617c +index.md 51595dafbc732653bfe5a00246cde38c68db0f7b4d5742f26a7432668a22534d +index.md 51da2033ae4e64f0feac6a7235717326294c188bcdef75e5eaabcd8627a54640 +index.md 68427df841c6c117bc6e98596c0e29d12617b54797bdd5c2a52cec36190c07ba +index.md 69780811b571c4b135b86aa6658ad0ca17ba5d08d224373b5c4e2ea9d5b04e64 +index.md 83a9ae0afbd6373ea138a1aab50d0755dffc97d588aa6b8eda8ff1ac5b55e491 +index.md 8d4b82ca8c247b8991761907aab15c629aae44db4e3358d72610456a3ac28664 +index.md 9081522a512ac330d633c4cedd3f07edb792928b35ad8640b0b853c27fbb1789 +index.md b4cbd980c42e8fc5692a167a90fe332b8fc0a2b94f0dc3d97f86205e0ba2e81b +index.md e05cb576dfbad33a6dbf85526583d2d3abd6e594e8e9c8aa163b8399c93a594a +index.md e4be7c56a5573793e2d7dc8cad56c206f9d19b286a7e3be2e09ff3b79b9642c7 +index.md e4bef4de208914c563a7a63801206366a41ed0ccdfd52c6eaae5dfc64a7af18a +index.md ef432a1a9c6b9eaba3deb9a659ad3d5d1da45345806628fe080cc43eda356be0 +index.md fea00911919a950ca9de2ea79a308aeb54bbf0647619f85da6a74a079fce3b29 +playbooks/assess/accessibility.md b9349df70e40518df65ef528f7366d288ffb5183fa2ef95f0641feb02ba6363c +playbooks/assess/accessibility.md e3acdf8af25c5ca317f4126925f535840ebe1deb670865e9b2f77cf468fc34a4 +playbooks/assess/api-design.md b51892ede7e00ef2e7d12f672c4c0da68589638538ed68c0581fff640ed43c7a +playbooks/assess/api-design.md ffb618ba0ce0e6909d89e18f1fceef214e1df5eac4619593091b415aa4318763 +playbooks/assess/architecture.md ba1dafe9eaa44b761e80636ad6254c0b44a10c9b036da4a9f90a6fb2dd5c5c37 +playbooks/assess/architecture.md e8b1de975d950f1d85a5ee7380e5a2385809711bc71824886c87c3399f0fa305 +playbooks/assess/aws-well-architected.md 962ca955caf258746d4b50b72ac6d48390a330f524a8d2e2065d1f185adb8d2d +playbooks/assess/azure-well-architected.md 05ccf31371bd07c918a2a2ff255e37a14acdcbf584927663f2dc03d0dc7fa66a +playbooks/assess/ci-cd.md 339e25cb98ebd5d2c3d9092d67f8a66d28388b4b92849c3c082f7d7f5b7e6f91 +playbooks/assess/ci-cd.md 5e177aa43610aabe12515c956147d8f575657c3956aabc95941cf77cf190782a +playbooks/assess/code-quality.md 5d2d7aedf26c7a5774a415af3baa6c789420091ec563c336d1f381a7e9392537 +playbooks/assess/code-quality.md 8695ebbdf1be36e82e704e32dd3655e894873686801861b704b9f36b0494eab0 +playbooks/assess/compliance.md 38cfe4a0e0a056e0b55d2db8a5ddc6cf2f5e00d33c6f610ccc69e12ca4355bce +playbooks/assess/cost-optimisation.md 04dda1b90eb7db6726cdfb6fa0eb2a3ae29f897488a47502887654c3a7e56931 +playbooks/assess/cost-optimisation.md 74848df4b3cf1288ecb4a634bfd7215e335df9c2c18fe7bb951f48b97cc25199 +playbooks/assess/domain-security.md 3b7ab6ddfee426b9c956c948f54890cdf124a31b09c7bb4ead4e96f49547504b +playbooks/assess/full.md 815a857b5da895f56fd1522905c3925f38923d04d1598134fd6aa0a71f0fa4c7 +playbooks/assess/full.md d560735f7deab0b9f377ce33458590c8d6bd76da81c5e91b5ed55ef5da2ec3ac +playbooks/assess/gdpr.md 054ae601ba346497c60599addc636029c723dd2651e3c9d8e1061cc99b44352f +playbooks/assess/gdpr.md 7b838bb450cb6d39764053ee3deac503796251226096c7b96627033747963ec0 +playbooks/assess/gdpr.md bc18db84c7917d039f08bafb95071ab5231bfb263f2d408088e5093529bf7d12 +playbooks/assess/gdpr.md c3a0727188cecae887be68adae8ba98eb1f2c598e06d4db6c439479191c60258 +playbooks/assess/gdpr.md ca5c89169bbb885827e13a2114a1c59a19a40596c8045d94e143614a34f87b19 +playbooks/assess/gdpr.md cae1da5f6aea9f01dea8402f2e38bd02e0aa9b26d141fa308890f5c9c15d498e +playbooks/assess/iac.md 8b98da9a863d2891e1ce283672de264d4406424883c3763713142e92a676e50e +playbooks/assess/iac.md 9977d505a067d47ad8b40a6e14fd501cec1b9082d432f51dd76d93b174e07711 +playbooks/assess/observability.md 04e05fbfbbc762871faab52de3ba85acc8711b7ddd1adfdf22edd35047b95cf3 +playbooks/assess/observability.md a795c4afdcfa4be78afdefdc068cf7fcc2bdd01268080cdf14629c9f7a0c4913 +playbooks/assess/operational-excellence.md 1d3a5189f588a40f0f38b2a3faa08738edf36574957c43720fe0ee152fb9cec9 +playbooks/assess/operational-excellence.md 6e1c03adbdf22a7a6dc6cc809736fe97c71d0043cd8e83834fa102fc9ab0f6a6 +playbooks/assess/pci-dss.md dbf890ac9882322d6a3019465f7e2b0a1d9f030410f97b7583f77a8d3b190446 +playbooks/assess/pen-test.md d2897e4e7d8c229228085ec9cabd86a390a00867a0ef55bd7057114e6a1698f0 +playbooks/assess/performance.md 93b145c2cc987d854459279ad85268c36245ddc54b16bf0d20f8aaa2f2da296e +playbooks/assess/performance.md b7e080aea72e168ebc3aedb7dc97cf410cd98f102ded3dda0df86017a61d9f23 +playbooks/assess/resilience.md 666e2ab2db632f4525a69fd767870bf30f7759e30e4960383cbf28d3d83e48fc +playbooks/assess/resilience.md 6f87548dda8d68a1ced72092e73370ad3b2e4e747ab308b9855ce6eb0fdac32a +playbooks/assess/security.md 5788e854bc32e1a1acdca278a579616c3e6f0e329b0a78280fcdc848895f7d38 +playbooks/assess/security.md ac6b64e59649f94ab806aa8a070c6a63e53e1140fa998c22b741b64c1eb019cb +playbooks/assess/tech-debt.md 09b149e01126b9c97ff678659257fda9f10c97b43fb0ebe00ca23ed75e917827 +playbooks/assess/tech-debt.md bd7f183b5f13556d01852be2ecd29f168725e5da8cb3fb93d5b5defec415fe89 +playbooks/assess/test-coverage.md 19b9e4d79a07871b52c862a24054c0b16838f43c9e236cf68f41fbe7e4918086 +playbooks/assess/testing.md 3e37c8ab2ac430184b0a80510ab9228862e9bcf0a3811d14bb64082296cf2cb7 +playbooks/assessment/api-design.md abd926b550ba8345ae47ee701e44c9e4d15d3e7e505eb1345432987e952f2765 +playbooks/assessment/architecture.md 8ec79eced66864307217476091e17b28e2a4dd70104fa0bdc118e26d7c5932ff +playbooks/assessment/code-quality.md de4aa9d6d02df42e5d5c9f52088f564ded0b11954c9a93d6c75d68ddef574125 +playbooks/assessment/compliance.md 4c43c3a081edb1fa1702872a0e5c097ae41d958c901c269ade63cce076c5a50e +playbooks/assessment/full.md 5ba4e34f6618fb08c7fb8b10537865b4dcb72635dd84d72af422ea69d39f10b5 +playbooks/assessment/iac-maturity.md 7cab087e44b32e8d775f6a7bbf5f61c300cd673bd0a36dc1c3bc75314abd7b9b +playbooks/assessment/observability.md 4f370943cf6c9d4477e439f4dd703c3858c3e4806bd458972c744589a67e6735 +playbooks/assessment/performance-resilience.md 1c09b3e02b7b9af225b740b42719577469b8cb1da1c4d195acccb19bda3453dc +playbooks/assessment/security.md 1a18d9fb3e210e97386f731b21685dcd077c9c16d778a465dc3d432b2f688b86 +playbooks/assessment/technical-debt.md 5e25ee63dbf53968924cc677ac5a465243c2dc1b23f726916351a2217679e58e +playbooks/assessment/test-coverage.md 4e42612fed1d82dd9bf4284fc87df1ac11b4718f3dd78b75eab8a8f6567ded49 +playbooks/assessment/test-coverage.md 623fd6328e1a665079efc848a05917333e2375072ffc662c33404771626c6da2 +playbooks/debug/scientific-debugging.md 1a52e094b429b34074428d3d215feffbdf5a44d6cb66d8d64cdbbea8208531eb +playbooks/docs/gitbook.md 5951d16a59db69e693e07845f11129bb122a13bebbaa3ab177a733365d619f06 +playbooks/docs/gitbook.md ae4d294347fef67bbda7736d465b794af08cb7a7e83d7d16603b542bb94611e5 +playbooks/plan/adr.md 1015979a43df16e3f469b4f4f5fb8afe15df300d29cda400f05fc977f4ea7c7a +playbooks/plan/design-doc.md 938a2ea9ee36e10e0caf70ef51149ef2c44d45dbe4f94cecf457e4c536dfb5ea +playbooks/plan/research.md 8b0c774783cb369bafd09e37b4be641224ee836caf564f14ab7f3536f5e73347 +playbooks/plan/risk-assessment.md e68d1dbbcecf3ac31f0842c2ea37f28e176d9edb72bffea0c7c95d36d221711e +playbooks/plan/spike.md 29b9f19ff3a030eb7bdcf67b9903fb919282cd78997ca2103c4729328fc2cef4 +playbooks/planning/01-technical-design-doc.md ee5534cb69babdd8b465c81b2b90ad94860d93fd15cce3d0f0f0fc18e43ef545 +playbooks/planning/02-architecture-decision-record.md 72b18e3f3972949afe579a941695cc854be4e9520174faf358a1e623662480ec +playbooks/planning/03-risk-assessment.md d749f4847143c9ca98ff5f661a73c4985f14bd4c1d23f680e6d0d518042e6cb1 +playbooks/planning/04-spike-research.md 51e22c7ed0897e65a275677d92eeb81c4702655deed7de1113de29910f4da47d +playbooks/planning/adr.md 72b18e3f3972949afe579a941695cc854be4e9520174faf358a1e623662480ec +playbooks/planning/design-doc.md ee5534cb69babdd8b465c81b2b90ad94860d93fd15cce3d0f0f0fc18e43ef545 +playbooks/planning/risk-assessment.md d749f4847143c9ca98ff5f661a73c4985f14bd4c1d23f680e6d0d518042e6cb1 +playbooks/planning/spike.md 51e22c7ed0897e65a275677d92eeb81c4702655deed7de1113de29910f4da47d +playbooks/refactor/dependency-upgrade.md b8bdf61de51e5f98fea320971c356a9f8bd4e73706ba3f87ee6e98dffd85e86a +playbooks/refactor/extract-module.md c3d67a579216457e471263d09e6783bf93e1c9e701682fa72a015402469764d3 +playbooks/refactor/safe-refactor.md c767d5bd189d3868163dcdaa786371e4b88e246a3ecd0a64aaba09f10d185416 +playbooks/refactoring/01-safe-refactoring-runbook.md 02a3cf6e7cc2deb63b5b116334efd4e0afdf497833eead6c1e5d2abedad037a7 +playbooks/refactoring/02-extract-module.md 5e702feb94a39fa0a068c67f98be921e925443392a77258a3d4ad4363d85fa7f +playbooks/refactoring/03-dependency-upgrade.md 0f7f4e133970c9ce0499b211ed8876c084c90afaed17217134cbb951b6b64a0e +playbooks/refactoring/04-technical-debt-reduction.md 5e25ee63dbf53968924cc677ac5a465243c2dc1b23f726916351a2217679e58e +playbooks/repository-review/00-full-assessment.md 720f06d6bf0ccb5db2f2511d1a1c3dab327a4189a827ca5c649e0789f264058d +playbooks/repository-review/00-full-assessment.md c3214365365087c8fa5289bebec17258b72fa08e36971840555d84754ea6cf49 +playbooks/repository-review/01-architectural-assessment.md 8ec79eced66864307217476091e17b28e2a4dd70104fa0bdc118e26d7c5932ff +playbooks/repository-review/02-security-assessment.md 1a18d9fb3e210e97386f731b21685dcd077c9c16d778a465dc3d432b2f688b86 +playbooks/repository-review/03-test-coverage-assessment.md 4e42612fed1d82dd9bf4284fc87df1ac11b4718f3dd78b75eab8a8f6567ded49 +playbooks/repository-review/04-solid-clean-code-assessment.md de4aa9d6d02df42e5d5c9f52088f564ded0b11954c9a93d6c75d68ddef574125 +playbooks/repository-review/05-iac-maturity-assessment.md 7cab087e44b32e8d775f6a7bbf5f61c300cd673bd0a36dc1c3bc75314abd7b9b +playbooks/repository-review/06-performance-resilience-assessment.md 1c09b3e02b7b9af225b740b42719577469b8cb1da1c4d195acccb19bda3453dc +playbooks/repository-review/07-observability-assessment.md 4f370943cf6c9d4477e439f4dd703c3858c3e4806bd458972c744589a67e6735 +playbooks/repository-review/08-api-assessment.md abd926b550ba8345ae47ee701e44c9e4d15d3e7e505eb1345432987e952f2765 +playbooks/review/accessibility.md 21e31f3116b8690373dae060d850f776f05154bee0c5f038ba4f0c2f16f14ed1 +playbooks/review/api-design.md 113721e0474bb09580fd11dc4b96edccf9b9916330dd794988e2aa10a6a57ee9 +playbooks/review/api-design.md 74f207da849e6cbaf823873f79b8c8cef91c427061d29ba7c04e265f6c144c03 +playbooks/review/architecture.md 3b1474280f7ca9e4f441a18fb88afca7edcd23fc9a0b7cd45083f3130ddad3fd +playbooks/review/architecture.md 8deb5eb26771519a7f0612d2eaedb0a78f12b1560f10e18c377b80b5fef05c7c +playbooks/review/architecture.md ede077ffe43c748e84b4489c226b34ae4ed063352f24c8dd9d92762a3021532e +playbooks/review/code-quality.md 023a1304d119007b40da67aa601c443576fe1e66b70bcad904ca9263d6e48d6c +playbooks/review/code-quality.md aedfb78f9c96d827096f8714a4e2f872423aacd9c1eb54ca6e8cabec1a850988 +playbooks/review/compliance.md 19924a2ef3bcb2678f399bf44fead68531d76c1881b1e3ca26c35608ec15699f +playbooks/review/compliance.md f7a6c4d568b70ecc133ba7bd7747139a715cc19a0b06b5821b7e7e11f54c46bf +playbooks/review/cost-optimisation.md 69a8bd5c8bc5ec3372c5a7ba39e076c40c3729a1b645f75f33c6e1a12dd448bc +playbooks/review/iac-maturity.md 681b2fa60f704f736ed733f8b38c6e6eb376673e578a43c6eafb59f59554fe41 +playbooks/review/iac.md 6ed7e2805b95cc0af76c743b6d120463a8f43030434e07aacdd4eee8cc57cc75 +playbooks/review/iac.md 8449c682b18e38040cfa42cb721c532556c0b190878691fe6dc5581a327e3070 +playbooks/review/observability.md 4796c378b6067fd8192a10b5ff21aaf04375c9d08acde77e64f6f733e0f767b2 +playbooks/review/observability.md 6f6434b2b253bb7de6c35a0921776a55284012b1ec60732122ac30f60802995a +playbooks/review/performance-resilience.md fbf6552960a807e9eaffb31095bd6e62e4dc52e8fe88095718552d3086b6d84c +playbooks/review/performance.md 36a17da7faaeaf6848ed24d1528f4ad98369d2a3cb40479477fafc16099032c7 +playbooks/review/security.md 29ebbbdb9b63748c77fe606a5282a8a1ed18f2c8983287abc0ccfba35880cdc1 +playbooks/review/security.md 5a209101a4c54199e370c54079464843f4ca480597e42080d4e61eb6dc2baf37 +playbooks/review/test-coverage.md 29c9c9d0be655ac7b3bbca8036b04b6d932713152ee9b6fb69afc51d0ba89dcf +playbooks/review/test-coverage.md c0455e559ba52b38162cc85cbd516ae925b643947e75faea14e0db2b502b594e +playbooks/review/testing.md eae8dec22058ccf5db9771ea461f1dd404a549ce072f61ad725174e78cb882b4 +playbooks/setup/create-local-otel-stack.md 085154d6d924bbde6b1d2c622703ab9a08ac53f357d1c2ebeafe02107b85bfb5 +playbooks/setup/create-local-otel-stack.md d085a19dd7d426a89fa7900bbaadfa581aafd69c6117f7b35312a6d9f8d43049 +playbooks/setup/discover-local-otel-stack.md b226d151297ad068c6195d9dc52ef001c605219f0e6593aa71f8656649bbb9f4 +playbooks/setup/discover-local-otel-stack.md fca3574c6a107094485297dab48f974af5999471431e9b41df3af229909542bd +playbooks/setup/instrument-dotnet-otel.md 35fdadbc0c089e91c2ece381a80ae59694f77b4c7f6335cb4bb73ea607887aae +playbooks/setup/use-local-otel-stack.md 180df32e273585ddd9b3e7466e19516f7dcd1f4c69193623b0ba980b2c69222f +playbooks/setup/use-local-otel-stack.md 75f5ffbbf83f87495e00d8994b7de79409434b4f1e656734bffa00cdb3a3a2d3 +standards/accessibility.md 222c94e68e30f73081913e46eeb2d423eee8306b7753899729ba83f9560ffc75 +standards/ado-pipelines.md 3f75ef9a3042b1cfd45db92c30b0c9a38e82324f41dd6c9b1e0402203960e583 +standards/ado-pipelines.md 6240d322009b1a192c899054aa0860e26f27f69dc7cda811b3c179f46e9ac8a2 +standards/ado-pipelines.md 9185125bdf2c3faf787d39be3fbde78e46cb155ed4d6fb7f694e3692e4e02728 +standards/ado-pipelines.md 9a59b388de3c205949367e6a9d3be298383c3926059b0479665db425b2558fef +standards/ado-pipelines.md aeea8574e26c474bdab2b672a99541a32f5ddefb78257df7e2fd27742d112f2b +standards/ado-pipelines.md b98717360705ada4f0bb32df3ec5530e0c30c9b9ecfaca2364625ffde26092f9 +standards/ado-pipelines.md ecfaf7686ec26502e945864d3babed3da156c955e937a845c9802d78f24d5b34 +standards/api-design.md 93fbe27830ce29ddc0510088a178e9a27cfa56b1e697a88b9a93adad48b0658b +standards/api-design.md e9be58dfe6eec16eb0591936c6fa664dca30f4b2929e3319b6297a3e7543302d +standards/architecture.md a1a1ff3f370f56c4ce82519bb137389985d666646c9aa252bc644233d1067762 +standards/aws-well-architected.md 3ef134cee4986cb4ef85cdd113663966d85917bf7e7b602b3b79ef43e87aa7e6 +standards/azure-well-architected.md 6b18818e64c4b17fbf5e348f1ba78ff6e0bd78c3e77560b5b1e11018d309c30f +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 03d8dc1c18738e5cbab1123e7a4b9f18aeb984b50e03984ef324fb518bdd7f0b +standards/gdpr.md 2c8284037e8abd07c460f9f5fde2a316e9f25e732d6183a12124ded1f9f012ca +standards/gdpr.md 46401eab094e5b5641533bde3b8851edeb1bd2fc800400c00386b6c09fae06a5 +standards/gdpr.md 6fdd28381e69ee097839b1f2eeeceead1a17b98e9e5b906727a6702022b58cc8 +standards/gdpr.md 70a3778dbb45bb577c5dff4d9e1de7cb61c51471ce7a8933e8befb6cd8477e03 +standards/gdpr.md 8d712e6f9389aee5888cd4e0e5bbbc196e8468ad23981900309d19954bfd7b2c +standards/gdpr.md 926ad95210832a6f63080649bdba59e2e15d4b9fdc1bda8bde68b9d6d8d6d3f4 +standards/gdpr.md ccc21f190f192c3367c786445d0905486c36d519bc1f2b7a67424413ba36720f +standards/iac.md 42e3f14947e6e666efcac2a90da19308828c87b0d0f2ffb374e7205320a2f73e +standards/mssql.md 2d66ad11557e317a1bf52163c01900a3ff0f6f71655c1289efb7cf806f1de25b +standards/observability.md 715b0e76ad1a42049d83c7d5afb694e1cd52d0289c0341cd97c32646fe816a1d +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/playwright.md b1ff16bf0b9ae452a872cf48e7da27d1cbf40547ee651cb93bc7be12e4eb2cc7 +standards/playwright.md c34a078d1ac15fc5ac83d0a91a4c701cc3514d27d933edf16cb3be752c385490 +standards/playwright.md c8a74de8b9c7363016425844ff561888d1a3f49155167f7b178c75aca0d7b38d +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/scripts/ci/write-unversioned-baseline.sh b/scripts/ci/write-unversioned-baseline.sh new file mode 100755 index 0000000..30c833b --- /dev/null +++ b/scripts/ci/write-unversioned-baseline.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# write-unversioned-baseline.sh - regenerate scripts/baselines/unversioned.sha256. +# +# Unversioned adopters deployed from whichever commit of main was current at the +# time, not from one fixed snapshot. A baseline built from a single commit +# reports every file the library has changed since an adopter deployed as a +# consumer edit, and migrate then pins it as a "mode: replace" override - so the +# adopter silently stops receiving improvements to it. It also mistakes a file +# the library has since removed for one the consumer added. +# +# This records every revision each path has had across the history of , so +# a file matching any of them is recognised as pristine. Hashes are taken with +# line endings normalised to LF, matching ac_sha256_lf in migrate. +# +# It also records every revision of the framework-authored region of the +# AGENTS.md template (see ac_agents_region), so migrate can swap that region for +# the managed block in place instead of leaving the consumer with two copies. +# +# Usage: write-unversioned-baseline.sh [rev] (default: HEAD) +# +# This file is committed by hand. It is not CI-owned and is never rewritten by a +# release. +# +# Portability: macOS bash 3.2 with BSD userland, and Linux with GNU userland. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +# shellcheck source=scripts/lib/common.sh +. "$REPO_ROOT/scripts/lib/common.sh" + +REV="${1:-HEAD}" +OUT="$REPO_ROOT/scripts/baselines/unversioned.sha256" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT INT TERM + +blob_sha256_lf() { + git -C "$REPO_ROOT" cat-file blob "$1" > "$WORK/blob" + ac_sha256_lf "$WORK/blob" +} + +# Map a source path to the path it is deployed at under .context/. +target_path() { + case "$1" in + standards/*.md|playbooks/*.md) printf '%s' "$1" ;; + core/.context/conventions/*.md) printf 'conventions/%s' "${1#core/.context/conventions/}" ;; + core/.context/index.md) printf 'index.md' ;; + *) return 1 ;; + esac +} + +# Unique (path, blob) pairs across history; each blob is hashed once. +git -C "$REPO_ROOT" rev-list "$REV" | while IFS= read -r commit; do + git -C "$REPO_ROOT" ls-tree -r "$commit" -- standards playbooks core/.context/conventions core/.context/index.md core/AGENTS.md +done | awk '{ print $3 "\t" $4 }' | LC_ALL=C sort -u > "$WORK/pairs" + +: > "$WORK/out" +while IFS="$(printf '\t')" read -r blob path; do + if [ "$path" = "core/AGENTS.md" ]; then + git -C "$REPO_ROOT" cat-file blob "$blob" > "$WORK/agents" + if ac_agents_region "$WORK/agents" > "$WORK/region"; then + printf '%s %s\n' "$AC_AGENTS_REGION_KEY" "$(ac_sha256 "$WORK/region")" >> "$WORK/out" + fi + continue + fi + rel="$(target_path "$path")" || continue + printf '%s %s\n' "$rel" "$(blob_sha256_lf "$blob")" >> "$WORK/out" +done < "$WORK/pairs" + +LC_ALL=C sort -u "$WORK/out" > "$OUT" +echo "Wrote $OUT ($(grep -c . "$OUT") entries, $(cut -d' ' -f1 "$OUT" | sort -u | grep -c .) paths)" diff --git a/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/scripts/deploy.ps1 b/scripts/deploy.ps1 new file mode 100644 index 0000000..8324181 --- /dev/null +++ b/scripts/deploy.ps1 @@ -0,0 +1,899 @@ +<# +.SYNOPSIS + Deploy agent-contexts templates to a target repository. + +.DESCRIPTION + Copies shared engineering standards and agent-specific configuration files + (for Claude Code, GitHub Copilot, Cursor, Devin, and Windsurf) to target + repositories. Generates skill wrapper SKILL.md files from playbooks for + Claude Code and GitHub Copilot. + +.PARAMETER Agents + One or more agents to deploy: claude, copilot, cursor, devin, windsurf, all. + If omitted in an interactive terminal, a selection menu is shown. + +.PARAMETER TargetRepo + Target repository path. Defaults to the current directory. + +.PARAMETER Overwrite + Overwrite all existing files without prompting. + +.PARAMETER NoOverwrite + Skip all existing files without prompting. + +.EXAMPLE + .\deploy.ps1 -Agents claude,copilot + .\deploy.ps1 -Agents claude copilot + .\deploy.ps1 -Agents "claude copilot windsurf" + .\deploy.ps1 -Agents all -TargetRepo C:\repos\my-project + .\deploy.ps1 + .\deploy.ps1 -Agents all -TargetRepo C:\repos\my-project -NoOverwrite +#> +[CmdletBinding()] +param( + [string[]]$Agents, + [string]$TargetRepo, + [switch]$Help, + [switch]$Overwrite, + [switch]$NoOverwrite, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingAgents +) + +$ErrorActionPreference = 'Stop' + +if ($RemainingAgents) { + $Agents = @($Agents) + @($RemainingAgents) +} +if ($Agents) { + $Agents = @($Agents | ForEach-Object { $_ -split '[\s,]+' } | Where-Object { $_ -ne '' }) +} + +$ValidAgents = @('claude', 'copilot', 'cursor', 'devin', 'windsurf') +$script:EnabledAgents = @() +$script:OverwriteMode = "" # "all" | "none" | "" (prompt per-file) +$script:SkippedFiles = @() + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + +function Show-Usage { + Write-Host @" +Usage: .\deploy.ps1 -Agents [-TargetRepo ] + +Copy agent-contexts templates to a target repository and generate skill wrappers. +If -TargetRepo is omitted, deploys to the current directory. + +Shared content (always copied): + AGENTS.md -> target repo root + .context\ -> target .context\ (index + conventions) + standards\ -> target .context\standards\ + playbooks\ -> target .context\playbooks\ + +Agent-specific files (copied only for selected agents): + claude -> CLAUDE.md, .claude\settings.json, .claude\skills\ + copilot -> .github\copilot-instructions.md, .github\skills\ + cursor -> .cursor\rules\standards.mdc + devin -> .devin\devin.json + windsurf -> .windsurfrules + all -> all of the above + +Parameters: + -Agents Mandatory in non-interactive mode. Accepts one or more values: + claude copilot cursor devin windsurf all + -TargetRepo Target directory (default: current directory) + -Overwrite Overwrite all existing files without prompting + -NoOverwrite Skip all existing files without prompting + Default: prompt per-file when conflicts are detected + -Help Show this help message and exit +"@ +} + +function Print-Banner { + Write-Host " __" -ForegroundColor Cyan + Write-Host " _(\ |@@|" -ForegroundColor Cyan + Write-Host "(__/\__ \--/ __" -ForegroundColor Cyan + Write-Host " \___|----| | __" -ForegroundColor Cyan + Write-Host " \ }{ /\ )_ / _\" -ForegroundColor Cyan + Write-Host " /\__/\ \__O (__" -ForegroundColor Cyan + Write-Host " (--/\--) \__/" -ForegroundColor Cyan + Write-Host " _)( )(_" -ForegroundColor Cyan + Write-Host " ``---''---``" -ForegroundColor Cyan + Write-Host "A comprehensive list of engineering standards for context engineering with AI Agents" -ForegroundColor Yellow + Write-Host "https://github.com/ldastey-dev/agentic-context" -ForegroundColor Cyan + Write-Host "Written by Leigh Dastey" -ForegroundColor Magenta + Write-Host "" +} + +function Test-AgentEnabled { + param([string]$Agent) + return ($script:EnabledAgents -contains $Agent) +} + +function Confirm-Overwrite { + param([string]$Destination) + + # New files always proceed + if (-not (Test-Path $Destination)) { + return $true + } + + switch ($script:OverwriteMode) { + "all" { return $true } + "none" { + $script:SkippedFiles += $Destination + return $false + } + } + + # Non-interactive -> safe default (skip) + $isInteractive = $false + try { + $isInteractive = [Environment]::UserInteractive -and -not [Console]::IsInputRedirected + } catch { } + + if (-not $isInteractive) { + Write-Host " Skipping existing file (non-interactive): $Destination" + $script:SkippedFiles += $Destination + return $false + } + + while ($true) { + Write-Host " File already exists: $Destination" + $answer = Read-Host " Overwrite? [y]es / [n]o / [N]o to all / [a]ll" + switch ($answer) { + 'y' { return $true } + 'n' { $script:SkippedFiles += $Destination; return $false } + 'N' { $script:OverwriteMode = "none"; $script:SkippedFiles += $Destination; return $false } + 'a' { $script:OverwriteMode = "all"; return $true } + default { Write-Host " Please enter y, n, N, or a." } + } + } +} + +$script:TextExtensions = @('.md', '.json', '.mdc', '.txt', '.yaml', '.yml', '.toml', '.ini') + +function Test-IsUtf8Compatible { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path + ) + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + try { + $bom = [byte[]]::new(4) + $stream = [System.IO.File]::OpenRead($resolvedPath) + try { $read = $stream.Read($bom, 0, 4) } + finally { $stream.Close() } + # UTF-32 must be tested before UTF-16 (shares FF FE prefix) + if ($read -ge 4 -and $bom[0] -eq 0xFF -and $bom[1] -eq 0xFE -and $bom[2] -eq 0x00 -and $bom[3] -eq 0x00) { return $false } # UTF-32 LE + if ($read -ge 4 -and $bom[0] -eq 0x00 -and $bom[1] -eq 0x00 -and $bom[2] -eq 0xFE -and $bom[3] -eq 0xFF) { return $false } # UTF-32 BE + if ($read -ge 2 -and $bom[0] -eq 0xFF -and $bom[1] -eq 0xFE) { return $false } # UTF-16 LE + if ($read -ge 2 -and $bom[0] -eq 0xFE -and $bom[1] -eq 0xFF) { return $false } # UTF-16 BE + return $true + } catch { + Write-Warning "Test-IsUtf8Compatible: could not read '$resolvedPath' - $_. Falling back to Copy-Item." + return $false + } +} + +function Copy-SingleFile { + [CmdletBinding()] + param([string]$Source, [string]$Destination) + $ext = [System.IO.Path]::GetExtension($Source).ToLowerInvariant() + $isText = ($ext -in $script:TextExtensions) -and (Test-IsUtf8Compatible -Path $Source) + $content = $null + if ($isText) { + # ANSI/Windows-1252 files without a BOM are indistinguishable from UTF-8 at the header level and will pass + # Test-IsUtf8Compatible; characters above U+007F may be silently replaced. All repo source files are UTF-8. + $content = [System.IO.File]::ReadAllText($Source, [System.Text.Encoding]::UTF8) -replace "`r`n", "`n" -replace "`r", "`n" + } + # Identical content needs no decision. Without this, redeploying the same + # version prompts once for every file in the library, and a non-interactive + # redeploy lists them all as "skipped" when nothing would have changed. + if (Test-Path -LiteralPath $Destination -PathType Leaf) { + $destFull = [System.IO.Path]::GetFullPath($Destination) + if ($isText) { + $existing = [System.IO.File]::ReadAllText($destFull, [System.Text.Encoding]::UTF8) -replace "`r`n", "`n" + if ($existing -ceq $content) { return } + } elseif ((Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash -eq (Get-FileHash -LiteralPath $destFull -Algorithm SHA256).Hash) { + return + } + } + if (-not (Confirm-Overwrite -Destination $Destination)) { + return + } + $parentDir = Split-Path $Destination -Parent + if (-not (Test-Path $parentDir)) { + New-Item -ItemType Directory -Path $parentDir -Force | Out-Null + } + if ($isText) { + [System.IO.File]::WriteAllText($Destination, $content, (New-Object System.Text.UTF8Encoding($false))) + } else { + Copy-Item -Path $Source -Destination $Destination -Force + } +} + +function Copy-DirectoryContents { + 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 + Copy-SingleFile -Source $file.FullName -Destination $destPath + } +} + +# 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, + # which we set via kernel32!SetConsoleMode. Returns $false if the API isn't + # available (e.g. constrained language mode) or the call fails - callers should + # refuse to render ANSI in that case rather than print literal escape text. + $onWindows = ($PSVersionTable.PSVersion.Major -le 5) -or $IsWindows + if (-not $onWindows) { return $true } + + try { + if (-not ('AgenticContext.NativeConsole' -as [type])) { + Add-Type -Namespace AgenticContext -Name NativeConsole -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] +public static extern System.IntPtr GetStdHandle(int nStdHandle); +[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] +public static extern bool GetConsoleMode(System.IntPtr hConsoleHandle, out uint lpMode); +[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] +public static extern bool SetConsoleMode(System.IntPtr hConsoleHandle, uint dwMode); +'@ + # Add-Type compiles the above via an external process, which resets + # [Environment]::CurrentDirectory (observed jumping to C:\WINDOWS\System32) as a + # side effect. Restore it so relative-path .NET file I/O elsewhere in the script + # keeps resolving against PowerShell's actual working directory. + [System.Environment]::CurrentDirectory = (Get-Location).Path + } + + $STD_OUTPUT_HANDLE = -11 + $ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x4 + + $stdOut = [AgenticContext.NativeConsole]::GetStdHandle($STD_OUTPUT_HANDLE) + $mode = 0 + if ([AgenticContext.NativeConsole]::GetConsoleMode($stdOut, [ref]$mode)) { + return [AgenticContext.NativeConsole]::SetConsoleMode($stdOut, $mode -bor $ENABLE_VIRTUAL_TERMINAL_PROCESSING) + } + return $false + } catch { + return $false + } +} + +function Render-AgentMenu { + param( + [string[]]$Options, + [int[]]$Selected, + [int]$Cursor, + [string]$StatusMessage, + [bool]$FirstRender + ) + + $esc = [char]27 + $linesDrawn = $Options.Count + 1 # menu rows + status row + + if (-not $FirstRender) { + [Console]::Write("$esc[${linesDrawn}A") + } + + for ($i = 0; $i -lt $Options.Count; $i++) { + $pointer = " " + $marker = "[ ]" + if ($i -eq $Cursor) { $pointer = "> " } + if ($Selected[$i] -eq 1) { $marker = "[x]" } + + $line = "$pointer$marker $($Options[$i])" + + if ($i -eq $Cursor) { + [Console]::Write("`r$esc[2K$esc[32m$line$esc[0m`n") + } else { + [Console]::Write("`r$esc[2K$line`n") + } + } + + [Console]::Write("`r$esc[2K$StatusMessage`n") +} + +function Select-AgentsInteractive { + if (-not (Enable-VirtualTerminal)) { + Write-Error "Interactive menu unavailable: virtual-terminal mode could not be enabled. Re-run with -Agents ." + exit 1 + } + + $options = @('all') + $ValidAgents + @('clear and exit') + $optionsCount = $options.Count + $exitIndex = $optionsCount - 1 + $selected = @(0) * $optionsCount + $cursor = 0 + $statusMsg = "" + + Write-Host "Select one or more agents (space to toggle, Up/Down to move, Enter to confirm)." + Write-Host "Select 'all' to deploy every supported agent." + Write-Host "Select 'clear and exit' to clear selection and quit." + Write-Host "" + + $firstRender = $true + + try { + [Console]::CursorVisible = $false + + Render-AgentMenu -Options $options -Selected $selected -Cursor $cursor -StatusMessage $statusMsg -FirstRender $firstRender + $firstRender = $false + + while ($true) { + $keyInfo = [Console]::ReadKey($true) + + switch ($keyInfo.Key) { + 'UpArrow' { + $cursor = ($cursor - 1 + $optionsCount) % $optionsCount + $statusMsg = "" + } + 'DownArrow' { + $cursor = ($cursor + 1) % $optionsCount + $statusMsg = "" + } + 'Spacebar' { + if ($cursor -eq $exitIndex) { + if ($selected[$cursor] -eq 1) { + $selected[$cursor] = 0 + $statusMsg = "" + } else { + for ($i = 0; $i -lt $optionsCount; $i++) { $selected[$i] = 0 } + $selected[$cursor] = 1 + $statusMsg = "Press Enter to clear selections and exit." + } + } elseif ($options[$cursor] -eq 'all') { + if ($selected[$cursor] -eq 1) { + for ($i = 0; $i -lt $exitIndex; $i++) { $selected[$i] = 0 } + } else { + for ($i = 0; $i -lt $exitIndex; $i++) { $selected[$i] = 1 } + } + $selected[$exitIndex] = 0 + $statusMsg = "" + } else { + $selected[$cursor] = if ($selected[$cursor] -eq 1) { 0 } else { 1 } + + $selected[$exitIndex] = 0 + $selected[0] = 1 + for ($i = 1; $i -lt $exitIndex; $i++) { + if ($selected[$i] -eq 0) { + $selected[0] = 0 + break + } + } + $statusMsg = "" + } + } + 'Enter' { + if ($cursor -eq $exitIndex) { + [Console]::CursorVisible = $true + Write-Host "`nSelection cleared. Exiting." + return $null + } + + $chosen = @() + for ($i = 0; $i -lt $optionsCount; $i++) { + if ($i -eq $exitIndex) { continue } + if ($selected[$i] -eq 1) { $chosen += $options[$i] } + } + + if ($chosen.Count -eq 0) { + $statusMsg = "Select at least one option." + } else { + [Console]::CursorVisible = $true + return $chosen + } + } + } + + Render-AgentMenu -Options $options -Selected $selected -Cursor $cursor -StatusMessage $statusMsg -FirstRender $firstRender + } + } finally { + [Console]::CursorVisible = $true + } +} + +function Resolve-SelectedAgents { + param([string[]]$Selected) + + $seen = @{} + $enabled = [System.Collections.Generic.List[string]]::new() + + foreach ($agent in $Selected) { + switch ($agent) { + 'all' { + $script:EnabledAgents = @($ValidAgents) + return + } + { $_ -in $ValidAgents } { + if (-not $seen.ContainsKey($agent)) { + $enabled.Add($agent) + $seen[$agent] = $true + } + } + default { + Write-Error "Unsupported agent '$agent'. Supported agents: all, $($ValidAgents -join ', ')" + exit 1 + } + } + } + + if ($enabled.Count -eq 0) { + Write-Error "At least one agent must be selected." + exit 1 + } + + $script:EnabledAgents = @($enabled) +} + +function New-SkillWrapper { + param( + [string]$PlaybookPath, + [string]$TargetDir, + [string]$RelPath, + [string]$AllowedTools = "" + ) + + $name = "" + $description = "" + + # Explicit UTF-8: Windows PowerShell 5.1 would otherwise decode the playbook + # as ANSI and write a mangled description into every wrapper. + foreach ($line in [System.IO.File]::ReadAllLines([System.IO.Path]::GetFullPath($PlaybookPath), [System.Text.Encoding]::UTF8)) { + if ($line -match '^name:\s*(.+)$') { + if (-not $name) { $name = $Matches[1].Trim() } + } + if ($line -match '^description:\s*"?(.+?)"?\s*$') { + if (-not $description) { $description = $Matches[1].Trim().Trim('"') } + } + if ($name -and $description) { break } + } + + if (-not $name -or -not $description) { return } + + $skillDir = Join-Path $TargetDir $name + $skillFile = Join-Path $skillDir "SKILL.md" + + $bt = '`' + $lines = @("---", "name: $name", "description: `"$description`"") + if ($AllowedTools) { + $lines += "allowed-tools: `"$AllowedTools`"" + } + $lines += @("---", "", "Read and follow ${bt}.context/playbooks/${RelPath}${bt} in full.") + + $content = ($lines -join "`n") + "`n" + + # Same rule as Copy-SingleFile: an unchanged wrapper needs no prompt. + if (Test-Path -LiteralPath $skillFile -PathType Leaf) { + $existing = [System.IO.File]::ReadAllText([System.IO.Path]::GetFullPath($skillFile), [System.Text.Encoding]::UTF8) -replace "`r`n", "`n" + if ($existing -ceq $content) { return } + } + + if (-not (Confirm-Overwrite -Destination $skillFile)) { + return + } + + New-Item -ItemType Directory -Path $skillDir -Force | Out-Null + + [System.IO.File]::WriteAllText($skillFile, $content, (New-Object System.Text.UTF8Encoding($false))) +} + +# Remove generated wrappers whose playbook the library no longer ships. +# +# A wrapper is only a pointer, so once its playbook is renamed or removed it +# advertises a skill that cannot load. Only files that are exactly what +# New-SkillWrapper writes - at most seven lines, ending in the "Read and follow" +# pointer - are touched; anything a consumer wrote themselves is left alone. +# Mirrors prune_stale_skills in deploy.sh. +function Remove-StaleSkillWrappers { + param([string]$SkillsDir) + if (-not (Test-Path -LiteralPath $SkillsDir)) { return } + foreach ($dir in (Get-ChildItem -LiteralPath $SkillsDir -Directory -ErrorAction SilentlyContinue)) { + $skillFile = Join-Path $dir.FullName 'SKILL.md' + if (-not (Test-Path -LiteralPath $skillFile -PathType Leaf)) { continue } + $lines = [System.IO.File]::ReadAllLines($skillFile, [System.Text.Encoding]::UTF8) + if ($lines.Count -gt 7) { continue } + $target = $null + foreach ($line in $lines) { + if ($line -match '^Read and follow `\.context/playbooks/(.+)` in full\.$') { $target = $Matches[1] } + } + if (-not $target) { continue } + if (Test-Path -LiteralPath (Join-Path $script:Target ".context/playbooks/$target")) { continue } + Remove-Item -LiteralPath $dir.FullName -Recurse -Force + Write-Host " Removed stale skill wrapper $($dir.Name) (playbook $target no longer ships)" + } +} + +function New-SkillsForSelectedAgents { + param( + [string]$PlaybookPath, + [string]$RelPath, + [string]$AllowedTools = "Read, Grep, Glob, Bash(git *), Write, Edit, Agent" + ) + + if (Test-AgentEnabled 'claude') { + New-SkillWrapper -PlaybookPath $PlaybookPath -TargetDir (Join-Path $script:Target '.claude/skills') -RelPath $RelPath -AllowedTools $AllowedTools + } + if (Test-AgentEnabled 'copilot') { + New-SkillWrapper -PlaybookPath $PlaybookPath -TargetDir (Join-Path $script:Target '.github/skills') -RelPath $RelPath -AllowedTools "" + } +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Dot-source guard: allows deploy.Tests.ps1 to import these functions without running a deploy. +if ($MyInvocation.InvocationName -ne '.') { + +if ($Help) { + Show-Usage + return +} + +Print-Banner + +if ($Overwrite -and $NoOverwrite) { + Write-Error "-Overwrite and -NoOverwrite are mutually exclusive." + exit 1 +} +if ($Overwrite) { + $script:OverwriteMode = "all" +} +if ($NoOverwrite) { + $script:OverwriteMode = "none" +} + +if (-not $Agents -or $Agents.Count -eq 0) { + $isInteractive = $false + try { + $isInteractive = [Environment]::UserInteractive ` + -and -not [Console]::IsInputRedirected ` + -and -not [Console]::IsOutputRedirected + } catch { } + + if ($isInteractive) { + $result = Select-AgentsInteractive + if ($null -eq $result) { + exit 0 + } + $Agents = $result + } else { + Write-Error "-Agents is mandatory in non-interactive mode." + Show-Usage + exit 1 + } +} + +Resolve-SelectedAgents -Selected $Agents + +if (-not $TargetRepo) { + $script:Target = (Get-Location).Path +} elseif ([System.IO.Path]::IsPathRooted($TargetRepo)) { + $script:Target = $TargetRepo +} else { + # Resolve relative to PowerShell's working directory rather than letting .NET fall back to + # [Environment]::CurrentDirectory. Add-Type (invoked by Enable-VirtualTerminal for the + # interactive menu) resets that process-wide value on Windows, which would otherwise silently + # redirect every subsequent relative-path file operation to the wrong directory. + $script:Target = [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $TargetRepo)) +} + +if (-not (Test-Path $script:Target -PathType Container)) { + $answer = Read-Host "Directory '$($script:Target)' does not exist. Create it? [y/N]" + if ($answer -match '^[yY]') { + New-Item -ItemType Directory -Path $script:Target -Force | Out-Null + Write-Host "Created '$($script:Target)'" + } else { + Write-Host "Aborted." -ForegroundColor Red + exit 1 + } +} + +# 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 ((Read-AcTextLines -Path $versionFile) -join '') + 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 ', ')" + +# A deployment made before versioning has a .context/ but no manifest. Copying +# over it would stamp a manifest claiming the new version onto whatever base +# files the overwrite guard skipped, leave AGENTS.md without a managed block, +# and make migrate refuse to run afterwards because a manifest now exists. +# Upgrade it first: migrate preserves every local edit as an override, and +# refuses to run on a dirty git tree so the result is reviewable as a diff. +# Mirrors the same step in deploy.sh. +$legacyContext = Join-Path $script:Target '.context' +if ((Test-Path -LiteralPath $legacyContext) -and + -not (Test-Path -LiteralPath (Join-Path $legacyContext 'manifest.json')) -and + ((Test-Path -LiteralPath (Join-Path $legacyContext 'index.md')) -or + (Test-Path -LiteralPath (Join-Path $legacyContext 'standards')) -or + (Test-Path -LiteralPath (Join-Path $legacyContext 'playbooks')))) { + Write-Host " Existing deployment from before versioning detected - upgrading it first." + Write-Host "" + $global:LASTEXITCODE = 0 + $migrated = $true + try { + & (Join-Path $PSScriptRoot 'migrate.ps1') -Target $script:Target -Apply + } catch { + Write-Host $_.Exception.Message -ForegroundColor Red + $migrated = $false + } + if (-not $migrated -or $LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath (Join-Path $legacyContext 'manifest.json'))) { + Write-Host "" + Write-Host "Error: could not upgrade the existing deployment; nothing else was changed." -ForegroundColor Red + exit 1 + } + Write-Host "" +} + +Write-Host " Copying shared context files..." + +# 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 = Read-AcTextLines -Path $agentsSrc | ForEach-Object { + if ($_.StartsWith('" + } else { + $_ + } + } + Write-AcTextFile -Path $agentsDst -Lines $seeded +} elseif (Test-AcManagedBlock -Path $agentsDst) { + Update-AcManagedBlock -Source $agentsSrc -Destination $agentsDst -Version $DeployVersion + Write-Host " AGENTS.md: refreshed managed block (your content preserved)" +} 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 $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 $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 $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 $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 $SourceRoot 'core/.windsurfrules') -Destination (Join-Path $script:Target '.windsurfrules') +} + +Write-Host " Copying standards\ -> $($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 $SourceRoot 'playbooks') -Destination (Join-Path $script:Target '.context/playbooks') + +if ((Test-AgentEnabled 'claude') -or (Test-AgentEnabled 'copilot')) { + Write-Host " Generating skill wrappers from playbooks..." + if (Test-AgentEnabled 'claude') { + Write-Host " -> .claude\skills\ (Claude Code)" + New-Item -ItemType Directory -Path (Join-Path $script:Target '.claude/skills') -Force | Out-Null + } + if (Test-AgentEnabled 'copilot') { + Write-Host " -> .github\skills\ (GitHub Copilot)" + New-Item -ItemType Directory -Path (Join-Path $script:Target '.github/skills') -Force | Out-Null + } + + $playbookCategories = @( + @{ Dir = 'assess'; Tools = $null } + @{ Dir = 'review'; Tools = 'Read, Grep, Glob, Bash(git *)' } + @{ Dir = 'plan'; Tools = $null } + @{ Dir = 'refactor'; Tools = $null } + @{ Dir = 'debug'; Tools = 'Read, Grep, Glob, Bash, Write, Edit, Agent' } + @{ Dir = 'docs'; Tools = $null } + @{ Dir = 'setup'; Tools = 'Read, Grep, Glob, Bash, Write, Edit, Agent' } + ) + + foreach ($category in $playbookCategories) { + $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) { + $relPath = "$($category.Dir)/$($playbook.Name)" + $params = @{ + PlaybookPath = $playbook.FullName + RelPath = $relPath + } + if ($null -ne $category.Tools) { + $params['AllowedTools'] = $category.Tools + } + New-SkillsForSelectedAgents @params + } + } + } + + if (Test-AgentEnabled 'claude') { Remove-StaleSkillWrappers -SkillsDir (Join-Path $script:Target '.claude/skills') } + if (Test-AgentEnabled 'copilot') { Remove-StaleSkillWrappers -SkillsDir (Join-Path $script:Target '.github/skills') } +} else { + Write-Host " Skipping skill wrapper generation (no selected agent uses skills)." +} + +# 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) + } +} + +Write-AcTextFile -Path (Join-Path $script:Target '.context/VERSION') -Lines @($DeployVersion) + +Write-Host " Writing manifest -> $(Join-Path $script:Target '.context/manifest.json')" +# pin and checkFrequency are consumer configuration, not derived state. +# 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-AcManifest -Path $manifestPath + 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 + +function Show-NextStep { + param([string]$Message) + Write-Host " $($script:step). $Message" + $script:step++ +} + +Show-NextStep "Fill in [CONFIGURE] sections in $($script:Target)\AGENTS.md" + +if (Test-AgentEnabled 'claude') { + Show-NextStep "Fill in [CONFIGURE] sections in $($script:Target)\CLAUDE.md" + Show-NextStep "Review $($script:Target)\.claude\settings.json and adjust permissions/hooks" +} + +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):" + foreach ($f in $script:SkippedFiles) { + Write-Host " - $f" + } +} + +} # end dot-source guard diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..0b2608a --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,922 @@ +#!/bin/bash +# deploy.sh — copy agent-contexts templates to a target repository +# +# Usage: +# ./deploy.sh --agents [target-repo] +# +# If --agents is omitted in an interactive terminal, an arrow-key multiselect +# prompt is shown. + +set -euo pipefail + +VALID_AGENTS=(claude copilot cursor devin windsurf) +SELECTED_AGENTS=() +ENABLED_AGENTS=() +TARGET="" +AGENTS_FLAG_PROVIDED=0 +OVERWRITE_MODE="" # "all" | "none" | "" (prompt per-file) +OVERWRITE_FLAG=0 +NO_OVERWRITE_FLAG=0 +SKIPPED_FILES=() + +usage() { + cat < [target-repo] + +Copy agent-contexts templates to a target repository and generate skill wrappers. +If [target-repo] is omitted, deploys to the current directory. + +Shared content (always copied): + AGENTS.md → target repo root + .context/ → target .context/ (index + conventions) + standards/ → target .context/standards/ + playbooks/ → target .context/playbooks/ + +Agent-specific files (copied only for selected agents): + claude → CLAUDE.md, .claude/settings.json, .claude/skills/ + copilot → .github/copilot-instructions.md, .github/skills/ + cursor → .cursor/rules/standards.mdc + devin → .devin/devin.json + windsurf → .windsurfrules + all → all of the above + +Options: + --agents Mandatory in non-interactive mode. Supports one or more values: + claude copilot cursor devin windsurf all + --overwrite Overwrite all existing files without prompting + --no-overwrite Skip all existing files without prompting + Default: prompt per-file when conflicts are detected + -h, --help Show this help message and exit +EOF +} + +print_banner() { + local c1=$'\033[38;5;39m' + local c2=$'\033[38;5;45m' + local c3=$'\033[38;5;51m' + local c4=$'\033[38;5;220m' + local c_link=$'\033[38;5;81m' + local c_name=$'\033[38;5;213m' + local reset=$'\033[0m' + local repo_url="https://github.com/ldastey-dev/agentic-context" + + printf "%b\n" "${c1} __${reset}" + printf "%b\n" "${c2} _(\\\\ |@@|${reset}" + printf "%b\n" "${c3}(__/\\\\__ \\\\--/ __${reset}" + printf "%b\n" "${c1} \\\\___|----| | __${reset}" + printf "%b\n" "${c2} \\\\ }{ /\\\\ )_ / _\\\\${reset}" + printf "%b\n" "${c3} /\\\\__/\\\\ \\\\__O (__${reset}" + printf "%b\n" "${c1} (--/\\\\--) \\\\__/${reset}" + printf "%b\n" "${c2} _)( )(_${reset}" + printf "%b\n" "${c3} \`---''---\`${reset}" + printf "%b\n" "${c4}A comprehensive list of engineering standards for context engineering with AI Agents${reset}" + + if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then + printf "%b\033]8;;%s\a%s\033]8;;\a%b\n" "$c_link" "$repo_url" "$repo_url" "$reset" + else + printf "%b%s%b\n" "$c_link" "$repo_url" "$reset" + fi + + printf "%b%s%b\n\n" "$c_name" "Written by Leigh Dastey" "$reset" +} + +join_by() { + local delimiter="$1" + shift + local first=1 + local value + for value in "$@"; do + if [[ $first -eq 1 ]]; then + printf "%s" "$value" + first=0 + else + printf "%s%s" "$delimiter" "$value" + fi + done +} + +agent_enabled() { + local sought="$1" + local agent + for agent in "${ENABLED_AGENTS[@]}"; do + if [[ "$agent" == "$sought" ]]; then + return 0 + fi + done + return 1 +} + +confirm_overwrite() { + local dst="$1" + + # New files always proceed + if [[ ! -e "$dst" ]]; then + return 0 + fi + + case "$OVERWRITE_MODE" in + all) return 0 ;; + none) + SKIPPED_FILES+=("$dst") + return 1 + ;; + esac + + # Non-interactive without explicit flag → safe default (skip) + if [[ ! -t 0 || ! -t 1 ]]; then + echo " Skipping existing file (non-interactive): $dst" + SKIPPED_FILES+=("$dst") + return 1 + fi + + while true; do + printf " File already exists: %s\n" "$dst" + printf " Overwrite? [y]es / [n]o / [N]o to all / [a]ll: " + read -r answer + case "$answer" in + y) return 0 ;; + n) SKIPPED_FILES+=("$dst"); return 1 ;; + N) OVERWRITE_MODE="none"; SKIPPED_FILES+=("$dst"); return 1 ;; + a) OVERWRITE_MODE="all"; return 0 ;; + *) echo " Please enter y, n, N, or a." ;; + esac + done +} + +copy_file() { + local src="$1" + local dst="$2" + # Identical content needs no decision. Without this, redeploying the same + # version prompts once for every file in the library, and a non-interactive + # redeploy lists them all as "skipped" when nothing would have changed. + if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then + return 0 + fi + if ! confirm_overwrite "$dst"; then + return 0 + fi + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" +} + +copy_dir_contents() { + local src="$1" + local dst="$2" + local rel_path file_dst + + while IFS= read -r -d '' file; do + rel_path="${file#"$src"/}" + file_dst="$dst/$rel_path" + copy_file "$file" "$file_dst" + 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[@]} + local exit_index=$((options_count - 1)) + local -a selected=() + local cursor=0 + local key seq1 seq2 i + local colour_green=$'\033[32m' + local colour_reset=$'\033[0m' + local status_msg="" + local first_render=1 + + for ((i=0; i&2 + echo "Supported agents: all, $(join_by ', ' "${VALID_AGENTS[@]}")" >&2 + exit 1 + ;; + esac + done + + if [[ ${#ENABLED_AGENTS[@]} -eq 0 ]]; then + echo "Error: at least one agent must be selected." >&2 + exit 1 + fi +} + +generate_skill() { + local playbook_path="$1" + local target_dir="$2" + local rel_path="$3" + local allowed_tools="${4:-}" + + local name description + name=$(sed -n 's/^name: *//p' "$playbook_path" | head -1) + description=$(sed -n 's/^description: *//p' "$playbook_path" | head -1 | sed 's/^"//;s/"$//') + + if [[ -z "$name" || -z "$description" ]]; then + return + fi + + local skill_dir="$target_dir/$name" + local skill_file="$skill_dir/SKILL.md" + local content + + # Built with printf rather than a heredoc inside $(...): bash 3.2, which is + # /bin/bash on macOS, mis-parses heredocs in command substitutions. + content="$(printf -- '---\nname: %s\ndescription: "%s"\n' "$name" "$description")" + if [[ -n "$allowed_tools" ]]; then + content="$content"$'\n'"$(printf 'allowed-tools: "%s"' "$allowed_tools")" + fi + content="$content"$'\n'"---"$'\n\n'"Read and follow \`.context/playbooks/$rel_path\` in full." + + # Same rule as copy_file: an unchanged wrapper needs no prompt. + if [[ -f "$skill_file" ]] && [[ "$(cat "$skill_file")" == "$content" ]]; then + return + fi + + if ! confirm_overwrite "$skill_file"; then + return + fi + + mkdir -p "$skill_dir" + printf '%s\n' "$content" > "$skill_file" +} + +# Remove generated wrappers whose playbook the library no longer ships. +# +# A wrapper is only a pointer, so once its playbook is renamed or removed it +# advertises a skill that cannot load. Only files that are exactly what +# generate_skill writes - at most seven lines, ending in the "Read and follow" +# pointer - are touched; anything a consumer wrote themselves is left alone. +prune_stale_skills() { + local dir="$1" f target + [[ -d "$dir" ]] || return 0 + for f in "$dir"/*/SKILL.md; do + [[ -f "$f" ]] || continue + [[ "$(wc -l < "$f" | tr -d ' ')" -le 7 ]] || continue + target="$(sed -n 's/^Read and follow `\.context\/playbooks\/\(.*\)` in full\.$/\1/p' "$f" | head -1)" + [[ -n "$target" ]] || continue + [[ -f "$TARGET/.context/playbooks/$target" ]] && continue + rm -f "$f" + rmdir "$(dirname "$f")" 2>/dev/null || true + echo " Removed stale skill wrapper $(basename "$(dirname "$f")") (playbook $target no longer ships)" + done +} + +generate_skills_for_selected_agents() { + local playbook_path="$1" + local rel_path="$2" + local allowed_tools="${3:-Read, Grep, Glob, Bash(git *), Write, Edit, Agent}" + + if agent_enabled claude; then + generate_skill "$playbook_path" "$TARGET/.claude/skills" "$rel_path" "$allowed_tools" + fi + if agent_enabled copilot; then + generate_skill "$playbook_path" "$TARGET/.github/skills" "$rel_path" "" + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --agents) + if [[ $AGENTS_FLAG_PROVIDED -eq 1 ]]; then + echo "Error: --agents provided more than once." >&2 + exit 1 + fi + AGENTS_FLAG_PROVIDED=1 + shift + + while [[ $# -gt 0 ]]; do + case "$1" in + all|claude|copilot|cursor|devin|windsurf) + SELECTED_AGENTS+=("$1") + shift + ;; + --*) + break + ;; + *) + break + ;; + esac + done + + if [[ ${#SELECTED_AGENTS[@]} -eq 0 ]]; then + echo "Error: --agents requires at least one value." >&2 + usage >&2 + exit 1 + fi + ;; + --overwrite) + OVERWRITE_MODE="all" + OVERWRITE_FLAG=1 + shift + ;; + --no-overwrite) + OVERWRITE_MODE="none" + NO_OVERWRITE_FLAG=1 + shift + ;; + --*) + echo "Error: unknown option '$1'" >&2 + echo "" >&2 + usage >&2 + exit 1 + ;; + *) + if [[ -z "$TARGET" ]]; then + TARGET="$1" + else + echo "Error: unexpected argument '$1'" >&2 + usage >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ $OVERWRITE_FLAG -eq 1 && $NO_OVERWRITE_FLAG -eq 1 ]]; then + echo "Error: --overwrite and --no-overwrite are mutually exclusive." >&2 + exit 1 +fi + +print_banner + +if [[ $AGENTS_FLAG_PROVIDED -eq 0 ]]; then + if [[ -t 0 && -t 1 ]]; then + if interactive_select_agents; then + : + else + selector_status=$? + if [[ $selector_status -eq 2 ]]; then + exit 0 + fi + exit "$selector_status" + fi + else + echo "Error: --agents is mandatory in non-interactive mode." >&2 + usage >&2 + exit 1 + fi +fi + +normalise_agents + +if [[ -z "$TARGET" ]]; then + TARGET="." +fi + +if [[ ! -d "$TARGET" ]]; then + printf "Directory '%s' does not exist. Create it? [y/N] " "$TARGET" + read -r answer + case "$answer" in + [yY]|[yY][eE][sS]) + mkdir -p "$TARGET" + echo "Created '$TARGET'" + ;; + *) + echo "Aborted." >&2 + exit 1 + ;; + esac +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[@]}")" + +# A deployment made before versioning has a .context/ but no manifest. Copying +# over it would stamp a manifest claiming the new version onto whatever base +# files the overwrite guard skipped, leave AGENTS.md without a managed block, +# and make migrate refuse to run afterwards because a manifest now exists. +# Upgrade it first: migrate preserves every local edit as an override, and +# refuses to run on a dirty git tree so the result is reviewable as a diff. +if [[ -d "$TARGET/.context" && ! -f "$TARGET/.context/manifest.json" ]] && + [[ -f "$TARGET/.context/index.md" || -d "$TARGET/.context/standards" || -d "$TARGET/.context/playbooks" ]]; then + echo " Existing deployment from before versioning detected — upgrading it first." + echo "" + if ! "$SCRIPT_DIR/migrate.sh" --apply "$TARGET"; then + echo "" >&2 + echo "Error: could not upgrade the existing deployment; nothing else was changed." >&2 + exit 1 + fi + echo "" +fi + +echo " Copying shared context files..." +deploy_agents_md "$SOURCE_ROOT/core/AGENTS.md" "$TARGET/AGENTS.md" "$DEPLOY_VERSION" +# The override subtree is deliberately excluded here and seeded later with +# 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 "$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 "$SOURCE_ROOT/core/.github/copilot-instructions.md" "$TARGET/.github/copilot-instructions.md" +fi + +if agent_enabled cursor; then + echo " Copying Cursor files..." + 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 "$SOURCE_ROOT/core/.devin/devin.json" "$TARGET/.devin/devin.json" +fi + +if agent_enabled windsurf; then + echo " Copying Windsurf files..." + copy_file "$SOURCE_ROOT/core/.windsurfrules" "$TARGET/.windsurfrules" +fi + +echo " Copying standards/ → $TARGET/.context/standards/" +copy_dir_contents "$SOURCE_ROOT/standards" "$TARGET/.context/standards" + +echo " Copying 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..." + if agent_enabled claude; then + echo " → .claude/skills/ (Claude Code)" + mkdir -p "$TARGET/.claude/skills" + fi + if agent_enabled copilot; then + echo " → .github/skills/ (GitHub Copilot)" + mkdir -p "$TARGET/.github/skills" + fi + + for playbook in "$SOURCE_ROOT"/playbooks/assess/*.md; do + filename=$(basename "$playbook") + generate_skills_for_selected_agents "$playbook" "assess/$filename" + done + + 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 "$SOURCE_ROOT"/playbooks/plan/*.md; do + filename=$(basename "$playbook") + generate_skills_for_selected_agents "$playbook" "plan/$filename" + done + + for playbook in "$SOURCE_ROOT"/playbooks/refactor/*.md; do + filename=$(basename "$playbook") + generate_skills_for_selected_agents "$playbook" "refactor/$filename" + done + + 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" \ + "Read, Grep, Glob, Bash, Write, Edit, Agent" + done + fi + + 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 "$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" \ + "Read, Grep, Glob, Bash, Write, Edit, Agent" + done + fi + + if agent_enabled claude; then prune_stale_skills "$TARGET/.claude/skills"; fi + if agent_enabled copilot; then prune_stale_skills "$TARGET/.github/skills"; fi +else + echo " Skipping skill wrapper generation (no selected agent uses skills)." +fi + +# 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 +next_step() { + echo " $step. $1" + step=$((step + 1)) +} + +next_step "Fill in [CONFIGURE] sections in $TARGET/AGENTS.md" + +if agent_enabled claude; then + next_step "Fill in [CONFIGURE] sections in $TARGET/CLAUDE.md" + next_step "Review $TARGET/.claude/settings.json and adjust permissions/hooks" +fi + +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):" + for f in "${SKIPPED_FILES[@]}"; do + echo " - $f" + done +fi diff --git a/scripts/lib/common.ps1 b/scripts/lib/common.ps1 new file mode 100644 index 0000000..ba928b7 --- /dev/null +++ b/scripts/lib/common.ps1 @@ -0,0 +1,526 @@ +# scripts/lib/common.ps1 - shared helpers for deploy, update and migrate. +# +# Portability: must run on Windows PowerShell 5.1 as well as PowerShell 7+. +# No PowerShell 7-only syntax (no ternaries, no ??, no -Parallel). +# +# Dot-source this file; do not execute it. + +# Overridable from the environment, exactly as in common.sh, so a mirror or a +# test fixture can be pointed at without editing the deployed tooling. +$script:AcSourceRepo = 'ldastey-dev/agentic-context' +if ($env:AC_SOURCE_REPO) { $script:AcSourceRepo = $env:AC_SOURCE_REPO } +$script:AcRawBase = 'https://raw.githubusercontent.com' +if ($env:AC_RAW_BASE) { $script:AcRawBase = $env:AC_RAW_BASE } +$script:AcWebBase = 'https://github.com' +if ($env:AC_WEB_BASE) { $script:AcWebBase = $env:AC_WEB_BASE } +$script:AcTimeoutSec = 5 + +# --- hashing --------------------------------------------------------------- + +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() +} + +# Hash a string's UTF-8 bytes (no BOM), matching ac_sha256 over a file with the +# same content. +function Get-AcStringHash { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) + $bytes = (New-Object System.Text.UTF8Encoding($false)).GetBytes($Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($bytes) + } finally { + $sha.Dispose() + } + return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() +} + +# --- text I/O ---------------------------------------------------------------- +# +# Every text read and write goes through these. On Windows PowerShell 5.1, +# Get-Content without -Encoding decodes a BOM-less UTF-8 file as the ANSI code +# page, and Set-Content -Encoding UTF8 writes a BOM. Together they turn every +# em dash in a consumer's AGENTS.md into mojibake on each rewrite, and the BOM +# hides a managed-block marker on line 1 from the bash tooling - so a Windows +# migration followed by a bash update leaves the block unmanaged for good. + +function ConvertTo-AcFullPath { + param([Parameter(Mandatory)][string]$Path) + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) +} + +# Read a text file as UTF-8 (a BOM, if present, is dropped) and return its +# lines without terminators. +function Read-AcTextLines { + param([Parameter(Mandatory)][string]$Path) + $text = [System.IO.File]::ReadAllText((ConvertTo-AcFullPath $Path), [System.Text.Encoding]::UTF8) + if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) } + if ($text.Length -eq 0) { return } + $lines = $text -split "\r?\n" + if ($text.EndsWith("`n")) { $lines = $lines[0..($lines.Count - 2)] } + # Unrolled on purpose so the result pipes line by line; wrap the call in + # @() where an array is required. + return $lines +} + +# True when a file uses CRLF line endings, so a rewrite can keep them. +function Test-AcCrlf { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $text = [System.IO.File]::ReadAllText((ConvertTo-AcFullPath $Path), [System.Text.Encoding]::UTF8) + return $text.Contains("`r`n") +} + +# Write lines as UTF-8 without a BOM, LF-terminated unless -Crlf is given. +function Write-AcTextFile { + param( + [Parameter(Mandatory)][string]$Path, + [AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines = @(), + [switch]$Crlf + ) + $nl = "`n" + if ($Crlf) { $nl = "`r`n" } + $text = '' + if (@($Lines).Count -gt 0) { $text = (@($Lines) -join $nl) + $nl } + [System.IO.File]::WriteAllText((ConvertTo-AcFullPath $Path), $text, (New-Object System.Text.UTF8Encoding($false))) +} + +# --- semver ---------------------------------------------------------------- + +function ConvertTo-AcSemVer { + 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 + # A server that does not label VERSION as text (a mirror, or a plain + # file server) yields byte[] here, which [string] would render as a + # list of numbers and the check would silently never succeed. + $content = $resp.Content + if ($content -is [byte[]]) { $content = [System.Text.Encoding]::UTF8.GetString($content) } + $v = ConvertTo-AcSemVer ([string]$content) + if (Test-AcSemVer $v) { return $v } + return $null + } catch { + 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 ((Read-AcTextLines -Path $Path) -join "`n" | 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 + Write-AcTextFile -Path (Join-Path $ContextDir 'manifest.json') -Lines ($json -split "\r?\n") +} + +# --- managed block --------------------------------------------------------- + +$script:AcBeginMarker = '' + +function Test-AcManagedBlock { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $false } + $lines = @(Read-AcTextLines -Path $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 (Read-AcTextLines -Path $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 + + $crlf = Test-AcCrlf -Path $Destination + foreach ($line in (Read-AcTextLines -Path $Destination)) { + if ($line.StartsWith($script:AcBeginMarker)) { + $inBlock = $true + foreach ($b in $block) { $out.Add($b) } + continue + } + if ($line.StartsWith($script:AcEndMarker)) { + if ($inBlock) { $inBlock = $false; continue } + } + if (-not $inBlock) { $out.Add($line) } + } + + Write-AcTextFile -Path $Destination -Lines $out -Crlf:$crlf +} + +# 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 (Read-AcTextLines -Path $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 +} + +# --- migration baseline ------------------------------------------------------ + +# Load a baseline into path -> list of known hashes. A path may appear more than +# once: the unversioned baseline records every revision the library shipped, +# because unversioned adopters deployed from whichever commit was current. +function Read-AcBaseline { + param([Parameter(Mandatory)][string]$Path) + $map = @{} + foreach ($line in (Read-AcTextLines -Path $Path)) { + if ($line -match '^(\S+)\s+([a-f0-9]+)$') { + if (-not $map.ContainsKey($Matches[1])) { + $map[$Matches[1]] = New-Object System.Collections.Generic.List[string] + } + $map[$Matches[1]].Add($Matches[2]) + } + } + return $map +} + +# Mirrors ac_baseline_match in common.sh: 'pristine' when the hash is a known +# revision of the path, 'edited' when the path is known but the hash is not, +# 'unknown' when the path is not in the baseline at all. +function Get-AcBaselineStatus { + param( + [Parameter(Mandatory)][hashtable]$Baseline, + [Parameter(Mandatory)][string]$RelPath, + [Parameter(Mandatory)][string]$Hash + ) + if (-not $Baseline.ContainsKey($RelPath)) { return 'unknown' } + if ($Baseline[$RelPath].Contains($Hash)) { return 'pristine' } + return 'edited' +} + +# --- AGENTS.md framework region ---------------------------------------------- +# +# Mirrors ac_agents_region in common.sh; the two must stay equivalent. Before +# 1.0.0 the template had no markers, so the framework-authored sections are +# located by heading: from "## Context System" up to, not including, +# "## Project-Specific Rules". +$script:AcAgentsRegionKey = 'AGENTS.md#framework-region' + +# Return the region as LF-joined text (each line LF-terminated), or $null when +# either heading is missing. +function Get-AcAgentsRegion { + param([Parameter(Mandatory)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $null } + $sb = New-Object System.Text.StringBuilder + $started = $false + foreach ($line in (Read-AcTextLines -Path $Path)) { + $l = $line.Replace("`r", '') + if (-not $started -and $l -match '^## Context System\s*$') { $started = $true } + if ($started -and $l.StartsWith('## Project-Specific Rules')) { return $sb.ToString() } + if ($started) { [void]$sb.Append($l).Append("`n") } + } + return $null +} diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..c1c0cd5 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,319 @@ +#!/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 +} + +# Classify one file against a baseline. +# +# A baseline may list a path more than once: the unversioned baseline records +# every revision the library ever shipped for a path, because unversioned +# adopters deployed from whichever commit was current at the time. A file is +# pristine if it matches ANY recorded revision. +# +# ac_baseline_match +# 0 - the hash is a known revision of this path (pristine) +# 1 - the path is known but the hash is not (edited) +# 2 - the path is not in the baseline at all (consumer-added) +ac_baseline_match() { + local baseline="$1" path="$2" hash="$3" + [ -f "$baseline" ] || return 2 + awk -v p="$path" -v h="$hash" ' + $1 == p { known = 1; if ($2 == h) { hit = 1; exit } } + END { if (hit) exit 0; if (known) exit 1; exit 2 } + ' "$baseline" +} + +# --- AGENTS.md framework region ---------------------------------------------- +# +# Before 1.0.0 the template had no markers, so the framework-authored sections +# of a consumer's AGENTS.md cannot be located by marker. They can be located by +# heading: the region runs from "## Context System" up to (not including) +# "## Project-Specific Rules". migrate hashes that region and, when it matches a +# revision the library shipped, replaces it in place with the managed block, so +# the consumer is not left with two copies of every framework section. +# +# The key is not a .context path, so it can never collide with a file entry. +# shellcheck disable=SC2034 # read by migrate.sh and ci/write-unversioned-baseline.sh +AC_AGENTS_REGION_KEY='AGENTS.md#framework-region' + +# Print the region, CR-stripped, or return 1 when either heading is missing. +# Mirrors Get-AcAgentsRegion in common.ps1; the two must stay equivalent. +ac_agents_region() { + local file="$1" + [ -f "$file" ] || return 1 + tr -d '\r' < "$file" | awk ' + /^## Context System[[:space:]]*$/ && !started { started = 1 } + /^## Project-Specific Rules/ && started { ended = 1; exit } + started { print } + END { exit !(started && ended) } + ' +} + +# --- managed block --------------------------------------------------------- +# +# 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..0fa7a69 --- /dev/null +++ b/scripts/migrate.ps1 @@ -0,0 +1,457 @@ +# 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 = Read-AcBaseline -Path $Baseline + +$diverged = New-Object System.Collections.Generic.List[string] +$added = New-Object System.Collections.Generic.List[string] +$nonMd = New-Object System.Collections.Generic.List[string] +$retired = New-Object System.Collections.Generic.List[string] +$totalCount = 0 + +# Where a deployed base path comes from in this checkout. A path with no source +# file is one the library no longer ships. +function Get-AcSourcePath { + param([string]$Rel) + if ($Rel.StartsWith('conventions/')) { return (Join-Path $SourceRoot "core/.context/$Rel") } + if ($Rel -eq 'index.md') { return (Join-Path $SourceRoot 'core/.context/index.md') } + return (Join-Path $SourceRoot $Rel) +} + +foreach ($area in @('standards', 'playbooks', 'conventions')) { + $areaDir = Join-Path $ContextDir $area + if (-not (Test-Path -LiteralPath $areaDir)) { continue } + + $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. + $status = Get-AcBaselineStatus -Baseline $baselineMap -RelPath $rel -Hash (Get-AcFileHashLf -Path $f.FullName) + $shipped = Test-Path -LiteralPath (Get-AcSourcePath $rel) + if ($status -eq 'pristine') { + # Unedited; removed if the library has since stopped shipping it. + if (-not $shipped) { $retired.Add($rel) } + } elseif ($status -eq 'edited' -and $shipped) { + $diverged.Add($rel) + } else { + # Not in the baseline (a file the consumer added), or an edited copy + # of a file the library no longer ships - there is no base left for an + # override to replace, so it is kept as a standalone file. + $added.Add($rel) + } + } +} + +# index.md is base content too. Consumers commonly add their own routes to it, +# and the restore below overwrites it, so an edited copy must be kept. +$indexEdited = $false +$indexPath = Join-Path $ContextDir 'index.md' +if (Test-Path -LiteralPath $indexPath) { + $indexStatus = Get-AcBaselineStatus -Baseline $baselineMap -RelPath 'index.md' -Hash (Get-AcFileHashLf -Path $indexPath) + if ($indexStatus -ne 'pristine') { $indexEdited = $true } +} + +# Non-markdown files. The baseline only covers .md, but the restore below +# replaces each area wholesale, so anything else here is destroyed unless it is +# recognised. A file that also exists in the source tree is a framework +# 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 -and -not $indexEdited) { + 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, or edited files the library no longer ships ($($added.Count))" + Write-Host "- will move to overrides as standalone additions:" + foreach ($rel in $added) { Write-Host " $rel -> .context/overrides/$rel" } + Write-Host "" + } + 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 "" + } +} + +if ($indexEdited) { + Write-Host "index.md has local edits - your copy will be kept as .context/overrides/index.md" + Write-Host " (mode: extend, so routes the library adds still reach you). Trim it to just" + Write-Host " your own routes, or move them to the Additional Context table in AGENTS.md." + Write-Host "" +} + +if ($retired.Count -gt 0) { + Write-Host "Framework files the library no longer ships ($($retired.Count)) - unedited, will be removed:" + foreach ($rel in $retired) { Write-Host " $rel" } + Write-Host "" +} + +# State the destructive behaviour before it happens, not after. +Write-Host "This migration replaces .context/{standards,playbooks,conventions} wholesale." +Write-Host "Anything listed above is preserved under .context/overrides/. A base file you" +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 = @(Read-AcTextLines -Path $src) + Write-AcTextFile -Path $dst -Lines (@($header) + @($body)) + } 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 +} + +if ($indexEdited) { + $indexHeader = @( + '---', + 'overrides: index.md', + 'mode: extend', + '---', + '', + '', + '' + ) + Write-AcTextFile -Path (Join-Path $overrideRoot 'index.md') -Lines (@($indexHeader) + @(Read-AcTextLines -Path $indexPath)) +} + +Write-Host "Restoring base content from $SourceRoot ..." +foreach ($area in $sourceAreas) { + if (-not (Test-Path -LiteralPath $area.From)) { continue } + $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 +} +$srcGitignore = Join-Path $SourceRoot 'core/.context/.gitignore' +if (Test-Path -LiteralPath $srcGitignore) { + Copy-Item -LiteralPath $srcGitignore -Destination (Join-Path $ContextDir '.gitignore') -Force +} +$srcOverrideReadme = Join-Path $SourceRoot 'core/.context/overrides/README.md' +if (Test-Path -LiteralPath $srcOverrideReadme) { + Copy-Item -LiteralPath $srcOverrideReadme -Destination (Join-Path $overrideRoot 'README.md') -Force +} + +$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. When the framework-authored region (## Context System up to +# ## Project-Specific Rules) is exactly as some release of the template shipped +# it, swap it for the managed block in place: the consumer's title and every +# [CONFIGURE] section stay where they are, and nothing is duplicated. When the +# region was edited, or cannot be found, which parts are framework-authored is +# guesswork - so the block is prepended and a human reviews one file instead. +$NewVersion = '0.0.0' +$versionFile = Join-Path $SourceRoot 'VERSION' +if (Test-Path -LiteralPath $versionFile) { + $candidate = ConvertTo-AcSemVer ((Read-AcTextLines -Path $versionFile) -join '') + if (Test-AcSemVer $candidate) { $NewVersion = $candidate } +} + +$agentsFile = Join-Path $Target 'AGENTS.md' +$agentsSrc = Join-Path $SourceRoot 'core/AGENTS.md' +$agentsPrepended = $false + +if (Test-Path -LiteralPath $agentsSrc) { + $block = Get-AcManagedBlock -Path $agentsSrc -Version $NewVersion + if (-not (Test-Path -LiteralPath $agentsFile)) { + $seeded = Read-AcTextLines -Path $agentsSrc | ForEach-Object { + if ($_.StartsWith('" + } else { + $_ + } + } + Write-AcTextFile -Path $agentsFile -Lines $seeded + } elseif (Test-AcManagedBlock -Path $agentsFile) { + Write-Host " AGENTS.md already has a managed block - left as is." + } else { + $region = Get-AcAgentsRegion -Path $agentsFile + $regionPristine = $false + if ($null -ne $region) { + $regionStatus = Get-AcBaselineStatus -Baseline $baselineMap -RelPath $script:AcAgentsRegionKey -Hash (Get-AcStringHash $region) + $regionPristine = ($regionStatus -eq 'pristine') + } + # Line endings are preserved: a CRLF file stays CRLF, so the diff shows + # only the region that changed rather than every line. + $crlf = Test-AcCrlf -Path $agentsFile + $original = @(Read-AcTextLines -Path $agentsFile) + + if ($regionPristine) { + $out = New-Object System.Collections.Generic.List[string] + $state = 0 + foreach ($line in $original) { + if ($state -eq 0 -and $line -match '^## Context System\s*$') { + $state = 1 + foreach ($b in $block) { $out.Add($b) } + $out.Add('') + $out.Add('---') + $out.Add('') + continue + } + if ($state -eq 1 -and $line.StartsWith('## Project-Specific Rules')) { $state = 2 } + if ($state -ne 1) { $out.Add($line) } + } + Write-AcTextFile -Path $agentsFile -Lines $out -Crlf:$crlf + Write-Host " AGENTS.md: framework sections replaced in place by the managed block; your sections untouched." + } else { + $notice = @( + '', + '---', + '', + '', + '' + ) + Write-AcTextFile -Path $agentsFile -Lines (@($block) + $notice + @($original)) -Crlf:$crlf + $agentsPrepended = $true + Write-Host " AGENTS.md: your framework sections were edited, so the managed block was" + Write-Host " prepended and your original content kept below it for review." + } + } +} + +# Record which agents this deployment serves, inferred from the files deploy +# writes for each. An empty list would make the manifest claim no agents, and a +# later deploy or update would have nothing to go on. +$agents = New-Object System.Collections.Generic.List[string] +if ((Test-Path -LiteralPath (Join-Path $Target 'CLAUDE.md')) -or (Test-Path -LiteralPath (Join-Path $Target '.claude/skills'))) { $agents.Add('claude') } +if ((Test-Path -LiteralPath (Join-Path $Target '.github/copilot-instructions.md')) -or (Test-Path -LiteralPath (Join-Path $Target '.github/skills'))) { $agents.Add('copilot') } +if (Test-Path -LiteralPath (Join-Path $Target '.cursor/rules/standards.mdc')) { $agents.Add('cursor') } +if (Test-Path -LiteralPath (Join-Path $Target '.devin/devin.json')) { $agents.Add('devin') } +if (Test-Path -LiteralPath (Join-Path $Target '.windsurfrules')) { $agents.Add('windsurf') } + +# Write the manifest last, so its hashes reflect the final state. +Write-AcManifest -ContextDir $ContextDir -Version $NewVersion -Agents $agents.ToArray() +Write-AcTextFile -Path (Join-Path $ContextDir 'VERSION') -Lines @($NewVersion) + +Write-Host "" +Write-Host "Migration complete. Now on $NewVersion." +Write-Host "" +Write-Host "Review before committing:" +Write-Host " git -C $Target diff --stat" +if ($agentsPrepended) { + Write-Host " $Target/AGENTS.md - remove sections duplicated by the managed block" +} +if ($diverged.Count -gt 0) { + Write-Host " $Target/.context/overrides/ - convert 'mode: replace' to 'mode: extend' where you can" +} diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..2f53582 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,470 @@ +#!/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)" +RETIRED_LIST="$(mktemp)" +cleanup() { rm -f "$DIVERGED_LIST" "$MISSING_LIST" "$TOTAL_LIST" "$NONMD_LIST" "$RETIRED_LIST"; } +trap cleanup EXIT INT TERM + +# Where a deployed base path comes from in this checkout. A path with no source +# file is one the library no longer ships. +source_path_for() { + case "$1" in + conventions/*) printf '%s' "$SOURCE_ROOT/core/.context/$1" ;; + index.md) printf '%s' "$SOURCE_ROOT/core/.context/index.md" ;; + *) printf '%s' "$SOURCE_ROOT/$1" ;; + esac +} + +for area in standards playbooks conventions; do + [ -d "$CONTEXT_DIR/$area" ] || continue + find "$CONTEXT_DIR/$area" -type f -name '*.md' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do + 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")" + status=0 + ac_baseline_match "$BASELINE" "$rel" "$actual" || status=$? + shipped=1 + [ -f "$(source_path_for "$rel")" ] || shipped=0 + case "$status:$shipped" in + 0:1) ;; # pristine, still shipped + 0:0) printf '%s\n' "$rel" >> "$RETIRED_LIST" ;; # pristine, since removed upstream + 1:1) printf '%s\n' "$rel" >> "$DIVERGED_LIST" ;; # edited framework file + # Edited, but the library no longer ships it: there is no base left for an + # override to replace, so keep it as a standalone file. + 1:0) printf '%s\n' "$rel" >> "$MISSING_LIST" ;; + *) printf '%s\n' "$rel" >> "$MISSING_LIST" ;; # not in the baseline: consumer-added + esac + done +done + +# index.md is base content too. Consumers commonly add their own routes to it, +# and the restore below overwrites it, so an edited copy must be kept. +INDEX_EDITED=0 +if [ -f "$CONTEXT_DIR/index.md" ]; then + status=0 + ac_baseline_match "$BASELINE" index.md "$(ac_sha256_lf "$CONTEXT_DIR/index.md")" || status=$? + [ "$status" -eq 0 ] || INDEX_EDITED=1 +fi + +# Non-markdown files. The baseline only covers .md, but the restore below +# replaces each area wholesale, so anything else here is destroyed unless it is +# recognised. A file that also exists in the source tree is a framework +# 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 + +retired_count=$(wc -l < "$RETIRED_LIST" | tr -d ' ') + +if [ "$diverged_count" -eq 0 ] && [ "$added_count" -eq 0 ] && [ "$nonmd_count" -eq 0 ] && [ "$INDEX_EDITED" -eq 0 ]; then + echo "No local modifications detected — this deployment is pristine." +else + if [ "$diverged_count" -gt 0 ]; then + 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, or edited files the library no longer ships ($added_count)" + echo "— will move to overrides as standalone additions:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel -> .context/overrides/$rel" + 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 + +if [ "$INDEX_EDITED" -eq 1 ]; then + echo "index.md has local edits — your copy will be kept as .context/overrides/index.md" + echo " (mode: extend, so routes the library adds still reach you). Trim it to just" + echo " your own routes, or move them to the Additional Context table in AGENTS.md." + echo "" +fi + +if [ "$retired_count" -gt 0 ]; then + echo "Framework files the library no longer ships ($retired_count) — unedited, will be removed:" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + echo " $rel" + done < "$RETIRED_LIST" + echo "" +fi + +# State the destructive behaviour before it happens, not after. +echo "This migration replaces .context/{standards,playbooks,conventions} wholesale." +echo "Anything listed above is preserved under .context/overrides/. A base file you" +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" + +if [ "$INDEX_EDITED" -eq 1 ]; then + { + printf -- '---\n' + printf 'overrides: index.md\n' + printf 'mode: extend\n' + printf -- '---\n\n' + printf '\n\n' + cat "$CONTEXT_DIR/index.md" + } > "$CONTEXT_DIR/overrides/index.md" +fi + +echo "Restoring base content from $SOURCE_ROOT ..." +for pair in "standards:$SOURCE_ROOT/standards" "playbooks:$SOURCE_ROOT/playbooks" "conventions:$SOURCE_ROOT/core/.context/conventions"; do + name="${pair%%:*}" + 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/.gitignore" ] && cp "$SOURCE_ROOT/core/.context/.gitignore" "$CONTEXT_DIR/.gitignore" +[ -f "$SOURCE_ROOT/core/.context/overrides/README.md" ] && cp "$SOURCE_ROOT/core/.context/overrides/README.md" "$CONTEXT_DIR/overrides/README.md" + +mkdir -p "$CONTEXT_DIR/bin/lib" +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. When the framework-authored region (## Context System up to +# ## Project-Specific Rules) is exactly as some release of the template shipped +# it, swap it for the managed block in place: the consumer's title and every +# [CONFIGURE] section stay where they are, and nothing is duplicated. When the +# region was edited, or cannot be found, which parts are framework-authored is +# guesswork - so the block is prepended and a human reviews one file instead. +NEW_VERSION="$(ac_semver_normalise "$(cat "$SOURCE_ROOT/VERSION" 2>/dev/null || echo '0.0.0')")" +AGENTS_FILE="$TARGET/AGENTS.md" +AGENTS_PREPENDED=0 + +if [ -f "$SOURCE_ROOT/core/AGENTS.md" ]; then + block="$(mktemp)" + awk -v b='' ' + index($0, b) == 1 { inblock = 1 } + inblock { print } + index($0, e) == 1 { inblock = 0 } + ' "$SOURCE_ROOT/core/AGENTS.md" \ + | sed "1s|^|" > "$block" + + region_pristine=1 + if [ -f "$AGENTS_FILE" ] && ac_agents_region "$AGENTS_FILE" > "$block.region"; then + ac_baseline_match "$BASELINE" "$AC_AGENTS_REGION_KEY" "$(ac_sha256 "$block.region")" || region_pristine=0 + else + region_pristine=0 + fi + + if [ ! -f "$AGENTS_FILE" ]; then + sed "s|^|" \ + "$SOURCE_ROOT/core/AGENTS.md" > "$AGENTS_FILE" + elif grep -q '^\n\n' + cat "$AGENTS_FILE" + } > "$tmp" + cat "$tmp" > "$AGENTS_FILE" + rm -f "$tmp" + AGENTS_PREPENDED=1 + echo " AGENTS.md: your framework sections were edited, so the managed block was" + echo " prepended and your original content kept below it for review." + fi + rm -f "$block" "$block.region" +fi + +# Record which agents this deployment serves, inferred from the files deploy +# writes for each. An empty list would make the manifest claim no agents, and a +# later deploy or update would have nothing to go on. +AGENTS_JSON="" +add_agent() { if [ -n "$AGENTS_JSON" ]; then AGENTS_JSON="$AGENTS_JSON,"; fi; AGENTS_JSON="$AGENTS_JSON\"$1\""; } +{ [ -f "$TARGET/CLAUDE.md" ] || [ -d "$TARGET/.claude/skills" ]; } && add_agent claude +{ [ -f "$TARGET/.github/copilot-instructions.md" ] || [ -d "$TARGET/.github/skills" ]; } && add_agent copilot +[ -f "$TARGET/.cursor/rules/standards.mdc" ] && add_agent cursor +[ -f "$TARGET/.devin/devin.json" ] && add_agent devin +[ -f "$TARGET/.windsurfrules" ] && add_agent windsurf + +# 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": [%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' +} > "$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" +if [ "$AGENTS_PREPENDED" -eq 1 ]; then + echo " $TARGET/AGENTS.md — remove sections duplicated by the managed block" +fi +if [ "$diverged_count" -gt 0 ]; then + echo " $TARGET/.context/overrides/ — convert 'mode: replace' to 'mode: extend' where you can" +fi 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/scripts/tests/test-deploy.ps1 b/scripts/tests/test-deploy.ps1 new file mode 100644 index 0000000..80d231f --- /dev/null +++ b/scripts/tests/test-deploy.ps1 @@ -0,0 +1,530 @@ +#!/usr/bin/env pwsh +# Test suite for deploy.ps1 - verifies setup/ playbook deployment and regressions. +# +# Usage: +# pwsh ./tests/test-deploy.ps1 +# +# Exit codes: +# 0 All tests passed +# 1 One or more tests failed + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $PSCommandPath +$RepoDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$ScriptsDir = Split-Path -Parent $ScriptDir + +$script:Passed = 0 +$script:Failed = 0 + +function Pass { + param([string]$Label) + Write-Host " PASS: $Label" + $script:Passed++ +} + +function Fail { + param([string]$Label) + Write-Host " FAIL: $Label" + $script:Failed++ +} + +function Assert-FileExists { + param([string]$Label, [string]$Path) + if (Test-Path $Path -PathType Leaf) { + Pass "$Label exists" + } else { + Fail "$Label does not exist: $Path" + } +} + +function Assert-FileNotExists { + param([string]$Label, [string]$Path) + if (-not (Test-Path $Path -PathType Leaf)) { + Pass "$Label does not exist (expected)" + } else { + Fail "$Label unexpectedly exists: $Path" + } +} + +function Assert-DirNotExists { + param([string]$Label, [string]$Path) + if (-not (Test-Path $Path -PathType Container)) { + Pass "$Label directory does not exist (expected)" + } else { + Fail "$Label directory unexpectedly exists: $Path" + } +} + +function Assert-Executable { + param([string]$Label, [string]$Path) + if ($IsLinux -or $IsMacOS) { + if (Test-Path $Path) { + $mode = (Get-Item $Path).UnixMode + if ($mode -match 'x') { + Pass "$Label is executable" + } else { + Fail "$Label is not executable: $Path" + } + } else { + Fail "$Label does not exist: $Path" + } + } else { + # On Windows, skip execute bit check + Pass "$Label executable check skipped (Windows)" + } +} + +function Assert-Contains { + param([string]$Label, [string]$Path, [string]$Expected) + if (Test-Path $Path) { + $content = Get-Content $Path -Raw + if ($content -match [regex]::Escape($Expected)) { + Pass "$Label contains '$Expected'" + } else { + Fail "$Label does not contain '$Expected'" + } + } else { + Fail "$Label file not found: $Path" + } +} + +function Assert-NotContains { + param([string]$Label, [string]$Path, [string]$Unexpected) + if (Test-Path $Path) { + $content = Get-Content $Path -Raw + if ($content -notmatch [regex]::Escape($Unexpected)) { + Pass "$Label does not contain '$Unexpected'" + } else { + Fail "$Label unexpectedly contains '$Unexpected'" + } + } else { + Fail "$Label file not found: $Path" + } +} + +# ======================================================================= +# TC1: Fresh deploy - all agents +# ======================================================================= +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 +& "$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" +Assert-FileExists "discover-local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/discover-local-otel-stack.md" +Assert-FileExists "use-local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/use-local-otel-stack.md" +Assert-FileNotExists "instrument-dotnet-otel.md (migrated to standard)" "$tc1Dir/.context/playbooks/setup/instrument-dotnet-otel.md" + +Write-Host " --- OTel standards ---" +Assert-FileExists "opentelemetry.md" "$tc1Dir/.context/standards/opentelemetry.md" +Assert-FileExists "opentelemetry-dotnet.md" "$tc1Dir/.context/standards/opentelemetry-dotnet.md" + +Write-Host " --- Debugging standard and playbooks ---" +Assert-FileExists "debugging.md" "$tc1Dir/.context/standards/debugging.md" +Assert-FileExists "debug/scientific-debugging.md" "$tc1Dir/.context/playbooks/debug/scientific-debugging.md" +Assert-FileExists "plan/research.md" "$tc1Dir/.context/playbooks/plan/research.md" + +Write-Host " --- Companion scripts ---" +Assert-FileExists "start-local-otel-stack.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/start-local-otel-stack.sh" +Assert-Executable "start-local-otel-stack.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/start-local-otel-stack.sh" +Assert-FileExists "test-local-otel-stack.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/test-local-otel-stack.sh" +Assert-Executable "test-local-otel-stack.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/test-local-otel-stack.sh" +Assert-FileExists "validate-config.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" +Assert-Executable "validate-config.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" + +Write-Host " --- Non-executable files ---" +Assert-FileExists "Start-LocalOtelStack.ps1" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/Start-LocalOtelStack.ps1" +Assert-FileExists "versions.env" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/versions.env" + +Write-Host " --- Claude thin wrappers ---" +Assert-FileExists "claude/setup-create-local-otel-stack" "$tc1Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" +Assert-FileExists "claude/setup-discover-local-otel-stack" "$tc1Dir/.claude/skills/setup-discover-local-otel-stack/SKILL.md" +Assert-FileExists "claude/setup-use-local-otel-stack" "$tc1Dir/.claude/skills/setup-use-local-otel-stack/SKILL.md" +Assert-FileNotExists "claude/setup-instrument-dotnet-otel (removed - migrated to standard)" "$tc1Dir/.claude/skills/setup-instrument-dotnet-otel/SKILL.md" +Assert-FileExists "claude/debug-scientific-debugging" "$tc1Dir/.claude/skills/debug-scientific-debugging/SKILL.md" +Assert-FileExists "claude/plan-research" "$tc1Dir/.claude/skills/plan-research/SKILL.md" +Assert-Contains "claude debug wrapper has playbook path" "$tc1Dir/.claude/skills/debug-scientific-debugging/SKILL.md" ".context/playbooks/debug/scientific-debugging.md" + +Write-Host " --- Copilot thin wrappers ---" +Assert-FileExists "copilot/setup-create-local-otel-stack" "$tc1Dir/.github/skills/setup-create-local-otel-stack/SKILL.md" +Assert-FileExists "copilot/setup-discover-local-otel-stack" "$tc1Dir/.github/skills/setup-discover-local-otel-stack/SKILL.md" +Assert-FileExists "copilot/setup-use-local-otel-stack" "$tc1Dir/.github/skills/setup-use-local-otel-stack/SKILL.md" +Assert-FileNotExists "copilot/setup-instrument-dotnet-otel (removed - migrated to standard)" "$tc1Dir/.github/skills/setup-instrument-dotnet-otel/SKILL.md" +Assert-FileExists "copilot/debug-scientific-debugging" "$tc1Dir/.github/skills/debug-scientific-debugging/SKILL.md" +Assert-FileExists "copilot/plan-research" "$tc1Dir/.github/skills/plan-research/SKILL.md" +Assert-NotContains "copilot debug wrapper no allowed-tools" "$tc1Dir/.github/skills/debug-scientific-debugging/SKILL.md" "allowed-tools:" + +Write-Host " --- allowed-tools check ---" +Assert-Contains "claude wrapper allowed-tools" "$tc1Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" "allowed-tools:" +Assert-NotContains "claude wrapper no git-only bash" "$tc1Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" "Bash(git *)" + +Write-Host " --- Safety and provenance ---" +Assert-Contains "local-dev-only warning" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack.md" "Local development and testing only" +Assert-Contains "provenance comment" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack.md" "Ported from devopsin" + +Write-Host " --- Index routing ---" +Assert-Contains "index has setup playbooks" "$tc1Dir/.context/index.md" "playbooks/setup/" + +Write-Host " --- Negative ---" +Assert-FileNotExists "local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/local-otel-stack.md" + +Remove-Item -Recurse -Force $tc1Dir + +# ======================================================================= +# TC2: Agent-scoped deploy - Claude only +# ======================================================================= +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 +& "$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" + +Remove-Item -Recurse -Force $tc2Dir + +# ======================================================================= +# TC3: Agent-scoped deploy - Copilot only +# ======================================================================= +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 +& "$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" + +Remove-Item -Recurse -Force $tc3Dir + +# ======================================================================= +# TC4: No regressions - existing thin wrappers +# ======================================================================= +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 +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc4Dir *>$null + +Assert-FileExists "assess-observability" "$tc4Dir/.claude/skills/assess-observability/SKILL.md" + +Remove-Item -Recurse -Force $tc4Dir + +# ======================================================================= +# TC5: Relative -TargetRepo survives [Environment]::CurrentDirectory corruption +# ======================================================================= +# Add-Type (used by Enable-VirtualTerminal for the interactive agent menu) resets +# [Environment]::CurrentDirectory as a side effect on Windows. This reproduces that +# corruption directly (no real interactive console needed) and proves a relative +# -TargetRepo still resolves against PowerShell's actual working directory rather +# than silently landing under the corrupted one. +Write-Host "" +Write-Host "=== TC5: Relative -TargetRepo survives CurrentDirectory corruption ===" +$tc5Base = Join-Path ([System.IO.Path]::GetTempPath()) "tc5-$([guid]::NewGuid().ToString('N').Substring(0,8))" +$tc5Launch = Join-Path $tc5Base "launch" +$tc5CorruptParent = Join-Path $tc5Base "corrupt-parent" +$tc5Corrupt = Join-Path $tc5CorruptParent "corrupt" +$tc5CorrectTarget = Join-Path $tc5Base "reltarget" +New-Item -ItemType Directory -Path $tc5Launch, $tc5Corrupt, $tc5CorrectTarget -Force | Out-Null + +$originalCwd = (Get-Location).Path +$originalCurrentDirectory = [Environment]::CurrentDirectory +try { + Set-Location $tc5Launch + [Environment]::CurrentDirectory = $tc5Corrupt + & "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target "../reltarget" *>$null +} finally { + Set-Location $originalCwd + [Environment]::CurrentDirectory = $originalCurrentDirectory +} + +Assert-FileExists "AGENTS.md deployed relative to launch dir" "$tc5CorrectTarget/AGENTS.md" +Assert-FileNotExists "AGENTS.md NOT deployed relative to corrupted CurrentDirectory" "$tc5CorruptParent/reltarget/AGENTS.md" + +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() +# Parsed rather than string-matched: Windows PowerShell 5.1's ConvertTo-Json +# pads the colon with two spaces, so a literal match fails there. +$tc6Manifest = Get-Content (Join-Path $tc6Dir '.context/manifest.json') -Raw | ConvertFrom-Json +if ($tc6Manifest.version -eq $tc6Version) { + Pass "manifest records the current version" +} else { + Fail "manifest records '$($tc6Manifest.version)', expected '$tc6Version'" +} + +Assert-FileExists "override layer README" "$tc6Dir/.context/overrides/README.md" + +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. +# In-process rather than via pwsh, so the Windows PowerShell 5.1 job really +# exercises update.ps1 on 5.1. +$tc6Status = & "$tc6Dir/.context/bin/update.ps1" -Status *>&1 | Out-String +if ($tc6Status -match [regex]::Escape("agentic-context $tc6Version")) { + Pass "update.ps1 -Status reports the deployed version" +} else { + 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 + +# ======================================================================= +# Helpers for the upgrade-path cases: turn a fresh deployment into what a +# pre-versioning deploy left - no manifest, tooling or override layer, and an +# AGENTS.md without markers. Mirrors make_unversioned in test-deploy.sh. +# ======================================================================= +function ConvertTo-Unversioned { + param([string]$Dir) + foreach ($p in @('.context/manifest.json', '.context/VERSION', '.context/bin', '.context/overrides', '.context/.gitignore')) { + $full = Join-Path $Dir $p + if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force } + } + $agents = Join-Path $Dir 'AGENTS.md' + $out = New-Object System.Collections.Generic.List[string] + $skip = $false + foreach ($line in [System.IO.File]::ReadAllLines($agents, [System.Text.Encoding]::UTF8)) { + if ($line.StartsWith('')) { continue } + if ($line.StartsWith('\s*$') { $skip = $false }; continue } + $out.Add($line) + } + [System.IO.File]::WriteAllText($agents, (($out -join "`n") + "`n"), (New-Object System.Text.UTF8Encoding($false))) +} + +function New-GitDir { + param([string]$Prefix) + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ($Prefix + [System.Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + & git -C $dir init -q 2>$null + & git -C $dir config user.email t@t + & git -C $dir config user.name t + # Git for Windows defaults to core.autocrlf=true, which prints a warning per + # file on add; Windows PowerShell 5.1 stalls redirecting that much native + # stderr. The fixtures are LF and the line-ending cases are tested directly. + & git -C $dir config core.autocrlf false + & git -C $dir config core.safecrlf false + return $dir +} + +function Save-GitState { + param([string]$Dir) + & git -C $Dir add -A 2>$null + & git -C $Dir commit -qm init 2>$null | Out-Null +} + +function Get-TestSha256 { + param([string]$Path) + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +. (Join-Path $ScriptsDir 'lib/common.ps1') + +# ======================================================================= +# TC8: migrate recognises content from any earlier revision +# Mirrors TC17 in test-deploy.sh. +# ======================================================================= +Write-Host "" +Write-Host "=== TC8: migrate across historical revisions ===" +$tc8Dir = New-GitDir 'ac-tc8-' +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc8Dir *>$null +ConvertTo-Unversioned -Dir $tc8Dir + +$tc8Baseline = Join-Path ([System.IO.Path]::GetTempPath()) ("ac-tc8-baseline-" + [System.Guid]::NewGuid().ToString("N")) +Copy-Item -LiteralPath (Join-Path $ScriptsDir 'baselines/unversioned.sha256') -Destination $tc8Baseline +$tc8Testing = Join-Path $tc8Dir '.context/standards/testing.md' +[System.IO.File]::WriteAllText($tc8Testing, "older revision`n") +$tc8Retired = Join-Path $tc8Dir '.context/playbooks/setup/retired.md' +[System.IO.File]::WriteAllText($tc8Retired, "retired playbook`n") +$tc8Region = Get-AcAgentsRegion -Path (Join-Path $tc8Dir 'AGENTS.md') +$tc8Extra = @( + "standards/testing.md $(Get-TestSha256 $tc8Testing)", + "playbooks/setup/retired.md $(Get-TestSha256 $tc8Retired)", + "$($script:AcAgentsRegionKey) $(Get-AcStringHash $tc8Region)" +) +[System.IO.File]::AppendAllText($tc8Baseline, (($tc8Extra -join "`n") + "`n")) +# A consumer route added to the index, and non-ASCII text that must survive. +$tc8Index = Join-Path $tc8Dir '.context/index.md' +$tc8Dash = [string][char]0x2014 +[System.IO.File]::AppendAllText($tc8Index, "| billing | .context/overrides/standards/billing.md | ours $tc8Dash mine |`n") +$tc8AgentsPath = Join-Path $tc8Dir 'AGENTS.md' +$tc8DashesBefore = ([regex]::Matches([System.IO.File]::ReadAllText($tc8AgentsPath, [System.Text.Encoding]::UTF8), [string][char]0x2014)).Count +Save-GitState -Dir $tc8Dir + +& "$ScriptsDir/migrate.ps1" -Target $tc8Dir -Apply -Baseline $tc8Baseline *>$null + +$tc8RepoTesting = Join-Path $RepoDir 'standards/testing.md' +if (-not (Test-Path -LiteralPath (Join-Path $tc8Dir '.context/overrides/standards/testing.md')) -and + ((Get-TestSha256 $tc8Testing) -eq (Get-TestSha256 $tc8RepoTesting))) { + Pass "migrate: an earlier revision is pristine, restored and not promoted" +} else { + Fail "migrate: an earlier revision was treated as a consumer edit" +} + +if (-not (Test-Path -LiteralPath $tc8Retired) -and + -not (Test-Path -LiteralPath (Join-Path $tc8Dir '.context/overrides/playbooks/setup/retired.md'))) { + Pass "migrate: an unedited file the library no longer ships is removed" +} else { + Fail "migrate: a retired framework file was kept as if the consumer wrote it" +} + +Assert-Contains "edited index.md kept as an extend override" (Join-Path $tc8Dir '.context/overrides/index.md') "mode: extend" + +$tc8Agents = [System.IO.File]::ReadAllText($tc8AgentsPath, [System.Text.Encoding]::UTF8) +$tc8Lines = $tc8Agents -split "`n" +if (([regex]::Matches($tc8Agents, '(?m)^## Context System')).Count -eq 1 -and + $tc8Lines[0] -eq '# AGENTS.md' -and + $tc8Agents.Contains('')) { + Pass "migrate: AGENTS.md framework sections replaced in place, not duplicated" +} else { + Fail "migrate: AGENTS.md was not converted in place" +} + +# Windows PowerShell 5.1 regressions: Set-Content -Encoding UTF8 wrote a BOM, +# and Get-Content decoded UTF-8 as ANSI, turning every em dash into mojibake. +$tc8Bytes = [System.IO.File]::ReadAllBytes($tc8AgentsPath) +if ($tc8Bytes.Length -ge 3 -and $tc8Bytes[0] -eq 0xEF -and $tc8Bytes[1] -eq 0xBB -and $tc8Bytes[2] -eq 0xBF) { + Fail "migrate: AGENTS.md was written with a BOM" +} else { + Pass "migrate: AGENTS.md written without a BOM" +} +$tc8DashesAfter = ([regex]::Matches($tc8Agents, [string][char]0x2014)).Count +if ($tc8DashesAfter -ge $tc8DashesBefore -and -not $tc8Agents.Contains([string][char]0x00E2)) { + Pass "migrate: non-ASCII text in AGENTS.md survives intact" +} else { + Fail "migrate: non-ASCII text in AGENTS.md was corrupted" +} + +$tc8Manifest = Get-AcManifest -Path (Join-Path $tc8Dir '.context/manifest.json') +if ($tc8Manifest -and @($tc8Manifest.agents) -contains 'claude') { + Pass "migrate: manifest records the agents inferred from the deployment" +} else { + Fail "migrate: manifest agents were not inferred" +} + +Assert-Contains "update-check stamp git-ignored" (Join-Path $tc8Dir '.context/.gitignore') "last-update-check" + +Remove-Item -Recurse -Force $tc8Dir +Remove-Item -Force $tc8Baseline + +# ======================================================================= +# TC9: re-running deploy over a pre-versioning deployment upgrades it +# Mirrors TC18 in test-deploy.sh. +# ======================================================================= +Write-Host "" +Write-Host "=== TC9: deploy over an unversioned deployment ===" +$tc9Dir = New-GitDir 'ac-tc9-' +& "$ScriptsDir/deploy.ps1" -Agents claude -Overwrite -Target $tc9Dir *>$null +ConvertTo-Unversioned -Dir $tc9Dir +[System.IO.File]::AppendAllText((Join-Path $tc9Dir '.context/standards/security.md'), "MY LOCAL EDIT`n") +$tc9Stale = Join-Path $tc9Dir '.claude/skills/old-gone' +New-Item -ItemType Directory -Path $tc9Stale -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $tc9Stale 'SKILL.md'), "---`nname: old-gone`ndescription: `"x`"`n---`n`nRead and follow ``.context/playbooks/assess/old-gone.md`` in full.`n") +$tc9Mine = Join-Path $tc9Dir '.claude/skills/my-skill' +New-Item -ItemType Directory -Path $tc9Mine -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $tc9Mine 'SKILL.md'), "My own skill, which points at .context/playbooks/assess/not-there.md`n") +Save-GitState -Dir $tc9Dir + +[System.IO.File]::WriteAllText((Join-Path $tc9Dir 'dirty.txt'), "dirty`n") +& "$ScriptsDir/deploy.ps1" -Agents claude -NoOverwrite -Target $tc9Dir *>$null +if (-not (Test-Path -LiteralPath (Join-Path $tc9Dir '.context/manifest.json'))) { + Pass "deploy: refuses to upgrade a dirty tree and writes nothing" +} else { + Fail "deploy: upgraded an unversioned deployment on a dirty tree" +} +Remove-Item -LiteralPath (Join-Path $tc9Dir 'dirty.txt') -Force + +$tc9Out = & "$ScriptsDir/deploy.ps1" -Agents claude -NoOverwrite -Target $tc9Dir *>&1 | Out-String + +$tc9Override = Join-Path $tc9Dir '.context/overrides/standards/security.md' +if ((Test-Path -LiteralPath (Join-Path $tc9Dir '.context/manifest.json')) -and + (Test-Path -LiteralPath $tc9Override) -and + ([System.IO.File]::ReadAllText($tc9Override)).Contains('MY LOCAL EDIT') -and + -not ([System.IO.File]::ReadAllText((Join-Path $tc9Dir '.context/standards/security.md'))).Contains('MY LOCAL EDIT')) { + Pass "deploy: unversioned deployment migrated, local edit preserved as an override" +} else { + Fail "deploy: unversioned deployment was not migrated" +} + +Assert-Contains "AGENTS.md gained the managed block" (Join-Path $tc9Dir 'AGENTS.md') "agentic-context:begin" + +if ($tc9Out -notmatch 'Skipped files') { + Pass "deploy: unchanged files are not reported as skipped" +} else { + Fail "deploy: identical files were reported as skipped" +} + +if (-not (Test-Path -LiteralPath $tc9Stale) -and (Test-Path -LiteralPath (Join-Path $tc9Mine 'SKILL.md'))) { + Pass "deploy: stale generated wrapper pruned, consumer skill untouched" +} else { + Fail "deploy: skill wrapper pruning removed the wrong files" +} + +Remove-Item -Recurse -Force $tc9Dir + +# ======================================================================= +# Summary +# ======================================================================= +Write-Host "" +Write-Host "=== Results ===" +Write-Host " Passed: $($script:Passed)" +Write-Host " Failed: $($script:Failed)" + +if ($script:Failed -gt 0) { + Write-Host "" + Write-Host "TEST SUITE FAILED" + exit 1 +} else { + Write-Host "" + Write-Host "TEST SUITE PASSED" + exit 0 +} diff --git a/scripts/tests/test-deploy.sh b/scripts/tests/test-deploy.sh new file mode 100755 index 0000000..7b3ed47 --- /dev/null +++ b/scripts/tests/test-deploy.sh @@ -0,0 +1,1003 @@ +#!/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" + +# ═══════════════════════════════════════════════════════════════════════ +# Helper: turn a fresh deployment into what a pre-versioning deploy left. +# No manifest, no tooling, no override layer, and an AGENTS.md without markers. +# ═══════════════════════════════════════════════════════════════════════ +make_unversioned() { + local dir="$1" tmp + rm -rf "$dir/.context/manifest.json" "$dir/.context/VERSION" "$dir/.context/bin" \ + "$dir/.context/overrides" "$dir/.context/.gitignore" + tmp="$(mktemp)" + awk ' + /^/ { next } + /^[[:space:]]*$/) skip = 0; next } + { print } + ' "$dir/AGENTS.md" > "$tmp" + cat "$tmp" > "$dir/AGENTS.md" + rm -f "$tmp" +} + +# sha256 of a file's contents, portable across macOS and Linux. +sha_file() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1"; else sha256sum "$1"; fi | awk '{print $1}'; } + +# ═══════════════════════════════════════════════════════════════════════ +# TC17: migrate recognises content from any earlier revision +# +# Unversioned adopters deployed from whichever commit was current, so a file +# matching ANY revision the library shipped is pristine. A single-snapshot +# baseline turned every file the library had since improved into a pinned +# "mode: replace" override, and kept files the library had since removed as if +# the consumer had written them. The framework sections of AGENTS.md are +# swapped for the managed block in place rather than duplicated. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC17: migrate across historical revisions ===" + +TC17_DIR=$(mktemp -d) +(cd "$TC17_DIR" && git init -q . && git config user.email t@t && git config user.name t) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC17_DIR" >/dev/null 2>&1 +make_unversioned "$TC17_DIR" + +TC17_BASELINE="$(mktemp)" +cp "$SCRIPTS_DIR/baselines/unversioned.sha256" "$TC17_BASELINE" +# An older revision of a file that still ships. +printf 'older revision\n' > "$TC17_DIR/.context/standards/testing.md" +printf 'standards/testing.md %s\n' "$(sha_file "$TC17_DIR/.context/standards/testing.md")" >> "$TC17_BASELINE" +# A file the library used to ship and no longer does. +printf 'retired playbook\n' > "$TC17_DIR/.context/playbooks/setup/retired.md" +printf 'playbooks/setup/retired.md %s\n' "$(sha_file "$TC17_DIR/.context/playbooks/setup/retired.md")" >> "$TC17_BASELINE" +# The framework region of this AGENTS.md, as an earlier template shipped it. +( + # shellcheck source=scripts/lib/common.sh + . "$SCRIPTS_DIR/lib/common.sh" + region="$(mktemp)" + ac_agents_region "$TC17_DIR/AGENTS.md" > "$region" + printf '%s %s\n' "$AC_AGENTS_REGION_KEY" "$(sha_file "$region")" >> "$TC17_BASELINE" + rm -f "$region" +) +# A consumer route added to the index. +echo "| billing | .context/overrides/standards/billing.md | ours |" >> "$TC17_DIR/.context/index.md" +(cd "$TC17_DIR" && git add -A >/dev/null 2>&1 && git commit -qm init >/dev/null 2>&1) + +TC17_OUT="$("$SCRIPTS_DIR/migrate.sh" --apply --baseline "$TC17_BASELINE" "$TC17_DIR" 2>&1)" || true + +if [ ! -e "$TC17_DIR/.context/overrides/standards/testing.md" ] \ + && cmp -s "$TC17_DIR/.context/standards/testing.md" "$REPO_DIR/standards/testing.md"; then + pass "migrate: an earlier revision is pristine, restored and not promoted" +else + fail "migrate: an earlier revision was treated as a consumer edit (output: $TC17_OUT)" +fi + +if [ ! -e "$TC17_DIR/.context/playbooks/setup/retired.md" ] \ + && [ ! -e "$TC17_DIR/.context/overrides/playbooks/setup/retired.md" ]; then + pass "migrate: an unedited file the library no longer ships is removed" +else + fail "migrate: a retired framework file was kept as if the consumer wrote it" +fi + +if grep -q '^mode: extend' "$TC17_DIR/.context/overrides/index.md" 2>/dev/null \ + && grep -q 'billing' "$TC17_DIR/.context/overrides/index.md"; then + pass "migrate: an edited index.md is kept as an extend override" +else + fail "migrate: consumer routes in index.md were lost" +fi + +if [ "$(grep -c '^## Context System' "$TC17_DIR/AGENTS.md")" = "1" ] \ + && [ "$(head -1 "$TC17_DIR/AGENTS.md")" = "# AGENTS.md" ] \ + && grep -q '^' "$TC17_DIR/AGENTS.md"; then + pass "migrate: AGENTS.md framework sections replaced in place, not duplicated" +else + fail "migrate: AGENTS.md was not converted in place" +fi + +if grep -q '"agents": \["claude"\]' "$TC17_DIR/.context/manifest.json"; then + pass "migrate: manifest records the agents inferred from the deployment" +else + fail "migrate: manifest agents were not inferred" +fi + +if [ -f "$TC17_DIR/.context/.gitignore" ] && grep -q 'last-update-check' "$TC17_DIR/.context/.gitignore"; then + pass "migrate: update-check stamp is git-ignored" +else + fail "migrate: .context/.gitignore was not installed" +fi + +rm -rf "$TC17_DIR" "$TC17_BASELINE" + +# ═══════════════════════════════════════════════════════════════════════ +# TC18: re-running deploy over a pre-versioning deployment upgrades it +# +# Most adopters will simply pull the library and re-run deploy, as they always +# have. Doing so used to stamp a 1.0.0 manifest over stale base files, leave +# AGENTS.md unmanaged, and make migrate refuse to run afterwards. +# ═══════════════════════════════════════════════════════════════════════ +echo "" +echo "=== TC18: deploy over an unversioned deployment ===" + +TC18_DIR=$(mktemp -d) +(cd "$TC18_DIR" && git init -q . && git config user.email t@t && git config user.name t) +"$SCRIPTS_DIR/deploy.sh" --agents claude --overwrite "$TC18_DIR" >/dev/null 2>&1 +make_unversioned "$TC18_DIR" +echo "MY LOCAL EDIT" >> "$TC18_DIR/.context/standards/security.md" +mkdir -p "$TC18_DIR/.claude/skills/old-gone" +printf -- '---\nname: old-gone\ndescription: "x"\n---\n\nRead and follow `.context/playbooks/assess/old-gone.md` in full.\n' \ + > "$TC18_DIR/.claude/skills/old-gone/SKILL.md" +mkdir -p "$TC18_DIR/.claude/skills/my-skill" +printf 'My own skill, which points at .context/playbooks/assess/not-there.md\n' > "$TC18_DIR/.claude/skills/my-skill/SKILL.md" +(cd "$TC18_DIR" && git add -A >/dev/null 2>&1 && git commit -qm init >/dev/null 2>&1) + +echo "dirty" > "$TC18_DIR/dirty.txt" +if ! "$SCRIPTS_DIR/deploy.sh" --agents claude --no-overwrite "$TC18_DIR" >/dev/null 2>&1 \ + && [ ! -f "$TC18_DIR/.context/manifest.json" ]; then + pass "deploy: refuses to upgrade a dirty tree and writes nothing" +else + fail "deploy: upgraded an unversioned deployment on a dirty tree" +fi +rm -f "$TC18_DIR/dirty.txt" + +TC18_OUT="$("$SCRIPTS_DIR/deploy.sh" --agents claude --no-overwrite "$TC18_DIR" 2>&1)" || true + +if [ -f "$TC18_DIR/.context/manifest.json" ] \ + && grep -q 'MY LOCAL EDIT' "$TC18_DIR/.context/overrides/standards/security.md" 2>/dev/null \ + && ! grep -q 'MY LOCAL EDIT' "$TC18_DIR/.context/standards/security.md"; then + pass "deploy: unversioned deployment migrated, local edit preserved as an override" +else + fail "deploy: unversioned deployment was not migrated (output: $TC18_OUT)" +fi + +if grep -q '^' ' + 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.ps1 b/tests/test-deploy.ps1 deleted file mode 100644 index f325c50..0000000 --- a/tests/test-deploy.ps1 +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env pwsh -# Test suite for deploy.ps1 — verifies setup/ playbook deployment and regressions. -# -# Usage: -# pwsh ./tests/test-deploy.ps1 -# -# Exit codes: -# 0 All tests passed -# 1 One or more tests failed - -$ErrorActionPreference = "Stop" - -$ScriptDir = Split-Path -Parent $PSCommandPath -$RepoDir = Split-Path -Parent $ScriptDir - -$script:Passed = 0 -$script:Failed = 0 - -function Pass { - param([string]$Label) - Write-Host " PASS: $Label" - $script:Passed++ -} - -function Fail { - param([string]$Label) - Write-Host " FAIL: $Label" - $script:Failed++ -} - -function Assert-FileExists { - param([string]$Label, [string]$Path) - if (Test-Path $Path -PathType Leaf) { - Pass "$Label exists" - } else { - Fail "$Label does not exist: $Path" - } -} - -function Assert-FileNotExists { - param([string]$Label, [string]$Path) - if (-not (Test-Path $Path -PathType Leaf)) { - Pass "$Label does not exist (expected)" - } else { - Fail "$Label unexpectedly exists: $Path" - } -} - -function Assert-DirNotExists { - param([string]$Label, [string]$Path) - if (-not (Test-Path $Path -PathType Container)) { - Pass "$Label directory does not exist (expected)" - } else { - Fail "$Label directory unexpectedly exists: $Path" - } -} - -function Assert-Executable { - param([string]$Label, [string]$Path) - if ($IsLinux -or $IsMacOS) { - if (Test-Path $Path) { - $mode = (Get-Item $Path).UnixMode - if ($mode -match 'x') { - Pass "$Label is executable" - } else { - Fail "$Label is not executable: $Path" - } - } else { - Fail "$Label does not exist: $Path" - } - } else { - # On Windows, skip execute bit check - Pass "$Label executable check skipped (Windows)" - } -} - -function Assert-Contains { - param([string]$Label, [string]$Path, [string]$Expected) - if (Test-Path $Path) { - $content = Get-Content $Path -Raw - if ($content -match [regex]::Escape($Expected)) { - Pass "$Label contains '$Expected'" - } else { - Fail "$Label does not contain '$Expected'" - } - } else { - Fail "$Label file not found: $Path" - } -} - -function Assert-NotContains { - param([string]$Label, [string]$Path, [string]$Unexpected) - if (Test-Path $Path) { - $content = Get-Content $Path -Raw - if ($content -notmatch [regex]::Escape($Unexpected)) { - Pass "$Label does not contain '$Unexpected'" - } else { - Fail "$Label unexpectedly contains '$Unexpected'" - } - } else { - Fail "$Label file not found: $Path" - } -} - -# ═══════════════════════════════════════════════════════════════════════ -# TC1: Fresh deploy — all agents -# ═══════════════════════════════════════════════════════════════════════ -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 - -Write-Host " --- Playbook files ---" -Assert-FileExists "create-local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack.md" -Assert-FileExists "discover-local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/discover-local-otel-stack.md" -Assert-FileExists "use-local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/use-local-otel-stack.md" -Assert-FileNotExists "instrument-dotnet-otel.md (migrated to standard)" "$tc1Dir/.context/playbooks/setup/instrument-dotnet-otel.md" - -Write-Host " --- OTel standards ---" -Assert-FileExists "opentelemetry.md" "$tc1Dir/.context/standards/opentelemetry.md" -Assert-FileExists "opentelemetry-dotnet.md" "$tc1Dir/.context/standards/opentelemetry-dotnet.md" - -Write-Host " --- Debugging standard and playbooks ---" -Assert-FileExists "debugging.md" "$tc1Dir/.context/standards/debugging.md" -Assert-FileExists "debug/scientific-debugging.md" "$tc1Dir/.context/playbooks/debug/scientific-debugging.md" -Assert-FileExists "plan/research.md" "$tc1Dir/.context/playbooks/plan/research.md" - -Write-Host " --- Companion scripts ---" -Assert-FileExists "start-local-otel-stack.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/start-local-otel-stack.sh" -Assert-Executable "start-local-otel-stack.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/start-local-otel-stack.sh" -Assert-FileExists "test-local-otel-stack.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/test-local-otel-stack.sh" -Assert-Executable "test-local-otel-stack.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/test-local-otel-stack.sh" -Assert-FileExists "validate-config.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" -Assert-Executable "validate-config.sh" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/validate-config.sh" - -Write-Host " --- Non-executable files ---" -Assert-FileExists "Start-LocalOtelStack.ps1" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/Start-LocalOtelStack.ps1" -Assert-FileExists "versions.env" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack/versions.env" - -Write-Host " --- Claude thin wrappers ---" -Assert-FileExists "claude/setup-create-local-otel-stack" "$tc1Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" -Assert-FileExists "claude/setup-discover-local-otel-stack" "$tc1Dir/.claude/skills/setup-discover-local-otel-stack/SKILL.md" -Assert-FileExists "claude/setup-use-local-otel-stack" "$tc1Dir/.claude/skills/setup-use-local-otel-stack/SKILL.md" -Assert-FileNotExists "claude/setup-instrument-dotnet-otel (removed — migrated to standard)" "$tc1Dir/.claude/skills/setup-instrument-dotnet-otel/SKILL.md" -Assert-FileExists "claude/debug-scientific-debugging" "$tc1Dir/.claude/skills/debug-scientific-debugging/SKILL.md" -Assert-FileExists "claude/plan-research" "$tc1Dir/.claude/skills/plan-research/SKILL.md" -Assert-Contains "claude debug wrapper has playbook path" "$tc1Dir/.claude/skills/debug-scientific-debugging/SKILL.md" ".context/playbooks/debug/scientific-debugging.md" - -Write-Host " --- Copilot thin wrappers ---" -Assert-FileExists "copilot/setup-create-local-otel-stack" "$tc1Dir/.github/skills/setup-create-local-otel-stack/SKILL.md" -Assert-FileExists "copilot/setup-discover-local-otel-stack" "$tc1Dir/.github/skills/setup-discover-local-otel-stack/SKILL.md" -Assert-FileExists "copilot/setup-use-local-otel-stack" "$tc1Dir/.github/skills/setup-use-local-otel-stack/SKILL.md" -Assert-FileNotExists "copilot/setup-instrument-dotnet-otel (removed — migrated to standard)" "$tc1Dir/.github/skills/setup-instrument-dotnet-otel/SKILL.md" -Assert-FileExists "copilot/debug-scientific-debugging" "$tc1Dir/.github/skills/debug-scientific-debugging/SKILL.md" -Assert-FileExists "copilot/plan-research" "$tc1Dir/.github/skills/plan-research/SKILL.md" -Assert-NotContains "copilot debug wrapper no allowed-tools" "$tc1Dir/.github/skills/debug-scientific-debugging/SKILL.md" "allowed-tools:" - -Write-Host " --- allowed-tools check ---" -Assert-Contains "claude wrapper allowed-tools" "$tc1Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" "allowed-tools:" -Assert-NotContains "claude wrapper no git-only bash" "$tc1Dir/.claude/skills/setup-create-local-otel-stack/SKILL.md" "Bash(git *)" - -Write-Host " --- Safety and provenance ---" -Assert-Contains "local-dev-only warning" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack.md" "Local development and testing only" -Assert-Contains "provenance comment" "$tc1Dir/.context/playbooks/setup/create-local-otel-stack.md" "Ported from devopsin" - -Write-Host " --- Index routing ---" -Assert-Contains "index has setup playbooks" "$tc1Dir/.context/index.md" "playbooks/setup/" - -Write-Host " --- Negative ---" -Assert-FileNotExists "local-otel-stack.md" "$tc1Dir/.context/playbooks/setup/local-otel-stack.md" - -Remove-Item -Recurse -Force $tc1Dir - -# ═══════════════════════════════════════════════════════════════════════ -# TC2: Agent-scoped deploy — Claude only -# ═══════════════════════════════════════════════════════════════════════ -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 - -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" - -Remove-Item -Recurse -Force $tc2Dir - -# ═══════════════════════════════════════════════════════════════════════ -# TC3: Agent-scoped deploy — Copilot only -# ═══════════════════════════════════════════════════════════════════════ -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 - -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" - -Remove-Item -Recurse -Force $tc3Dir - -# ═══════════════════════════════════════════════════════════════════════ -# TC4: No regressions — existing thin wrappers -# ═══════════════════════════════════════════════════════════════════════ -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 - -Assert-FileExists "assess-observability" "$tc4Dir/.claude/skills/assess-observability/SKILL.md" - -Remove-Item -Recurse -Force $tc4Dir - -# ═══════════════════════════════════════════════════════════════════════ -# TC5: Relative -TargetRepo survives [Environment]::CurrentDirectory corruption -# ═══════════════════════════════════════════════════════════════════════ -# Add-Type (used by Enable-VirtualTerminal for the interactive agent menu) resets -# [Environment]::CurrentDirectory as a side effect on Windows. This reproduces that -# corruption directly (no real interactive console needed) and proves a relative -# -TargetRepo still resolves against PowerShell's actual working directory rather -# than silently landing under the corrupted one. -Write-Host "" -Write-Host "=== TC5: Relative -TargetRepo survives CurrentDirectory corruption ===" -$tc5Base = Join-Path ([System.IO.Path]::GetTempPath()) "tc5-$([guid]::NewGuid().ToString('N').Substring(0,8))" -$tc5Launch = Join-Path $tc5Base "launch" -$tc5CorruptParent = Join-Path $tc5Base "corrupt-parent" -$tc5Corrupt = Join-Path $tc5CorruptParent "corrupt" -$tc5CorrectTarget = Join-Path $tc5Base "reltarget" -New-Item -ItemType Directory -Path $tc5Launch, $tc5Corrupt, $tc5CorrectTarget -Force | Out-Null - -$originalCwd = (Get-Location).Path -$originalCurrentDirectory = [Environment]::CurrentDirectory -try { - Set-Location $tc5Launch - [Environment]::CurrentDirectory = $tc5Corrupt - & "$RepoDir/deploy.ps1" -Agents claude -Overwrite -Target "../reltarget" *>$null -} finally { - Set-Location $originalCwd - [Environment]::CurrentDirectory = $originalCurrentDirectory -} - -Assert-FileExists "AGENTS.md deployed relative to launch dir" "$tc5CorrectTarget/AGENTS.md" -Assert-FileNotExists "AGENTS.md NOT deployed relative to corrupted CurrentDirectory" "$tc5CorruptParent/reltarget/AGENTS.md" - -Remove-Item -Recurse -Force $tc5Base - -# ═══════════════════════════════════════════════════════════════════════ -# Summary -# ═══════════════════════════════════════════════════════════════════════ -Write-Host "" -Write-Host "=== Results ===" -Write-Host " Passed: $($script:Passed)" -Write-Host " Failed: $($script:Failed)" - -if ($script:Failed -gt 0) { - Write-Host "" - Write-Host "TEST SUITE FAILED" - exit 1 -} else { - Write-Host "" - Write-Host "TEST SUITE PASSED" - exit 0 -} 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