diff --git a/.github/scripts/prepare-copilot-source-artifact.sh b/.github/scripts/prepare-copilot-source-artifact.sh
index 1d47174..9f8f288 100755
--- a/.github/scripts/prepare-copilot-source-artifact.sh
+++ b/.github/scripts/prepare-copilot-source-artifact.sh
@@ -5,6 +5,11 @@
# GH_TOKEN - PAT with read access to SOURCE_REPO
# SOURCE_REPO - source repository in owner/repo format
# OUTPUT_DIR - directory to populate with copilot-files.json and blobs/*.b64
+#
+# The artifact normalizes known adapter paths so broadcast propagation keeps the
+# Cratis AI corpus shape intact. A source repository may already contain copied
+# content at an adapter path; propagation should repair that back to an adapter
+# when the matching canonical .ai file exists in the source tree.
set -euo pipefail
@@ -16,96 +21,119 @@ source_repo="${SOURCE_REPO:?SOURCE_REPO must be set}"
output_dir="${OUTPUT_DIR:?OUTPUT_DIR must be set}"
blobs_dir="${output_dir}/blobs"
-mkdir -p "$blobs_dir"
+source_tree_has_path() {
+ local path="$1"
-normalize_source_path() {
- python3 - "$1" "$2" <<'PY'
-import os.path
-import sys
+ echo "$source_tree_raw" | jq -e \
+ --arg p "$path" \
+ '.tree[] | select(.path == $p)' >/dev/null 2>&1
+}
-print(os.path.normpath(os.path.join(sys.argv[1], sys.argv[2])))
-PY
+source_tree_has_prefix() {
+ local prefix="$1"
+
+ echo "$source_tree_raw" | jq -e \
+ --arg p "$prefix" \
+ '.tree[] | select(.path | startswith($p))' >/dev/null 2>&1
}
-is_path_reference_adapter() {
- case "$1" in
- AGENTS.md | \
- .github/copilot-instructions.md | \
- .github/instructions/* | \
- .github/agents/* | \
- .claude/CLAUDE.md | \
- .claude/rules/* | \
- .claude/commands/*)
- return 0
+adapter_spec_for_path() {
+ local path="$1"
+ local file name target
+
+ case "$path" in
+ AGENTS.md)
+ source_tree_has_path ".ai/rules/general.md" && printf '120000\t.ai/rules/general.md\n'
+ ;;
+ .github/copilot-instructions.md)
+ source_tree_has_path ".ai/rules/general.md" && printf '100644\t../.ai/rules/general.md\n'
+ ;;
+ .claude/CLAUDE.md)
+ source_tree_has_path ".ai/rules/general.md" && printf '120000\t../.ai/rules/general.md\n'
+ ;;
+ .agents/skills)
+ source_tree_has_prefix ".ai/skills/" && printf '120000\t../.ai/skills\n'
+ ;;
+ .github/prompts)
+ source_tree_has_prefix ".ai/prompts/" && printf '120000\t../.ai/prompts\n'
+ ;;
+ .github/skills)
+ source_tree_has_prefix ".ai/skills/" && printf '120000\t../.ai/skills\n'
;;
- *)
- return 1
+ .claude/agents)
+ source_tree_has_prefix ".ai/agents/" && printf '120000\t../.ai/agents\n'
+ ;;
+ .claude/skills)
+ source_tree_has_prefix ".ai/skills/" && printf '120000\t../.ai/skills\n'
+ ;;
+ .github/instructions/*.instructions.md)
+ file="${path##*/}"
+ name="${file%.instructions.md}"
+ target=".ai/rules/${name}.md"
+ source_tree_has_path "$target" && printf '100644\t../../.ai/rules/%s.md\n' "$name"
+ ;;
+ .claude/rules/*.md)
+ file="${path##*/}"
+ name="${file%.md}"
+ target=".ai/rules/${name}.md"
+ source_tree_has_path "$target" && printf '120000\t../../.ai/rules/%s.md\n' "$name"
+ ;;
+ .github/agents/*.agent.md)
+ file="${path##*/}"
+ name="${file%.agent.md}"
+ target=".ai/agents/${name}.md"
+ source_tree_has_path "$target" && printf '120000\t../../.ai/agents/%s.md\n' "$name"
+ ;;
+ .claude/commands/*.md)
+ file="${path##*/}"
+ name="${file%.md}"
+ target=".ai/prompts/${name}.prompt.md"
+ source_tree_has_path "$target" && printf '120000\t../../.ai/prompts/%s.prompt.md\n' "$name"
;;
esac
}
-looks_like_adapter_target() {
- local target="$1"
+write_synthetic_blob() {
+ local content="$1"
+ local sha
- [ -n "$target" ] &&
- [ "${#target}" -le 512 ] &&
- [[ "$target" != /* ]] &&
- [[ "$target" != *$'\n'* ]] &&
- [[ "$target" != *$'\r'* ]]
+ sha=$(printf '%s' "$content" | git hash-object --stdin)
+ printf '%s' "$content" | base64 | tr -d '\n' > "${blobs_dir}/${sha}.b64"
+ printf '%s' "$sha"
}
-find_real_blob_for_path() {
- local resolved_path="$1"
-
- echo "$source_tree_raw" | jq -r \
- --arg p "$resolved_path" \
- '.tree[] |
- select(.path == $p and .type == "blob" and .mode != "120000") |
- [.sha, (.mode // "100644")] |
- @tsv' 2>/dev/null | head -1
-}
-
-resolve_adapter_entries() {
- local resolved='[]'
- local file_path file_sha file_mode
- local blob_content adapter_target resolved_path real_blob real_sha real_mode
+normalize_adapter_entries() {
+ local normalized='[]'
+ local file_path file_sha file_mode spec adapter_mode adapter_target adapter_sha
while IFS=$'\t' read -r file_path file_sha file_mode; do
[ -z "$file_path" ] && continue
- if [ "$file_mode" = "120000" ] || is_path_reference_adapter "$file_path"; then
- blob_content=$(gh_api_with_retry "repos/${source_repo}/git/blobs/${file_sha}" \
- --jq '.content' 2>/dev/null || true)
- adapter_target=$(printf '%s' "$blob_content" | base64 -d 2>/dev/null || true)
-
- if looks_like_adapter_target "$adapter_target"; then
- adapter_target=$(printf '%s' "$adapter_target" | tr -d '\r\n')
- resolved_path=$(normalize_source_path "$(dirname "$file_path")" "$adapter_target")
- real_blob=$(find_real_blob_for_path "$resolved_path")
-
- if [ -n "$real_blob" ]; then
- IFS=$'\t' read -r real_sha real_mode <<< "$real_blob"
- echo "Resolved adapter ${file_path} -> ${resolved_path}" >&2
- resolved=$(echo "$resolved" | jq -c \
- --arg p "$file_path" \
- --arg s "$real_sha" \
- --arg m "${real_mode:-100644}" \
- '. + [{path: $p, sha: $s, mode: $m}]')
- continue
- fi
- fi
+ spec=$(adapter_spec_for_path "$file_path" || true)
+ if [ -n "$spec" ]; then
+ IFS=$'\t' read -r adapter_mode adapter_target <<< "$spec"
+ adapter_sha=$(write_synthetic_blob "$adapter_target")
+ echo "Normalized adapter ${file_path} -> ${adapter_target}" >&2
+ normalized=$(echo "$normalized" | jq -c \
+ --arg p "$file_path" \
+ --arg s "$adapter_sha" \
+ --arg m "$adapter_mode" \
+ '. + [{path: $p, sha: $s, mode: $m}]')
+ continue
fi
- resolved=$(echo "$resolved" | jq -c \
+ normalized=$(echo "$normalized" | jq -c \
--arg p "$file_path" \
--arg s "$file_sha" \
--arg m "${file_mode:-100644}" \
'. + [{path: $p, sha: $s, mode: $m}]')
done <<< "$(echo "$copilot_files" | jq -r '.[] | .path + "\t" + .sha + "\t" + (.mode // "100644")' 2>/dev/null || true)"
- echo "$resolved"
+ echo "$normalized"
}
+mkdir -p "$blobs_dir"
+
echo "Fetching Copilot instruction files from ${source_repo}..."
source_tree_raw=$(gh_api_with_retry "repos/${source_repo}/git/trees/HEAD?recursive=1")
@@ -140,7 +168,7 @@ if [ "$copilot_files" != "[]" ]; then
fi
if [ "$copilot_files" != "[]" ]; then
- copilot_files=$(resolve_adapter_entries)
+ copilot_files=$(normalize_adapter_entries)
fi
printf '%s' "$copilot_files" > "${output_dir}/copilot-files.json"
diff --git a/.github/workflows/propagate-copilot-instructions.yml b/.github/workflows/propagate-copilot-instructions.yml
index 7b27f28..2870c65 100644
--- a/.github/workflows/propagate-copilot-instructions.yml
+++ b/.github/workflows/propagate-copilot-instructions.yml
@@ -90,8 +90,8 @@ jobs:
# pattern also matches regular merge commits ("Merge pull request #N
# from …/copilot-sync/update-instructions"), which would incorrectly
# suppress propagation in repos that regularly receive — and merge —
- # sync PRs from other canonical sources. For regular merges the
- # propagation proceeds; the per-repo "up to date" content check in the
+ # sync PRs from other sources. For regular merges the propagation
+ # proceeds; the per-repo "up to date" content check in the
# propagation script naturally prevents creating redundant PRs.
actual_event=$(gh_api_with_retry "repos/${SOURCE_REPO}/actions/runs/${GITHUB_RUN_ID}" \
--jq '.event' 2>/dev/null || true)
diff --git a/README.md b/README.md
index 69eb0df..1e0d848 100644
--- a/README.md
+++ b/README.md
@@ -150,7 +150,7 @@ The common workflow bootstrap also propagates **`.github/codeql/codeql-config.ym
### Copilot instruction synchronization
-Copilot artifacts are kept in one authoritative repository and automatically propagated to all other Cratis repositories whenever they change.
+Copilot and AI-assistant artifacts are broadcast between Cratis repositories whenever they change.
The artifacts that are synchronized are:
@@ -167,7 +167,7 @@ The artifacts that are synchronized are:
| `.agents/` | Codex adapter folder |
| `AGENTS.md` | Root Codex instructions file |
-File adapters that point at canonical `.ai/` files are materialized when they are propagated, so target repositories receive usable instruction content. Directory adapters such as `.github/skills`, `.github/prompts`, `.claude/skills`, and `.agents/skills` remain symlinks to the synchronized `.ai/` tree.
+Adapters are normalized and propagated as adapters. When a matching canonical `.ai` file exists in the source tree, known adapter paths are written as symlinks or path-reference files, even if the source repository currently contains copied content at that adapter path. This keeps `.ai/` as the synchronized source of truth instead of duplicating `.ai` content into tool-specific files.
### Excluding files from synchronization
@@ -199,18 +199,18 @@ When the `.copilot-sync-ignore` file is present in the source repository, any ma
### Propagation flow
-When Copilot instruction files are pushed to `main` in any Cratis repository:
+When AI corpus files are pushed to `main` in any Cratis repository:
```mermaid
sequenceDiagram
- participant Source as Source Repo
(e.g. Chronicle)
+ participant Source as Source Repo
(e.g. Chronicle or AI)
participant Propagate as propagate-copilot-instructions
(Cratis/Workflows)
participant Target as Target Repo
(e.g. Arc, Fundamentals, …)
- Source->>Propagate: push to main
(copilot paths changed)
+ Source->>Propagate: push to main
(AI corpus paths changed)
Propagate->>Propagate: Validate caller is Cratis org
Propagate->>Propagate: List all Cratis repositories
- Propagate->>Source: Fetch copilot files once
+ Propagate->>Source: Fetch AI corpus files once
(normalize adapters)
Propagate->>Propagate: Upload source files as workflow artifact
loop For each target repo (except source)
Propagate->>Propagate: Download source artifact